Skip to content

feat(ai-mcp): add @auth0/ai-mcp, Auth0 token handling for MCP servers - #414

Draft
tusharpandey13 wants to merge 28 commits into
mainfrom
feat/ai-mcp
Draft

tusharpandey13 wants to merge 28 commits into
mainfrom
feat/ai-mcp

Conversation

@tusharpandey13

Copy link
Copy Markdown

feat(ai-mcp): add @auth0/ai-mcp, Auth0 token handling for MCP servers

Summary

Adds @auth0/ai-mcp, a new package that secures Model Context Protocol servers with Auth0. It is built on @auth0/auth0-api-js and ships no MCP framework of its own.

For each request the package:

  1. Verifies the Authorization header (Bearer or DPoP) and answers failures with a 401 and an RFC 6750 WWW-Authenticate challenge. It also serves the RFC 9728 protected resource metadata document.
  2. Gates server-wide scopes with a 403 insufficient_scope, and lets individual tools check their own scopes.
  3. Builds a Principal from the verified claims and binds it, together with a request-scoped client, to the request through AsyncLocalStorage.
  4. Exchanges the caller's token for downstream tokens (On Behalf Of), requests client credentials tokens, and fetches Token Vault tokens, with an optional cache in front of the On Behalf Of exchange.

A thin adapter on the @auth0/ai-mcp/mcp-sdk subpath connects this to the official MCP TypeScript SDK (Streamable HTTP, stateless). examples/ai-mcp-server shows a working server.

Principal and buildPrincipal live in this package. The core client takes verified claims from the caller and does not build a principal.

Token response scope handling follows RFC 6749 section 5.1. An absent scope means the grant equals the request. An empty scope means nothing was granted, which is a narrowed grant when scopes were requested.

Public API

Main entry @auth0/ai-mcp:

  • Client: createMcpClient, McpClient, VerifiedCaller, ForeignCallerError
  • Handler: createMcpHandler, McpHandlerOptions, AuthInfo, HandlerDpopMode
  • Request context: runWithRequestContext, getRequestContext, getCallerPrincipal, getCallerMcpClient, getCallerMcpClientOrThrow
  • Scopes: requireScopes, withRequiredScopes
  • Principal: buildPrincipal, Principal
  • Stores: MemoryIndexedTokenStore, createEncryptedTokenStore, and the core TokenStore, IndexedTokenStore, CachedToken, TokenIndexMember, TokenSet types
  • Errors: UnauthenticatedError (401), InsufficientScopeError (403), DownscopedTokenError (403)
  • Option and result types: McpClientOptions, RequestScopedMcpClient, OnBehalfOfOptions, ClientCredentialsOptions, TokenForServiceOptions, ClientCredentialsTokenResult, ServiceTokenResult, ScopeMatching, OrganizationPolicy

Subpath @auth0/ai-mcp/mcp-sdk (needs the optional peer @modelcontextprotocol/sdk 1.31 or later): createMcpSdkHandler, createNodeMcpHandler, toNodeHandler, asTokenVerifier.

An API surface snapshot test pins both entry points.

Tests

  • Package: 653 tests in 29 files (unit, integration over the real MCP SDK transport, and documentation snippet tests that compile and run the code in the guides).
  • Example server: 13 tests.
  • Mutation checks were run during development: roughly 90 mutants killed on the handler, request-scoped client and stores, with one equivalent mutant.

Known limitations

Full list in packages/ai-mcp/docs/KnownLimitations.md. The main ones:

  • A token in its last five seconds passes verification but cannot be exchanged, because the core exchange refuses subject tokens within five seconds of expiry.
  • Verified claims are recognized by object identity, so they cannot cross a process boundary. Another process must verify the token again.
  • The encrypted store supports one secret and has no rotation.
  • The requireBearerAuth shim (asTokenVerifier) does not restore the request context.
  • The SDK adapter is stateless: no sessions, no streaming responses, no server-initiated notifications.
  • The MCP SDK still exposes request headers, including Authorization, to tools. AuthInfo.token is redacted to an empty string, but tool code must not log or forward headers.
  • Client credentials and Token Vault tokens are not cached by this package.
  • MemoryIndexedTokenStore is per process.

Dependencies and CI

CI will be red on this PR until the core prerelease exists:

  1. Core prerelease. packages/ai-mcp/package.json depends on @auth0/auth0-api-js ^1.7.0-beta.0, which adds the token store, scope matching and verified-claims APIs this package uses. That version is not published yet, so npm install reports notarget. A separate draft PR prepares the prerelease: Prerelease preview: auth0-api-js v1.7.0-beta.0 auth0-auth-js#317.
  2. Root lockfile. package-lock.json has no entry for the new workspace. Publishing @auth0/auth0-api-js 1.7.0-beta.0 and refreshing the lockfile fixes both items, and a lockfile refresh commit on this branch should then turn the checks green.
  3. Chatbot example. examples/calling-apis/chatbot needs AUTH0_SECRET, AUTH0_CLIENT_SECRET and LANGGRAPH_API_URL to build locally. This is unrelated to this change.

