Repository navigation
feat(ai-mcp): add @auth0/ai-mcp, Auth0 token handling for MCP servers - #414
Draft
tusharpandey13 wants to merge 28 commits into
Draft
tusharpandey13 wants to merge 28 commits into
tusharpandey13 wants to merge 28 commits into
Conversation
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>
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>
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.
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-jsand ships no MCP framework of its own.For each request the package:
Authorizationheader (Bearer or DPoP) and answers failures with a 401 and an RFC 6750WWW-Authenticatechallenge. It also serves the RFC 9728 protected resource metadata document.insufficient_scope, and lets individual tools check their own scopes.Principalfrom the verified claims and binds it, together with a request-scoped client, to the request throughAsyncLocalStorage.A thin adapter on the
@auth0/ai-mcp/mcp-sdksubpath connects this to the official MCP TypeScript SDK (Streamable HTTP, stateless).examples/ai-mcp-servershows a working server.PrincipalandbuildPrincipallive 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
scopemeans the grant equals the request. An emptyscopemeans nothing was granted, which is a narrowed grant when scopes were requested.Public API
Main entry
@auth0/ai-mcp:createMcpClient,McpClient,VerifiedCaller,ForeignCallerErrorcreateMcpHandler,McpHandlerOptions,AuthInfo,HandlerDpopModerunWithRequestContext,getRequestContext,getCallerPrincipal,getCallerMcpClient,getCallerMcpClientOrThrowrequireScopes,withRequiredScopesbuildPrincipal,PrincipalMemoryIndexedTokenStore,createEncryptedTokenStore, and the coreTokenStore,IndexedTokenStore,CachedToken,TokenIndexMember,TokenSettypesUnauthenticatedError(401),InsufficientScopeError(403),DownscopedTokenError(403)McpClientOptions,RequestScopedMcpClient,OnBehalfOfOptions,ClientCredentialsOptions,TokenForServiceOptions,ClientCredentialsTokenResult,ServiceTokenResult,ScopeMatching,OrganizationPolicySubpath
@auth0/ai-mcp/mcp-sdk(needs the optional peer@modelcontextprotocol/sdk1.31 or later):createMcpSdkHandler,createNodeMcpHandler,toNodeHandler,asTokenVerifier.An API surface snapshot test pins both entry points.
Tests
Known limitations
Full list in
packages/ai-mcp/docs/KnownLimitations.md. The main ones:requireBearerAuthshim (asTokenVerifier) does not restore the request context.Authorization, to tools.AuthInfo.tokenis redacted to an empty string, but tool code must not log or forward headers.MemoryIndexedTokenStoreis per process.Dependencies and CI
CI will be red on this PR until the core prerelease exists:
packages/ai-mcp/package.jsondepends 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, sonpm installreportsnotarget. A separate draft PR prepares the prerelease: Prerelease preview: auth0-api-js v1.7.0-beta.0 auth0-auth-js#317.package-lock.jsonhas no entry for the new workspace. Publishing@auth0/auth0-api-js1.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.examples/calling-apis/chatbotneedsAUTH0_SECRET,AUTH0_CLIENT_SECRETandLANGGRAPH_API_URLto 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": trueand has no release script on purpose. This repository releases with semantic-release frommainonly, so merging afeatcommit for a new package would publish 1.0.0 to thelatesttag. 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:
packages/ai-mcp/README.mdanddocs/OnBehalfOf.mdfor the model and the scope semantics.src/types.ts,src/errors.ts,src/principal.ts: the shapes and thePrincipal.src/client.tsandsrc/request-scoped-client.ts: verification, the opaque caller handle, the exchange paths, the downscope guard and error sanitizing.src/single-flight.tsandsrc/stores/: request de-duplication and the memory and encrypted stores.src/handler.tsandsrc/challenge.ts: ingress, DPoP, challenges, metadata route.src/scopes.tsandsrc/request-context.ts: scope gating and the context binding.src/mcp-sdk.ts: the MCP SDK and Node adapters.test/(start withapi-surface.spec.ts,request-scoped-client.spec.ts,handler.spec.ts, thentest/integration/), thenexamples/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) andstores/encrypted-store.ts.🤖 Generated with Claude Code