From 3d77e48e3004a0b78765ac94e560311b56374c08 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:13:26 +0000 Subject: [PATCH 1/2] Keep Sharesight's portfolio list and one-day reports warm keep_warm on the sharesight server: list_portfolios, and get_performance_report only for a start_date..end_date window of at most one day. Those results are refetched in the background just before the 30-minute cache expires, but only while someone asked for them since the last fetch (an idle query is dropped, so it stops costing API calls). A failed refresh drops the entry. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- README.md | 7 ++-- lib/mcp/server.py | 85 +++++++++++++++++++++++++++++++++++++++++++---- mcp_servers.json | 3 +- tests/test_mcp.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8b8f9d7..28b783e 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ Per-server keys: `command`/`args`/`env` (local, stdio) or `url`/`headers` (remot `${VAR}` in `env`/`headers` is taken from the environment; `description`; `blocked_tools`; `allowed_tools` (an explicit allow-list); `disabled`; `max_concurrent` (default 4, stops a 20-stock request hammering Yahoo); `cache_ttl` seconds during which identical calls share one result (0 = -off); `slim` (result slimming, defaults to the server's name; `sharesight` flattens holdings); +off); `keep_warm` (tools whose cached results are refetched just before they expire, while people keep asking: a list of +tool names, or `{name: longest start_date..end_date window in days}` for tools with dates, so a longer or undated call is +cached but not refreshed); `slim` (result slimming, defaults to the server's name; `sharesight` flattens holdings); `hide_params` (optional parameters kept out of the tool definition because the model never needs them, saving tokens every round; a required parameter is never hidden); `gate` (`portfolio` = only offered when the question is about portfolios/holdings); `current_holdings_only` (Sharesight: never include sold holdings). @@ -190,7 +192,8 @@ Each answer's log line shows the cached percentage. rows dropped, `1.20067e+11` shown as `120.067B`, NaN as `-`), results capped at 12,000 characters; an identical call within one request gets a short "same as earlier" note instead of a second copy; `cache_ttl` shares results between users: 30 seconds for Yahoo, 30 minutes for Sharesight (so a portfolio - answer can be up to 30 minutes old). + answer can be up to 30 minutes old). Sharesight's portfolio list and its one-day performance reports are refetched in the background + just before they expire while people keep asking for them; a query nobody repeats is dropped. - **Compact history:** stored HTML is shown to the model as plain text (ticker links become the symbol, tags and Yahoo URLs dropped), lines cut to 240 characters and the bot's own to 160. - **Smaller prompts:** the system prompt is about half its previous size, the repeated instruction diff --git a/lib/mcp/server.py b/lib/mcp/server.py index 2e35464..5dd4987 100644 --- a/lib/mcp/server.py +++ b/lib/mcp/server.py @@ -12,6 +12,8 @@ import time from collections.abc import Awaitable, Callable from contextlib import AsyncExitStack +from dataclasses import dataclass +from datetime import date, timedelta from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -25,6 +27,17 @@ START_TIMEOUT = 180 # generous: the first npx run downloads the package +@dataclass +class _Entry: + """A cached result, with what it takes to fetch it again.""" + + at: float + out: str + tool: str + args: dict + used: bool = True # someone asked for it since it was fetched (a warm entry is only kept warm while used) + + class MCPServer: """The connection lives in its own task because the MCP SDK's context managers must be entered and exited in the same task.""" @@ -45,8 +58,14 @@ def __init__(self, label: str, cfg: dict, *, timeout: float, max_output: int, self._stop = asyncio.Event() self._task: asyncio.Task | None = None self._ttl = float(cfg.get("cache_ttl", 0)) # seconds identical calls are shared; 0 = off - self._cache: dict[str, tuple[float, str]] = {} + self._cache: dict[str, _Entry] = {} self._inflight: dict[str, asyncio.Task] = {} + # Tools whose cached results are refetched just before they expire, so asking never waits on a cold fetch: + # {tool: longest start_date..end_date window in days that stays warm, or None for any call}. + warm = cfg.get("keep_warm", []) + self._warm: dict[str, int | None] = ({t: None for t in warm} if isinstance(warm, list) else dict(warm)) \ + if self._ttl > 0 else {} + self._warm_every = min(30.0, max(1.0, self._ttl / 10)) # seconds between checks, and how early to refetch def start(self) -> None: """Connect in the background; tools appear once the server is up. Idempotent.""" @@ -92,7 +111,12 @@ async def _run(self) -> None: await self._load_tools(session) self.session, self.error = session, None self._ready.set() - await self._stop.wait() + warmer = asyncio.create_task(self._warm_loop()) if self._warm else None + try: + await self._stop.wait() + finally: + if warmer: + warmer.cancel() except Exception as e: self.error = describe_failure(e, errlog) log.error("MCP server %s failed: %s", self.label, self.error, exc_info=True) @@ -117,7 +141,7 @@ async def _load_tools(self, session: ClientSession) -> None: fn, compact_description(t.description or ""), compact_schema(t.inputSchema or {"type": "object", "properties": {}}, hide=frozenset(self.cfg.get("hide_params", []))))) - self._warn_unknown_names(listed, allow | blocked) + self._warn_unknown_names(listed, allow | blocked | set(self._warm)) enabled = sorted(self.fn_names.values()) skipped = sorted(t.name for t in listed if t.name not in enabled) log_names(f"MCP {self.label}: {len(enabled)} tools:", enabled) @@ -143,8 +167,13 @@ async def call(self, tool: str, args: dict) -> str: return await self._call(tool, args) key = f"{tool}:{json.dumps(args, sort_keys=True, default=str)}" hit = self._cache.get(key) - if hit and time.monotonic() - hit[0] < self._ttl: - return hit[1] + if hit and time.monotonic() - hit.at < self._ttl: + hit.used = True + return hit.out + return await self._fetch(key, tool, args) + + async def _fetch(self, key: str, tool: str, args: dict) -> str: + """Fetch and cache one result; concurrent fetches of the same call share one request.""" task = self._inflight.get(key) if task is None: task = self._inflight[key] = asyncio.create_task(self._call(tool, args)) @@ -155,10 +184,52 @@ async def call(self, tool: str, args: dict) -> str: self._inflight.pop(key, None) if not out.startswith(("Tool error", "Error")): now = time.monotonic() - self._cache = {k: v for k, v in self._cache.items() if now - v[0] < self._ttl} - self._cache[key] = (now, out) + # Expired entries go, except warm ones: _warm_once decides about those. + self._cache = {k: v for k, v in self._cache.items() if now - v.at < self._ttl or self._warmable(v.tool, v.args)} + self._cache[key] = _Entry(now, out, tool, args) return out + def _warmable(self, tool: str, args: dict) -> bool: + """Is this call kept warm? A tool with a day limit only when it asks for a window of at most that many days.""" + if tool not in self._warm: + return False + days = self._warm[tool] + if days is None: + return True + try: + window = date.fromisoformat(args["end_date"]) - date.fromisoformat(args["start_date"]) + except (KeyError, ValueError, TypeError): + return False # undated or malformed: the tool's default window is a long one + return timedelta(0) <= window <= timedelta(days=days) + + async def _warm_loop(self) -> None: + while True: + await asyncio.sleep(self._warm_every) + try: + await self._warm_once() + except Exception: + log.exception("MCP %s cache refresh failed", self.label) + + async def _warm_once(self) -> None: + """Refetch warm results about to expire if someone asked for them since the last fetch; drop the idle ones, + so a query nobody repeats stops costing API calls.""" + now = time.monotonic() + for key, entry in list(self._cache.items()): + if not self._warmable(entry.tool, entry.args) or now - entry.at < self._ttl - self._warm_every: + continue + if not entry.used: + self._cache.pop(key, None) + continue + try: + await self._fetch(key, entry.tool, entry.args) + except Exception as e: + log.warning("MCP %s.%s refresh failed: %s", self.label, entry.tool, e) + fresh = self._cache.get(key) + if fresh is None or fresh is entry: # the refetch failed or came back as an error: let it go + self._cache.pop(key, None) + else: + fresh.used = False + async def _call(self, tool: str, args: dict) -> str: holdings_only = bool(self.cfg.get("current_holdings_only")) if holdings_only and tool == "get_performance_report": diff --git a/mcp_servers.json b/mcp_servers.json index 6e5ddad..969dbf6 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -40,7 +40,8 @@ "slim": "sharesight", "gate": "portfolio", "max_concurrent": 1, - "cache_ttl": 1800 + "cache_ttl": 1800, + "keep_warm": {"list_portfolios": null, "get_performance_report": 1} } } } diff --git a/tests/test_mcp.py b/tests/test_mcp.py index a2894b7..938e837 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -128,6 +128,83 @@ async def test_sharesight_results_are_cached_for_thirty_minutes(monkeypatch): assert len(s.session.calls) == 2 # expired, fetched again +def fake_clock(monkeypatch, start=1000.0): + clock = [start] + monkeypatch.setattr("lib.mcp.server.time", N(monotonic=lambda: clock[0])) + return clock + + +async def test_keep_warm_refetches_before_expiry_and_serves_the_fresh_copy(monkeypatch): + clock = fake_clock(monkeypatch) + s = make_server(cache_ttl=1800, keep_warm=["list_portfolios"]) + await s.call("list_portfolios", {}) + await s.call("get_b", {}) # not kept warm + clock[0] += 1700 + await s._warm_once() + assert len(s.session.calls) == 2 # too early + clock[0] += 80 # 1,780 s old: inside the last 30 s before it expires + await s._warm_once() + assert [c[0] for c in s.session.calls] == ["list_portfolios", "get_b", "list_portfolios"] + clock[0] += 100 # 1,880 s after the first fetch, when it would have expired + await s.call("list_portfolios", {}) + assert len(s.session.calls) == 3 # answered from the refreshed copy + await s.call("get_b", {}) + assert len(s.session.calls) == 4 # the tool that isn't kept warm expired as before + + +async def test_keep_warm_lets_a_result_nobody_repeats_go(monkeypatch): + clock = fake_clock(monkeypatch) + s = make_server(cache_ttl=1800, keep_warm=["list_portfolios"]) + await s.call("list_portfolios", {}) + clock[0] += 1780 + await s._warm_once() + assert len(s.session.calls) == 2 # asked once, so refreshed once + clock[0] += 1780 + await s._warm_once() + assert len(s.session.calls) == 2 and not s._cache # nobody asked since: dropped, no more API calls + + +async def test_keep_warm_only_refreshes_reports_for_a_one_day_window(monkeypatch): + clock = fake_clock(monkeypatch) + s = make_server(cache_ttl=1800, keep_warm={"get_performance_report": 1}) + day = {"portfolio_id": 1, "start_date": "2026-10-06", "end_date": "2026-10-07"} + same_day = {"portfolio_id": 1, "start_date": "2026-10-07", "end_date": "2026-10-07"} + others = [{"portfolio_id": 1, "start_date": "2026-10-01", "end_date": "2026-10-07"}, # a week + {"portfolio_id": 1}, # undated: the tool's default window is long + {"portfolio_id": 1, "start_date": "2026-10-07", "end_date": "2026-10-06"}, # backwards + {"portfolio_id": 1, "start_date": "later", "end_date": "2026-10-07"}] # malformed + for args in [day, same_day, *others]: + await s.call("get_performance_report", args) + clock[0] += 1780 + await s._warm_once() + assert [a for _, a in s.session.calls[6:]] == [day, same_day] + + +async def test_keep_warm_drops_a_result_whose_refresh_fails(monkeypatch, caplog): + clock = fake_clock(monkeypatch) + s = make_server(cache_ttl=1800, keep_warm=["list_portfolios"]) + await s.call("list_portfolios", {}) + + async def boom(tool, args): + raise RuntimeError("down") + + s.session.call_tool = boom + clock[0] += 1780 + await s._warm_once() + assert not s._cache and "list_portfolios refresh failed" in caplog.text + + +async def test_unknown_keep_warm_names_are_warned_about(caplog): + s = make_server(cache_ttl=1800, keep_warm=["get_aa"]) + await s._load_tools(s.session) + assert "get_aa" in caplog.text and "matches no tool" in caplog.text + + +def test_sharesight_keeps_the_portfolio_list_and_one_day_reports_warm(): + cfg = json.loads((Path(__file__).parent.parent / "mcp_servers.json").read_text())["mcpServers"]["sharesight"] + assert cfg["keep_warm"] == {"list_portfolios": None, "get_performance_report": 1} + + async def test_no_cache_by_default_and_not_connected_message(): s = make_server() await s.call("get_a", {}) From e43d6b3aeeb30787b642f517807339173f9114f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:16:23 +0000 Subject: [PATCH 2/2] keep_warm: only single-day report windows (days = 0) get_performance_report is kept warm only when start_date equals end_date. Tests cover both limits. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- README.md | 6 +++--- lib/mcp/server.py | 2 +- mcp_servers.json | 2 +- tests/test_mcp.py | 13 ++++++++----- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 28b783e..4b1b21d 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ Per-server keys: `command`/`args`/`env` (local, stdio) or `url`/`headers` (remot `allowed_tools` (an explicit allow-list); `disabled`; `max_concurrent` (default 4, stops a 20-stock request hammering Yahoo); `cache_ttl` seconds during which identical calls share one result (0 = off); `keep_warm` (tools whose cached results are refetched just before they expire, while people keep asking: a list of -tool names, or `{name: longest start_date..end_date window in days}` for tools with dates, so a longer or undated call is -cached but not refreshed); `slim` (result slimming, defaults to the server's name; `sharesight` flattens holdings); +tool names, or `{name: longest start_date..end_date window in days, 0 = a single day}` for tools with dates, so a longer or undated +call is cached but not refreshed); `slim` (result slimming, defaults to the server's name; `sharesight` flattens holdings); `hide_params` (optional parameters kept out of the tool definition because the model never needs them, saving tokens every round; a required parameter is never hidden); `gate` (`portfolio` = only offered when the question is about portfolios/holdings); `current_holdings_only` (Sharesight: never include sold holdings). @@ -192,7 +192,7 @@ Each answer's log line shows the cached percentage. rows dropped, `1.20067e+11` shown as `120.067B`, NaN as `-`), results capped at 12,000 characters; an identical call within one request gets a short "same as earlier" note instead of a second copy; `cache_ttl` shares results between users: 30 seconds for Yahoo, 30 minutes for Sharesight (so a portfolio - answer can be up to 30 minutes old). Sharesight's portfolio list and its one-day performance reports are refetched in the background + answer can be up to 30 minutes old). Sharesight's portfolio list and its single-day performance reports are refetched in the background just before they expire while people keep asking for them; a query nobody repeats is dropped. - **Compact history:** stored HTML is shown to the model as plain text (ticker links become the symbol, tags and Yahoo URLs dropped), lines cut to 240 characters and the bot's own to 160. diff --git a/lib/mcp/server.py b/lib/mcp/server.py index 5dd4987..9707020 100644 --- a/lib/mcp/server.py +++ b/lib/mcp/server.py @@ -61,7 +61,7 @@ def __init__(self, label: str, cfg: dict, *, timeout: float, max_output: int, self._cache: dict[str, _Entry] = {} self._inflight: dict[str, asyncio.Task] = {} # Tools whose cached results are refetched just before they expire, so asking never waits on a cold fetch: - # {tool: longest start_date..end_date window in days that stays warm, or None for any call}. + # {tool: longest start_date..end_date window in days that stays warm (0 = one date), or None for any call}. warm = cfg.get("keep_warm", []) self._warm: dict[str, int | None] = ({t: None for t in warm} if isinstance(warm, list) else dict(warm)) \ if self._ttl > 0 else {} diff --git a/mcp_servers.json b/mcp_servers.json index 969dbf6..e34c573 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -41,7 +41,7 @@ "gate": "portfolio", "max_concurrent": 1, "cache_ttl": 1800, - "keep_warm": {"list_portfolios": null, "get_performance_report": 1} + "keep_warm": {"list_portfolios": null, "get_performance_report": 0} } } } diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 938e837..42ec112 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3,6 +3,8 @@ from pathlib import Path from types import SimpleNamespace as N +import pytest + from lib.mcp.results import diet, slim_result, squeeze_tables, table_records, tidy_sharesight from lib.mcp.schema import ToolDef, compact_description, compact_schema, looks_read_only from lib.mcp.server import MCPServer, Registry @@ -164,9 +166,10 @@ async def test_keep_warm_lets_a_result_nobody_repeats_go(monkeypatch): assert len(s.session.calls) == 2 and not s._cache # nobody asked since: dropped, no more API calls -async def test_keep_warm_only_refreshes_reports_for_a_one_day_window(monkeypatch): +@pytest.mark.parametrize("days,warm", [(0, ["same_day"]), (1, ["day", "same_day"])]) +async def test_keep_warm_only_refreshes_reports_for_a_short_window(monkeypatch, days, warm): clock = fake_clock(monkeypatch) - s = make_server(cache_ttl=1800, keep_warm={"get_performance_report": 1}) + s = make_server(cache_ttl=1800, keep_warm={"get_performance_report": days}) day = {"portfolio_id": 1, "start_date": "2026-10-06", "end_date": "2026-10-07"} same_day = {"portfolio_id": 1, "start_date": "2026-10-07", "end_date": "2026-10-07"} others = [{"portfolio_id": 1, "start_date": "2026-10-01", "end_date": "2026-10-07"}, # a week @@ -177,7 +180,7 @@ async def test_keep_warm_only_refreshes_reports_for_a_one_day_window(monkeypatch await s.call("get_performance_report", args) clock[0] += 1780 await s._warm_once() - assert [a for _, a in s.session.calls[6:]] == [day, same_day] + assert [a for _, a in s.session.calls[6:]] == [{"day": day, "same_day": same_day}[n] for n in warm] async def test_keep_warm_drops_a_result_whose_refresh_fails(monkeypatch, caplog): @@ -200,9 +203,9 @@ async def test_unknown_keep_warm_names_are_warned_about(caplog): assert "get_aa" in caplog.text and "matches no tool" in caplog.text -def test_sharesight_keeps_the_portfolio_list_and_one_day_reports_warm(): +def test_sharesight_keeps_the_portfolio_list_and_single_day_reports_warm(): cfg = json.loads((Path(__file__).parent.parent / "mcp_servers.json").read_text())["mcpServers"]["sharesight"] - assert cfg["keep_warm"] == {"list_portfolios": None, "get_performance_report": 1} + assert cfg["keep_warm"] == {"list_portfolios": None, "get_performance_report": 0} async def test_no_cache_by_default_and_not_connected_message():