At the head of this branch, build, lint and type-check pass for packages/ai-mcp, and the root lint and test runs pass.

Release channel

The package is "private": true and has no release script on purpose. This repository releases with semantic-release from main only, so merging a feat commit for a new package would publish 1.0.0 to the latest tag. The maintainers need to choose a release channel (for example a beta prerelease branch) before the private flag comes off.

Reviewer guide

Read in this order:

  1. packages/ai-mcp/README.md and docs/OnBehalfOf.md for the model and the scope semantics.
  2. src/types.ts, src/errors.ts, src/principal.ts: the shapes and the Principal.
  3. src/client.ts and src/request-scoped-client.ts: verification, the opaque caller handle, the exchange paths, the downscope guard and error sanitizing.
  4. src/single-flight.ts and src/stores/: request de-duplication and the memory and encrypted stores.
  5. src/handler.ts and src/challenge.ts: ingress, DPoP, challenges, metadata route.
  6. src/scopes.ts and src/request-context.ts: scope gating and the context binding.
  7. src/mcp-sdk.ts: the MCP SDK and Node adapters.
  8. test/ (start with api-surface.spec.ts, request-scoped-client.spec.ts, handler.spec.ts, then test/integration/), then examples/ai-mcp-server.

The remaining docs/ guides are reference material. Security-sensitive spots: request-scoped-client.ts (error sanitizing, cache key and downscope check), handler.ts (header parsing, DPoP, redaction) and stores/encrypted-store.ts.

🤖 Generated with Claude Code

tusharpandey13 and others added 28 commits October 16, 2026 14:10
Add the @auth0/ai-mcp package skeleton: package manifest, TypeScript,
ESLint and Vitest configuration, and a build step that writes ESM and
CommonJS package.json markers into dist.

The public surface starts with three error classes built on the core
AuthError (UnauthenticatedError as 401 invalid_token, InsufficientScopeError
and DownscopedTokenError as 403 insufficient_scope) and the shared type
contracts: Principal, RequestContext, McpClientOptions and the
RequestScopedMcpClient interface. Token store and token result types are
imported from @auth0/auth0-api-js rather than redefined.

The package declares @auth0/auth0-api-js ^1.7.0-beta.0 and an optional
peer on @modelcontextprotocol/sdk ^1.31.0. No release tooling is wired
up yet, so the package cannot be published from this commit.

Co-Authored-By: Claude <noreply@anthropic.com>
Add shared test helpers under test/support:

- RS256 test keys, a JWKS exporter and access token minting with
  per-claim overrides and omission.
- MSW handlers for OIDC discovery, JWKS and the token endpoint, with
  exchange and client credentials counters, recorded requests, a granted
  scope knob that distinguishes an absent scope field from an empty one,
  and failure injection.
- A downstream API oracle that verifies the forwarded JWT signature,
  issuer, audience, expiry and algorithm before accepting a call.

Self-tests cover each knob and every oracle rejection path.

Co-Authored-By: Claude <noreply@anthropic.com>
The encrypted token store uses jose for JWE at runtime, so it cannot
stay a development-only dependency.

Co-Authored-By: Claude <noreply@anthropic.com>
buildPrincipal turns verified access token claims into a frozen Principal.
It rejects a blank sub or a non-numeric exp, takes the client from
client_id before azp, reads scopes from the scope claim only, keeps
permissions separate, and reads claims as own properties only.

Co-Authored-By: Claude <noreply@anthropic.com>
The request context lives in AsyncLocalStorage, so concurrent requests
never see each other's caller. requireScopes enforces every required
scope from the scope claim and throws InsufficientScopeError with a
generic message. withRequiredScopes turns a per-tool denial into a
sanitized tool result marked insufficient_scope and lets every other
error propagate.

Co-Authored-By: Claude <noreply@anthropic.com>
…ight

MemoryIndexedTokenStore is a bounded LRU indexed store that copies values
on read and write. createEncryptedTokenStore wraps any store with compact
JWE (dir, A256CBC-HS512) under an HKDF-derived key, binds each ciphertext
to its storage key, and treats tampering, a wrong secret or a moved
ciphertext as a cache miss. SingleFlight de-duplicates concurrent calls
per key and never caches a failure.

