Foundry Foundry

MCP Auth Server — Frictionless Remote MCP via a CIMD/DCR Authorization Server

Status: DRAFT for red-team / blue-team (next session). Created 2026-06-24. Ladders to North Star B3/B6 and the MCP-as-infrastructure wedge (decisions D56). Supersedes the archived MCP spike docs (projects/autri/archive/epics/epic-1-*, archive/functional-mvp "MCP over SSE + OAuth"). Research synthesized 2026-06-24 from a 7-subagent fan-out over the AWS Bedrock AgentCore docs + the MCP auth spec + client support; sources at the end.

Why this epic exists

Autri's strategic wedge is MCP-as-infrastructure — be the MCP server that Claude / Cursor / Copilot consume, not just a web chat. Exposing the retrieval surface as MCP tools is what makes Autri "make any AI better without retraining."

Already built and proven (May 2026 spike, validated end-to-end):

  • @autri/mcp-doc-search — the MCP tool surface over a KB (5 tools: vector_search, fts_search, lookup_section, list_knowledge_bases, get_document).
  • JWT auth (CognitoJwksAuth) + per-connector scope enforcement + audit trail.
  • Deployed on AWS Bedrock AgentCore Runtime (arm64 container).
  • Proven working end-to-end in Claude Desktop (manual 24-hour dev JWT via the mcp-remote shim; clean audit rows, no errors).

The one gap (why MCP was cut from beta — D56 / D41): frictionless auth. Users today must paste a hand-minted 24-hour JWT. Real MCP clients expect OAuth with client self-registration — and Cognito (our IdP) supports neither Dynamic Client Registration (DCR, RFC 7591) nor Client ID Metadata Documents (CIMD). That gap stalled the June attempt and pushed MCP to beta+1.

What changed since June (the unlock)

  1. The MCP auth spec moved (2025-11-25): the client-registration priority order is pre-registration → CIMD → DCR. CIMD is SHOULD and labeled "most common"; DCR is demoted to MAY "for backwards compatibility" — but not deprecated (grounding correction: the earlier draft of this doc overstated this). Crucially, Claude supports BOTH CIMD and DCR today, so DCR is a fully-sanctioned, production-ready path — which is exactly what lets us lead with it (see Decision).
  2. AgentCore provides the server-side OAuth half for free (the 401 + WWW-Authenticate + /.well-known/oauth-protected-resource discovery dance) and is IdP-agnostic — it validates JWTs against any OIDC discovery URL, including an authorization server we run. (Grounding: the inbound authorizer requires the …/.well-known/openid-configuration form plus at least one of allowedAudience/allowedClients; node-oidc-provider serves that discovery form natively, and the authorizer config is mutable post-create via UpdateAgentRuntime.)
  3. We shipped the consumable API + library-scoped keys (roadmap item 2) — the data + tenancy substrate the MCP tools sit on.

Decision: build our own minimal authorization server

Build a thin OAuth 2.1 authorization server (on node-oidc-provider — OpenID-certified; do not hand-roll the crypto) that:

  • Federates user login upstream to Cognito (the live app pool us-east-1_lSk6wYeDM) — users keep one identity; we never reimplement passwords / MFA / accounts.
  • Mints our own MCP access tokens (own JWKS; signing keys in Secrets Manager). Required for self-owned client registration — Cognito will not issue tokens for clients it didn't register.
  • DCR — the sole registration path (red-teamed 2026-06-24, blue-teamed same day). node-oidc-provider's Dynamic Client Registration (RFC 7591) is production-solid, certified, on by default, and Claude, Cursor, and Copilot all speak DCR today — so a single DCR path covers every target client at launch. Registrations live in our own store (a DynamoDB adapter with a TTL/cleanup), so the Cognito app-client quota wall doesn't apply.
  • CIMD — CUT (blue-team 2026-06-24). Out of scope; DCR is the only registration path for now. CIMD would add only spec-alignment (not client coverage — every target client speaks DCR today) and rests on node-oidc-provider's experimental, draft-01-pinned feature. Revisit on real demand. See projects/autri/requirements/mcp-auth-server.
  • Serves OAuth discovery + JWKS. Two validation layers: AgentCore's inbound JWT authorizer points at this server's OIDC discovery URL (…/.well-known/openid-configuration) with allowedAudience = the MCP resource; the in-container verifier re-checks the same aud. Our AS mints tokens with that aud (RFC 8707 audience binding), and the token carries connector_id only — read/write scope is resolved live per request, never baked into claims (red-team HIGH: the stale-grant fix).

