Skip to main content
Version: main 🚧

Research: MCP Server Authentication + Caller-Provided Backend Keys

FastMCP Middleware Injection

Decision: Use Starlette ASGI middleware (starlette.middleware.Middleware) passed to mcp.run().

Rationale: FastMCP 3.2.3 exposes two middleware systems:

  1. fastmcp.server.middleware.Middleware — MCP-protocol-level (tool/resource hooks); does NOT intercept HTTP auth headers
  2. starlette.middleware.Middleware aliased as ASGIMiddleware in fastmcp/server/mixins/transport.py — HTTP transport level; correct for auth

mcp.run(transport=..., host=..., port=..., middleware=[Middleware(MCPAuthMiddleware)]) works because run()run_async()run_http_async(middleware=...) (confirmed via source inspection). This means we can add middleware to the existing mcp.run() call without switching to run_http_async().

Alternative considered: Per-server inline run_http_async() — rejected for being more verbose with no benefit; run() kwargs passthrough is confirmed.


JWT Validation (OAuth2 Mode)

Decision: Copy verify_token() logic and JwksCache class from ai_platform_engineering/utils/auth/ into the new mcp_agent_auth package.

Rationale: oauth2_middleware.py imports from a2a.types import AgentCard (line 7), making it impossible to import from MCP servers that don't carry a2a-sdk. However, verify_token() itself has zero dependency on AgentCard — it only uses jwt, JwksCache, and module-level config globals. JwksCache only requires requests (already available in all server envs). Copying these two artifacts (~80 lines total) avoids adding a2a-sdk as a dep.

Alternative considered: Import verify_token directly from utils — rejected because a2a.types import at module level poisons the import even if the class is not instantiated.


Per-Request Token Access

Decision: Use fastmcp.server.dependencies.get_http_request() inside tool functions.

Rationale: FastMCP stores the current Starlette Request in a ContextVar per async task. get_http_request() (at line 444 of fastmcp/server/dependencies.py) retrieves it. Raises LookupError or RuntimeError when called outside an HTTP request context (STDIO mode), making it safe to catch and fall back to env var.


Server Inventory

ServerEntry pointToken env var(s)Auth header typeclient.py raises ValueError?Notes
argocdserver.pyARGOCD_API_TOKEN / ARGOCD_TOKENBearerYesStandard pattern
backstageserver.pyBACKSTAGE_TOKEN / BACKSTAGE_API_TOKENBearerYesStandard pattern
confluenceserver.pyATLASSIAN_TOKEN (+ ATLASSIAN_EMAIL)Basic (email:token)No (warns)Email stays in env; token replaces password
jiraserver.pyATLASSIAN_TOKEN (+ ATLASSIAN_EMAIL)Basic (email:token)No (warns)Email stays in env; token replaces password
komodorserver.pyKOMODOR_TOKENX-API-KEY headerYesNot Bearer; caller token maps to X-API-KEY
netutilsserver.pyNoneN/AN/ANo client.py; only server.py needs middleware
pagerdutyserver.pyPAGERDUTY_API_KEYBearerNoNo startup raise; DEFAULT_API_KEY may be None
splunkserver.pySPLUNK_TOKENBearerYesStandard pattern
victoropsserver.pyX_VO_API_KEY + X_VO_API_IDCustom headersYesMulti-org via VICTOROPS_ORGS; only single-org updated
webex__init__.pyWEBEX_TOKEN (click required=True)Bearerclick raisesauth_token closure in all 8+ tools; must switch to per-request

Module-Level ValueError Problem

Decision: In HTTP/shared_key mode, backend tokens are expected from the caller — not the environment. Remove raise ValueError for token-only env vars; keep raises for URL env vars (which are always required).

Rationale: Servers currently fail at import time if their backend token env var is absent (e.g., ARGOCD_API_TOKEN). In HTTP mode with caller-provided tokens, the env var is intentionally absent. The module-level raise prevents the server from starting. URL env vars (ARGOCD_API_URL, KOMODOR_API_URL, etc.) remain required since they can't come from the caller.

Pattern: Change raise ValueError("...token...") to logger.warning("...token..."). The token check already happens in make_api_request() where it returns an error dict if no token is available.


Webex Special Case

Decision: Make --auth-token click option required=False (default None). Change each tool function in mcp_server.py to call get_request_token("WEBEX_TOKEN") or auth_token_default at invocation time.

Rationale: Webex currently captures auth_token in a closure at tool registration time (register_tools(server, auth_token=auth_token)). The same token is used by all 8+ tool functions. In HTTP mode, the per-request token must be read at call time, not at registration time. The fallback to auth_token_default (the registered startup token) preserves backward compatibility for STDIO mode.


VictorOps Special Case

Decision: Update only the single-org path (X_VO_API_KEY). Multi-org path (VICTOROPS_ORGS) unchanged.

Rationale: Multi-org config embeds per-org api_key values directly in the JSON env var. Replacing these with a single caller-provided bearer token would break the multi-org routing logic. YAGNI — only update what the spec requires.


New Package Location

Decision: ai_platform_engineering/agents/common/mcp-auth/ with Python package name mcp_agent_auth.

Rationale: Consistent with other common/ utilities in the agents directory. All 10 MCP servers add it as a local path dependency in their pyproject.toml via [tool.uv.sources].


Env Vars

VarModeRequired whenDefault
MCP_AUTH_MODEAllNever (has default)none
MCP_SHARED_KEYshared_keyMCP_AUTH_MODE=shared_key
JWKS_URIoauth2MCP_AUTH_MODE=oauth2
AUDIENCEoauth2MCP_AUTH_MODE=oauth2
ISSUERoauth2MCP_AUTH_MODE=oauth2
OAUTH2_CLIENT_IDoauth2MCP_AUTH_MODE=oauth2
ALLOWED_ALGORITHMSoauth2Never (has default)RS256,ES256