Repository navigation
feat(ai-mcp): add the @auth0/ai-mcp package core - #416
tusharpandey13 wants to merge 16 commits into
Conversation
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>
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>
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) => |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
What this does
Adds the new
@auth0/ai-mcpworkspace 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 staysprivateat version0.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 aregetTokenOnBehalfOf,getClientCredentialsTokenandgetTokenForConnection(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.AsyncLocalStoragerequest context (runWithRequestContext,getRequestContext,getCallerMcpClient).buildPrincipal, scope helpers (requireScopes,withRequiredScopes) and the three error classes (UnauthenticatedError,InsufficientScopeError,DownscopedTokenError).mcpClientOptionsFromEnvandresolveMcpClientOptions, the explicit way to readAUTH0_DOMAIN,AUTH0_AUDIENCE,AUTH0_CLIENT_IDandAUTH0_CLIENT_SECRET.createMcpClientitself reads no environment variable.The root
package-lock.jsonchanges 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:
packages/ai-mcp/src/client.tsandsrc/request-scoped-client.ts. The invariants: aVerifiedCallerhandle 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.packages/ai-mcp/src/principal.tsandsrc/env.ts. The principal reads own claims only, prefersazpfor 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.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.packages/ai-mcp/src/request-context.tsandsrc/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.tsandtest/stores/encrypted-store.spec.ts. The mock tenant intest/supporthas 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, theWWW-Authenticatechallenge builder, the protected resource metadata builder and the@auth0/ai-mcp/mcp-sdksubpath. 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
The test run reports 18 files and 347 tests. This package depends on
@auth0/auth0-api-js^1.7.0 andjose^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 targetmain, so retarget each PR tomainafter the one below it merges.🤖 Generated with Claude Code