Keep Cognito as the IdP; add this as the AS. DCR-only at launch; write tools arrive in Wave 2 (see Effort + sequencing).

IdP vs Authorization Server (why this split)

  • Identity Provider authenticates the user (Cognito: accounts, login, MFA) — fine as-is.
  • Authorization Server issues tokens and owns client registration (/authorize, /token, discovery, DCR). Cognito's AS half lacks DCR. We add an AS that does, delegating user auth back to Cognito.

Alternatives considered (rejected)

  • AWS-native AS — none exists. Cognito (no DCR/CIMD), IAM Identity Center (AWS-account-scoped SSO), Verified Permissions (policy engine, issues no tokens), API Gateway/Lambda (token validators). AWS's own documented answer to this gap is "build it yourself."
  • WorkOS AuthKit/Connect (our old beta+1 plan): best registration (DCR + CIMD) but cannot keep Cognito as the user source of truth — its model replaces Cognito (migration forces password resets) or treats it only as an org-scoped SSO connection. Fails the "one identity, keep Cognito" requirement. This corrects the D56 "beta+1 = WorkOS Connect" note.
  • Ory (Hydra self-host / Ory Network managed): clean "keep Cognito" fit (delegates login), DCR works today, but CIMD not yet (unscheduled). Retained as the low-ops fallback (~$70/mo + a small Claude DCR-compat proxy) if we decide not to run our own AS.
  • Cognito-app-client DCR shim (Lambda creates a Cognito app client per registration; keeps Cognito-issued tokens): cannot do CIMD, and hits Cognito's app-client quota (1,000 default / 10,000 hard cap, non-adjustable create rate) — needs a reaper. Rejected as the primary path.

Architecture (draft — refine in red-team)

Red-teamed + decisions locked 2026-06-24.

  • Runtime: Lambda + DynamoDB adapter (flips the original Fargate lean). An OAuth AS is stateful (auth codes, grants, sessions), but node-oidc-provider externalizes that through a storage adapter, so it runs on Lambda with a DynamoDB adapter and scales to zero (~$0 idle) — right for a pre-launch, ~1-user surface. Risk to smoke at milestone 0: node-oidc-provider has a heavy init, so confirm cold-start latency on the one-time interactive login is acceptable (this codebase has been bitten by Lambda cold-starts). Fargate/ECS is the fallback if cold-start hurts — and it's net-new infra either way (no ECS/ALB today).
  • Stack layout: extract the AgentCore Runtime into its own dedicated stack (keeps all MCP work off the drift-sensitive auth-and-compute stack permanently). Low-risk extraction: the Runtime is stateless (pulls from ECR) and MCP is cut from beta (no users depend on it, D56), so stand up a fresh Runtime in the new stack from the same image + the new authorizer, verify, then delete the old one — no fragile CloudFormation resource-import/move needed.
  • Two-layer token validation: (1) AgentCore inbound authorizer → discoveryUrl = our AS …/.well-known/openid-configuration, allowedAudience = the MCP resource; (2) in-container AuthVerifier → a new impl re-checking that aud. The current CognitoJwksAuth checks scope/client_id, not aud — so this is a new verifier impl (the auth interface is cleanly env-selected, so it's config + a small impl, not a refactor).
  • Tokens: RS256 JWTs with aud = MCP resource (RFC 8707 audience binding); JWKS published at the AS; signing keys in Secrets Manager (reuse the existing secret pattern — no customer-managed KMS today; the AWS-managed default is fine for beta).
  • CIMD fetch (fast-follow): GET the client metadata URL (200 only, no redirects), validate client_id == URL, redirect_uri ∈ doc, JSON shape; SSRF guard (block private/loopback, HTTPS-only, optional allow-list).
  • Login delegation: the AS acts as an OIDC client to Cognito (us-east-1_lSk6wYeDM); after Cognito authenticates the user, the AS mints the MCP token.
  • Consent: node-oidc-provider needs login + consent interaction views built — and the spec requires per-client user consent before forwarding (confused-deputy MUST). Real scope, not free.
  • MCP server: @autri/mcp-doc-search on AgentCore Runtime — swap its token validation from CognitoJwksAuth to the new AS verifier (env-selected); keep the tool surface + scope enforcement + audit.
  • Tenancy: map the authenticated user → library scope in the minted token's claims (reuse the library-scoped model — library_access → library_kbs).

What's reusable (do not rebuild)

  • The MCP tool surface + scope enforcement + audit (built, deployed on AgentCore Runtime). Grounding correction: it's 5 tools, not 4 — 3 shared core (vector_search, fts_search, lookup_section, defined once in @autri/retrieval so the chat / REST / MCP surfaces can't drift) + 2 MCP-only (list_knowledge_bases, get_document).
  • The AgentCore Runtime resource. ⚠️ Verify it's still live in autri-prod before relying on it — it's defined in the rarely-deployed auth stack, so source ≠ confirmed-deployed. (It also moves to its own stack — see Architecture.)
  • The consumable API + library-scoped keys (data + tenancy substrate; autri_sk_<keyId>.<secret>, library-scoped read / per-KB-allowlist write).
  • Cognito live pool us-east-1_lSk6wYeDM (federate to it). Clean up the stale us-east-1_7YgaDlZlB MCP-era pool — it's not in the CDK, so that's a console/CLI delete.
  • The existing Secrets Manager pattern for the AS signing keys (no new KMS infra needed).

