refactor(mcp): remove periodic refresh, add manual refresh/reconnect controls

Deletes the _periodic_refresh task and its supporting state
(_refresh_task, _refresh_failures, _refresh_backoff_until,
_REFRESH_BACKOFF_BASE/MAX, _DEFAULT_REFRESH_INTERVAL, refresh_interval
kwarg) from MCPClientManager. Push notifications and operator-driven
manual refresh now cover all catalog-update needs; the long-running
4-hour timer was dead complexity that obscured the per-user pool
work to come.

Catalog freshness on auto-reconnect is preserved by scheduling an
unblocking _refresh_server task on the mcp-loop after _connect_one
succeeds; the calling thread returns immediately so half-open
recovery latency does not double. Adds MCPClientManager.reconnect_sync
(clears the circuit, closes any existing session, calls _connect_one,
clears stale catalog on failure).

Wires a new pair of operator endpoints —
POST /v1/api/admin/mcp-servers/{name}/refresh and
/v1/api/admin/mcp-servers/{name}/reconnect — that fan out to all
nodes through the existing _internal route family, with per-row
"Refresh" and "Reconnect" buttons in the MCP Servers admin tab.
The new node-internal paths /api/_internal/mcp-{refresh,reconnect}/
are gated to the approve scope to prevent direct unprivileged
reconnects bypassing the console's admin.mcp gate. Internal
endpoints return generic error messages and a filtered status
payload (no command/url) to keep transport details admin-gated.

Drops the [mcp] refresh_interval setting, the
--mcp-refresh-interval CLI flag, and the matching config-mapping
entry; updates docs/architecture.md, docs/tools.md,
docs/settings.md, and the three PlantUML diagrams that referenced
the periodic loop.

Tradeoffs (intentional):
- Idle nodes will not auto-rejoin a recovered MCP server until
  traffic arrives or an operator clicks Reconnect. The previous
  background reconnection loop is gone by design — push
  notifications + operator controls replace it.
- Console fan-out blocks on the slowest node (existing pattern);
  not changed here.

