mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
4db7d9c6cf
Phase 5 of OAuth-MCP — adds a per-(user, MCP-server) ClientSession
pool to MCPClientManager alongside the existing static-server path,
gated entirely on the per-server `auth_type='oauth_user'` config.
Pool architecture:
- `_user_pool_entries: dict[(user_id, server_name), PoolEntryState]`
with lazy connect on first dispatch, per-key asyncio.Lock allocated
on the mcp-loop, idle eviction coroutine (default 600s TTL, LRU cap
200), and an `in_flight` counter as the eviction interlock so live
calls can never be torn down mid-flight.
- `_dispatch_pool` runs the token-state machine: missing token →
`mcp_consent_required`; key-rotation decrypt failure →
`mcp_token_undecryptable_key_unknown` with NO consent prompt and NO
auto-delete; expired token → silent refresh under per-(user, server)
advisory lock; refresh failure → revoke + consent.
- `_classify_failure` separates transport (trips breaker) from auth
401/403 (does NOT trip breaker — server-only invariant) from
protocol (no breaker change).
- `entry.open_lock` held only across connect-or-reuse and released
before the `await session.call_tool` so concurrent calls from one
user against one server overlap (validated by Spike 1 scenario 2).
Auth-class failures are fail-soft in Phase 5: any 401/403 surfaced by
the SDK propagates to the agent as a tool error and the next dispatch
reconnects on a fresh refresh. Real introspection of upstream 401/403
is a Phase 6 concern — the MCP SDK's `streamable_http` post_writer
swallows `httpx.HTTPStatusError` upstream, so detecting status from
the response chain requires `McpError(CONNECTION_CLOSED)` payload
parsing or a custom httpx middleware around `streamablehttp_client`.
The mid-flight 401 refresh-retry path and the `mcp_insufficient_scope`
structured error for 403 step-up land together in Phase 6, gated by
an integration test that drives a real upstream 401/403 (the unit-
test injection of `HTTPStatusError` is what masked the production gap
on the first apply-findings pass — the integration test is the
structural gate so the gap can't reopen). RFC §1.5 steps 4-5 and the
phase table in §Implementation phases reflect this scope split.
Multi-node refresh contention:
- New `StorageBackend.acquire_advisory_lock_sync` Protocol method.
SQLite returns nullcontext (single-node, in-process asyncio.Lock
is sufficient). Postgres uses `pg_try_advisory_xact_lock` with
retry on a fresh per-attempt connection, so waiters don't pin pool
connections during the AS roundtrip. Inner try/except + nested
finally ensures conn is always returned to the pool, even when
begin / execute / yield / commit raises mid-body.
- Lock ordering: pg_advisory outer, asyncio.Lock inner. Re-read after
lock collapses cluster-wide contention to one HTTP roundtrip per
(user, server) per refresh window.
- `_PgRefreshLock` enter/exit pinned to a single-worker
ThreadPoolExecutor so SQLAlchemy connection state stays
thread-affine across cancellations.
Token storage refactor:
- `get_user_access_token_classified` returns a tagged TokenLookupResult
(Token / MissingToken / DecryptFailure / RefreshFailed) so the
dispatcher maps each state to the right user-facing error.
- `get_user_access_token` is now a thin wrapper around the classified
variant; the previous duplicated state machine is gone.
Security:
- Pool dispatch + admin endpoints reject `http://` URLs for
`auth_type='oauth_user'` servers (only exact loopback hostnames are
exempt — `*.localhost` is intentionally NOT honored because RFC 6761
localhost-zone resolution is configuration-dependent and could route
bearers to non-loopback IPs via custom resolvers / hosts file /
Docker overlays). Validated at three layers:
`_dispatch_pool` (structured `mcp_oauth_url_insecure` error),
`_connect_one_pool` (defensive ValueError), and
`admin_create_mcp_server` / `admin_update_mcp_server` (400 before
storage write).
- Admin URL change on an oauth_user row purges per-user OAuth tokens
bound to the old URL: bearers are bound (via OAuth resource /
audience) to the URL active at consent time, so silently rebinding
them to a new URL is a token-binding violation. Re-consent forces
fresh issuance for the new resource.
- Encryption-key fingerprints stay in audit logs only; no longer
surfaced in agent-facing error payloads.
User_id thread-through:
- `MCPClientManager.call_tool_sync(..., user_id=None)` (additive;
default None preserves the static path byte-identically).
- `ChatSession._exec_mcp_tool` passes `self._user_id or None`.
- `set_app_state(app_state)` setter wires OAuth state at lifespan
startup, called from both turnstone-server and turnstone-console.
Performance:
- LRU cap eviction iterates `_user_pool_entries` (not
`_user_pool_last_used`) so pre-dispatch entries are eligible.
- Eviction batch closes via `asyncio.gather` instead of serial await.
- `_resolve_pool_target` returns the resolved server row to
`_dispatch_pool` to eliminate the second DB lookup.
- Production reachability of pool dispatch is gated on Phase 7
(catalog scoping) wiring pool tools into `_tool_map`; until then
pool dispatch is reachable only via direct `call_tool_sync` with a
prefixed name (the path the new pool tests exercise).
Hardening parity preserved:
- Static path (auth_type ∈ {none, static}) byte-identical; PR #296
hardening (SDK #2147 mitigations, anyio cancel-scope, stale-session-
and-stack guard, server-only circuit breaker) intact.
- `test_reconnect_preserves_static_state_identity` unchanged + green.
- `MCPTokenStore.get_user_token` does not auto-delete on
MCPTokenDecryptError (key-rotation safety).
- Notification debounce stays manager-level.
- Connect-failure cleanup factored into
`_safe_teardown_on_connect_failure` shared by both connect paths.
Tests: 5475 → 5493 (+18). New file `tests/test_mcp_user_pool.py`
plus additions to test_mcp_oauth_refresh.py, test_mcp_admin_api.py,
and test_mcp_client.py covering: pool data structures, lazy connect,
eviction TTL + LRU + lock interlock, dispatch state machine (token
states), failure classification, http-rejection at dispatch and
admin layers, URL-change-purges-tokens (sec), concurrent dispatch on
one (user, server), pg_advisory lock parity, and user_id threading.
Phase exit criterion (synthetic load test 50 users × 3 servers × LRU
30 × 1000 calls × 200 evictions) deferred to a post-Phase-5 fitness
spike that runs against a staging deployment with real FDs and real
network behaviour, not a CI mock — same shape as Spike 1's
pre-Phase-0 SDK validation.
Out-of-scope for Phase 5 (Phase 6+): SDK-level 401 refresh-retry +
403 `mcp_insufficient_scope` (Phase 6), per-user catalog scoping
(Phase 7), consent UX SSE event + dashboard renderer (Phase 8),
admin UI status indicators (Phase 9).
1939 lines
71 KiB
Python
1939 lines
71 KiB
Python
"""Tests for MCP server admin API endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import uuid
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
if TYPE_CHECKING:
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
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,
|
|
)
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth middleware variants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
|
"""Inject an admin auth result with admin.mcp permission."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="test-user",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="config",
|
|
permissions=frozenset(
|
|
{
|
|
"read",
|
|
"write",
|
|
"approve",
|
|
"admin.mcp",
|
|
}
|
|
),
|
|
)
|
|
resp: Response = await call_next(request)
|
|
return resp
|
|
|
|
|
|
class _InjectAuthNoMcpMiddleware(BaseHTTPMiddleware):
|
|
"""Inject an auth result WITHOUT admin.mcp permission."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="test-user",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="jwt",
|
|
permissions=frozenset(
|
|
{
|
|
"read",
|
|
"write",
|
|
"approve",
|
|
}
|
|
),
|
|
)
|
|
resp: Response = await call_next(request)
|
|
return resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_ROUTES = [
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/admin/mcp-servers", admin_list_mcp_servers),
|
|
Route(
|
|
"/api/admin/mcp-servers",
|
|
admin_create_mcp_server,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/api/admin/mcp-servers/import",
|
|
admin_import_mcp_config,
|
|
methods=["POST"],
|
|
),
|
|
Route(
|
|
"/api/admin/mcp-servers/reload",
|
|
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,
|
|
),
|
|
Route(
|
|
"/api/admin/mcp-servers/{server_id}",
|
|
admin_update_mcp_server,
|
|
methods=["PUT"],
|
|
),
|
|
Route(
|
|
"/api/admin/mcp-servers/{server_id}",
|
|
admin_delete_mcp_server,
|
|
methods=["DELETE"],
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
def _routes_with_internal() -> list[Mount]:
|
|
"""Routes including the node-side internal endpoints (lazy-imported)."""
|
|
from turnstone.server import (
|
|
internal_mcp_reconnect_one,
|
|
internal_mcp_refresh_one,
|
|
internal_mcp_reload,
|
|
internal_mcp_status,
|
|
)
|
|
|
|
return [
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
*_ROUTES[0].routes, # type: ignore[union-attr]
|
|
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"],
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
def _install_token_store(app, storage) -> None:
|
|
"""Install an MCPTokenStore on ``app.state`` for tests that exercise
|
|
the OAuth client-secret write path. Uses a deterministic test key."""
|
|
from cryptography.fernet import Fernet
|
|
|
|
from turnstone.core.mcp_crypto import (
|
|
MCPTokenCipher,
|
|
MCPTokenCipherConfig,
|
|
MCPTokenStore,
|
|
)
|
|
|
|
raw_key = base64.urlsafe_b64decode(Fernet.generate_key())
|
|
cipher = MCPTokenCipher(MCPTokenCipherConfig(keys=(raw_key,)))
|
|
app.state.mcp_token_cipher = cipher
|
|
app.state.mcp_token_store = MCPTokenStore(
|
|
storage, cipher, node_id="test", audit_storage=storage
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(storage):
|
|
"""TestClient wired to console admin MCP endpoints with full permissions."""
|
|
app = Starlette(
|
|
routes=_ROUTES,
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
_install_token_store(app, storage)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_no_perm(storage):
|
|
"""TestClient without admin.mcp permission."""
|
|
app = Starlette(
|
|
routes=_ROUTES,
|
|
middleware=[Middleware(_InjectAuthNoMcpMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
_install_token_store(app, storage)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_no_token_store(storage):
|
|
"""TestClient WITHOUT MCPTokenStore — the 503 path for OAuth secret writes."""
|
|
app = Starlette(
|
|
routes=_ROUTES,
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
app.state.mcp_token_store = None
|
|
app.state.mcp_token_cipher = None
|
|
return TestClient(app)
|
|
|
|
|
|
def _create_server(
|
|
client: TestClient,
|
|
*,
|
|
name: str = "test-server",
|
|
transport: str = "stdio",
|
|
command: str = "npx",
|
|
args: list[str] | None = None,
|
|
env: dict[str, str] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
url: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Helper to create a server via the API and return the response dict."""
|
|
body: dict[str, Any] = {"name": name, "transport": transport}
|
|
if transport == "stdio":
|
|
body["command"] = command
|
|
body["args"] = args or ["-y", "@modelcontextprotocol/server-test"]
|
|
else:
|
|
body["url"] = url or "http://localhost:8080/mcp"
|
|
if env is not None:
|
|
body["env"] = env
|
|
if headers is not None:
|
|
body["headers"] = headers
|
|
r = client.post("/v1/api/admin/mcp-servers", json=body)
|
|
assert r.status_code == 200
|
|
data: dict[str, Any] = r.json()
|
|
return data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock _collect_mcp_status to avoid real HTTP calls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PATCH_MCP_STATUS = patch(
|
|
"turnstone.console.server._collect_mcp_status",
|
|
new_callable=AsyncMock,
|
|
return_value={},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# List
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListMcpServers:
|
|
def test_list_empty(self, client):
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get("/v1/api/admin/mcp-servers")
|
|
assert r.status_code == 200
|
|
assert r.json()["servers"] == []
|
|
|
|
def test_list_returns_created_servers(self, client):
|
|
_create_server(client, name="server-a")
|
|
_create_server(client, name="server-b")
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get("/v1/api/admin/mcp-servers")
|
|
assert r.status_code == 200
|
|
names = [s["name"] for s in r.json()["servers"]]
|
|
assert "server-a" in names
|
|
assert "server-b" in names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateMcpServer:
|
|
def test_create_stdio_server(self, client):
|
|
data = _create_server(client, name="my-mcp", transport="stdio", command="node")
|
|
assert data["name"] == "my-mcp"
|
|
assert data["transport"] == "stdio"
|
|
assert data["command"] == "node"
|
|
assert data["server_id"]
|
|
assert data["enabled"] is True
|
|
|
|
def test_create_http_server(self, client):
|
|
data = _create_server(
|
|
client,
|
|
name="remote-mcp",
|
|
transport="streamable-http",
|
|
url="http://mcp.example.com/sse",
|
|
)
|
|
assert data["name"] == "remote-mcp"
|
|
assert data["transport"] == "streamable-http"
|
|
assert data["url"] == "http://mcp.example.com/sse"
|
|
|
|
def test_create_invalid_name_spaces(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={"name": "bad name!", "transport": "stdio", "command": "x"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "name" in r.json()["error"].lower()
|
|
|
|
def test_create_invalid_name_double_underscore(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={"name": "bad__name", "transport": "stdio", "command": "x"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "__" in r.json()["error"]
|
|
|
|
def test_create_invalid_transport(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={"name": "ok-name", "transport": "grpc"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "transport" in r.json()["error"].lower()
|
|
|
|
def test_create_duplicate_name(self, client):
|
|
_create_server(client, name="dup-test")
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={"name": "dup-test", "transport": "stdio", "command": "x"},
|
|
)
|
|
assert r.status_code == 409
|
|
assert "already exists" in r.json()["error"]
|
|
|
|
def test_create_missing_name(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={"transport": "stdio", "command": "x"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "name" in r.json()["error"].lower()
|
|
|
|
def test_admin_create_oauth_server(self, client):
|
|
"""Admin can POST a server with auth_type=oauth_user; the seven
|
|
OAuth text fields round-trip via GET and the plaintext client
|
|
secret is encrypted-at-rest via the dedicated writer."""
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "oauth-srv",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_abc",
|
|
"oauth_client_secret": "secret-value",
|
|
"oauth_scopes": "openid profile",
|
|
"oauth_audience": "https://mcp.example.com",
|
|
"oauth_registration_mode": "preregistered",
|
|
"oauth_authorization_server_url": "https://auth.example.com",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert data["auth_type"] == "oauth_user"
|
|
assert data["oauth_client_id"] == "cli_abc"
|
|
assert data["oauth_scopes"] == "openid profile"
|
|
assert data["oauth_audience"] == "https://mcp.example.com"
|
|
assert data["oauth_registration_mode"] == "preregistered"
|
|
assert data["oauth_authorization_server_url"] == "https://auth.example.com"
|
|
# Ciphertext is persisted; the response masks it to "***".
|
|
assert data["oauth_client_secret_ct"] == "***"
|
|
|
|
def test_admin_create_oauth_server_without_token_store_returns_503(self, client_no_token_store):
|
|
"""When MCPTokenStore is unconfigured (no Fernet key), the admin
|
|
form returns 503 with an operator-actionable hint rather than
|
|
silently dropping the secret."""
|
|
r = client_no_token_store.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "oauth-srv",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_abc",
|
|
"oauth_client_secret": "secret-value",
|
|
},
|
|
)
|
|
assert r.status_code == 503, r.text
|
|
assert "mcp_token_encryption_key" in r.json()["error"]
|
|
|
|
def test_admin_create_oauth_server_503_does_not_create_orphan_row(
|
|
self, client_no_token_store, storage
|
|
):
|
|
"""bug-1: a 503 from the token-store gate must not leave an orphan
|
|
``oauth_user`` row behind. The validation is pre-mutation."""
|
|
r = client_no_token_store.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "would-be-orphan",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_abc",
|
|
"oauth_client_secret": "secret-value",
|
|
},
|
|
)
|
|
assert r.status_code == 503
|
|
# No row was created — the storage write was gated on the token store.
|
|
assert storage.get_mcp_server_by_name("would-be-orphan") is None
|
|
assert storage.list_mcp_servers() == []
|
|
|
|
def test_admin_create_oauth_server_rejects_non_string_secret(self, client):
|
|
"""bug-3: ``oauth_client_secret`` must be a string or null in JSON.
|
|
A boolean / number / list payload should 400 cleanly, not be coerced
|
|
via ``str(...)``."""
|
|
for bad in (False, 0, [], {}):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": f"bad-secret-{type(bad).__name__}",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_secret": bad,
|
|
},
|
|
)
|
|
assert r.status_code == 400, f"payload={bad!r} got {r.status_code}: {r.text}"
|
|
assert "oauth_client_secret" in r.json()["error"]
|
|
|
|
def test_create_invalid_auth_type(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "bad-auth",
|
|
"transport": "stdio",
|
|
"command": "x",
|
|
"auth_type": "magic",
|
|
},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "auth_type" in r.json()["error"].lower()
|
|
|
|
def test_admin_create_oauth_user_rejects_http(self, client, storage):
|
|
"""sec-1: an oauth_user row with a plaintext (non-loopback) URL
|
|
must 400 — pool dispatch would transmit the per-user bearer in
|
|
the clear."""
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "insecure-oauth",
|
|
"transport": "streamable-http",
|
|
"url": "http://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_abc",
|
|
"oauth_client_secret": "secret-value",
|
|
},
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
assert "https://" in r.json()["error"]
|
|
# No partial row was persisted.
|
|
assert storage.get_mcp_server_by_name("insecure-oauth") is None
|
|
|
|
def test_admin_create_oauth_user_accepts_loopback_http(self, client):
|
|
"""``http://localhost`` and ``http://127.0.0.1`` must remain
|
|
usable for dev/test convenience."""
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "dev-oauth",
|
|
"transport": "streamable-http",
|
|
"url": "http://127.0.0.1:9000/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_abc",
|
|
"oauth_client_secret": "secret-value",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Get single
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetMcpServer:
|
|
def test_get_existing(self, client):
|
|
created = _create_server(client, name="get-test")
|
|
sid = created["server_id"]
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get(f"/v1/api/admin/mcp-servers/{sid}")
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "get-test"
|
|
|
|
def test_get_not_found(self, client):
|
|
fake_id = uuid.uuid4().hex
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get(f"/v1/api/admin/mcp-servers/{fake_id}")
|
|
assert r.status_code == 404
|
|
assert "not found" in r.json()["error"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateMcpServer:
|
|
def test_update_name(self, client):
|
|
created = _create_server(client, name="old-name")
|
|
sid = created["server_id"]
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"name": "new-name"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "new-name"
|
|
|
|
def test_update_transport(self, client):
|
|
created = _create_server(
|
|
client,
|
|
name="update-transport",
|
|
transport="streamable-http",
|
|
url="http://localhost/mcp",
|
|
)
|
|
sid = created["server_id"]
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"transport": "stdio", "command": "node"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["transport"] == "stdio"
|
|
|
|
def test_update_enabled(self, client):
|
|
created = _create_server(client, name="toggle-enabled")
|
|
sid = created["server_id"]
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"enabled": False},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["enabled"] is False
|
|
|
|
def test_update_not_found(self, client):
|
|
fake_id = uuid.uuid4().hex
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{fake_id}",
|
|
json={"name": "x"},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
def test_update_invalid_transport(self, client):
|
|
created = _create_server(client, name="bad-transport-update")
|
|
sid = created["server_id"]
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"transport": "websocket"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "transport" in r.json()["error"].lower()
|
|
|
|
def test_admin_update_auth_type_static_to_oauth(self, client):
|
|
"""An existing static row can be flipped to oauth_user with
|
|
OAuth fields supplied alongside. The row must already use
|
|
https:// — sec-1 enforces this on update too."""
|
|
created = _create_server(
|
|
client,
|
|
name="flip-to-oauth",
|
|
transport="streamable-http",
|
|
url="https://mcp.example.com/sse",
|
|
)
|
|
sid = created["server_id"]
|
|
assert created["auth_type"] == "static"
|
|
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_xyz",
|
|
"oauth_audience": "https://mcp.example.com",
|
|
"oauth_registration_mode": "dcr",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert data["auth_type"] == "oauth_user"
|
|
assert data["oauth_client_id"] == "cli_xyz"
|
|
assert data["oauth_audience"] == "https://mcp.example.com"
|
|
assert data["oauth_registration_mode"] == "dcr"
|
|
|
|
def test_admin_update_oauth_url_to_http_rejected(self, client):
|
|
"""sec-1: flipping the URL on an existing oauth_user row to
|
|
plaintext http must 400."""
|
|
# Create with proper https.
|
|
r0 = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "oauth-flip-url",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_abc",
|
|
"oauth_client_secret": "secret-value",
|
|
},
|
|
)
|
|
assert r0.status_code == 200, r0.text
|
|
sid = r0.json()["server_id"]
|
|
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"url": "http://insecure.example.com/sse"},
|
|
)
|
|
assert r.status_code == 400, r.text
|
|
assert "https://" in r.json()["error"]
|
|
|
|
def test_admin_update_oauth_url_change_purges_user_tokens(self, client, storage):
|
|
"""sec-1 (pre-push): URL change on an oauth_user row must purge
|
|
per-user tokens. Bearer tokens are bound (via OAuth resource /
|
|
audience) to the URL active at consent time; sending them to a
|
|
new URL is a token-binding violation. A compromised admin who
|
|
flips the URL to an attacker endpoint would otherwise replay
|
|
every user's bearer there silently. Re-consent must be forced.
|
|
"""
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.storage._schema import mcp_user_tokens
|
|
|
|
# Seed an oauth_user row at URL_A.
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "url-change-purge",
|
|
"transport": "streamable-http",
|
|
"url": "https://orig.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_seed",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
|
|
# Plant a per-user token row keyed on the server name.
|
|
with storage._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.insert(mcp_user_tokens),
|
|
{
|
|
"user_id": "u1",
|
|
"server_name": "url-change-purge",
|
|
"access_token_ct": b"\x00ciphertext-a",
|
|
"refresh_token_ct": b"\x00ciphertext-r",
|
|
"expires_at": "2026-12-31T00:00:00",
|
|
"scopes": "openid",
|
|
"as_issuer": "https://auth.orig.example.com",
|
|
"audience": "https://orig.example.com",
|
|
"created": "2026-05-04T11:00:00",
|
|
"last_refreshed": None,
|
|
},
|
|
)
|
|
conn.commit()
|
|
count_before = conn.execute(
|
|
sa.select(sa.func.count())
|
|
.select_from(mcp_user_tokens)
|
|
.where(mcp_user_tokens.c.server_name == "url-change-purge")
|
|
).scalar()
|
|
assert count_before == 1
|
|
|
|
# Flip the URL to a different (still https) endpoint.
|
|
sid = r.json()["server_id"]
|
|
r2 = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"url": "https://new.example.com/sse"},
|
|
)
|
|
assert r2.status_code == 200, r2.text
|
|
assert r2.json()["url"] == "https://new.example.com/sse"
|
|
|
|
# Token rows keyed on the OLD server name must be gone — the new
|
|
# URL is a different OAuth resource, so the bearer is no longer
|
|
# valid there. Force re-consent.
|
|
with storage._engine.connect() as conn:
|
|
count_after = conn.execute(
|
|
sa.select(sa.func.count())
|
|
.select_from(mcp_user_tokens)
|
|
.where(mcp_user_tokens.c.server_name == "url-change-purge")
|
|
).scalar()
|
|
assert count_after == 0, "URL change must purge per-user tokens"
|
|
|
|
def test_update_invalid_auth_type(self, client):
|
|
created = _create_server(client, name="bad-auth-update")
|
|
sid = created["server_id"]
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"auth_type": "wat"},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "auth_type" in r.json()["error"].lower()
|
|
|
|
def test_update_empty_auth_type_rejected(self, client):
|
|
"""Empty-string auth_type is rejected (no silent coercion to 'static')."""
|
|
created = _create_server(client, name="empty-auth-update")
|
|
sid = created["server_id"]
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"auth_type": ""},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "auth_type" in r.json()["error"].lower()
|
|
|
|
def test_create_empty_auth_type_rejected(self, client):
|
|
"""Empty-string auth_type on create is rejected too."""
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "empty-auth-create",
|
|
"transport": "stdio",
|
|
"command": "x",
|
|
"auth_type": "",
|
|
},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "auth_type" in r.json()["error"].lower()
|
|
|
|
def test_update_auth_type_oauth_to_static_clears_oauth_fields(self, client):
|
|
"""Flipping auth_type away from oauth_user clears the oauth_* text
|
|
columns so a stale client_id / audience can't leak back."""
|
|
# Seed an oauth_user row with all fields populated.
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "flip-away",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_seed",
|
|
"oauth_scopes": "openid",
|
|
"oauth_audience": "https://mcp.example.com",
|
|
"oauth_registration_mode": "preregistered",
|
|
"oauth_authorization_server_url": "https://auth.example.com",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
sid = r.json()["server_id"]
|
|
|
|
# Flip to static — server should clear all oauth_* text fields.
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"auth_type": "static"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert data["auth_type"] == "static"
|
|
assert data["oauth_client_id"] is None
|
|
assert data["oauth_scopes"] is None
|
|
assert data["oauth_audience"] is None
|
|
assert data["oauth_registration_mode"] is None
|
|
assert data["oauth_authorization_server_url"] is None
|
|
|
|
def test_update_auth_type_oauth_to_none_clears_oauth_fields(self, client):
|
|
"""Same clear behavior when flipping to 'none'."""
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "flip-to-none",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_seed2",
|
|
"oauth_audience": "https://mcp.example.com",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
sid = r.json()["server_id"]
|
|
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"auth_type": "none"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert data["auth_type"] == "none"
|
|
assert data["oauth_client_id"] is None
|
|
assert data["oauth_audience"] is None
|
|
|
|
def test_admin_update_oauth_server_503_does_not_partial_write(
|
|
self, client_no_token_store, storage
|
|
):
|
|
"""bug-2: a 503 from the token-store gate during PUT must leave the
|
|
existing row unchanged — no partial column rewrites persist."""
|
|
# Seed a static row directly via storage so no token store is needed.
|
|
storage.create_mcp_server(
|
|
server_id="srv-pre",
|
|
name="pre-existing",
|
|
transport="streamable-http",
|
|
url="https://orig.example.com/sse",
|
|
auth_type="static",
|
|
)
|
|
before = storage.get_mcp_server("srv-pre")
|
|
assert before is not None
|
|
|
|
# Try to flip to oauth_user with a secret while token store is None.
|
|
r = client_no_token_store.put(
|
|
"/v1/api/admin/mcp-servers/srv-pre",
|
|
json={
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_partial",
|
|
"oauth_client_secret": "would-be-written",
|
|
"url": "https://changed.example.com/sse",
|
|
},
|
|
)
|
|
assert r.status_code == 503, r.text
|
|
|
|
# Row must be unchanged — no partial column rewrites.
|
|
after = storage.get_mcp_server("srv-pre")
|
|
assert after is not None
|
|
assert after["auth_type"] == "static"
|
|
assert after["url"] == "https://orig.example.com/sse"
|
|
assert after.get("oauth_client_id") in (None, "")
|
|
|
|
def test_admin_update_rejects_non_string_secret(self, client):
|
|
"""bug-3 (update path): non-string ``oauth_client_secret`` -> 400."""
|
|
# Seed an oauth_user row.
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "oauth-update-bad-secret",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
sid = r.json()["server_id"]
|
|
|
|
for bad in (False, 0, [], {}):
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"oauth_client_secret": bad},
|
|
)
|
|
assert r.status_code == 400, f"payload={bad!r} got {r.status_code}: {r.text}"
|
|
assert "oauth_client_secret" in r.json()["error"]
|
|
|
|
def test_auth_type_transition_clears_oauth_client_secret_ct(self, client, storage):
|
|
"""sec-2: flipping auth_type from oauth_user away (to static or none)
|
|
must clear the encrypted client secret column. Otherwise the stale
|
|
ciphertext would resurface if the row were flipped back."""
|
|
# Seed an oauth_user row WITH a client secret persisted.
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={
|
|
"name": "transition-clears-secret",
|
|
"transport": "streamable-http",
|
|
"url": "https://mcp.example.com/sse",
|
|
"auth_type": "oauth_user",
|
|
"oauth_client_id": "cli_xx",
|
|
"oauth_client_secret": "stays-until-transition",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
sid = r.json()["server_id"]
|
|
# Confirm the ciphertext column is populated before the transition.
|
|
seeded = storage.get_mcp_server(sid)
|
|
assert seeded is not None
|
|
assert seeded.get("oauth_client_secret_ct") is not None
|
|
|
|
# Flip to static — column must be cleared.
|
|
r = client.put(
|
|
f"/v1/api/admin/mcp-servers/{sid}",
|
|
json={"auth_type": "static"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
cleared = storage.get_mcp_server(sid)
|
|
assert cleared is not None
|
|
assert cleared["auth_type"] == "static"
|
|
assert cleared.get("oauth_client_secret_ct") is None
|
|
|
|
def test_auth_type_transition_clears_secret_without_token_store(
|
|
self, client_no_token_store, storage
|
|
):
|
|
"""sec-2: when no encryption key is configured the transition still
|
|
clears the column via a direct storage call — the operator's
|
|
mental model holds even with the cipher disabled at runtime."""
|
|
# Seed an oauth_user row with raw ciphertext bytes via storage so the
|
|
# transition has something to clear.
|
|
storage.create_mcp_server(
|
|
server_id="srv-direct-clear",
|
|
name="direct-clear",
|
|
transport="streamable-http",
|
|
url="https://mcp.example.com/sse",
|
|
auth_type="oauth_user",
|
|
oauth_client_id="cli_direct",
|
|
)
|
|
# Plant ciphertext via the dedicated writer (no cipher needed).
|
|
storage.set_mcp_oauth_client_secret_ct("srv-direct-clear", b"opaque-bytes")
|
|
seeded = storage.get_mcp_server("srv-direct-clear")
|
|
assert seeded is not None
|
|
assert seeded.get("oauth_client_secret_ct") is not None
|
|
|
|
r = client_no_token_store.put(
|
|
"/v1/api/admin/mcp-servers/srv-direct-clear",
|
|
json={"auth_type": "none"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
cleared = storage.get_mcp_server("srv-direct-clear")
|
|
assert cleared is not None
|
|
assert cleared["auth_type"] == "none"
|
|
assert cleared.get("oauth_client_secret_ct") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteMcpServer:
|
|
def test_delete_existing(self, client):
|
|
created = _create_server(client, name="del-test")
|
|
sid = created["server_id"]
|
|
r = client.delete(f"/v1/api/admin/mcp-servers/{sid}")
|
|
assert r.status_code == 200
|
|
assert r.json()["status"] == "ok"
|
|
|
|
# Confirm it's gone
|
|
with _PATCH_MCP_STATUS:
|
|
r2 = client.get(f"/v1/api/admin/mcp-servers/{sid}")
|
|
assert r2.status_code == 404
|
|
|
|
def test_delete_not_found(self, client):
|
|
fake_id = uuid.uuid4().hex
|
|
r = client.delete(f"/v1/api/admin/mcp-servers/{fake_id}")
|
|
assert r.status_code == 404
|
|
assert "not found" in r.json()["error"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secret masking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSecretMasking:
|
|
def test_list_masks_secrets(self, client):
|
|
_create_server(
|
|
client,
|
|
name="secret-test",
|
|
env={"API_KEY": "sk-real-secret-123"},
|
|
headers={"Authorization": "Bearer tok-xyz"},
|
|
transport="streamable-http",
|
|
url="http://localhost/mcp",
|
|
)
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get("/v1/api/admin/mcp-servers")
|
|
assert r.status_code == 200
|
|
server = r.json()["servers"][0]
|
|
env = json.loads(server["env"])
|
|
headers = json.loads(server["headers"])
|
|
assert env["API_KEY"] == "***"
|
|
assert headers["Authorization"] == "***"
|
|
|
|
def test_list_reveals_secrets(self, client):
|
|
_create_server(
|
|
client,
|
|
name="reveal-test",
|
|
env={"API_KEY": "sk-real-secret-123"},
|
|
headers={"Authorization": "Bearer tok-xyz"},
|
|
transport="streamable-http",
|
|
url="http://localhost/mcp",
|
|
)
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get("/v1/api/admin/mcp-servers?reveal=true")
|
|
assert r.status_code == 200
|
|
server = r.json()["servers"][0]
|
|
env = json.loads(server["env"])
|
|
headers = json.loads(server["headers"])
|
|
assert env["API_KEY"] == "sk-real-secret-123"
|
|
assert headers["Authorization"] == "Bearer tok-xyz"
|
|
|
|
def test_get_masks_secrets_by_default(self, client):
|
|
created = _create_server(
|
|
client,
|
|
name="mask-get-test",
|
|
env={"SECRET": "value"},
|
|
)
|
|
sid = created["server_id"]
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get(f"/v1/api/admin/mcp-servers/{sid}")
|
|
assert r.status_code == 200
|
|
env = json.loads(r.json()["env"])
|
|
assert env["SECRET"] == "***"
|
|
|
|
def test_get_reveals_secrets(self, client):
|
|
created = _create_server(
|
|
client,
|
|
name="reveal-get-test",
|
|
env={"SECRET": "real-value"},
|
|
)
|
|
sid = created["server_id"]
|
|
with _PATCH_MCP_STATUS:
|
|
r = client.get(f"/v1/api/admin/mcp-servers/{sid}?reveal=true")
|
|
assert r.status_code == 200
|
|
env = json.loads(r.json()["env"])
|
|
assert env["SECRET"] == "real-value"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Import
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestImportMcpConfig:
|
|
def test_import_inline_config(self, client):
|
|
config = {
|
|
"mcpServers": {
|
|
"filesystem": {
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
},
|
|
"remote": {
|
|
"url": "http://remote.example.com/mcp",
|
|
},
|
|
},
|
|
}
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers/import",
|
|
json={"config": config},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert "filesystem" in data["imported"]
|
|
assert "remote" in data["imported"]
|
|
assert data["skipped"] == []
|
|
assert data["errors"] == []
|
|
|
|
def test_import_not_a_dict(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers/import",
|
|
json={"config": "not-a-dict"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
def test_import_skips_duplicates(self, client):
|
|
_create_server(client, name="existing-srv")
|
|
config = {
|
|
"mcpServers": {
|
|
"existing-srv": {"command": "node", "args": []},
|
|
"new-srv": {"command": "node", "args": []},
|
|
},
|
|
}
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers/import",
|
|
json={"config": config},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert "new-srv" in data["imported"]
|
|
assert "existing-srv" in data["skipped"]
|
|
|
|
def test_import_empty_body(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers/import",
|
|
json={},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "config" in r.json()["error"].lower()
|
|
|
|
def test_import_no_mcp_servers_key(self, client):
|
|
r = client.post(
|
|
"/v1/api/admin/mcp-servers/import",
|
|
json={"config": {"other": "data"}},
|
|
)
|
|
assert r.status_code == 400
|
|
assert "mcpServers" in r.json()["error"] or "No" in r.json()["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Permission check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPermission:
|
|
def test_list_without_permission(self, client_no_perm):
|
|
with _PATCH_MCP_STATUS:
|
|
r = client_no_perm.get("/v1/api/admin/mcp-servers")
|
|
assert r.status_code == 403
|
|
assert "admin.mcp" in r.json()["error"]
|
|
|
|
def test_create_without_permission(self, client_no_perm):
|
|
r = client_no_perm.post(
|
|
"/v1/api/admin/mcp-servers",
|
|
json={"name": "test", "transport": "stdio", "command": "x"},
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
def test_delete_without_permission(self, client_no_perm):
|
|
r = client_no_perm.delete(f"/v1/api/admin/mcp-servers/{uuid.uuid4().hex}")
|
|
assert r.status_code == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit tests for _collect_mcp_status / _notify_nodes_mcp_reload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_request(*nodes: dict[str, Any], proxy_client: Any = None) -> MagicMock:
|
|
"""Build a minimal mock request with collector and proxy_client."""
|
|
collector = MagicMock()
|
|
collector.get_nodes.return_value = (list(nodes), len(nodes))
|
|
collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0]
|
|
req = MagicMock()
|
|
req.state.auth_result = None
|
|
req.app.state.collector = collector
|
|
req.app.state.jwt_secret = ""
|
|
req.app.state.proxy_client = proxy_client or AsyncMock()
|
|
req.app.state.proxy_token_mgr = None
|
|
req.app.state.proxy_auth_token = "tok"
|
|
return req
|
|
|
|
|
|
def _mock_resp(status_code: int = 200, json_data: Any = None) -> MagicMock:
|
|
"""Build a mock httpx response (sync .json(), like the real thing)."""
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.json.return_value = json_data or {}
|
|
return resp
|
|
|
|
|
|
class TestCollectMcpStatus:
|
|
@pytest.mark.anyio
|
|
async def test_returns_servers_on_200(self):
|
|
resp = _mock_resp(200, {"servers": {"s1": {"status": "ok"}}})
|
|
client = AsyncMock()
|
|
client.get.return_value = resp
|
|
req = _fake_request(
|
|
{"node_id": "n1", "server_url": "http://n1:8000"},
|
|
proxy_client=client,
|
|
)
|
|
result = await _collect_mcp_status(req)
|
|
assert result == {"n1": {"s1": {"status": "ok"}}}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_skips_non_200(self):
|
|
client = AsyncMock()
|
|
client.get.return_value = _mock_resp(503)
|
|
req = _fake_request(
|
|
{"node_id": "n1", "server_url": "http://n1:8000"},
|
|
proxy_client=client,
|
|
)
|
|
result = await _collect_mcp_status(req)
|
|
assert result == {}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_skips_nodes_without_url(self):
|
|
client = AsyncMock()
|
|
req = _fake_request(
|
|
{"node_id": "n1", "server_url": ""},
|
|
{"node_id": "n2"},
|
|
proxy_client=client,
|
|
)
|
|
result = await _collect_mcp_status(req)
|
|
assert result == {}
|
|
client.get.assert_not_called()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_handles_exception(self):
|
|
client = AsyncMock()
|
|
client.get.side_effect = ConnectionError("refused")
|
|
req = _fake_request(
|
|
{"node_id": "n1", "server_url": "http://n1:8000"},
|
|
proxy_client=client,
|
|
)
|
|
result = await _collect_mcp_status(req)
|
|
assert result == {}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_empty_cluster(self):
|
|
req = _fake_request()
|
|
result = await _collect_mcp_status(req)
|
|
assert result == {}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_multiple_nodes_mixed(self):
|
|
ok_resp = _mock_resp(200, {"servers": {"s1": {"status": "ok"}}})
|
|
err_resp = _mock_resp(500)
|
|
|
|
client = AsyncMock()
|
|
client.get.side_effect = [ok_resp, ConnectionError("down"), err_resp]
|
|
req = _fake_request(
|
|
{"node_id": "n1", "server_url": "http://n1:8000"},
|
|
{"node_id": "n2", "server_url": "http://n2:8000"},
|
|
{"node_id": "n3", "server_url": "http://n3:8000"},
|
|
proxy_client=client,
|
|
)
|
|
result = await _collect_mcp_status(req)
|
|
assert result == {"n1": {"s1": {"status": "ok"}}}
|
|
|
|
|
|
class TestNotifyNodesMcpReload:
|
|
@pytest.mark.anyio
|
|
async def test_returns_json_on_success(self):
|
|
client = AsyncMock()
|
|
client.post.return_value = _mock_resp(200, {"reloaded": 3})
|
|
req = _fake_request(
|
|
{"node_id": "n1", "server_url": "http://n1:8000"},
|
|
proxy_client=client,
|
|
)
|
|
result = await _notify_nodes_mcp_reload(req)
|
|
assert result == {"n1": {"reloaded": 3}}
|
|
|
|
@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_reload(req)
|
|
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_reload(req)
|
|
assert "n1" in result
|
|
assert "error" in result["n1"]
|
|
assert "refused" in result["n1"]["error"]
|
|
|
|
@pytest.mark.anyio
|
|
async def test_empty_cluster(self):
|
|
req = _fake_request()
|
|
result = await _notify_nodes_mcp_reload(req)
|
|
assert result == {}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_multiple_nodes_mixed(self):
|
|
client = AsyncMock()
|
|
client.post.side_effect = [
|
|
_mock_resp(200, {"reloaded": 2}),
|
|
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_reload(req)
|
|
assert result["n1"] == {"reloaded": 2}
|
|
assert "error" in result["n2"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Console reload endpoint: POST /v1/api/admin/mcp-servers/reload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAdminMcpReloadEndpoint:
|
|
"""HTTP-level tests for the console reload endpoint."""
|
|
|
|
def test_reload_success(self, client: TestClient) -> None:
|
|
"""Reload endpoint returns status ok and fan-out results."""
|
|
with patch(
|
|
"turnstone.console.server._notify_nodes_mcp_reload",
|
|
new_callable=AsyncMock,
|
|
return_value={"n1": {"reloaded": 3}},
|
|
):
|
|
r = client.post("/v1/api/admin/mcp-servers/reload")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["status"] == "ok"
|
|
assert data["results"] == {"n1": {"reloaded": 3}}
|
|
|
|
def test_reload_empty_cluster(self, client: TestClient) -> None:
|
|
"""Reload with no nodes returns empty results."""
|
|
with patch(
|
|
"turnstone.console.server._notify_nodes_mcp_reload",
|
|
new_callable=AsyncMock,
|
|
return_value={},
|
|
):
|
|
r = client.post("/v1/api/admin/mcp-servers/reload")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["status"] == "ok"
|
|
assert data["results"] == {}
|
|
|
|
def test_reload_permission_denied(self, client_no_perm: TestClient) -> None:
|
|
"""Reload without admin.mcp permission is rejected."""
|
|
r = client_no_perm.post("/v1/api/admin/mcp-servers/reload")
|
|
assert r.status_code == 403
|
|
assert "admin.mcp" in r.json()["error"]
|
|
|
|
def test_reload_no_storage(self) -> None:
|
|
"""Reload returns 503 when auth_storage is not available."""
|
|
app = Starlette(
|
|
routes=_ROUTES,
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
# Deliberately omit app.state.auth_storage
|
|
no_storage_client = TestClient(app, raise_server_exceptions=False)
|
|
r = no_storage_client.post("/v1/api/admin/mcp-servers/reload")
|
|
assert r.status_code == 503
|
|
|
|
def test_reload_mixed_node_results(self, client: TestClient) -> None:
|
|
"""Reload propagates per-node errors in results."""
|
|
with patch(
|
|
"turnstone.console.server._notify_nodes_mcp_reload",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"n1": {"reloaded": 2},
|
|
"n2": {"error": "Connection refused"},
|
|
},
|
|
):
|
|
r = client.post("/v1/api/admin/mcp-servers/reload")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["results"]["n1"] == {"reloaded": 2}
|
|
assert "error" in data["results"]["n2"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node reload endpoint: POST /v1/api/_internal/mcp-reload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInternalMcpReloadEndpoint:
|
|
"""HTTP-level tests for the node-side MCP reload endpoint."""
|
|
|
|
@pytest.fixture()
|
|
def node_client(self, storage: SQLiteBackend) -> TestClient:
|
|
"""TestClient with an MCP client manager on app.state."""
|
|
app = Starlette(
|
|
routes=_routes_with_internal(),
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
mgr = MagicMock()
|
|
mgr.reconcile_sync.return_value = {
|
|
"added": ["new-srv"],
|
|
"removed": [],
|
|
"updated": [],
|
|
}
|
|
app.state.mcp_client = mgr
|
|
return TestClient(app, raise_server_exceptions=False)
|
|
|
|
def test_reload_calls_reconcile(self, node_client: TestClient, storage: SQLiteBackend) -> None:
|
|
"""Reload endpoint calls reconcile_sync and returns its result."""
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=storage):
|
|
r = node_client.post("/v1/api/_internal/mcp-reload")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["status"] == "ok"
|
|
assert data["added"] == ["new-srv"]
|
|
assert data["removed"] == []
|
|
assert data["updated"] == []
|
|
|
|
def test_reload_passes_storage_to_reconcile(
|
|
self,
|
|
storage: SQLiteBackend,
|
|
) -> None:
|
|
"""Verify reconcile_sync receives the storage backend."""
|
|
app = Starlette(
|
|
routes=_routes_with_internal(),
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
mgr = MagicMock()
|
|
mgr.reconcile_sync.return_value = {"added": [], "removed": [], "updated": []}
|
|
app.state.mcp_client = mgr
|
|
c = TestClient(app, raise_server_exceptions=False)
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=storage):
|
|
r = c.post("/v1/api/_internal/mcp-reload")
|
|
assert r.status_code == 200
|
|
mgr.reconcile_sync.assert_called_once_with(storage)
|
|
|
|
def test_reload_creates_manager_when_missing(self, storage: SQLiteBackend) -> None:
|
|
"""When mcp_client is absent, a new MCPClientManager is created."""
|
|
app = Starlette(
|
|
routes=_routes_with_internal(),
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
# No mcp_client on app.state
|
|
c = TestClient(app, raise_server_exceptions=False)
|
|
with (
|
|
patch("turnstone.core.storage._registry.get_storage", return_value=storage),
|
|
patch("turnstone.core.mcp_client.MCPClientManager") as mock_cls,
|
|
):
|
|
mock_mgr = MagicMock()
|
|
mock_mgr.reconcile_sync.return_value = {
|
|
"added": [],
|
|
"removed": [],
|
|
"updated": [],
|
|
}
|
|
mock_cls.return_value = mock_mgr
|
|
r = c.post("/v1/api/_internal/mcp-reload")
|
|
assert r.status_code == 200
|
|
mock_cls.assert_called_once_with({})
|
|
mock_mgr.start.assert_called_once()
|
|
mock_mgr.reconcile_sync.assert_called_once_with(storage)
|
|
|
|
def test_reload_reconcile_result_in_response(self, storage: SQLiteBackend) -> None:
|
|
"""Full reconcile result fields (added/removed/updated) appear in JSON."""
|
|
app = Starlette(
|
|
routes=_routes_with_internal(),
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
mgr = MagicMock()
|
|
mgr.reconcile_sync.return_value = {
|
|
"added": ["a"],
|
|
"removed": ["b"],
|
|
"updated": ["c"],
|
|
}
|
|
app.state.mcp_client = mgr
|
|
c = TestClient(app, raise_server_exceptions=False)
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=storage):
|
|
r = c.post("/v1/api/_internal/mcp-reload")
|
|
data = r.json()
|
|
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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Node fan-out status endpoint: GET /v1/api/_internal/mcp-status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInternalMcpStatusEndpoint:
|
|
"""HTTP-level tests for the node-side aggregate status endpoint.
|
|
|
|
The endpoint falls through to ``read`` scope (a deliberate choice
|
|
so dashboards can render status indicators for non-admin
|
|
operators). Because of that, the response must strip ``command``
|
|
(stdio argv) and ``url`` (remote MCP endpoint) — both admin-only
|
|
context — before it leaves the process.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def node_app_factory(self, storage: SQLiteBackend):
|
|
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_status_strips_command_url_and_error(self, node_app_factory) -> None:
|
|
mgr = MagicMock()
|
|
mgr.get_all_server_status.return_value = {
|
|
"srv-stdio": {
|
|
"connected": False,
|
|
"tools": 0,
|
|
"resources": 0,
|
|
"prompts": 0,
|
|
# Error text would carry the binary path even after
|
|
# ``command`` is stripped — read scope must not see it.
|
|
"error": "FileNotFoundError: [Errno 2] No such file or "
|
|
"directory: '/usr/local/bin/secret-mcp-bin'",
|
|
"transport": "stdio",
|
|
"command": ["/usr/local/bin/secret-mcp-bin", "--token", "abc"],
|
|
"url": "",
|
|
"circuit_open": True,
|
|
"consecutive_failures": 5,
|
|
},
|
|
"srv-http": {
|
|
"connected": True,
|
|
"tools": 1,
|
|
"resources": 1,
|
|
"prompts": 0,
|
|
"error": "",
|
|
"transport": "streamable-http",
|
|
"command": "",
|
|
"url": "https://internal-mcp.example/mcp",
|
|
"circuit_open": False,
|
|
"consecutive_failures": 0,
|
|
},
|
|
}
|
|
c = node_app_factory(mgr)
|
|
r = c.get("/v1/api/_internal/mcp-status")
|
|
assert r.status_code == 200
|
|
servers = r.json()["servers"]
|
|
assert set(servers) == {"srv-stdio", "srv-http"}
|
|
for entry in servers.values():
|
|
assert "command" not in entry
|
|
assert "url" not in entry
|
|
# ``error`` text is replaced by ``has_error`` boolean so a
|
|
# FileNotFoundError binary path or httpx URL cannot leak
|
|
# through verbose exception messages at read scope.
|
|
assert "error" not in entry
|
|
assert "has_error" in entry
|
|
# Coarse error indicator preserved.
|
|
assert servers["srv-stdio"]["has_error"] is True
|
|
assert servers["srv-http"]["has_error"] is False
|
|
# Operational fields preserved.
|
|
assert servers["srv-http"]["tools"] == 1
|
|
assert servers["srv-http"]["transport"] == "streamable-http"
|
|
assert servers["srv-stdio"]["circuit_open"] is True
|
|
# No leaked binary path anywhere in the rendered response.
|
|
assert "secret-mcp-bin" not in r.text
|
|
|
|
def test_status_no_mcp_client_returns_empty_servers(self, node_app_factory) -> None:
|
|
c = node_app_factory(None)
|
|
r = c.get("/v1/api/_internal/mcp-status")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"servers": {}}
|