Co-Authored-By: Claude <noreply@anthropic.com>
createMcpClient wraps the core API client, forwards scope matching,
organization and DPoP settings, and rejects a non_strict configuration
without an indexed store at construction. verifyAccessToken returns an
opaque caller handle that keeps the token and the verified claims
private. withToken accepts only handles issued by the same client and
rejects expired callers. The request-scoped client exchanges tokens On
Behalf Of the caller with the verified claims passed through untouched,
and rejects a token granted fewer scopes than requested.

Co-Authored-By: Claude <noreply@anthropic.com>
Also documents that a non_strict configuration is validated when the
client is created, and that a supplied core client cannot be combined
with settings it would ignore.

Co-Authored-By: Claude <noreply@anthropic.com>
…eign handles

Concurrent identical on-behalf-of requests made through one MCP client now
share a single token exchange. The de-duplication key is built with the core
cache key builder, so two requests only share an exchange when the core cache
would store them under the same entry. Each caller receives its own copy of
the result and runs its own downscope check. A failed exchange rejects every
waiter and is not remembered, so the next call exchanges again.

A caller handle produced by a different client now throws
ForeignCallerError, which is not an authentication error and is meant to be
reported as an internal server error. An expired handle still throws
UnauthenticatedError.

Co-Authored-By: Claude <noreply@anthropic.com>
…llenges

createMcpHandler turns a web-standard Request into a Response. It extracts
Bearer or DPoP credentials, verifies them with the MCP client, enforces any
server-wide required scopes and runs the downstream handler inside the
request context, passing an AuthInfo object shaped like the one the MCP SDK
expects. It also serves the RFC 9728 protected resource metadata document.

Challenges follow RFC 6750 and RFC 9728. A request with no usable credential
gets a challenge without an error parameter. Invalid tokens get
invalid_token, malformed requests get invalid_request, and missing
server-wide scopes get insufficient_scope listing only the missing scopes.
Challenges carry realm when configured and resource_metadata pointing at the
metadata route. Descriptions are fixed generic strings restricted to the
characters RFC 6750 allows, so verifier messages never reach the client.
DPoP nonces from the verifier are passed on only when they match the
RFC 9449 character set.

Errors raised after authentication, such as a failed token exchange, are
reported as a generic 500 unless they are the package's own authentication
or scope errors. A foreign caller handle is also a 500, and nothing thrown
while building a response can escape the handler.

The MCP client now exposes its DPoP mode so the handler advertises and
accepts the schemes the client verifies.

Co-Authored-By: Claude <noreply@anthropic.com>
The new @auth0/ai-mcp/mcp-sdk entry point connects the HTTP handler to the
MCP TypeScript SDK. createMcpSdkHandler serves a stateless web-standard
Streamable HTTP transport so that tools receive the verified AuthInfo and
run inside the request context. toNodeHandler and createNodeMcpHandler
adapt the same handler to Node's http module. The adapter rebuilds the
request URL from the configured resource origin rather than the Host
header, rejects malformed paths and Content-Length values, caps the request
body, and respects response backpressure.

asTokenVerifier adapts the MCP client to the SDK's bearer middleware for
servers that already use it. It inherits that middleware's challenge
behaviour and does not set up the request context, which its documentation
states.

The SDK stays an optional peer dependency: nothing under the root entry
point imports it. Scope-guarded tool handlers now type-check as SDK tool
callbacks.

Co-Authored-By: Claude <noreply@anthropic.com>
When the response body fails before anything is written, the generic 500
now clears the headers the handler had already set, so cookies, challenges
and a stale Content-Length do not reach the client. The response reader is
cancelled as soon as the client disconnects, so a stalled stream cannot
hold the request open.

Oversized request bodies are read with events and the request is destroyed
only after the 413 has been sent, which keeps the response deliverable. An
empty object body left by a body parser with no request body is treated as
no body.

Co-Authored-By: Claude <noreply@anthropic.com>
…d API

The ai-mcp-server example runs an MCP server with two tools. list_reports
exchanges the caller's token for one scoped to a downstream API and calls
that API with the exchanged token only. admin_summary is guarded by a
required scope and returns a tool error when the caller lacks it.

The bundled downstream API verifies every token it receives: signature
against the issuer's JWKS with RS256 pinned, issuer, audience, expiry and
scope. Tool results never carry exchange or network error text, and the
downstream call has a timeout.

Configuration comes only from environment variables, documented in
.env.example. An offline smoke test runs both servers against a mocked
authorization server.

Co-Authored-By: Claude <noreply@anthropic.com>
The suites drive the MCP SDK client and Streamable HTTP transport against
the package's Node wiring on a real socket and its web-standard wiring
in-process. Only the authorization server and the downstream API are
mocked, and the downstream API verifies every token it receives.