This is Phase 1 of the OAuth-MCP series — feature subtraction
ahead of per-user state.
This commit is contained in:
Patrick Buckley
2026-05-04 16:42:34 -07:00
parent 0a8083e6d5
commit eb2a119da9
21 changed files with 1054 additions and 246 deletions
+6 -7
View File
@@ -546,11 +546,9 @@ adds, removes, or reconnects servers as needed.
6. `_exec_mcp_tool()` calls `call_tool_sync()` which dispatches to the async loop
via `asyncio.run_coroutine_threadsafe()`
**Tool refresh:** Three mechanisms keep tools up-to-date without restart:
**Tool refresh:** Two mechanisms keep tools up-to-date without restart:
- **Push:** Servers declaring `tools.listChanged` send `ToolListChangedNotification`;
the registered `message_handler` triggers immediate single-server refresh.
- **Periodic:** Servers without push support are polled on a staggered interval
(default 4 h, configurable via `[mcp] refresh_interval` or `--mcp-refresh-interval`).
- **Manual:** `/mcp refresh [server]` calls `refresh_sync()` for on-demand refresh
(also attempts reconnection for disconnected servers).
@@ -572,10 +570,11 @@ from a healthy connection do not trip the breaker. When the cooldown expires
(`call_tool_sync`, `read_resource_sync`, `get_prompt_sync`, `refresh_sync`)
cancel orphaned futures on timeout to prevent coroutine accumulation on the
background event loop. Push notification refreshes are debounced (5 s per
server) to protect against notification storms. The periodic refresh loop
attempts reconnection for disconnected servers with exponential backoff
(60 s1 h). Transport stream references are pre-closed before stack teardown to
work around the MCP SDK's anyio cancel-scope CPU busy-loop (SDK #2147).
server) to protect against notification storms. Operators can force a
catalog refresh or full reconnect from the admin panel; reconnects clear
the circuit breaker and run a fresh handshake. Transport stream references
are pre-closed before stack teardown to work around the MCP SDK's anyio
cancel-scope CPU busy-loop (SDK #2147).
**Error isolation:** Per-server connection/refresh failures are caught and logged; other
servers are unaffected. Tool execution errors return error strings to the LLM
+1 -1
View File
@@ -40,7 +40,7 @@ package "turnstone/core/" <<Rectangle>> {
component [auth.py\nAuthentication] as auth <<core>>
component [healthcheck.py\nBackendHealthMonitor] as healthcheck <<core>>
component [ratelimit.py\nRateLimiter] as ratelimit <<core>>
component [mcp_client.py\nMCPClientManager\n(push + periodic refresh)] as mcp <<core>>
component [mcp_client.py\nMCPClientManager\n(push + manual refresh)] as mcp <<core>>
component [tool_search.py\nToolSearchManager, BM25] as toolsearch <<core>>
component [model_registry.py\nModelRegistry] as registry <<core>>
}
+1 -1
View File
@@ -253,7 +253,7 @@ class "MCPClientManager" as MCPMgr {
Background asyncio event loop
bridges async MCP SDK to
sync ChatSession dispatch.
Push + periodic + manual refresh.
Push + manual refresh.
Resources + prompts discovered
alongside tools at startup.
--
+15 -11
View File
@@ -190,21 +190,25 @@ group Push Notifications (debounced 5s per server)
MCPMgr -> Storage : sync_prompts_to_storage()
end
group Periodic Polling (default 4h)
MCPMgr -> MCPMgr : _periodic_refresh()
group Manual Refresh
Session -> MCPMgr : refresh_sync()
note right
Only polls capabilities
without push support.
Staggered per-server.
Disconnected servers get
reconnect attempts with
exponential backoff (60s-1h).
/mcp refresh [server] —
re-fetches catalog and
attempts reconnect for
disconnected servers.
end note
end
group Manual Refresh
Session -> MCPMgr : refresh_sync()
note right: /mcp refresh [server]
group Manual Reconnect
Session -> MCPMgr : reconnect_sync(name)
note right
Operator-driven via the
console admin panel —
tears down session, clears
circuit breaker, runs a
fresh handshake.
end note
end
== Policy Evaluation ==
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3b5c59403a6febd81667fc8fd2a7d22bc59da6130eba0dea5449c42668d0ede
size 387044
oid sha256:95dd5ebc899a1261d516686a5aa3319a7f45015d411302825fa28afbfc82e1ce
size 326766
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:474b900448ec04d1117b48a2b55614524721b2f04ac4bda66170bd0a06aae0f2
size 624573
oid sha256:25b5448bbb7da8ddafe4f65c6c5e6cbcaa9cb9f31746ca46d3a2241bc47b1956
size 259687
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7623df33be9baf7647ca1c2450640df57e1cd73e8be1f8168aae16e546ad683c
size 459941
oid sha256:d6aff446a062aa08f316985d00c2183148694f786d7f22172bc50b30046c728b
size 379259
+1 -1
View File
@@ -100,7 +100,7 @@ initialization:
| `tools` | timeout, truncation, agent_max_turns, skip_permissions, search, search_threshold, search_max_results |
| `server` | workstream_idle_timeout, max_workstreams |
| `cluster` | node_fan_out_limit, mcp_max_servers |
| `mcp` | config_path, refresh_interval, registry_url |
| `mcp` | config_path, registry_url |
| `ratelimit` | enabled, requests_per_second, burst, trusted_proxies |
| `health` | backend_probe_interval, backend_probe_timeout, circuit_breaker_threshold, circuit_breaker_cooldown |
| `judge` | enabled, model, provider, base_url, api_key, confidence_threshold, max_context_ratio, timeout, read_only_tools, output_guard, redact_secrets, cancel_on_approval |
+5 -14
View File
@@ -758,22 +758,18 @@ MCP tools (3):
### Dynamic tool refresh
MCP tool lists stay up-to-date without restart through three mechanisms:
MCP tool lists stay up-to-date without restart through two mechanisms:
1. **Push notifications** -- MCP servers that declare `tools.listChanged: true` in
their capabilities send `notifications/tools/list_changed` when their tool list
changes. `MCPClientManager` registers a `message_handler` on each `ClientSession`
that triggers an immediate refresh for that server.
2. **Periodic timer** -- Servers that do *not* support push notifications are polled
on a configurable interval (default 4 hours). The timer is staggered using a
launch-time seed (`monotonic_ns ^ pid`) so cluster nodes don't all hit MCP
servers simultaneously. Configure via `[mcp] refresh_interval` in `config.toml`
or `--mcp-refresh-interval SECONDS` on the CLI. Set to `0` to disable.
3. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
2. **Manual** -- `/mcp refresh` re-fetches tools from all servers immediately.
`/mcp refresh <server>` targets a single server. If a server has disconnected,
manual refresh attempts reconnection.
manual refresh attempts reconnection. The console admin panel exposes the
same controls (refresh / reconnect buttons per server) for cluster-wide
fan-out.
When tools change, `MCPClientManager` rebuilds its merged tool list using copy-on-write
(new list/dict objects assigned atomically) and notifies all active `ChatSession`
@@ -781,11 +777,6 @@ instances via registered listener callbacks. Each session rebuilds its `_tools`,
`_task_tools`, `_agent_tools`, and reconstructs its `ToolSearchManager` (if active),
preserving the set of previously expanded (discovered) tools.
```toml
[mcp]
refresh_interval = 14400 # seconds (default 4h), 0 to disable
```
```
/mcp refresh
MCP refresh complete:
+24
View File
@@ -207,6 +207,30 @@ class TestRequiredScope:
"""Only POST is elevated — GET falls through to read."""
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
def test_internal_mcp_refresh_one_needs_approve(self):
assert required_scope("POST", "/api/_internal/mcp-refresh/srv") == "approve"
def test_v1_internal_mcp_refresh_one_needs_approve(self):
assert required_scope("POST", "/v1/api/_internal/mcp-refresh/srv") == "approve"
def test_proxy_internal_mcp_refresh_one_needs_approve(self):
assert required_scope("POST", "/node/n1/v1/api/_internal/mcp-refresh/srv") == "approve"
def test_proxy_no_v1_internal_mcp_refresh_one_needs_approve(self):
assert required_scope("POST", "/node/n1/api/_internal/mcp-refresh/srv") == "approve"
def test_internal_mcp_reconnect_one_needs_approve(self):
assert required_scope("POST", "/api/_internal/mcp-reconnect/srv") == "approve"
def test_v1_internal_mcp_reconnect_one_needs_approve(self):
assert required_scope("POST", "/v1/api/_internal/mcp-reconnect/srv") == "approve"
def test_proxy_internal_mcp_reconnect_one_needs_approve(self):
assert required_scope("POST", "/node/n1/v1/api/_internal/mcp-reconnect/srv") == "approve"
def test_proxy_no_v1_internal_mcp_reconnect_one_needs_approve(self):
assert required_scope("POST", "/node/n1/api/_internal/mcp-reconnect/srv") == "approve"
# Workstream sub-resource mutations (parametric paths)
def test_ws_delete_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/delete") == "write"
+441 -2
View File
@@ -20,12 +20,16 @@ if TYPE_CHECKING:
from turnstone.console.server import (
_collect_mcp_status,
_notify_nodes_mcp_reconnect_one,
_notify_nodes_mcp_refresh_one,
_notify_nodes_mcp_reload,
admin_create_mcp_server,
admin_delete_mcp_server,
admin_get_mcp_server,
admin_import_mcp_config,
admin_list_mcp_servers,
admin_mcp_reconnect_one,
admin_mcp_refresh_one,
admin_mcp_reload,
admin_update_mcp_server,
)
@@ -102,6 +106,16 @@ _ROUTES = [
admin_mcp_reload,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/refresh",
admin_mcp_refresh_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/reconnect",
admin_mcp_reconnect_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{server_id}",
admin_get_mcp_server,
@@ -122,8 +136,12 @@ _ROUTES = [
def _routes_with_internal() -> list[Mount]:
"""Routes including the node-side internal endpoint (lazy-imported)."""
from turnstone.server import internal_mcp_reload
"""Routes including the node-side internal endpoints (lazy-imported)."""
from turnstone.server import (
internal_mcp_reconnect_one,
internal_mcp_refresh_one,
internal_mcp_reload,
)
return [
Mount(
@@ -131,6 +149,16 @@ def _routes_with_internal() -> list[Mount]:
routes=[
*_ROUTES[0].routes, # type: ignore[union-attr]
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route(
"/api/_internal/mcp-refresh/{name}",
internal_mcp_refresh_one,
methods=["POST"],
),
Route(
"/api/_internal/mcp-reconnect/{name}",
internal_mcp_reconnect_one,
methods=["POST"],
),
],
),
]
@@ -889,3 +917,414 @@ class TestInternalMcpReloadEndpoint:
assert data["added"] == ["a"]
assert data["removed"] == ["b"]
assert data["updated"] == ["c"]
# ---------------------------------------------------------------------------
# _notify_nodes_mcp_refresh_one / _notify_nodes_mcp_reconnect_one
# ---------------------------------------------------------------------------
class TestNotifyNodesMcpRefreshOne:
@pytest.mark.anyio
async def test_returns_json_on_success(self):
client = AsyncMock()
client.post.return_value = _mock_resp(200, {"status": "ok"})
req = _fake_request(
{"node_id": "n1", "server_url": "http://n1:8000"},
proxy_client=client,
)
result = await _notify_nodes_mcp_refresh_one(req, "srv")
assert result == {"n1": {"status": "ok"}}
# Verify the URL used the safe-encoded name segment
call_args = client.post.call_args
assert call_args[0][0].endswith("/v1/api/_internal/mcp-refresh/srv")
@pytest.mark.anyio
async def test_skips_nodes_without_url(self):
client = AsyncMock()
req = _fake_request(
{"node_id": "n1", "server_url": ""},
proxy_client=client,
)
result = await _notify_nodes_mcp_refresh_one(req, "srv")
assert result == {}
client.post.assert_not_called()
@pytest.mark.anyio
async def test_records_error_on_exception(self):
client = AsyncMock()
client.post.side_effect = ConnectionError("refused")
req = _fake_request(
{"node_id": "n1", "server_url": "http://n1:8000"},
proxy_client=client,
)
result = await _notify_nodes_mcp_refresh_one(req, "srv")
assert "n1" in result
assert "error" in result["n1"]
assert "refused" in result["n1"]["error"]
@pytest.mark.anyio
async def test_multiple_nodes_mixed(self):
client = AsyncMock()
client.post.side_effect = [
_mock_resp(200, {"status": "ok"}),
TimeoutError("timeout"),
]
req = _fake_request(
{"node_id": "n1", "server_url": "http://n1:8000"},
{"node_id": "n2", "server_url": "http://n2:8000"},
proxy_client=client,
)
result = await _notify_nodes_mcp_refresh_one(req, "srv")
assert result["n1"] == {"status": "ok"}
assert "error" in result["n2"]
@pytest.mark.anyio
async def test_empty_cluster(self):
req = _fake_request()
result = await _notify_nodes_mcp_refresh_one(req, "srv")
assert result == {}
class TestNotifyNodesMcpReconnectOne:
@pytest.mark.anyio
async def test_returns_json_on_success(self):
client = AsyncMock()
client.post.return_value = _mock_resp(200, {"status": "ok"})
req = _fake_request(
{"node_id": "n1", "server_url": "http://n1:8000"},
proxy_client=client,
)
result = await _notify_nodes_mcp_reconnect_one(req, "srv")
assert result == {"n1": {"status": "ok"}}
call_args = client.post.call_args
assert call_args[0][0].endswith("/v1/api/_internal/mcp-reconnect/srv")
@pytest.mark.anyio
async def test_skips_nodes_without_url(self):
client = AsyncMock()
req = _fake_request(
{"node_id": "n1", "server_url": ""},
proxy_client=client,
)
result = await _notify_nodes_mcp_reconnect_one(req, "srv")
assert result == {}
client.post.assert_not_called()
@pytest.mark.anyio
async def test_records_error_on_exception(self):
client = AsyncMock()
client.post.side_effect = ConnectionError("refused")
req = _fake_request(
{"node_id": "n1", "server_url": "http://n1:8000"},
proxy_client=client,
)
result = await _notify_nodes_mcp_reconnect_one(req, "srv")
assert "n1" in result
assert "error" in result["n1"]
assert "refused" in result["n1"]["error"]
@pytest.mark.anyio
async def test_multiple_nodes_mixed(self):
client = AsyncMock()
client.post.side_effect = [
_mock_resp(200, {"status": "ok"}),
TimeoutError("timeout"),
]
req = _fake_request(
{"node_id": "n1", "server_url": "http://n1:8000"},
{"node_id": "n2", "server_url": "http://n2:8000"},
proxy_client=client,
)
result = await _notify_nodes_mcp_reconnect_one(req, "srv")
assert result["n1"] == {"status": "ok"}
assert "error" in result["n2"]
@pytest.mark.anyio
async def test_empty_cluster(self):
req = _fake_request()
result = await _notify_nodes_mcp_reconnect_one(req, "srv")
assert result == {}
# ---------------------------------------------------------------------------
# Console refresh / reconnect endpoints
# ---------------------------------------------------------------------------
class TestAdminMcpRefreshOneEndpoint:
"""HTTP-level tests for the console refresh-one endpoint."""
def test_refresh_one_success(self, client: TestClient) -> None:
with patch(
"turnstone.console.server._notify_nodes_mcp_action",
new_callable=AsyncMock,
return_value={"n1": {"status": "ok"}},
) as mock_notify:
r = client.post("/v1/api/admin/mcp-servers/srv/refresh")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["results"] == {"n1": {"status": "ok"}}
# The shared helper is called with the action verb.
mock_notify.assert_awaited_once()
args = mock_notify.await_args.args
assert args[1] == "refresh"
assert args[2] == "srv"
def test_refresh_one_permission_denied(self, client_no_perm: TestClient) -> None:
r = client_no_perm.post("/v1/api/admin/mcp-servers/srv/refresh")
assert r.status_code == 403
assert "admin.mcp" in r.json()["error"]
def test_refresh_one_invalid_name(self, client: TestClient) -> None:
# Names with '__' (reserved delimiter) are rejected.
r = client.post("/v1/api/admin/mcp-servers/bad__name/refresh")
assert r.status_code == 400
assert "invalid" in r.json()["error"].lower()
class TestAdminMcpReconnectOneEndpoint:
"""HTTP-level tests for the console reconnect-one endpoint."""
def test_reconnect_one_success(self, client: TestClient) -> None:
with patch(
"turnstone.console.server._notify_nodes_mcp_action",
new_callable=AsyncMock,
return_value={"n1": {"status": "ok"}},
) as mock_notify:
r = client.post("/v1/api/admin/mcp-servers/srv/reconnect")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["results"] == {"n1": {"status": "ok"}}
mock_notify.assert_awaited_once()
args = mock_notify.await_args.args
assert args[1] == "reconnect"
assert args[2] == "srv"
def test_reconnect_one_permission_denied(self, client_no_perm: TestClient) -> None:
r = client_no_perm.post("/v1/api/admin/mcp-servers/srv/reconnect")
assert r.status_code == 403
assert "admin.mcp" in r.json()["error"]
def test_reconnect_one_invalid_name(self, client: TestClient) -> None:
r = client.post("/v1/api/admin/mcp-servers/bad__name/reconnect")
assert r.status_code == 400
assert "invalid" in r.json()["error"].lower()
# ---------------------------------------------------------------------------
# Node refresh-one endpoint: POST /v1/api/_internal/mcp-refresh/{name}
# ---------------------------------------------------------------------------
class TestInternalMcpRefreshOneEndpoint:
"""HTTP-level tests for the node-side per-server refresh endpoint."""
@pytest.fixture()
def node_app_factory(self, storage: SQLiteBackend):
"""Build a TestClient with an MCP client manager on app.state."""
def _make(mgr: Any) -> TestClient:
app = Starlette(
routes=_routes_with_internal(),
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
if mgr is not None:
app.state.mcp_client = mgr
return TestClient(app, raise_server_exceptions=False)
return _make
def test_refresh_one_success(self, node_app_factory) -> None:
mgr = MagicMock()
mgr.refresh_sync.return_value = None
mgr.get_server_status.return_value = {
"connected": True,
"tools": 3,
"resources": 0,
"prompts": 1,
"error": "",
"transport": "stdio",
"command": "/usr/bin/secret-stdio",
"url": "",
"circuit_open": False,
"consecutive_failures": 0,
}
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-refresh/srv")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
# sec-3: command/url stripped from response.
assert "command" not in data["server"]
assert "url" not in data["server"]
assert data["server"]["tools"] == 3
mgr.refresh_sync.assert_called_once_with(server_name="srv")
def test_refresh_one_no_mcp_client_returns_503(self, node_app_factory) -> None:
c = node_app_factory(None)
r = c.post("/v1/api/_internal/mcp-refresh/srv")
assert r.status_code == 503
assert r.json()["status"] == "error"
def test_refresh_one_raises_returns_500(self, node_app_factory) -> None:
mgr = MagicMock()
mgr.refresh_sync.side_effect = RuntimeError("internal stdio path /etc/shadow blew up")
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-refresh/srv")
assert r.status_code == 500
# sec-2: raw exception detail must not leak to the caller.
body = r.json()
assert body["error"] == "refresh failed"
assert "shadow" not in body["error"]
def test_refresh_one_per_server_error_returns_500(self, node_app_factory) -> None:
# q-3 / bug-3: refresh_sync swallows per-server errors into _last_error,
# so a 200 from refresh_sync is not enough — get_server_status reports.
mgr = MagicMock()
mgr.refresh_sync.return_value = None
mgr.get_server_status.return_value = {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": "Refresh failed: connection refused",
"transport": "stdio",
"command": "secret",
"url": "",
"circuit_open": True,
"consecutive_failures": 5,
}
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-refresh/srv")
assert r.status_code == 500
data = r.json()
assert data["status"] == "error"
assert data["error"] == "refresh failed"
# Public status echoed but command/url stripped.
assert "command" not in data["server"]
assert "url" not in data["server"]
assert data["server"]["circuit_open"] is True
def test_refresh_one_invalid_name_returns_400(self, node_app_factory) -> None:
# sec-4: name validation symmetric with console side.
mgr = MagicMock()
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-refresh/bad__name")
assert r.status_code == 400
assert "invalid" in r.json()["error"].lower()
mgr.refresh_sync.assert_not_called()
# ---------------------------------------------------------------------------
# Node reconnect-one endpoint: POST /v1/api/_internal/mcp-reconnect/{name}
# ---------------------------------------------------------------------------
class TestInternalMcpReconnectOneEndpoint:
"""HTTP-level tests for the node-side per-server reconnect endpoint."""
@pytest.fixture()
def node_app_factory(self, storage: SQLiteBackend):
"""Build a TestClient with an MCP client manager on app.state."""
def _make(mgr: Any) -> TestClient:
app = Starlette(
routes=_routes_with_internal(),
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
if mgr is not None:
app.state.mcp_client = mgr
return TestClient(app, raise_server_exceptions=False)
return _make
def test_reconnect_one_success(self, node_app_factory) -> None:
mgr = MagicMock()
mgr.reconnect_sync.return_value = {
"connected": True,
"tools": 2,
"resources": 0,
"prompts": 0,
"error": "",
}
mgr.get_server_status.return_value = {
"connected": True,
"tools": 2,
"resources": 0,
"prompts": 0,
"error": "",
"transport": "stdio",
"command": "secret-cmd",
"url": "",
"circuit_open": False,
"consecutive_failures": 0,
}
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-reconnect/srv")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
# sec-3: command/url stripped from response.
assert "command" not in data["server"]
assert "url" not in data["server"]
mgr.reconnect_sync.assert_called_once_with("srv")
def test_reconnect_one_no_mcp_client_returns_503(self, node_app_factory) -> None:
c = node_app_factory(None)
r = c.post("/v1/api/_internal/mcp-reconnect/srv")
assert r.status_code == 503
def test_reconnect_one_returns_error_dict_500(self, node_app_factory) -> None:
mgr = MagicMock()
mgr.reconnect_sync.return_value = {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": "secret stdio at /etc/shadow timed out",
}
mgr.get_server_status.return_value = {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": "secret stdio at /etc/shadow timed out",
"transport": "stdio",
"command": "secret",
"url": "",
"circuit_open": False,
"consecutive_failures": 1,
}
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-reconnect/srv")
assert r.status_code == 500
body = r.json()
# The top-level `error` is generic; the inner `server.error` echoes
# whatever ``get_server_status`` returned (still admin-facing).
assert body["error"] == "reconnect failed"
assert "command" not in body["server"]
assert "url" not in body["server"]
def test_reconnect_one_raises_returns_500(self, node_app_factory) -> None:
mgr = MagicMock()
mgr.reconnect_sync.side_effect = RuntimeError("internal stdio /etc/shadow blew up")
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-reconnect/srv")
assert r.status_code == 500
body = r.json()
assert body["error"] == "reconnect failed"
assert "shadow" not in body["error"]
def test_reconnect_one_invalid_name_returns_400(self, node_app_factory) -> None:
# sec-4: name validation symmetric with console side.
mgr = MagicMock()
c = node_app_factory(mgr)
r = c.post("/v1/api/_internal/mcp-reconnect/bad__name")
assert r.status_code == 400
assert "invalid" in r.json()["error"].lower()
mgr.reconnect_sync.assert_not_called()
+191 -47
View File
@@ -130,6 +130,31 @@ def _fake_prompt_dict(
}
@pytest.fixture
def running_loop_mgr():
"""Yield a (mgr, loop, thread) triple with a background loop already running.
Spawns an MCPClientManager with a default config of {"srv": stdio echo}
and a fresh asyncio loop driven by a daemon thread. Tests that need a
different config can mutate ``mgr._server_configs`` directly. The
fixture stops the loop and joins the thread on teardown so each test
leaves a clean slate.
"""
import threading as _threading
cfg = {"srv": {"type": "stdio", "command": "echo"}}
mgr = MCPClientManager(cfg)
loop = asyncio.new_event_loop()
thread = _threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
mgr._loop = loop
try:
yield mgr, loop, thread
finally:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
# ---------------------------------------------------------------------------
# Schema conversion
# ---------------------------------------------------------------------------
@@ -1903,60 +1928,179 @@ class TestNotificationDebounce:
# ---------------------------------------------------------------------------
# Fix 5: Periodic refresh backoff
# reconnect_sync — operator-driven full reconnect
# ---------------------------------------------------------------------------
class TestPeriodicRefreshBackoff:
"""Verify periodic refresh backoff and auto-reconnect."""
class TestReconnectSync:
"""Verify reconnect_sync tears down old session, clears CB, calls _connect_one."""
def test_backoff_set_on_failure(self):
def test_reconnect_unknown_server_returns_error(self):
mgr = MCPClientManager({})
mgr._refresh_failures["srv"] = 1
# Simulate what _periodic_refresh does on failure
failures = mgr._refresh_failures.get("srv", 0) + 1
mgr._refresh_failures["srv"] = failures
backoff = min(mgr._REFRESH_BACKOFF_BASE * (2 ** (failures - 1)), mgr._REFRESH_BACKOFF_MAX)
mgr._refresh_backoff_until["srv"] = time.monotonic() + backoff
assert mgr._refresh_backoff_until["srv"] > time.monotonic()
assert failures == 2
result = mgr.reconnect_sync("missing")
assert result == {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": "unknown server",
}
def test_backoff_doubles(self):
mgr = MCPClientManager({})
b1 = min(mgr._REFRESH_BACKOFF_BASE * (2**0), mgr._REFRESH_BACKOFF_MAX)
b2 = min(mgr._REFRESH_BACKOFF_BASE * (2**1), mgr._REFRESH_BACKOFF_MAX)
b3 = min(mgr._REFRESH_BACKOFF_BASE * (2**2), mgr._REFRESH_BACKOFF_MAX)
assert b1 == 60
assert b2 == 120
assert b3 == 240
def test_reconnect_clears_circuit_breaker(self, running_loop_mgr):
mgr, _loop, _thread = running_loop_mgr
def test_backoff_capped(self):
mgr = MCPClientManager({})
b = min(mgr._REFRESH_BACKOFF_BASE * (2**20), mgr._REFRESH_BACKOFF_MAX)
assert b == mgr._REFRESH_BACKOFF_MAX
async def _fake_connect_one(name: str, _cfg: dict[str, Any]) -> None:
mgr._sessions[name] = MagicMock()
def test_backoff_clears_on_success(self):
mgr = MCPClientManager({})
mgr._refresh_failures["srv"] = 3
mgr._refresh_backoff_until["srv"] = time.monotonic() + 1000
# Simulate success
mgr._refresh_failures.pop("srv", None)
mgr._refresh_backoff_until.pop("srv", None)
assert "srv" not in mgr._refresh_failures
assert "srv" not in mgr._refresh_backoff_until
def test_server_status_includes_circuit_info(self):
mgr = MCPClientManager({"srv": {"type": "stdio", "command": "echo"}})
status = mgr.get_server_status("srv")
assert "circuit_open" in status
assert "consecutive_failures" in status
assert status["circuit_open"] is False
assert status["consecutive_failures"] == 0
def test_server_status_shows_open_circuit(self):
mgr = MCPClientManager({"srv": {"type": "stdio", "command": "echo"}})
# Pre-trip the breaker
for _ in range(3):
mgr._cb_record_failure("srv")
status = mgr.get_server_status("srv")
assert status["circuit_open"] is True
assert status["consecutive_failures"] == 3
assert "srv" in mgr._circuit_open_until
with (
patch.object(mgr, "_connect_one", side_effect=_fake_connect_one),
patch.object(mgr, "_pre_close_streams", new=AsyncMock()),
):
result = mgr.reconnect_sync("srv")
assert result["connected"] is True
assert result["error"] == ""
assert "srv" not in mgr._circuit_open_until
assert "srv" not in mgr._consecutive_failures
def test_reconnect_closes_old_session_then_calls_connect_one(self, running_loop_mgr):
mgr, _loop, _thread = running_loop_mgr
order: list[str] = []
old_stack = MagicMock(spec=AsyncExitStack)
async def _pre_close(name: str) -> None:
order.append("pre_close")
async def _safe_close(stack: Any) -> None:
order.append("safe_close")
assert stack is old_stack
async def _connect_one(name: str, _cfg: dict[str, Any]) -> None:
order.append("connect_one")
mgr._sessions[name] = MagicMock()
mgr._sessions["srv"] = MagicMock() # populated old session
mgr._per_server_stacks["srv"] = old_stack
mgr._server_streams["srv"] = (MagicMock(), MagicMock())
with (
patch.object(mgr, "_pre_close_streams", side_effect=_pre_close),
patch.object(mgr, "_safe_close_stack", side_effect=_safe_close),
patch.object(mgr, "_connect_one", side_effect=_connect_one),
):
result = mgr.reconnect_sync("srv")
assert result["connected"] is True
assert order == ["pre_close", "safe_close", "connect_one"]
assert "srv" not in mgr._per_server_stacks
def test_reconnect_failure_returns_error_dict(self, running_loop_mgr):
mgr, _loop, _thread = running_loop_mgr
async def _connect_one(name: str, _cfg: dict[str, Any]) -> None:
raise RuntimeError("handshake failed")
with (
patch.object(mgr, "_connect_one", side_effect=_connect_one),
patch.object(mgr, "_pre_close_streams", new=AsyncMock()),
):
result = mgr.reconnect_sync("srv")
assert result["connected"] is False
assert "handshake failed" in result["error"]
def test_reconnect_failure_clears_stale_catalog(self, running_loop_mgr):
# bug-2: when _connect_one fails mid-reconnect, the per-server
# catalog must be dropped so the merged tool/resource/prompt maps
# don't keep advertising entries with no live session.
mgr, _loop, _thread = running_loop_mgr
# Seed catalog state from a previous successful connect.
mgr._per_server_tools["srv"] = [_fake_openai_tool("mcp__srv__t")]
mgr._per_server_resources["srv"] = [_fake_resource_dict(server="srv")]
mgr._per_server_prompts["srv"] = [_fake_prompt_dict(server="srv")]
mgr._rebuild_tools()
mgr._rebuild_resources()
mgr._rebuild_prompts()
async def _connect_one(name: str, _cfg: dict[str, Any]) -> None:
raise RuntimeError("handshake failed")
with (
patch.object(mgr, "_connect_one", side_effect=_connect_one),
patch.object(mgr, "_pre_close_streams", new=AsyncMock()),
):
result = mgr.reconnect_sync("srv")
assert result["connected"] is False
# Per-server catalog and merged maps should both be empty for srv.
assert "srv" not in mgr._per_server_tools
assert "srv" not in mgr._per_server_resources
assert "srv" not in mgr._per_server_prompts
assert "mcp__srv__t" not in mgr._tool_map
# ---------------------------------------------------------------------------
# _cb_auto_reconnect — refresh-on-reconnect
# ---------------------------------------------------------------------------
class TestCBAutoReconnectRefresh:
"""Verify _cb_auto_reconnect schedules catalog refresh after a successful reconnect.
The refresh runs as a fire-and-forget background task on the loop so it
doesn't block the caller (perf-1). Tests wait briefly for the scheduled
task to run and observe its effect.
"""
def test_auto_reconnect_schedules_refresh_server_on_success(self, running_loop_mgr):
import threading as _threading
mgr, _loop, _thread = running_loop_mgr
new_session = MagicMock()
refresh_event = _threading.Event()
async def _connect_one(name: str, _cfg: dict[str, Any]) -> None:
mgr._sessions[name] = new_session
async def _refresh(name: str) -> tuple[list[str], list[str]]:
refresh_event.set()
return [], []
with (
patch.object(mgr, "_connect_one", side_effect=_connect_one),
patch.object(mgr, "_refresh_server", side_effect=_refresh),
):
session = mgr._cb_auto_reconnect("srv")
# Wait for the scheduled refresh task to actually run on the loop.
assert refresh_event.wait(timeout=5), "refresh task was not scheduled"
assert session is new_session
def test_auto_reconnect_swallows_refresh_failure(self, running_loop_mgr):
import threading as _threading
mgr, _loop, _thread = running_loop_mgr
new_session = MagicMock()
refresh_started = _threading.Event()
async def _connect_one(name: str, _cfg: dict[str, Any]) -> None:
mgr._sessions[name] = new_session
async def _refresh_failing(name: str) -> tuple[list[str], list[str]]:
refresh_started.set()
raise RuntimeError("catalog fetch broke")
with (
patch.object(mgr, "_connect_one", side_effect=_connect_one),
patch.object(mgr, "_refresh_server", side_effect=_refresh_failing),
):
# Must not raise — refresh failures are non-fatal.
session = mgr._cb_auto_reconnect("srv")
# Background refresh actually started and exception was swallowed
# by the task without affecting the synchronous caller.
assert refresh_started.wait(timeout=5), "refresh task was not scheduled"
assert session is new_session
-1
View File
@@ -117,7 +117,6 @@
[mcp]
# config_path = "" # Path to MCP servers config file (JSON)
# refresh_interval = 14400 # Refresh interval in seconds (default: 4h)
# --- Server (node, console) ---
-10
View File
@@ -1015,15 +1015,6 @@ def main() -> None:
help="Path to MCP server config file (standard mcpServers JSON format)",
)
from turnstone.core.config import nonneg_float
parser.add_argument(
"--mcp-refresh-interval",
type=nonneg_float,
default=14400,
metavar="SECONDS",
help="Periodic MCP tool refresh interval for servers without push notifications (default: 14400 = 4h, 0 to disable)",
)
judge_group = parser.add_argument_group("Judge options")
judge_group.add_argument(
"--judge",
@@ -1136,7 +1127,6 @@ def main() -> None:
mcp_client = create_mcp_client(
getattr(args, "mcp_config", None),
refresh_interval=getattr(args, "mcp_refresh_interval", 14400),
storage=_get_storage(),
)
+112
View File
@@ -8388,6 +8388,57 @@ async def _notify_nodes_mcp_reload(request: Request) -> dict[str, Any]:
return {nid: data for nid, data in results if data is not None}
async def _notify_nodes_mcp_action(request: Request, action: str, name: str) -> dict[str, Any]:
"""Tell all nodes to perform a per-server MCP action.
*action* is the suffix of the internal endpoint currently
``"refresh"`` or ``"reconnect"``. Each node is hit at
``/v1/api/_internal/mcp-{action}/{name}``.
"""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
safe_name = urllib.parse.quote(name, safe="")
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.post(
f"{url.rstrip('/')}/v1/api/_internal/mcp-{action}/{safe_name}",
headers=headers,
timeout=30,
)
return node_id, resp.json()
except Exception as exc:
log.debug(
"Failed to notify node %s for MCP %s of %s",
node_id,
action,
name,
exc_info=True,
)
return node_id, {"error": str(exc)}
results = await asyncio.gather(*[_notify(n) for n in nodes])
return {nid: data for nid, data in results if data is not None}
async def _notify_nodes_mcp_refresh_one(request: Request, name: str) -> dict[str, Any]:
"""Tell all nodes to refresh a single MCP server's catalog."""
return await _notify_nodes_mcp_action(request, "refresh", name)
async def _notify_nodes_mcp_reconnect_one(request: Request, name: str) -> dict[str, Any]:
"""Tell all nodes to force-reconnect a single MCP server."""
return await _notify_nodes_mcp_action(request, "reconnect", name)
async def admin_mcp_reload(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/reload — tell nodes to re-read DB."""
from turnstone.core.auth import require_permission
@@ -8404,6 +8455,57 @@ async def admin_mcp_reload(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "results": results})
async def _admin_mcp_action(request: Request, action: str) -> JSONResponse:
"""Shared body for the per-server MCP admin actions.
*action* is the verb suffix ``"refresh"`` or ``"reconnect"``.
Auth, name validation, audit, and per-node fan-out are identical
across the two; only the audit action string and the notify call
vary, both keyed on *action*.
"""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
name = request.path_params["name"]
if "__" in name:
return JSONResponse({"error": "invalid server name"}, status_code=400)
existing = storage.get_mcp_server_by_name(name)
target_id = existing.get("id", name) if existing else name
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
f"mcp_server.{action}",
"mcp_server",
target_id,
{"name": name},
ip,
)
results = await _notify_nodes_mcp_action(request, action, name)
return JSONResponse({"status": "ok", "results": results})
async def admin_mcp_refresh_one(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/{name}/refresh — refresh one server's catalog."""
return await _admin_mcp_action(request, "refresh")
async def admin_mcp_reconnect_one(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/{name}/reconnect — force-reconnect one server."""
return await _admin_mcp_action(request, "reconnect")
async def admin_import_mcp_config(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/import — import from pasted JSON config."""
import uuid
@@ -11198,6 +11300,16 @@ def create_app(
admin_mcp_reload,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/refresh",
admin_mcp_refresh_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/reconnect",
admin_mcp_reconnect_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{server_id}",
admin_get_mcp_server,
+50 -2
View File
@@ -3316,9 +3316,17 @@ function _renderMcpServers(items) {
var detailAttr = isConfig
? 'data-mcp-detail-name="' + escapeHtml(s.name) + '"'
: 'data-mcp-detail="' + escapeHtml(s.server_id) + '"';
var actionBtns =
'<button class="admin-btn-action" data-mcp-refresh="' +
escapeHtml(s.name) +
'">refresh</button>' +
'<button class="admin-btn-action" data-mcp-reconnect="' +
escapeHtml(s.name) +
'">reconnect</button>';
var actions = isConfig
? ""
: '<button class="admin-btn-action" data-mcp-edit="' +
? actionBtns
: actionBtns +
'<button class="admin-btn-action" data-mcp-edit="' +
escapeHtml(s.server_id) +
'">edit</button>' +
'<button class="admin-btn-danger" data-mcp-delete="' +
@@ -3383,6 +3391,46 @@ function _renderMcpServers(items) {
showEditMcpModal(this.getAttribute("data-mcp-edit"));
});
});
el.querySelectorAll("[data-mcp-refresh]").forEach(function (btn) {
btn.addEventListener("click", function () {
var name = this.getAttribute("data-mcp-refresh");
authFetch(
"/v1/api/admin/mcp-servers/" + encodeURIComponent(name) + "/refresh",
{ method: "POST" },
)
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Refreshed " + name);
loadAdminMcp();
})
.catch(function () {
showToast("Failed to refresh " + name);
});
});
});
el.querySelectorAll("[data-mcp-reconnect]").forEach(function (btn) {
btn.addEventListener("click", function () {
var name = this.getAttribute("data-mcp-reconnect");
authFetch(
"/v1/api/admin/mcp-servers/" + encodeURIComponent(name) + "/reconnect",
{ method: "POST" },
)
.then(function (r) {
if (!r.ok) throw new Error();
return r.json();
})
.then(function () {
showToast("Reconnected " + name);
loadAdminMcp();
})
.catch(function () {
showToast("Failed to reconnect " + name);
});
});
});
el.querySelectorAll("[data-mcp-delete]").forEach(function (btn) {
btn.addEventListener("click", function () {
var sid = this.getAttribute("data-mcp-delete");
+11
View File
@@ -533,6 +533,13 @@ def required_scope(method: str, path: str) -> str:
if method == "POST" and normalized in APPROVE_PATHS:
return "approve"
# Path-keyed internal admin actions: /api/_internal/mcp-{refresh,reconnect}/{name}
if method == "POST" and (
normalized.startswith("/api/_internal/mcp-refresh/")
or normalized.startswith("/api/_internal/mcp-reconnect/")
):
return "approve"
# Write endpoints
if method == "POST" and normalized in WRITE_PATHS:
return "write"
@@ -594,6 +601,10 @@ def required_scope(method: str, path: str) -> str:
if proxied:
if proxied in APPROVE_PATHS:
return "approve"
if proxied.startswith("/api/_internal/mcp-refresh/") or proxied.startswith(
"/api/_internal/mcp-reconnect/"
):
return "approve"
if proxied in WRITE_PATHS:
return "write"
# Parametric workstream sub-resource mutations
-1
View File
@@ -124,7 +124,6 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
},
"mcp": {
"config_path": "mcp_config",
"refresh_interval": "mcp_refresh_interval",
},
"ratelimit": {
"enabled": "ratelimit_enabled",
+95 -133
View File
@@ -8,12 +8,10 @@ synchronous. We bridge the two by running a dedicated asyncio event loop
in a daemon thread. ``call_tool_sync`` dispatches coroutines onto that loop
via ``asyncio.run_coroutine_threadsafe``.
Refresh: three mechanisms keep tool/resource/prompt lists up-to-date:
Refresh: two mechanisms keep tool/resource/prompt lists up-to-date:
1. Push notifications servers declaring ``listChanged`` on the
respective capability trigger immediate refresh.
2. Periodic timer servers *without* push support are polled on a
staggered interval (configurable, default 4 h, seeded at launch).
3. Manual ``/mcp refresh [server]`` triggers ``refresh_sync()``.
2. Manual ``/mcp refresh [server]`` triggers ``refresh_sync()``.
"""
from __future__ import annotations
@@ -22,7 +20,6 @@ import asyncio
import concurrent.futures
import contextlib
import json
import os
import random
import threading
import time
@@ -44,8 +41,6 @@ from turnstone.core.log import get_logger
log = get_logger("turnstone.mcp")
_DEFAULT_REFRESH_INTERVAL: float = 14400 # 4 hours
# ---------------------------------------------------------------------------
# MCP ↔ OpenAI schema conversion
@@ -88,12 +83,8 @@ class MCPClientManager:
def __init__(
self,
server_configs: dict[str, dict[str, Any]],
*,
refresh_interval: float = _DEFAULT_REFRESH_INTERVAL,
) -> None:
self._server_configs = server_configs
if refresh_interval < 0:
refresh_interval = 0.0
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._exit_stack: AsyncExitStack | None = None
@@ -147,10 +138,6 @@ class MCPClientManager:
self._storage: Any = None
self._sync_lock = threading.Lock()
# Periodic refresh for servers without push notifications
self._refresh_interval = refresh_interval
self._refresh_task: asyncio.Task[None] | None = None
# Circuit breaker (per-server) — prevents repeated calls to broken servers
self._consecutive_failures: dict[str, int] = {}
self._circuit_open_until: dict[str, float] = {} # monotonic timestamp
@@ -163,10 +150,6 @@ class MCPClientManager:
# Notification debounce (per-server)
self._last_notification_refresh: dict[str, float] = {}
# Periodic refresh backoff (per-server)
self._refresh_failures: dict[str, int] = {}
self._refresh_backoff_until: dict[str, float] = {} # monotonic timestamp
# -- lifecycle -----------------------------------------------------------
def start(self) -> None:
@@ -201,22 +184,6 @@ class MCPClientManager:
self._connected.set()
# Start periodic refresh for servers without push notifications
needs_periodic = any(
not self._supports_list_changed.get(name, False)
or (
self._supports_resources.get(name, False)
and not self._supports_resource_list_changed.get(name, False)
)
or (
self._supports_prompts.get(name, False)
and not self._supports_prompt_list_changed.get(name, False)
)
for name in self._sessions
)
if needs_periodic and self._refresh_interval > 0:
self._refresh_task = asyncio.get_running_loop().create_task(self._periodic_refresh())
_CONNECT_TIMEOUT = 30 # seconds — prevents hung connections on broken remotes
_TCP_PROBE_TIMEOUT = 5 # seconds — fast TCP pre-flight for HTTP transports
@@ -228,10 +195,6 @@ class MCPClientManager:
# Notification debounce
_NOTIFICATION_DEBOUNCE = 5.0 # seconds between refreshes per server
# Periodic refresh backoff
_REFRESH_BACKOFF_BASE = 60.0 # seconds
_REFRESH_BACKOFF_MAX = 3600.0 # 1 hour
# -- circuit breaker (per-server) -----------------------------------------
def _cb_check(self, name: str) -> tuple[bool, bool]:
@@ -666,9 +629,12 @@ class MCPClientManager:
Returns ``(added_tools, removed_tools)`` names (tool diff only,
for backward compatibility with ``/mcp refresh`` output).
"""
added, removed = await self._refresh_server_tools(name)
await self._refresh_server_resources(name)
await self._refresh_server_prompts(name)
tool_diff, _, _ = await asyncio.gather(
self._refresh_server_tools(name),
self._refresh_server_resources(name),
self._refresh_server_prompts(name),
)
added, removed = tool_diff
self._last_error.pop(name, None)
return added, removed
@@ -728,85 +694,6 @@ class MCPClientManager:
future.cancel()
raise TimeoutError(f"MCP refresh timed out after {timeout}s") from None
async def _periodic_refresh(self) -> None:
"""Periodically refresh servers that lack push notifications.
Applies per-server exponential backoff on failure and attempts
reconnection for disconnected servers.
"""
# Stagger start using a launch-time seed so cluster nodes don't
# all hit MCP servers simultaneously.
seed = random.Random(time.monotonic_ns() ^ os.getpid()).random()
initial_delay = seed * self._refresh_interval
await asyncio.sleep(initial_delay)
while True:
for name in list(self._server_configs):
now = time.monotonic()
# Check per-server backoff
backoff_until = self._refresh_backoff_until.get(name, 0.0)
if now < backoff_until:
continue # still in backoff
if name not in self._sessions:
# Attempt reconnection for disconnected servers
cfg = self._server_configs.get(name)
if cfg:
try:
log.info("Periodic reconnect attempt for '%s'", name)
await self._connect_one(name, cfg)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
self._cb_record_success(name)
except asyncio.CancelledError:
raise
except Exception as exc:
failures = self._refresh_failures.get(name, 0) + 1
self._refresh_failures[name] = failures
backoff = min(
self._REFRESH_BACKOFF_BASE * (2 ** (failures - 1)),
self._REFRESH_BACKOFF_MAX,
)
self._refresh_backoff_until[name] = time.monotonic() + backoff
log.warning(
"Periodic reconnect failed for '%s' (attempt %d, backoff %.0fs)",
name,
failures,
backoff,
)
self._set_error(name, f"Reconnect failed: {exc}")
continue
try:
if not self._supports_list_changed.get(name, False):
await self._refresh_server_tools(name)
if not self._supports_resource_list_changed.get(name, False):
await self._refresh_server_resources(name)
if not self._supports_prompt_list_changed.get(name, False):
await self._refresh_server_prompts(name)
self._last_error.pop(name, None)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
except Exception as exc:
failures = self._refresh_failures.get(name, 0) + 1
self._refresh_failures[name] = failures
backoff = min(
self._REFRESH_BACKOFF_BASE * (2 ** (failures - 1)),
self._REFRESH_BACKOFF_MAX,
)
self._refresh_backoff_until[name] = time.monotonic() + backoff
log.warning(
"Periodic refresh failed for '%s' (attempt %d, backoff %.0fs)",
name,
failures,
backoff,
)
self._set_error(name, f"Periodic refresh failed: {exc}")
# Note: per-server backoff (max 1h) is only meaningful when
# refresh_interval is shorter than _REFRESH_BACKOFF_MAX. With
# the default 4h interval this sleep already bounds retry frequency.
await asyncio.sleep(self._refresh_interval)
# -- resource refresh ----------------------------------------------------
def _rebuild_resources(self) -> None:
@@ -1142,10 +1029,6 @@ class MCPClientManager:
def shutdown(self) -> None:
"""Close all MCP sessions and stop the background loop."""
# Cancel periodic refresh
if self._refresh_task and self._loop:
self._loop.call_soon_threadsafe(self._refresh_task.cancel)
# Close all per-server stacks (transports + sessions)
if self._loop and self._per_server_stacks:
@@ -1204,8 +1087,6 @@ class MCPClientManager:
self._circuit_trip_count.clear()
self._server_streams.clear()
self._last_notification_refresh.clear()
self._refresh_failures.clear()
self._refresh_backoff_until.clear()
log.info("MCP client shut down")
@@ -1252,6 +1133,77 @@ class MCPClientManager:
"error": "",
}
def reconnect_sync(self, name: str, timeout: int = 30) -> dict[str, Any]:
"""Force a fresh connection to an MCP server (blocks the calling thread).
Tears down the current session/transport (if any), clears the circuit
breaker, and runs a new ``_connect_one``. Returns status dict with
keys: connected, tools, resources, prompts, error (parity with
``add_server_sync``).
"""
if name not in self._server_configs:
return {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": "unknown server",
}
if self._loop is None:
return {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": "MCP event loop not running",
}
cfg = self._server_configs[name]
async def _reconnect() -> None:
self._cb_clear(name)
self._sessions.pop(name, None)
await self._pre_close_streams(name)
stack = self._per_server_stacks.pop(name, None)
if stack is not None:
await self._safe_close_stack(stack)
try:
await self._connect_one(name, cfg)
except Exception:
# Connect failed mid-reconnect — drop the stale per-server
# catalog so the merged tool/resource/prompt maps don't keep
# advertising entries with no live session behind them.
self._per_server_tools.pop(name, None)
self._per_server_resources.pop(name, None)
self._per_server_prompts.pop(name, None)
self._rebuild_tools()
self._rebuild_resources()
self._rebuild_prompts()
raise
future = asyncio.run_coroutine_threadsafe(_reconnect(), self._loop)
try:
future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
return {
"connected": False,
"tools": 0,
"resources": 0,
"prompts": 0,
"error": f"MCP server '{name}' reconnect timed out",
}
except Exception as exc:
return {"connected": False, "tools": 0, "resources": 0, "prompts": 0, "error": str(exc)}
return {
"connected": name in self._sessions,
"tools": len(self._per_server_tools.get(name, [])),
"resources": len(self._per_server_resources.get(name, [])),
"prompts": len(self._per_server_prompts.get(name, [])),
"error": "",
}
def remove_server_sync(self, name: str, timeout: int = 15) -> bool:
"""Disconnect and remove an MCP server at runtime (blocks the calling thread).
@@ -1285,8 +1237,6 @@ class MCPClientManager:
self._supports_prompt_list_changed.pop(name, None)
self._last_error.pop(name, None)
self._last_notification_refresh.pop(name, None)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
self._cb_clear(name)
# Rebuild merged state (serialized with notification handlers)
self._rebuild_tools()
@@ -1312,8 +1262,6 @@ class MCPClientManager:
self._supports_prompt_list_changed.pop(name, None)
self._last_error.pop(name, None)
self._last_notification_refresh.pop(name, None)
self._refresh_failures.pop(name, None)
self._refresh_backoff_until.pop(name, None)
self._cb_clear(name)
self._rebuild_tools()
self._rebuild_resources()
@@ -1527,6 +1475,21 @@ class MCPClientManager:
if session is None:
self._cb_record_failure(server_name)
raise RuntimeError(f"MCP server '{server_name}' reconnect produced no session")
# Schedule catalog refresh on the loop without blocking the caller.
# The reconnected session is valid for the imminent dispatch; catalog
# drift will be reconciled on the loop in the background.
def _schedule_refresh() -> None:
try:
asyncio.create_task(self._refresh_server(server_name))
except Exception:
log.warning(
"Catalog refresh after reconnect failed for '%s'",
server_name,
exc_info=True,
)
self._loop.call_soon_threadsafe(_schedule_refresh)
return session
def call_tool_sync(
@@ -1794,7 +1757,6 @@ def load_mcp_config(
def create_mcp_client(
config_path: str | None = None,
*,
refresh_interval: float = _DEFAULT_REFRESH_INTERVAL,
storage: Any = None,
) -> MCPClientManager | None:
"""Create and start an MCP client manager.
@@ -1815,7 +1777,7 @@ def create_mcp_client(
if not servers:
return None
mgr = MCPClientManager(servers, refresh_interval=refresh_interval)
mgr = MCPClientManager(servers)
# Mark DB-sourced servers so reconcile_sync won't remove config-file servers
mgr._db_managed = {name for name in servers if name in db_names}
mgr.start()
-8
View File
@@ -340,14 +340,6 @@ def _build_registry() -> dict[str, SettingDef]:
"Tip: use the MCP Servers tab to manage servers via the database instead.",
reference_url="https://modelcontextprotocol.io",
),
SettingDef(
"mcp.refresh_interval",
"int",
14400,
"MCP resource/prompt refresh interval in seconds (0 = disabled, default 4h)",
"mcp",
min_value=0,
),
SettingDef(
"mcp.registry_url",
"str",
+95 -1
View File
@@ -2850,6 +2850,91 @@ def internal_mcp_status(request: Request) -> JSONResponse:
return JSONResponse({"servers": mcp_mgr.get_all_server_status()})
_SERVER_STATUS_PUBLIC_KEYS: tuple[str, ...] = (
"connected",
"tools",
"resources",
"prompts",
"error",
"transport",
"circuit_open",
"consecutive_failures",
)
def _public_server_status(mcp_mgr: Any, name: str) -> dict[str, Any]:
"""Strip ``command``/``url`` from ``get_server_status`` before returning over the wire.
The full status dict embeds stdio argv and remote URLs that are
admin-only context. Internal node endpoints surface only the
operational fields callers need to reflect to the operator.
"""
full = mcp_mgr.get_server_status(name)
return {k: full[k] for k in _SERVER_STATUS_PUBLIC_KEYS if k in full}
def internal_mcp_refresh_one(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/mcp-refresh/{name} — refresh a single MCP server's catalog."""
name = request.path_params["name"]
if "__" in name:
return JSONResponse({"status": "error", "error": "invalid name"}, status_code=400)
mcp_mgr = getattr(request.app.state, "mcp_client", None)
if mcp_mgr is None:
return JSONResponse({"status": "error", "error": "MCP client not running"}, status_code=503)
try:
mcp_mgr.refresh_sync(server_name=name)
except Exception as exc:
log.warning("internal_mcp_refresh_one failed for %s: %s", name, exc)
return JSONResponse({"status": "error", "error": "refresh failed"}, status_code=500)
# _refresh_all swallows per-server errors into _last_error rather than
# raising, so a 200-OK from refresh_sync isn't enough — re-check status
# and surface 500 if the refresh actually failed for this server.
status = _public_server_status(mcp_mgr, name)
if status.get("error"):
log.warning(
"internal_mcp_refresh_one: refresh reported error for %s: %s", name, status["error"]
)
return JSONResponse(
{"status": "error", "error": "refresh failed", "server": status},
status_code=500,
)
return JSONResponse({"status": "ok", "server": status})
def internal_mcp_reconnect_one(request: Request) -> JSONResponse:
"""POST /v1/api/_internal/mcp-reconnect/{name} — force-reconnect a single MCP server."""
name = request.path_params["name"]
if "__" in name:
return JSONResponse({"status": "error", "error": "invalid name"}, status_code=400)
mcp_mgr = getattr(request.app.state, "mcp_client", None)
if mcp_mgr is None:
return JSONResponse({"status": "error", "error": "MCP client not running"}, status_code=503)
try:
result = mcp_mgr.reconnect_sync(name)
except Exception as exc:
log.warning("internal_mcp_reconnect_one failed for %s: %s", name, exc)
return JSONResponse({"status": "error", "error": "reconnect failed"}, status_code=500)
if result.get("error"):
log.warning(
"internal_mcp_reconnect_one: reconnect reported error for %s: %s",
name,
result.get("error", ""),
)
return JSONResponse(
{
"status": "error",
"error": "reconnect failed",
"server": _public_server_status(mcp_mgr, name),
},
status_code=500,
)
return JSONResponse({"status": "ok", "server": _public_server_status(mcp_mgr, name)})
# -- internal model management -----------------------------------------------
@@ -3790,6 +3875,16 @@ def create_app(
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route("/api/_internal/mcp-status", internal_mcp_status),
Route(
"/api/_internal/mcp-refresh/{name}",
internal_mcp_refresh_one,
methods=["POST"],
),
Route(
"/api/_internal/mcp-reconnect/{name}",
internal_mcp_reconnect_one,
methods=["POST"],
),
Route(
"/api/_internal/model-reload",
internal_model_reload,
@@ -4052,7 +4147,6 @@ def main() -> None:
mcp_config_cli = args.mcp_config # CLI-only (no config.toml for this)
mcp_client = create_mcp_client(
mcp_config_cli or config_store.get("mcp.config_path") or None,
refresh_interval=config_store.get("mcp.refresh_interval"),
storage=_get_storage(),
)
# Mutable ref so session_factory always sees the latest MCP client,