feat: authenticated MCP support — built-in MCP server + per-user SSO tokens for external MCP servers#588
Open
hassan11196 wants to merge 8 commits into
Open
Conversation
…tokens for external MCP servers Two complementary capabilities: Built-in MCP server (services.mcp_server.enabled): - /mcp/sse endpoint exposing archi tools (query, document discovery, metadata search, content grep, chunk inspection, corpus stats, deployment info, agent specs) over MCP SSE - OAuth2 authorization-code flow with PKCE for MCP clients (Claude Desktop, Claude Code, VS Code, Cursor): RFC 8414 discovery, RFC 7591 dynamic client registration, RFC 8707 protected-resource metadata, bearer-token auth backed by mcp_tokens - /mcp/auth token management page (SSO-gated) Authenticated external MCP servers (mcp_servers.<name>.sso_auth): - per-user OAuth tokens stored encrypted in Postgres (mcp_oauth_tokens), acquired via an authorization-code + PKCE flow brokered by the chat app (/mcp/authorize, /mcp/callback) after SSO login - agent toolset injects fresh Bearer tokens per request; sso_auth servers with no valid token are skipped gracefully - SSOTokenService persists chat-app SSO access/refresh tokens so MCP auth survives Flask session expiry - CERN CA bundle support for SSE/streamable_http transports and an OpenAI 128-tool-limit guard Schema changes are in init.sql for fresh deploys and mirrored in ConfigService for live-database upgrades.
Server side: - oauth_authorize: validate client_id + redirect_uri against the registered client (RFC 6749 §4.1.2.1) — prevents authorization-code exfiltration via attacker-controlled redirect URIs, which PKCE alone does not stop - require MCP bearer tokens whenever chat-app auth is enabled, not only when SSO is enabled (basic-auth deployments were left fully open) - consume authorization codes even when PKCE/redirect checks fail - stop logging active MCP session ids on unknown-session requests - drop the 5-hour advertised client_timeout default from archi_query's schema (resource-exhaustion lever for the dispatch pool) - mcp_auth page: correct the claim that token rotation leaves OAuth sessions untouched Client side: - key broker tokens by the SSO subject id (users.id FK target, the same identifier chat requests pass) instead of preferred_username — on IdPs where sub != preferred_username the store violated the FK and lookups always missed - surface token-store failures (store_user_token returns bool) and guard the post-login authorize chain with a per-session attempted set — a failing store no longer traps users in an infinite redirect loop - build request-scoped agents when per-user MCP tokens are in play: the agent carrying user A's Bearer headers is kept on a thread-local, never published on the shared pipeline where a concurrent request could execute tools with A's credentials - fix the OpenAI 128-tool truncation to actually preserve extra tools (it was slicing MCP tools into their positions) - validate 'next' redirect targets (relative paths only) - preserve a pending sso_next redirect through the MCP authorize chain - silent token refresh no longer slides the hard session window - invalidate stored chat-app SSO tokens on logout - warn when falling back from BYOK_ENCRYPTION_KEY to another secret - tolerate JSON null expires_in in token responses
With chat-app auth disabled the MCP endpoint is open, so the OAuth surface has no identity to bind tokens to — and /mcp/oauth/authorize crashed with a BuildError because no 'login' endpoint exists. Register the discovery/register/authorize/token/auth-page routes only when MCP bearer auth is actually required.
ae57f52 to
66d07f4
Compare
Add a third auth mode for external MCP servers alongside sso_auth: service_auth: true attaches a deployment-level static Bearer token read from secrets (token_secret, default <NAME>_MCP_TOKEN), for contexts that have no user identity and can never complete the per-user OAuth flow (mattermost bot, benchmarking, cron jobs, the startup tool registry). service_auth servers are deliberately skipped on user-scoped requests: logged-in chat keeps going through per-user sso_auth tokens, so actions on the remote stay attributed to the real user and a per-user twin of the same server (e.g. unified + unified_svc) never collides on tool names. Servers with a missing token secret are skipped gracefully. Also render service_auth/token_secret in the base-config template, which previously dropped unknown mcp_servers fields.
…and tools Settings gains an MCP Servers section showing every configured external MCP server with its connection state for the current user: active (with the expandable tool list), needs authorization (with a one-click Authorize link into /mcp/authorize), headless-only (service_auth servers on user-scoped requests), missing service token, or failed. Backed by GET /api/mcp/status (auth-required). The per-server auth gating in mcp.py is factored into _prepare_server_configs, shared between agent initialization and the new get_mcp_server_status so the panel reports exactly what the agent build would do.
Rework the Settings > MCP Servers list for large tool surfaces: - summary bar (server / active / tool / needs-auth counts) and a Refresh button that re-checks all servers on demand - per-server status dot + auth-mode tag alongside the state chip - per-tool read/write badges: /api/mcp/status now flags tools whose remote description carries the [WRITE OPERATION] marker, so the UI can warn which tools can mutate remote state (94/235 on the unified server) - in-list tool search + 'write only' filter, needed once a server exposes hundreds of tools - a notice when the combined tool count exceeds the 128-tool per-request limit, since beyond that the agent silently drops tools from a request
Consolidations (no behavior changes intended): - mcp_auth_schema.py: canonical DDL for the six auth tables, rendered into init.sql and executed by ConfigService._ensure_config_tables — the two copies had already drifted (upgraded deployments were missing idx_mcp_tokens_user and idx_mcp_auth_codes_expires) - env.get_token_encryption_key(): single resolution point (and warning) for the pgcrypto key, cached per process; both token services use it, and MCPOAuthService now honors session_lifetime_days instead of a hardcoded 30 days - env.ssl_verify(): one CA-bundle resolution honoring SSL_CERT_FILE (set by the ssl_cert_host/ssl_cert_file deploy option) instead of two hardcoded bundle paths - catalog_search.py: metadata-query grammar + grep helpers shared by the uploader and the MCP tools (were verbatim copies, already drifting); filter keys derive from the canonical catalog column map - mcp_sse: table-driven tools/call dispatch with a schema/handler parity check; agent listing reuses the canonical agent_spec loader; archi_search_document_content fetches metadata only for matching documents instead of one catalog query per scanned file - app.py: _next_mcp_auth_redirect owns the post-auth chaining loop guard for both callbacks; mcp_tokens fetch/create SQL has one owner; SSO token endpoint comes from the provider's OIDC discovery document (Keycloak URL shape kept only as fallback); dead imports and an unreachable hasattr guard removed - base-config template: mcp_servers entries pass through verbatim (tojson) instead of a field whitelist that silently dropped unknown keys; dead services.mcp_server.timeout removed - base_react: per-request agent rebuilds are skipped entirely when no configured server is user-scoped (sso_auth/service_auth); the 128-tool cap is a module constant exposed to the UI via /api/mcp/status (tool_limit) so the panel cannot drift from it - token services fall back to ConnectionPool.get_instance instead of building (and leaking) a fresh pool per token operation; MCP status listing queries servers concurrently; the write-operation marker is parsed server-side only and the UI trusts the write flag - chat.js uses API.fetchJson + CONFIG.ENDPOINTS (regains the shared 401-redirect); mcp_auth.html copy button reuses copySnippet
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds authenticated MCP support to A2rchi in three complementary directions:
1. Built-in MCP server (
services.mcp_server)A2rchi can expose itself as an MCP server over SSE, so MCP clients (Claude Desktop, Claude Code, VS Code, Cursor) can connect directly to a deployment:
/mcp/sseendpoint exposing 12 read-only tools:archi_query(full RAG/chat pipeline round-trip with conversation continuity), document discovery, metadata search, content grep, chunk inspection, corpus stats, deployment info, agent-spec inspection, and health check./.well-known/oauth-authorization-server), RFC 7591 dynamic client registration, RFC 8707 protected-resource metadata, and bearer tokens backed by themcp_tokenstable./mcp/authSSO-gated page for generating/rotating tokens manually, with setup tabs for Claude Desktop / Claude Code / VS Code.enabled: false(default) no/mcp/*routes are registered. With chat-app auth disabled, the endpoint is open (matches the rest of the app's auth model).2. Per-user tokens for external MCP servers (
mcp_servers.<name>.sso_auth)Agent MCP tool connections can now authenticate to external MCP servers on behalf of the logged-in user:
/mcp/authorize,/mcp/callback), with dynamic client registration and token refresh handled byMCPOAuthService.pgp_sym_encrypt) inmcp_oauth_tokens.sso_authservers with no valid token are skipped gracefully (the agent still runs with the remaining tools).SSOTokenServicepersists chat-app SSO access/refresh tokens (encrypted) so MCP auth outlives short-lived Flask sessions.ssl_cert_host/ssl_cert_fileconfig + compose mount, plus an httpx client factory) for TLS verification against internal servers.3. Service-account tokens for headless interfaces (
mcp_servers.<name>.service_auth)Some interfaces have no user session and can never complete a per-user OAuth flow — the Mattermost bot, benchmarking, cron/pipeline jobs, and the startup tool registry.
service_authattaches a deployment-level static Bearer token (read from secrets) for exactly those contexts:token_secret(default<NAME>_MCP_TOKEN); a server whose secret is unset is skipped gracefully.service_authservers are deliberately skipped on user-scoped requests: interactive chat always goes through the per-usersso_authpath, so actions on the remote stay attributed to the real user, and a per-user + service twin of the same server never collide on tool names.sso_authandservice_authare mutually exclusive per entry (both set →sso_authwins, with a warning).unified+ serviceunified_svc) keeps the privilege boundary visible in config.Note: in a deployment with
allow_anonymous: true, anonymous web users share the headless agent and would therefore seeservice_authtools — keep anonymous access off when configuring service servers.MCP Servers panel (Settings → MCP Servers)
A settings panel surfaces every configured external MCP server for the current user:
service_authon a user request), no service token, or failed — backed by a new auth-gatedGET /api/mcp/status./api/mcp/statusflags tools whose remote description carries the[WRITE OPERATION]marker, with an in-list search + "write only" filter so large tool surfaces stay navigable.The per-server auth gating in
mcp.pyis factored into a shared_prepare_server_configs, so the panel reports exactly what an agent build would do for the session.Screenshots
Before authorization — each server's state, with a one-click Authorize for the per-user server and headless-only service servers clearly marked:
After authorizing the per-user server — the tool list with per-tool read/write badges, an in-list filter + "write only" toggle, and the >128-tool truncation notice:
Dark theme:
Schema
New tables:
sso_tokens,mcp_oauth_clients,mcp_oauth_tokens(client side),mcp_tokens,mcp_auth_codes,mcp_registered_clients(server side). Defined ininit.sqlfor fresh deployments and mirrored inConfigServiceso existing deployments get them on upgrade without a manual migration. (service_authadds no schema — it reuses the remote'smcp_tokensand a deployment secret.)Testing
tests/unit/test_mcp_sse_tools.py(9 tests) covering the MCP SSE tool layer.initialize→tools/list(12 tools) →tools/callfor health/stats/deployment-info → fullarchi_queryLLM round-trip returning the expected answer plus a conversation id.sso_auth: trueserver with no stored token is skipped gracefully at agent init; an unreachable external server degrades gracefully without breaking the agent./mcp/authorize→ remote OAuth →/mcp/callbackstores an encrypted per-user token → the next authenticated message builds a request-scoped agent that loads the remote's tools.service_auth: a loopback service server (the deployment's own/mcp/sse, token seeded intomcp_tokens) loads its tools through the real client path on the headless/anonymous build; the same server is correctly skipped on a user-scoped build; a server whose token secret is unset is skipped gracefully./api/mcp/statusreturns correct per-server state and tool lists (includingwriteflags) for both the headless and user-scoped views.ConfigServicecreated all six new tables at startup.main(the same pre-existing environment-dependent failures occur on both).Security review (self-review, addressed in-branch)
The branch went through an adversarial security review before this PR was opened; the following were found and fixed here:
oauth_authorizenow validatesclient_id+redirect_uriagainst the registered client (RFC 6749 §4.1.2.1). Previously an attacker-crafted authorize link could exfiltrate a victim's authorization code to an arbitrary redirect URI (PKCE does not prevent this when the attacker supplies the code_challenge).users.idFK target and the id chat requests pass), notpreferred_username— on IdPs wheresub != preferred_usernamethe previous keying silently violated the FK and lookups never matched.BYOK_ENCRYPTION_KEY) can no longer trap users in an infinite login→authorize→callback redirect loop.nextredirect targets are restricted to relative paths; a pendingsso_nextredirect survives the MCP authorize chain; silent token refresh no longer extends the hard session window; stored chat-app SSO tokens are invalidated on logout; falling back fromBYOK_ENCRYPTION_KEYto another secret logs a prominent warning.Known limitations (candidates for follow-up)
sso_auth/service_authtokens can carry write privileges on the remote (the panel flags[WRITE OPERATION]tools), and the agent can invoke them from a chat prompt. A confirmation gate for write tools, and scoping service accounts to read-only remote roles, are recommended follow-ups.sso_authservers configured, each authenticated message re-initializes MCP connections (sequentialget_toolsper server) and recompiles the agent. A short-TTL per-user tool cache would remove most of this.resource(RFC 8707) orscopeparameter in the brokered authorize/token exchanges; discovery ignores URL paths for path-mounted servers. Remote-issuedmcp_tokensare long-lived and report noexpires_in, so the broker assumes a 1-hour lifetime and re-auths on expiry.sso_tokenscurrently has no consumer (stored for upcoming per-user Bearer use); rows are encrypted at rest and invalidated on logout.