Skip to content

feat(ai-mcp): add the @auth0/ai-mcp package core - #416

Open
tusharpandey13 wants to merge 16 commits into
mainfrom
split/1-core
Open

tusharpandey13 wants to merge 16 commits into
mainfrom
split/1-core

Conversation

@tusharpandey13

@tusharpandey13 tusharpandey13 commented Oct 26, 2026 •

Copy link
Copy Markdown

What this does

Adds the new @auth0/ai-mcp workspace package with its core: everything a server needs to verify a caller and obtain tokens for downstream APIs, without any HTTP or MCP SDK wiring yet. The package stays private at version 0.0.0, so merging this does not publish anything.

The package contains:

  • createMcpClient, which verifies the caller's access token and hands out a client bound to that caller. Its three token methods are getTokenOnBehalfOf, getClientCredentialsToken and getTokenForConnection (Token Vault). The on-behalf-of exchange has single-flight and an optional cache; the other two are never cached. The client rejects a result that arrives already expired.
  • The request-scoped client that tool code uses, plus an AsyncLocalStorage request context (runWithRequestContext, getRequestContext, getCallerMcpClient).
  • buildPrincipal, scope helpers (requireScopes, withRequiredScopes) and the three error classes (UnauthenticatedError, InsufficientScopeError, DownscopedTokenError).
  • mcpClientOptionsFromEnv and resolveMcpClientOptions, the explicit way to read AUTH0_DOMAIN, AUTH0_AUDIENCE, AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET. createMcpClient itself reads no environment variable.
  • A bounded in-memory indexed token store and an encrypting wrapper for caller-supplied stores. The token store is opt-in: without one, every on-behalf-of call exchanges.
  • Build, lint, type-check and vitest configuration, and a mock Auth0 tenant (msw) for tests.

The root package-lock.json changes too. It adds the workspace entry and the development dependencies, and moves the hoisted vitest packages from 3.2.4 to 3.2.7. Skim it; the package manager wrote it.

What to review

If you have limited time, read these in order:

  1. packages/ai-mcp/src/client.ts and src/request-scoped-client.ts. The invariants: a VerifiedCaller handle only works with the client that issued it, tokens never appear in logged or serialized handles, concurrent identical exchanges collapse into one request, exchange failures surface as generic errors without upstream detail, and a narrowed or expired grant never reaches tool code.
  2. packages/ai-mcp/src/principal.ts and src/env.ts. The principal reads own claims only, prefers azp for the client id and rejects claims of the wrong type. The environment helpers name the missing option and variable in their errors and never a value.
  3. packages/ai-mcp/src/stores/encrypted-store.ts. The store derives its key from a secret of at least 32 characters, and an entry it cannot decrypt counts as a cache miss.
  4. packages/ai-mcp/src/request-context.ts and src/scopes.ts. The context is bound per call, a denied scope check returns a sanitized tool error rather than throwing, and a requirement that names no scope is a configuration error.

For tests, skim test/request-scoped-client.spec.ts, test/request-scoped-single-flight.spec.ts, test/request-scoped-expired.spec.ts and test/stores/encrypted-store.spec.ts. The mock tenant in test/support has its own spec so the later suites can rely on it. The commits are not each gated by the full test run; the branch head is, so read the branch diff.

Out of scope, lands in the next PR

The request handler, authenticateRequest, the WWW-Authenticate challenge builder, the protected resource metadata builder and the @auth0/ai-mcp/mcp-sdk subpath. Their tests, the public API snapshot and the built-module smoke tests come with them. The integration suites, the README, guides and example, and the real-tenant suite follow after that.

How to run it

npm ci
cd packages/ai-mcp
npm run type-check
npm run lint
npm test
npm run build

The test run reports 18 files and 347 tests. This package depends on @auth0/auth0-api-js ^1.7.0 and jose ^6.0.0.

Stack position

PR 1 of 5. Base: main. The next PRs are #417 (ingress), #418 (integration tests), #419 (documentation and example) and #420 (real-tenant end-to-end suite). The repository's CI runs only for PRs that target main, so retarget each PR to main after the one below it merges.

🤖 Generated with Claude Code

