Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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).
Expand Down Expand Up @@ -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 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.
- **Smaller prompts:** the system prompt is about half its previous size, the repeated instruction
Expand Down
85 changes: 78 additions & 7 deletions lib/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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 (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 {}
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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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":
Expand Down
3 changes: 2 additions & 1 deletion mcp_servers.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": 0}
}
}
}
80 changes: 80 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,6 +130,84 @@ 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


@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": 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
{"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": day, "same_day": same_day}[n] for n in warm]


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_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": 0}


async def test_no_cache_by_default_and_not_connected_message():
s = make_server()
await s.call("get_a", {})
Expand Down
Loading