mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
610513398b
Wires the structured-error envelopes produced by Phase 7b's pool
dispatcher (mcp_consent_required / mcp_insufficient_scope /
mcp_*_forbidden / mcp_token_undecryptable_key_unknown /
mcp_oauth_url_insecure) through to the user-facing dashboard, and
adds a per-user settings panel for managing MCP server consents.
Changes
- ``_dispatch_pool_sync`` and ``_dispatch_pool_resource_sync`` wrap
structured-error string returns as ``RuntimeError(json_str)`` via
``_is_structured_error()`` so the session-layer ``except Exception``
branch fires uniformly across tool / resource / prompt dispatchers
(the prompt path's ``isinstance(result, str)`` shortcut works only
because prompts return ``list[dict]`` on success). Without this,
the consent UX silently does not render for tool / resource calls.
- ``_structured_error`` extended with an optional ``consent_url``
field; ``_build_consent_url`` produces ``/v1/api/mcp/oauth/start``
query strings (path-relative; the dashboard appends ``return_url``
at click time). Wired to all 12 ``mcp_consent_required`` and the
``mcp_insufficient_scope`` emit sites.
- New endpoints ``GET /v1/api/mcp/oauth/connections`` and
``DELETE /v1/api/mcp/oauth/connections/{server_name}`` registered
on both ``turnstone-server`` and ``turnstone-console``. The DELETE
handler runs local delete + audit + 204 first, then schedules the
RFC 7009 upstream revoke as a fire-and-forget ``asyncio.create_task``
with strong-ref tracking via ``_revoke_upstream_tasks`` (mirrors
the ``_pg_refresh_drain_tasks`` pattern). Soft cap of 256 concurrent
in-flight revokes prevents pile-up under coordinated mass-revoke;
the audit detail records ``upstream_revoke_outcome`` as
``scheduled | no_refresh_token | no_http_client | shed_by_cap``.
- ``ASMetadata`` extended with ``revocation_endpoint`` parsed from
RFC 8414 metadata. ``revoke_token_at_as`` helper posts the form
body under ``asyncio.timeout`` (not ``asyncio.wait_for``) and
never raises; ``_attempt_upstream_revoke`` is wrapped in an outer
``try/except Exception`` so unhandled exceptions don't surface as
``Task exception was never retrieved``.
- ``/v1/api/mcp/oauth/start`` accepts an optional ``scopes=`` query
param; tokens are validated against RFC 6749 §3.3 grammar via
``is_valid_scope_token`` (promoted to ``mcp_http_parsers``),
capped at ``_MAX_INSUFFICIENT_SCOPE_REPORTED`` (32), and unioned
with the configured server scopes for the step-up consent flow.
- Storage primitive ``list_mcp_user_token_metadata_by_user`` projects
the metadata columns at the SQL boundary so ciphertext blobs never
cross the wire on the settings-list path. New
``MCPUserTokenMetadataRow`` TypedDict in ``_protocol.py``;
``MCPTokenStore.list_user_token_metadata`` re-types to the existing
``MCPUserTokenMetadata`` shape.
- Dashboard renderer (``app.js``): ``tryParseMcpError`` detects the
envelope shape on ``tool_result`` SSE events with ``is_error=True``
and ``buildMcpErrorEmbed`` renders an action card mirroring the
existing ``buildMediaEmbed`` pattern. Three categories: actionable
(consent_required / insufficient_scope) with a ``Connect`` button
that opens ``/v1/api/mcp/oauth/start`` in a popup with a scheme
guard, forbidden (mcp_*_forbidden) with a static notice, operator
(key-mismatch / url-insecure) with an operator-action notice.
- New gear button in the appbar opens an MCP-connections settings
modal driven by ``loadMcpConnections`` / ``confirmRevokeMcp``
(two-step revoke confirmation matching the existing delete-ws
pattern). Pending-consent badge tracks unresolved consent prompts
in this tab; cleared after the connections list returns. Console
proxy collision-checked: the IIFE only prepends a node-id pill to
``header.firstChild``, so the right-anchored gear button is safe.
Bearer-leak invariant
- No ``exc_info=True`` on any new path that can carry a chained
``httpx.Request`` (revoke handler, dispatch sites, exec sites).
The two pre-existing ``exc_info=True`` calls in
``_exec_read_resource`` / ``_exec_use_prompt`` were replaced with
structured-field logs as a Phase 8 sibling fix.
Tests
- 440 pytest passes on both Python 3.13 (.venv) and 3.11
(/tmp/venv311); ruff + mypy clean.
- 5 new test files: ``test_mcp_consent_url_sibling_audit`` (structural
gate that every ``code="mcp_consent_required"`` / ``mcp_insufficient_scope``
site carries ``consent_url=``), ``test_mcp_oauth_connections``,
``test_mcp_oauth_revoke``, ``test_mcp_token_store_metadata``,
``test_session_mcp_dispatch_error``.
- End-to-end regression coverage for the bug-1 sibling pattern:
``test_call_tool_sync_raises_on_structured_error_envelope``,
``test_read_resource_sync_raises_on_structured_error_envelope``,
``test_get_prompt_sync_raises_on_structured_error_envelope``, plus
``test_call_tool_sync_does_not_wrap_non_structured_string`` as the
defensive gate (only ``mcp_*`` envelopes are wrapped).
Hard invariants honored
- Static path byte-identical for ``auth_type ∈ {none, static}``: the
wrap fires only when the dispatcher returns a structured-mcp-error
string, which only happens on the oauth_user pool path.
- ``asyncio.timeout`` (not ``asyncio.wait_for``) on every new
AS / SDK / pool-loop await per Python 3.11 anyio cancel-scope
hazard.
- Scope cap ``_MAX_INSUFFICIENT_SCOPE_REPORTED = 32`` enforced at
every output / merge site.
- Cross-user isolation on the revoke endpoint: a non-owner DELETE
returns 404 with the same body shape as a never-existed row;
``http_client_mock.post.assert_not_called()`` pins this in 3 tests.
Deferred (not Phase 8 blockers)
- perf-2 (``asyncio.gather`` parallelisation in revoke handler) —
superseded by perf-1's fire-and-forget pattern.
- q-4 (prompt-path ``isinstance(str)`` vs sibling ``_is_structured_error``
asymmetry) — already documented in the function docstring.
- q-9 (``_pendingConsentServers`` → ``_serversNeedingConsent``
rename) — pure naming taste.
(cherry picked from commit 5a3f46a1fa)
108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
"""Tests for ``MCPTokenStore.list_user_token_metadata``.
|
|
|
|
Validates the non-secret projection used by the settings UI: ciphertext
|
|
columns are stripped, ordering is preserved, and the empty case returns
|
|
``[]``. Decrypt is intentionally skipped — the list view must never need
|
|
the access/refresh secrets.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
|
|
import sqlalchemy as sa
|
|
from cryptography.fernet import Fernet
|
|
|
|
from turnstone.core.mcp_crypto import (
|
|
MCPTokenCipher,
|
|
MCPTokenCipherConfig,
|
|
MCPTokenStore,
|
|
)
|
|
|
|
|
|
def _make_cipher() -> MCPTokenCipher:
|
|
raw = base64.urlsafe_b64decode(Fernet.generate_key())
|
|
return MCPTokenCipher(MCPTokenCipherConfig(keys=(raw,)))
|
|
|
|
|
|
def _make_store(backend) -> MCPTokenStore:
|
|
return MCPTokenStore(backend, _make_cipher(), node_id="test-node")
|
|
|
|
|
|
def _seed_token(
|
|
store: MCPTokenStore,
|
|
backend,
|
|
*,
|
|
user_id: str,
|
|
server_name: str,
|
|
created: str,
|
|
) -> None:
|
|
"""Create a token via the store and backdate ``created`` for ordering."""
|
|
store.create_user_token(
|
|
user_id,
|
|
server_name,
|
|
access_token="access-secret",
|
|
refresh_token="refresh-secret",
|
|
expires_at="2026-05-04T12:00:00",
|
|
scopes="openid profile",
|
|
as_issuer="https://auth.example.com",
|
|
audience="https://mcp.example.com",
|
|
)
|
|
with backend._engine.connect() as conn:
|
|
conn.execute(
|
|
sa.text(
|
|
"UPDATE mcp_user_tokens SET created = :created "
|
|
"WHERE user_id = :uid AND server_name = :sn"
|
|
),
|
|
{"created": created, "uid": user_id, "sn": server_name},
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
class TestListUserTokenMetadata:
|
|
def test_list_user_token_metadata_returns_non_secret_fields_only(self, backend) -> None:
|
|
store = _make_store(backend)
|
|
_seed_token(
|
|
store, backend, user_id="u1", server_name="srv-a", created="2026-05-01T00:00:00"
|
|
)
|
|
rows = store.list_user_token_metadata("u1")
|
|
assert len(rows) == 1
|
|
meta = rows[0]
|
|
# Secrets MUST be absent.
|
|
assert "access_token" not in meta
|
|
assert "refresh_token" not in meta
|
|
assert "access_token_ct" not in meta
|
|
assert "refresh_token_ct" not in meta
|
|
# Non-secret columns surface verbatim.
|
|
assert meta["user_id"] == "u1"
|
|
assert meta["server_name"] == "srv-a"
|
|
assert meta["scopes"] == "openid profile"
|
|
assert meta["as_issuer"] == "https://auth.example.com"
|
|
assert meta["audience"] == "https://mcp.example.com"
|
|
assert meta["expires_at"] == "2026-05-04T12:00:00"
|
|
assert meta["created"] == "2026-05-01T00:00:00"
|
|
assert meta["last_refreshed"] is None
|
|
|
|
def test_list_user_token_metadata_empty(self, backend) -> None:
|
|
store = _make_store(backend)
|
|
assert store.list_user_token_metadata("nobody") == []
|
|
|
|
def test_list_user_token_metadata_preserves_creation_order(self, backend) -> None:
|
|
store = _make_store(backend)
|
|
_seed_token(
|
|
store, backend, user_id="u1", server_name="srv-c", created="2026-05-03T00:00:00"
|
|
)
|
|
_seed_token(
|
|
store, backend, user_id="u1", server_name="srv-a", created="2026-05-01T00:00:00"
|
|
)
|
|
_seed_token(
|
|
store, backend, user_id="u1", server_name="srv-b", created="2026-05-02T00:00:00"
|
|
)
|
|
rows = store.list_user_token_metadata("u1")
|
|
assert [r["server_name"] for r in rows] == ["srv-a", "srv-b", "srv-c"]
|
|
assert [r["created"] for r in rows] == [
|
|
"2026-05-01T00:00:00",
|
|
"2026-05-02T00:00:00",
|
|
"2026-05-03T00:00:00",
|
|
]
|