tusharpandey13 and others added 5 commits October 26, 2026 13:27
Adds the @auth0/ai-mcp workspace package: build, lint, type-check and vitest configuration, the three public error classes (401, 403 and downscoped-token), and the types that describe principals, request context and client options.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds a test support layer built on msw: a mock Auth0 tenant that serves discovery, JWKS and token endpoints, a signing key helper, access token minting, and a downstream resource oracle. The helpers carry their own spec so later suites can rely on them.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds buildPrincipal, scope enforcement helpers, an AsyncLocalStorage based request context, a keyed single-flight helper and a fetch wrapper with a timeout.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds a bounded in-memory token store that supports covering-scope lookup, and a wrapper that encrypts cached tokens before they reach a caller-supplied store.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds createMcpClient, which verifies the caller's token and performs on-behalf-of, client-credentials and token-for-service exchanges with caching and single-flight, plus the request-scoped client that tool handlers use. Wires up the package entry point.

Co-Authored-By: Claude <noreply@anthropic.com>
tusharpandey13 and others added 9 commits October 26, 2026 23:02
Ingress code needs to know which tenant and which API identifier a client
was created for, so it can derive defaults instead of asking the caller to
repeat them. McpClient now carries optional read-only domain and audience
members that report the values given to createMcpClient. They are optional
so existing custom clients and test doubles keep compiling, and they are
undefined when the declared value is not a usable string.

Co-Authored-By: Claude <noreply@anthropic.com>
The method exchanges the caller's token for a third-party service token
through a connection, and Python names the same operation by connection.
The options and result types follow: TokenForConnectionOptions and
ConnectionTokenResult. The package is unreleased, so no aliases are kept.

Co-Authored-By: Claude <noreply@anthropic.com>
After a successful token response, getTokenOnBehalfOf,
getClientCredentialsToken and getTokenForConnection now fail with the
generic token operation error when the returned expiry is at or before the
current time, instead of handing tool code a dead token. The cause carries
no token data. The core client stores an exchange result before this check
runs, but its cache treats an expired entry as a miss, so it is never
served again.

Co-Authored-By: Claude <noreply@anthropic.com>
mcpClientOptionsFromEnv reads AUTH0_DOMAIN, AUTH0_AUDIENCE, AUTH0_CLIENT_ID
and AUTH0_CLIENT_SECRET, treating blank values as unset. resolveMcpClientOptions
merges explicit options over the environment and fails with an error that
names the option and the variable, never a value, when domain or audience is
missing. createMcpClient itself still reads nothing from the environment.

Co-Authored-By: Claude <noreply@anthropic.com>
requireScopes and withRequiredScopes now throw InvalidConfigurationError for
an empty string, an empty list, or a list with a blank or non-string entry,
because such a requirement would pass for every caller. withRequiredScopes
validates when the wrapper is created and keeps its own copy of the list.

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

Auth0 access tokens carry azp and never client_id, so azp is now read first
with client_id as the fallback, and a token with neither is still accepted
with a null clientId. A present permissions claim must be an array of
strings and a present org_id must be a string; anything else is rejected
instead of being silently coerced.

Co-Authored-By: Claude <noreply@anthropic.com>
A matrix through McpClient.verifyAccessToken with a mock tenant: empty,
whitespace, non-JWT, wrong segment counts, bad base64, a non-JSON header,
alg none, HS256 signed with the public key, unknown kid, expired, nbf in the
future, missing exp, wrong audience and wrong issuer. Each must end as a 401
authentication failure and never a raw TypeError. Also covers nbf in the
past and empty or absent scope claims.

Co-Authored-By: Claude <noreply@anthropic.com>
A numeric org_id is now rejected when the principal is built, so the
single-flight test that needs a verified token with a non-string keyed claim
uses sid, which the principal does not read.

Co-Authored-By: Claude <noreply@anthropic.com>
tusharpandey13 and others added 2 commits October 29, 2026 20:50
The doc comment on MemoryIndexedTokenStore called it the default cache
for a server. No token store is used unless one is passed as the
tokenStore, so the comment now describes it as a reference store for a
single process and for tests that a server can opt into.

Co-Authored-By: Claude <noreply@anthropic.com>
The package throws and maps these two classes from the core package.
Sibling packages and applications that import them from the core package
get a different class when more than one copy of it is installed, and
instanceof checks then fail. Re-exporting them from the package entry
gives every consumer the class this package uses.

Co-Authored-By: Claude <noreply@anthropic.com>
}
const known = error instanceof Error ? coreErrorPrototype(error) : undefined;
let safe: Error;
if (error instanceof AuthError) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code, statusCode and headers are copied only when the error is an AuthError from auth0-api-js. TokenExchangeError and TokenByClientCredentialsError come from auth0-auth-js and are not, so they keep the class but lose these fields. For a 429 from the token endpoint I got statusCode as undefined, and only cause.statusCode had 429.

