Skip to main content

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