Open questions / red-team targets

Red-teamed 2026-06-24 (fresh-agent grounding against the repo, live CDK, and upstream specs). Resolved items locked into the sections above; genuinely-open items (⬜) carry to build / blue-team.

Resolved:

  1. ✅ Registration sequencing → DCR-first, CIMD fast-follow. node-oidc-provider DCR is production-solid + Claude/Cursor/Copilot all speak it; its CIMD is experimental/draft-01.
  2. ✅ AS runtime → Lambda + DynamoDB adapter (scales to zero; Fargate is the cold-start fallback).
  3. ✅ Auth-stack touch → extract AgentCore to its own stack via fresh-Runtime-then-delete-old (stateless, no CFN import dance).
  4. ✅ AgentCore discovery URL → mutable on Runtime (UpdateAgentRuntime accepts authorizerConfiguration); must serve the OIDC openid-configuration form + set allowedAudience.
  5. ✅ Token-audience binding → mint aud = MCP resource (RFC 8707), validated at both layers (AgentCore authorizer + in-container verifier).
  6. ✅ Tenancy mapping → reuse library-scope; mint the library scope into the token claims.

Still open (build / blue-team):

  • ⬜ Milestone 0 = a real Claude DCR handshake (interactive smoke) BEFORE pouring infra. Claude silently falls back CIMD→DCR unless the AS advertises both client_id_metadata_document_supported and none, so prove the end-to-end flow first.
  • ⬜ Self-host MCP server vs AgentCore Gateway over /api/v1 — hosting model undecided (inbound auth routes through our AS either way).
  • ⬜ AgentCore Gateway discovery-URL mutability — confirmed for Runtime; Gateway unverified.
  • ⬜ node-oidc-provider CIMD draft reconciliation (draft-01 vs the spec's referenced draft) — for the fast-follow.
  • ⬜ Lambda cold-start of node-oidc-provider on the interactive flow — smoke before committing to Lambda.
  • ⬜ Consent-screen UX + confused-deputy per-client consent (spec MUST).
  • ⬜ Security hardening: SSRF on the CIMD metadata fetch, signing-key custody, redirect allow-listing.

Effort + sequencing

Blue-teamed 2026-06-24 → scope contract: projects/autri/requirements/mcp-auth-server. One epic, two waves, DCR-only (CIMD + static-bearer cut — see the requirements doc's Out-of-scope).

  • Milestone 0 (do first): a real Claude DCR handshake against a minimal AS — interactive smoke, the recurring lesson. Everything else is sized only after this proves out.
  • Wave 1 — AS + frictionless DCR auth + read surface. The wedge-unblocking core; carries the risky unknowns (real Claude handshake, node-oidc-provider, AgentCore JWT acceptance). Includes the token-connector_id-only design, two-layer aud validation, the AgentCore stack extraction, the read-path tenancy-leak fix, and filter_then_rank on the read surface. Read-side dogfood = recall over MCP.
  • Wave 2 — write surface (dev-memory dogfood). connector_write_kbs + hardened resolver, publish_document (replace-by-key on the immutable session id), Option-B direct-S3 integration (s3:PutObject IAM on the AgentCore role, route through the shared org-checked executor), audit extension, and wiring /stop to publish via MCP. v1 write = create/publish + supersede-own-by-key only; arbitrary replace/delete deferred.
  • The earlier ~1–1.5wk estimate is superseded — the write surface alone carries four HIGH red-team findings' worth of hardening. Size against the requirements doc's per-wave acceptance criteria, not a single number.
  • MCP is Autri-product leverage, not on the Brehob critical path — but it compounds into Brehob (the same hardened API + auth substrate powers dev-memory recall and QuoteAI's consumption of Autri). Being ahead on the Brehob substrate makes this the right window.

Stories — dependency-ordered (Wave 1 + Wave 2)

Refined 2026-06-24 from the requirements contract (projects/autri/requirements/mcp-auth-server). IDs are stable. → = depends on. Each story names its check: 🤖 mechanized (loop closes before review) / 🧑 human-smoke. Ship via /hl:ship wave-by-wave in the topo order at the end.

Wave 1 — AS + frictionless DCR + read surface

  • W1-S0 — Milestone-0 vertical-slice spike (THE GATE). ✅ GATE PASSED 2026-06-24 — real Claude Desktop completed DCR end-to-end against a throwaway node-oidc-provider AS + minimal MCP server and called a read tool; AgentCore Runtime confirmed LIVE in autri-prod (autri_mcp_doc_search-F2ukq3HHqR, READY, authorizer currently → Cognito, so mutability for S7 is confirmed). Throwaway spike on branch w1-s0-milestone-spike (draft PR #73, do-not-merge). → nothing. Unblocked S1+. 🧑 interactive handshake.
  • W1-S5 — Read-path tenancy fix. ✅ Shipped (9c934fb, PR #71). Joined library_access on (library_id, user_id) into getKbScopeForConnector (retrieval/src/scope.ts) so a revoked member's connector loses read access live; reads NOT gated on can_admin (that's the W2 write resolver's concern). Verified by scope tests (leak + non-admin cases) + ci. → nothing. 🤖 isolation test (scripts/probe-tenant-isolation.sh).
  • W1-S6 — filter_then_rank on the MCP surface. ✅ Shipped (3bfb2e8, PR #72). (The epic's original "~1 line, add to CORE_RETRIEVAL_TOOLS" premise was wrong — filter_then_rank is a per-KB factory buildFilterThenRankTool, deliberately excluded from the core set; it's registered per-connector in mcp-servers/doc-search/src/tools.ts, loading each in-scope KB's attribute schema, mirroring the v1/REST runFilterThenRank pattern. Now the 6th MCP tool.) → nothing. 🤖 unit + live retrieval call.
  • W1-S1 — node-oidc-provider AS (production form). DCR, Cognito federation, RS256 + own JWKS, signing keys in Secrets Manager, OIDC discovery at …/.well-known/openid-configuration, DynamoDB adapter, token carries connector_id only — resolved from the user-owned connector being authorized (selected via the connector-scoped MCP URL; ownership verified at consent), with aud kept to a single canonical MCP resource (so S7's AgentCore allowedAudience stays fixed). → S0. 🤖 conformance/integration tests.
  • W1-S3 — Login + consent interaction screens. node-oidc-provider interaction views: login delegation to Cognito + per-client consent (confused-deputy MUST), confirming the connector → library being granted and verifying the logged-in user owns that connector. → S1. 🧑.
  • W1-S4 — Token lifecycle. Short-lived access + refresh + revocation; scope resolved live per request, never from claims. → S1. 🤖 test: revoke → next request denied.
  • W1-S2 — AS runtime: Lambda + DynamoDB, deploy to autri-prod. Package for Lambda + the DynamoDB adapter; cold-start smoke of node-oidc init on the interactive flow; deploy. → S1. 🤖 cold-start measure · 🧑.
  • W1-S7 — AgentCore extraction + two-layer aud validation. Extract the AgentCore Runtime to its own stack (fresh-Runtime-then-delete-old; autri-infra/lib/auth-and-compute/agentcore.ts); set the inbound authorizer (discoveryUrl = our AS, allowedAudience = the MCP resource) via UpdateAgentRuntime; swap the in-container verifier CognitoJwksAuth → AS verifier checking aud (mcp-servers/doc-search/src/auth.ts buildAuthVerifier). → S2. 🤖 + 🧑.
  • W1-S9 — Connector management UI (Settings). Rebuild app/app/settings/connectors on the new model: create / list / rename / repoint-library / revoke a connector, and surface its unique MCP URL to paste into Claude. A connector is a user-owned, library-scoped, editable object; scope resolves live (W1-S5) so an edit hits an already-connected client with no client-side change. Retires the client-credentials/Cognito-app-client connector model (D34/D35, never wired) for the DCR path. → S1. 🧑. (Added 2026-06-24 — blue-team amendment: pre-create-in-Settings connector model.)
  • W1-S8 — Wave-1 integration + acceptance. E2E: create a connector in Settings (S9) → Claude → DCR → token → extracted MCP stack → all read tools (incl. filter_then_rank) scope-enforced; revoke connector / library_access (or repoint the connector) → next request reflects it; zero hand-minted JWTs. → S1, S2, S3, S4, S5, S6, S7, S9. 🧑 (the Wave-1 acceptance contract).

Wave 2 — write surface (dev-memory dogfood)

  • W2-S1 — connector_write_kbs + resolver write scope. New table (mirror api_key_write_kbs, app/lib/db/schema.ts:440); extend the resolver → { readKbs, writeKbs } with the CA-2 re-intersect against the live, revoke- and library_access-checked read set (builds on W1-S5). → W1-S5. 🤖 test: a stale allowlist row never grants write.
  • W2-S2 — Write-allowlist issuance (admin-gated). The action to grant a connector write to a KB, gated on requireLibraryAdmin. → W2-S1. 🤖.
  • W2-S3 — AgentCore S3 write access (Option B infra). Add s3:PutObject IAM + UPLOADS_BUCKET to the AgentCore Runtime role (its own stack from W1-S7). → W1-S7. 🤖 + 🧑.
  • W2-S4 — publish_document tool (replace-by-key on session id). MCP write tool: check write scope → route through the shared org-checked createUploadInScope (app/lib/documents/create-upload.ts) → PUT to S3 directly → replace-by-key on the immutable session id (port replaceByName, api-client/src/client.ts:181); size cap. → W2-S1, W2-S3. 🤖 test: two sessions → two docs; double-publish of one session → one doc.
  • W2-S5 — Audit extension. writeToolCallEvent (mcp-servers/doc-search/src/audit.ts) records targetType=document, targetId, document.create. → W2-S4. 🤖.
  • W2-S6 — Wire /stop to MCP + Wave-2 acceptance. Switch the dev-memory /stop publish from the CLI to the MCP publish_document tool; E2E: /stop → episode lands → recall-able via filter_then_rank; double-fire → one doc; empty-allowlist (Brehob) connector = read-only; isolation probe extended to the write path. → W2-S4, W2-S5. 🧑 dogfood E2E.

Open (decide in build): self-host MCP vs AgentCore Gateway (affects S7); COGS attribution for connector writes (api_key_usage FK to api_keys — affects S4/S5).

Ship order (topo): W1-{S5, S6} land immediately · S0 gates · S1 → {S2, S3, S4, S9} → S7 → S8 · then W2: S1 → {S2, S3} → S4 → S5 → S6.

Sources (key)

Review

🔒

Enter your access token to view annotations