The JSDoc above and the docs say code and statusCode are kept. As of now a tool cannot read Retry-After or the error code. Can we copy the fields when they are present, or change the docs to say they are on cause only?

readonly layout: OboCacheKeyLayout;
}

const KEYED_CLAIMS = ['iss', 'client_id', 'azp', 'sub', 'org_id', 'sid', 'jti'] as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remove any one name from this list, no test fails. The list is used only for the guard in flightKey, and the current guard test passes even without the guard, because sid and jti are different for the two callers anyway.

Can we make the test use callers that differ only in one non string claim, one case for each name in the list? Then both the list and the guard are protected.

return { ...result, grantedScopes };
},

getTokenForConnection: (options: TokenForConnectionOptions) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I routed this call through the single flight with a key made from the connection name, and all the tests still passed. In that case two callers on the same connection at the same time share one provider token.

Can we add a test where two callers call getTokenForConnection together on one connection, and each gets its own token?

// reject a narrowed result. Rejecting it here keeps the cache correct (a later,
// narrower request can still reuse it) while this caller never runs with less
// authority than it asked for. This runs per caller, after the shared exchange.
assertCovers(options.scope, result.grantedScopes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I move this call inside the shared exchange, no test fails. Then all the waiting callers get the same error object, which the comment on sanitizeTokenError says should not happen.

Can we add a test with two concurrent callers and a narrowed grant, and check that the two errors are different objects?

// from this server's own ingress check, so the only way to fail here is a caller token that
// expired while the request was running. That is the caller's credential, not a server
// fault, so it surfaces as the package's 401 rather than as an internal error.
if (error instanceof VerifyAccessTokenError) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says the only way to fail here is a caller token that expired during the request, and it becomes a 401. This is true only when a tokenStore is set, because the five second check is in the cache path. Without a store, a token with 3 seconds left is exchanged normally.

If it expires during the request and there is no store, the tenant rejects it, the caller gets a TokenExchangeError, and it ends as a 500. Can we correct this comment and the known limitations in the docs, or apply the same check in withToken?

* The core client stores an exchange result before this check runs and its cache treats an
* expired entry as a miss, so an expired entry may remain in a store but is never served.
*/
function assertNotExpired(expiresAt: number): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the token response has no expires_in, expiresAt becomes NaN and this throws the same 'already expired' cause as for expires_in: 0. I checked OBO and client credentials. The cause is misleading, and no test in this PR covers the NaN branch.

Token Vault has the same check. Does Auth0 send expires_in for a provider token that never expires? If not, every call for such a connection will fail. Kindly check once with a connection like that.

const fromEnv = mcpClientOptionsFromEnv(env);
const merged: Partial<McpClientOptions> = { ...fromEnv, ...explicit };
for (const name of Object.keys(ENV_NAMES) as Array<keyof typeof ENV_NAMES>) {
const value = present(explicit[name]) ?? fromEnv[name];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the caller passes apiClient and the environment has AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET, these values get merged into the options. Then createMcpClient throws that apiClient cannot be combined with clientId and clientSecret, though the user never passed them.

Can we skip the environment credentials when apiClient is present?


/** A string that holds something other than whitespace, otherwise `undefined`. */
function present(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A value with a trailing newline or space passes here and is used as it is. With AUTH0_AUDIENCE having a trailing newline, the server starts fine but every token fails with unexpected "aud" claim value, and nothing points to the variable.

Can we trim these identifiers, or reject a padded value with an error that names the option?

dpop?: ApiClientOptions['dpop'];
/**
* Upper limit, in milliseconds, for each outbound HTTP request the core client makes:
* the token exchange, discovery and JWKS fetches. The limit covers the response body

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the JWKS fetch the limit is not really this value. With the default 10000, a JWKS endpoint that never answers fails after about 5 seconds, because jose has its own 5 second cap. With requestTimeoutMs: 800 it failed after 803 ms.

Please add one sentence here and in the docs that JWKS fetches stop at 5 seconds at most.

"@eslint/compat": "^1.2.7",
"@eslint/js": "^9.18.0",
"@types/node": "^22.10.6",
"@vitest/coverage-v8": "^3.0.9",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No script or CI step runs coverage right now. But this package has an exact vitest peer, so the lockfile also moves vitest and seven @vitest/* packages for all workspaces from 3.2.4 to 3.2.7.

If we do not need coverage in this PR, can we remove it and the coverage block in vitest.config.ts? We can add it back with a script later.

@tusharpandey13
tusharpandey13 marked this pull request as ready for review November 1, 2026 07:13
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.

2 participants