mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-22 03:44:47 -06:00
1a1043c4df
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).
(cherry picked from commit 4db7d9c6cf)
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": {}}
|