They cover bearer handling at the transport, de-duplication of concurrent
on-behalf-of exchanges, cache isolation across subjects, organizations,
issuers and clients, session separation, clock skew at the cache leeway
boundary, organization policy, request context isolation under concurrent
requests, and the difference between per-tool scope denial and server-wide
scope enforcement.

Co-Authored-By: Claude <noreply@anthropic.com>
The MCP SDK copies a thrown error's message into the tool result it returns
to the client, so a failed token exchange inside a tool exposed the
underlying library or authorization server text. Errors that leave the
request-scoped client's token methods now carry a fixed message. The
original error is kept as the cause for server-side diagnostics, and core
authentication errors keep their class, code, status and a copy of their
headers so callers can still branch on them. The package's own scope and
authentication errors are passed through unchanged, and every caller
waiting on a shared exchange receives its own error instance.

Co-Authored-By: Claude <noreply@anthropic.com>
Principal.scopes and Principal.permissions are now readonly so callers cannot mutate the verified caller's grants after construction.

Co-Authored-By: Claude <noreply@anthropic.com>
Custom token store implementations need TokenStore, IndexedTokenStore, TokenIndexMember, CachedToken and TokenSet. Re-exporting them as types saves integrators a direct dependency on the core package.

Co-Authored-By: Claude <noreply@anthropic.com>
Pin the sorted runtime export names of both entry points in ESM and CJS so an added or removed export fails the suite.

Co-Authored-By: Claude <noreply@anthropic.com>
…ures

- swallow a rejected promise returned by the onError observer, as a synchronous throw already was
- reject a whitespace-only realm with the same configuration error as an empty one
- answer a malformed request URL with 400 invalid_request instead of throwing from handle()

Co-Authored-By: Claude <noreply@anthropic.com>
…CP SDK

The root install hoists an older @modelcontextprotocol/sdk than the example declares. A test now fails loudly when the resolved version is below 1.31.0, and the README documents a local install that satisfies it without changing the lockfile.

Co-Authored-By: Claude <noreply@anthropic.com>
Cover install, the MCP SDK quick start, the framework-agnostic handler, token verification and DPoP, scope enforcement, request context, on-behalf-of and client credentials, token caching and stores, Token Vault, organization policy, the error reference and known limitations.

Co-Authored-By: Claude <noreply@anthropic.com>
…aviour

Compile every TypeScript code block in the README, examples and guides against the source, check relative links, and assert the behaviour the guides describe.

Co-Authored-By: Claude <noreply@anthropic.com>
…he SDK handler

AuthInfo.token is now an empty string, so tool callbacks no longer receive the raw access token. Tools should call downstream APIs through the request-scoped client. The MCP SDK transport still exposes raw request headers to tools, and the JSDoc says so.

createMcpSdkHandler now answers non-POST requests to the MCP endpoint with 405 and Allow: POST. Before this, a GET opened an SSE stream that closed as soon as the per-request server shut down, which made clients reconnect in a loop. The protected resource metadata route is unchanged.

On this handler path, an exchange downscoped inside a tool surfaces as an isError tool result with HTTP 200, because the MCP SDK catches tool errors. The doc comments and a new test pin that behaviour.

Co-Authored-By: Claude <noreply@anthropic.com>
…ilure

When the authorization server grants a service token fewer scopes than requested, the cause is server configuration, and the end client cannot fix it. getClientCredentialsToken now throws a generic error that carries the downscope detail as its cause. The handler maps it to a 500 with no insufficient_scope challenge. On-behalf-of exchanges still throw DownscopedTokenError.

Co-Authored-By: Claude <noreply@anthropic.com>
…aviour

The Express example mounted only /mcp, so OAuth discovery at the protected resource metadata path never reached the handler. The README and handler guide now route both paths.

The docs now also cover the empty AuthInfo token, 405 for non-POST requests, an isError result for a downscope inside a tool on the SDK handler, and a 500 for a narrowed client credentials grant. The docs snippet test keeps only a generic dash guard.

Co-Authored-By: Claude <noreply@anthropic.com>
The transport recorder now captures the request method. The test asserts that every non-GET request succeeds and that a 405 only answers a GET. Before this, a POST rejected with 405 would have passed. The token-forwarding test tool now fails with a clear message when no Authorization header is present.

Co-Authored-By: Claude <noreply@anthropic.com>
An absent scope means the grant equals the request (RFC 6749 section 5.1);
an empty scope means nothing was granted. Document it for the on-behalf-of
and client credentials results and note why the code uses ??.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant