From 9deaf2011f09f5ff6d3c767f4576b40e3e0dd582 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:10:51 +0530 Subject: [PATCH 01/31] feat(ai-mcp): scaffold package with errors and types 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 --- packages/ai-mcp/LICENSE | 176 ++++++++++++++++++ packages/ai-mcp/eslint.config.mjs | 41 ++++ packages/ai-mcp/package.json | 78 ++++++++ .../ai-mcp/scripts/write-module-markers.mjs | 19 ++ packages/ai-mcp/src/errors.ts | 63 +++++++ packages/ai-mcp/src/index.ts | 15 ++ packages/ai-mcp/src/types.ts | 164 ++++++++++++++++ packages/ai-mcp/test/errors.spec.ts | 109 +++++++++++ .../test/smoke/fixtures/cjs-consumer.cts | 6 + .../test/smoke/fixtures/esm-consumer.mts | 6 + .../ai-mcp/test/smoke/fixtures/tsconfig.json | 12 ++ .../ai-mcp/test/smoke/module-formats.spec.ts | 91 +++++++++ packages/ai-mcp/test/types.spec.ts | 168 +++++++++++++++++ packages/ai-mcp/tsconfig.build.json | 5 + packages/ai-mcp/tsconfig.json | 10 + packages/ai-mcp/vitest.config.ts | 12 ++ 16 files changed, 975 insertions(+) create mode 100644 packages/ai-mcp/LICENSE create mode 100644 packages/ai-mcp/eslint.config.mjs create mode 100644 packages/ai-mcp/package.json create mode 100644 packages/ai-mcp/scripts/write-module-markers.mjs create mode 100644 packages/ai-mcp/src/errors.ts create mode 100644 packages/ai-mcp/src/index.ts create mode 100644 packages/ai-mcp/src/types.ts create mode 100644 packages/ai-mcp/test/errors.spec.ts create mode 100644 packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts create mode 100644 packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts create mode 100644 packages/ai-mcp/test/smoke/fixtures/tsconfig.json create mode 100644 packages/ai-mcp/test/smoke/module-formats.spec.ts create mode 100644 packages/ai-mcp/test/types.spec.ts create mode 100644 packages/ai-mcp/tsconfig.build.json create mode 100644 packages/ai-mcp/tsconfig.json create mode 100644 packages/ai-mcp/vitest.config.ts diff --git a/packages/ai-mcp/LICENSE b/packages/ai-mcp/LICENSE new file mode 100644 index 00000000..e6815d1e --- /dev/null +++ b/packages/ai-mcp/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/packages/ai-mcp/eslint.config.mjs b/packages/ai-mcp/eslint.config.mjs new file mode 100644 index 00000000..15980571 --- /dev/null +++ b/packages/ai-mcp/eslint.config.mjs @@ -0,0 +1,41 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; +import path from "node:path"; +import { includeIgnoreFile } from "@eslint/compat"; + +const gitIgnore = path.resolve(import.meta.dirname, "../../", ".gitignore"); + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + includeIgnoreFile(gitIgnore), + { ignores: ["eslint.config.mjs", "test/smoke/fixtures/**"] }, + { + files: ["src/**/*.{js,mjs,cjs,ts}"], + languageOptions: { globals: globals.browser }, + }, + { + files: ["test/**/*.{js,mjs,cjs,ts,mts,cts}"], + languageOptions: { globals: globals.node }, + }, + { + files: ["scripts/**/*.{js,mjs,cjs}"], + languageOptions: { globals: globals.node }, + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.ts", "test/**/*.ts"], + ignores: ["test/smoke/fixtures/**"], + + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + project: "./tsconfig.json", + }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, +]; diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json new file mode 100644 index 00000000..096abc06 --- /dev/null +++ b/packages/ai-mcp/package.json @@ -0,0 +1,78 @@ +{ + "name": "@auth0/ai-mcp", + "version": "0.0.0", + "description": "Auth0 token delegation and request authentication for Model Context Protocol servers", + "repository": { + "type": "git", + "url": "https://github.com/auth0/auth0-ai-js", + "directory": "packages/ai-mcp" + }, + "homepage": "https://github.com/auth0/auth0-ai-js/tree/main/packages/ai-mcp#readme", + "types": "dist/esm/index.d.ts", + "main": "dist/cjs/index.js", + "module": "./dist/esm/index.js", + "scripts": { + "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:markers", + "build:watch": "run-p 'build:esm -- --watch' 'build:cjs -- -watch'", + "build:esm": "tsc --project ./tsconfig.build.json --outDir ./dist/esm", + "build:cjs": "tsc --project ./tsconfig.build.json --module nodenext --moduleResolution nodenext --outDir ./dist/cjs", + "build:markers": "node ./scripts/write-module-markers.mjs", + "clean": "rm -rf dist", + "lint": "eslint .", + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:smoke": "vitest run test/smoke" + }, + "files": [ + "dist/**/*.{*js,*js.map,d.ts,d.ts.map}", + "dist/**/package.json" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, + "author": { + "name": "Auth0 Inc.", + "url": "https://auth0.com" + }, + "license": "Apache-2.0", + "dependencies": { + "@auth0/auth0-api-js": "^1.7.0-beta.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.31.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + }, + "devDependencies": { + "@eslint/compat": "^1.2.7", + "@eslint/js": "^9.18.0", + "@modelcontextprotocol/sdk": "^1.31.0", + "@types/node": "^22.10.6", + "@vitest/coverage-v8": "^3.0.9", + "eslint": "^9.22.0", + "globals": "^15.11.0", + "jose": "^6.0.0", + "msw": "^2.0.0", + "typedoc": "^0.27.6", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", + "vitest": "^3.0.9", + "zod": "^3.25.76" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ai-mcp/scripts/write-module-markers.mjs b/packages/ai-mcp/scripts/write-module-markers.mjs new file mode 100644 index 00000000..f148f41a --- /dev/null +++ b/packages/ai-mcp/scripts/write-module-markers.mjs @@ -0,0 +1,19 @@ +// The package.json at the package root has no "type" field, so Node treats every +// .js file below it as CommonJS. Each build output directory therefore gets its own +// marker so that dist/esm loads as ES modules and dist/cjs as CommonJS. +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const distDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"); + +const markers = [ + ["esm", "module"], + ["cjs", "commonjs"], +]; + +for (const [dir, type] of markers) { + const target = path.join(distDir, dir); + mkdirSync(target, { recursive: true }); + writeFileSync(path.join(target, "package.json"), `${JSON.stringify({ type }, null, 2)}\n`); +} diff --git a/packages/ai-mcp/src/errors.ts b/packages/ai-mcp/src/errors.ts new file mode 100644 index 00000000..34ee75a1 --- /dev/null +++ b/packages/ai-mcp/src/errors.ts @@ -0,0 +1,63 @@ +import { AuthError } from '@auth0/auth0-api-js'; + +type ErrorHeaders = Record; + +/** + * Raised when a request reaches protected code without an authenticated caller, + * for example when no request context is active or no principal was resolved. + * + * HTTP semantics: 401 Unauthorized. Its `code` is `invalid_token`, the RFC 6750 + * section 3.1 error value for a missing or unusable credential. Pass `headers` + * through to the response so a `WWW-Authenticate` challenge, when one is + * supplied, reaches the client. + */ +export class UnauthenticatedError extends AuthError { + constructor(message = 'Unauthenticated', headers?: ErrorHeaders) { + super(message, 'invalid_token', 401, headers); + this.name = 'UnauthenticatedError'; + } +} + +/** + * Raised when the authenticated caller lacks one or more scopes that a tool or + * route requires. + * + * HTTP semantics: 403 Forbidden with the RFC 6750 section 3.1 error value + * `insufficient_scope`. The message is generic by design and never names the + * missing scopes, so a client cannot enumerate what a server expects. Read + * {@link InsufficientScopeError.requiredScopes} on the server side when the + * list is needed for a challenge or for logging. + */ +export class InsufficientScopeError extends AuthError { + /** + * The scopes the caller needed. This is a frozen copy of the list passed to + * the constructor, so changing the original array afterwards has no effect. + */ + readonly requiredScopes: readonly string[]; + + constructor( + requiredScopes: readonly string[], + message = 'Missing one or more required scopes.', + headers?: ErrorHeaders + ) { + super(message, 'insufficient_scope', 403, headers); + this.name = 'InsufficientScopeError'; + this.requiredScopes = Object.freeze([...requiredScopes]); + } +} + +/** + * Raised when a token exchange succeeded but the issued token does not carry + * every scope that was requested, so using it would run with less authority + * than the caller asked for. + * + * HTTP semantics: 403 Forbidden with the RFC 6750 section 3.1 error value + * `insufficient_scope`. The error carries no scope data, which keeps the + * granted and requested scope sets out of responses and logs. + */ +export class DownscopedTokenError extends AuthError { + constructor(message = 'Exchanged token did not include all requested scopes.', headers?: ErrorHeaders) { + super(message, 'insufficient_scope', 403, headers); + this.name = 'DownscopedTokenError'; + } +} diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts new file mode 100644 index 00000000..5ec4c335 --- /dev/null +++ b/packages/ai-mcp/src/index.ts @@ -0,0 +1,15 @@ +// Public entry point of @auth0/ai-mcp: error classes and the types describing the client surface. +export { UnauthenticatedError, InsufficientScopeError, DownscopedTokenError } from './errors.js'; +export type { + Principal, + RequestContext, + McpClientOptions, + RequestScopedMcpClient, + ScopeMatching, + OrganizationPolicy, + OnBehalfOfOptions, + ClientCredentialsOptions, + TokenForServiceOptions, + ClientCredentialsTokenResult, + ServiceTokenResult, +} from './types.js'; diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts new file mode 100644 index 00000000..9fbf3364 --- /dev/null +++ b/packages/ai-mcp/src/types.ts @@ -0,0 +1,164 @@ +import type { + ApiClient, + ApiClientOptions, + IndexedTokenStore, + OnBehalfOfTokenResult, + ScopeMatching as CoreScopeMatching, + TokenSet, + TokenStore, +} from '@auth0/auth0-api-js'; + +/** + * How a cached On Behalf Of token is matched against a request. Same type as the + * core package uses; `'non_strict'` requires an {@link IndexedTokenStore}. + */ +export type ScopeMatching = CoreScopeMatching; + +/** + * Organization enforcement applied when an access token is verified. Derived from + * the core client options so the two cannot drift apart. + * + * - `'allow'`: no organization check. + * - `'required'`: the token must carry an `org_id` claim, and when + * {@link McpClientOptions.organizationId} is set the claim must be in that list. + */ +export type OrganizationPolicy = NonNullable; + +/** + * The identity of the caller, resolved from a verified access token. + * It holds no credential and is safe to log. + */ +export interface Principal { + /** The `sub` claim: the user or client the token was issued for. Never blank. */ + readonly sub: string; + /** Token expiry as epoch seconds (the `exp` claim). */ + readonly expiresAt: number; + /** Scopes parsed from the space-delimited `scope` claim. Empty when the claim is absent. */ + readonly scopes: string[]; + /** + * RBAC permissions from the `permissions` claim. `null` when the claim is absent, + * meaning permissions are unknown. An empty array means the caller has none. + */ + readonly permissions: string[] | null; + /** + * The OAuth client the token was issued to. Taken from the `client_id` claim, + * falling back to `azp`. `null` when neither is present. + */ + readonly clientId: string | null; + /** The `org_id` claim. `null` when the token was not issued in an organization context. */ + readonly orgId: string | null; +} + +/** Options for exchanging the caller's token for a token to a downstream API. */ +export interface OnBehalfOfOptions { + /** Identifier of the downstream API the new token is for. */ + audience: string; + /** Space-separated scopes to request. */ + scope?: string; +} + +/** Options for obtaining a token for the server itself, with no caller involved. */ +export interface ClientCredentialsOptions { + /** Identifier of the API the token is for. */ + audience: string; + /** Space-separated scopes to request. */ + scope?: string; +} + +/** Options for obtaining a token for a third-party service through a connection. */ +export interface TokenForServiceOptions { + /** Name of the connection that holds the caller's linked account. */ + connection: string; + /** Optional hint that selects among several linked accounts. */ + loginHint?: string; +} + +/** + * Result of a client credentials grant: the core token set plus the normalized + * list of scopes that were granted. + */ +export interface ClientCredentialsTokenResult extends TokenSet { + /** + * The granted scopes, deduplicated and sorted. When the server returned no + * `scope` field this holds the normalized requested scopes. + */ + grantedScopes: string[]; +} + +/** Result of a connection token request. Tracks the core client's return type. */ +export type ServiceTokenResult = Awaited>; + +/** + * A client bound to a single verified request. Tool code receives this instead of + * the shared client so it always acts for the caller who made the request. + * + * The verified access token is closed over by the implementation and is never + * exposed through this interface, so tool code cannot forward or log it by accident. + */ +export interface RequestScopedMcpClient { + /** Exchanges the caller's token for a token to a downstream API. */ + getTokenOnBehalfOf(options: OnBehalfOfOptions): Promise; + /** Obtains a token for this server using its own client credentials. */ + getClientCredentialsToken(options: ClientCredentialsOptions): Promise; + /** Obtains a token for a third-party service linked to the caller. */ + getTokenForService(options: TokenForServiceOptions): Promise; + /** Returns the identity of the caller this client is bound to. */ + getPrincipal(): Principal; +} + +/** Everything a tool handler needs to know about the request it is serving. */ +export interface RequestContext { + /** The authenticated caller. */ + readonly principal: Principal; + /** A client bound to this caller's verified token. */ + readonly mcpClient: RequestScopedMcpClient; +} + +/** Options for creating an MCP client. */ +export interface McpClientOptions { + /** The Auth0 tenant domain, for example `example.auth0.com`. */ + domain: string; + /** Identifier of this MCP server as an API. Access tokens must be issued for it. */ + audience: string; + /** Client id of the application used for token exchanges. */ + clientId?: string; + /** Client secret of the application used for token exchanges. */ + clientSecret?: string; + /** + * Cache for exchanged tokens. Omit it to exchange on every call. Must be an + * {@link IndexedTokenStore} when `scopeMatching` is `'non_strict'`: the core + * client throws `InvalidConfigurationError` otherwise, when a token is requested + * (the store is supplied per call, so it cannot be checked earlier). + */ + tokenStore?: TokenStore | IndexedTokenStore; + /** + * How a cached token is matched against a request. `'non_strict'` needs an + * indexed `tokenStore`; see {@link McpClientOptions.tokenStore}. + * @defaultValue `'strict'` + */ + scopeMatching?: ScopeMatching; + /** + * Organization enforcement applied at ingress. + * @defaultValue `'allow'` + */ + organizationPolicy?: OrganizationPolicy; + /** + * Organization ids accepted when `organizationPolicy` is `'required'`. These are + * `org_...` identifiers, not organization names. Setting it with any other + * policy (including the default) makes the core client throw + * `InvalidConfigurationError` when it is created. + */ + organizationId?: string | string[]; + /** + * DPoP settings forwarded to the core client. Configure this to accept + * DPoP-bound access tokens at ingress. + */ + dpop?: ApiClientOptions['dpop']; + /** + * A pre-built core client. When provided it is used as is, so `clientId`, + * `clientSecret`, `scopeMatching`, `organizationPolicy`, `organizationId` and + * `dpop` are ignored: configure those on the client you pass in. `tokenStore` + * is still honoured because it is supplied per call. + */ + apiClient?: ApiClient; +} diff --git a/packages/ai-mcp/test/errors.spec.ts b/packages/ai-mcp/test/errors.spec.ts new file mode 100644 index 00000000..47f155b6 --- /dev/null +++ b/packages/ai-mcp/test/errors.spec.ts @@ -0,0 +1,109 @@ +import { AuthError } from '@auth0/auth0-api-js'; +import { describe, expect, it } from 'vitest'; +import { DownscopedTokenError, InsufficientScopeError, UnauthenticatedError } from '../src/errors.js'; + +describe('UnauthenticatedError', () => { + it('is a 401 invalid_token with a default message', () => { + const err = new UnauthenticatedError(); + expect(err.statusCode).toBe(401); + expect(err.code).toBe('invalid_token'); + expect(err.name).toBe('UnauthenticatedError'); + expect(err.message).toBe('Unauthenticated'); + }); + + it('accepts a message override and passes headers through', () => { + const headers = { 'WWW-Authenticate': 'Bearer realm="mcp"' }; + const err = new UnauthenticatedError('No context', headers); + expect(err.message).toBe('No context'); + expect(err.headers).toBe(headers); + }); + + it('has no headers by default', () => { + expect(new UnauthenticatedError().headers).toBeUndefined(); + }); + + it('is an instance of itself, AuthError and Error', () => { + const err = new UnauthenticatedError(); + expect(err).toBeInstanceOf(UnauthenticatedError); + expect(err).toBeInstanceOf(AuthError); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe('InsufficientScopeError', () => { + it('is a 403 insufficient_scope with a generic default message', () => { + const err = new InsufficientScopeError(['read:orders']); + expect(err.statusCode).toBe(403); + expect(err.code).toBe('insufficient_scope'); + expect(err.name).toBe('InsufficientScopeError'); + expect(err.message).toBe('Missing one or more required scopes.'); + }); + + it('accepts a message override and passes headers through', () => { + const headers = { 'WWW-Authenticate': 'Bearer error="insufficient_scope"' }; + const err = new InsufficientScopeError(['a'], 'Nope', headers); + expect(err.message).toBe('Nope'); + expect(err.headers).toBe(headers); + }); + + it('never names the required scopes in the default message', () => { + const err = new InsufficientScopeError(['read:orders', 'write:orders']); + expect(err.message).not.toContain('read:orders'); + expect(err.message).not.toContain('write:orders'); + expect(String(err)).not.toContain('read:orders'); + }); + + it('exposes the required scopes as a frozen copy', () => { + const input = ['read:orders', 'write:orders']; + const err = new InsufficientScopeError(input); + input.push('admin'); + input[0] = 'changed'; + expect(err.requiredScopes).toEqual(['read:orders', 'write:orders']); + expect(Object.isFrozen(err.requiredScopes)).toBe(true); + expect(err.requiredScopes).not.toBe(input); + }); + + it('rejects mutation of the required scopes', () => { + const err = new InsufficientScopeError(['a']); + expect(() => (err.requiredScopes as string[]).push('b')).toThrow(TypeError); + }); + + it('is an instance of itself, AuthError and Error', () => { + const err = new InsufficientScopeError([]); + expect(err).toBeInstanceOf(InsufficientScopeError); + expect(err).toBeInstanceOf(AuthError); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe('DownscopedTokenError', () => { + it('is a 403 insufficient_scope with a generic default message', () => { + const err = new DownscopedTokenError(); + expect(err.statusCode).toBe(403); + expect(err.code).toBe('insufficient_scope'); + expect(err.name).toBe('DownscopedTokenError'); + expect(err.message).toBe('Exchanged token did not include all requested scopes.'); + }); + + it('accepts a message override and passes headers through', () => { + const headers = { 'WWW-Authenticate': ['Bearer error="insufficient_scope"'] }; + const err = new DownscopedTokenError('Narrowed', headers); + expect(err.message).toBe('Narrowed'); + expect(err.headers).toBe(headers); + }); + + it('carries no scope data', () => { + const err = new DownscopedTokenError(); + expect(Object.keys(err)).not.toContain('requiredScopes'); + expect(Object.keys(err).filter((key) => /scope/i.test(key))).toEqual([]); + }); + + it('is an instance of itself, AuthError and Error, and not of its siblings', () => { + const err = new DownscopedTokenError(); + expect(err).toBeInstanceOf(DownscopedTokenError); + expect(err).toBeInstanceOf(AuthError); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(InsufficientScopeError); + expect(err).not.toBeInstanceOf(UnauthenticatedError); + }); +}); diff --git a/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts b/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts new file mode 100644 index 00000000..4d843b9e --- /dev/null +++ b/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts @@ -0,0 +1,6 @@ +import { UnauthenticatedError } from '@auth0/ai-mcp'; +import type { McpClientOptions, Principal, RequestContext, RequestScopedMcpClient } from '@auth0/ai-mcp'; + +const error: Error = new UnauthenticatedError(); +export type Surface = [McpClientOptions, Principal, RequestContext, RequestScopedMcpClient]; +export default error; diff --git a/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts b/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts new file mode 100644 index 00000000..4d843b9e --- /dev/null +++ b/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts @@ -0,0 +1,6 @@ +import { UnauthenticatedError } from '@auth0/ai-mcp'; +import type { McpClientOptions, Principal, RequestContext, RequestScopedMcpClient } from '@auth0/ai-mcp'; + +const error: Error = new UnauthenticatedError(); +export type Surface = [McpClientOptions, Principal, RequestContext, RequestScopedMcpClient]; +export default error; diff --git a/packages/ai-mcp/test/smoke/fixtures/tsconfig.json b/packages/ai-mcp/test/smoke/fixtures/tsconfig.json new file mode 100644 index 00000000..be19a83e --- /dev/null +++ b/packages/ai-mcp/test/smoke/fixtures/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "ES2022", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["cjs-consumer.cts", "esm-consumer.mts"] +} diff --git a/packages/ai-mcp/test/smoke/module-formats.spec.ts b/packages/ai-mcp/test/smoke/module-formats.spec.ts new file mode 100644 index 00000000..37c66def --- /dev/null +++ b/packages/ai-mcp/test/smoke/module-formats.spec.ts @@ -0,0 +1,91 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageDir = fileURLToPath(new URL("../..", import.meta.url)); +const esmEntry = path.join(packageDir, "dist", "esm", "index.js"); +const cjsEntry = path.join(packageDir, "dist", "cjs", "index.js"); +const built = existsSync(esmEntry) && existsSync(cjsEntry); + +// CI builds before testing, so a missing build there is a failure, not a skip. +if (process.env.CI === "true" && !built) { + describe("built package formats", () => { + it("requires dist to be built in CI", () => { + expect.fail("dist/esm and dist/cjs must exist; run the build before the smoke tests"); + }); + }); +} + +const expectedValues = [ + "UnauthenticatedError", + "InsufficientScopeError", + "DownscopedTokenError", +]; + +// Resolves the package by its own name so the exports map is what gets exercised. +function run(args: string[]): string { + return execFileSync(process.execPath, args, { + cwd: packageDir, + encoding: "utf8", + }).trim(); +} + +describe.skipIf(!built)("built package formats", () => { + it("marks dist/esm as ES modules and dist/cjs as CommonJS", () => { + const esm = JSON.parse( + readFileSync(path.join(packageDir, "dist", "esm", "package.json"), "utf8"), + ); + const cjs = JSON.parse( + readFileSync(path.join(packageDir, "dist", "cjs", "package.json"), "utf8"), + ); + expect(esm).toEqual({ type: "module" }); + expect(cjs).toEqual({ type: "commonjs" }); + }); + + it("loads through the exports map with a native ESM import()", () => { + const out = run([ + "--input-type=module", + "-e", + "import('@auth0/ai-mcp').then((m) => console.log(JSON.stringify(Object.keys(m).sort())))", + ]); + expect(JSON.parse(out)).toEqual([...expectedValues].sort()); + }); + + it("loads through the exports map with a native CommonJS require()", () => { + const out = run([ + "-e", + "const m = require('@auth0/ai-mcp'); console.log(JSON.stringify(Object.keys(m).sort()))", + ]); + expect(JSON.parse(out)).toEqual([...expectedValues].sort()); + }); + + it("exports constructible error classes from both formats", () => { + const esm = run([ + "--input-type=module", + "-e", + "import('@auth0/ai-mcp').then((m) => console.log(new m.UnauthenticatedError() instanceof Error))", + ]); + const cjs = run([ + "-e", + "const m = require('@auth0/ai-mcp'); console.log(new m.UnauthenticatedError() instanceof Error)", + ]); + expect(esm).toBe("true"); + expect(cjs).toBe("true"); + }); +}); + +describe.skipIf(!built)("type declarations", () => { + const tsc = createRequire(import.meta.url).resolve("typescript/bin/tsc"); + const fixtureConfig = path.join(packageDir, "test", "smoke", "fixtures", "tsconfig.json"); + + it("typechecks a CommonJS and an ESM consumer through the exports map", () => { + const out = execFileSync(process.execPath, [tsc, "--noEmit", "-p", fixtureConfig], { + cwd: packageDir, + encoding: "utf8", + }); + expect(out.trim()).toBe(""); + }); +}); diff --git a/packages/ai-mcp/test/types.spec.ts b/packages/ai-mcp/test/types.spec.ts new file mode 100644 index 00000000..147fb8ec --- /dev/null +++ b/packages/ai-mcp/test/types.spec.ts @@ -0,0 +1,168 @@ +import type { ApiClient, IndexedTokenStore, OnBehalfOfTokenResult, TokenSet, TokenStore } from '@auth0/auth0-api-js'; +import { describe, expectTypeOf, it } from 'vitest'; +import type { + ClientCredentialsTokenResult, + McpClientOptions, + OnBehalfOfOptions, + OrganizationPolicy, + Principal, + RequestContext, + RequestScopedMcpClient, + ScopeMatching, + ServiceTokenResult, +} from '../src/types.js'; + +const principal: Principal = { + sub: 'auth0|123', + expiresAt: 1_900_000_000, + scopes: ['read:orders'], + permissions: null, + clientId: null, + orgId: null, +}; + +describe('Principal', () => { + it('accepts a complete value', () => { + expectTypeOf(principal).toMatchTypeOf(); + }); + + it('has the expected field types', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('is readonly', () => { + const mutate = (p: Principal) => { + // @ts-expect-error sub is readonly + p.sub = 'other'; + // @ts-expect-error expiresAt is readonly + p.expiresAt = 1; + // @ts-expect-error orgId is readonly + p.orgId = 'org_1'; + }; + void mutate; + }); + + it('rejects a value missing a required field', () => { + // @ts-expect-error sub is required + const bad: Principal = { expiresAt: 1, scopes: [], permissions: null, clientId: null, orgId: null }; + void bad; + }); +}); + +describe('RequestContext', () => { + it('has a principal and a request scoped client, both readonly', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + const mutate = (ctx: RequestContext) => { + // @ts-expect-error principal is readonly + ctx.principal = principal; + // @ts-expect-error mcpClient is readonly + ctx.mcpClient = {} as RequestScopedMcpClient; + }; + void mutate; + }); +}); + +describe('RequestScopedMcpClient', () => { + it('exposes the expected methods and no way to read the token', () => { + expectTypeOf().toEqualTypeOf< + (options: OnBehalfOfOptions) => Promise + >(); + expectTypeOf().toEqualTypeOf<() => Principal>(); + expectTypeOf().returns.resolves.toEqualTypeOf(); + expectTypeOf().returns.resolves.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + 'getTokenOnBehalfOf' | 'getClientCredentialsToken' | 'getTokenForService' | 'getPrincipal' + >(); + }); + + it('requires an audience for OBO and a connection for service tokens', () => { + const call = (client: RequestScopedMcpClient) => { + // @ts-expect-error audience is required + void client.getTokenOnBehalfOf({ scope: 'a' }); + // @ts-expect-error connection is required + void client.getTokenForService({ loginHint: 'x' }); + }; + void call; + }); +}); + +describe('result types', () => { + it('extends the core token set with grantedScopes', () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('tracks the core connection token return type', () => { + expectTypeOf().toEqualTypeOf< + Awaited> + >(); + }); +}); + +describe('McpClientOptions', () => { + it('accepts the minimal options', () => { + const options: McpClientOptions = { domain: 'example.auth0.com', audience: 'https://mcp.example.com' }; + void options; + }); + + it('requires domain and audience', () => { + // @ts-expect-error audience is required + const noAudience: McpClientOptions = { domain: 'example.auth0.com' }; + // @ts-expect-error domain is required + const noDomain: McpClientOptions = { audience: 'https://mcp.example.com' }; + void noAudience; + void noDomain; + }); + + it('accepts the full option set', () => { + const options: McpClientOptions = { + domain: 'example.auth0.com', + audience: 'https://mcp.example.com', + clientId: 'id', + clientSecret: 'secret', + tokenStore: {} as IndexedTokenStore, + scopeMatching: 'non_strict', + organizationPolicy: 'required', + organizationId: ['org_1', 'org_2'], + dpop: { mode: 'required' }, + apiClient: {} as ApiClient, + }; + void options; + }); + + it('accepts a plain or an indexed token store', () => { + const indexed: McpClientOptions = { domain: 'd', audience: 'a', tokenStore: {} as IndexedTokenStore }; + const plain: McpClientOptions = { domain: 'd', audience: 'a', tokenStore: {} as TokenStore }; + // @ts-expect-error a store must at least implement get + const bad: McpClientOptions = { domain: 'd', audience: 'a', tokenStore: { set: async () => {}, delete: async () => {} } }; + void indexed; + void plain; + void bad; + }); + + it('rejects a bad scopeMatching or organizationPolicy', () => { + // @ts-expect-error not a scope matching mode + const badMatching: McpClientOptions = { domain: 'd', audience: 'a', scopeMatching: 'loose' }; + // @ts-expect-error not an organization policy + const badPolicy: McpClientOptions = { domain: 'd', audience: 'a', organizationPolicy: 'maybe' }; + void badMatching; + void badPolicy; + }); + + it('accepts a single organization id or a list, and nothing else', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('literal unions', () => { + it('are exactly the supported values', () => { + expectTypeOf().toEqualTypeOf<'strict' | 'non_strict'>(); + expectTypeOf().toEqualTypeOf<'required' | 'allow'>(); + }); +}); diff --git a/packages/ai-mcp/tsconfig.build.json b/packages/ai-mcp/tsconfig.build.json new file mode 100644 index 00000000..ed1a9c80 --- /dev/null +++ b/packages/ai-mcp/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["test/**/*", "node_modules", "dist"] +} diff --git a/packages/ai-mcp/tsconfig.json b/packages/ai-mcp/tsconfig.json new file mode 100644 index 00000000..9b89e305 --- /dev/null +++ b/packages/ai-mcp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tools/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist", + "lib": ["dom", "ES2022"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist", "test/smoke/fixtures"] +} diff --git a/packages/ai-mcp/vitest.config.ts b/packages/ai-mcp/vitest.config.ts new file mode 100644 index 00000000..9d33371b --- /dev/null +++ b/packages/ai-mcp/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.spec.ts", "test/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**"], + }, + }, +}); From 3691874d53a1ebfe678027f990036fdf28f3826f Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:11:07 +0530 Subject: [PATCH 02/31] test(ai-mcp): add Auth0 and downstream API test support 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 --- .../ai-mcp/test/support/auth0-handlers.ts | 269 +++++++++++ packages/ai-mcp/test/support/index.ts | 3 + packages/ai-mcp/test/support/support.spec.ts | 442 ++++++++++++++++++ packages/ai-mcp/test/support/tokens.ts | 114 +++++ packages/ai-mcp/test/support/work-api.ts | 201 ++++++++ 5 files changed, 1029 insertions(+) create mode 100644 packages/ai-mcp/test/support/auth0-handlers.ts create mode 100644 packages/ai-mcp/test/support/index.ts create mode 100644 packages/ai-mcp/test/support/support.spec.ts create mode 100644 packages/ai-mcp/test/support/tokens.ts create mode 100644 packages/ai-mcp/test/support/work-api.ts diff --git a/packages/ai-mcp/test/support/auth0-handlers.ts b/packages/ai-mcp/test/support/auth0-handlers.ts new file mode 100644 index 00000000..84b30032 --- /dev/null +++ b/packages/ai-mcp/test/support/auth0-handlers.ts @@ -0,0 +1,269 @@ +/** + * MSW v2 handlers that emulate an Auth0 tenant: OIDC discovery, JWKS and a + * token endpoint supporting RFC 8693 token exchange and client_credentials. + * Issued access tokens are real signed JWTs so a downstream oracle can verify them. + */ + +import { HttpResponse, http } from "msw"; +import type { HttpHandler } from "msw"; +import { randomUUID } from "node:crypto"; +import { createLocalJWKSet, jwtVerify } from "jose"; +import { exportJwks, mintAccessToken } from "./tokens.js"; +import type { TestKey } from "./tokens.js"; + +export const TOKEN_EXCHANGE_GRANT = + "urn:ietf:params:oauth:grant-type:token-exchange"; +export const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + +/** How the token endpoint reports the granted scope. */ +export type ScopeMode = + | { kind: "echo" } + | { kind: "fixed"; scope: string } + | { kind: "absent" } + | { kind: "empty" }; + +export const scopeMode = { + /** Grant exactly what was requested (no `scope` field when nothing was requested). */ + echo: { kind: "echo" } as ScopeMode, + /** Always grant the given scope string, whatever was requested. */ + fixed: (scope: string): ScopeMode => ({ kind: "fixed", scope }), + /** Omit the `scope` field from the JSON response. */ + absent: { kind: "absent" } as ScopeMode, + /** Return `scope: ""`. */ + empty: { kind: "empty" } as ScopeMode, +}; + +export type FailureGrant = "token-exchange" | "client_credentials" | "any"; + +export interface Auth0Failure { + status: number; + /** Response body. Defaults to `{ error: "invalid_request" }`. */ + body?: Record; + /** Which grant fails. Defaults to `token-exchange`. */ + grant?: FailureGrant; +} + +export interface Auth0HandlerOptions { + /** Tenant domain, without scheme. Defaults to `tenant.example.com`. */ + domain?: string; + /** Signing key. Its public half is served from the JWKS endpoint. */ + key: TestKey; + scope?: ScopeMode; + /** `expires_in` seconds on issued tokens. Defaults to 3600. */ + expiresIn?: number; + failure?: Auth0Failure | null; + /** Audience used when a request carries none. */ + defaultAudience?: string; +} + +export interface RecordedTokenRequest { + grantType: string; + params: Record; + /** Client id from the form body or, failing that, the HTTP Basic header. */ + clientId: string | undefined; +} + +export interface Auth0HandlerState { + /** + * Number of RFC 8693 token-exchange requests received, including requests + * that were rejected by validation or answered with a configured failure. + */ + exchangeCount: number; + /** Number of token-exchange requests answered with a 200 and an issued token. */ + successfulExchangeCount: number; + /** Number of client_credentials requests received. */ + clientCredentialsCount: number; + lastExchangeRequest: RecordedTokenRequest | null; + lastClientCredentialsRequest: RecordedTokenRequest | null; + /** Every token endpoint request in arrival order. */ + requests: RecordedTokenRequest[]; + scope: ScopeMode; + expiresIn: number; + failure: Auth0Failure | null; + /** Restores counters, recordings and configuration to the initial options. */ + reset(): void; +} + +export interface Auth0Handlers { + handlers: HttpHandler[]; + state: Auth0HandlerState; + /** `https:///` */ + issuer: string; + tokenEndpoint: string; + jwksUri: string; + key: TestKey; +} + +function resolveScope(mode: ScopeMode, requested: string | undefined): string | undefined { + switch (mode.kind) { + case "echo": + return requested; + case "fixed": + return mode.scope; + case "absent": + return undefined; + case "empty": + return ""; + } +} + +function oauthError(status: number, error: string, description: string) { + return HttpResponse.json({ error, error_description: description }, { status }); +} + +/** Extracts the client id from an HTTP Basic `Authorization` header. */ +function basicClientId(header: string | null): string | undefined { + const match = /^Basic +(\S+)$/i.exec(header ?? ""); + if (!match) return undefined; + const decoded = Buffer.from(match[1]!, "base64").toString("utf8"); + const id = decoded.split(":")[0]; + return id ? decodeURIComponent(id) : undefined; +} + +export function createAuth0Handlers(options: Auth0HandlerOptions): Auth0Handlers { + const domain = options.domain ?? "tenant.example.com"; + const issuer = `https://${domain}/`; + const tokenEndpoint = `https://${domain}/oauth/token`; + const jwksUri = `https://${domain}/.well-known/jwks.json`; + const initialScope = options.scope ?? scopeMode.echo; + const initialExpiresIn = options.expiresIn ?? 3600; + const initialFailure = options.failure ?? null; + + const state: Auth0HandlerState = { + exchangeCount: 0, + successfulExchangeCount: 0, + clientCredentialsCount: 0, + lastExchangeRequest: null, + lastClientCredentialsRequest: null, + requests: [], + scope: initialScope, + expiresIn: initialExpiresIn, + failure: initialFailure, + reset() { + state.exchangeCount = 0; + state.successfulExchangeCount = 0; + state.clientCredentialsCount = 0; + state.lastExchangeRequest = null; + state.lastClientCredentialsRequest = null; + state.requests = []; + state.scope = initialScope; + state.expiresIn = initialExpiresIn; + state.failure = initialFailure; + }, + }; + + const subjectKeys = createLocalJWKSet(exportJwks(options.key)); + + async function issue( + params: Record, + clientId: string, + sub: string, + granted: string | undefined, + orgId: unknown + ): Promise { + return mintAccessToken(options.key, { + iss: issuer, + aud: params["audience"] ?? options.defaultAudience ?? false, + sub, + scope: granted, + expiresIn: state.expiresIn, + jti: randomUUID(), + claims: { + client_id: clientId, + ...(orgId !== undefined && { org_id: orgId }), + }, + }); + } + + const handlers = [ + http.get(`https://${domain}/.well-known/openid-configuration`, () => + HttpResponse.json({ + issuer, + jwks_uri: jwksUri, + token_endpoint: tokenEndpoint, + id_token_signing_alg_values_supported: [options.key.alg], + }) + ), + + http.get(jwksUri, () => HttpResponse.json(exportJwks(options.key))), + + http.post(tokenEndpoint, async ({ request }) => { + const params = Object.fromEntries(new URLSearchParams(await request.text())); + const grantType = params["grant_type"] ?? ""; + const clientId = params["client_id"] || basicClientId(request.headers.get("authorization")); + const recorded: RecordedTokenRequest = { grantType, params, clientId }; + state.requests.push(recorded); + + const isExchange = grantType === TOKEN_EXCHANGE_GRANT; + const isClientCredentials = grantType === "client_credentials"; + + if (isExchange) { + state.exchangeCount++; + state.lastExchangeRequest = recorded; + } else if (isClientCredentials) { + state.clientCredentialsCount++; + state.lastClientCredentialsRequest = recorded; + } else { + return HttpResponse.json( + { error: "unsupported_grant_type", error_description: `Unsupported: ${grantType}` }, + { status: 400 } + ); + } + + const failure = state.failure; + if (failure) { + const target = failure.grant ?? "token-exchange"; + const applies = + target === "any" || + (target === "token-exchange" && isExchange) || + (target === "client_credentials" && isClientCredentials); + if (applies) { + return HttpResponse.json(failure.body ?? { error: "invalid_request" }, { + status: failure.status, + }); + } + } + + if (!clientId) { + return oauthError(400, "invalid_request", "client_id is required"); + } + + let sub = `${clientId}@clients`; + let orgId: unknown; + if (isExchange) { + const subjectToken = params["subject_token"]; + if (!subjectToken) { + return oauthError(400, "invalid_request", "subject_token is required"); + } + if (!params["subject_token_type"]) { + return oauthError(400, "invalid_request", "subject_token_type is required"); + } + try { + const { payload } = await jwtVerify(subjectToken, subjectKeys, { issuer }); + sub = payload.sub ?? "unknown-subject"; + orgId = payload["org_id"]; + } catch (err) { + return oauthError( + 400, + "invalid_grant", + `invalid subject_token: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + const granted = resolveScope(state.scope, params["scope"]); + const accessToken = await issue(params, clientId, sub, granted, orgId); + if (isExchange) state.successfulExchangeCount++; + + return HttpResponse.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: state.expiresIn, + ...(granted !== undefined && { scope: granted }), + ...(isExchange && { issued_token_type: ACCESS_TOKEN_TYPE }), + }); + }), + ]; + + return { handlers, state, issuer, tokenEndpoint, jwksUri, key: options.key }; +} diff --git a/packages/ai-mcp/test/support/index.ts b/packages/ai-mcp/test/support/index.ts new file mode 100644 index 00000000..3824d982 --- /dev/null +++ b/packages/ai-mcp/test/support/index.ts @@ -0,0 +1,3 @@ +export * from "./tokens.js"; +export * from "./auth0-handlers.js"; +export * from "./work-api.js"; diff --git a/packages/ai-mcp/test/support/support.spec.ts b/packages/ai-mcp/test/support/support.spec.ts new file mode 100644 index 00000000..366b44ec --- /dev/null +++ b/packages/ai-mcp/test/support/support.spec.ts @@ -0,0 +1,442 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { SignJWT, decodeJwt, createLocalJWKSet, jwtVerify } from "jose"; +import { setupServer } from "msw/node"; +import { + TOKEN_EXCHANGE_GRANT, + createAuth0Handlers, + createDownstreamOracle, + createTestKey, + exportJwks, + mintAccessToken, + mintUnsignedToken, + scopeMode, +} from "./index.js"; +import type { TestKey } from "./index.js"; + +const DOWNSTREAM = "https://downstream.example.com/resource"; +const AUDIENCE = "https://api.example.com"; + +let key: TestKey; +let otherKey: TestKey; +let ctx: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + otherKey = await createTestKey(); + server.listen({ onUnhandledRequest: "error" }); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); + +function use(options: Partial[0]> = {}) { + ctx = createAuth0Handlers({ key, ...options }); + server.use(...ctx.handlers); + return ctx; +} + +function post(params: Record) { + return fetch(ctx.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); +} + +async function exchange(extra: Record = {}) { + const subject = await mintAccessToken(key, { sub: "user|42", aud: "https://mcp.example.com" }); + return post({ + grant_type: TOKEN_EXCHANGE_GRANT, + subject_token: subject, + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + audience: AUDIENCE, + client_id: "client-a", + ...extra, + }); +} + +describe("auth0 handlers", () => { + it("serves discovery and a JWKS that verifies minted tokens", async () => { + use(); + const discovery = await ( + await fetch("https://tenant.example.com/.well-known/openid-configuration") + ).json(); + expect(discovery.issuer).toBe("https://tenant.example.com/"); + expect(discovery.token_endpoint).toBe(ctx.tokenEndpoint); + + const jwks = await (await fetch(discovery.jwks_uri)).json(); + expect(jwks.keys).toHaveLength(1); + expect(jwks.keys[0].kid).toBe(key.kid); + expect(jwks.keys[0].d).toBeUndefined(); + + const token = await mintAccessToken(key, { aud: AUDIENCE }); + const { payload } = await jwtVerify(token, createLocalJWKSet(jwks), { audience: AUDIENCE }); + expect(payload.sub).toBe("user|1"); + }); + + it("counts only token-exchange requests as exchanges", async () => { + use(); + await post({ grant_type: "client_credentials", client_id: "m2m", audience: AUDIENCE }); + expect(ctx.state.exchangeCount).toBe(0); + expect(ctx.state.clientCredentialsCount).toBe(1); + + await exchange(); + await exchange(); + expect(ctx.state.exchangeCount).toBe(2); + expect(ctx.state.clientCredentialsCount).toBe(1); + expect(ctx.state.requests).toHaveLength(3); + expect(ctx.state.lastExchangeRequest?.params["audience"]).toBe(AUDIENCE); + }); + + it("issues a verifiable JWT bound to the requested audience and subject", async () => { + use(); + const body = await (await exchange({ scope: "read:a read:b" })).json(); + expect(body.token_type).toBe("Bearer"); + expect(body.expires_in).toBe(3600); + const claims = decodeJwt(body.access_token); + expect(claims.aud).toBe(AUDIENCE); + expect(claims.sub).toBe("user|42"); + expect(claims.iss).toBe("https://tenant.example.com/"); + expect(claims["scope"]).toBe("read:a read:b"); + }); + + it("echoes the requested scope", async () => { + use({ scope: scopeMode.echo }); + const body = await (await exchange({ scope: "read:a" })).json(); + expect(body.scope).toBe("read:a"); + }); + + it("returns a fixed granted scope regardless of the request", async () => { + use({ scope: scopeMode.fixed("read:only") }); + const body = await (await exchange({ scope: "read:a write:a" })).json(); + expect(body.scope).toBe("read:only"); + expect(decodeJwt(body.access_token)["scope"]).toBe("read:only"); + }); + + it("omits scope entirely in absent mode", async () => { + use({ scope: scopeMode.absent }); + const body = await (await exchange({ scope: "read:a" })).json(); + expect("scope" in body).toBe(false); + expect("scope" in decodeJwt(body.access_token)).toBe(false); + }); + + it("returns an empty scope string in empty mode", async () => { + use({ scope: scopeMode.empty }); + const body = await (await exchange({ scope: "read:a" })).json(); + expect(body.scope).toBe(""); + }); + + it("honours a configurable expires_in", async () => { + use({ expiresIn: 30 }); + const body = await (await exchange()).json(); + expect(body.expires_in).toBe(30); + const claims = decodeJwt(body.access_token); + expect(claims.exp! - claims.iat!).toBe(30); + }); + + it("returns the configured failure for exchanges only", async () => { + use({ failure: { status: 403, body: { error: "access_denied", error_description: "no" } } }); + const res = await exchange(); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "access_denied", error_description: "no" }); + expect(ctx.state.exchangeCount).toBe(1); + + const cc = await post({ grant_type: "client_credentials", client_id: "m2m" }); + expect(cc.status).toBe(200); + }); + + it("can fail client_credentials requests", async () => { + use({ failure: { status: 500, grant: "client_credentials" } }); + const cc = await post({ grant_type: "client_credentials", client_id: "m2m" }); + expect(cc.status).toBe(500); + expect((await exchange()).status).toBe(200); + }); + + it("counts all received exchanges but only successful ones as successful", async () => { + use({ failure: { status: 503 } }); + await exchange(); + expect(ctx.state.exchangeCount).toBe(1); + expect(ctx.state.successfulExchangeCount).toBe(0); + + ctx.state.failure = null; + await exchange(); + await post({ grant_type: TOKEN_EXCHANGE_GRANT, client_id: "client-a" }); + expect(ctx.state.exchangeCount).toBe(3); + expect(ctx.state.successfulExchangeCount).toBe(1); + }); + + it("requires subject_token and subject_token_type on exchange", async () => { + use(); + const noSubject = await post({ grant_type: TOKEN_EXCHANGE_GRANT, client_id: "c" }); + expect(noSubject.status).toBe(400); + expect((await noSubject.json()).error).toBe("invalid_request"); + + const subject = await mintAccessToken(key, {}); + const noType = await post({ + grant_type: TOKEN_EXCHANGE_GRANT, + client_id: "c", + subject_token: subject, + }); + expect(noType.status).toBe(400); + expect((await noType.json()).error).toBe("invalid_request"); + }); + + it("requires client_id on both grants", async () => { + use(); + const subject = await mintAccessToken(key, {}); + const ex = await post({ + grant_type: TOKEN_EXCHANGE_GRANT, + subject_token: subject, + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + }); + expect(ex.status).toBe(400); + expect((await ex.json()).error).toBe("invalid_request"); + + const cc = await post({ grant_type: "client_credentials" }); + expect(cc.status).toBe(400); + expect((await cc.json()).error).toBe("invalid_request"); + }); + + it("accepts client credentials from an HTTP Basic header", async () => { + use(); + const basic = Buffer.from("basic-client:secret").toString("base64"); + const res = await fetch(ctx.tokenEndpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${basic}`, + }, + body: new URLSearchParams({ grant_type: "client_credentials" }), + }); + expect(res.status).toBe(200); + expect(ctx.state.lastClientCredentialsRequest?.clientId).toBe("basic-client"); + expect(decodeJwt((await res.json()).access_token)["client_id"]).toBe("basic-client"); + }); + + it("rejects subject tokens with a bad signature or issuer as invalid_grant", async () => { + use(); + const forged = await mintAccessToken(otherKey, {}); + const wrongIssuer = await mintAccessToken(key, { iss: "https://evil.example.com/" }); + for (const subject_token of [forged, wrongIssuer, "not-a-jwt"]) { + const res = await exchange({ subject_token }); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("invalid_grant"); + } + expect(ctx.state.successfulExchangeCount).toBe(0); + }); + + it("copies org_id from the subject token to the issued token", async () => { + use(); + const subject = await mintAccessToken(key, { claims: { org_id: "org_123" } }); + const body = await (await exchange({ subject_token: subject })).json(); + expect(decodeJwt(body.access_token)["org_id"]).toBe("org_123"); + + const plain = await (await exchange()).json(); + expect("org_id" in decodeJwt(plain.access_token)).toBe(false); + }); + + it("rejects unsupported grants", async () => { + use(); + const res = await post({ grant_type: "password" }); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe("unsupported_grant_type"); + }); + + it("reset restores counters, recordings and configuration", async () => { + use({ scope: scopeMode.absent }); + await exchange(); + ctx.state.scope = scopeMode.empty; + ctx.state.failure = { status: 500 }; + ctx.state.reset(); + expect(ctx.state.exchangeCount).toBe(0); + expect(ctx.state.requests).toHaveLength(0); + expect(ctx.state.lastExchangeRequest).toBeNull(); + expect(ctx.state.failure).toBeNull(); + expect(ctx.state.scope).toEqual(scopeMode.absent); + }); +}); + +describe("downstream oracle", () => { + const issuer = "https://tenant.example.com/"; + const oracle = () => + createDownstreamOracle({ + jwks: exportJwks(key), + issuer, + audience: AUDIENCE, + requiredScope: "work:read", + }); + const valid = (over = {}) => + mintAccessToken(key, { iss: issuer, aud: AUDIENCE, scope: "work:read other", ...over }); + + it("accepts a valid token with or without the Bearer prefix", async () => { + const o = oracle(); + const token = await valid(); + const a = await o.verify(`Bearer ${token}`); + const b = await o.verify(token); + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + if (a.ok) expect(a.scopes).toEqual(["work:read", "other"]); + }); + + it("rejects a wrong audience", async () => { + const v = await oracle().verify(await valid({ aud: "https://other.example.com" })); + expect(v).toMatchObject({ ok: false, status: 401, reason: "wrong_audience" }); + }); + + it("rejects a wrong issuer", async () => { + const v = await oracle().verify(await valid({ iss: "https://evil.example.com/" })); + expect(v).toMatchObject({ ok: false, status: 401, reason: "wrong_issuer" }); + }); + + it("rejects an expired token", async () => { + const past = Math.floor(Date.now() / 1000) - 3600; + const v = await oracle().verify(await valid({ exp: past, iat: past - 60 })); + expect(v).toMatchObject({ ok: false, status: 401, reason: "expired" }); + }); + + it("rejects a token signed by another key", async () => { + const sameKid = { ...otherKey, kid: key.kid }; + const token = await mintAccessToken(sameKid, { iss: issuer, aud: AUDIENCE, scope: "work:read" }); + const v = await oracle().verify(token); + expect(v).toMatchObject({ ok: false, status: 401, reason: "bad_signature" }); + }); + + it("rejects a token with an unknown key id", async () => { + const token = await mintAccessToken(otherKey, { iss: issuer, aud: AUDIENCE, scope: "work:read" }); + const v = await oracle().verify(token); + expect(v).toMatchObject({ ok: false, status: 401, reason: "bad_signature" }); + }); + + it("rejects alg none tokens", async () => { + const token = mintUnsignedToken({ iss: issuer, aud: AUDIENCE, scope: "work:read" }); + const v = await oracle().verify(token); + expect(v).toMatchObject({ ok: false, status: 401, reason: "bad_signature" }); + }); + + it("rejects an HS256 token signed with the RSA public key material", async () => { + const secret = new TextEncoder().encode(JSON.stringify(key.publicJwk)); + const token = await new SignJWT({ iss: issuer, aud: AUDIENCE, sub: "u", scope: "work:read" }) + .setProtectedHeader({ alg: "HS256", kid: key.kid }) + .setExpirationTime("1h") + .sign(secret); + const v = await oracle().verify(token); + expect(v).toMatchObject({ ok: false, status: 401, reason: "bad_signature" }); + }); + + it("rejects tokens missing exp or sub", async () => { + const o = oracle(); + expect((await o.verify(await valid({ exp: false }))).ok).toBe(false); + expect((await o.verify(await valid({ sub: false }))).ok).toBe(false); + }); + + it("rejects tokens missing aud or iss", async () => { + const o = oracle(); + expect(await o.verify(await valid({ aud: false }))).toMatchObject({ + ok: false, + reason: "wrong_audience", + }); + expect(await o.verify(await valid({ iss: false }))).toMatchObject({ + ok: false, + reason: "wrong_issuer", + }); + }); + + it("reads scope from scp as a string or array and tolerates extra whitespace", async () => { + const o = oracle(); + const asString = await o.verify(await valid({ scope: undefined, claims: { scp: "a work:read" } })); + const asArray = await o.verify(await valid({ scope: undefined, claims: { scp: ["work:read"] } })); + const spaced = await o.verify(await valid({ scope: "a\twork:read\n" })); + expect(asString.ok && asString.scopes).toEqual(["a", "work:read"]); + expect(asArray.ok).toBe(true); + expect(spaced.ok).toBe(true); + }); + + it("handler requires a strict Bearer Authorization header", async () => { + const o = oracle(); + server.use(o.handler(DOWNSTREAM)); + const token = await valid(); + const call = async (authorization?: string) => { + const res = await fetch(DOWNSTREAM, authorization ? { headers: { authorization } } : {}); + return { status: res.status, body: await res.json() }; + }; + expect((await call(token)).body.reason).toBe("malformed"); + expect((await call(`Basic ${token}`)).body.reason).toBe("malformed"); + expect((await call(`Bearer ${token} extra`)).body.reason).toBe("malformed"); + expect((await call("Bearer")).body.reason).toBe("missing_token"); + expect((await call()).body.reason).toBe("missing_token"); + expect((await call(`Bearer ${token}`)).status).toBe(200); + }); + + it("rejects a missing scope with 403", async () => { + const v = await oracle().verify(await valid({ scope: "other" })); + expect(v).toMatchObject({ ok: false, status: 403, reason: "missing_scope" }); + }); + + it("rejects missing and malformed tokens", async () => { + const o = oracle(); + expect(await o.verify(undefined)).toMatchObject({ ok: false, reason: "missing_token" }); + expect(await o.verify("Bearer not-a-jwt")).toMatchObject({ ok: false, reason: "malformed" }); + }); + + it("accepts a token issued by the auth0 handlers", async () => { + use({ scope: scopeMode.fixed("work:read") }); + const body = await (await exchange()).json(); + const v = await oracle().verify(`Bearer ${body.access_token}`); + expect(v.ok).toBe(true); + }); + + it("works as an MSW handler and records calls", async () => { + const o = oracle(); + server.use(o.handler(DOWNSTREAM)); + const ok = await fetch(DOWNSTREAM, { headers: { authorization: `Bearer ${await valid()}` } }); + expect(ok.status).toBe(200); + const denied = await fetch(DOWNSTREAM, { + headers: { authorization: `Bearer ${await valid({ scope: "other" })}` }, + }); + expect(denied.status).toBe(403); + const missing = await fetch(DOWNSTREAM); + expect(missing.status).toBe(401); + expect(o.calls).toHaveLength(3); + o.reset(); + expect(o.calls).toHaveLength(0); + }); +}); + +describe("token minting", () => { + it("writes configurable claims and header, and can omit each registered claim", async () => { + const token = await mintAccessToken(key, { + iss: "https://issuer.example.test/", + aud: ["a", "b"], + sub: "user|7", + scope: "", + jti: "jti-1", + claims: { azp: "client-x", client_id: "client-x", sid: "sid-1", org_id: "org_9" }, + header: { kid: "other-kid" }, + }); + const claims = decodeJwt(token); + expect(claims).toMatchObject({ + iss: "https://issuer.example.test/", + aud: ["a", "b"], + sub: "user|7", + scope: "", + jti: "jti-1", + azp: "client-x", + client_id: "client-x", + sid: "sid-1", + org_id: "org_9", + }); + expect(JSON.parse(Buffer.from(token.split(".")[0]!, "base64url").toString()).kid).toBe("other-kid"); + + const bare = decodeJwt( + await mintAccessToken(key, { iss: false, aud: false, sub: false, exp: false, iat: false }) + ); + expect(Object.keys(bare)).toEqual([]); + expect("scope" in decodeJwt(await mintAccessToken(key))).toBe(false); + }); +}); diff --git a/packages/ai-mcp/test/support/tokens.ts b/packages/ai-mcp/test/support/tokens.ts new file mode 100644 index 00000000..17d5e6f0 --- /dev/null +++ b/packages/ai-mcp/test/support/tokens.ts @@ -0,0 +1,114 @@ +/** + * Token utilities for tests: key generation, JWKS export and access token minting. + * + * Written and tested against jose 6. + */ + +import { SignJWT, exportJWK, generateKeyPair } from "jose"; +import type { JSONWebKeySet, JWK, JWTHeaderParameters, JWTPayload } from "jose"; + +type GeneratedKeyPair = Awaited>; + +export interface TestKey { + kid: string; + alg: string; + privateKey: GeneratedKeyPair["privateKey"]; + /** Public JWK including `kid`, `alg` and `use`. */ + publicJwk: JWK; +} + +export interface CreateTestKeyOptions { + kid?: string; + /** Signing algorithm. Defaults to RS256. */ + alg?: string; +} + +let keyCounter = 0; + +/** Generates a fresh signing key pair. */ +export async function createTestKey( + options: CreateTestKeyOptions = {} +): Promise { + const alg = options.alg ?? "RS256"; + const kid = options.kid ?? `test-key-${++keyCounter}`; + const { privateKey, publicKey } = await generateKeyPair(alg, { + extractable: true, + }); + const publicJwk = await exportJWK(publicKey); + publicJwk.kid = kid; + publicJwk.alg = alg; + publicJwk.use = "sig"; + return { kid, alg, privateKey, publicJwk }; +} + +/** Builds a JWKS document from the public halves of the given keys. */ +export function exportJwks(...keys: TestKey[]): JSONWebKeySet { + return { keys: keys.map((key) => ({ ...key.publicJwk })) }; +} + +export interface MintAccessTokenOptions { + /** Issuer. Pass `false` to omit the claim. */ + iss?: string | false; + /** Audience. The claim is omitted when not given; `false` also omits it. */ + aud?: string | string[] | false; + /** Subject. Pass `false` to omit the claim. */ + sub?: string | false; + /** + * Space-delimited scope claim. Omitted when `undefined`; `""` is written + * as an empty string. + */ + scope?: string; + /** Absolute expiry in epoch seconds. Pass `false` to omit the claim. */ + exp?: number | false; + /** Seconds from now until expiry, used when `exp` is not given. Defaults to 3600. */ + expiresIn?: number; + /** Issued-at in epoch seconds. Pass `false` to omit the claim. */ + iat?: number | false; + /** Unique token id. */ + jti?: string; + /** Extra claims; these win over the named options above. */ + claims?: Record; + /** Extra or overriding protected header parameters (for example a different `kid`). */ + header?: Partial & Record; +} + +/** Signs a JWT with `key`. Every claim and header field can be overridden. */ +export async function mintAccessToken( + key: TestKey, + options: MintAccessTokenOptions = {} +): Promise { + const now = Math.floor(Date.now() / 1000); + const payload: JWTPayload = {}; + + if (options.iss !== false) payload.iss = options.iss ?? "https://tenant.example.com/"; + if (options.aud !== false && options.aud !== undefined) payload.aud = options.aud; + if (options.sub !== false) payload.sub = options.sub ?? "user|1"; + if (options.scope !== undefined) payload["scope"] = options.scope; + if (options.jti !== undefined) payload.jti = options.jti; + if (options.iat !== false) payload.iat = options.iat ?? now; + if (options.exp !== false) { + payload.exp = options.exp ?? now + (options.expiresIn ?? 3600); + } + Object.assign(payload, options.claims); + + const header = { + alg: key.alg, + kid: key.kid, + typ: "at+jwt", + ...options.header, + } as JWTHeaderParameters; + + return new SignJWT(payload).setProtectedHeader(header).sign(key.privateKey); +} + +function base64url(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +/** Builds an unsigned (`alg: none`) token for negative tests. */ +export function mintUnsignedToken( + claims: Record, + header: Record = {} +): string { + return `${base64url({ alg: "none", typ: "JWT", ...header })}.${base64url(claims)}.`; +} diff --git a/packages/ai-mcp/test/support/work-api.ts b/packages/ai-mcp/test/support/work-api.ts new file mode 100644 index 00000000..9d8c5ab9 --- /dev/null +++ b/packages/ai-mcp/test/support/work-api.ts @@ -0,0 +1,201 @@ +/** + * Downstream API oracle. Verifies a presented bearer JWT the way a real + * resource server would and returns a structured verdict. Usable directly as a + * function or as an MSW handler for a downstream URL. + */ + +import { HttpResponse, http } from "msw"; +import type { HttpHandler } from "msw"; +import { createLocalJWKSet, jwtVerify } from "jose"; +import type { JSONWebKeySet, JWTPayload } from "jose"; + +export interface DownstreamOracleOptions { + /** Keys trusted to sign tokens (the test JWKS). */ + jwks: JSONWebKeySet; + issuer: string; + audience: string; + /** Scope(s) that must all be present in the `scope` or `scp` claim. */ + requiredScope?: string | string[]; + /** Accepted signature algorithms. Defaults to RS256. */ + algorithms?: string[]; +} + +export type DownstreamRejection = + | "missing_token" + | "malformed" + | "expired" + | "bad_signature" + | "wrong_issuer" + | "wrong_audience" + | "missing_scope"; + +export type DownstreamVerdict = + | { ok: true; status: 200; claims: JWTPayload; scopes: string[] } + | { + ok: false; + /** 401 for invalid tokens, 403 for insufficient scope. */ + status: 401 | 403; + reason: DownstreamRejection; + message: string; + }; + +export interface DownstreamCall { + authorization: string | null; + verdict: DownstreamVerdict; +} + +export interface DownstreamOracle { + /** + * Lenient helper for direct calls: accepts a `Bearer ` header value or + * a bare JWT. + */ + verify(authorizationOrToken: string | null | undefined): Promise; + /** + * Strict Authorization header verification, as used by `handler`. Only + * `Bearer ` is accepted. A missing, empty or bare `Bearer` header is + * `missing_token`; any other scheme, or a raw JWT without a scheme, is + * `malformed`. + */ + verifyAuthorization(header: string | null | undefined): Promise; + /** MSW handler answering any method on `url` with the verdict. */ + handler(url: string): HttpHandler; + /** Every call seen by `verify` and by handlers created from this oracle. */ + readonly calls: DownstreamCall[]; + reset(): void; +} + +function scopesOf(payload: JWTPayload): string[] { + const scope = payload["scope"]; + const scp = payload["scp"]; + const split = (value: string) => value.split(/\s+/).filter((s) => s.length > 0); + const fromScope = typeof scope === "string" ? split(scope) : []; + const fromScp = + typeof scp === "string" + ? split(scp) + : Array.isArray(scp) + ? scp.filter((s): s is string => typeof s === "string") + : []; + return [...fromScope, ...fromScp]; +} + +function reject( + status: 401 | 403, + reason: DownstreamRejection, + message: string +): DownstreamVerdict { + return { ok: false, status, reason, message }; +} + +function mapJoseError(err: unknown): DownstreamVerdict { + const code = (err as { code?: string } | null)?.code; + const claim = (err as { claim?: string } | null)?.claim; + const message = err instanceof Error ? err.message : String(err); + + if (code === "ERR_JWT_EXPIRED") return reject(401, "expired", message); + if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && claim === "iss") { + return reject(401, "wrong_issuer", message); + } + if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && claim === "aud") { + return reject(401, "wrong_audience", message); + } + if ( + code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" || + code === "ERR_JWKS_NO_MATCHING_KEY" || + code === "ERR_JWKS_MULTIPLE_MATCHING_KEYS" || + code === "ERR_JOSE_ALG_NOT_ALLOWED" + ) { + return reject(401, "bad_signature", message); + } + return reject(401, "malformed", message); +} + +export function createDownstreamOracle( + options: DownstreamOracleOptions +): DownstreamOracle { + const keySet = createLocalJWKSet(options.jwks); + const required = + options.requiredScope === undefined + ? [] + : Array.isArray(options.requiredScope) + ? options.requiredScope + : [options.requiredScope]; + const calls: DownstreamCall[] = []; + + async function verifyToken(token: string): Promise { + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, keySet, { + issuer: options.issuer, + audience: options.audience, + algorithms: options.algorithms ?? ["RS256"], + requiredClaims: ["exp", "sub"], + })); + } catch (err) { + return mapJoseError(err); + } + + const scopes = scopesOf(payload); + const missing = required.filter((s) => !scopes.includes(s)); + if (missing.length > 0) { + return reject(403, "missing_scope", `missing required scope: ${missing.join(" ")}`); + } + return { ok: true, status: 200, claims: payload, scopes }; + } + + async function record( + authorization: string | null | undefined, + verdict: DownstreamVerdict + ): Promise { + calls.push({ authorization: authorization ?? null, verdict }); + return verdict; + } + + async function verify( + authorizationOrToken: string | null | undefined + ): Promise { + const raw = authorizationOrToken?.trim() ?? ""; + const token = raw.replace(/^Bearer +/i, ""); + const verdict = + token.length === 0 || /^Bearer$/i.test(token) + ? reject(401, "missing_token", "no bearer token presented") + : await verifyToken(token); + return record(authorizationOrToken, verdict); + } + + async function verifyAuthorization( + header: string | null | undefined + ): Promise { + const raw = header?.trim() ?? ""; + let verdict: DownstreamVerdict; + if (raw.length === 0 || /^Bearer$/i.test(raw)) { + verdict = reject(401, "missing_token", "no bearer token presented"); + } else { + const match = /^Bearer +(\S+)$/i.exec(raw); + verdict = match + ? await verifyToken(match[1]!) + : reject(401, "malformed", "Authorization header must use the Bearer scheme"); + } + return record(header, verdict); + } + + return { + verify, + verifyAuthorization, + handler(url: string) { + return http.all(url, async ({ request }) => { + const verdict = await verifyAuthorization(request.headers.get("authorization")); + if (verdict.ok) { + return HttpResponse.json({ ok: true, scopes: verdict.scopes }, { status: 200 }); + } + return HttpResponse.json( + { ok: false, reason: verdict.reason, message: verdict.message }, + { status: verdict.status } + ); + }); + }, + calls, + reset() { + calls.length = 0; + }, + }; +} From 9ee84dd4319c8abe43c8430e780af4563a7d9b6d Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:13:37 +0530 Subject: [PATCH 03/31] chore(ai-mcp): mark package private until the release channel is decided Co-Authored-By: Claude --- packages/ai-mcp/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 096abc06..06968b29 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -1,6 +1,7 @@ { "name": "@auth0/ai-mcp", "version": "0.0.0", + "private": true, "description": "Auth0 token delegation and request authentication for Model Context Protocol servers", "repository": { "type": "git", From 7759ce20f67a14b583262810c953131ceab10186 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:14:04 +0530 Subject: [PATCH 04/31] chore(ai-mcp): declare jose as a runtime dependency The encrypted token store uses jose for JWE at runtime, so it cannot stay a development-only dependency. Co-Authored-By: Claude --- packages/ai-mcp/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 06968b29..401bae2f 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -47,7 +47,8 @@ }, "license": "Apache-2.0", "dependencies": { - "@auth0/auth0-api-js": "^1.7.0-beta.0" + "@auth0/auth0-api-js": "^1.7.0-beta.0", + "jose": "^6.0.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.31.0" @@ -65,7 +66,6 @@ "@vitest/coverage-v8": "^3.0.9", "eslint": "^9.22.0", "globals": "^15.11.0", - "jose": "^6.0.0", "msw": "^2.0.0", "typedoc": "^0.27.6", "typescript": "^5.7.3", From 3fd22d6d9c85bfc1da5487597dad50a0fc6d4243 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:25:08 +0530 Subject: [PATCH 05/31] feat(ai-mcp): build the caller principal from verified claims 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 --- packages/ai-mcp/src/principal.ts | 73 +++++++++ packages/ai-mcp/test/principal.spec.ts | 216 +++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 packages/ai-mcp/src/principal.ts create mode 100644 packages/ai-mcp/test/principal.spec.ts diff --git a/packages/ai-mcp/src/principal.ts b/packages/ai-mcp/src/principal.ts new file mode 100644 index 00000000..069ff845 --- /dev/null +++ b/packages/ai-mcp/src/principal.ts @@ -0,0 +1,73 @@ +import type { VerifiedAccessTokenClaims } from '@auth0/auth0-api-js'; +import { UnauthenticatedError } from './errors.js'; +import type { Principal } from './types.js'; + +/** Reads a claim only when it is an own property, so prototype values never count. */ +function ownClaim(claims: object, name: string): unknown { + return Object.hasOwn(claims, name) ? (claims as Record)[name] : undefined; +} + +/** Returns the value when it is a non-empty string, otherwise `null`. */ +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +/** + * Builds a {@link Principal} from the claims of an already verified access token. + * + * This function does not verify anything. Pass only claims returned by token + * verification. Every claim is read as an own property of `claims`; values + * inherited through the prototype chain are ignored. + * + * - `sub` must be a non-blank string. + * - `expiresAt` comes from `exp`, which must be a finite number of epoch seconds. + * - `clientId` is the `client_id` claim when it is a non-empty string, otherwise + * the `azp` claim when that is a non-empty string, otherwise `null`. + * - `orgId` is the `org_id` claim when it is a non-empty string, otherwise `null`. + * - `scopes` come from the space-delimited `scope` claim only. Empty entries are + * dropped, and a missing or non-string claim gives an empty list. + * - `permissions` come from the `permissions` claim. `null` means the claim is + * absent, so permissions are unknown. When the claim is present but is not an + * array the result is an empty list (no permissions), and non-string entries + * are dropped, so malformed data never widens access. + * + * The returned principal and its arrays are frozen. + * + * @param claims - Verified access token claims. + * @returns The resolved principal. + * @throws {UnauthenticatedError} When `sub` is missing, blank or not a string, or + * `exp` is missing or not a finite number. The message never contains claim values. + * The error carries no `WWW-Authenticate` challenge, so the HTTP layer must add it. + */ +export function buildPrincipal(claims: VerifiedAccessTokenClaims): Principal { + const sub = ownClaim(claims, 'sub'); + if (typeof sub !== 'string' || sub.trim() === '') { + throw new UnauthenticatedError('Access token has a missing or blank subject.'); + } + + const exp = ownClaim(claims, 'exp'); + if (typeof exp !== 'number' || !Number.isFinite(exp)) { + throw new UnauthenticatedError('Access token has a missing or invalid expiry.'); + } + + const rawScope = ownClaim(claims, 'scope'); + const scopes = typeof rawScope === 'string' ? rawScope.split(/\s+/).filter(Boolean) : []; + + const rawPermissions = ownClaim(claims, 'permissions'); + let permissions: string[] | null = null; + if (rawPermissions !== undefined) { + permissions = Array.isArray(rawPermissions) + ? rawPermissions.filter((p): p is string => typeof p === 'string') + : []; + Object.freeze(permissions); + } + + return Object.freeze({ + sub, + expiresAt: exp, + scopes: Object.freeze(scopes) as string[], + permissions, + clientId: nonEmptyString(ownClaim(claims, 'client_id')) ?? nonEmptyString(ownClaim(claims, 'azp')), + orgId: nonEmptyString(ownClaim(claims, 'org_id')), + }); +} diff --git a/packages/ai-mcp/test/principal.spec.ts b/packages/ai-mcp/test/principal.spec.ts new file mode 100644 index 00000000..4b14873d --- /dev/null +++ b/packages/ai-mcp/test/principal.spec.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import type { VerifiedAccessTokenClaims } from '@auth0/auth0-api-js'; +import { buildPrincipal } from '../src/principal.js'; +import { UnauthenticatedError } from '../src/errors.js'; + +function claims(overrides: Record): VerifiedAccessTokenClaims { + return overrides as unknown as VerifiedAccessTokenClaims; +} + +const BASE = { sub: 'user|123', exp: 1_900_000_000 }; + +describe('buildPrincipal', () => { + describe('sub', () => { + it('copies a valid sub', () => { + expect(buildPrincipal(claims(BASE)).sub).toBe('user|123'); + }); + + it.each([ + ['missing', undefined], + ['empty', ''], + ['blank', ' '], + ['number', 42], + ['object', { a: 1 }], + ])('rejects a %s sub with a generic message', (_name, sub) => { + const input = claims({ ...BASE, sub }); + expect(() => buildPrincipal(input)).toThrowError(UnauthenticatedError); + expect(() => buildPrincipal(input)).toThrowError('Access token has a missing or blank subject.'); + }); + + it('does not read sub from the prototype chain', () => { + const input = Object.create({ sub: 'inherited' }, { exp: { value: BASE.exp, enumerable: true } }); + expect(() => buildPrincipal(input)).toThrowError(UnauthenticatedError); + }); + + it('does not leak claim values in the message', () => { + const inputs = [ + claims({ sub: 'secret-sub-value', exp: 'secret-exp-value' }), + claims({ sub: ' ', exp: 'secret-exp-value' }), + ]; + for (const input of inputs) { + let caught: unknown; + try { + buildPrincipal(input); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(UnauthenticatedError); + expect((caught as UnauthenticatedError).statusCode).toBe(401); + expect((caught as Error).message).not.toContain('secret-sub-value'); + expect((caught as Error).message).not.toContain('secret-exp-value'); + } + }); + }); + + describe('exp', () => { + it('maps exp to expiresAt', () => { + expect(buildPrincipal(claims(BASE)).expiresAt).toBe(BASE.exp); + }); + + it.each([ + ['missing', undefined], + ['string', '1900000000'], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['null', null], + ])('rejects a %s exp with a generic message', (_name, exp) => { + const input = claims({ sub: BASE.sub, exp }); + expect(() => buildPrincipal(input)).toThrowError(UnauthenticatedError); + expect(() => buildPrincipal(input)).toThrowError('Access token has a missing or invalid expiry.'); + }); + }); + + describe('exp (inheritance)', () => { + it('does not read exp from the prototype chain', () => { + const input = Object.create({ exp: 9999999999 }); + input.sub = 'user|123'; + expect(() => buildPrincipal(input)).toThrowError('Access token has a missing or invalid expiry.'); + }); + }); + + describe('clientId', () => { + it('uses client_id', () => { + expect(buildPrincipal(claims({ ...BASE, client_id: 'cid' })).clientId).toBe('cid'); + }); + + it('falls back to azp', () => { + expect(buildPrincipal(claims({ ...BASE, azp: 'azp1' })).clientId).toBe('azp1'); + }); + + it('prefers client_id over azp', () => { + expect(buildPrincipal(claims({ ...BASE, client_id: 'cid', azp: 'azp1' })).clientId).toBe('cid'); + }); + + it('falls back to azp when client_id is empty or not a string', () => { + expect(buildPrincipal(claims({ ...BASE, client_id: '', azp: 'azp1' })).clientId).toBe('azp1'); + expect(buildPrincipal(claims({ ...BASE, client_id: 7, azp: 'azp1' })).clientId).toBe('azp1'); + }); + + it('is null when neither is a usable string', () => { + expect(buildPrincipal(claims(BASE)).clientId).toBeNull(); + expect(buildPrincipal(claims({ ...BASE, client_id: '', azp: 5 })).clientId).toBeNull(); + }); + + it('ignores client_id and azp on the prototype chain', () => { + const input = Object.create({ client_id: 'inherited', azp: 'inherited' }); + Object.assign(input, BASE); + expect(buildPrincipal(input).clientId).toBeNull(); + }); + }); + + describe('orgId', () => { + it('reads org_id', () => { + expect(buildPrincipal(claims({ ...BASE, org_id: 'org_1' })).orgId).toBe('org_1'); + }); + + it('is null when absent, empty or not a string', () => { + expect(buildPrincipal(claims(BASE)).orgId).toBeNull(); + expect(buildPrincipal(claims({ ...BASE, org_id: '' })).orgId).toBeNull(); + expect(buildPrincipal(claims({ ...BASE, org_id: 9 })).orgId).toBeNull(); + }); + + it('ignores org_id on the prototype chain', () => { + const input = Object.create({ org_id: 'org_inherited' }); + Object.assign(input, BASE); + expect(buildPrincipal(input).orgId).toBeNull(); + }); + }); + + describe('scopes', () => { + it('splits on any whitespace and drops empties', () => { + const p = buildPrincipal(claims({ ...BASE, scope: ' read:a write:b\tadmin\n' })); + expect(p.scopes).toEqual(['read:a', 'write:b', 'admin']); + }); + + it('is empty when absent, empty or not a string', () => { + expect(buildPrincipal(claims(BASE)).scopes).toEqual([]); + expect(buildPrincipal(claims({ ...BASE, scope: '' })).scopes).toEqual([]); + expect(buildPrincipal(claims({ ...BASE, scope: ['a'] })).scopes).toEqual([]); + }); + + it('does not derive scopes from permissions', () => { + expect(buildPrincipal(claims({ ...BASE, permissions: ['read:a'] })).scopes).toEqual([]); + }); + + it('ignores scope on the prototype chain', () => { + const input = Object.create({ scope: 'admin' }); + Object.assign(input, BASE); + expect(buildPrincipal(input).scopes).toEqual([]); + }); + }); + + describe('permissions', () => { + it('is null when the claim is absent', () => { + expect(buildPrincipal(claims(BASE)).permissions).toBeNull(); + }); + + it('returns a copy of a present array', () => { + const source = ['read:a', 'write:b']; + const p = buildPrincipal(claims({ ...BASE, permissions: source })); + expect(p.permissions).toEqual(source); + expect(p.permissions).not.toBe(source); + }); + + it('keeps an empty array as empty, not null', () => { + expect(buildPrincipal(claims({ ...BASE, permissions: [] })).permissions).toEqual([]); + }); + + it('fails safe to an empty list when the claim is not an array', () => { + expect(buildPrincipal(claims({ ...BASE, permissions: 'read:a' })).permissions).toEqual([]); + expect(buildPrincipal(claims({ ...BASE, permissions: null })).permissions).toEqual([]); + }); + + it('drops non-string entries', () => { + const p = buildPrincipal(claims({ ...BASE, permissions: ['read:a', 1, null, { x: 1 }, 'b'] })); + expect(p.permissions).toEqual(['read:a', 'b']); + }); + + it('ignores permissions on the prototype chain', () => { + const input = Object.create({ permissions: ['admin'] }); + Object.assign(input, BASE); + expect(buildPrincipal(input).permissions).toBeNull(); + }); + }); + + describe('immutability', () => { + it('is frozen when permissions is absent', () => { + const p = buildPrincipal(claims(BASE)); + expect(p.permissions).toBeNull(); + expect(Object.isFrozen(p)).toBe(true); + }); + + it('is unaffected by later mutation of the input claims', () => { + const input: Record = { ...BASE, scope: 'a b', permissions: ['p'], org_id: 'org_1' }; + const p = buildPrincipal(claims(input)); + input['sub'] = 'other'; + input['scope'] = 'z'; + input['org_id'] = 'org_2'; + (input['permissions'] as string[]).push('x'); + expect(p).toEqual({ + sub: 'user|123', + expiresAt: BASE.exp, + scopes: ['a', 'b'], + permissions: ['p'], + clientId: null, + orgId: 'org_1', + }); + }); + + it('returns a frozen principal with frozen arrays', () => { + const p = buildPrincipal(claims({ ...BASE, scope: 'a', permissions: ['p'] })); + expect(Object.isFrozen(p)).toBe(true); + expect(Object.isFrozen(p.scopes)).toBe(true); + expect(Object.isFrozen(p.permissions)).toBe(true); + }); + }); +}); From d3c85cbb6e77c00ad4d0daa66522c2d855c9a6b2 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:25:14 +0530 Subject: [PATCH 06/31] feat(ai-mcp): add request context and scope checks 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 --- packages/ai-mcp/src/request-context.ts | 56 +++++++ packages/ai-mcp/src/scopes.ts | 80 ++++++++++ packages/ai-mcp/test/request-context.spec.ts | 121 +++++++++++++++ packages/ai-mcp/test/scopes.spec.ts | 153 +++++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 packages/ai-mcp/src/request-context.ts create mode 100644 packages/ai-mcp/src/scopes.ts create mode 100644 packages/ai-mcp/test/request-context.spec.ts create mode 100644 packages/ai-mcp/test/scopes.spec.ts diff --git a/packages/ai-mcp/src/request-context.ts b/packages/ai-mcp/src/request-context.ts new file mode 100644 index 00000000..5eaac6a3 --- /dev/null +++ b/packages/ai-mcp/src/request-context.ts @@ -0,0 +1,56 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { UnauthenticatedError } from './errors.js'; +import type { Principal, RequestContext, RequestScopedMcpClient } from './types.js'; + +const storage = new AsyncLocalStorage(); + +/** + * Runs `fn` with `context` bound as the current request context. The context is + * visible to everything `fn` starts, including code after `await`, timers and + * promise chains, and is isolated from concurrent calls. Nested calls restore + * the outer context when the inner one finishes. + * + * @param context - The context for the request being served. + * @param fn - The work to run inside the context. + * @returns Whatever `fn` returns. + */ +export function runWithRequestContext(context: RequestContext, fn: () => T): T { + return storage.run(context, fn); +} + +/** + * Returns the current request context, or `undefined` when called outside + * {@link runWithRequestContext}. + */ +export function getRequestContext(): RequestContext | undefined { + return storage.getStore(); +} + +/** + * Returns the principal of the caller being served, or `undefined` when no + * request context is active. + */ +export function getCallerPrincipal(): Principal | undefined { + return storage.getStore()?.principal; +} + +/** + * Returns the client bound to the caller being served, or `undefined` when no + * request context is active. + */ +export function getCallerMcpClient(): RequestScopedMcpClient | undefined { + return storage.getStore()?.mcpClient; +} + +/** + * Returns the client bound to the caller being served. + * + * @throws {UnauthenticatedError} When no request context is active. + */ +export function getCallerMcpClientOrThrow(): RequestScopedMcpClient { + const client = storage.getStore()?.mcpClient; + if (!client) { + throw new UnauthenticatedError('No authenticated request context.'); + } + return client; +} diff --git a/packages/ai-mcp/src/scopes.ts b/packages/ai-mcp/src/scopes.ts new file mode 100644 index 00000000..70c51143 --- /dev/null +++ b/packages/ai-mcp/src/scopes.ts @@ -0,0 +1,80 @@ +import { InsufficientScopeError, UnauthenticatedError } from './errors.js'; +import { getCallerPrincipal } from './request-context.js'; +import type { Principal } from './types.js'; + +/** + * A minimal MCP tool result that signals an error. Structurally compatible with + * `CallToolResult` from the MCP SDK, defined here so the optional peer is not + * needed at runtime. + */ +export interface McpToolErrorResult { + /** Text content shown to the caller. Always generic. */ + content: Array<{ type: 'text'; text: string }>; + /** Always `true`. */ + isError: true; + /** Machine-readable marker for the failure kind. */ + _meta: { error: 'insufficient_scope' }; +} + +function normalize(required: string | readonly string[]): string[] { + const list = typeof required === 'string' ? [required] : required; + return list.flatMap((entry) => entry.split(/\s+/)).filter((entry) => entry.length > 0); +} + +/** + * Asserts that the principal holds every required scope (AND semantics). Only + * the `scope` claim is consulted, never `permissions`. + * + * @param required - One scope, a space-delimited string of scopes, or a list. + * An empty requirement always passes. + * @param principal - The principal to check. Passing `undefined` means use the + * current caller. + * @throws {InsufficientScopeError} When any required scope is missing. The + * message is generic and does not list the scopes the caller holds. + * @throws {UnauthenticatedError} When no principal is given and no request + * context is active. + */ +export function requireScopes(required: string | readonly string[], principal?: Principal): void { + const needed = normalize(required); + if (needed.length === 0) return; + const caller = principal ?? getCallerPrincipal(); + if (!caller) { + throw new UnauthenticatedError('No authenticated request context.'); + } + const held = new Set(caller.scopes); + if (!needed.every((scope) => held.has(scope))) { + throw new InsufficientScopeError(needed); + } +} + +/** + * Wraps a tool handler so it only runs when the current caller holds every + * required scope. On a scope failure it returns a sanitized MCP error result + * instead of throwing. Every other error, including those from the handler, + * propagates unchanged. + * + * @param required - One scope, a space-delimited string of scopes, or a list. + * @param handler - The tool handler to protect. + * @returns A handler with the same arguments that resolves to the handler's + * result or to an error result. + */ +export function withRequiredScopes( + required: string | readonly string[], + handler: (...args: A) => R | Promise +): (...args: A) => Promise { + return async (...args: A) => { + try { + requireScopes(required); + } catch (error) { + if (error instanceof InsufficientScopeError) { + return { + content: [{ type: 'text', text: 'Missing one or more required scopes.' }], + isError: true, + _meta: { error: 'insufficient_scope' }, + }; + } + throw error; + } + return handler(...args); + }; +} diff --git a/packages/ai-mcp/test/request-context.spec.ts b/packages/ai-mcp/test/request-context.spec.ts new file mode 100644 index 00000000..ce3f1b78 --- /dev/null +++ b/packages/ai-mcp/test/request-context.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { UnauthenticatedError } from '../src/errors.js'; +import { + getCallerMcpClient, + getCallerMcpClientOrThrow, + getCallerPrincipal, + getRequestContext, + runWithRequestContext, +} from '../src/request-context.js'; +import type { Principal, RequestContext, RequestScopedMcpClient } from '../src/types.js'; + +function makeContext(sub: string, scopes: string[] = []): RequestContext { + const principal: Principal = { sub, expiresAt: 0, scopes, permissions: null, clientId: null, orgId: null }; + const mcpClient = { getPrincipal: () => principal } as unknown as RequestScopedMcpClient; + return { principal, mcpClient }; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('request context', () => { + it('returns undefined and throws UnauthenticatedError outside a request', () => { + expect(getRequestContext()).toBeUndefined(); + expect(getCallerPrincipal()).toBeUndefined(); + expect(getCallerMcpClient()).toBeUndefined(); + expect(() => getCallerMcpClientOrThrow()).toThrow(UnauthenticatedError); + }); + + it('exposes principal and client inside a request and returns fn result', () => { + const ctx = makeContext('a'); + const result = runWithRequestContext(ctx, () => { + expect(getRequestContext()).toBe(ctx); + expect(getCallerPrincipal()).toBe(ctx.principal); + expect(getCallerMcpClientOrThrow()).toBe(ctx.mcpClient); + expect(getCallerMcpClient()).toBe(ctx.mcpClient); + return 42; + }); + expect(result).toBe(42); + }); + + it('restores the outer context after a nested run', () => { + const outer = makeContext('outer'); + const inner = makeContext('inner'); + runWithRequestContext(outer, () => { + runWithRequestContext(inner, () => { + expect(getCallerPrincipal()?.sub).toBe('inner'); + }); + expect(getCallerPrincipal()?.sub).toBe('outer'); + }); + }); + + it('survives awaits, timers and promise chains', async () => { + await runWithRequestContext(makeContext('async'), async () => { + await delay(1); + expect(getCallerPrincipal()?.sub).toBe('async'); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(getCallerPrincipal()?.sub).toBe('async'); + await Promise.resolve().then(() => delay(1)).then(() => { + expect(getCallerPrincipal()?.sub).toBe('async'); + }); + }); + }); + + it('does not leak after fn settles, including on throw and rejection', async () => { + runWithRequestContext(makeContext('x'), () => undefined); + expect(getRequestContext()).toBeUndefined(); + expect(() => + runWithRequestContext(makeContext('x'), () => { + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(getRequestContext()).toBeUndefined(); + await expect( + runWithRequestContext(makeContext('x'), async () => { + await delay(1); + throw new Error('later'); + }) + ).rejects.toThrow('later'); + expect(getRequestContext()).toBeUndefined(); + }); + + it('isolates 100 concurrent requests', async () => { + const results = await Promise.all( + Array.from({ length: 100 }, (_, i) => + runWithRequestContext(makeContext(`user-${i}`), async () => { + const seen: string[] = []; + for (let step = 0; step < 4; step++) { + await delay(Math.random() * 5); + seen.push(getCallerPrincipal()!.sub); + seen.push(getCallerMcpClientOrThrow().getPrincipal().sub); + } + return seen; + }) + ) + ); + results.forEach((seen, i) => { + expect(new Set(seen)).toEqual(new Set([`user-${i}`])); + }); + }); + + it('keeps the first request isolated while all others start and finish', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let started = 0; + const total = 50; + const first = runWithRequestContext(makeContext('first'), async () => { + await gate; + return [getCallerPrincipal()?.sub, getCallerMcpClientOrThrow().getPrincipal().sub]; + }); + const others = Array.from({ length: total - 1 }, (_, i) => + runWithRequestContext(makeContext(`other-${i}`), async () => { + if (++started === total - 1) release(); + await delay(1); + return getCallerPrincipal()?.sub; + }) + ); + expect(await first).toEqual(['first', 'first']); + expect(await Promise.all(others)).toEqual(others.map((_, i) => `other-${i}`)); + }); +}); diff --git a/packages/ai-mcp/test/scopes.spec.ts b/packages/ai-mcp/test/scopes.spec.ts new file mode 100644 index 00000000..6a2fc5fc --- /dev/null +++ b/packages/ai-mcp/test/scopes.spec.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InsufficientScopeError, UnauthenticatedError } from '../src/errors.js'; +import { runWithRequestContext, getCallerPrincipal } from '../src/request-context.js'; +import { requireScopes, withRequiredScopes } from '../src/scopes.js'; +import type { Principal, RequestContext, RequestScopedMcpClient } from '../src/types.js'; + +function makePrincipal(sub: string, scopes: string[], permissions: string[] | null = null): Principal { + return { sub, expiresAt: 0, scopes, permissions, clientId: null, orgId: null }; +} + +function makeContext(principal: Principal): RequestContext { + return { principal, mcpClient: { getPrincipal: () => principal } as unknown as RequestScopedMcpClient }; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('requireScopes', () => { + const p = makePrincipal('u', ['read', 'write']); + + it('passes when all scopes are present, for string, space-delimited and list', () => { + expect(() => requireScopes('read', p)).not.toThrow(); + expect(() => requireScopes('read write', p)).not.toThrow(); + expect(() => requireScopes(['write', 'read'], p)).not.toThrow(); + expect(() => requireScopes(' read \t write ', p)).not.toThrow(); + }); + + it('passes for an empty requirement', () => { + expect(() => requireScopes([], p)).not.toThrow(); + expect(() => requireScopes('', makePrincipal('u', []))).not.toThrow(); + expect(() => requireScopes([])).not.toThrow(); + }); + + it('requires every scope (AND semantics)', () => { + expect(() => requireScopes(['read', 'admin'], p)).toThrow(InsufficientScopeError); + expect(() => requireScopes('admin', p)).toThrow(InsufficientScopeError); + }); + + it('requires every scope of a space-delimited string', () => { + expect(() => requireScopes('a b', makePrincipal('u', ['a']))).toThrow(InsufficientScopeError); + }); + + it('accepts duplicate requirements when held', () => { + expect(() => requireScopes(['read', 'read'], p)).not.toThrow(); + }); + + it('ignores permissions', () => { + const rbac = makePrincipal('u', [], ['read']); + expect(() => requireScopes('read', rbac)).toThrow(InsufficientScopeError); + }); + + it('throws a generic error that does not leak held scopes', () => { + const secret = makePrincipal('u', ['held:secret-scope']); + try { + requireScopes('admin', secret); + expect.unreachable(); + } catch (error) { + const err = error as InsufficientScopeError; + expect(err).toBeInstanceOf(InsufficientScopeError); + expect(err.statusCode).toBe(403); + expect(err.message).not.toContain('held:secret-scope'); + expect(err.requiredScopes).toEqual(['admin']); + } + }); + + it('defaults to the current caller and throws UnauthenticatedError outside a request', () => { + expect(() => requireScopes('read')).toThrow(UnauthenticatedError); + runWithRequestContext(makeContext(p), () => { + expect(() => requireScopes('read')).not.toThrow(); + expect(() => requireScopes('admin')).toThrow(InsufficientScopeError); + }); + }); +}); + +describe('withRequiredScopes', () => { + it('runs the handler with its arguments when scopes are held', async () => { + const handler = vi.fn(async (a: number, b: number) => a + b); + const wrapped = withRequiredScopes('read', handler); + const result = await runWithRequestContext(makeContext(makePrincipal('u', ['read'])), () => wrapped(1, 2)); + expect(result).toBe(3); + expect(handler).toHaveBeenCalledWith(1, 2); + }); + + it('supports synchronous handlers', async () => { + const wrapped = withRequiredScopes(['read'], () => 'sync'); + expect(await runWithRequestContext(makeContext(makePrincipal('u', ['read'])), () => wrapped())).toBe('sync'); + }); + + it('returns a sanitized error result without calling the handler on denial', async () => { + const handler = vi.fn(); + const wrapped = withRequiredScopes(['read', 'admin'], handler); + const result = await runWithRequestContext(makeContext(makePrincipal('u', ['read', 'held:secret'])), () => + wrapped() + ); + expect(handler).not.toHaveBeenCalled(); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Missing one or more required scopes.' }], + isError: true, + _meta: { error: 'insufficient_scope' }, + }); + const text = JSON.stringify(result); + expect(text).not.toContain('held:secret'); + expect(text).not.toContain('admin'); + }); + + it('does not swallow non-scope errors raised outside a request', async () => { + const wrapped = withRequiredScopes('read', async () => 'x'); + await expect(wrapped()).rejects.toBeInstanceOf(UnauthenticatedError); + }); + + it('propagates handler errors unchanged, including InsufficientScopeError', async () => { + const boom = new Error('handler failed'); + const inner = new InsufficientScopeError(['other']); + const ctx = makeContext(makePrincipal('u', ['read'])); + await expect( + runWithRequestContext(ctx, () => + withRequiredScopes('read', async () => { + throw boom; + })() + ) + ).rejects.toBe(boom); + await expect( + runWithRequestContext(ctx, () => + withRequiredScopes('read', async () => { + throw inner; + })() + ) + ).rejects.toBe(inner); + }); + + it('keeps 60 concurrent callers isolated through the wrapper', async () => { + const results = await Promise.all( + Array.from({ length: 60 }, (_, i) => { + const allowed = i % 2 === 0; + const principal = makePrincipal(`user-${i}`, allowed ? ['tool:run'] : ['other']); + const wrapped = withRequiredScopes('tool:run', async () => { + await delay(Math.random() * 5); + return getCallerPrincipal()?.sub; + }); + return runWithRequestContext(makeContext(principal), async () => { + await delay(Math.random() * 5); + const out = await wrapped(); + await delay(Math.random() * 5); + return { i, allowed, out, after: getCallerPrincipal()?.sub }; + }); + }) + ); + for (const { i, allowed, out, after } of results) { + expect(after).toBe(`user-${i}`); + if (allowed) expect(out).toBe(`user-${i}`); + else expect(out).toMatchObject({ isError: true }); + } + }); +}); From b956cfcbe7da601fa771aa78ae43063aca770538 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:25:17 +0530 Subject: [PATCH 07/31] feat(ai-mcp): add indexed memory store, encrypted store and single flight 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 --- packages/ai-mcp/src/single-flight.ts | 43 +++ packages/ai-mcp/src/stores/encrypted-store.ts | 225 ++++++++++++ .../ai-mcp/src/stores/memory-indexed-store.ts | 200 ++++++++++ packages/ai-mcp/test/single-flight.spec.ts | 91 +++++ .../test/stores/encrypted-store.spec.ts | 347 ++++++++++++++++++ .../test/stores/memory-indexed-store.spec.ts | 198 ++++++++++ 6 files changed, 1104 insertions(+) create mode 100644 packages/ai-mcp/src/single-flight.ts create mode 100644 packages/ai-mcp/src/stores/encrypted-store.ts create mode 100644 packages/ai-mcp/src/stores/memory-indexed-store.ts create mode 100644 packages/ai-mcp/test/single-flight.spec.ts create mode 100644 packages/ai-mcp/test/stores/encrypted-store.spec.ts create mode 100644 packages/ai-mcp/test/stores/memory-indexed-store.spec.ts diff --git a/packages/ai-mcp/src/single-flight.ts b/packages/ai-mcp/src/single-flight.ts new file mode 100644 index 00000000..4b13464a --- /dev/null +++ b/packages/ai-mcp/src/single-flight.ts @@ -0,0 +1,43 @@ +/** + * Collapses concurrent calls that share a key into one in-flight operation. + * + * The first caller for a key starts the operation. Every caller that arrives with the same + * key before it settles receives the same promise, so the operation runs once. When it + * settles, successfully or not, the key is released: a failure reaches every waiter but is + * never remembered, and the next call for that key starts a fresh operation. + * + * Calls with different keys never wait on each other. The key is the only isolation between + * callers, so it must include everything that identifies the result: for a token, that means + * the principal, tenant, audience and scopes. Two callers who should not share a token must + * never share a key. The operation passed to `run` must not call `run` again with the same + * key while it is in flight, because it would wait on itself. + */ +export class SingleFlight { + readonly #inFlight = new Map>(); + + /** + * Runs `fn` unless an operation for `key` is already in flight, in which case the + * in-flight promise is returned instead. + * + * @param key - Identifies operations that may share a result. + * @param fn - The operation. A synchronous throw is treated as a rejection. + * @returns The result of the shared operation. + */ + run(key: string, fn: () => Promise): Promise { + const existing = this.#inFlight.get(key); + + if (existing !== undefined) { + return existing; + } + + const promise = (async () => fn())().finally(() => { + if (this.#inFlight.get(key) === promise) { + this.#inFlight.delete(key); + } + }); + + this.#inFlight.set(key, promise); + + return promise; + } +} diff --git a/packages/ai-mcp/src/stores/encrypted-store.ts b/packages/ai-mcp/src/stores/encrypted-store.ts new file mode 100644 index 00000000..036c3e99 --- /dev/null +++ b/packages/ai-mcp/src/stores/encrypted-store.ts @@ -0,0 +1,225 @@ +import { InvalidConfigurationError } from '@auth0/auth0-api-js'; +import type { CachedToken, IndexedTokenStore, TokenIndexMember, TokenStore } from '@auth0/auth0-api-js'; +import { CompactEncrypt, compactDecrypt } from 'jose'; + +/** Shortest accepted secret, in characters. */ +const MIN_SECRET_LENGTH = 32; + +/** A256CBC-HS512 uses a 512-bit combined key. */ +const KEY_BYTES = 64; + +const HKDF_SALT = new TextEncoder().encode('auth0-ai-mcp.encrypted-token-store.salt.v1'); +const HKDF_INFO = new TextEncoder().encode('auth0-ai-mcp.encrypted-token-store.content-key.v1'); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * Options for {@link createEncryptedTokenStore}. + */ +export interface EncryptedTokenStoreOptions { + /** + * Secret the encryption key is derived from. Must be a string of at least 32 + * characters and should come from a secret manager, not from source code. Every process + * that shares the underlying store must use the same secret, and changing it makes + * existing entries unreadable, which the cache treats as a miss. + */ + secret: string; +} + +/** Injective encodings, so no pair of distinct keys can produce the same binding. */ +const tokenBinding = (key: string): string => JSON.stringify(['t', key]); +const indexBinding = (indexKey: string, tokenKey: string): string => JSON.stringify(['i', indexKey, tokenKey]); + +function isIndexed(store: TokenStore): store is IndexedTokenStore { + const candidate = store as Partial; + return typeof candidate.addIndexMember === 'function' && typeof candidate.listIndexMembers === 'function'; +} + +async function deriveKey(secret: string): Promise { + const material = await globalThis.crypto.subtle.importKey('raw', encoder.encode(secret), 'HKDF', false, [ + 'deriveBits', + ]); + const bits = await globalThis.crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: HKDF_SALT, info: HKDF_INFO }, + material, + KEY_BYTES * 8 + ); + return new Uint8Array(bits); +} + +/** + * Wraps a {@link TokenStore} so that everything it holds is encrypted at rest. + * + * Use it in front of any store that persists or shares tokens, such as one backed by a + * database or a cache server. Token values are sealed as compact JWE (`dir` with + * `A256CBC-HS512`) using a key derived from `secret` with HKDF-SHA-256. The inner store + * never sees an access token in the clear. + * + * ## Secret + * + * The secret should be at least 32 random bytes from a secret manager, for example the + * base64 or hex encoding of 32 random bytes. The length check counts characters and cannot + * measure entropy, so a long but guessable string passes the check and is still weak. Only + * one secret is supported and there is no rotation yet: changing it makes every existing + * entry unreadable, which the cache treats as a miss. + * + * ## What is and is not protected + * + * Each ciphertext is bound, through its authenticated protected header, to the storage key + * it was written under. A ciphertext copied to a different key fails to decrypt and reads + * as a miss. Each token entry also repeats its expiry time inside the sealed payload, and a + * read whose plaintext expiry differs from the sealed one is a miss. + * + * Stored in the clear, because the cache needs it to find entries: the storage keys, each + * entry's expiry time (so the inner store can apply its own time to live), and the token + * key and expiry of each index member. Storage keys are opaque hashes that carry no claim + * value, but they are stable pseudonymous identifiers for a given caller. The granted + * scopes of an index member are encrypted, so the inner indexed store must treat index + * members as opaque: their `grantedScopes` hold ciphertext, not scope names. + * + * Encryption does not defend against an attacker with write access to the inner store. + * Such an attacker can delete entries, or replay an older ciphertext under the same key + * until it expires, which is still bounded by the expiry sealed inside it. + * + * ## Failures + * + * Reads fail open. Tampered or malformed ciphertext, a different secret or a key mismatch + * produces a miss, never an exception, and nothing about the value is logged. Failures + * raised by the inner store itself are not swallowed here. Writes and deletes pass + * through, so an inner write failure propagates to the caller. + * + * The returned store has index methods when `inner` does. This is decided at run time by + * the presence of the methods, so an indexed store typed as a plain {@link TokenStore} + * still yields a store with index methods. + * + * @param inner - The store that holds the encrypted values. + * @param options - Encryption options. + * @returns A store with the same interface as `inner`. + * @throws {InvalidConfigurationError} When `secret` is not a string of at least 32 + * characters. + */ +export function createEncryptedTokenStore(inner: IndexedTokenStore, options: EncryptedTokenStoreOptions): IndexedTokenStore; +export function createEncryptedTokenStore(inner: TokenStore, options: EncryptedTokenStoreOptions): TokenStore; +export function createEncryptedTokenStore(inner: TokenStore, options: EncryptedTokenStoreOptions): TokenStore { + const secret = options?.secret; + + if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) { + throw new InvalidConfigurationError(`secret must be a string of at least ${MIN_SECRET_LENGTH} characters.`); + } + + let keyPromise: Promise | undefined; + const getKey = (): Promise => { + keyPromise ??= deriveKey(secret).catch((error: unknown) => { + keyPromise = undefined; + throw error; + }); + return keyPromise; + }; + + const seal = async (plaintext: unknown, binding: string): Promise => + new CompactEncrypt(encoder.encode(JSON.stringify(plaintext))) + .setProtectedHeader({ alg: 'dir', enc: 'A256CBC-HS512', bnd: binding }) + .encrypt(await getKey()); + + /** Returns the decrypted JSON value, or `undefined` for any failure. */ + const open = async (jwe: unknown, binding: string): Promise => { + try { + if (typeof jwe !== 'string') { + return undefined; + } + const { plaintext, protectedHeader } = await compactDecrypt(jwe, await getKey(), { + keyManagementAlgorithms: ['dir'], + contentEncryptionAlgorithms: ['A256CBC-HS512'], + }); + if (protectedHeader.bnd !== binding) { + return undefined; + } + return JSON.parse(decoder.decode(plaintext)); + } catch { + return undefined; + } + }; + + const store: TokenStore = { + async get(key) { + const envelope: unknown = await inner.get(key); + if (!isRecord(envelope)) { + return undefined; + } + const value = await open(envelope.accessToken, tokenBinding(key)); + // The plaintext expiry is what the inner store acts on, so it has to match the sealed one. + return isCachedToken(value) && value.expiresAt === envelope.expiresAt ? value : undefined; + }, + + async set(key, value) { + const envelope: CachedToken = { + accessToken: await seal(value, tokenBinding(key)), + expiresAt: value.expiresAt, + grantedScopes: [], + }; + await inner.set(key, envelope); + }, + + delete(key) { + return inner.delete(key); + }, + }; + + if (!isIndexed(inner)) { + return store; + } + + const indexed: IndexedTokenStore = { + ...store, + + async addIndexMember(indexKey, member) { + const sealed = await seal( + { grantedScopes: member.grantedScopes, expiresAt: member.expiresAt }, + indexBinding(indexKey, member.tokenKey) + ); + await inner.addIndexMember(indexKey, { + tokenKey: member.tokenKey, + grantedScopes: [sealed], + expiresAt: member.expiresAt, + }); + }, + + async listIndexMembers(indexKey) { + const stored: unknown = await inner.listIndexMembers(indexKey); + if (!Array.isArray(stored)) { + return []; + } + const members: TokenIndexMember[] = []; + for (const item of stored as unknown[]) { + if (!isRecord(item) || typeof item.tokenKey !== 'string' || !Array.isArray(item.grantedScopes)) { + continue; + } + const value = await open(item.grantedScopes[0], indexBinding(indexKey, item.tokenKey)); + if (isRecord(value) && isStringArray(value.grantedScopes) && typeof value.expiresAt === 'number') { + members.push({ tokenKey: item.tokenKey, grantedScopes: value.grantedScopes, expiresAt: value.expiresAt }); + } + } + return members; + }, + }; + + return indexed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isCachedToken(value: unknown): value is CachedToken { + return ( + isRecord(value) && + typeof value.accessToken === 'string' && + typeof value.expiresAt === 'number' && + isStringArray(value.grantedScopes) + ); +} diff --git a/packages/ai-mcp/src/stores/memory-indexed-store.ts b/packages/ai-mcp/src/stores/memory-indexed-store.ts new file mode 100644 index 00000000..64c296e1 --- /dev/null +++ b/packages/ai-mcp/src/stores/memory-indexed-store.ts @@ -0,0 +1,200 @@ +import { InvalidConfigurationError } from '@auth0/auth0-api-js'; +import type { CachedToken, IndexedTokenStore, TokenIndexMember } from '@auth0/auth0-api-js'; + +/** Default upper bound on the number of cached tokens. */ +const DEFAULT_MAX_ENTRIES = 1000; + +/** + * Options for {@link MemoryIndexedTokenStore}. + */ +export interface MemoryIndexedTokenStoreOptions { + /** + * Largest number of tokens the store keeps. When a write would exceed it, the least + * recently used token is evicted. The scope index is bounded by the same number: it never + * holds more members than this, and a member is removed together with the token it points + * at. + * + * Must be a positive safe integer. Defaults to 1000. + */ + maxEntries?: number; +} + +/** + * An in-memory {@link IndexedTokenStore} with a hard size bound and least-recently-used + * eviction. + * + * Suitable for a single process, for tests, and as the default cache for a server that + * does not need tokens to survive a restart. The contents live in the memory of one + * process only: they are not shared between processes or workers and are lost on exit. Use + * a store backed by shared storage, wrapped with `createEncryptedTokenStore`, when + * several instances must see the same tokens. + * + * Values are copied when they are written and again when they are read, so neither the + * caller that stored a value nor the caller that received one can change what the store + * holds. Entries that have passed their expiry time are dropped lazily on access. + */ +export class MemoryIndexedTokenStore implements IndexedTokenStore { + readonly #maxEntries: number; + /** Token entries in least-recently-used order (oldest first). */ + readonly #tokens = new Map(); + /** Index members keyed by index key, then token key. Outer map is in least-recently-used order. */ + readonly #indexes = new Map>(); + /** For each token key, the index keys that hold a member pointing at it. */ + readonly #tokenToIndexes = new Map>(); + #memberCount = 0; + + /** + * @param options - Store options. + * @throws {InvalidConfigurationError} When `maxEntries` is not a positive safe integer. + */ + constructor(options: MemoryIndexedTokenStoreOptions = {}) { + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + + if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) { + throw new InvalidConfigurationError('maxEntries must be a positive integer.'); + } + + this.#maxEntries = maxEntries; + } + + /** The number of tokens currently held. Intended for diagnostics and tests. */ + get size(): number { + return this.#tokens.size; + } + + async get(key: string): Promise { + const entry = this.#tokens.get(key); + + if (entry === undefined) { + return undefined; + } + + if (isExpired(entry.expiresAt)) { + this.#removeToken(key); + return undefined; + } + + // Refresh recency. + this.#tokens.delete(key); + this.#tokens.set(key, entry); + + return structuredClone(entry); + } + + async set(key: string, value: CachedToken): Promise { + if (isExpired(value.expiresAt)) { + this.#removeToken(key); + return; + } + + const copy = structuredClone(value); + + this.#tokens.delete(key); + this.#tokens.set(key, copy); + + while (this.#tokens.size > this.#maxEntries) { + const oldest = this.#tokens.keys().next().value as string; + this.#removeToken(oldest); + } + } + + async delete(key: string): Promise { + this.#removeToken(key); + } + + async addIndexMember(indexKey: string, member: TokenIndexMember): Promise { + const copy = structuredClone(member); + let members = this.#indexes.get(indexKey); + + if (members === undefined) { + members = new Map(); + } else { + this.#indexes.delete(indexKey); // Refresh recency. + } + this.#indexes.set(indexKey, members); + + if (members.has(copy.tokenKey)) { + members.delete(copy.tokenKey); + this.#memberCount--; + } + members.set(copy.tokenKey, copy); + this.#memberCount++; + + let owners = this.#tokenToIndexes.get(copy.tokenKey); + if (owners === undefined) { + owners = new Set(); + this.#tokenToIndexes.set(copy.tokenKey, owners); + } + owners.add(indexKey); + + while (this.#memberCount > this.#maxEntries) { + this.#evictOldestMember(); + } + } + + async listIndexMembers(indexKey: string): Promise { + const members = this.#indexes.get(indexKey); + + if (members === undefined) { + return []; + } + + // Refresh recency. + this.#indexes.delete(indexKey); + this.#indexes.set(indexKey, members); + + const result: TokenIndexMember[] = []; + + for (const [tokenKey, member] of [...members]) { + if (isExpired(member.expiresAt)) { + this.#removeMember(indexKey, tokenKey); + } else { + result.push(structuredClone(member)); + } + } + + return result; + } + + /** Removes a token and every index member that points at it. */ + #removeToken(key: string): void { + this.#tokens.delete(key); + + const owners = this.#tokenToIndexes.get(key); + if (owners !== undefined) { + for (const indexKey of [...owners]) { + this.#removeMember(indexKey, key); + } + } + } + + #removeMember(indexKey: string, tokenKey: string): void { + const members = this.#indexes.get(indexKey); + + if (members?.delete(tokenKey)) { + this.#memberCount--; + if (members.size === 0) { + this.#indexes.delete(indexKey); + } + } + + const owners = this.#tokenToIndexes.get(tokenKey); + if (owners !== undefined) { + owners.delete(indexKey); + if (owners.size === 0) { + this.#tokenToIndexes.delete(tokenKey); + } + } + } + + #evictOldestMember(): void { + const oldestIndex = this.#indexes.entries().next().value as [string, Map]; + const [indexKey, members] = oldestIndex; + const tokenKey = members.keys().next().value as string; + this.#removeMember(indexKey, tokenKey); + } +} + +function isExpired(expiresAt: number): boolean { + return typeof expiresAt === 'number' && expiresAt <= Math.floor(Date.now() / 1000); +} diff --git a/packages/ai-mcp/test/single-flight.spec.ts b/packages/ai-mcp/test/single-flight.spec.ts new file mode 100644 index 00000000..96933ceb --- /dev/null +++ b/packages/ai-mcp/test/single-flight.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SingleFlight } from '../src/single-flight.js'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('SingleFlight', () => { + it('runs the operation once for 10 concurrent callers with the same key', async () => { + const flight = new SingleFlight(); + const gate = deferred(); + const fn = vi.fn(() => gate.promise); + + const calls = Array.from({ length: 10 }, () => flight.run('k', fn)); + gate.resolve('value'); + + await expect(Promise.all(calls)).resolves.toEqual(Array(10).fill('value')); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('runs different keys independently', async () => { + const flight = new SingleFlight(); + const a = deferred(); + const b = deferred(); + const fnA = vi.fn(() => a.promise); + const fnB = vi.fn(() => b.promise); + + const pa = flight.run('a', fnA); + const pb = flight.run('b', fnB); + b.resolve('B'); + a.resolve('A'); + + await expect(pa).resolves.toBe('A'); + await expect(pb).resolves.toBe('B'); + expect(fnA).toHaveBeenCalledTimes(1); + expect(fnB).toHaveBeenCalledTimes(1); + }); + + it('starts a new operation after the previous one succeeded', async () => { + const flight = new SingleFlight(); + const fn = vi.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2); + + await expect(flight.run('k', fn)).resolves.toBe(1); + await expect(flight.run('k', fn)).resolves.toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('propagates a failure to every waiter and does not cache it', async () => { + const flight = new SingleFlight(); + const gate = deferred(); + const failing = vi.fn(() => gate.promise); + + const waiters = Array.from({ length: 3 }, () => flight.run('k', failing)); + const settled = Promise.allSettled(waiters); + gate.reject(new Error('boom')); + + const results = await settled; + expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected', 'rejected']); + expect(failing).toHaveBeenCalledTimes(1); + + const retry = vi.fn().mockResolvedValue('ok'); + await expect(flight.run('k', retry)).resolves.toBe('ok'); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('treats a synchronous throw as a rejection and releases the key', async () => { + const flight = new SingleFlight(); + const throwing = (): Promise => { + throw new Error('sync'); + }; + + await expect(flight.run('k', throwing)).rejects.toThrow('sync'); + await expect(flight.run('k', async () => 'after')).resolves.toBe('after'); + }); + + it('starts a fresh run for a call made from a continuation after the key settled', async () => { + const flight = new SingleFlight(); + const fn = vi.fn().mockResolvedValueOnce(1).mockResolvedValueOnce(2); + + const second = flight.run('k', fn).then(() => flight.run('k', fn)); + + await expect(second).resolves.toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/ai-mcp/test/stores/encrypted-store.spec.ts b/packages/ai-mcp/test/stores/encrypted-store.spec.ts new file mode 100644 index 00000000..143d2582 --- /dev/null +++ b/packages/ai-mcp/test/stores/encrypted-store.spec.ts @@ -0,0 +1,347 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupServer } from 'msw/node'; +import { ApiClient } from '@auth0/auth0-api-js'; +import { createAuth0Handlers, createTestKey, mintAccessToken } from '../support/index.js'; +import type { TestKey } from '../support/index.js'; +import { InvalidConfigurationError } from '@auth0/auth0-api-js'; +import type { CachedToken, IndexedTokenStore, TokenStore } from '@auth0/auth0-api-js'; +import { createEncryptedTokenStore } from '../../src/stores/encrypted-store.js'; +import { MemoryIndexedTokenStore } from '../../src/stores/memory-indexed-store.js'; + +const SECRET = 'a-secret-that-is-at-least-32-characters-long'; +const OTHER_SECRET = 'a-different-secret-with-32-or-more-characters'; +const ACCESS_TOKEN = 'eyJ.super-secret-access-token.sig'; + +const value = (): CachedToken => ({ + accessToken: ACCESS_TOKEN, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + grantedScopes: ['read:data', 'write:data'], + scope: 'read:data write:data', + tokenType: 'bearer', + issuedTokenType: 'urn:ietf:params:oauth:token-type:access_token', +}); + +/** A plain record-keeping store that exposes its raw contents. */ +class RawStore implements IndexedTokenStore { + tokens = new Map(); + indexes = new Map(); + async get(key: string) { + return this.tokens.get(key); + } + async set(key: string, v: CachedToken) { + this.tokens.set(key, v); + } + async delete(key: string) { + this.tokens.delete(key); + } + async addIndexMember(indexKey: string, member: import('@auth0/auth0-api-js').TokenIndexMember) { + const list = (this.indexes.get(indexKey) ?? []).filter((m) => m.tokenKey !== member.tokenKey); + list.push(member); + this.indexes.set(indexKey, list); + } + async listIndexMembers(indexKey: string) { + return this.indexes.get(indexKey) ?? []; + } +} + +class PlainStore implements TokenStore { + tokens = new Map(); + async get(key: string) { + return this.tokens.get(key); + } + async set(key: string, v: CachedToken) { + this.tokens.set(key, v); + } + async delete(key: string) { + this.tokens.delete(key); + } +} + +/** Flips one character in the middle of the ciphertext segment of a compact JWE. */ +function tamper(jwe: string): string { + const parts = jwe.split('.'); + const ct = parts[3]; + const mid = Math.floor(ct.length / 2); + parts[3] = ct.slice(0, mid) + (ct[mid] === 'A' ? 'B' : 'A') + ct.slice(mid + 1); + return parts.join('.'); +} + +describe('createEncryptedTokenStore', () => { + describe('secret validation', () => { + it.each([['short', 'x'.repeat(31)], ['empty', ''], ['number', 123 as unknown as string], ['undefined', undefined as unknown as string]])( + 'throws synchronously for a %s secret without echoing it', + (_name, secret) => { + let error: unknown; + try { + createEncryptedTokenStore(new PlainStore(), { secret }); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(InvalidConfigurationError); + if (typeof secret === 'string' && secret.length > 0) { + expect((error as Error).message).not.toContain(secret); + } + } + ); + + it('accepts a 32 character secret', () => { + expect(() => createEncryptedTokenStore(new PlainStore(), { secret: 'x'.repeat(32) })).not.toThrow(); + }); + }); + + describe('tokens', () => { + it('round trips a value', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + const v = value(); + await store.set('k', v); + expect(await store.get('k')).toEqual(v); + }); + + it('returns undefined for an unknown key', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + expect(await store.get('nope')).toBeUndefined(); + }); + + it('delete passes through', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + await store.set('k', value()); + await store.delete('k'); + expect(await store.get('k')).toBeUndefined(); + }); + + it('never gives the inner store the access token or scopes in the clear', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.set('k', value()); + await store.addIndexMember('idx', { tokenKey: 'k', grantedScopes: ['read:data'], expiresAt: value().expiresAt }); + + const raw = JSON.stringify([[...inner.tokens], [...inner.indexes]]); + expect(raw).not.toContain(ACCESS_TOKEN); + expect(raw).not.toContain('read:data'); + expect(raw).not.toContain('write:data'); + }); + + it('keeps the expiry visible to the inner store so it can apply a time to live', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + const v = value(); + await store.set('k', v); + expect(inner.tokens.get('k')?.expiresAt).toBe(v.expiresAt); + }); + + it('treats a tampered ciphertext as a miss', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.set('k', value()); + const stored = inner.tokens.get('k')!; + inner.tokens.set('k', { ...stored, accessToken: tamper(stored.accessToken) }); + expect(await store.get('k')).toBeUndefined(); + }); + + it('treats a value written under another secret as a miss', async () => { + const inner = new RawStore(); + await createEncryptedTokenStore(inner, { secret: SECRET }).set('k', value()); + expect(await createEncryptedTokenStore(inner, { secret: OTHER_SECRET }).get('k')).toBeUndefined(); + }); + + it('treats a ciphertext moved to another key as a miss', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.set('a', value()); + inner.tokens.set('b', inner.tokens.get('a')!); + expect(await store.get('b')).toBeUndefined(); + expect(await store.get('a')).toBeDefined(); + }); + + it('treats an entry whose plaintext expiry differs from the sealed expiry as a miss', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.set('k', value()); + const stored = inner.tokens.get('k')!; + inner.tokens.set('k', { ...stored, expiresAt: stored.expiresAt + 86_400 }); + expect(await store.get('k')).toBeUndefined(); + }); + + it('binds token ciphertexts injectively as well as by key', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.set('a:b', value()); + inner.tokens.set('b', inner.tokens.get('a:b')!); + inner.tokens.set('a', inner.tokens.get('a:b')!); + expect(await store.get('b')).toBeUndefined(); + expect(await store.get('a')).toBeUndefined(); + }); + + it('recovers when key derivation fails once', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + const spy = vi.spyOn(globalThis.crypto.subtle, 'importKey').mockRejectedValueOnce(new Error('transient')); + await expect(store.set('k', value())).rejects.toThrow('transient'); + spy.mockRestore(); + await store.set('k', value()); + expect(await store.get('k')).toEqual(value()); + }); + + it.each([ + ['plaintext cached token', { accessToken: ACCESS_TOKEN, expiresAt: 1, grantedScopes: [] }], + ['non-string accessToken', { accessToken: 5, expiresAt: 1, grantedScopes: [] }], + ['null', null], + ['string', 'garbage'], + ])('treats a malformed inner value (%s) as a miss', async (_name, bad) => { + const inner = new RawStore(); + inner.tokens.set('k', bad as unknown as CachedToken); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + expect(await store.get('k')).toBeUndefined(); + }); + + it('does not swallow a failure raised by the inner store', async () => { + const inner = new RawStore(); + inner.get = async () => { + throw new Error('backend down'); + }; + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await expect(store.get('k')).rejects.toThrow('backend down'); + }); + }); + + describe('index', () => { + it('round trips members', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + const member = { tokenKey: 't1', grantedScopes: ['a', 'b'], expiresAt: 4_000_000_000 }; + await store.addIndexMember('idx', member); + expect(await store.listIndexMembers('idx')).toEqual([member]); + }); + + it('replaces a member with the same token key', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + await store.addIndexMember('idx', { tokenKey: 't1', grantedScopes: ['a'], expiresAt: 4_000_000_000 }); + await store.addIndexMember('idx', { tokenKey: 't1', grantedScopes: ['a', 'b'], expiresAt: 4_000_000_000 }); + const listed = await store.listIndexMembers('idx'); + expect(listed).toHaveLength(1); + expect(listed[0].grantedScopes).toEqual(['a', 'b']); + }); + + it('returns an empty list for an unknown index', async () => { + const store = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + expect(await store.listIndexMembers('idx')).toEqual([]); + }); + + it('drops a tampered member and keeps the others', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.addIndexMember('idx', { tokenKey: 't1', grantedScopes: ['a'], expiresAt: 4_000_000_000 }); + await store.addIndexMember('idx', { tokenKey: 't2', grantedScopes: ['b'], expiresAt: 4_000_000_000 }); + const list = inner.indexes.get('idx')!; + list[0] = { ...list[0], grantedScopes: [tamper(list[0].grantedScopes[0])] }; + expect((await store.listIndexMembers('idx')).map((m) => m.tokenKey)).toEqual(['t2']); + }); + + it('drops a member copied under another index or token key', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.addIndexMember('idx', { tokenKey: 't1', grantedScopes: ['admin'], expiresAt: 4_000_000_000 }); + const original = inner.indexes.get('idx')![0]; + inner.indexes.set('other', [original]); + inner.indexes.set('idx2', [{ ...original, tokenKey: 'victim' }]); + expect(await store.listIndexMembers('other')).toEqual([]); + expect(await store.listIndexMembers('idx2')).toEqual([]); + }); + + it('drops a plaintext member injected into the inner store', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + inner.indexes.set('idx', [{ tokenKey: 't1', grantedScopes: ['admin'], expiresAt: 4_000_000_000 }]); + expect(await store.listIndexMembers('idx')).toEqual([]); + }); + + it('ignores a malformed index listing from the inner store', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + inner.listIndexMembers = async () => 'garbage' as unknown as []; + expect(await store.listIndexMembers('idx')).toEqual([]); + + inner.listIndexMembers = async () => + [null, { tokenKey: 5, grantedScopes: [] }, { tokenKey: 't', grantedScopes: 'x' }] as unknown as []; + expect(await store.listIndexMembers('idx')).toEqual([]); + }); + + it('reads as empty when listed under a different secret', async () => { + const inner = new RawStore(); + await createEncryptedTokenStore(inner, { secret: SECRET }).addIndexMember('idx', { + tokenKey: 't1', + grantedScopes: ['a'], + expiresAt: 4_000_000_000, + }); + expect(await createEncryptedTokenStore(inner, { secret: OTHER_SECRET }).listIndexMembers('idx')).toEqual([]); + }); + + it('binds members injectively, so keys that differ only where a colon moves do not collide', async () => { + const inner = new RawStore(); + const store = createEncryptedTokenStore(inner, { secret: SECRET }); + await store.addIndexMember('a:b', { tokenKey: 'c', grantedScopes: ['admin'], expiresAt: 4_000_000_000 }); + const original = inner.indexes.get('a:b')![0]; + inner.indexes.set('a', [{ ...original, tokenKey: 'b:c' }]); + expect(await store.listIndexMembers('a')).toEqual([]); + expect(await store.listIndexMembers('a:b')).toHaveLength(1); + }); + + it('works on top of the in-memory indexed store', async () => { + const store = createEncryptedTokenStore(new MemoryIndexedTokenStore(), { secret: SECRET }); + const v = value(); + await store.set('k', v); + await store.addIndexMember('idx', { tokenKey: 'k', grantedScopes: v.grantedScopes, expiresAt: v.expiresAt }); + expect(await store.get('k')).toEqual(v); + expect(await store.listIndexMembers('idx')).toHaveLength(1); + }); + }); + + describe('shape', () => { + it('exposes index methods only when the inner store has them', () => { + const plain = createEncryptedTokenStore(new PlainStore(), { secret: SECRET }) as Partial; + expect(plain.addIndexMember).toBeUndefined(); + expect(plain.listIndexMembers).toBeUndefined(); + + const indexed = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + expect(typeof indexed.addIndexMember).toBe('function'); + expect(typeof indexed.listIndexMembers).toBe('function'); + }); + + it('types an indexed inner store as indexed', () => { + const indexed: IndexedTokenStore = createEncryptedTokenStore(new RawStore(), { secret: SECRET }); + expect(indexed).toBeDefined(); + }); + }); +}); + +describe('createEncryptedTokenStore with ApiClient in non_strict mode', () => { + const AUDIENCE = 'https://api.example.com'; + const DOWNSTREAM = 'https://downstream.example.com'; + const server = setupServer(); + let key: TestKey; + + beforeAll(async () => { + key = await createTestKey(); + server.listen({ onUnhandledRequest: 'error' }); + }); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it('serves a narrower request from a cached superset and exchanges once', async () => { + const auth0 = createAuth0Handlers({ key }); + server.use(...auth0.handlers); + const store = createEncryptedTokenStore(new MemoryIndexedTokenStore(), { secret: SECRET }); + const client = new ApiClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'client-1', + clientSecret: 'client-secret', + scopeMatching: 'non_strict', + }); + const subject = await mintAccessToken(key, { iss: auth0.issuer, aud: AUDIENCE, sub: 'user|1' }); + + const wide = await client.getTokenOnBehalfOf(subject, { audience: DOWNSTREAM, scope: 'read write' }, { store }); + const narrow = await client.getTokenOnBehalfOf(subject, { audience: DOWNSTREAM, scope: 'read' }, { store }); + + expect(auth0.state.exchangeCount).toBe(1); + expect(narrow.accessToken).toBe(wide.accessToken); + }); +}); diff --git a/packages/ai-mcp/test/stores/memory-indexed-store.spec.ts b/packages/ai-mcp/test/stores/memory-indexed-store.spec.ts new file mode 100644 index 00000000..ece1df0e --- /dev/null +++ b/packages/ai-mcp/test/stores/memory-indexed-store.spec.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InvalidConfigurationError } from '@auth0/auth0-api-js'; +import type { CachedToken } from '@auth0/auth0-api-js'; +import { MemoryIndexedTokenStore } from '../../src/stores/memory-indexed-store.js'; + +const now = () => Math.floor(Date.now() / 1000); + +function token(overrides: Partial = {}): CachedToken { + return { accessToken: 'at', expiresAt: now() + 3600, grantedScopes: ['read'], ...overrides }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('MemoryIndexedTokenStore', () => { + describe('options', () => { + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])('rejects maxEntries %s', (maxEntries) => { + expect(() => new MemoryIndexedTokenStore({ maxEntries })).toThrowError(InvalidConfigurationError); + }); + + it('accepts no options', () => { + expect(() => new MemoryIndexedTokenStore()).not.toThrow(); + }); + }); + + describe('tokens', () => { + it('returns undefined for an unknown key', async () => { + expect(await new MemoryIndexedTokenStore().get('nope')).toBeUndefined(); + }); + + it('round trips and deletes', async () => { + const store = new MemoryIndexedTokenStore(); + const value = token({ scope: 'read', tokenType: 'bearer', issuedTokenType: 'x' }); + await store.set('k', value); + expect(await store.get('k')).toEqual(value); + await store.delete('k'); + expect(await store.get('k')).toBeUndefined(); + }); + + it('does not let callers mutate stored state through the written reference', async () => { + const store = new MemoryIndexedTokenStore(); + const value = token(); + await store.set('k', value); + value.accessToken = 'changed'; + value.grantedScopes.push('admin'); + expect((await store.get('k'))?.accessToken).toBe('at'); + expect((await store.get('k'))?.grantedScopes).toEqual(['read']); + }); + + it('does not let callers mutate stored state through a returned reference', async () => { + const store = new MemoryIndexedTokenStore(); + await store.set('k', token()); + const first = (await store.get('k'))!; + first.accessToken = 'changed'; + first.grantedScopes.push('admin'); + const second = (await store.get('k'))!; + expect(second.accessToken).toBe('at'); + expect(second.grantedScopes).toEqual(['read']); + }); + + it('drops expired entries on read', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2030-01-01T00:00:00Z')); + const store = new MemoryIndexedTokenStore(); + await store.set('k', token({ expiresAt: now() + 10 })); + expect(await store.get('k')).toBeDefined(); + vi.advanceTimersByTime(11_000); + expect(await store.get('k')).toBeUndefined(); + expect(store.size).toBe(0); + }); + + it('does not store a value that has already expired', async () => { + const store = new MemoryIndexedTokenStore(); + await store.set('k', token({ expiresAt: now() - 1 })); + expect(store.size).toBe(0); + expect(await store.get('k')).toBeUndefined(); + }); + + it('evicts the least recently used token beyond maxEntries', async () => { + const store = new MemoryIndexedTokenStore({ maxEntries: 2 }); + await store.set('a', token({ accessToken: 'a' })); + await store.set('b', token({ accessToken: 'b' })); + await store.get('a'); // a is now the most recently used + await store.set('c', token({ accessToken: 'c' })); + + expect(store.size).toBe(2); + expect(await store.get('b')).toBeUndefined(); + expect((await store.get('a'))?.accessToken).toBe('a'); + expect((await store.get('c'))?.accessToken).toBe('c'); + }); + + it('never grows past maxEntries', async () => { + const store = new MemoryIndexedTokenStore({ maxEntries: 5 }); + for (let i = 0; i < 50; i++) { + await store.set(`k${i}`, token()); + } + expect(store.size).toBe(5); + }); + }); + + describe('index', () => { + const member = (tokenKey: string, extra: Partial<{ expiresAt: number; grantedScopes: string[] }> = {}) => ({ + tokenKey, + grantedScopes: ['read'], + expiresAt: now() + 3600, + ...extra, + }); + + it('returns an empty list for an unknown index', async () => { + expect(await new MemoryIndexedTokenStore().listIndexMembers('i')).toEqual([]); + }); + + it('adds members and replaces one with the same token key', async () => { + const store = new MemoryIndexedTokenStore(); + await store.addIndexMember('i', member('t1')); + await store.addIndexMember('i', member('t2')); + await store.addIndexMember('i', member('t1', { grantedScopes: ['read', 'write'] })); + + const members = await store.listIndexMembers('i'); + expect(members).toHaveLength(2); + expect(members.find((m) => m.tokenKey === 't1')?.grantedScopes).toEqual(['read', 'write']); + }); + + it('isolates indexes from each other', async () => { + const store = new MemoryIndexedTokenStore(); + await store.addIndexMember('i1', member('t1')); + expect(await store.listIndexMembers('i2')).toEqual([]); + }); + + it('does not let callers mutate stored members', async () => { + const store = new MemoryIndexedTokenStore(); + const input = member('t1'); + await store.addIndexMember('i', input); + input.grantedScopes.push('admin'); + const listed = await store.listIndexMembers('i'); + listed[0].grantedScopes.push('admin'); + expect((await store.listIndexMembers('i'))[0].grantedScopes).toEqual(['read']); + }); + + it('drops expired members when listing', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2030-01-01T00:00:00Z')); + const store = new MemoryIndexedTokenStore(); + await store.addIndexMember('i', member('t1', { expiresAt: now() + 5 })); + await store.addIndexMember('i', member('t2')); + vi.advanceTimersByTime(6_000); + expect((await store.listIndexMembers('i')).map((m) => m.tokenKey)).toEqual(['t2']); + }); + + it('removes members when the token they point at is deleted', async () => { + const store = new MemoryIndexedTokenStore(); + await store.set('t1', token()); + await store.addIndexMember('i', member('t1')); + await store.delete('t1'); + expect(await store.listIndexMembers('i')).toEqual([]); + }); + + it('removes members when the token they point at is evicted', async () => { + const store = new MemoryIndexedTokenStore({ maxEntries: 1 }); + await store.set('t1', token()); + await store.addIndexMember('i', member('t1')); + await store.set('t2', token()); + expect(await store.listIndexMembers('i')).toEqual([]); + }); + + it('removes members when the token they point at expires on read', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2030-01-01T00:00:00Z')); + const store = new MemoryIndexedTokenStore(); + await store.set('t1', token({ expiresAt: now() + 5 })); + await store.addIndexMember('i', member('t1', { expiresAt: now() + 3600 })); + vi.advanceTimersByTime(6_000); + await store.get('t1'); + expect(await store.listIndexMembers('i')).toEqual([]); + }); + + it('evicts the least recently used index when maxEntries is reached across indexes', async () => { + const store = new MemoryIndexedTokenStore({ maxEntries: 1 }); + await store.addIndexMember('i1', member('t1')); + await store.addIndexMember('i2', member('t2')); + expect(await store.listIndexMembers('i1')).toEqual([]); + expect((await store.listIndexMembers('i2')).map((m) => m.tokenKey)).toEqual(['t2']); + }); + + it('bounds the total number of members even when no token backs them', async () => { + const store = new MemoryIndexedTokenStore({ maxEntries: 3 }); + for (let i = 0; i < 20; i++) { + await store.addIndexMember(`i${i % 4}`, member(`t${i}`)); + } + let total = 0; + for (let i = 0; i < 4; i++) { + total += (await store.listIndexMembers(`i${i}`)).length; + } + expect(total).toBe(3); + }); + }); +}); From 0a6e3a51bb8c9e6e63bd3be04af65c6170b7303f Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:25:20 +0530 Subject: [PATCH 08/31] feat(ai-mcp): add the MCP client and request-scoped client 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 --- packages/ai-mcp/src/client.ts | 140 +++++++ packages/ai-mcp/src/request-scoped-client.ts | 95 +++++ packages/ai-mcp/test/client.spec.ts | 296 ++++++++++++++ .../ai-mcp/test/request-scoped-client.spec.ts | 369 ++++++++++++++++++ 4 files changed, 900 insertions(+) create mode 100644 packages/ai-mcp/src/client.ts create mode 100644 packages/ai-mcp/src/request-scoped-client.ts create mode 100644 packages/ai-mcp/test/client.spec.ts create mode 100644 packages/ai-mcp/test/request-scoped-client.spec.ts diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts new file mode 100644 index 00000000..9797735d --- /dev/null +++ b/packages/ai-mcp/src/client.ts @@ -0,0 +1,140 @@ +import { ApiClient, InvalidConfigurationError } from '@auth0/auth0-api-js'; +import type { IndexedTokenStore, TokenStore, VerifiedToken, VerifyAccessTokenOptions } from '@auth0/auth0-api-js'; +import { UnauthenticatedError } from './errors.js'; +import { buildPrincipal } from './principal.js'; +import { createRequestScopedClient } from './request-scoped-client.js'; +import type { McpClientOptions, Principal, RequestScopedMcpClient } from './types.js'; + +/** + * An opaque handle for a caller whose access token has been verified by an + * {@link McpClient}. + * + * It exposes only the caller's {@link Principal}. The verified token is held + * privately by the client that issued the handle, and the handle serializes and + * inspects to a redacted view, so logging it cannot leak a credential. Only + * {@link McpClient.verifyAccessToken} produces handles that + * {@link McpClient.withToken} accepts. A copy, a hand-built object or a handle from + * another client is rejected. + */ +export interface VerifiedCaller { + /** The caller's identity. */ + readonly principal: Principal; +} + +/** Verifies incoming access tokens and creates clients bound to the verified caller. */ +export interface McpClient { + /** + * Verifies an access token (Bearer or DPoP) and resolves the caller. Organization + * policy and DPoP rules configured on the client are enforced here. Errors from the + * core client propagate unchanged. + * + * A DPoP-bound subject token can be exchanged on behalf of the caller only because + * it is verified here, at ingress, and the verified claims are passed to the core + * client. The exchange request itself carries no DPoP proof. If the authorization + * server rejects that exchange, the error propagates to the caller. + * + * With `dpop.mode` set to `'disabled'` the core client accepts a DPoP-bound token + * presented as a Bearer token. Leave DPoP enabled unless you intend that. + */ + verifyAccessToken(options: VerifyAccessTokenOptions): Promise; + /** + * Creates a client bound to a caller returned by this client's + * {@link McpClient.verifyAccessToken}. + * @throws {UnauthenticatedError} When `caller` was not produced by this client, or + * when the caller's token has expired. + */ + withToken(caller: VerifiedCaller): RequestScopedMcpClient; +} + +function isIndexedStore(store: TokenStore | undefined): store is IndexedTokenStore { + const candidate = store as Partial | undefined; + return typeof candidate?.addIndexMember === 'function' && typeof candidate.listIndexMembers === 'function'; +} + +const CORE_CLIENT_OPTIONS = [ + 'clientId', + 'clientSecret', + 'scopeMatching', + 'organizationPolicy', + 'organizationId', + 'dpop', +] as const; + +/** + * Creates an {@link McpClient}. + * + * When `scopeMatching` is `'non_strict'` and no indexed `tokenStore` is supplied, this + * throws at construction. If you pass your own `apiClient`, its matching mode cannot be + * read, so that check is skipped and the core client reports a mismatch on the first + * token request instead. Supplying `apiClient` together with any option that configures + * the core client is rejected, so no setting is silently ignored. + * + * @throws {InvalidConfigurationError} On an unusable scope matching and store combination, + * on `apiClient` combined with core client options, or on any invalid option the core + * client rejects. + */ +export function createMcpClient(options: McpClientOptions): McpClient { + if (options.apiClient !== undefined) { + const conflicting = CORE_CLIENT_OPTIONS.filter((name) => options[name] !== undefined); + if (conflicting.length > 0) { + throw new InvalidConfigurationError( + `Invalid configuration: "apiClient" cannot be combined with ${conflicting.map((n) => `"${n}"`).join(', ')}. Configure those on the client you pass in.` + ); + } + } else if (options.scopeMatching === 'non_strict' && !isIndexedStore(options.tokenStore)) { + throw new InvalidConfigurationError( + 'Invalid scope matching configuration: "non_strict" requires an indexed token store.' + ); + } + + const apiClient = + options.apiClient ?? + new ApiClient({ + domain: options.domain, + audience: options.audience, + ...(options.clientId !== undefined && { clientId: options.clientId }), + ...(options.clientSecret !== undefined && { clientSecret: options.clientSecret }), + ...(options.scopeMatching !== undefined && { scopeMatching: options.scopeMatching }), + ...(options.organizationPolicy !== undefined && { organizationPolicy: options.organizationPolicy }), + ...(options.organizationId !== undefined && { organizationId: options.organizationId }), + ...(options.dpop !== undefined && { dpop: options.dpop }), + }); + + const tokenStore = options.tokenStore; + // Maps each handle this client issued to the token the core client verified. Without a + // store the core client only compares token strings and never re-verifies, so a handle + // this client did not issue must never reach it. The core claims object is stored as + // returned, because the core client recognizes verified claims by object identity. + const issued = new WeakMap(); + + return { + async verifyAccessToken(verifyOptions) { + const claims = await apiClient.verifyAccessToken(verifyOptions); + const principal = buildPrincipal(claims); + const redacted = () => ({ principal }); + const caller: VerifiedCaller = Object.freeze({ + principal, + toJSON: redacted, + [Symbol.for('nodejs.util.inspect.custom')]: () => ({ principal }), + }); + issued.set(caller, Object.freeze({ accessToken: verifyOptions.accessToken, claims })); + return caller; + }, + + withToken(caller) { + const verified = typeof caller === 'object' && caller !== null ? issued.get(caller) : undefined; + if (verified === undefined) { + throw new UnauthenticatedError('Caller was not verified by this client.'); + } + if (caller.principal.expiresAt <= Math.floor(Date.now() / 1000)) { + throw new UnauthenticatedError('Access token has expired.'); + } + return createRequestScopedClient({ + apiClient, + tokenStore, + principal: caller.principal, + verified, + }); + }, + }; +} diff --git a/packages/ai-mcp/src/request-scoped-client.ts b/packages/ai-mcp/src/request-scoped-client.ts new file mode 100644 index 00000000..a21818aa --- /dev/null +++ b/packages/ai-mcp/src/request-scoped-client.ts @@ -0,0 +1,95 @@ +import { ApiClient, normalizeScopes } from '@auth0/auth0-api-js'; +import type { TokenStore, VerifiedToken } from '@auth0/auth0-api-js'; +import { DownscopedTokenError } from './errors.js'; +import type { + ClientCredentialsOptions, + ClientCredentialsTokenResult, + OnBehalfOfOptions, + Principal, + RequestScopedMcpClient, + TokenForServiceOptions, +} from './types.js'; + +/** Everything a request-scoped client needs from its parent client. */ +export interface RequestScopedClientInit { + /** The core client that performs the token operations. */ + readonly apiClient: ApiClient; + /** Cache for exchanged tokens, or `undefined` to exchange on every call. */ + readonly tokenStore: TokenStore | undefined; + /** The caller's identity. */ + readonly principal: Principal; + /** The caller's verified token, exactly as produced by token verification. */ + readonly verified: VerifiedToken; +} + +/** + * Throws when scopes were requested and the grant does not include all of them. + * No scope requested means nothing to enforce. + */ +function assertCovers(requested: string | undefined, grantedScopes: readonly string[]): void { + if (requested === undefined) return; + const granted = new Set(grantedScopes); + if (!normalizeScopes(requested).every((scope) => granted.has(scope))) { + throw new DownscopedTokenError(); + } +} + +/** + * Creates a {@link RequestScopedMcpClient} bound to one verified caller. + * + * The access token and verified claims are held in closure variables only. The + * returned object has no data properties, so serializing or logging it cannot + * reveal the token. + * + * The caller's `verified` object must be the one the core client produced for + * this exact token. It is passed to the core client untouched, because the core + * client recognizes verified claims by object identity. + * + * @param init - The core client, optional token store and the verified caller. + * @returns A frozen client that always acts for the given caller. + */ +export function createRequestScopedClient(init: RequestScopedClientInit): RequestScopedMcpClient { + const { apiClient, tokenStore, principal, verified } = init; + + return Object.freeze({ + async getTokenOnBehalfOf(options: OnBehalfOfOptions) { + const result = await apiClient.getTokenOnBehalfOf( + verified.accessToken, + { + audience: options.audience, + ...(options.scope !== undefined && { scope: options.scope }), + }, + { ...(tokenStore !== undefined && { store: tokenStore }), verified } + ); + + // The core client caches the token under the scopes it was granted and does not + // 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. + assertCovers(options.scope, result.grantedScopes); + return result; + }, + + async getClientCredentialsToken(options: ClientCredentialsOptions): Promise { + const result = await apiClient.getClientCredentialsToken({ + audience: options.audience, + ...(options.scope !== undefined && { scope: options.scope }), + }); + const grantedScopes = normalizeScopes(result.scope ?? options.scope ?? ''); + assertCovers(options.scope, grantedScopes); + return { ...result, grantedScopes }; + }, + + getTokenForService(options: TokenForServiceOptions) { + return apiClient.getAccessTokenForConnection({ + connection: options.connection, + accessToken: verified.accessToken, + ...(options.loginHint !== undefined && { loginHint: options.loginHint }), + }); + }, + + getPrincipal(): Principal { + return principal; + }, + }); +} diff --git a/packages/ai-mcp/test/client.spec.ts b/packages/ai-mcp/test/client.spec.ts new file mode 100644 index 00000000..b9b989e9 --- /dev/null +++ b/packages/ai-mcp/test/client.spec.ts @@ -0,0 +1,296 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { inspect } from 'node:util'; +import { createHash, randomUUID } from 'node:crypto'; +import { setupServer } from 'msw/node'; +import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair } from 'jose'; +import { + ApiClient, + InvalidConfigurationError, + MissingClientAuthError, + MissingOrganizationError, + OrganizationNotAllowedError, + VerifyAccessTokenError, +} from '@auth0/auth0-api-js'; +import type { CachedToken, IndexedTokenStore, TokenIndexMember } from '@auth0/auth0-api-js'; +import { createMcpClient } from '../src/client.js'; +import type { McpClient } from '../src/client.js'; +import { UnauthenticatedError } from '../src/errors.js'; +import { createAuth0Handlers, createTestKey, mintAccessToken } from './support/index.js'; +import type { TestKey } from './support/index.js'; + +const AUDIENCE = 'https://mcp.example.com'; +const DOWNSTREAM = 'https://downstream.example.com/resource'; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function useTenant() { + tenant = createAuth0Handlers({ key }); + server.use(...tenant.handlers); +} + +class PlainStore { + readonly entries = new Map(); + async get(k: string) { + return this.entries.get(k); + } + async set(k: string, v: CachedToken) { + this.entries.set(k, v); + } + async delete(k: string) { + this.entries.delete(k); + } +} + +class IndexedStore extends PlainStore implements IndexedTokenStore { + readonly index = new Map(); + async addIndexMember(indexKey: string, member: TokenIndexMember) { + this.index.set(indexKey, [...(this.index.get(indexKey) ?? []), member]); + } + async listIndexMembers(indexKey: string) { + return this.index.get(indexKey) ?? []; + } +} + +function makeClient(extra: Partial[0]> = {}): McpClient { + return createMcpClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'mcp-client', + clientSecret: 'secret', + ...extra, + }); +} + +function token(sub = 'user|1', claims: Record = {}) { + return mintAccessToken(key, { iss: tenant.issuer, aud: AUDIENCE, sub, scope: 'openid', claims }); +} + +async function dpopMaterial(accessTokenFor: (jkt: string) => Promise, url: string, method: string) { + const { privateKey, publicKey } = await generateKeyPair('ES256'); + const jwk = await exportJWK(publicKey); + const accessToken = await accessTokenFor(await calculateJwkThumbprint(jwk)); + const ath = createHash('sha256').update(accessToken).digest('base64url'); + const dpopProof = await new SignJWT({ htm: method, htu: url, jti: randomUUID(), ath }) + .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk }) + .setIssuedAt() + .sign(privateKey); + return { accessToken, dpopProof }; +} + +describe('createMcpClient construction', () => { + it('throws InvalidConfigurationError at construction for non_strict without a store', () => { + expect(() => makeClient({ scopeMatching: 'non_strict' })).toThrow(InvalidConfigurationError); + }); + + it('throws InvalidConfigurationError at construction for non_strict with a plain store', () => { + expect(() => makeClient({ scopeMatching: 'non_strict', tokenStore: new PlainStore() })).toThrow( + InvalidConfigurationError + ); + }); + + it('throws for a store that implements only one index method', () => { + const half = Object.assign(new PlainStore(), { addIndexMember: async () => undefined }); + expect(() => makeClient({ scopeMatching: 'non_strict', tokenStore: half })).toThrow(InvalidConfigurationError); + }); + + it('accepts non_strict with an indexed store', () => { + expect(() => makeClient({ scopeMatching: 'non_strict', tokenStore: new IndexedStore() })).not.toThrow(); + }); + + it('accepts strict mode with no store or a plain store', () => { + expect(() => makeClient()).not.toThrow(); + expect(() => makeClient({ scopeMatching: 'strict', tokenStore: new PlainStore() })).not.toThrow(); + }); + + it('throws when organizationId is set without the required organization policy', () => { + expect(() => makeClient({ organizationId: 'org_1' })).toThrow(InvalidConfigurationError); + }); + + it('accepts a supplied apiClient together with a token store', () => { + const apiClient = new ApiClient({ domain: 'tenant.example.com', audience: AUDIENCE }); + expect(() => + createMcpClient({ domain: 'tenant.example.com', audience: AUDIENCE, apiClient, tokenStore: new PlainStore() }) + ).not.toThrow(); + }); + + it.each([ + ['clientId', { clientId: 'x' }], + ['clientSecret', { clientSecret: 'x' }], + ['scopeMatching', { scopeMatching: 'strict' as const }], + ['organizationPolicy', { organizationPolicy: 'allow' as const }], + ['organizationId', { organizationId: 'org_1' }], + ['dpop', { dpop: { mode: 'allowed' as const } }], + ])('rejects apiClient combined with %s', (_name, extra) => { + const apiClient = new ApiClient({ domain: 'tenant.example.com', audience: AUDIENCE }); + expect(() => createMcpClient({ domain: 'tenant.example.com', audience: AUDIENCE, apiClient, ...extra })).toThrow( + InvalidConfigurationError + ); + }); + + it('does not expose the core client', () => { + expect('apiClient' in makeClient()).toBe(false); + }); +}); + +describe('verifyAccessToken', () => { + it('returns the principal and hands core the exact claims object it produced', async () => { + useTenant(); + const apiClient = new ApiClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'mcp-client', + clientSecret: 'secret', + }); + const verifySpy = vi.spyOn(apiClient, 'verifyAccessToken'); + const exchangeSpy = vi.spyOn(apiClient, 'getTokenOnBehalfOf'); + const client = createMcpClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + apiClient, + tokenStore: new PlainStore(), + }); + const accessToken = await token('user|9', { org_id: 'org_1' }); + + const caller = await client.verifyAccessToken({ accessToken }); + await client.withToken(caller).getTokenOnBehalfOf({ audience: DOWNSTREAM }); + + const coreClaims = await verifySpy.mock.results[0]!.value; + const context = exchangeSpy.mock.calls[0]![2]!; + expect(context.verified!.claims).toBe(coreClaims); + expect(context.verified!.accessToken).toBe(accessToken); + expect(exchangeSpy.mock.calls[0]![0]).toBe(accessToken); + expect(caller.principal.sub).toBe('user|9'); + expect(caller.principal.orgId).toBe('org_1'); + }); + + it('propagates core verification errors unchanged', async () => { + useTenant(); + const client = makeClient(); + const bad = await mintAccessToken(key, { iss: tenant.issuer, aud: 'https://other.example.com' }); + + await expect(client.verifyAccessToken({ accessToken: bad })).rejects.toBeInstanceOf(VerifyAccessTokenError); + }); + + it('enforces organization policy at ingress', async () => { + useTenant(); + const client = makeClient({ organizationPolicy: 'required', organizationId: ['org_ok'] }); + + await expect(client.verifyAccessToken({ accessToken: await token('user|1') })).rejects.toBeInstanceOf( + MissingOrganizationError + ); + await expect( + client.verifyAccessToken({ accessToken: await token('user|1', { org_id: 'org_bad' }) }) + ).rejects.toBeInstanceOf(OrganizationNotAllowedError); + const ok = await client.verifyAccessToken({ accessToken: await token('user|1', { org_id: 'org_ok' }) }); + expect(ok.principal.orgId).toBe('org_ok'); + }); + + it('returns a frozen handle that does not reveal the token when serialized or inspected', async () => { + useTenant(); + const client = makeClient(); + const accessToken = await token(); + + const caller = await client.verifyAccessToken({ accessToken }); + + expect(Object.isFrozen(caller)).toBe(true); + expect(JSON.stringify(caller)).not.toContain(accessToken); + expect(JSON.parse(JSON.stringify(caller))).toEqual({ principal: caller.principal }); + expect(inspect(caller, { depth: 6, showHidden: true })).not.toContain(accessToken); + expect(Object.values(caller)).not.toContain(accessToken); + }); + + describe('DPoP', () => { + const URL_ = 'https://mcp.example.com/mcp'; + + it('rejects a Bearer token when DPoP is required', async () => { + useTenant(); + const client = makeClient({ dpop: { mode: 'required' } }); + + await expect(client.verifyAccessToken({ accessToken: await token() })).rejects.toThrow(); + }); + + it('verifies a DPoP-bound token and exchanges it on behalf of the caller', async () => { + useTenant(); + const client = makeClient({ dpop: { mode: 'required' }, tokenStore: new PlainStore() }); + const { accessToken, dpopProof } = await dpopMaterial( + (jkt) => token('user|dpop', { cnf: { jkt } }), + URL_, + 'POST' + ); + + const caller = await client.verifyAccessToken({ + accessToken, + scheme: 'dpop', + dpopProof, + httpMethod: 'POST', + httpUrl: URL_, + }); + const scoped = client.withToken(caller); + await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(caller.principal.sub).toBe('user|dpop'); + expect(tenant.state.exchangeCount).toBe(1); + }); + }); +}); + +describe('withToken', () => { + it('rejects values that this client did not produce', async () => { + useTenant(); + const client = makeClient({ tokenStore: new PlainStore() }); + const other = makeClient(); + const real = await client.verifyAccessToken({ accessToken: await token() }); + + expect(() => client.withToken({ principal: real.principal })).toThrow(UnauthenticatedError); + expect(() => client.withToken({ ...real })).toThrow(UnauthenticatedError); + expect(() => client.withToken(JSON.parse(JSON.stringify(real)))).toThrow(UnauthenticatedError); + expect(() => client.withToken(Object.create(real))).toThrow(UnauthenticatedError); + expect(() => other.withToken(real)).toThrow(UnauthenticatedError); + expect(() => client.withToken(null as never)).toThrow(UnauthenticatedError); + expect(() => client.withToken(undefined as never)).toThrow(UnauthenticatedError); + expect(() => client.withToken(real)).not.toThrow(); + }); + + it('throws UnauthenticatedError once the caller token has expired', async () => { + useTenant(); + const client = makeClient(); + const caller = await client.verifyAccessToken({ accessToken: await token() }); + const now = Date.now(); + const spy = vi.spyOn(Date, 'now').mockReturnValue(now + 2 * 3600 * 1000); + try { + expect(() => client.withToken(caller)).toThrow(UnauthenticatedError); + } finally { + spy.mockRestore(); + } + expect(() => client.withToken(caller)).not.toThrow(); + }); + + it('fails with the core missing-client-auth error when the client has no secret', async () => { + useTenant(); + const client = makeClient({ clientSecret: undefined, tokenStore: new PlainStore() }); + const scoped = client.withToken(await client.verifyAccessToken({ accessToken: await token() })); + + await expect(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })).rejects.toBeInstanceOf(MissingClientAuthError); + }); + + it('exchanges through a store with the verified claims from the same client', async () => { + useTenant(); + const client = makeClient({ tokenStore: new PlainStore() }); + const scoped = client.withToken(await client.verifyAccessToken({ accessToken: await token() })); + + const result = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(result.accessToken).toEqual(expect.any(String)); + expect(tenant.state.exchangeCount).toBe(1); + }); +}); diff --git a/packages/ai-mcp/test/request-scoped-client.spec.ts b/packages/ai-mcp/test/request-scoped-client.spec.ts new file mode 100644 index 00000000..5cf128a2 --- /dev/null +++ b/packages/ai-mcp/test/request-scoped-client.spec.ts @@ -0,0 +1,369 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { inspect } from 'node:util'; +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { decodeJwt } from 'jose'; +import { MissingClientAuthError } from '@auth0/auth0-api-js'; +import type { CachedToken, IndexedTokenStore, TokenIndexMember } from '@auth0/auth0-api-js'; +import { createMcpClient } from '../src/client.js'; +import type { McpClient, VerifiedCaller } from '../src/client.js'; +import { DownscopedTokenError } from '../src/errors.js'; +import { createAuth0Handlers, createTestKey, mintAccessToken, scopeMode } from './support/index.js'; +import type { TestKey } from './support/index.js'; + +const AUDIENCE = 'https://mcp.example.com'; +const DOWNSTREAM = 'https://downstream.example.com/resource'; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function useTenant(scope = scopeMode.echo) { + tenant = createAuth0Handlers({ key, scope }); + server.use(...tenant.handlers); +} + +/** Minimal in-memory indexed store. */ +class TestIndexedStore implements IndexedTokenStore { + readonly entries = new Map(); + readonly index = new Map(); + async get(k: string) { + return this.entries.get(k); + } + async set(k: string, v: CachedToken) { + this.entries.set(k, v); + } + async delete(k: string) { + this.entries.delete(k); + } + async addIndexMember(indexKey: string, member: TokenIndexMember) { + const rest = (this.index.get(indexKey) ?? []).filter((m) => m.tokenKey !== member.tokenKey); + this.index.set(indexKey, [...rest, member]); + } + async listIndexMembers(indexKey: string) { + return this.index.get(indexKey) ?? []; + } +} + +class TestStore { + readonly entries = new Map(); + async get(k: string) { + return this.entries.get(k); + } + async set(k: string, v: CachedToken) { + this.entries.set(k, v); + } + async delete(k: string) { + this.entries.delete(k); + } +} + +function makeClient(extra: Partial[0]> = {}): McpClient { + return createMcpClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'mcp-client', + clientSecret: 'secret', + ...extra, + }); +} + +function subjectToken(sub = 'user|1', claims: Record = {}) { + return mintAccessToken(key, { iss: tenant.issuer, aud: AUDIENCE, sub, scope: 'openid', claims }); +} + +async function caller(client: McpClient, sub = 'user|1', claims: Record = {}): Promise { + return client.verifyAccessToken({ accessToken: await subjectToken(sub, claims) }); +} + +describe('request-scoped client: getTokenOnBehalfOf', () => { + it('reuses a cached token on an identical second call (strict)', async () => { + useTenant(); + const store = new TestStore(); + const client = makeClient({ tokenStore: store }); + const scoped = client.withToken(await caller(client)); + + const first = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' }); + const second = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' }); + + expect(tenant.state.exchangeCount).toBe(1); + expect(second.accessToken).toBe(first.accessToken); + expect(second.grantedScopes).toEqual(first.grantedScopes); + expect(first.grantedScopes).toEqual(['read:a', 'write:a']); + }); + + it('does not reuse a cached superset for a narrower request in strict mode', async () => { + useTenant(); + const client = makeClient({ tokenStore: new TestStore() }); + const scoped = client.withToken(await caller(client)); + + await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' }); + await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('reuses a cached superset for a narrower request in non_strict mode', async () => { + useTenant(); + const client = makeClient({ tokenStore: new TestIndexedStore(), scopeMatching: 'non_strict' }); + const scoped = client.withToken(await caller(client)); + + const wide = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' }); + const narrow = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(tenant.state.exchangeCount).toBe(1); + expect(narrow.accessToken).toBe(wide.accessToken); + }); + + it('exchanges on every call when no store is configured', async () => { + useTenant(); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('treats an absent scope in the response as granting what was requested', async () => { + useTenant(scopeMode.absent); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(result.grantedScopes).toEqual(['read:a']); + }); + + it('rejects an empty scope in the response when scopes were requested', async () => { + useTenant(scopeMode.empty); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + await expect(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' })).rejects.toBeInstanceOf( + DownscopedTokenError + ); + }); + + it('accepts an empty scope in the response when no scope was requested', async () => { + useTenant(scopeMode.empty); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM }); + + expect(result.grantedScopes).toEqual([]); + }); + + it('rejects a narrowed grant with a generic DownscopedTokenError', async () => { + useTenant(scopeMode.fixed('read:a')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const err = await scoped + .getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(DownscopedTokenError); + expect((err as DownscopedTokenError).statusCode).toBe(403); + expect((err as DownscopedTokenError).message).not.toContain('write:a'); + }); + + it('accepts a grant that is a superset of the request', async () => { + useTenant(scopeMode.fixed('read:a write:a extra')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(result.grantedScopes).toEqual(['extra', 'read:a', 'write:a']); + }); + + it('caches the narrowed token under its granted scopes even though the call is rejected', async () => { + useTenant(scopeMode.fixed('read:a')); + const store = new TestIndexedStore(); + const client = makeClient({ tokenStore: store, scopeMatching: 'non_strict' }); + const scoped = client.withToken(await caller(client)); + + await expect(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' })).rejects.toBeInstanceOf( + DownscopedTokenError + ); + // A later request the narrowed token does cover is served from the cache. + const reused = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(tenant.state.exchangeCount).toBe(1); + expect(reused.grantedScopes).toEqual(['read:a']); + }); + + it('never gives one caller the token cached for another caller', async () => { + useTenant(); + const store = new TestIndexedStore(); + const client = makeClient({ tokenStore: store, scopeMatching: 'non_strict' }); + const alice = client.withToken(await caller(client, 'user|alice')); + const bob = client.withToken(await caller(client, 'user|bob')); + + const a = await alice.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + const b = await bob.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + const aAgain = await alice.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(decodeJwt(a.accessToken).sub).toBe('user|alice'); + expect(decodeJwt(b.accessToken).sub).toBe('user|bob'); + expect(aAgain.accessToken).toBe(a.accessToken); + expect(tenant.state.exchangeCount).toBe(2); + }); +}); + +describe('request-scoped client: scope edge cases and configuration', () => { + it('accepts a request with duplicate and extra whitespace in the scope', async () => { + useTenant(scopeMode.fixed('read:a')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a read:a' }); + + expect(result.grantedScopes).toEqual(['read:a']); + }); + + it('does not throw for an empty requested scope', async () => { + useTenant(scopeMode.fixed('read:a')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + await expect(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: '' })).resolves.toBeDefined(); + }); + + it('fails with the core missing-client-auth error when the client has no secret', async () => { + useTenant(); + const client = makeClient({ clientSecret: undefined }); + const scoped = client.withToken(await caller(client)); + + await expect(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })).rejects.toBeInstanceOf(MissingClientAuthError); + }); +}); + +describe('request-scoped client: other operations', () => { + it('returns the caller principal', async () => { + useTenant(); + const client = makeClient(); + const c = await caller(client, 'user|7', { org_id: 'org_1' }); + + const principal = client.withToken(c).getPrincipal(); + + expect(principal).toBe(c.principal); + expect(principal.sub).toBe('user|7'); + expect(principal.orgId).toBe('org_1'); + }); + + it('does not expose the access token through serialization or inspection', async () => { + useTenant(); + const client = makeClient(); + const token = await subjectToken(); + const c = await client.verifyAccessToken({ accessToken: token }); + const scoped = client.withToken(c); + + expect(JSON.stringify(scoped)).toBe('{}'); + expect(inspect(scoped, { depth: 5, showHidden: true })).not.toContain(token); + expect(Object.values(scoped).some((v) => v === token)).toBe(false); + }); + + it('returns normalized grantedScopes for client credentials, from the response', async () => { + useTenant(scopeMode.fixed('b a a')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a' }); + + expect(result.grantedScopes).toEqual(['a', 'b']); + expect(result.accessToken).toEqual(expect.any(String)); + expect(tenant.state.clientCredentialsCount).toBe(1); + }); + + it('falls back to the requested scopes for client credentials when the response has none', async () => { + useTenant(scopeMode.absent); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'b a' }); + + expect(result.grantedScopes).toEqual(['a', 'b']); + }); + + it('rejects a client credentials grant that lacks a requested scope', async () => { + useTenant(scopeMode.fixed('a')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + await expect( + scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a b' }) + ).rejects.toBeInstanceOf(DownscopedTokenError); + }); + + it('rejects an empty client credentials grant when scopes were requested', async () => { + useTenant(scopeMode.empty); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + await expect(scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a' })).rejects.toBeInstanceOf( + DownscopedTokenError + ); + }); + + it('reports no granted scopes for an empty client credentials response when none were requested', async () => { + useTenant(scopeMode.empty); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getClientCredentialsToken({ audience: DOWNSTREAM }); + + expect(result.grantedScopes).toEqual([]); + }); + + it('returns no granted scopes for client credentials when nothing was requested or returned', async () => { + useTenant(scopeMode.absent); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const result = await scoped.getClientCredentialsToken({ audience: DOWNSTREAM }); + + expect(result.grantedScopes).toEqual([]); + }); + + it('requests a connection token with the caller token and is not cached', async () => { + useTenant(); + const seen: Record[] = []; + server.use( + http.post(tenant.tokenEndpoint, async ({ request }) => { + const params = Object.fromEntries(new URLSearchParams(await request.text())); + if (!params['requested_token_type']) return undefined; + seen.push(params); + return HttpResponse.json({ + access_token: 'connection-token', + expires_in: 3600, + scope: 'repo', + token_type: 'Bearer', + }); + }) + ); + const client = makeClient({ tokenStore: new TestStore() }); + const token = await subjectToken(); + const scoped = client.withToken(await client.verifyAccessToken({ accessToken: token })); + + const first = await scoped.getTokenForService({ connection: 'github', loginHint: 'octo' }); + await scoped.getTokenForService({ connection: 'github' }); + + expect(first.accessToken).toBe('connection-token'); + expect(seen).toHaveLength(2); + expect(seen[0]?.['subject_token']).toBe(token); + expect(seen[0]?.['connection']).toBe('github'); + expect(seen[0]?.['login_hint']).toBe('octo'); + expect(seen[1]?.['login_hint']).toBeUndefined(); + }); +}); From 2639fe6166aea60697ea53f27b4f6471078e9f4c Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:25:24 +0530 Subject: [PATCH 09/31] feat(ai-mcp): export the client, context, scope and store APIs 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 --- packages/ai-mcp/src/index.ts | 16 ++++++++++++++++ packages/ai-mcp/src/types.ts | 17 ++++++++++------- .../ai-mcp/test/smoke/module-formats.spec.ts | 11 +++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 5ec4c335..b6f002a0 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -13,3 +13,19 @@ export type { ClientCredentialsTokenResult, ServiceTokenResult, } from './types.js'; +export { buildPrincipal } from './principal.js'; +export { createMcpClient } from './client.js'; +export type { McpClient, VerifiedCaller } from './client.js'; +export { + runWithRequestContext, + getRequestContext, + getCallerPrincipal, + getCallerMcpClient, + getCallerMcpClientOrThrow, +} from './request-context.js'; +export { requireScopes, withRequiredScopes } from './scopes.js'; +export type { McpToolErrorResult } from './scopes.js'; +export { MemoryIndexedTokenStore } from './stores/memory-indexed-store.js'; +export type { MemoryIndexedTokenStoreOptions } from './stores/memory-indexed-store.js'; +export { createEncryptedTokenStore } from './stores/encrypted-store.js'; +export type { EncryptedTokenStoreOptions } from './stores/encrypted-store.js'; diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index 9fbf3364..ce10deb7 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -126,9 +126,10 @@ export interface McpClientOptions { clientSecret?: string; /** * Cache for exchanged tokens. Omit it to exchange on every call. Must be an - * {@link IndexedTokenStore} when `scopeMatching` is `'non_strict'`: the core - * client throws `InvalidConfigurationError` otherwise, when a token is requested - * (the store is supplied per call, so it cannot be checked earlier). + * {@link IndexedTokenStore} when `scopeMatching` is `'non_strict'`: `createMcpClient` + * throws `InvalidConfigurationError` at construction otherwise. That check is skipped + * when you pass your own `apiClient`, because its matching mode cannot be read; the + * core client then throws on the first token request. */ tokenStore?: TokenStore | IndexedTokenStore; /** @@ -155,10 +156,12 @@ export interface McpClientOptions { */ dpop?: ApiClientOptions['dpop']; /** - * A pre-built core client. When provided it is used as is, so `clientId`, - * `clientSecret`, `scopeMatching`, `organizationPolicy`, `organizationId` and - * `dpop` are ignored: configure those on the client you pass in. `tokenStore` - * is still honoured because it is supplied per call. + * A pre-built core client, used as is. Combining it with `clientId`, `clientSecret`, + * `scopeMatching`, `organizationPolicy`, `organizationId` or `dpop` makes + * `createMcpClient` throw `InvalidConfigurationError`, so no setting is silently + * ignored: configure those on the client you pass in. `tokenStore` is still honoured + * because it is supplied per call. The client is not exposed on the returned + * `McpClient`. */ apiClient?: ApiClient; } diff --git a/packages/ai-mcp/test/smoke/module-formats.spec.ts b/packages/ai-mcp/test/smoke/module-formats.spec.ts index 37c66def..3bd02be9 100644 --- a/packages/ai-mcp/test/smoke/module-formats.spec.ts +++ b/packages/ai-mcp/test/smoke/module-formats.spec.ts @@ -23,6 +23,17 @@ const expectedValues = [ "UnauthenticatedError", "InsufficientScopeError", "DownscopedTokenError", + "buildPrincipal", + "createMcpClient", + "runWithRequestContext", + "getRequestContext", + "getCallerPrincipal", + "getCallerMcpClient", + "getCallerMcpClientOrThrow", + "requireScopes", + "withRequiredScopes", + "MemoryIndexedTokenStore", + "createEncryptedTokenStore", ]; // Resolves the package by its own name so the exports map is what gets exercised. From b45196ece8dcd913ff2002124d64b2ea97608084 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 14:35:07 +0530 Subject: [PATCH 10/31] feat(ai-mcp): share concurrent on-behalf-of exchanges and isolate foreign 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 --- packages/ai-mcp/src/client.ts | 42 ++- packages/ai-mcp/src/index.ts | 2 +- packages/ai-mcp/src/request-scoped-client.ts | 87 ++++- packages/ai-mcp/test/client.spec.ts | 49 ++- .../test/request-scoped-single-flight.spec.ts | 322 ++++++++++++++++++ 5 files changed, 475 insertions(+), 27 deletions(-) create mode 100644 packages/ai-mcp/test/request-scoped-single-flight.spec.ts diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 9797735d..4a8eaa70 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -1,8 +1,15 @@ import { ApiClient, InvalidConfigurationError } from '@auth0/auth0-api-js'; -import type { IndexedTokenStore, TokenStore, VerifiedToken, VerifyAccessTokenOptions } from '@auth0/auth0-api-js'; +import type { + IndexedTokenStore, + OnBehalfOfTokenResult, + TokenStore, + VerifiedToken, + VerifyAccessTokenOptions, +} from '@auth0/auth0-api-js'; import { UnauthenticatedError } from './errors.js'; import { buildPrincipal } from './principal.js'; import { createRequestScopedClient } from './request-scoped-client.js'; +import { SingleFlight } from './single-flight.js'; import type { McpClientOptions, Principal, RequestScopedMcpClient } from './types.js'; /** @@ -21,6 +28,22 @@ export interface VerifiedCaller { readonly principal: Principal; } +/** + * Thrown by {@link McpClient.withToken} when the value passed in was not produced by this + * client's {@link McpClient.verifyAccessToken}. + * + * This is a server programming error (a wrong, copied or hand-built handle), not a problem + * with the caller's credentials. It deliberately does not extend the core `AuthError`, so a + * transport maps it to an internal server error and never to a 401 challenge. Its message + * is fixed and generic. + */ +export class ForeignCallerError extends Error { + constructor() { + super('Caller handle was not issued by this client.'); + this.name = 'ForeignCallerError'; + } +} + /** Verifies incoming access tokens and creates clients bound to the verified caller. */ export interface McpClient { /** @@ -40,8 +63,9 @@ export interface McpClient { /** * Creates a client bound to a caller returned by this client's * {@link McpClient.verifyAccessToken}. - * @throws {UnauthenticatedError} When `caller` was not produced by this client, or - * when the caller's token has expired. + * @throws {ForeignCallerError} When `caller` was not produced by this client. This is a + * programming error and is not an authentication failure. + * @throws {UnauthenticatedError} When the caller's token has expired. */ withToken(caller: VerifiedCaller): RequestScopedMcpClient; } @@ -101,6 +125,14 @@ export function createMcpClient(options: McpClientOptions): McpClient { }); const tokenStore = options.tokenStore; + // One per client: request-scoped clients of this client share concurrent exchanges. + const exchanges = new SingleFlight(); + const exchangeIdentity = { + tenant: options.domain, + clientId: options.clientId, + audience: options.audience, + layout: options.scopeMatching === 'non_strict' ? ('index' as const) : ('strict' as const), + }; // Maps each handle this client issued to the token the core client verified. Without a // store the core client only compares token strings and never re-verifies, so a handle // this client did not issue must never reach it. The core claims object is stored as @@ -124,7 +156,7 @@ export function createMcpClient(options: McpClientOptions): McpClient { withToken(caller) { const verified = typeof caller === 'object' && caller !== null ? issued.get(caller) : undefined; if (verified === undefined) { - throw new UnauthenticatedError('Caller was not verified by this client.'); + throw new ForeignCallerError(); } if (caller.principal.expiresAt <= Math.floor(Date.now() / 1000)) { throw new UnauthenticatedError('Access token has expired.'); @@ -134,6 +166,8 @@ export function createMcpClient(options: McpClientOptions): McpClient { tokenStore, principal: caller.principal, verified, + exchanges, + exchangeIdentity, }); }, }; diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index b6f002a0..06586482 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -14,7 +14,7 @@ export type { ServiceTokenResult, } from './types.js'; export { buildPrincipal } from './principal.js'; -export { createMcpClient } from './client.js'; +export { createMcpClient, ForeignCallerError } from './client.js'; export type { McpClient, VerifiedCaller } from './client.js'; export { runWithRequestContext, diff --git a/packages/ai-mcp/src/request-scoped-client.ts b/packages/ai-mcp/src/request-scoped-client.ts index a21818aa..d15f24bb 100644 --- a/packages/ai-mcp/src/request-scoped-client.ts +++ b/packages/ai-mcp/src/request-scoped-client.ts @@ -1,6 +1,7 @@ -import { ApiClient, normalizeScopes } from '@auth0/auth0-api-js'; -import type { TokenStore, VerifiedToken } from '@auth0/auth0-api-js'; +import { ApiClient, buildOboTokenKey, normalizeScopes } from '@auth0/auth0-api-js'; +import type { OboCacheKeyLayout, OnBehalfOfTokenResult, TokenStore, VerifiedToken } from '@auth0/auth0-api-js'; import { DownscopedTokenError } from './errors.js'; +import type { SingleFlight } from './single-flight.js'; import type { ClientCredentialsOptions, ClientCredentialsTokenResult, @@ -20,6 +21,60 @@ export interface RequestScopedClientInit { readonly principal: Principal; /** The caller's verified token, exactly as produced by token verification. */ readonly verified: VerifiedToken; + /** + * Shared by every request-scoped client created by the same parent client, so that + * concurrent identical exchanges collapse into one. + */ + readonly exchanges: SingleFlight; + /** + * What the parent client fixes for every exchange it performs. These never differ + * between request-scoped clients of one parent, and are part of the de-duplication key + * so the key stays a superset of what the core cache distinguishes. + */ + readonly exchangeIdentity: ExchangeIdentity; +} + +/** The parent client's side of an exchange: who performs it and how scopes are matched. */ +export interface ExchangeIdentity { + /** The tenant domain the exchange is sent to. */ + readonly tenant: string; + /** The client id performing the exchange, if configured. */ + readonly clientId: string | undefined; + /** The audience the parent client validates incoming tokens against. */ + readonly audience: string; + /** The scope matching layout the core client caches under. */ + readonly layout: OboCacheKeyLayout; +} + +const KEYED_CLAIMS = ['iss', 'client_id', 'azp', 'sub', 'org_id', 'sid', 'jti'] as const; + +/** + * Builds the key that decides which concurrent exchanges may share one result, or + * `undefined` when the caller's claims cannot be keyed safely and the exchange must not + * be shared. + * + * The key is derived by the core client's own cache key builder from the same inputs the + * core cache uses: exchanging tenant, client and audience, the subject token's issuer, + * client, subject, organization and session (`sid`, else `jti`, else a digest of the + * token), the requested audience and the normalized scope set. Two requests that core + * would cache separately therefore never share a flight. + */ +function flightKey(identity: ExchangeIdentity, verified: VerifiedToken, audience: string, scope: string | undefined) { + const claims = verified.claims as Record; + // The key encoding reads a non-string claim as absent, so such a token could collide with + // a different identity. Exchange it alone. + if (KEYED_CLAIMS.some((name) => claims[name] !== undefined && typeof claims[name] !== 'string')) { + return undefined; + } + return buildOboTokenKey({ + layout: identity.layout, + exchangeTenant: identity.tenant, + exchangeClientId: identity.clientId, + exchangeAudience: identity.audience, + verified, + requestedAudience: audience, + scope, + }); } /** @@ -49,23 +104,31 @@ function assertCovers(requested: string | undefined, grantedScopes: readonly str * @returns A frozen client that always acts for the given caller. */ export function createRequestScopedClient(init: RequestScopedClientInit): RequestScopedMcpClient { - const { apiClient, tokenStore, principal, verified } = init; + const { apiClient, tokenStore, principal, verified, exchanges, exchangeIdentity } = init; + + const exchange = (options: OnBehalfOfOptions) => + apiClient.getTokenOnBehalfOf( + verified.accessToken, + { + audience: options.audience, + ...(options.scope !== undefined && { scope: options.scope }), + }, + { ...(tokenStore !== undefined && { store: tokenStore }), verified } + ); return Object.freeze({ async getTokenOnBehalfOf(options: OnBehalfOfOptions) { - const result = await apiClient.getTokenOnBehalfOf( - verified.accessToken, - { - audience: options.audience, - ...(options.scope !== undefined && { scope: options.scope }), - }, - { ...(tokenStore !== undefined && { store: tokenStore }), verified } - ); + // Concurrent identical requests from this parent client share one exchange. The + // outcome is not remembered once it settles, so a failure is retried by the next call. + const key = flightKey(exchangeIdentity, verified, options.audience, options.scope); + const shared = key === undefined ? await exchange(options) : await exchanges.run(key, () => exchange(options)); + // Each caller gets its own copy, so one caller changing its result cannot alter another's. + const result = { ...shared, grantedScopes: [...shared.grantedScopes] }; // The core client caches the token under the scopes it was granted and does not // 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. + // authority than it asked for. This runs per caller, after the shared exchange. assertCovers(options.scope, result.grantedScopes); return result; }, diff --git a/packages/ai-mcp/test/client.spec.ts b/packages/ai-mcp/test/client.spec.ts index b9b989e9..0c63e573 100644 --- a/packages/ai-mcp/test/client.spec.ts +++ b/packages/ai-mcp/test/client.spec.ts @@ -8,11 +8,12 @@ import { InvalidConfigurationError, MissingClientAuthError, MissingOrganizationError, + AuthError, OrganizationNotAllowedError, VerifyAccessTokenError, } from '@auth0/auth0-api-js'; import type { CachedToken, IndexedTokenStore, TokenIndexMember } from '@auth0/auth0-api-js'; -import { createMcpClient } from '../src/client.js'; +import { ForeignCallerError, createMcpClient } from '../src/client.js'; import type { McpClient } from '../src/client.js'; import { UnauthenticatedError } from '../src/errors.js'; import { createAuth0Handlers, createTestKey, mintAccessToken } from './support/index.js'; @@ -245,19 +246,36 @@ describe('verifyAccessToken', () => { }); describe('withToken', () => { - it('rejects values that this client did not produce', async () => { + it('rejects values that this client did not produce with a programming error, not a 401', async () => { useTenant(); const client = makeClient({ tokenStore: new PlainStore() }); const other = makeClient(); const real = await client.verifyAccessToken({ accessToken: await token() }); - expect(() => client.withToken({ principal: real.principal })).toThrow(UnauthenticatedError); - expect(() => client.withToken({ ...real })).toThrow(UnauthenticatedError); - expect(() => client.withToken(JSON.parse(JSON.stringify(real)))).toThrow(UnauthenticatedError); - expect(() => client.withToken(Object.create(real))).toThrow(UnauthenticatedError); - expect(() => other.withToken(real)).toThrow(UnauthenticatedError); - expect(() => client.withToken(null as never)).toThrow(UnauthenticatedError); - expect(() => client.withToken(undefined as never)).toThrow(UnauthenticatedError); + const foreign: Array<[string, () => unknown]> = [ + ['hand-built', () => client.withToken({ principal: real.principal })], + ['spread copy', () => client.withToken({ ...real })], + ['serialized copy', () => client.withToken(JSON.parse(JSON.stringify(real)))], + ['prototype descendant', () => client.withToken(Object.create(real))], + ['other client', () => other.withToken(real)], + ['null', () => client.withToken(null as never)], + ['undefined', () => client.withToken(undefined as never)], + ]; + for (const [, call] of foreign) { + const err = (() => { + try { + call(); + } catch (e) { + return e; + } + return undefined; + })(); + expect(err).toBeInstanceOf(ForeignCallerError); + expect(err).not.toBeInstanceOf(AuthError); + expect(err).not.toBeInstanceOf(UnauthenticatedError); + expect((err as Error).message).toBe('Caller handle was not issued by this client.'); + expect((err as { statusCode?: number }).statusCode).toBeUndefined(); + } expect(() => client.withToken(real)).not.toThrow(); }); @@ -268,7 +286,18 @@ describe('withToken', () => { const now = Date.now(); const spy = vi.spyOn(Date, 'now').mockReturnValue(now + 2 * 3600 * 1000); try { - expect(() => client.withToken(caller)).toThrow(UnauthenticatedError); + const err = (() => { + try { + client.withToken(caller); + } catch (e) { + return e; + } + return undefined; + })(); + expect(err).toBeInstanceOf(UnauthenticatedError); + expect(err).not.toBeInstanceOf(ForeignCallerError); + expect((err as UnauthenticatedError).statusCode).toBe(401); + expect((err as UnauthenticatedError).code).toBe('invalid_token'); } finally { spy.mockRestore(); } diff --git a/packages/ai-mcp/test/request-scoped-single-flight.spec.ts b/packages/ai-mcp/test/request-scoped-single-flight.spec.ts new file mode 100644 index 00000000..c03f241a --- /dev/null +++ b/packages/ai-mcp/test/request-scoped-single-flight.spec.ts @@ -0,0 +1,322 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { setupServer } from 'msw/node'; +import type { CachedToken } from '@auth0/auth0-api-js'; +import { createMcpClient } from '../src/client.js'; +import type { McpClient, VerifiedCaller } from '../src/client.js'; +import { DownscopedTokenError } from '../src/errors.js'; +import { createAuth0Handlers, createTestKey, mintAccessToken, scopeMode } from './support/index.js'; +import type { TestKey } from './support/index.js'; + +const AUDIENCE = 'https://mcp.example.com'; +const DOWNSTREAM = 'https://downstream.example.com/resource'; +const OTHER_DOWNSTREAM = 'https://other.example.com/resource'; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function useTenant(scope = scopeMode.echo) { + tenant = createAuth0Handlers({ key, scope }); + server.use(...tenant.handlers); +} + +class TestStore { + readonly entries = new Map(); + async get(k: string) { + return this.entries.get(k); + } + async set(k: string, v: CachedToken) { + this.entries.set(k, v); + } + async delete(k: string) { + this.entries.delete(k); + } +} + +function makeClient(extra: Partial[0]> = {}): McpClient { + return createMcpClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'mcp-client', + clientSecret: 'secret', + ...extra, + }); +} + +/** Claims shared by default so each test varies exactly one thing. */ +const SESSION = { sid: 'session-1', jti: 'token-1' }; + +function verify(client: McpClient, sub = 'user|1', claims: Record = {}): Promise { + return mintAccessToken(key, { + iss: tenant.issuer, + aud: AUDIENCE, + sub, + scope: 'openid', + claims: { ...SESSION, ...claims }, + }).then((accessToken) => client.verifyAccessToken({ accessToken })); +} + +async function scoped(client: McpClient, sub?: string, claims?: Record) { + return client.withToken(await verify(client, sub, claims)); +} + +describe('single flight on getTokenOnBehalfOf', () => { + it('collapses 10 concurrent identical requests into one exchange', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + const results = await Promise.all( + Array.from({ length: 10 }, () => c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' })) + ); + + expect(tenant.state.exchangeCount).toBe(1); + expect(tenant.state.successfulExchangeCount).toBe(1); + expect(new Set(results.map((r) => r.accessToken)).size).toBe(1); + }); + + it('shares one exchange across request-scoped clients of the same client', async () => { + useTenant(); + const client = makeClient(); + const clients = await Promise.all(Array.from({ length: 10 }, () => scoped(client))); + + await Promise.all(clients.map((c) => c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }))); + + expect(tenant.state.exchangeCount).toBe(1); + }); + + it('shares one exchange when a store is configured and still fills the cache', async () => { + useTenant(); + const store = new TestStore(); + const client = makeClient({ tokenStore: store }); + const c = await scoped(client); + + await Promise.all(Array.from({ length: 10 }, () => c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }))); + await c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(tenant.state.exchangeCount).toBe(1); + expect(store.entries.size).toBe(1); + }); + + it('treats the order and repetition of scopes as the same request', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + await Promise.all([ + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' }), + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'write:a read:a' }), + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: ' write:a read:a read:a ' }), + ]); + + expect(tenant.state.exchangeCount).toBe(1); + }); + + it('keeps requests for different audiences apart', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + const [a, b] = await Promise.all([ + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + c.getTokenOnBehalfOf({ audience: OTHER_DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + expect(a.accessToken).not.toBe(b.accessToken); + }); + + it('keeps requests for different scopes apart', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + const [a, b] = await Promise.all([ + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:b' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + expect(a.grantedScopes).toEqual(['read:a']); + expect(b.grantedScopes).toEqual(['read:b']); + }); + + it('keeps a request with no scope apart from one with scopes', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + await Promise.all([ + c.getTokenOnBehalfOf({ audience: DOWNSTREAM }), + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('keeps different subjects apart even within the same session id', async () => { + useTenant(); + const client = makeClient(); + const alice = await scoped(client, 'user|alice'); + const bob = await scoped(client, 'user|bob'); + + const [a, b] = await Promise.all([ + alice.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + bob.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + expect(a.accessToken).not.toBe(b.accessToken); + }); + + it('keeps different session ids apart even for the same subject and token id', async () => { + useTenant(); + const client = makeClient(); + const first = await scoped(client, 'user|1', { sid: 'session-A' }); + const second = await scoped(client, 'user|1', { sid: 'session-B' }); + + await Promise.all([ + first.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + second.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('shares an exchange across different tokens of one session', async () => { + useTenant(); + const client = makeClient(); + const first = await scoped(client, 'user|1', { jti: 'token-A' }); + const second = await scoped(client, 'user|1', { jti: 'token-B' }); + + await Promise.all([ + first.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + second.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(1); + }); + + it('keeps different organizations apart', async () => { + useTenant(); + const client = makeClient(); + const one = await scoped(client, 'user|1', { org_id: 'org_1' }); + const two = await scoped(client, 'user|1', { org_id: 'org_2' }); + + await Promise.all([ + one.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + two.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('keeps different tokens apart when the token carries no session id or token id', async () => { + useTenant(); + const client = makeClient(); + const bare = { sid: undefined, jti: undefined }; + const first = await scoped(client, 'user|1', { ...bare, extra: 1 }); + const second = await scoped(client, 'user|1', { ...bare, extra: 2 }); + + await Promise.all([ + first.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + second.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('does not exchange a token with a non-string keyed claim together with another caller', async () => { + useTenant(); + const client = makeClient(); + const odd = await scoped(client, 'user|1', { org_id: 42 }); + const plain = await scoped(client, 'user|1'); + + await Promise.all([ + odd.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + plain.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('does not share exchanges between separate clients', async () => { + useTenant(); + const one = await scoped(makeClient()); + const two = await scoped(makeClient()); + + await Promise.all([ + one.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + two.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('rejects every waiter when the exchange fails, and does not remember the failure', async () => { + useTenant(); + tenant.state.failure = { status: 400, body: { error: 'invalid_grant' } }; + const client = makeClient(); + const c = await scoped(client); + + const settled = await Promise.allSettled( + Array.from({ length: 5 }, () => c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' })) + ); + + expect(settled.map((r) => r.status)).toEqual(Array(5).fill('rejected')); + expect(tenant.state.exchangeCount).toBe(1); + + tenant.state.failure = null; + const retry = await c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(retry.accessToken).toEqual(expect.any(String)); + expect(tenant.state.exchangeCount).toBe(2); + expect(tenant.state.successfulExchangeCount).toBe(1); + }); + + it('keeps no in-flight entry after a success', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + await c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + await c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(tenant.state.exchangeCount).toBe(2); + }); + + it('runs the scope check for every waiter after the shared exchange', async () => { + useTenant(scopeMode.fixed('read:a')); + const client = makeClient(); + const c = await scoped(client); + + const settled = await Promise.allSettled( + Array.from({ length: 5 }, () => c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a write:a' })) + ); + + expect(tenant.state.exchangeCount).toBe(1); + expect(settled.every((r) => r.status === 'rejected' && r.reason instanceof DownscopedTokenError)).toBe(true); + }); + + it('gives each waiter its own result object', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + + const [first, second] = await Promise.all([ + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }), + ]); + first.grantedScopes.push('tampered'); + + expect(tenant.state.exchangeCount).toBe(1); + expect(second.grantedScopes).toEqual(['read:a']); + }); +}); From 75dd5e3ceafef62332d69409449a08585c05938e Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:14:31 +0530 Subject: [PATCH 11/31] feat(ai-mcp): add a framework-agnostic HTTP handler with RFC 6750 challenges 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 --- packages/ai-mcp/src/challenge.ts | 104 ++ packages/ai-mcp/src/client.ts | 10 + packages/ai-mcp/src/handler.ts | 536 +++++++ packages/ai-mcp/src/index.ts | 2 + packages/ai-mcp/test/challenge.spec.ts | 209 +++ packages/ai-mcp/test/client.spec.ts | 14 + packages/ai-mcp/test/handler.spec.ts | 1252 +++++++++++++++++ .../ai-mcp/test/smoke/module-formats.spec.ts | 2 + 8 files changed, 2129 insertions(+) create mode 100644 packages/ai-mcp/src/challenge.ts create mode 100644 packages/ai-mcp/src/handler.ts create mode 100644 packages/ai-mcp/test/challenge.spec.ts create mode 100644 packages/ai-mcp/test/handler.spec.ts diff --git a/packages/ai-mcp/src/challenge.ts b/packages/ai-mcp/src/challenge.ts new file mode 100644 index 00000000..678b8b79 --- /dev/null +++ b/packages/ai-mcp/src/challenge.ts @@ -0,0 +1,104 @@ +/** Authentication schemes a challenge can advertise. */ +export type ChallengeScheme = "Bearer" | "DPoP"; + +/** + * Error values a challenge can carry. `invalid_token`, `invalid_request` and + * `insufficient_scope` come from RFC 6750 section 3.1; `invalid_dpop_proof` is + * defined by RFC 9449 section 7.1 for the `DPoP` scheme, and `use_dpop_nonce` + * by RFC 9449 section 9. + */ +export type ChallengeErrorCode = + | "invalid_token" + | "invalid_request" + | "insufficient_scope" + | "invalid_dpop_proof" + | "use_dpop_nonce"; + +/** Input for {@link buildWwwAuthenticate}. */ +export interface ChallengeOptions { + /** Schemes to advertise, one challenge each. Must not be empty. */ + schemes: readonly ChallengeScheme[]; + /** Signing algorithms advertised on the `DPoP` challenge. Defaults to `['ES256']`. */ + dpopAlgs?: readonly string[]; + /** + * Error to report. Leave it out when the request carried no credential at all: + * RFC 6750 section 3.1 forbids an error value in that case. + */ + error?: ChallengeErrorCode; + /** Human readable, generic description. Never pass a raw library message. */ + errorDescription?: string; + /** Scopes to list in the `scope` parameter (`insufficient_scope` responses). */ + scope?: readonly string[]; + /** Protection space, emitted as `realm`. */ + realm?: string; + /** Absolute URL of the RFC 9728 protected resource metadata document. */ + resourceMetadata?: string; + /** + * Scheme the request used. It receives the error parameters; the other schemes + * only advertise. Defaults to the first scheme. + */ + errorScheme?: ChallengeScheme; +} + +/** + * Keeps only the characters RFC 6750 section 3 allows inside a quoted challenge + * value (`%x20-21 / %x23-5B / %x5D-7E`). Quotes, backslashes, control characters + * and anything outside printable ASCII are dropped, so a value can neither end + * the quoted string nor inject another header field. + */ +export function sanitizeChallengeValue(value: string): string { + return value.replace(/[^\x20\x21\x23-\x5B\x5D-\x7E]/g, ""); +} + +function sanitizeScopeList(scopes: readonly string[]): string { + return scopes + .map((scope) => sanitizeChallengeValue(scope).replace(/ /g, "")) + .filter((scope) => scope !== "") + .join(" "); +} + +/** + * Builds `WWW-Authenticate` header values, one per scheme. Every quoted value goes + * through {@link sanitizeChallengeValue}; empty values are omitted. + * + * @throws {TypeError} When `schemes` is empty. + */ +export function buildWwwAuthenticate(options: ChallengeOptions): string[] { + if (options.schemes.length === 0) { + throw new TypeError("At least one challenge scheme is required."); + } + const errorScheme = + options.errorScheme !== undefined && + options.schemes.includes(options.errorScheme) + ? options.errorScheme + : options.schemes[0]; + const realm = + options.realm === undefined ? "" : sanitizeChallengeValue(options.realm); + const description = + options.errorDescription === undefined + ? "" + : sanitizeChallengeValue(options.errorDescription); + const scope = + options.scope === undefined ? "" : sanitizeScopeList(options.scope); + const resourceMetadata = + options.resourceMetadata === undefined + ? "" + : sanitizeChallengeValue(options.resourceMetadata); + const algs = sanitizeChallengeValue( + (options.dpopAlgs ?? ["ES256"]).join(" "), + ); + + return options.schemes.map((scheme) => { + const params: string[] = []; + if (realm !== "") params.push(`realm="${realm}"`); + if (scheme === errorScheme && options.error !== undefined) { + params.push(`error="${options.error}"`); + if (description !== "") params.push(`error_description="${description}"`); + if (scope !== "") params.push(`scope="${scope}"`); + } + if (scheme === "DPoP" && algs !== "") params.push(`algs="${algs}"`); + if (resourceMetadata !== "") + params.push(`resource_metadata="${resourceMetadata}"`); + return params.length === 0 ? scheme : `${scheme} ${params.join(", ")}`; + }); +} diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 4a8eaa70..9e058d81 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -46,6 +46,14 @@ export class ForeignCallerError extends Error { /** Verifies incoming access tokens and creates clients bound to the verified caller. */ export interface McpClient { + /** + * The DPoP mode this client enforces: the `dpop.mode` it was created with, or + * `'allowed'` when none was given. Ingress code reads it to decide which schemes + * to accept and advertise. When you pass your own `apiClient` its mode cannot be + * read, so this reports `'allowed'`; build the client from options if challenges + * must reflect `'required'` or `'disabled'`. + */ + readonly dpopMode: 'allowed' | 'required' | 'disabled'; /** * Verifies an access token (Bearer or DPoP) and resolves the caller. Organization * policy and DPoP rules configured on the client are enforced here. Errors from the @@ -140,6 +148,8 @@ export function createMcpClient(options: McpClientOptions): McpClient { const issued = new WeakMap(); return { + dpopMode: options.apiClient === undefined ? (options.dpop?.mode ?? 'allowed') : 'allowed', + async verifyAccessToken(verifyOptions) { const claims = await apiClient.verifyAccessToken(verifyOptions); const principal = buildPrincipal(claims); diff --git a/packages/ai-mcp/src/handler.ts b/packages/ai-mcp/src/handler.ts new file mode 100644 index 00000000..55f995ee --- /dev/null +++ b/packages/ai-mcp/src/handler.ts @@ -0,0 +1,536 @@ +import { + AuthError, + BearerMethod, + InvalidConfigurationError, + InvalidDpopProofError, + InvalidRequestError, + ProtectedResourceMetadataBuilder, + VerifyAccessTokenError, +} from "@auth0/auth0-api-js"; +import type { VerifyAccessTokenOptions } from "@auth0/auth0-api-js"; +import { buildWwwAuthenticate, sanitizeChallengeValue } from "./challenge.js"; +import type { ChallengeErrorCode, ChallengeScheme } from "./challenge.js"; +import type { McpClient } from "./client.js"; +import { + DownscopedTokenError, + InsufficientScopeError, + UnauthenticatedError, +} from "./errors.js"; +import { runWithRequestContext } from "./request-context.js"; +import type { Principal, RequestScopedMcpClient } from "./types.js"; + +/** + * What the downstream handler learns about the authenticated caller. The shape + * matches the `AuthInfo` type of the MCP TypeScript SDK, so the object can be + * handed to that SDK as is. + */ +export interface AuthInfo { + /** The access token the caller presented. */ + token: string; + /** The OAuth client the token was issued to. Empty when the token names none. */ + clientId: string; + /** Scopes from the `scope` claim. */ + scopes: string[]; + /** Token expiry in epoch seconds. */ + expiresAt: number; + /** The protected resource the token was issued for. */ + resource?: URL; + /** Additional caller data: `sub`, and `orgId` and `permissions` when present. */ + extra?: Record; +} + +/** + * DPoP handling of the {@link McpClient}. The handler reads it from + * {@link McpClient.dpopMode} to decide which schemes it accepts and advertises. + */ +export type HandlerDpopMode = McpClient["dpopMode"]; + +/** Options for {@link createMcpHandler}. */ +export interface McpHandlerOptions { + /** Verifies tokens and creates the request-scoped client. */ + client: McpClient; + /** Identifier of this MCP server. Absolute URL; used as the `resource` of the metadata document. */ + resource: string; + /** Issuer URLs of the authorization servers that can issue tokens for this server. */ + authorizationServers: string[]; + /** + * Serves the authenticated request. It runs inside the request context, so + * `getCallerMcpClient()` and friends work in everything it starts. + */ + handler: (request: Request, authInfo: AuthInfo) => Promise; + /** Scopes every request must carry (all of them). Checked against the `scope` claim only. */ + requiredScopes?: readonly string[]; + /** Protection space reported as `realm` in challenges. */ + realm?: string; + /** Settings for the RFC 9728 metadata route. */ + metadata?: { + /** + * Path of the route. Defaults to the RFC 9728 well-known path for `resource`: + * `/.well-known/oauth-protected-resource` followed by the path of `resource`. + * A custom path must start with `/`, carry no query or fragment and be + * normalized (no dot segments, no characters that need percent-encoding); + * `createMcpHandler` throws `InvalidConfigurationError` otherwise. The + * `resource_metadata` value in challenges is always this route's absolute URL. + * + * The route sends no CORS headers; add them in your framework if browser-based + * clients must read the document. + */ + path?: string; + /** Scopes to publish as `scopes_supported`. */ + scopesSupported?: string[]; + /** Human readable name to publish as `resource_name`. */ + resourceName?: string; + /** Documentation URL to publish as `resource_documentation`. */ + resourceDocumentation?: string; + }; + /** + * Called with the raw error whenever the handler answers 500. The error never + * reaches the response. A throwing hook is ignored. + */ + onError?: (error: unknown) => void; +} + +const WELL_KNOWN_PRM = "/.well-known/oauth-protected-resource"; +const TOKEN_PATTERN = /^[A-Za-z0-9\-._~+/]+=*$/; +const PROOF_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; +const NONCE_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]{1,256}$/; +const MAX_CREDENTIAL_LENGTH = 8192; + +const DESCRIPTIONS: Record = { + invalid_token: "The access token is invalid or has expired.", + invalid_request: "The request is malformed.", + insufficient_scope: "The access token does not include the required scope.", + invalid_dpop_proof: "The DPoP proof is invalid.", + use_dpop_nonce: "A server-provided DPoP nonce is required.", +}; + +const GENERIC_500_BODY = + '{"error":"server_error","error_description":"Internal server error."}'; + +function genericServerError(): Response { + return new Response(GENERIC_500_BODY, { + status: 500, + headers: { + "content-type": "application/json", + "cache-control": "no-store", + }, + }); +} + +type Credentials = + | { kind: "absent" } + | { kind: "malformed"; scheme: ChallengeScheme } + | { kind: "bearer"; token: string } + | { kind: "dpop"; token: string; proof: string }; + +/** An error the handler turns into an HTTP response. */ +interface Failure { + status: number; + error?: ChallengeErrorCode; + /** Body `error` value for responses that carry no challenge error. */ + bodyError?: string; + description: string; + scope?: readonly string[]; + challenge: boolean; + scheme?: ChallengeScheme; + forward?: Array<[string, string]>; +} + +/** + * Reads the credential. A scheme the configured DPoP mode does not accept + * (Bearer when DPoP is required, DPoP when it is disabled) and any unknown + * scheme count as no credential: the client gets a challenge without an error + * value, because RFC 6750 section 3.1 reserves error values for requests that + * did carry the authentication the server supports. + */ +function extractCredentials( + request: Request, + mode: HandlerDpopMode, +): Credentials { + const authorization = request.headers.get("authorization"); + if (authorization === null) { + return { kind: "absent" }; + } + const [rawScheme, token, ...rest] = authorization.split(/ +/); + const scheme = rawScheme?.toLowerCase(); + if (scheme === "bearer" && mode !== "required") { + if ( + rest.length > 0 || + token === undefined || + token.length > MAX_CREDENTIAL_LENGTH || + !TOKEN_PATTERN.test(token) + ) { + return { kind: "malformed", scheme: "Bearer" }; + } + return { kind: "bearer", token }; + } + if (scheme === "dpop" && mode !== "disabled") { + const proof = request.headers.get("dpop"); + if ( + rest.length > 0 || + token === undefined || + token.length > MAX_CREDENTIAL_LENGTH || + !TOKEN_PATTERN.test(token) || + proof === null || + proof.length > MAX_CREDENTIAL_LENGTH || + !PROOF_PATTERN.test(proof) + ) { + return { kind: "malformed", scheme: "DPoP" }; + } + return { kind: "dpop", token, proof }; + } + return { kind: "absent" }; +} + +function schemesFor(mode: HandlerDpopMode): ChallengeScheme[] { + if (mode === "required") return ["DPoP"]; + if (mode === "disabled") return ["Bearer"]; + return ["Bearer", "DPoP"]; +} + +function jsonResponse( + status: number, + body: Record, + headers: Headers = new Headers(), +): Response { + headers.set("content-type", "application/json"); + headers.set("cache-control", "no-store"); + return new Response(JSON.stringify(body), { status, headers }); +} + +/** Header values from a core error that are safe to put on a response (RFC 9449 nonce syntax). */ +function nonceValues(error: AuthError): Array<[string, string]> { + const headers = error.headers; + if (headers === undefined || headers === null) return []; + const entry = Object.entries(headers).find( + ([key]) => key.toLowerCase() === "dpop-nonce", + ); + if (entry === undefined) return []; + const values: unknown[] = Array.isArray(entry[1]) ? entry[1] : [entry[1]]; + return values + .filter( + (value): value is string => + typeof value === "string" && NONCE_PATTERN.test(value), + ) + .map((value): [string, string] => ["DPoP-Nonce", value]); +} + +function statusToError( + status: number | undefined, +): ChallengeErrorCode | undefined { + if (status === 400) return "invalid_request"; + if (status === 401) return "invalid_token"; + if (status === 403) return "insufficient_scope"; + return undefined; +} + +function missingFrom( + required: readonly string[], + held: readonly string[], +): string[] { + const have = new Set(held); + return required.filter((scope) => !have.has(scope)); +} + +function authFailure( + status: number, + code: ChallengeErrorCode, + extra: Partial = {}, +): Failure { + return { + status, + error: code, + description: DESCRIPTIONS[code], + challenge: true, + ...extra, + }; +} + +const SERVER_FAILURE: Failure = { + status: 500, + bodyError: "server_error", + description: "Internal server error.", + challenge: false, +}; + +/** + * Maps an error from credential extraction, verification, `withToken` or the + * scope gate. Core errors keep their status class; everything else is a 500. + */ +function classifyAuthentication(error: unknown): Failure { + if (error instanceof UnauthenticatedError) { + return authFailure(401, "invalid_token"); + } + if (error instanceof AuthError) { + const forward = nonceValues(error); + const causeCode = (error.cause as { code?: unknown } | undefined)?.code; + if (error.code === "use_dpop_nonce" || causeCode === "use_dpop_nonce") { + // Without a usable nonce the client cannot retry, so report the proof as invalid. + return forward.length > 0 + ? authFailure(401, "use_dpop_nonce", { scheme: "DPoP", forward }) + : authFailure(401, "invalid_dpop_proof", { scheme: "DPoP" }); + } + if (error instanceof InvalidDpopProofError) { + return authFailure(401, "invalid_dpop_proof", { + scheme: "DPoP", + forward, + }); + } + if (error instanceof InvalidRequestError) { + return authFailure(400, "invalid_request", { forward }); + } + if (error instanceof VerifyAccessTokenError) { + return authFailure(401, "invalid_token", { forward }); + } + const code = statusToError(error.statusCode); + if (code !== undefined && error.statusCode !== undefined) { + return authFailure(error.statusCode, code, { forward }); + } + } + return SERVER_FAILURE; +} + +/** + * Maps an error thrown by the downstream handler. Only the wrapper's own errors + * become client-facing failures; anything else, including core errors such as a + * failed token exchange, is an internal error and must not turn into a challenge. + */ +function classifyDownstream(error: unknown, held: readonly string[]): Failure { + if (error instanceof InsufficientScopeError) { + return authFailure(403, "insufficient_scope", { + scope: missingFrom(error.requiredScopes, held), + }); + } + if (error instanceof DownscopedTokenError) { + return authFailure(403, "insufficient_scope"); + } + if (error instanceof UnauthenticatedError) { + return authFailure(401, "invalid_token"); + } + return SERVER_FAILURE; +} + +function validateMetadataPath(path: string, origin: string): void { + let normalized: string | undefined; + if (path.startsWith("/")) { + try { + normalized = new URL(path, origin).pathname; + } catch { + normalized = undefined; + } + } + if (normalized !== path) { + throw new InvalidConfigurationError( + 'Invalid configuration: "metadata.path" must be an absolute, normalized path without query or fragment.', + ); + } +} + +/** + * Creates a function that authenticates MCP HTTP requests and hands them to your + * handler. It takes a standard `Request` and returns a `Response`, so it plugs + * into any runtime or framework that speaks the Fetch API. + * + * Per request it serves the RFC 9728 metadata document, or extracts a Bearer or + * DPoP credential, verifies it, enforces `requiredScopes`, binds the caller into + * the request context and calls `handler`. Accepted schemes, challenges and the + * metadata DPoP declaration follow `client.dpopMode`. Authentication failures + * become RFC 6750 responses with a generic body and a rebuilt `WWW-Authenticate` + * challenge; verifier messages are never echoed. Errors thrown by `handler` map + * to a client-facing failure only when they are the wrapper's + * `UnauthenticatedError`, `InsufficientScopeError` or `DownscopedTokenError`; + * every other error becomes a generic 500 without a challenge. + * + * DPoP `htu` is checked against `request.url`. Behind a proxy that rewrites the + * URL, pass a `Request` whose URL is the public one the client used; the handler + * has no option to override it. The metadata route sends no CORS headers. + * + * @throws {TypeError} When `resource` is not an absolute URL. + * @throws {InvalidConfigurationError} When `metadata.path` is not a normalized absolute path, + * or `realm` is empty or contains characters a challenge cannot carry. + */ +export function createMcpHandler( + options: McpHandlerOptions, +): (request: Request) => Promise { + const resourceUrl = new URL(options.resource); + if ( + options.realm !== undefined && + (typeof options.realm !== "string" || + options.realm === "" || + sanitizeChallengeValue(options.realm) !== options.realm) + ) { + throw new InvalidConfigurationError( + 'Invalid configuration: "realm" must be a non-empty string without quotes, backslashes or control characters.', + ); + } + const dpop = options.client.dpopMode; + const schemes = schemesFor(dpop); + const resourcePath = + resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, ""); + if (options.metadata?.path !== undefined) { + validateMetadataPath(options.metadata.path, resourceUrl.origin); + } + const metadataPath = + options.metadata?.path ?? `${WELL_KNOWN_PRM}${resourcePath}`; + const metadataUrl = new URL(metadataPath, resourceUrl.origin).href; + const requiredScopes = [...new Set(options.requiredScopes ?? [])]; + + const builder = new ProtectedResourceMetadataBuilder( + options.resource, + options.authorizationServers, + ); + builder.withBearerMethodsSupported([BearerMethod.HEADER]); + if (dpop !== "disabled") builder.withDpopSigningAlgValuesSupported(["ES256"]); + if (dpop === "required") builder.withDpopBoundAccessTokensRequired(true); + const meta = options.metadata; + if (meta?.scopesSupported !== undefined) + builder.withScopesSupported(meta.scopesSupported); + if (meta?.resourceName !== undefined) + builder.withResourceName(meta.resourceName); + if (meta?.resourceDocumentation !== undefined) + builder.withResourceDocumentation(meta.resourceDocumentation); + const metadataBody = JSON.stringify(builder.build().toJSON()); + + function respond(failure: Failure, presented?: ChallengeScheme): Response { + const headers = new Headers(); + if (failure.challenge) { + const values = buildWwwAuthenticate({ + schemes, + error: failure.error, + errorDescription: + failure.error === undefined ? undefined : failure.description, + scope: failure.scope, + realm: options.realm, + resourceMetadata: metadataUrl, + errorScheme: failure.scheme ?? presented, + }); + for (const value of values) headers.append("www-authenticate", value); + } + for (const [name, value] of failure.forward ?? []) + headers.append(name, value); + return jsonResponse( + failure.status, + { + error: failure.error ?? failure.bodyError, + error_description: failure.description, + }, + headers, + ); + } + + function report(error: unknown): void { + try { + options.onError?.(error); + } catch { + // A failing observer must not change the response. + } + } + + /** Builds the failure response. Nothing in here may escape to the framework. */ + function fail( + classify: () => Failure, + error: unknown, + presented?: ChallengeScheme, + ): Response { + try { + const failure = classify(); + if (failure.status === 500) report(error); + return respond(failure, presented); + } catch { + report(error); + return genericServerError(); + } + } + + return async function handle(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === metadataPath) { + if (request.method !== "GET") { + return new Response(null, { status: 405, headers: { allow: "GET" } }); + } + return new Response(metadataBody, { + status: 200, + headers: { + "content-type": "application/json", + "cache-control": "public, max-age=3600", + }, + }); + } + + const credentials = extractCredentials(request, dpop); + if (credentials.kind === "absent") { + return fail( + () => ({ + status: 401, + bodyError: "unauthorized", + description: "Authentication is required.", + challenge: true, + }), + undefined, + ); + } + if (credentials.kind === "malformed") { + return fail( + () => authFailure(400, "invalid_request"), + undefined, + credentials.scheme, + ); + } + + const presented: ChallengeScheme = + credentials.kind === "dpop" ? "DPoP" : "Bearer"; + let principal: Principal; + let mcpClient: RequestScopedMcpClient; + try { + const verifyOptions: VerifyAccessTokenOptions = + credentials.kind === "dpop" + ? { + accessToken: credentials.token, + scheme: "dpop", + dpopProof: credentials.proof, + httpMethod: request.method, + httpUrl: request.url, + } + : { accessToken: credentials.token, scheme: "bearer" }; + const caller = await options.client.verifyAccessToken(verifyOptions); + principal = caller.principal; + + const missing = missingFrom(requiredScopes, principal.scopes); + if (missing.length > 0) { + return respond( + authFailure(403, "insufficient_scope", { scope: missing }), + presented, + ); + } + mcpClient = options.client.withToken(caller); + } catch (error) { + return fail(() => classifyAuthentication(error), error, presented); + } + + const authInfo: AuthInfo = { + token: credentials.token, + clientId: principal.clientId ?? "", + scopes: [...principal.scopes], + expiresAt: principal.expiresAt, + resource: resourceUrl, + extra: { + sub: principal.sub, + ...(principal.orgId !== null && { orgId: principal.orgId }), + ...(principal.permissions !== null && { + permissions: [...principal.permissions], + }), + }, + }; + try { + return await runWithRequestContext({ principal, mcpClient }, () => + options.handler(request, authInfo), + ); + } catch (error) { + return fail( + () => classifyDownstream(error, principal.scopes), + error, + presented, + ); + } + }; +} diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index 06586482..b240ad10 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -16,6 +16,8 @@ export type { export { buildPrincipal } from './principal.js'; export { createMcpClient, ForeignCallerError } from './client.js'; export type { McpClient, VerifiedCaller } from './client.js'; +export { createMcpHandler } from './handler.js'; +export type { McpHandlerOptions, AuthInfo, HandlerDpopMode } from './handler.js'; export { runWithRequestContext, getRequestContext, diff --git a/packages/ai-mcp/test/challenge.spec.ts b/packages/ai-mcp/test/challenge.spec.ts new file mode 100644 index 00000000..4b8dbea5 --- /dev/null +++ b/packages/ai-mcp/test/challenge.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { + buildWwwAuthenticate, + sanitizeChallengeValue, +} from "../src/challenge.js"; +import type { ChallengeOptions } from "../src/challenge.js"; + +const PRM = "https://mcp.example.com/.well-known/oauth-protected-resource"; + +interface Case { + name: string; + options: ChallengeOptions; + expected: string[]; +} + +const cases: Case[] = [ + { + name: "missing credential: bare scheme, no error parameter", + options: { schemes: ["Bearer"] }, + expected: ["Bearer"], + }, + { + name: "missing credential with realm and metadata carries no error parameters", + options: { schemes: ["Bearer"], realm: "mcp", resourceMetadata: PRM }, + expected: [`Bearer realm="mcp", resource_metadata="${PRM}"`], + }, + { + name: "invalid token", + options: { + schemes: ["Bearer"], + error: "invalid_token", + errorDescription: "The access token is invalid or has expired.", + resourceMetadata: PRM, + }, + expected: [ + `Bearer error="invalid_token", error_description="The access token is invalid or has expired.", resource_metadata="${PRM}"`, + ], + }, + { + name: "malformed request", + options: { + schemes: ["Bearer"], + error: "invalid_request", + errorDescription: "The request is malformed.", + }, + expected: [ + 'Bearer error="invalid_request", error_description="The request is malformed."', + ], + }, + { + name: "insufficient scope lists the scopes", + options: { + schemes: ["Bearer"], + error: "insufficient_scope", + scope: ["tools:read", "tools:write"], + }, + expected: [ + 'Bearer error="insufficient_scope", scope="tools:read tools:write"', + ], + }, + { + name: "empty scope list is omitted", + options: { schemes: ["Bearer"], error: "insufficient_scope", scope: [] }, + expected: ['Bearer error="insufficient_scope"'], + }, + { + name: "realm on", + options: { schemes: ["Bearer"], realm: "my realm", error: "invalid_token" }, + expected: ['Bearer realm="my realm", error="invalid_token"'], + }, + { + name: "realm that sanitizes to nothing is omitted", + options: { schemes: ["Bearer"], realm: '"\\' }, + expected: ["Bearer"], + }, + { + name: "DPoP alone advertises the default algorithm", + options: { schemes: ["DPoP"] }, + expected: ['DPoP algs="ES256"'], + }, + { + name: "DPoP with custom algorithms and an error", + options: { + schemes: ["DPoP"], + dpopAlgs: ["ES256", "ES384"], + error: "invalid_dpop_proof", + }, + expected: ['DPoP error="invalid_dpop_proof", algs="ES256 ES384"'], + }, + { + name: "both schemes without a credential: neither carries an error", + options: { schemes: ["Bearer", "DPoP"], realm: "mcp" }, + expected: ['Bearer realm="mcp"', 'DPoP realm="mcp", algs="ES256"'], + }, + { + name: "both schemes: the error goes to the first scheme by default", + options: { schemes: ["Bearer", "DPoP"], error: "invalid_token" }, + expected: ['Bearer error="invalid_token"', 'DPoP algs="ES256"'], + }, + { + name: "both schemes: the error goes to the scheme the request used", + options: { + schemes: ["Bearer", "DPoP"], + error: "invalid_token", + errorScheme: "DPoP", + resourceMetadata: PRM, + }, + expected: [ + `Bearer resource_metadata="${PRM}"`, + `DPoP error="invalid_token", algs="ES256", resource_metadata="${PRM}"`, + ], + }, + { + name: "an error scheme that is not advertised falls back to the first scheme", + options: { + schemes: ["DPoP"], + error: "invalid_request", + errorScheme: "Bearer", + }, + expected: ['DPoP error="invalid_request", algs="ES256"'], + }, + { + name: "description that sanitizes to nothing is omitted", + options: { + schemes: ["Bearer"], + error: "invalid_token", + errorDescription: '"\\\n', + }, + expected: ['Bearer error="invalid_token"'], + }, + { + name: "quote and backslash are stripped from description", + options: { + schemes: ["Bearer"], + error: "invalid_token", + errorDescription: 'say "hi" \\ there', + }, + expected: [ + 'Bearer error="invalid_token", error_description="say hi there"', + ], + }, + { + name: "control characters and non-ASCII are dropped from description", + options: { + schemes: ["Bearer"], + error: "invalid_token", + errorDescription: "a\r\nb\tc\u0000d\u007feéf
g", + }, + expected: ['Bearer error="invalid_token", error_description="abcdefg"'], + }, + { + name: "header injection through realm is neutralized", + options: { schemes: ["Bearer"], realm: 'x"\r\nSet-Cookie: a=b' }, + expected: ['Bearer realm="xSet-Cookie: a=b"'], + }, + { + name: "scope tokens are sanitized and cannot contain spaces", + options: { + schemes: ["Bearer"], + error: "insufficient_scope", + scope: ['a"b', "c d", "\\", "e"], + }, + expected: ['Bearer error="insufficient_scope", scope="ab cd e"'], + }, + { + name: "resource metadata is sanitized", + options: { + schemes: ["Bearer"], + resourceMetadata: 'https://x.example/"a\\b', + }, + expected: ['Bearer resource_metadata="https://x.example/ab"'], + }, +]; + +describe("buildWwwAuthenticate", () => { + it.each(cases)("$name", ({ options, expected }) => { + expect(buildWwwAuthenticate(options)).toEqual(expected); + }); + + it("never writes an error parameter unless an error is given", () => { + const values = buildWwwAuthenticate({ + schemes: ["Bearer", "DPoP"], + errorDescription: "ignored without an error", + scope: ["a"], + realm: "r", + resourceMetadata: PRM, + }); + for (const value of values) { + expect(value).not.toContain("error"); + expect(value).not.toContain("scope="); + } + }); + + it("rejects an empty scheme list", () => { + expect(() => buildWwwAuthenticate({ schemes: [] })).toThrow(TypeError); + }); +}); + +describe("sanitizeChallengeValue", () => { + it("keeps every allowed character", () => { + const allowed = ` !#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_\`abcdefghijklmnopqrstuvwxyz{|}~`; + expect(sanitizeChallengeValue(allowed)).toBe(allowed); + }); + + it("removes quote, backslash, DEL and C0 controls", () => { + const forbidden = `"\\\u007f${Array.from({ length: 32 }, (_, i) => String.fromCharCode(i)).join("")}`; + expect(sanitizeChallengeValue(`a${forbidden}b`)).toBe("ab"); + }); +}); diff --git a/packages/ai-mcp/test/client.spec.ts b/packages/ai-mcp/test/client.spec.ts index 0c63e573..f7ad8729 100644 --- a/packages/ai-mcp/test/client.spec.ts +++ b/packages/ai-mcp/test/client.spec.ts @@ -88,6 +88,20 @@ async function dpopMaterial(accessTokenFor: (jkt: string) => Promise, ur } describe('createMcpClient construction', () => { + it.each([ + [undefined, 'allowed'], + ['allowed', 'allowed'], + ['required', 'required'], + ['disabled', 'disabled'], + ] as const)('reports dpopMode for dpop.mode %s', (mode, expected) => { + expect(makeClient(mode === undefined ? {} : { dpop: { mode } }).dpopMode).toBe(expected); + }); + + it('reports allowed for a supplied apiClient, whose mode cannot be read', () => { + const apiClient = new ApiClient({ domain: 'tenant.example.com', audience: AUDIENCE, dpop: { mode: 'required' } }); + expect(createMcpClient({ domain: 'tenant.example.com', audience: AUDIENCE, apiClient }).dpopMode).toBe('allowed'); + }); + it('throws InvalidConfigurationError at construction for non_strict without a store', () => { expect(() => makeClient({ scopeMatching: 'non_strict' })).toThrow(InvalidConfigurationError); }); diff --git a/packages/ai-mcp/test/handler.spec.ts b/packages/ai-mcp/test/handler.spec.ts new file mode 100644 index 00000000..5ae53e6a --- /dev/null +++ b/packages/ai-mcp/test/handler.spec.ts @@ -0,0 +1,1252 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { createHash, randomUUID } from "node:crypto"; +import { + SignJWT, + calculateJwkThumbprint, + exportJWK, + generateKeyPair, +} from "jose"; +import { setupServer } from "msw/node"; +import { + AuthError, + InvalidConfigurationError, + InvalidDpopProofError, + InvalidRequestError, + VerifyAccessTokenError, +} from "@auth0/auth0-api-js"; +import { createMcpClient, ForeignCallerError } from "../src/client.js"; +import type { McpClient } from "../src/client.js"; +import { + DownscopedTokenError, + InsufficientScopeError, + UnauthenticatedError, +} from "../src/errors.js"; +import { createMcpHandler } from "../src/handler.js"; +import type { AuthInfo, McpHandlerOptions } from "../src/handler.js"; +import { + getCallerMcpClient, + getCallerPrincipal, + getRequestContext, +} from "../src/request-context.js"; +import type { Principal, RequestScopedMcpClient } from "../src/types.js"; +import { + createAuth0Handlers, + createTestKey, + mintAccessToken, + scopeMode, +} from "./support/index.js"; +import type { TestKey } from "./support/index.js"; + +const AUDIENCE = "https://mcp.example.com"; +const ENDPOINT = "https://mcp.example.com/mcp"; +const PRM = "https://mcp.example.com/.well-known/oauth-protected-resource"; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + tenant = createAuth0Handlers({ key, scope: scopeMode.echo }); + server.listen({ onUnhandledRequest: "error" }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function useTenant() { + server.use(...tenant.handlers); +} + +type ClientOverrides = Partial[0]>; + +function makeClient(overrides: ClientOverrides = {}): McpClient { + return createMcpClient({ + domain: "tenant.example.com", + audience: AUDIENCE, + clientId: "mcp-client", + clientSecret: "secret", + ...overrides, + }); +} + +function token( + claims: Record = {}, + extra: Parameters[1] = {}, +) { + return mintAccessToken(key, { + iss: tenant.issuer, + aud: AUDIENCE, + sub: "user|1", + scope: "read write", + claims: { client_id: "client-a", ...claims }, + ...extra, + }); +} + +interface Harness { + handle: (request: Request) => Promise; + calls: Array<{ request: Request; authInfo: AuthInfo }>; +} + +function makeHandler( + options: Partial = {}, + downstream?: (request: Request, authInfo: AuthInfo) => Promise, + client: McpClient = makeClient(), +): Harness { + const calls: Harness["calls"] = []; + const handle = createMcpHandler({ + client, + resource: AUDIENCE, + authorizationServers: [tenant.issuer], + handler: async (request, authInfo) => { + calls.push({ request, authInfo }); + return downstream + ? downstream(request, authInfo) + : new Response("ok", { status: 200 }); + }, + ...options, + }); + return { handle, calls }; +} + +function request( + headers: Record = {}, + init: RequestInit = {}, + url = ENDPOINT, +) { + return new Request(url, { method: "POST", ...init, headers }); +} + +async function bearer( + claims: Record = {}, + extra: Parameters[1] = {}, +) { + return { authorization: `Bearer ${await token(claims, extra)}` }; +} + +async function bodyOf(response: Response) { + return (await response.json()) as Record; +} + +function wwwAuthenticate(response: Response): string | null { + return response.headers.get("www-authenticate"); +} + +const ALLOWED_PRM = `resource_metadata="${PRM}"`; +const BEARER_ADVERT = `Bearer ${ALLOWED_PRM}`; +const DPOP_ADVERT = `DPoP algs="ES256", ${ALLOWED_PRM}`; +const GENERIC = { + invalid_token: "The access token is invalid or has expired.", + invalid_request: "The request is malformed.", + insufficient_scope: "The access token does not include the required scope.", + invalid_dpop_proof: "The DPoP proof is invalid.", +}; + +function bearerError(code: keyof typeof GENERIC) { + return `Bearer error="${code}", error_description="${GENERIC[code]}", ${ALLOWED_PRM}`; +} +function dpopError(code: keyof typeof GENERIC) { + return `DPoP error="${code}", error_description="${GENERIC[code]}", algs="ES256", ${ALLOWED_PRM}`; +} + +async function dpopMaterial( + accessTokenFor: (jkt: string) => Promise, + url = ENDPOINT, + method = "POST", +) { + const { privateKey, publicKey } = await generateKeyPair("ES256", { + extractable: true, + }); + const jwk = await exportJWK(publicKey); + const jkt = await calculateJwkThumbprint(jwk); + const accessToken = await accessTokenFor(jkt); + const proof = await new SignJWT({ + htm: method, + htu: url, + ath: createHash("sha256").update(accessToken).digest("base64url"), + jti: randomUUID(), + }) + .setProtectedHeader({ alg: "ES256", typ: "dpop+jwt", jwk }) + .setIssuedAt() + .sign(privateKey); + return { accessToken, proof }; +} + +const dpopToken = (jkt: string, claims: Record = {}) => + token({ cnf: { jkt }, ...claims }); + +describe("failure responses", () => { + interface FailureCase { + name: string; + headers: () => Promise>; + status: number; + challenge: string; + body: { error: string; error_description: string }; + client?: ClientOverrides; + } + + const invalidToken = { + error: "invalid_token", + error_description: GENERIC.invalid_token, + }; + const unauthorized = { + error: "unauthorized", + error_description: "Authentication is required.", + }; + const malformed = { + error: "invalid_request", + error_description: GENERIC.invalid_request, + }; + + const cases: FailureCase[] = [ + { + name: "missing credential: no error parameter on any challenge", + headers: async () => ({}), + status: 401, + challenge: `${BEARER_ADVERT}, ${DPOP_ADVERT}`, + body: { + error: "unauthorized", + error_description: "Authentication is required.", + }, + }, + { + name: "unknown scheme counts as no credential", + headers: async () => ({ authorization: "Basic dXNlcjpwYXNz" }), + status: 401, + challenge: `${BEARER_ADVERT}, ${DPOP_ADVERT}`, + body: unauthorized, + }, + { + name: "multiple tokens", + headers: async () => ({ authorization: "Bearer abc def" }), + status: 400, + challenge: `${bearerError("invalid_request")}, ${DPOP_ADVERT}`, + body: malformed, + }, + { + name: "scheme with no token", + headers: async () => ({ authorization: "Bearer" }), + status: 400, + challenge: `${bearerError("invalid_request")}, ${DPOP_ADVERT}`, + body: malformed, + }, + { + name: "empty Authorization header counts as no credential", + headers: async () => ({ authorization: "" }), + status: 401, + challenge: `${BEARER_ADVERT}, ${DPOP_ADVERT}`, + body: unauthorized, + }, + { + name: "Bearer token over the length cap", + headers: async () => ({ authorization: `Bearer ${"a".repeat(8193)}` }), + status: 400, + challenge: `${bearerError("invalid_request")}, ${DPOP_ADVERT}`, + body: malformed, + }, + { + name: "DPoP token over the length cap", + headers: async () => ({ + authorization: `DPoP ${"a".repeat(8193)}`, + dpop: "a.b.c", + }), + status: 400, + challenge: `${BEARER_ADVERT}, ${dpopError("invalid_request")}`, + body: malformed, + }, + { + name: "DPoP proof over the length cap", + headers: async () => ({ + authorization: "DPoP abc.def.ghi", + dpop: `a.b.${"c".repeat(8192)}`, + }), + status: 400, + challenge: `${BEARER_ADVERT}, ${dpopError("invalid_request")}`, + body: malformed, + }, + { + name: "token with characters outside the token68 set", + headers: async () => ({ authorization: 'Bearer ab"c' }), + status: 400, + challenge: `${bearerError("invalid_request")}, ${DPOP_ADVERT}`, + body: malformed, + }, + { + name: "DPoP scheme without a proof header: error lands on the DPoP challenge", + headers: async () => ({ authorization: "DPoP abc.def.ghi" }), + status: 400, + challenge: `${BEARER_ADVERT}, ${dpopError("invalid_request")}`, + body: malformed, + }, + { + name: "DPoP scheme with a proof that is not a JWS", + headers: async () => ({ + authorization: "DPoP abc.def.ghi", + dpop: "not-a-jwt", + }), + status: 400, + challenge: `${BEARER_ADVERT}, ${dpopError("invalid_request")}`, + body: malformed, + }, + { + name: "DPoP scheme with several tokens", + headers: async () => ({ + authorization: "DPoP abc.def.ghi extra", + dpop: "a.b.c", + }), + status: 400, + challenge: `${BEARER_ADVERT}, ${dpopError("invalid_request")}`, + body: malformed, + }, + { + name: "token that is not a JWT", + headers: async () => ({ authorization: "Bearer not-a-jwt" }), + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + }, + { + name: "wrong audience", + headers: async () => bearer({}, { aud: "https://other.example.com" }), + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + }, + { + name: "expired token", + headers: async () => + bearer({}, { exp: Math.floor(Date.now() / 1000) - 3600 }), + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + }, + { + name: "signature from an unknown key", + headers: async () => { + const other = await createTestKey({ kid: "other-key" }); + return { + authorization: `Bearer ${await mintAccessToken(other, { iss: tenant.issuer, aud: AUDIENCE, sub: "u" })}`, + }; + }, + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + }, + { + name: "DPoP-bound token presented as Bearer", + headers: async () => { + const { accessToken } = await dpopMaterial((jkt) => dpopToken(jkt)); + return { authorization: `Bearer ${accessToken}` }; + }, + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + }, + { + name: "DPoP proof bound to another URL", + headers: async () => { + const { accessToken, proof } = await dpopMaterial( + (jkt) => dpopToken(jkt), + "https://mcp.example.com/other", + ); + return { authorization: `DPoP ${accessToken}`, dpop: proof }; + }, + status: 401, + challenge: `${BEARER_ADVERT}, ${dpopError("invalid_dpop_proof")}`, + body: { + error: "invalid_dpop_proof", + error_description: GENERIC.invalid_dpop_proof, + }, + }, + { + name: "organization not allowed collapses to invalid_token", + headers: async () => bearer({ org_id: "org_other" }), + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + client: { organizationPolicy: "required", organizationId: "org_allowed" }, + }, + { + name: "organization missing collapses to invalid_token", + headers: async () => bearer(), + status: 401, + challenge: `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + body: invalidToken, + client: { organizationPolicy: "required" }, + }, + ]; + + it.each(cases)( + "$name", + async ({ headers, status, challenge, body, client }) => { + useTenant(); + const { handle, calls } = makeHandler({}, undefined, makeClient(client)); + const response = await handle(request(await headers())); + expect(response.status).toBe(status); + expect(wwwAuthenticate(response)).toBe(challenge); + expect(await bodyOf(response)).toEqual(body); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(calls).toHaveLength(0); + }, + ); + + it("answers a missing credential with a header that has no error parameter at all", async () => { + const { handle } = makeHandler(); + const challenge = wwwAuthenticate(await handle(request())) ?? ""; + expect(challenge).not.toMatch(/error/); + }); + + it("matches the scheme name case-insensitively", async () => { + useTenant(); + const { handle } = makeHandler(); + const t = await token(); + expect( + (await handle(request({ authorization: `bEaReR ${t}` }))).status, + ).toBe(200); + }); + + it("accepts more than one space between scheme and token", async () => { + useTenant(); + const { handle } = makeHandler(); + const t = await token(); + expect( + (await handle(request({ authorization: `Bearer ${t}` }))).status, + ).toBe(200); + }); + + it("never repeats verifier messages in the response", async () => { + useTenant(); + const { handle } = makeHandler(); + const response = await handle( + request(await bearer({}, { aud: "https://other.example.com" })), + ); + const everything = `${wwwAuthenticate(response)}${JSON.stringify(await bodyOf(response))}`; + expect(everything).not.toMatch(/aud|claim|JWT|unexpected/i); + }); +}); + +describe("realm", () => { + it("is included in every challenge when configured", async () => { + const { handle } = makeHandler({ realm: "my mcp" }); + const response = await handle(request()); + expect(wwwAuthenticate(response)).toBe( + `Bearer realm="my mcp", ${ALLOWED_PRM}, DPoP realm="my mcp", algs="ES256", ${ALLOWED_PRM}`, + ); + }); + + it("is absent when not configured", async () => { + const { handle } = makeHandler(); + expect(wwwAuthenticate(await handle(request()))).not.toContain("realm"); + }); +}); + +describe("DPoP modes", () => { + it("required: advertises only DPoP and rejects a Bearer credential", async () => { + useTenant(); + const client = makeClient({ dpop: { mode: "required" } }); + const { handle, calls } = makeHandler({}, undefined, client); + const missing = await handle(request()); + expect(missing.status).toBe(401); + expect(wwwAuthenticate(missing)).toBe(DPOP_ADVERT); + const rejected = await handle(request(await bearer())); + expect(rejected.status).toBe(401); + expect(wwwAuthenticate(rejected)).toBe(DPOP_ADVERT); + expect(wwwAuthenticate(rejected)).not.toContain("error"); + expect(await bodyOf(rejected)).toEqual({ + error: "unauthorized", + error_description: "Authentication is required.", + }); + expect(calls).toHaveLength(0); + }); + + it("required: accepts a valid DPoP request", async () => { + useTenant(); + const client = makeClient({ dpop: { mode: "required" } }); + const { handle, calls } = makeHandler({}, undefined, client); + const { accessToken, proof } = await dpopMaterial((jkt) => dpopToken(jkt)); + const response = await handle( + request({ authorization: `DPoP ${accessToken}`, dpop: proof }), + ); + expect(response.status).toBe(200); + expect(calls[0]?.authInfo.token).toBe(accessToken); + }); + + it("allowed: accepts a DPoP request and a Bearer request", async () => { + useTenant(); + const { handle, calls } = makeHandler(); + const { accessToken, proof } = await dpopMaterial((jkt) => dpopToken(jkt)); + expect( + ( + await handle( + request({ authorization: `DPoP ${accessToken}`, dpop: proof }), + ) + ).status, + ).toBe(200); + expect((await handle(request(await bearer()))).status).toBe(200); + expect(calls).toHaveLength(2); + }); + + it("disabled: advertises only Bearer", async () => { + useTenant(); + const client = makeClient({ dpop: { mode: "disabled" } }); + const { handle, calls } = makeHandler({}, undefined, client); + expect(wwwAuthenticate(await handle(request()))).toBe(BEARER_ADVERT); + const { accessToken, proof } = await dpopMaterial((jkt) => dpopToken(jkt)); + const rejected = await handle( + request({ authorization: `DPoP ${accessToken}`, dpop: proof }), + ); + expect(rejected.status).toBe(401); + expect(wwwAuthenticate(rejected)).toBe(BEARER_ADVERT); + expect(calls).toHaveLength(0); + }); + + it("derives the mode from the client, not from a handler option", async () => { + const strict = makeHandler( + {}, + undefined, + makeClient({ dpop: { mode: "required" } }), + ); + expect(wwwAuthenticate(await strict.handle(request()))).toBe(DPOP_ADVERT); + const off = makeHandler( + {}, + undefined, + makeClient({ dpop: { mode: "disabled" } }), + ); + expect(wwwAuthenticate(await off.handle(request()))).toBe(BEARER_ADVERT); + }); + + it("declares the mode in the metadata document", async () => { + const required = makeHandler( + {}, + undefined, + makeClient({ dpop: { mode: "required" } }), + ); + expect( + await bodyOf(await required.handle(request({}, { method: "GET" }, PRM))), + ).toMatchObject({ + dpop_bound_access_tokens_required: true, + }); + }); +}); + +describe("server-wide required scopes", () => { + it("lists only the missing scopes in a 403 and skips the downstream handler", async () => { + useTenant(); + const { handle, calls } = makeHandler({ + requiredScopes: ["read", "admin", "audit"], + }); + const response = await handle(request(await bearer())); + expect(response.status).toBe(403); + expect(wwwAuthenticate(response)).toBe( + `Bearer error="insufficient_scope", error_description="${GENERIC.insufficient_scope}", scope="admin audit", ${ALLOWED_PRM}, ${DPOP_ADVERT}`, + ); + expect(await bodyOf(response)).toEqual({ + error: "insufficient_scope", + error_description: GENERIC.insufficient_scope, + }); + expect(calls).toHaveLength(0); + }); + + it("requires all scopes (AND semantics)", async () => { + useTenant(); + const { handle } = makeHandler({ requiredScopes: ["read", "write"] }); + expect( + (await handle(request(await bearer({}, { scope: "read" })))).status, + ).toBe(403); + expect( + (await handle(request(await bearer({}, { scope: "write read extra" })))) + .status, + ).toBe(200); + }); + + it("lists every required scope when the token has no scope claim", async () => { + useTenant(); + const { handle } = makeHandler({ requiredScopes: ["a", "b"] }); + const response = await handle( + request(await bearer({}, { scope: undefined })), + ); + expect(wwwAuthenticate(response)).toContain('scope="a b"'); + }); + + it("reads the scope claim only, not permissions", async () => { + useTenant(); + const { handle, calls } = makeHandler({ requiredScopes: ["a"] }); + const response = await handle( + request(await bearer({ permissions: ["a"] }, { scope: "other" })), + ); + expect(response.status).toBe(403); + expect(calls).toHaveLength(0); + }); + + it("lists a duplicated requirement once", async () => { + useTenant(); + const { handle } = makeHandler({ requiredScopes: ["a", "a"] }); + const response = await handle(request(await bearer({}, { scope: "x" }))); + expect(wwwAuthenticate(response)).toContain('scope="a"'); + }); + + it("puts the error on the DPoP challenge for a DPoP request", async () => { + useTenant(); + const { handle } = makeHandler({ requiredScopes: ["admin"] }); + const { accessToken, proof } = await dpopMaterial((jkt) => dpopToken(jkt)); + const response = await handle( + request({ authorization: `DPoP ${accessToken}`, dpop: proof }), + ); + expect(response.status).toBe(403); + expect(wwwAuthenticate(response)).toBe( + `${BEARER_ADVERT}, DPoP error="insufficient_scope", error_description="${GENERIC.insufficient_scope}", scope="admin", algs="ES256", ${ALLOWED_PRM}`, + ); + }); + + it("does not run the gate before the credential is verified", async () => { + useTenant(); + const { handle } = makeHandler({ requiredScopes: ["admin"] }); + expect( + (await handle(request({ authorization: "Bearer not-a-jwt" }))).status, + ).toBe(401); + }); +}); + +describe("AuthInfo", () => { + it("describes the caller", async () => { + useTenant(); + const { handle, calls } = makeHandler(); + const exp = Math.floor(Date.now() / 1000) + 1200; + const headers = await bearer({}, { exp }); + const response = await handle(request(headers)); + expect(response.status).toBe(200); + const info = calls[0]?.authInfo; + expect(info).toEqual({ + token: headers.authorization.slice("Bearer ".length), + clientId: "client-a", + scopes: ["read", "write"], + expiresAt: exp, + resource: new URL(AUDIENCE), + extra: { sub: "user|1" }, + }); + }); + + it("adds organization and permissions when the token carries them", async () => { + useTenant(); + const { handle, calls } = makeHandler(); + await handle( + request(await bearer({ org_id: "org_1", permissions: ["p:a"] })), + ); + expect(calls[0]?.authInfo.extra).toEqual({ + sub: "user|1", + orgId: "org_1", + permissions: ["p:a"], + }); + }); + + it("uses an empty client id when the token names none", async () => { + useTenant(); + const { handle, calls } = makeHandler(); + await handle(request(await bearer({ client_id: undefined }))); + expect(calls[0]?.authInfo.clientId).toBe(""); + }); + + it("passes the original request to the downstream handler", async () => { + useTenant(); + const { handle, calls } = makeHandler(); + const incoming = request(await bearer(), { body: "payload" }); + await handle(incoming); + expect(calls[0]?.request).toBe(incoming); + }); + + it("returns the downstream response unchanged", async () => { + useTenant(); + const { handle } = makeHandler( + {}, + async () => + new Response("created", { status: 201, headers: { "x-a": "b" } }), + ); + const response = await handle(request(await bearer())); + expect(response.status).toBe(201); + expect(response.headers.get("x-a")).toBe("b"); + expect(await response.text()).toBe("created"); + }); +}); + +describe("request context", () => { + it("is visible inside the downstream handler, including after await, and gone afterwards", async () => { + useTenant(); + let seen: { + principal?: Principal; + client?: RequestScopedMcpClient; + later?: string; + } = {}; + const { handle } = makeHandler({}, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + seen = { + principal: getCallerPrincipal(), + client: getCallerMcpClient(), + later: getRequestContext()?.principal.sub, + }; + return new Response("ok"); + }); + expect(getRequestContext()).toBeUndefined(); + await handle(request(await bearer({}, { sub: "user|ctx" }))); + expect(seen.principal?.sub).toBe("user|ctx"); + expect(seen.client?.getPrincipal().sub).toBe("user|ctx"); + expect(seen.later).toBe("user|ctx"); + expect(getRequestContext()).toBeUndefined(); + expect(getCallerPrincipal()).toBeUndefined(); + }); + + it("keeps concurrent requests apart", async () => { + useTenant(); + const seen: string[] = []; + const { handle } = makeHandler({}, async (_req, info) => { + await new Promise((resolve) => + setTimeout(resolve, info.extra?.["sub"] === "user|slow" ? 20 : 1), + ); + seen.push(`${info.extra?.["sub"]}=${getCallerPrincipal()?.sub}`); + return new Response("ok"); + }); + await Promise.all([ + handle(request(await bearer({}, { sub: "user|slow" }))), + handle(request(await bearer({}, { sub: "user|fast" }))), + ]); + expect(seen.sort()).toEqual(["user|fast=user|fast", "user|slow=user|slow"]); + }); + + it("is not set for rejected requests", async () => { + useTenant(); + let observed: Principal | undefined; + const { handle } = makeHandler({ requiredScopes: ["admin"] }, async () => { + observed = getCallerPrincipal(); + return new Response("ok"); + }); + await handle(request(await bearer())); + expect(observed).toBeUndefined(); + }); +}); + +describe("protected resource metadata route", () => { + it("serves the document without authentication", async () => { + const { handle, calls } = makeHandler({ + metadata: { + scopesSupported: ["read"], + resourceName: "Example MCP", + resourceDocumentation: "https://docs.example.com", + }, + }); + const response = await handle(request({}, { method: "GET" }, PRM)); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await bodyOf(response)).toEqual({ + resource: AUDIENCE, + authorization_servers: [tenant.issuer], + bearer_methods_supported: ["header"], + dpop_signing_alg_values_supported: ["ES256"], + scopes_supported: ["read"], + resource_name: "Example MCP", + resource_documentation: "https://docs.example.com", + }); + expect(calls).toHaveLength(0); + }); + + it("declares DPoP binding when DPoP is required and no DPoP support when disabled", async () => { + const required = await makeHandler( + {}, + undefined, + makeClient({ dpop: { mode: "required" } }), + ).handle(request({}, { method: "GET" }, PRM)); + expect(await bodyOf(required)).toMatchObject({ + dpop_bound_access_tokens_required: true, + }); + const disabled = await makeHandler( + {}, + undefined, + makeClient({ dpop: { mode: "disabled" } }), + ).handle(request({}, { method: "GET" }, PRM)); + const body = await bodyOf(disabled); + expect(body).not.toHaveProperty("dpop_signing_alg_values_supported"); + expect(body).not.toHaveProperty("dpop_bound_access_tokens_required"); + }); + + it("rejects other methods with 405", async () => { + const { handle } = makeHandler(); + const response = await handle(request({}, { method: "POST" }, PRM)); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET"); + }); + + it("inserts the resource path after the well-known prefix", async () => { + const { handle } = makeHandler({ + resource: "https://mcp.example.com/mcp/", + }); + const url = + "https://mcp.example.com/.well-known/oauth-protected-resource/mcp"; + const served = await handle(request({}, { method: "GET" }, url)); + expect(served.status).toBe(200); + expect((await bodyOf(served))["resource"]).toBe( + "https://mcp.example.com/mcp/", + ); + expect(wwwAuthenticate(await handle(request()))).toContain( + `resource_metadata="${url}"`, + ); + }); + + it("serves a custom path and points the challenge at it", async () => { + const { handle, calls } = makeHandler({ metadata: { path: "/prm.json" } }); + expect(wwwAuthenticate(await handle(request()))).toContain( + 'resource_metadata="https://mcp.example.com/prm.json"', + ); + expect( + ( + await handle( + request({}, { method: "GET" }, "https://mcp.example.com/prm.json"), + ) + ).status, + ).toBe(200); + expect((await handle(request({}, { method: "GET" }, PRM))).status).toBe( + 401, + ); + expect(calls).toHaveLength(0); + }); + + it("serves the same document the challenge URL names", async () => { + const { handle } = makeHandler(); + const challenge = wwwAuthenticate(await handle(request())) ?? ""; + const url = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? ""; + expect(url).toBe(PRM); + expect((await handle(request({}, { method: "GET" }, url))).status).toBe( + 200, + ); + }); + + it("ignores the query string when matching the route", async () => { + const { handle } = makeHandler(); + expect( + (await handle(request({}, { method: "GET" }, `${PRM}?x=1`))).status, + ).toBe(200); + }); +}); + +describe("error mapping", () => { + async function run( + error: unknown, + options: Partial = {}, + claims = {}, + ) { + useTenant(); + const { handle } = makeHandler(options, async () => { + throw error; + }); + return handle(request(await bearer(claims))); + } + + it("maps InsufficientScopeError to 403 listing the scopes the caller lacks", async () => { + const response = await run(new InsufficientScopeError(["read", "admin"])); + expect(response.status).toBe(403); + expect(wwwAuthenticate(response)).toContain('error="insufficient_scope"'); + expect(wwwAuthenticate(response)).toContain('scope="admin"'); + expect(wwwAuthenticate(response)).not.toContain("read"); + }); + + it("maps DownscopedTokenError to 403 without scope data", async () => { + const response = await run( + new DownscopedTokenError("granted: secret:scope"), + ); + expect(response.status).toBe(403); + expect(wwwAuthenticate(response)).toBe( + `Bearer error="insufficient_scope", error_description="${GENERIC.insufficient_scope}", ${ALLOWED_PRM}, ${DPOP_ADVERT}`, + ); + expect(JSON.stringify(await bodyOf(response))).not.toContain("secret"); + }); + + it("maps UnauthenticatedError to 401 invalid_token", async () => { + const response = await run( + new UnauthenticatedError("No authenticated request context."), + ); + expect(response.status).toBe(401); + expect(wwwAuthenticate(response)).toBe( + `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + ); + expect(JSON.stringify(await bodyOf(response))).not.toContain("context"); + }); + + it("answers 500 for a core auth error thrown downstream, such as a failed token exchange", async () => { + const onError = vi.fn(); + for (const status of [400, 401, 403]) { + const response = await run( + new AuthError("exchange failed", "token_exchange_error", status), + { onError }, + ); + expect(response.status).toBe(500); + expect(wwwAuthenticate(response)).toBeNull(); + } + for (const error of [ + new VerifyAccessTokenError("late"), + new InvalidRequestError("late"), + new InvalidDpopProofError("late"), + ]) { + expect((await run(error, { onError })).status).toBe(500); + } + expect(onError).toHaveBeenCalledTimes(6); + }); + + it("does not forward a DPoP nonce from a downstream error", async () => { + const error = new VerifyAccessTokenError("x", { "dpop-nonce": "n1" }); + expect((await run(error)).headers.get("dpop-nonce")).toBeNull(); + }); + + it.each([ + ["a plain error", new Error("database password is hunter2")], + ["an auth error without a status", new AuthError("hunter2", "custom")], + [ + "an auth error with a server status", + new AuthError("hunter2", "custom", 502), + ], + ["a thrown string", "hunter2"], + ])( + "answers 500 with a generic body and no challenge for %s", + async (_name, error) => { + const onError = vi.fn(); + const response = await run(error, { onError }); + expect(response.status).toBe(500); + expect(wwwAuthenticate(response)).toBeNull(); + expect(await bodyOf(response)).toEqual({ + error: "server_error", + error_description: "Internal server error.", + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error); + }, + ); + + it("does not call onError for authentication failures", async () => { + const onError = vi.fn(); + const response = await run(new UnauthenticatedError(), { onError }); + expect(response.status).toBe(401); + expect(onError).not.toHaveBeenCalled(); + }); + + it("still answers 500 when the onError hook throws", async () => { + const response = await run(new Error("boom"), { + onError: () => { + throw new Error("hook failed"); + }, + }); + expect(response.status).toBe(500); + }); +}); + +describe("request-scoped client failures", () => { + const principal: Principal = { + sub: "user|1", + expiresAt: Math.floor(Date.now() / 1000) + 600, + scopes: [], + permissions: null, + clientId: null, + orgId: null, + }; + + function fakeClient(withToken: McpClient["withToken"]): McpClient { + return { + dpopMode: "allowed", + verifyAccessToken: async () => ({ principal }), + withToken, + }; + } + + it("answers 500 for a handle that belongs to another client", async () => { + const onError = vi.fn(); + const client = fakeClient(() => { + throw new Error("Caller was not verified by this client."); + }); + const { handle, calls } = makeHandler({ onError }, undefined, client); + const response = await handle( + request({ authorization: "Bearer abc.def.ghi" }), + ); + expect(response.status).toBe(500); + expect(wwwAuthenticate(response)).toBeNull(); + expect(JSON.stringify(await bodyOf(response))).not.toContain("verified"); + expect(onError).toHaveBeenCalledTimes(1); + expect(calls).toHaveLength(0); + }); + + it("answers 500 when a real client rejects a handle issued by a different client", async () => { + useTenant(); + const issuer = makeClient(); + const other = makeClient(); + const onError = vi.fn(); + const client: McpClient = { + dpopMode: "allowed", + verifyAccessToken: (o) => issuer.verifyAccessToken(o), + withToken: (c) => other.withToken(c), + }; + const { handle, calls } = makeHandler({ onError }, undefined, client); + const response = await handle(request(await bearer())); + expect(response.status).toBe(500); + expect(wwwAuthenticate(response)).toBeNull(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(ForeignCallerError); + expect(calls).toHaveLength(0); + }); + + it("answers 401 invalid_token for an expired handle", async () => { + const client = fakeClient(() => { + throw new UnauthenticatedError("Access token has expired."); + }); + const { handle } = makeHandler({}, undefined, client); + const response = await handle( + request({ authorization: "Bearer abc.def.ghi" }), + ); + expect(response.status).toBe(401); + expect(wwwAuthenticate(response)).toBe( + `${bearerError("invalid_token")}, ${DPOP_ADVERT}`, + ); + }); + + it("answers 500 when verification fails with a non-auth error", async () => { + const client: McpClient = { + dpopMode: "allowed", + verifyAccessToken: async () => { + throw new TypeError("bad state"); + }, + withToken: () => { + throw new Error("unreachable"); + }, + }; + const { handle } = makeHandler({}, undefined, client); + expect( + (await handle(request({ authorization: "Bearer abc.def.ghi" }))).status, + ).toBe(500); + }); +}); + +describe("authentication-phase core errors", () => { + function failingClient(error: unknown): McpClient { + return { + dpopMode: "allowed", + verifyAccessToken: async () => { + throw error; + }, + withToken: () => { + throw new Error("unreachable"); + }, + }; + } + + async function failWith( + error: unknown, + options: Partial = {}, + ) { + const { handle } = makeHandler(options, undefined, failingClient(error)); + return handle(request({ authorization: "Bearer abc.def.ghi" })); + } + + it("replaces the challenge a core error carries and forwards a clean DPoP nonce", async () => { + const raw = new VerifyAccessTokenError("raw verifier detail", { + "www-authenticate": 'Bearer error_description="raw verifier detail"', + "DPoP-Nonce": "nonce-1", + "x-internal": "nope", + }); + const response = await failWith(raw); + expect(response.status).toBe(401); + expect(wwwAuthenticate(response)).not.toContain("raw verifier detail"); + expect(wwwAuthenticate(response)).toContain('error="invalid_token"'); + expect(response.headers.get("dpop-nonce")).toBe("nonce-1"); + expect(response.headers.get("x-internal")).toBeNull(); + }); + + it("forwards a nonce carried in an array", async () => { + const withArray = Object.assign(new InvalidRequestError("x"), { + headers: { "dpop-nonce": ["n1"] }, + }); + expect((await failWith(withArray)).headers.get("dpop-nonce")).toBe("n1"); + }); + + it.each([ + ["CR/LF", "abc\r\nSet-Cookie: a=b"], + ["a quote", 'ab"c'], + ["a backslash", "ab\\c"], + ["a space", "ab c"], + ["a non-ASCII character", "abé"], + ["an empty value", ""], + ["a value over 256 characters", "a".repeat(257)], + ])( + "drops a nonce containing %s and still answers normally", + async (_name, nonce) => { + const response = await failWith( + new VerifyAccessTokenError("x", { "dpop-nonce": nonce }), + ); + expect(response.status).toBe(401); + expect(response.headers.get("dpop-nonce")).toBeNull(); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(wwwAuthenticate(response)).toContain('error="invalid_token"'); + }, + ); + + it("accepts a nonce of exactly 256 allowed characters", async () => { + const nonce = "a".repeat(256); + const response = await failWith( + new VerifyAccessTokenError("x", { "dpop-nonce": nonce }), + ); + expect(response.headers.get("dpop-nonce")).toBe(nonce); + }); + + it("drops non-string nonce values", async () => { + const error = Object.assign(new VerifyAccessTokenError("x"), { + headers: { "dpop-nonce": [42, "ok"] as unknown as string[] }, + }); + expect((await failWith(error)).headers.get("dpop-nonce")).toBe("ok"); + }); + + it("answers a generic 500 when building the error response itself throws", async () => { + const onError = vi.fn(); + const error = new VerifyAccessTokenError("x"); + Object.defineProperty(error, "headers", { + get() { + throw new Error("hostile getter"); + }, + }); + const response = await failWith(error, { onError }); + expect(response.status).toBe(500); + expect(wwwAuthenticate(response)).toBeNull(); + expect(await bodyOf(response)).toEqual({ + error: "server_error", + error_description: "Internal server error.", + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0]?.[0]).toBe(error); + }); + + it("maps an invalid DPoP proof to 401", async () => { + const response = await failWith(new InvalidDpopProofError("detail")); + expect(response.status).toBe(401); + expect(wwwAuthenticate(response)).toContain( + 'DPoP error="invalid_dpop_proof"', + ); + }); + + it("maps a nonce-required error to 401 use_dpop_nonce with the nonce", async () => { + const error = new AuthError("detail", "use_dpop_nonce", 401, { + "DPoP-Nonce": "fresh-1", + }); + const response = await failWith(error); + expect(response.status).toBe(401); + expect(wwwAuthenticate(response)).toContain('DPoP error="use_dpop_nonce"'); + expect(wwwAuthenticate(response)).not.toContain("detail"); + expect(response.headers.get("dpop-nonce")).toBe("fresh-1"); + expect(await bodyOf(response)).toMatchObject({ error: "use_dpop_nonce" }); + }); + + it("reports invalid_dpop_proof when the core nonce fails the allowlist", async () => { + const error = new AuthError("detail", "use_dpop_nonce", 401, { + "DPoP-Nonce": "bad\r\nnonce", + }); + const response = await failWith(error); + expect(response.status).toBe(401); + expect(wwwAuthenticate(response)).toContain( + 'DPoP error="invalid_dpop_proof"', + ); + expect(wwwAuthenticate(response)).not.toContain("use_dpop_nonce"); + expect(response.headers.get("dpop-nonce")).toBeNull(); + const none = await failWith(new AuthError("detail", "use_dpop_nonce", 401)); + expect(wwwAuthenticate(none)).toContain('DPoP error="invalid_dpop_proof"'); + }); + + it("recognizes a nonce-required error signalled through the cause code", async () => { + const error = Object.assign(new InvalidDpopProofError("x"), { + cause: { code: "use_dpop_nonce" }, + }); + expect((await failWith(error)).status).toBe(401); + }); + + it("keeps the core status for other auth errors with a client-facing status", async () => { + const forbidden = await failWith(new AuthError("detail", "custom", 403)); + expect(forbidden.status).toBe(403); + expect(wwwAuthenticate(forbidden)).toContain('error="insufficient_scope"'); + const unauthorized = await failWith(new AuthError("detail", "custom", 401)); + expect(unauthorized.status).toBe(401); + const badRequest = await failWith(new AuthError("detail", "custom", 400)); + expect(badRequest.status).toBe(400); + expect(wwwAuthenticate(badRequest)).toContain('error="invalid_request"'); + expect( + (await failWith(new AuthError("detail", "custom", 502))).status, + ).toBe(500); + expect((await failWith(new AuthError("detail", "custom"))).status).toBe( + 500, + ); + }); +}); + +describe("metadata path validation", () => { + it.each([ + "prm", + "/prm?x=1", + "/prm#frag", + "//evil.example/prm", + "/a/../prm", + "/a b", + "//[bad", + "/é", + "", + ])("rejects %j", (path) => { + expect(() => makeHandler({ metadata: { path } })).toThrow( + InvalidConfigurationError, + ); + }); + + it("accepts a normalized path and advertises exactly the matched route", async () => { + const { handle } = makeHandler({ metadata: { path: "/docs/prm.json" } }); + const challenge = wwwAuthenticate(await handle(request())) ?? ""; + const advertised = /resource_metadata="([^"]+)"/.exec(challenge)?.[1] ?? ""; + expect(advertised).toBe("https://mcp.example.com/docs/prm.json"); + expect( + (await handle(request({}, { method: "GET" }, advertised))).status, + ).toBe(200); + }); +}); + +describe("realm validation", () => { + it.each(["", 'a"b', "a\\b", "a\nb", "é", 42 as unknown as string])( + "rejects %j", + (realm) => { + expect(() => makeHandler({ realm })).toThrow(InvalidConfigurationError); + }, + ); + + it("accepts a clean realm", () => { + expect(() => makeHandler({ realm: "my mcp" })).not.toThrow(); + }); +}); + +describe("pre-verification responses never escape", () => { + it("returns a generic 500 when building the no-credential response throws", async () => { + const { handle } = makeHandler({ realm: "ok" }); + const spy = vi.spyOn(Headers.prototype, "append").mockImplementation(() => { + throw new Error("boom"); + }); + try { + const absent = await handle(request()); + const malformed = await handle(request({ authorization: "Bearer a b" })); + expect(absent.status).toBe(500); + expect(malformed.status).toBe(500); + expect(await bodyOf(absent)).toEqual({ + error: "server_error", + error_description: "Internal server error.", + }); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("configuration", () => { + it("rejects a resource that is not an absolute URL", () => { + expect(() => makeHandler({ resource: "not a url" })).toThrow(TypeError); + }); +}); diff --git a/packages/ai-mcp/test/smoke/module-formats.spec.ts b/packages/ai-mcp/test/smoke/module-formats.spec.ts index 3bd02be9..f74ad0be 100644 --- a/packages/ai-mcp/test/smoke/module-formats.spec.ts +++ b/packages/ai-mcp/test/smoke/module-formats.spec.ts @@ -25,6 +25,8 @@ const expectedValues = [ "DownscopedTokenError", "buildPrincipal", "createMcpClient", + "ForeignCallerError", + "createMcpHandler", "runWithRequestContext", "getRequestContext", "getCallerPrincipal", From 0cca90004ea8425dd0268829b0bab5af4a8c1065 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:21:48 +0530 Subject: [PATCH 12/31] feat(ai-mcp): add MCP SDK transport wiring under the mcp-sdk subpath 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 --- packages/ai-mcp/package.json | 10 + packages/ai-mcp/src/mcp-sdk.ts | 331 +++++++ packages/ai-mcp/src/scopes.ts | 1 + packages/ai-mcp/test/mcp-sdk.spec.ts | 824 ++++++++++++++++++ .../ai-mcp/test/smoke/module-formats.spec.ts | 38 + 5 files changed, 1204 insertions(+) create mode 100644 packages/ai-mcp/src/mcp-sdk.ts create mode 100644 packages/ai-mcp/test/mcp-sdk.spec.ts diff --git a/packages/ai-mcp/package.json b/packages/ai-mcp/package.json index 401bae2f..04eb972d 100644 --- a/packages/ai-mcp/package.json +++ b/packages/ai-mcp/package.json @@ -30,6 +30,16 @@ ], "exports": { "./package.json": "./package.json", + "./mcp-sdk": { + "import": { + "types": "./dist/esm/mcp-sdk.d.ts", + "default": "./dist/esm/mcp-sdk.js" + }, + "require": { + "types": "./dist/cjs/mcp-sdk.d.ts", + "default": "./dist/cjs/mcp-sdk.js" + } + }, ".": { "import": { "types": "./dist/esm/index.d.ts", diff --git a/packages/ai-mcp/src/mcp-sdk.ts b/packages/ai-mcp/src/mcp-sdk.ts new file mode 100644 index 00000000..c9c4a20b --- /dev/null +++ b/packages/ai-mcp/src/mcp-sdk.ts @@ -0,0 +1,331 @@ +/** + * Glue between this package and the MCP TypeScript SDK (`@modelcontextprotocol/sdk`). + * + * This module is the only one that imports the SDK. The SDK is an optional peer + * dependency, so the module is published as its own entry point and nothing else + * in the package imports it. + */ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { AuthError } from '@auth0/auth0-api-js'; +import { + InsufficientScopeError as SdkInsufficientScopeError, + InvalidTokenError as SdkInvalidTokenError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import type { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import type { AuthInfo as SdkAuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import type { McpClient } from './client.js'; +import { createMcpHandler } from './handler.js'; +import type { AuthInfo, McpHandlerOptions } from './handler.js'; + +/** + * The part of the SDK's `McpServer` (or low-level `Server`) this module uses. + * Any object with `connect` and `close` fits. + */ +export interface McpServerLike { + connect(transport: WebStandardStreamableHTTPServerTransport): Promise; + close(): Promise; +} + +/** Options for {@link createMcpSdkHandler}. */ +export interface McpSdkHandlerOptions extends Omit { + /** + * Creates the server that answers one request. It is called once per request + * and the server is closed when the request ends, because the transport runs + * in stateless mode and the SDK does not allow a stateless transport to be + * shared between requests. Register tools inside this function; keep expensive + * setup outside of it. + */ + createServer: () => McpServerLike | Promise; +} + +/** + * Creates a function that authenticates MCP HTTP requests with + * {@link createMcpHandler} and serves them with the SDK's web standard + * Streamable HTTP transport. It takes a standard `Request` and returns a `Response`. + * + * The verified caller reaches your tools in two ways: as `extra.authInfo` in every + * tool callback (the SDK's own mechanism), and through the request context, so + * `getCallerMcpClient()`, `getCallerPrincipal()` and `withRequiredScopes()` work + * inside tools. + * + * `AuthInfo.clientId` is `''` when the token carries no `client_id` or `azp`. + * + * Mode: stateless and JSON responses. The server is closed as soon as + * `handleRequest` returns, which is only valid for JSON response mode (a streamed + * response would be cut off), so this function does not offer streaming. Each POST is answered with one JSON + * body and no session id is issued, so any instance can serve any request. + * Server-to-client notifications and standalone event streams (GET) are not + * offered. If you need sessions or streaming, use {@link createMcpHandler} and + * manage the transport yourself. + * + * Failures that happen before the server runs (no credential, bad token, missing + * server-wide scope) follow the challenge rules of {@link createMcpHandler}. + * A tool that needs a scope only for itself should use `withRequiredScopes`, + * which answers with an `isError` tool result and HTTP 200. + * + * @param options - Handler options plus {@link McpSdkHandlerOptions.createServer}. + * @returns A function from `Request` to `Response`. + */ +export function createMcpSdkHandler( + options: McpSdkHandlerOptions, +): (request: Request) => Promise { + const { createServer, ...handlerOptions } = options; + return createMcpHandler({ + ...handlerOptions, + handler: async (request: Request, authInfo: AuthInfo): Promise => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + const server = await createServer(); + try { + await server.connect(transport); + return await transport.handleRequest(request, { authInfo }); + } finally { + await server.close().catch(() => undefined); + } + }, + }); +} + +/** Options for {@link toNodeHandler}. */ +export interface NodeHandlerOptions { + /** + * Public identifier of the server. Its origin is used to rebuild the absolute + * request URL, so the `Host` header of the incoming request is never trusted + * (DPoP checks the URL the client used). + */ + resource: string; + /** Largest request body accepted, in bytes. Defaults to 1 MiB. Larger bodies get a 413. */ + maxBodyBytes?: number; +} + +const DEFAULT_MAX_BODY_BYTES = 1024 * 1024; + +function genericResponse( + res: ServerResponse, + status: number, + error: string, + description: string, + close = false, +): void { + if (res.headersSent) { + res.destroy(); + return; + } + res.statusCode = status; + if (close) res.setHeader('connection', 'close'); + res.setHeader('content-type', 'application/json'); + res.setHeader('cache-control', 'no-store'); + res.end(JSON.stringify({ error, error_description: description })); +} + +class BodyTooLargeError extends Error {} +class MalformedRequestError extends Error {} + +const VALID_PATH = /^\/(?![/\\])[^\s]*$/; + +async function readBody(req: IncomingMessage, limit: number): Promise { + const declared = req.headers['content-length']; + if (declared !== undefined) { + if (!/^\d{1,15}$/.test(declared)) throw new MalformedRequestError(); + if (Number(declared) > limit) throw new BodyTooLargeError(); + } + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of req) { + const bytes: Uint8Array = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk; + size += bytes.byteLength; + if (size > limit) throw new BodyTooLargeError(); + chunks.push(bytes); + } + if (chunks.length === 0) return undefined; + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +/** + * Adapts a standard `Request` to `Response` handler, such as the one returned + * by {@link createMcpHandler} or {@link createMcpSdkHandler}, to Node's `http` + * request listener signature. The result mounts on `http.createServer`, Express + * (`app.all('/mcp', handler)`) and anything else that passes + * `IncomingMessage` and `ServerResponse`. + * + * All authentication stays in the web handler; this function only converts + * messages. Headers, including `Authorization` and `DPoP`, are passed through + * unchanged. The body is read up to `maxBodyBytes`; a malformed `Content-Length` + * or a path that does not start with a single `/` gets 400, an oversized body 413 + * (the connection is closed). Mount it before body-parsing middleware; if a parser + * already consumed the stream, the parsed `req.body` (string, Buffer or object) is + * used, with the same size limit. Any other error becomes a generic 500. + * + * @param handle - The web handler to adapt. + * @param options - Public resource identifier and body limit. + * @returns A function that writes the response to `res` and never rejects. + */ +export function toNodeHandler( + handle: (request: Request) => Promise, + options: NodeHandlerOptions, +): (req: IncomingMessage, res: ServerResponse) => Promise { + const origin = new URL(options.resource).origin; + const limit = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + + return async function listener(req, res) { + try { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else { + headers.append(name, value); + } + } + const method = req.method ?? 'GET'; + const path = (req as IncomingMessage & { originalUrl?: string }).originalUrl ?? req.url ?? '/'; + // Only a single-slash path keeps the origin fixed; "//host" would be read as another authority. + if (!VALID_PATH.test(path)) throw new MalformedRequestError(); + let body: BodyInit | undefined; + if (method !== 'GET' && method !== 'HEAD') { + const parsed = (req as IncomingMessage & { body?: unknown }).body; + if (parsed !== undefined) { + const bytes = + parsed instanceof Uint8Array + ? parsed + : new TextEncoder().encode(typeof parsed === 'string' ? parsed : (JSON.stringify(parsed) ?? '')); + if (bytes.byteLength > limit) throw new BodyTooLargeError(); + body = bytes as unknown as BodyInit; + headers.delete('content-length'); + } else { + const bytes = await readBody(req, limit); + if (bytes !== undefined) body = bytes as unknown as BodyInit; + } + } + const controller = new AbortController(); + res.on('close', () => { + if (!res.writableFinished) controller.abort(); + }); + const response = await handle( + new Request(origin + path, { method, headers, body, signal: controller.signal }), + ); + res.statusCode = response.status; + response.headers.forEach((value, name) => res.appendHeader(name, value)); + if (response.body === null) { + res.end(); + return; + } + const reader = response.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (res.destroyed) break; + if (!res.write(value)) { + await new Promise((resolve) => { + const finish = () => { + res.off('drain', finish); + res.off('close', finish); + resolve(); + }; + res.once('drain', finish); + res.once('close', finish); + }); + } + } + res.end(); + } finally { + await reader.cancel().catch(() => undefined); + } + } catch (error) { + if (error instanceof BodyTooLargeError) { + res.once('finish', () => req.destroy()); + genericResponse(res, 413, 'invalid_request', 'The request body is too large.', true); + return; + } + if (error instanceof MalformedRequestError) { + genericResponse(res, 400, 'invalid_request', 'The request is malformed.', true); + return; + } + genericResponse(res, 500, 'server_error', 'Internal server error.'); + } + }; +} + +/** + * Convenience for {@link createMcpSdkHandler} followed by {@link toNodeHandler}. + * + * @param options - Handler options plus an optional body limit. + * @returns A Node request listener. + */ +export function createNodeMcpHandler( + options: McpSdkHandlerOptions & Pick, +): (req: IncomingMessage, res: ServerResponse) => Promise { + return toNodeHandler(createMcpSdkHandler(options), { + resource: options.resource, + maxBodyBytes: options.maxBodyBytes, + }); +} + +/** + * Adapts an {@link McpClient} to the SDK's `OAuthTokenVerifier`, for use with the + * SDK's `requireBearerAuth` Express middleware. + * + * Prefer {@link createMcpHandler} or {@link createMcpSdkHandler}. This shim + * INHERITS THE SDK MIDDLEWARE'S CHALLENGE BEHAVIOUR, which does not meet this + * package's challenge rules: it answers a missing credential with + * `error="invalid_token"` (RFC 6750 section 3.1 says to send no error value), + * echoes the SDK's own messages in `error_description`, lists the configured + * scopes rather than only the missing ones, and does not know DPoP. Here, Bearer + * is the only scheme and a DPoP-bound token fails verification. + * + * What you lose compared with the handler: `requireBearerAuth` does not run + * this package's request context. Inside your routes `getRequestContext()`, + * `getCallerPrincipal()` and `getCallerMcpClient()` return `undefined`, and no + * request-scoped client exists. To get them back, call `client.verifyAccessToken` + * yourself in a wrapper and use `runWithRequestContext`, or switch to the handler. + * + * Errors are mapped with generic messages: any failed verification becomes the + * SDK's `InvalidTokenError`, a 403 from the core becomes the SDK's + * `InsufficientScopeError`, and everything else is rethrown so the middleware + * answers 500. + * + * @param client - The client that verifies tokens. + * @returns A verifier for `requireBearerAuth({ verifier })`. + */ +export function asTokenVerifier(client: McpClient): OAuthTokenVerifier { + return { + async verifyAccessToken(token: string): Promise { + let caller; + try { + caller = await client.verifyAccessToken({ accessToken: token, scheme: 'bearer' }); + } catch (error) { + if (error instanceof AuthError) { + if (error.statusCode === 403) { + throw new SdkInsufficientScopeError('Insufficient scope.'); + } + if (error.statusCode === undefined || error.statusCode < 500) { + throw new SdkInvalidTokenError('Invalid access token.'); + } + } + throw error; + } + const principal = caller.principal; + return { + token, + clientId: principal.clientId ?? '', + scopes: [...principal.scopes], + expiresAt: principal.expiresAt, + extra: { + sub: principal.sub, + ...(principal.orgId !== null && { orgId: principal.orgId }), + }, + }; + }, + }; +} diff --git a/packages/ai-mcp/src/scopes.ts b/packages/ai-mcp/src/scopes.ts index 70c51143..bebed024 100644 --- a/packages/ai-mcp/src/scopes.ts +++ b/packages/ai-mcp/src/scopes.ts @@ -14,6 +14,7 @@ export interface McpToolErrorResult { isError: true; /** Machine-readable marker for the failure kind. */ _meta: { error: 'insufficient_scope' }; + [key: string]: unknown; } function normalize(required: string | readonly string[]): string[] { diff --git a/packages/ai-mcp/test/mcp-sdk.spec.ts b/packages/ai-mcp/test/mcp-sdk.spec.ts new file mode 100644 index 00000000..9ad4eb2d --- /dev/null +++ b/packages/ai-mcp/test/mcp-sdk.spec.ts @@ -0,0 +1,824 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createServer } from "node:http"; +import { connect } from "node:net"; +import type { Server as HttpServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { setupServer } from "msw/node"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; +import { + InsufficientScopeError as SdkInsufficientScopeError, + InvalidTokenError as SdkInvalidTokenError, +} from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import type { AuthInfo as SdkAuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { + AuthError, + InvalidConfigurationError, + VerifyAccessTokenError, +} from "@auth0/auth0-api-js"; +import { createMcpClient } from "../src/client.js"; +import type { McpClient } from "../src/client.js"; +import type { AuthInfo } from "../src/handler.js"; +import { + asTokenVerifier, + createMcpSdkHandler, + createNodeMcpHandler, + toNodeHandler, +} from "../src/mcp-sdk.js"; +import type { McpSdkHandlerOptions } from "../src/mcp-sdk.js"; +import { getCallerMcpClient, getCallerPrincipal } from "../src/request-context.js"; +import { withRequiredScopes } from "../src/scopes.js"; +import { + createAuth0Handlers, + createTestKey, + mintAccessToken, + scopeMode, +} from "./support/index.js"; +import type { TestKey } from "./support/index.js"; + +// The wrapper's AuthInfo must stay assignable to the SDK's AuthInfo. +const assertAssignable = (value: AuthInfo): SdkAuthInfo => value; +void assertAssignable; + +const ORIGIN = "https://mcp.example.com"; +const AUDIENCE = ORIGIN; +const ENDPOINT = `${ORIGIN}/mcp`; +const DOWNSTREAM = "https://api.example.com"; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + tenant = createAuth0Handlers({ key, scope: scopeMode.echo }); + server.listen({ + onUnhandledRequest(request, print) { + if (new URL(request.url).hostname === "127.0.0.1") return; + print.error(); + }, + }); +}); +afterEach(() => { + server.resetHandlers(); + tenant.state.reset(); +}); +afterAll(() => server.close()); + +function useTenant() { + server.use(...tenant.handlers); +} + +function makeClient( + overrides: Partial[0]> = {}, +): McpClient { + return createMcpClient({ + domain: "tenant.example.com", + audience: AUDIENCE, + clientId: "mcp-client", + clientSecret: "secret", + ...overrides, + }); +} + +function token(scope = "read write", extra: Parameters[1] = {}) { + return mintAccessToken(key, { + iss: tenant.issuer, + aud: AUDIENCE, + sub: "user|1", + scope, + claims: { client_id: "client-a" }, + ...extra, + }); +} + +interface Seen { + authInfo?: Record; + contextSub?: string; + hasClient?: boolean; +} + +function buildMcpServer(seen: Seen[]): McpServer { + const mcp = new McpServer({ name: "test", version: "1.0.0" }); + mcp.registerTool("whoami", { description: "Reports the caller." }, async (extra) => { + const info = (extra as { authInfo?: SdkAuthInfo }).authInfo; + seen.push({ + authInfo: info as unknown as Record, + contextSub: getCallerPrincipal()?.sub, + hasClient: getCallerMcpClient() !== undefined, + }); + return { content: [{ type: "text", text: "ok" }] }; + }); + mcp.registerTool( + "delegate", + { description: "Exchanges the caller token." }, + async () => { + const result = await getCallerMcpClient()!.getTokenOnBehalfOf({ + audience: DOWNSTREAM, + scope: "api:read", + }); + return { content: [{ type: "text", text: result.grantedScopes.join(" ") }] }; + }, + ); + mcp.registerTool( + "admin", + { description: "Needs an extra scope." }, + withRequiredScopes("admin", async () => ({ + content: [{ type: "text" as const, text: "secret" }], + })), + ); + return mcp; +} + +function toolCall(name: string, headers: Record = {}, url = ENDPOINT) { + return new Request(url, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: {} }, + }), + }); +} + +function makeHandler(seen: Seen[], overrides: Partial = {}) { + return createMcpSdkHandler({ + client: makeClient(), + resource: AUDIENCE, + authorizationServers: [tenant.issuer], + createServer: () => buildMcpServer(seen), + ...overrides, + }); +} + +interface ToolResult { + result?: { isError?: boolean; content: Array<{ text: string }> }; +} + +describe("createMcpSdkHandler", () => { + it("gives tools the verified caller as authInfo and as request context", async () => { + useTenant(); + const seen: Seen[] = []; + const response = await makeHandler(seen)( + toolCall("whoami", { authorization: `Bearer ${await token()}` }), + ); + + expect(response.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]!.authInfo).toMatchObject({ + clientId: "client-a", + scopes: ["read", "write"], + extra: { sub: "user|1" }, + }); + expect(typeof seen[0]!.authInfo!.token).toBe("string"); + expect(seen[0]!.contextSub).toBe("user|1"); + expect(seen[0]!.hasClient).toBe(true); + }); + + it("lets a tool exchange the caller's token through the request-scoped client", async () => { + useTenant(); + const response = await makeHandler([])( + toolCall("delegate", { authorization: `Bearer ${await token()}` }), + ); + const body = (await response.json()) as ToolResult; + + expect(body.result?.isError).toBeFalsy(); + expect(body.result?.content[0]?.text).toBe("api:read"); + expect(tenant.state.successfulExchangeCount).toBe(1); + }); + + it("keeps concurrent callers apart", async () => { + useTenant(); + const seen: Seen[] = []; + const handle = makeHandler(seen); + const [a, b] = await Promise.all([ + mintAccessToken(key, { iss: tenant.issuer, aud: AUDIENCE, sub: "alice", scope: "read", claims: { client_id: "c" } }), + mintAccessToken(key, { iss: tenant.issuer, aud: AUDIENCE, sub: "bob", scope: "read", claims: { client_id: "c" } }), + ]); + await Promise.all([ + handle(toolCall("whoami", { authorization: `Bearer ${a}` })), + handle(toolCall("whoami", { authorization: `Bearer ${b}` })), + ]); + + expect(seen.map((entry) => entry.contextSub).sort()).toEqual(["alice", "bob"]); + }); + + it("answers 401 without a credential and never runs the server", async () => { + useTenant(); + const seen: Seen[] = []; + const factory = vi.fn(() => buildMcpServer(seen)); + const response = await makeHandler(seen, { createServer: factory })(toolCall("whoami")); + + expect(response.status).toBe(401); + const challenge = response.headers.get("www-authenticate")!; + expect(challenge).toContain( + `Bearer resource_metadata="${ORIGIN}/.well-known/oauth-protected-resource"`, + ); + expect(challenge).not.toContain("error="); + expect(factory).not.toHaveBeenCalled(); + }); + + it("answers 401 invalid_token for a bad token", async () => { + useTenant(); + const response = await makeHandler([])( + toolCall("whoami", { authorization: "Bearer not-a.valid.token" }), + ); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"'); + }); + + it("answers 403 insufficient_scope for a missing server-wide scope", async () => { + useTenant(); + const factory = vi.fn(() => buildMcpServer([])); + const response = await makeHandler([], { + requiredScopes: ["read", "admin"], + createServer: factory, + })(toolCall("whoami", { authorization: `Bearer ${await token("read")}` })); + + expect(response.status).toBe(403); + expect(response.headers.get("www-authenticate")).toContain('error="insufficient_scope"'); + expect(response.headers.get("www-authenticate")).toContain('scope="admin"'); + expect(factory).not.toHaveBeenCalled(); + }); + + it("returns an isError tool result with HTTP 200 for a per-tool scope denial", async () => { + useTenant(); + const response = await makeHandler([])( + toolCall("admin", { authorization: `Bearer ${await token("read")}` }), + ); + const body = (await response.json()) as ToolResult; + + expect(response.status).toBe(200); + expect(body.result?.isError).toBe(true); + expect(body.result?.content[0]?.text).toBe("Missing one or more required scopes."); + }); + + it("runs the tool when the per-tool scope is held", async () => { + useTenant(); + const response = await makeHandler([])( + toolCall("admin", { authorization: `Bearer ${await token("read admin")}` }), + ); + const body = (await response.json()) as ToolResult; + + expect(body.result?.content[0]?.text).toBe("secret"); + }); + + it("closes the server after each request", async () => { + useTenant(); + const close = vi.fn(async () => undefined); + const handle = makeHandler([], { + createServer: () => { + const mcp = buildMcpServer([]); + const original = mcp.close.bind(mcp); + mcp.close = async () => { + close(); + return original(); + }; + return mcp; + }, + }); + await handle(toolCall("whoami", { authorization: `Bearer ${await token()}` })); + + expect(close).toHaveBeenCalledTimes(1); + }); +}); + +describe("Node adapter", () => { + let http: HttpServer | undefined; + afterEach(async () => { + await new Promise((resolve) => (http ? http.close(() => resolve()) : resolve())); + http = undefined; + }); + + async function listen(listener: Parameters[1]): Promise { + http = createServer(listener); + await new Promise((resolve) => http!.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${(http.address() as AddressInfo).port}/mcp`; + } + + const rpc = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "whoami", arguments: {} }, + }); + const jsonHeaders = { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }; + + it("serves an authenticated tool call over real HTTP", async () => { + useTenant(); + const seen: Seen[] = []; + const url = await listen( + createNodeMcpHandler({ + client: makeClient(), + resource: AUDIENCE, + authorizationServers: [tenant.issuer], + createServer: () => buildMcpServer(seen), + }), + ); + const response = await fetch(url, { + method: "POST", + headers: { ...jsonHeaders, authorization: `Bearer ${await token()}` }, + body: rpc, + }); + + expect(response.status).toBe(200); + expect(((await response.json()) as ToolResult).result?.content[0]?.text).toBe("ok"); + expect(seen[0]!.contextSub).toBe("user|1"); + }); + + it("passes Authorization and DPoP headers through unchanged", async () => { + const received: Array> = []; + const url = await listen( + toNodeHandler( + async (request) => { + received.push({ + authorization: request.headers.get("authorization"), + dpop: request.headers.get("dpop"), + url: request.url, + method: request.method, + }); + return new Response("done", { status: 200 }); + }, + { resource: AUDIENCE }, + ), + ); + await fetch(url, { + method: "POST", + headers: { authorization: "DPoP abc", dpop: "a.b.c" }, + body: "{}", + }); + + expect(received).toEqual([ + { authorization: "DPoP abc", dpop: "a.b.c", url: ENDPOINT, method: "POST" }, + ]); + }); + + it("builds the request URL from the configured resource, not the Host header", async () => { + let seenUrl = ""; + const url = await listen( + toNodeHandler( + async (request) => { + seenUrl = request.url; + return new Response(null, { status: 204 }); + }, + { resource: AUDIENCE }, + ), + ); + const response = await fetch(`${url}?x=1`, { headers: { host: "evil.example.net" } }); + + expect(response.status).toBe(204); + expect(seenUrl).toBe(`${ENDPOINT}?x=1`); + }); + + it("answers 401 with the challenge header over real HTTP", async () => { + useTenant(); + const url = await listen( + createNodeMcpHandler({ + client: makeClient(), + resource: AUDIENCE, + authorizationServers: [tenant.issuer], + createServer: () => buildMcpServer([]), + }), + ); + const response = await fetch(url, { method: "POST", headers: jsonHeaders, body: rpc }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain("resource_metadata="); + }); + + it("rejects a body over the limit with 413 and does not call the handler", async () => { + const handle = vi.fn(async () => new Response("x")); + const url = await listen(toNodeHandler(handle, { resource: AUDIENCE, maxBodyBytes: 16 })); + const response = await fetch(url, { method: "POST", body: "x".repeat(64) }); + + expect(response.status).toBe(413); + expect(handle).not.toHaveBeenCalled(); + expect(await response.json()).toEqual({ + error: "invalid_request", + error_description: "The request body is too large.", + }); + }); + + it("enforces the limit on chunked bodies that declare no length", async () => { + const handle = vi.fn(async () => new Response("x")); + const url = await listen(toNodeHandler(handle, { resource: AUDIENCE, maxBodyBytes: 16 })); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("x".repeat(10))); + controller.enqueue(new TextEncoder().encode("x".repeat(10))); + controller.close(); + }, + }); + const response = await fetch(url, { + method: "POST", + body: stream, + duplex: "half", + } as RequestInit); + + expect(response.status).toBe(413); + expect(handle).not.toHaveBeenCalled(); + }); + + function rawRequest(url: string, lines: string[]): Promise { + const { hostname, port } = new URL(url); + return new Promise((resolve) => { + const socket = connect(Number(port), hostname, () => socket.write(lines.join("\r\n") + "\r\n\r\n")); + let data = ""; + socket.on("data", (chunk) => (data += chunk)); + socket.on("close", () => resolve(data)); + socket.on("error", () => resolve(data)); + }); + } + + it("rejects a malformed Content-Length with 400", async () => { + const handle = vi.fn(async () => new Response("x")); + const url = await listen(toNodeHandler(handle, { resource: AUDIENCE })); + for (const length of ["abc", "-5", "1.5", "1e3"]) { + const reply = await rawRequest(url, [ + "POST /mcp HTTP/1.1", "Host: x", "Connection: close", `Content-Length: ${length}`, + ]); + expect(reply === "" || reply.startsWith("HTTP/1.1 400")).toBe(true); + } + expect(handle).not.toHaveBeenCalled(); + }); + + it("rejects a malformed Content-Length reaching the adapter with 400", async () => { + const handle = vi.fn(async () => new Response("x")); + const adapter = toNodeHandler(handle, { resource: AUDIENCE }); + const url = await listen((req, res) => { + req.headers["content-length"] = "-5"; + return adapter(req, res); + }); + const response = await fetch(url, { method: "POST", body: "{}" }); + + expect(response.status).toBe(400); + expect(response.headers.get("connection")).toBe("close"); + expect(handle).not.toHaveBeenCalled(); + }); + + it("closes the connection after a 413", async () => { + const url = await listen(toNodeHandler(async () => new Response("x"), { resource: AUDIENCE, maxBodyBytes: 4 })); + const response = await fetch(url, { method: "POST", body: "x".repeat(64) }); + + expect(response.status).toBe(413); + expect(response.headers.get("connection")).toBe("close"); + }); + + it("does not let a // path change the origin", async () => { + const handle = vi.fn(async () => new Response("x")); + const adapter = toNodeHandler(handle, { resource: AUDIENCE }); + const url = await listen((req, res) => { + req.url = "//evil.example/x"; + return adapter(req, res); + }); + const response = await fetch(url); + + expect(response.status).toBe(400); + expect(handle).not.toHaveBeenCalled(); + }); + + it("rejects paths that are not a single-slash path", async () => { + const handle = vi.fn(async () => new Response("x")); + const adapter = toNodeHandler(handle, { resource: AUDIENCE }); + for (const bad of ["http://evil.example/x", "/\\evil.example", "mcp"]) { + const url = await listen((req, res) => { + req.url = bad; + return adapter(req, res); + }); + expect((await fetch(url)).status).toBe(400); + await new Promise((resolve) => http!.close(() => resolve())); + } + expect(handle).not.toHaveBeenCalled(); + http = undefined; + }); + + it("never reaches the handler with two Authorization headers merged into a valid one", async () => { + const seen: Array = []; + const url = await listen( + toNodeHandler( + async (request) => { + seen.push(request.headers.get("authorization")); + return new Response("ok"); + }, + { resource: AUDIENCE }, + ), + ); + await rawRequest(url, [ + "GET /mcp HTTP/1.1", "Host: x", "Connection: close", + "Authorization: Bearer first", "Authorization: Bearer second", + ]); + + expect(seen).toHaveLength(1); + expect(seen[0]).not.toContain("second"); + }); + + it("handles a pre-parsed Buffer, Uint8Array and string body and limits each", async () => { + const received: string[] = []; + const adapter = toNodeHandler( + async (request) => { + received.push(await request.text()); + return new Response("ok"); + }, + { resource: AUDIENCE, maxBodyBytes: 8 }, + ); + const bodies: unknown[] = [Buffer.from("buf"), new TextEncoder().encode("u8"), "str", { a: 1 }]; + let index = 0; + const url = await listen((req, res) => { + (req as unknown as { body: unknown }).body = bodies[index++]; + return adapter(req, res); + }); + for (let i = 0; i < bodies.length; i++) await fetch(url, { method: "POST", body: "{}" }); + + expect(received).toEqual(["buf", "u8", "str", '{"a":1}']); + + bodies.push(Buffer.from("x".repeat(64))); + const big = await fetch(url, { method: "POST", body: "{}" }); + expect(big.status).toBe(413); + }); + + it("stops reading the response when the client goes away", async () => { + let cancelled = false; + const url = await listen( + toNodeHandler( + async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(65536)); + }, + cancel() { + cancelled = true; + }, + }), + ), + { resource: AUDIENCE }, + ), + ); + const controller = new AbortController(); + const response = await fetch(url, { signal: controller.signal }); + await response.body!.getReader().read(); + controller.abort(); + await vi.waitFor(() => expect(cancelled).toBe(true)); + }); + + it("turns a thrown error into a generic 500", async () => { + const url = await listen( + toNodeHandler( + async () => { + throw new Error("secret detail"); + }, + { resource: AUDIENCE }, + ), + ); + const response = await fetch(url); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(text).not.toContain("secret detail"); + expect(JSON.parse(text)).toEqual({ + error: "server_error", + error_description: "Internal server error.", + }); + }); + + it("re-serializes a body a parser already consumed", async () => { + let received = ""; + const handler = toNodeHandler( + async (request) => { + received = await request.text(); + return new Response("ok"); + }, + { resource: AUDIENCE }, + ); + const url = await listen((req, res) => { + (req as unknown as { body: unknown }).body = { hello: "world" }; + return handler(req, res); + }); + await fetch(url, { method: "POST", body: "ignored" }); + + expect(received).toBe('{"hello":"world"}'); + }); + + it("uses originalUrl when a framework rewrote the path", async () => { + let seenUrl = ""; + const handler = toNodeHandler( + async (request) => { + seenUrl = request.url; + return new Response(null, { status: 204 }); + }, + { resource: AUDIENCE }, + ); + const url = await listen((req, res) => { + (req as unknown as { originalUrl: string }).originalUrl = "/mcp"; + req.url = "/"; + return handler(req, res); + }); + await fetch(url); + + expect(seenUrl).toBe(ENDPOINT); + }); + + it("forwards repeated headers and streams bodies", async () => { + const url = await listen( + toNodeHandler( + async (request) => { + const headers = new Headers({ "x-seen": request.headers.get("x-multi") ?? "" }); + return new Response("hello", { status: 200, headers }); + }, + { resource: AUDIENCE }, + ), + ); + const response = await fetch(url, { headers: { "x-multi": "a" } }); + + expect(response.headers.get("x-seen")).toBe("a"); + expect(await response.text()).toBe("hello"); + }); +}); + +describe("asTokenVerifier", () => { + interface FakeRes { + statusCode?: number; + headers: Record; + body?: unknown; + set(name: string, value: string): FakeRes; + status(code: number): FakeRes; + json(value: unknown): FakeRes; + } + + async function run(authorization: string | undefined, client: McpClient, requiredScopes?: string[]) { + const res: FakeRes = { + headers: {}, + set(name, value) { + this.headers[name] = value; + return this; + }, + status(code) { + this.statusCode = code; + return this; + }, + json(value) { + this.body = value; + return this; + }, + }; + const req = { headers: authorization === undefined ? {} : { authorization } } as { + headers: Record; + auth?: SdkAuthInfo; + }; + const next = vi.fn(); + const middleware = requireBearerAuth({ + verifier: asTokenVerifier(client), + requiredScopes, + }); + await middleware(req as never, res as never, next); + return { req, res, next }; + } + + it("accepts a valid token and exposes the caller as AuthInfo", async () => { + useTenant(); + const { req, res, next } = await run(`Bearer ${await token()}`, makeClient()); + + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBeUndefined(); + expect(req.auth).toMatchObject({ + clientId: "client-a", + scopes: ["read", "write"], + extra: { sub: "user|1" }, + }); + expect(req.auth!.expiresAt).toBeGreaterThan(Date.now() / 1000); + }); + + it("carries the organization when the token has one", async () => { + useTenant(); + const bearer = await token("read", { claims: { client_id: "c", org_id: "org_1" } }); + const { req } = await run(`Bearer ${bearer}`, makeClient()); + + expect(req.auth!.extra).toMatchObject({ orgId: "org_1" }); + }); + + it("maps an invalid token to the SDK invalid_token response with a generic message", async () => { + useTenant(); + const { res, next } = await run("Bearer garbage.token.value", makeClient()); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(401); + expect(res.body).toMatchObject({ + error: "invalid_token", + error_description: "Invalid access token.", + }); + }); + + it("maps an expired token to invalid_token", async () => { + useTenant(); + const expired = await token("read", { exp: Math.floor(Date.now() / 1000) - 3600 }); + const { res } = await run(`Bearer ${expired}`, makeClient()); + + expect(res.statusCode).toBe(401); + expect(res.body).toMatchObject({ error: "invalid_token" }); + }); + + it("maps a missing server-wide scope to insufficient_scope", async () => { + useTenant(); + const { res, next } = await run(`Bearer ${await token("read")}`, makeClient(), ["admin"]); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(res.body).toMatchObject({ error: "insufficient_scope" }); + }); + + it("does not pass the original verifier message through", async () => { + const client = makeClient(); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue( + new VerifyAccessTokenError("leaky internal detail"), + ); + const verifier = asTokenVerifier(client); + + const error = await verifier.verifyAccessToken("t").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SdkInvalidTokenError); + expect((error as Error).message).toBe("Invalid access token."); + }); + + it("maps a core 403 to the SDK insufficient scope error", async () => { + const client = makeClient(); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue( + new AuthError("detail", "insufficient_scope", 403), + ); + + const error = await asTokenVerifier(client) + .verifyAccessToken("t") + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SdkInsufficientScopeError); + expect((error as Error).message).toBe("Insufficient scope."); + }); + + it("maps a core error without a status to invalid_token", async () => { + const client = makeClient(); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue(new AuthError("x", "x")); + + const error = await asTokenVerifier(client) + .verifyAccessToken("t") + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SdkInvalidTokenError); + }); + + it("rethrows server-side failures so the middleware answers 500", async () => { + const client = makeClient(); + const failure = new AuthError("down", "server_error", 503); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue(failure); + const unexpected = new InvalidConfigurationError("x"); + const verifier = asTokenVerifier(client); + + await expect(verifier.verifyAccessToken("t")).rejects.toBe(failure); + + vi.spyOn(client, "verifyAccessToken").mockRejectedValue(unexpected); + await expect(verifier.verifyAccessToken("t")).rejects.toBe(unexpected); + }); + + it("maps a non-core error thrown while verifying to a rethrow", async () => { + const client = makeClient(); + const boom = new TypeError("boom"); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue(boom); + + await expect(asTokenVerifier(client).verifyAccessToken("t")).rejects.toBe(boom); + }); + + it("answers a verifier failure that is not an auth error with a generic 500 body", async () => { + const client = makeClient(); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue(new TypeError("db password leaked")); + const { res, next } = await run("Bearer abc", client); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(500); + expect(JSON.stringify(res.body)).not.toContain("leaked"); + expect(res.body).toMatchObject({ error: "server_error" }); + }); + + it("answers a core 5xx with a generic 500 body", async () => { + const client = makeClient(); + vi.spyOn(client, "verifyAccessToken").mockRejectedValue(new AuthError("jwks host down", "x", 503)); + const { res } = await run("Bearer abc", client); + + expect(res.statusCode).toBe(500); + expect(JSON.stringify(res.body)).not.toContain("jwks"); + }); + + it("leaves the request context empty inside requireBearerAuth routes", async () => { + useTenant(); + await run(`Bearer ${await token()}`, makeClient()); + + expect(getCallerPrincipal()).toBeUndefined(); + }); +}); diff --git a/packages/ai-mcp/test/smoke/module-formats.spec.ts b/packages/ai-mcp/test/smoke/module-formats.spec.ts index f74ad0be..3e8369d1 100644 --- a/packages/ai-mcp/test/smoke/module-formats.spec.ts +++ b/packages/ai-mcp/test/smoke/module-formats.spec.ts @@ -38,6 +38,13 @@ const expectedValues = [ "createEncryptedTokenStore", ]; +const mcpSdkValues = [ + "createMcpSdkHandler", + "createNodeMcpHandler", + "toNodeHandler", + "asTokenVerifier", +]; + // Resolves the package by its own name so the exports map is what gets exercised. function run(args: string[]): string { return execFileSync(process.execPath, args, { @@ -75,6 +82,37 @@ describe.skipIf(!built)("built package formats", () => { expect(JSON.parse(out)).toEqual([...expectedValues].sort()); }); + it("resolves @auth0/ai-mcp/mcp-sdk through the exports map in ESM", () => { + const out = run([ + "--input-type=module", + "-e", + "import('@auth0/ai-mcp/mcp-sdk').then((m) => console.log(JSON.stringify(Object.keys(m).sort())))", + ]); + expect(JSON.parse(out)).toEqual(expect.arrayContaining(mcpSdkValues)); + }); + + it("resolves @auth0/ai-mcp/mcp-sdk through the exports map in CommonJS", () => { + const out = run([ + "-e", + "const m = require('@auth0/ai-mcp/mcp-sdk'); console.log(JSON.stringify(Object.keys(m).sort()))", + ]); + expect(JSON.parse(out)).toEqual(expect.arrayContaining(mcpSdkValues)); + }); + + it("does not export the mcp-sdk names from the root entry", () => { + for (const name of mcpSdkValues) { + expect(expectedValues).not.toContain(name); + } + const out = run([ + "-e", + "const m = require('@auth0/ai-mcp'); console.log(JSON.stringify(Object.keys(m)))", + ]); + const keys: string[] = JSON.parse(out); + for (const name of mcpSdkValues) { + expect(keys).not.toContain(name); + } + }); + it("exports constructible error classes from both formats", () => { const esm = run([ "--input-type=module", From 30103178c07b9a033e77a0df6e661e8990ef019c Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:25:25 +0530 Subject: [PATCH 13/31] fix(ai-mcp): harden the Node adapter error and abort paths 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 --- packages/ai-mcp/src/mcp-sdk.ts | 94 ++++++++++++++++++++------- packages/ai-mcp/test/mcp-sdk.spec.ts | 95 +++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 26 deletions(-) diff --git a/packages/ai-mcp/src/mcp-sdk.ts b/packages/ai-mcp/src/mcp-sdk.ts index c9c4a20b..aa074ad2 100644 --- a/packages/ai-mcp/src/mcp-sdk.ts +++ b/packages/ai-mcp/src/mcp-sdk.ts @@ -53,8 +53,9 @@ export interface McpSdkHandlerOptions extends Omit * * Mode: stateless and JSON responses. The server is closed as soon as * `handleRequest` returns, which is only valid for JSON response mode (a streamed - * response would be cut off), so this function does not offer streaming. Each POST is answered with one JSON - * body and no session id is issued, so any instance can serve any request. + * response would be cut off), so this function does not offer + * streaming. Each POST is answered with one JSON body and no session id is + * issued, so any instance can serve any request. * Server-to-client notifications and standalone event streams (GET) are not * offered. If you need sessions or streaming, use {@link createMcpHandler} and * manage the transport yourself. @@ -114,6 +115,8 @@ function genericResponse( res.destroy(); return; } + // Drop anything an earlier step set (for example headers of a failed upstream response). + for (const name of res.getHeaderNames()) res.removeHeader(name); res.statusCode = status; if (close) res.setHeader('connection', 'close'); res.setHeader('content-type', 'application/json'); @@ -126,28 +129,58 @@ class MalformedRequestError extends Error {} const VALID_PATH = /^\/(?![/\\])[^\s]*$/; -async function readBody(req: IncomingMessage, limit: number): Promise { +function readBody(req: IncomingMessage, limit: number): Promise { const declared = req.headers['content-length']; if (declared !== undefined) { - if (!/^\d{1,15}$/.test(declared)) throw new MalformedRequestError(); - if (Number(declared) > limit) throw new BodyTooLargeError(); + if (!/^\d{1,15}$/.test(declared)) return Promise.reject(new MalformedRequestError()); + if (Number(declared) > limit) return Promise.reject(new BodyTooLargeError()); } - const chunks: Uint8Array[] = []; - let size = 0; - for await (const chunk of req) { - const bytes: Uint8Array = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk; - size += bytes.byteLength; - if (size > limit) throw new BodyTooLargeError(); - chunks.push(bytes); - } - if (chunks.length === 0) return undefined; - const body = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - return body; + // Event based on purpose: leaving a `for await` loop early destroys the socket, + // which would drop the 413 before it is delivered. + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + let size = 0; + const cleanup = () => { + req.off('data', onData); + req.off('end', onEnd); + req.off('error', onError); + req.off('close', onClose); + }; + const onData = (chunk: Uint8Array | string) => { + const bytes = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk; + size += bytes.byteLength; + if (size > limit) { + cleanup(); + req.pause(); + reject(new BodyTooLargeError()); + return; + } + chunks.push(bytes); + }; + const onEnd = () => { + cleanup(); + if (chunks.length === 0) return resolve(undefined); + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + resolve(body); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onClose = () => { + cleanup(); + reject(new Error('Request closed.')); + }; + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + req.on('close', onClose); + }); } /** @@ -163,7 +196,9 @@ async function readBody(req: IncomingMessage, limit: number): Promise void reader.cancel().catch(() => undefined); + res.once('close', onClose); try { for (;;) { const { done, value } = await reader.read(); @@ -240,6 +287,7 @@ export function toNodeHandler( } res.end(); } finally { + res.off('close', onClose); await reader.cancel().catch(() => undefined); } } catch (error) { diff --git a/packages/ai-mcp/test/mcp-sdk.spec.ts b/packages/ai-mcp/test/mcp-sdk.spec.ts index 9ad4eb2d..b2f9d776 100644 --- a/packages/ai-mcp/test/mcp-sdk.spec.ts +++ b/packages/ai-mcp/test/mcp-sdk.spec.ts @@ -447,7 +447,8 @@ describe("Node adapter", () => { const reply = await rawRequest(url, [ "POST /mcp HTTP/1.1", "Host: x", "Connection: close", `Content-Length: ${length}`, ]); - expect(reply === "" || reply.startsWith("HTTP/1.1 400")).toBe(true); + // Node's own parser may refuse these before the adapter sees them. + expect(reply).not.toContain("HTTP/1.1 200"); } expect(handle).not.toHaveBeenCalled(); }); @@ -518,8 +519,96 @@ describe("Node adapter", () => { "Authorization: Bearer first", "Authorization: Bearer second", ]); - expect(seen).toHaveLength(1); - expect(seen[0]).not.toContain("second"); + expect(seen).toEqual(["Bearer first"]); + }); + + it("joins a string[] header value into repeated header entries", async () => { + const seen: Array = []; + const adapter = toNodeHandler( + async (request) => { + seen.push(request.headers.get("x-multi")); + return new Response("ok"); + }, + { resource: AUDIENCE }, + ); + const url = await listen((req, res) => { + req.headers["x-multi"] = ["a", "b"]; + return adapter(req, res); + }); + await fetch(url); + + expect(seen).toEqual(["a, b"]); + }); + + it("drops the handler's headers when the body stream fails before the first write", async () => { + const url = await listen( + toNodeHandler( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error("boom")); + }, + }), + { + status: 200, + headers: { "set-cookie": "a=b", "www-authenticate": "Bearer x", "content-length": "99" }, + }, + ), + { resource: AUDIENCE }, + ), + ); + const response = await fetch(url); + + expect(response.status).toBe(500); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("www-authenticate")).toBeNull(); + expect(await response.json()).toEqual({ + error: "server_error", + error_description: "Internal server error.", + }); + }); + + it("cancels a stalled stream when the client aborts", async () => { + let cancelled = false; + const url = await listen( + toNodeHandler( + async () => + new Response( + new ReadableStream({ + pull: () => new Promise(() => undefined), + cancel() { + cancelled = true; + }, + }), + ), + { resource: AUDIENCE }, + ), + ); + const controller = new AbortController(); + const pending = fetch(url, { signal: controller.signal }).catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.abort(); + await pending; + await vi.waitFor(() => expect(cancelled).toBe(true)); + }); + + it("treats an empty parser-default body as no body", async () => { + const received: Array = []; + const adapter = toNodeHandler( + async (request) => { + received.push(request.body === null ? null : await request.text()); + return new Response("ok"); + }, + { resource: AUDIENCE }, + ); + const url = await listen((req, res) => { + (req as unknown as { body: unknown }).body = {}; + return adapter(req, res); + }); + await fetch(url, { method: "POST" }); + + expect(received).toEqual([null]); }); it("handles a pre-parsed Buffer, Uint8Array and string body and limits each", async () => { From 6e454d1e26baa6045a75b7194ca0d0b030e28bc1 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:28:11 +0530 Subject: [PATCH 14/31] docs(examples): add an MCP server example that delegates to a verified 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 --- examples/ai-mcp-server/.env.example | 28 ++ examples/ai-mcp-server/README.md | 42 +++ examples/ai-mcp-server/eslint.config.mjs | 11 + examples/ai-mcp-server/package.json | 36 +++ examples/ai-mcp-server/src/config.ts | 89 ++++++ examples/ai-mcp-server/src/downstream-api.ts | 106 +++++++ .../ai-mcp-server/src/downstream-server.ts | 7 + examples/ai-mcp-server/src/mcp-app.ts | 109 +++++++ examples/ai-mcp-server/src/server.ts | 9 + examples/ai-mcp-server/test/smoke.spec.ts | 270 ++++++++++++++++++ examples/ai-mcp-server/tsconfig.build.json | 5 + examples/ai-mcp-server/tsconfig.json | 17 ++ examples/ai-mcp-server/vitest.config.ts | 8 + 13 files changed, 737 insertions(+) create mode 100644 examples/ai-mcp-server/.env.example create mode 100644 examples/ai-mcp-server/README.md create mode 100644 examples/ai-mcp-server/eslint.config.mjs create mode 100644 examples/ai-mcp-server/package.json create mode 100644 examples/ai-mcp-server/src/config.ts create mode 100644 examples/ai-mcp-server/src/downstream-api.ts create mode 100644 examples/ai-mcp-server/src/downstream-server.ts create mode 100644 examples/ai-mcp-server/src/mcp-app.ts create mode 100644 examples/ai-mcp-server/src/server.ts create mode 100644 examples/ai-mcp-server/test/smoke.spec.ts create mode 100644 examples/ai-mcp-server/tsconfig.build.json create mode 100644 examples/ai-mcp-server/tsconfig.json create mode 100644 examples/ai-mcp-server/vitest.config.ts diff --git a/examples/ai-mcp-server/.env.example b/examples/ai-mcp-server/.env.example new file mode 100644 index 00000000..2a04aa43 --- /dev/null +++ b/examples/ai-mcp-server/.env.example @@ -0,0 +1,28 @@ +# Auth0 tenant domain, without scheme. +AUTH0_DOMAIN=your-tenant.us.auth0.com + +# Public URL of this server. It is also the identifier of the API that represents +# it: access tokens sent to the server must be issued for it. +MCP_SERVER_URL=http://localhost:3001/mcp + +# Application that performs the on-behalf-of token exchange. +AUTH0_CLIENT_ID=your-client-id +AUTH0_CLIENT_SECRET=your-client-secret + +# Scopes every request to the MCP server must carry (space separated). Optional. +MCP_REQUIRED_SCOPES= + +# Scope a caller needs to use the "admin_summary" tool. +MCP_ADMIN_SCOPE=read:admin + +# Downstream API: identifier (audience), base URL, scope requested for it, listen port. +DOWNSTREAM_AUDIENCE=https://reports.example.com +DOWNSTREAM_URL=http://localhost:3002 +DOWNSTREAM_SCOPE=read:reports +DOWNSTREAM_REQUIRED_SCOPE=read:reports +# Longest wait for the downstream API, in milliseconds. Optional, defaults to 5000. +DOWNSTREAM_TIMEOUT_MS=5000 + +# Listen ports. +MCP_PORT=3001 +DOWNSTREAM_PORT=3002 diff --git a/examples/ai-mcp-server/README.md b/examples/ai-mcp-server/README.md new file mode 100644 index 00000000..6626e5f9 --- /dev/null +++ b/examples/ai-mcp-server/README.md @@ -0,0 +1,42 @@ +# MCP server with Auth0 token delegation + +A small MCP server built with `@auth0/ai-mcp` and the MCP TypeScript SDK. It checks the access token on every request, then calls a downstream API as the signed-in user through an on-behalf-of token exchange. A second small server plays the downstream API. + +## What it shows + +- `src/mcp-app.ts` wires `createNodeMcpHandler` to an `McpServer` over Streamable HTTP. Requests without a valid token are answered with a `WWW-Authenticate` challenge before any tool runs. The server also publishes its protected resource metadata (RFC 9728). +- `list_reports` exchanges the caller's token for a token to the downstream API with the request-scoped client, then calls the API with it. It sends only the exchanged token, whose audience is `DOWNSTREAM_AUDIENCE`, and never the caller's own token. Failures come back as a fixed error message, and the call stops after `DOWNSTREAM_TIMEOUT_MS`. +- `admin_summary` is wrapped in `withRequiredScopes`. A caller without the admin scope gets an error result for that tool only (HTTP 200); the rest of the server stays available. +- `src/downstream-api.ts` verifies every token it receives (signature from the tenant JWKS, issuer, audience, expiry and scope) before it returns data. + +## Auth0 setup + +Create these in your tenant: + +1. An API for the MCP server. Its identifier is the public URL of the server (`MCP_SERVER_URL`). Add the scope you use for `MCP_ADMIN_SCOPE`. +2. An API for the downstream service, with the identifier you set as `DOWNSTREAM_AUDIENCE` and a scope matching `DOWNSTREAM_SCOPE`. +3. An application allowed to perform on-behalf-of token exchange for the downstream API. Its client id and secret go in `AUTH0_CLIENT_ID` and `AUTH0_CLIENT_SECRET`. Authorize it for the downstream API and its scope. Enabling on-behalf-of exchange on a tenant may require Auth0 support; see the Auth0 documentation for token exchange. +4. A client application that signs users in and requests an access token for the MCP server API. + +## Run + +```sh +cp .env.example .env # fill in the values +npm run start:api # downstream API +npm run start # MCP server, in a second terminal +``` + +Point an MCP client at `MCP_SERVER_URL` and send the user's access token as a bearer token. + +The `.env` file is read with Node's `--env-file` support, so no extra package is needed. + +## Check + +```sh +npm run type-check +npm run lint +npm run build +npm test +``` + +`npm test` runs both servers on loopback with a mocked Auth0 tenant. It makes no network calls. diff --git a/examples/ai-mcp-server/eslint.config.mjs b/examples/ai-mcp-server/eslint.config.mjs new file mode 100644 index 00000000..00e9c2ee --- /dev/null +++ b/examples/ai-mcp-server/eslint.config.mjs @@ -0,0 +1,11 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { ignores: ["dist/**", "eslint.config.mjs"] }, + { languageOptions: { globals: globals.node } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, +]; diff --git a/examples/ai-mcp-server/package.json b/examples/ai-mcp-server/package.json new file mode 100644 index 00000000..7c29425b --- /dev/null +++ b/examples/ai-mcp-server/package.json @@ -0,0 +1,36 @@ +{ + "name": "@auth0/auth0-ai-js-examples-ai-mcp-server", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "MCP server that authenticates callers with Auth0 and calls a downstream API on their behalf", + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "start": "tsx --env-file=.env src/server.ts", + "start:api": "tsx --env-file=.env src/downstream-server.ts", + "lint": "eslint .", + "type-check": "tsc --noEmit", + "test": "vitest run" + }, + "author": { + "name": "Auth0 Inc.", + "url": "https://auth0.com" + }, + "license": "Apache-2.0", + "dependencies": { + "@auth0/ai-mcp": "*", + "@modelcontextprotocol/sdk": "^1.31.0", + "jose": "^6.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@types/node": "^22.10.6", + "eslint": "^9.22.0", + "globals": "^15.11.0", + "msw": "^2.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", + "vitest": "^3.0.9" + } +} diff --git a/examples/ai-mcp-server/src/config.ts b/examples/ai-mcp-server/src/config.ts new file mode 100644 index 00000000..045c9960 --- /dev/null +++ b/examples/ai-mcp-server/src/config.ts @@ -0,0 +1,89 @@ +/** Reads settings from environment variables. Nothing is hard-coded. */ + +function required(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]?.trim(); + if (!value) { + throw new Error(`Missing environment variable ${name}. See .env.example.`); + } + return value; +} + +function list(value: string | undefined): string[] { + return (value ?? "").split(/\s+/).filter((entry) => entry.length > 0); +} + +function port(env: NodeJS.ProcessEnv, name: string, fallback: number): number { + const raw = env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { + throw new Error(`Environment variable ${name} must be a port number.`); + } + return parsed; +} + +function positiveInt( + env: NodeJS.ProcessEnv, + name: string, + fallback: number, +): number { + const raw = env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`Environment variable ${name} must be a positive integer.`); + } + return parsed; +} + +export interface McpServerConfig { + domain: string; + /** Public URL of the MCP endpoint. Also the audience of access tokens sent to it. */ + serverUrl: string; + clientId: string; + clientSecret: string; + requiredScopes: string[]; + adminScope: string; + downstreamAudience: string; + downstreamUrl: string; + downstreamScope: string; + /** Longest wait for the downstream API, in milliseconds. */ + downstreamTimeoutMs: number; + port: number; +} + +export interface DownstreamConfig { + domain: string; + audience: string; + requiredScope: string; + port: number; +} + +export function loadMcpServerConfig( + env: NodeJS.ProcessEnv = process.env, +): McpServerConfig { + return { + domain: required(env, "AUTH0_DOMAIN"), + serverUrl: required(env, "MCP_SERVER_URL"), + clientId: required(env, "AUTH0_CLIENT_ID"), + clientSecret: required(env, "AUTH0_CLIENT_SECRET"), + requiredScopes: list(env["MCP_REQUIRED_SCOPES"]), + adminScope: required(env, "MCP_ADMIN_SCOPE"), + downstreamAudience: required(env, "DOWNSTREAM_AUDIENCE"), + downstreamUrl: required(env, "DOWNSTREAM_URL"), + downstreamScope: required(env, "DOWNSTREAM_SCOPE"), + downstreamTimeoutMs: positiveInt(env, "DOWNSTREAM_TIMEOUT_MS", 5000), + port: port(env, "MCP_PORT", 3001), + }; +} + +export function loadDownstreamConfig( + env: NodeJS.ProcessEnv = process.env, +): DownstreamConfig { + return { + domain: required(env, "AUTH0_DOMAIN"), + audience: required(env, "DOWNSTREAM_AUDIENCE"), + requiredScope: required(env, "DOWNSTREAM_REQUIRED_SCOPE"), + port: port(env, "DOWNSTREAM_PORT", 3002), + }; +} diff --git a/examples/ai-mcp-server/src/downstream-api.ts b/examples/ai-mcp-server/src/downstream-api.ts new file mode 100644 index 00000000..79af23e8 --- /dev/null +++ b/examples/ai-mcp-server/src/downstream-api.ts @@ -0,0 +1,106 @@ +import { createServer } from "node:http"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import type { JWTVerifyGetKey } from "jose"; +import type { DownstreamConfig } from "./config.js"; + +/** + * A small resource server. It verifies every token before it answers: signature + * (from the tenant's JWKS), issuer, audience, expiry and scope. A request that + * fails any check never reaches the data. + */ + +const REPORTS = [ + { id: "r-1", title: "Quarterly summary" }, + { id: "r-2", title: "Usage by region" }, +]; + +function send( + res: ServerResponse, + status: number, + body: unknown, + headers: Record = {}, +): void { + res.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + ...headers, + }); + res.end(JSON.stringify(body)); +} + +function bearerToken(req: IncomingMessage): string | undefined { + const match = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/.exec( + req.headers.authorization ?? "", + ); + return match?.[1]; +} + +export function createDownstreamListener( + config: DownstreamConfig, + keys?: JWTVerifyGetKey, +): (req: IncomingMessage, res: ServerResponse) => Promise { + const issuer = `https://${config.domain}/`; + const getKey = + keys ?? createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`)); + + return async (req, res) => { + const token = bearerToken(req); + if (token === undefined) { + send( + res, + 401, + { error: "invalid_token" }, + { "www-authenticate": "Bearer" }, + ); + return; + } + let scopes: string[]; + try { + const { payload } = await jwtVerify(token, getKey, { + issuer, + audience: config.audience, + algorithms: ["RS256"], + requiredClaims: ["exp", "sub"], + }); + scopes = + typeof payload["scope"] === "string" + ? payload["scope"].split(/\s+/) + : []; + } catch { + send( + res, + 401, + { error: "invalid_token" }, + { "www-authenticate": 'Bearer error="invalid_token"' }, + ); + return; + } + if (!scopes.includes(config.requiredScope)) { + send( + res, + 403, + { error: "insufficient_scope" }, + { + "www-authenticate": `Bearer error="insufficient_scope", scope="${config.requiredScope}"`, + }, + ); + return; + } + if (new URL(req.url ?? "/", "http://localhost").pathname !== "/reports") { + send(res, 404, { error: "not_found" }); + return; + } + send(res, 200, { reports: REPORTS }); + }; +} + +export function createDownstreamServer( + config: DownstreamConfig, + keys?: JWTVerifyGetKey, +): Server { + const listener = createDownstreamListener(config, keys); + return createServer((req, res) => { + listener(req, res).catch(() => send(res, 500, { error: "server_error" })); + }); +} diff --git a/examples/ai-mcp-server/src/downstream-server.ts b/examples/ai-mcp-server/src/downstream-server.ts new file mode 100644 index 00000000..3eabd91a --- /dev/null +++ b/examples/ai-mcp-server/src/downstream-server.ts @@ -0,0 +1,7 @@ +import { loadDownstreamConfig } from "./config.js"; +import { createDownstreamServer } from "./downstream-api.js"; + +const config = loadDownstreamConfig(); +createDownstreamServer(config).listen(config.port, () => { + console.log(`Downstream API listening on port ${config.port}`); +}); diff --git a/examples/ai-mcp-server/src/mcp-app.ts b/examples/ai-mcp-server/src/mcp-app.ts new file mode 100644 index 00000000..393c25a6 --- /dev/null +++ b/examples/ai-mcp-server/src/mcp-app.ts @@ -0,0 +1,109 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + DownscopedTokenError, + InsufficientScopeError, + MemoryIndexedTokenStore, + createMcpClient, + getCallerMcpClientOrThrow, + withRequiredScopes, +} from "@auth0/ai-mcp"; +import { createNodeMcpHandler } from "@auth0/ai-mcp/mcp-sdk"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServerConfig } from "./config.js"; + +function failure(message: string) { + return { ...text(message), isError: true as const }; +} + +function text(value: string) { + return { content: [{ type: "text" as const, text: value }] }; +} + +/** Builds the MCP server for one request. Tools read the caller from the request context. */ +function buildServer(config: McpServerConfig): McpServer { + const server = new McpServer({ name: "reports-example", version: "0.1.0" }); + + // Tool 1: calls the downstream API with a token obtained by on-behalf-of + // exchange. The request-scoped client always acts for the caller of this request. + server.registerTool( + "list_reports", + { + description: + "Lists reports from the downstream API, using the caller's identity.", + }, + async () => { + try { + const exchanged = await getCallerMcpClientOrThrow().getTokenOnBehalfOf({ + audience: config.downstreamAudience, + scope: config.downstreamScope, + }); + const response = await fetch( + new URL("/reports", config.downstreamUrl), + { + headers: { authorization: `Bearer ${exchanged.accessToken}` }, + signal: AbortSignal.timeout(config.downstreamTimeoutMs), + }, + ); + if (!response.ok) { + return failure(`The downstream API answered ${response.status}.`); + } + return text(JSON.stringify(await response.json())); + } catch (error) { + // Log the error name only. Messages can carry details that must not reach logs or callers. + console.error( + "list_reports failed", + error instanceof Error ? error.name : "unknown", + ); + if ( + error instanceof DownscopedTokenError || + error instanceof InsufficientScopeError + ) { + return failure( + "The downstream API did not grant the required access.", + ); + } + return failure("Could not reach the downstream API."); + } + }, + ); + + // Tool 2: only for callers whose token carries the admin scope. A caller + // without it gets an error result for this tool; the rest of the server works. + server.registerTool( + "admin_summary", + { description: "Summary for administrators. Needs the admin scope." }, + withRequiredScopes(config.adminScope, async () => + text("Administrator summary: all systems normal."), + ), + ); + + return server; +} + +/** Returns a Node request listener that authenticates and serves MCP requests. */ +export function createMcpApp( + config: McpServerConfig, +): (req: IncomingMessage, res: ServerResponse) => Promise { + const client = createMcpClient({ + domain: config.domain, + audience: config.serverUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, + // Reuses exchanged tokens for repeat calls by the same caller. + tokenStore: new MemoryIndexedTokenStore(), + }); + + return createNodeMcpHandler({ + client, + resource: config.serverUrl, + authorizationServers: [`https://${config.domain}/`], + requiredScopes: config.requiredScopes, + metadata: { resourceName: "Reports example" }, + onError: (error) => + console.error( + "request failed", + error instanceof Error ? error.name : "unknown", + ), + createServer: () => buildServer(config), + }); +} diff --git a/examples/ai-mcp-server/src/server.ts b/examples/ai-mcp-server/src/server.ts new file mode 100644 index 00000000..5bc6fef9 --- /dev/null +++ b/examples/ai-mcp-server/src/server.ts @@ -0,0 +1,9 @@ +import { createServer } from "node:http"; +import { loadMcpServerConfig } from "./config.js"; +import { createMcpApp } from "./mcp-app.js"; + +const config = loadMcpServerConfig(); +const app = createMcpApp(config); +createServer((req, res) => void app(req, res)).listen(config.port, () => { + console.log(`MCP server listening on port ${config.port}`); +}); diff --git a/examples/ai-mcp-server/test/smoke.spec.ts b/examples/ai-mcp-server/test/smoke.spec.ts new file mode 100644 index 00000000..03790ed3 --- /dev/null +++ b/examples/ai-mcp-server/test/smoke.spec.ts @@ -0,0 +1,270 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createServer } from "node:http"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { setupServer } from "msw/node"; +import { SignJWT, createLocalJWKSet } from "jose"; +import { + createAuth0Handlers, + createTestKey, + exportJwks, + mintAccessToken, + mintUnsignedToken, + scopeMode, +} from "../../../packages/ai-mcp/test/support/index.js"; +import type { TestKey } from "../../../packages/ai-mcp/test/support/index.js"; +import type { DownstreamConfig, McpServerConfig } from "../src/config.js"; +import { createDownstreamServer } from "../src/downstream-api.js"; +import { createMcpApp } from "../src/mcp-app.js"; + +// Offline: Auth0 is replaced by MSW handlers; both servers listen on loopback. +const MCP_URL = "https://mcp.example.com/mcp"; +const DOWNSTREAM_AUDIENCE = "https://reports.example.com"; + +let key: TestKey; +let tenant: ReturnType; +let downstream: Server; +let mcp: Server; +let mcpPort: number; +let downstreamPort: number; + +const msw = setupServer(); + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => + resolve((server.address() as AddressInfo).port), + ); + }); +} + +beforeAll(async () => { + key = await createTestKey(); + tenant = createAuth0Handlers({ key, scope: scopeMode.echo }); + msw.listen({ + onUnhandledRequest(request, print) { + if (new URL(request.url).hostname === "127.0.0.1") return; + print.error(); + }, + }); + // The handlers come from the package test support, which may resolve its own copy of msw. + msw.use(...(tenant.handlers as unknown as Parameters)); + + const downstreamConfig: DownstreamConfig = { + domain: "tenant.example.com", + audience: DOWNSTREAM_AUDIENCE, + requiredScope: "read:reports", + port: 0, + }; + downstream = createDownstreamServer( + downstreamConfig, + createLocalJWKSet( + exportJwks(key) as Parameters[0], + ), + ); + downstreamPort = await listen(downstream); + + const mcpConfig: McpServerConfig = { + domain: "tenant.example.com", + serverUrl: MCP_URL, + clientId: "mcp-client", + clientSecret: "secret", + requiredScopes: [], + adminScope: "read:admin", + downstreamAudience: DOWNSTREAM_AUDIENCE, + downstreamUrl: `http://127.0.0.1:${downstreamPort}`, + downstreamScope: "read:reports", + downstreamTimeoutMs: 2000, + port: 0, + }; + const app = createMcpApp(mcpConfig); + mcp = createServer((req, res) => void app(req, res)); + mcpPort = await listen(mcp); +}); + +afterEach(() => tenant.state.reset()); +afterAll(async () => { + msw.close(); + await Promise.all( + [mcp, downstream].map((s) => new Promise((resolve) => s.close(resolve))), + ); +}); + +function callerToken(scope: string, sub = "user|1") { + return mintAccessToken(key, { + iss: tenant.issuer, + aud: MCP_URL, + sub, + scope, + claims: { client_id: "client-a" }, + }); +} + +async function callTool(name: string, token?: string) { + return fetch(`http://127.0.0.1:${mcpPort}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + ...(token && { authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: {} }, + }), + }); +} + +interface ToolResult { + result?: { isError?: boolean; content: Array<{ text: string }> }; +} + +describe("example MCP server", () => { + it("rejects a request without a credential", async () => { + const response = await callTool("list_reports"); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toMatch(/^Bearer /); + }); + + it("lists reports from the downstream API with an exchanged token", async () => { + const response = await callTool( + "list_reports", + await callerToken("openid"), + ); + const body = (await response.json()) as ToolResult; + + expect(response.status).toBe(200); + expect(body.result?.isError).toBeFalsy(); + expect(JSON.parse(body.result!.content[0]!.text)).toMatchObject({ + reports: [{ id: "r-1" }, { id: "r-2" }], + }); + expect(tenant.state.successfulExchangeCount).toBe(1); + expect(tenant.state.lastExchangeRequest?.params["audience"]).toBe( + DOWNSTREAM_AUDIENCE, + ); + }); + + it("never returns exchange error text in the tool result", async () => { + tenant.state.failure = { + status: 400, + body: { + error: "invalid_grant", + error_description: "SECRET-INTERNAL-DETAIL tenant.example.com", + }, + }; + // A caller no earlier test used, so a cached exchange cannot hide the failure. + const response = await callTool( + "list_reports", + await callerToken("openid", "user|failing"), + ); + const raw = await response.text(); + const body = JSON.parse(raw) as ToolResult; + + expect(body.result?.isError).toBe(true); + expect(body.result?.content[0]?.text).toBe( + "Could not reach the downstream API.", + ); + expect(raw).not.toContain("SECRET-INTERNAL-DETAIL"); + expect(raw).not.toContain("invalid_grant"); + }); + + it("denies the admin tool per tool, with HTTP 200 and an error result", async () => { + const response = await callTool( + "admin_summary", + await callerToken("openid"), + ); + const body = (await response.json()) as ToolResult; + + expect(response.status).toBe(200); + expect(body.result?.isError).toBe(true); + }); + + it("runs the admin tool for a caller with the admin scope", async () => { + const response = await callTool( + "admin_summary", + await callerToken("openid read:admin"), + ); + const body = (await response.json()) as ToolResult; + + expect(body.result?.isError).toBeFalsy(); + expect(body.result?.content[0]?.text).toContain("Administrator summary"); + }); +}); + +describe("example downstream API", () => { + function get(token?: string) { + return fetch(`http://127.0.0.1:${downstreamPort}/reports`, { + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); + } + + it("answers 401 without a token", async () => { + expect((await get()).status).toBe(401); + }); + + it("answers 401 for a token issued for another audience", async () => { + const token = await mintAccessToken(key, { + iss: tenant.issuer, + aud: "https://other.example.com", + sub: "user|1", + scope: "read:reports", + }); + expect((await get(token)).status).toBe(401); + }); + + it("answers 403 when the scope is missing", async () => { + const token = await mintAccessToken(key, { + iss: tenant.issuer, + aud: DOWNSTREAM_AUDIENCE, + sub: "user|1", + scope: "other:scope", + }); + expect((await get(token)).status).toBe(403); + }); + + it("answers 200 for a valid token", async () => { + const token = await mintAccessToken(key, { + iss: tenant.issuer, + aud: DOWNSTREAM_AUDIENCE, + sub: "user|1", + scope: "read:reports", + }); + expect((await get(token)).status).toBe(200); + }); + + it("answers 401 for an unsigned alg:none token", async () => { + const token = mintUnsignedToken({ + iss: tenant.issuer, + aud: DOWNSTREAM_AUDIENCE, + sub: "user|1", + scope: "read:reports", + exp: Math.floor(Date.now() / 1000) + 3600, + }); + expect((await get(token)).status).toBe(401); + }); + + it("answers 401 for an HS256 token signed with the public key material", async () => { + const secret = new TextEncoder().encode(JSON.stringify(key.publicJwk)); + const token = await new SignJWT({ scope: "read:reports" }) + .setProtectedHeader({ alg: "HS256", kid: key.kid }) + .setIssuer(tenant.issuer) + .setAudience(DOWNSTREAM_AUDIENCE) + .setSubject("user|1") + .setExpirationTime("1h") + .sign(secret); + expect((await get(token)).status).toBe(401); + }); + + it("answers 401 for an expired token", async () => { + const token = await mintAccessToken(key, { + iss: tenant.issuer, + aud: DOWNSTREAM_AUDIENCE, + sub: "user|1", + scope: "read:reports", + exp: Math.floor(Date.now() / 1000) - 3600, + }); + expect((await get(token)).status).toBe(401); + }); +}); diff --git a/examples/ai-mcp-server/tsconfig.build.json b/examples/ai-mcp-server/tsconfig.build.json new file mode 100644 index 00000000..ed1a9c80 --- /dev/null +++ b/examples/ai-mcp-server/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["test/**/*", "node_modules", "dist"] +} diff --git a/examples/ai-mcp-server/tsconfig.json b/examples/ai-mcp-server/tsconfig.json new file mode 100644 index 00000000..82d8d87a --- /dev/null +++ b/examples/ai-mcp-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "dom"], + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/ai-mcp-server/vitest.config.ts b/examples/ai-mcp-server/vitest.config.ts new file mode 100644 index 00000000..7efbfe46 --- /dev/null +++ b/examples/ai-mcp-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.spec.ts"], + }, +}); From 6088379c7fa1e72164f8f34d9f94383f44641ad4 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:33:35 +0530 Subject: [PATCH 15/31] test(ai-mcp): add integration suites over the real MCP SDK transport 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 --- .../test/integration/clock-skew.spec.ts | 104 ++++ .../integration/context-concurrency.spec.ts | 127 +++++ .../downstream-verification.spec.ts | 82 +++ packages/ai-mcp/test/integration/harness.ts | 475 ++++++++++++++++++ .../ai-mcp/test/integration/isolation.spec.ts | 207 ++++++++ .../test/integration/obo-stampede.spec.ts | 114 +++++ .../integration/organization-policy.spec.ts | 98 ++++ .../test/integration/scope-denial.spec.ts | 86 ++++ .../test/integration/session-slot.spec.ts | 134 +++++ .../test/integration/transport-bearer.spec.ts | 175 +++++++ 10 files changed, 1602 insertions(+) create mode 100644 packages/ai-mcp/test/integration/clock-skew.spec.ts create mode 100644 packages/ai-mcp/test/integration/context-concurrency.spec.ts create mode 100644 packages/ai-mcp/test/integration/downstream-verification.spec.ts create mode 100644 packages/ai-mcp/test/integration/harness.ts create mode 100644 packages/ai-mcp/test/integration/isolation.spec.ts create mode 100644 packages/ai-mcp/test/integration/obo-stampede.spec.ts create mode 100644 packages/ai-mcp/test/integration/organization-policy.spec.ts create mode 100644 packages/ai-mcp/test/integration/scope-denial.spec.ts create mode 100644 packages/ai-mcp/test/integration/session-slot.spec.ts create mode 100644 packages/ai-mcp/test/integration/transport-bearer.spec.ts diff --git a/packages/ai-mcp/test/integration/clock-skew.spec.ts b/packages/ai-mcp/test/integration/clock-skew.spec.ts new file mode 100644 index 00000000..786374ba --- /dev/null +++ b/packages/ai-mcp/test/integration/clock-skew.spec.ts @@ -0,0 +1,104 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryIndexedTokenStore } from "../../src/stores/memory-indexed-store.js"; +import { + WIRINGS, + buildToolServer, + connectClient, + createRig, + delegateOnce, + makeMcpClient, + startApp, +} from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +const LEEWAY = 5; +const DAY = 86_400; +const START = new Date("2030-01-01T00:00:00Z"); + +// Only `Date` is faked, so sockets, timers and promises behave normally while every expiry +// calculation reads a fixed, controllable clock. +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(START); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +const nowSeconds = () => Math.floor(Date.now() / 1000); + +describe.each(WIRINGS)("expiry handling (%s wiring)", (wiring) => { + async function serve() { + const { tenant } = rig(); + rig().useTenant(); + return startApp(wiring, tenant, { + client: makeMcpClient(tenant, { tokenStore: new MemoryIndexedTokenStore() }), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }), + }); + } + + async function exchangesAfter(expiresIn: number, advanceSeconds: number): Promise { + vi.setSystemTime(START); + const app = await serve(); + try { + rig().tenant.auth0.state.expiresIn = expiresIn; + const bearer = await rig().token({ expiresIn: 2 * DAY }); + await delegateOnce(app, bearer); + vi.setSystemTime(new Date(START.getTime() + advanceSeconds * 1000)); + await delegateOnce(app, bearer); + return rig().tenant.auth0.state.successfulExchangeCount; + } finally { + await app.stop(); + } + } + + it("reuses a cached token that is comfortably valid", async () => { + expect(await exchangesAfter(3600, 0)).toBe(1); + }); + + it("reuses a cached token one second outside the leeway", async () => { + expect(await exchangesAfter(LEEWAY + 1, 0)).toBe(1); + }); + + it("does not reuse a cached token one second inside the leeway", async () => { + expect(await exchangesAfter(LEEWAY - 1, 0)).toBe(2); + }); + + it("does not reuse a cached token exactly at the leeway", async () => { + expect(await exchangesAfter(LEEWAY, 0)).toBe(2); + }); + + it("stops reusing a cached token as time passes into its leeway window", async () => { + expect(await exchangesAfter(3600, 3600 - LEEWAY - 1)).toBe(1); + rig().reset(); + expect(await exchangesAfter(3600, 3600 - LEEWAY + 1)).toBe(2); + }); + + it("rejects an expired subject token before any exchange", async () => { + const app = await serve(); + try { + const expired = await rig().token({ exp: nowSeconds() - 120 }); + + await expect(connectClient(app, expired)).rejects.toMatchObject({ code: 401 }); + expect(rig().tenant.auth0.state.exchangeCount).toBe(0); + } finally { + await app.stop(); + } + }); + + it("does not exchange a subject token that is about to expire", async () => { + const app = await serve(); + try { + // Still accepted at ingress, but inside the leeway the exchange treats it as expired. + const soon = await rig().token({ exp: nowSeconds() + LEEWAY - 1, sub: "user|soon" }); + + await expect(delegateOnce(app, soon)).rejects.toThrow(/delegate failed/); + expect(rig().tenant.auth0.state.exchangeCount).toBe(0); + expect(rig().tenant.oracle.calls).toHaveLength(0); + } finally { + await app.stop(); + } + }); +}); diff --git a/packages/ai-mcp/test/integration/context-concurrency.spec.ts b/packages/ai-mcp/test/integration/context-concurrency.spec.ts new file mode 100644 index 00000000..91eb6f20 --- /dev/null +++ b/packages/ai-mcp/test/integration/context-concurrency.spec.ts @@ -0,0 +1,127 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + WIRINGS, + buildToolServer, + callJson, + connectClient, + createBarrier, + createRig, + makeMcpClient, + withApp, +} from "./harness.js"; +import type { ContextProbe, DelegateOutcome } from "./harness.js"; +import { getRequestContext } from "../../src/request-context.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +const CALLERS = 6; + +describe.each(WIRINGS)("request context under concurrency (%s wiring)", (wiring) => { + it("shows every concurrent caller only its own principal and client", async () => { + const { tenant } = rig(); + rig().useTenant(); + const barrier = createBarrier(CALLERS); + await withApp( + wiring, + tenant, + { + client: makeMcpClient(tenant), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl, barrier }), + }, + async (app) => { + const subs = Array.from({ length: CALLERS }, (_, i) => `user|caller-${i}`); + const results = await Promise.all( + subs.map(async (sub) => { + const session = await connectClient(app, await rig().token({ sub })); + try { + return await callJson<{ contextSub: string; clientSub: string }>(session, "whoami"); + } finally { + await session.close(); + } + }), + ); + + expect(results.map((r) => r.value.contextSub)).toEqual(subs); + expect(results.map((r) => r.value.clientSub)).toEqual(subs); + }, + ); + }); + + it("exchanges and forwards each concurrent caller's own token", async () => { + const { tenant } = rig(); + rig().useTenant(); + const barrier = createBarrier(CALLERS); + await withApp( + wiring, + tenant, + { + client: makeMcpClient(tenant), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl, barrier }), + }, + async (app) => { + const subs = Array.from({ length: CALLERS }, (_, i) => `user|caller-${i}`); + const results = await Promise.all( + subs.map(async (sub) => { + const session = await connectClient(app, await rig().token({ sub })); + try { + return await callJson(session, "delegate"); + } finally { + await session.close(); + } + }), + ); + + expect(results.map((r) => r.value.body.sub)).toEqual(subs); + expect(tenant.auth0.state.successfulExchangeCount).toBe(CALLERS); + rig().assertAllForwardedTokensVerified(); + }, + ); + }); + + it("gives a later request, from another caller or none, nothing from an earlier one", async () => { + const { tenant } = rig(); + rig().useTenant(); + const probe: ContextProbe = { captured: [], latch: Promise.resolve(), pending: [] }; + let open!: () => void; + probe.latch = new Promise((resolve) => (open = resolve)); + await withApp( + wiring, + tenant, + { + client: makeMcpClient(tenant), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl, probe }), + }, + async (app) => { + const first = await connectClient(app, await rig().token({ sub: "user|first" })); + try { + await callJson(first, "capture"); + // A continuation that outlives the call must still see its own caller. + await callJson(first, "linger"); + } finally { + await first.close(); + } + + const second = await connectClient(app, await rig().token({ sub: "user|second" })); + try { + const seen = await callJson<{ sub: string; sameObjectAsEarlier: boolean; earlierSubs: string[] }>( + second, + "compare", + ); + expect(seen.value.sub).toBe("user|second"); + expect(seen.value.sameObjectAsEarlier).toBe(false); + expect(seen.value.earlierSubs).toEqual(["user|first"]); + } finally { + await second.close(); + } + + // A request without credentials never reaches a tool and must not disturb anyone's context. + await expect(connectClient(app)).rejects.toMatchObject({ code: 401 }); + + open(); + expect(await Promise.all(probe.pending)).toEqual(["user|first"]); + expect(getRequestContext()).toBeUndefined(); + }, + ); + }); +}); diff --git a/packages/ai-mcp/test/integration/downstream-verification.spec.ts b/packages/ai-mcp/test/integration/downstream-verification.spec.ts new file mode 100644 index 00000000..a7e68046 --- /dev/null +++ b/packages/ai-mcp/test/integration/downstream-verification.spec.ts @@ -0,0 +1,82 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createTestKey, mintAccessToken } from "../support/index.js"; +import { + DOWNSTREAM, + WIRINGS, + buildToolServer, + callJson, + connectClient, + createRig, + makeMcpClient, + withApp, +} from "./harness.js"; +import type { DelegateOutcome } from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +describe.each(WIRINGS)("downstream token verification (%s wiring)", (wiring) => { + async function viaTool(name: string) { + const { tenant } = rig(); + rig().useTenant(); + return withApp( + wiring, + tenant, + { client: makeMcpClient(tenant), createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }) }, + async (app) => { + const session = await connectClient(app, await rig().token({ sub: "user|d" })); + try { + return await callJson(session, name); + } finally { + await session.close(); + } + }, + ); + } + + it("accepts the exchanged token: right issuer, audience, signature and scope", async () => { + const { value } = await viaTool("delegate"); + + expect(value.status).toBe(200); + expect(value.body).toMatchObject({ ok: true, sub: "user|d", scopes: ["api:read"] }); + expect(rig().tenant.oracle.calls.map((c) => c.verdict.ok)).toEqual([true]); + }); + + it("rejects the caller's own inbound token, which is issued for this server and not for the API", async () => { + const { value } = await viaTool("forward_inbound"); + + expect(value.status).toBe(401); + expect(value.body).toMatchObject({ ok: false, reason: "wrong_audience" }); + }); + + it("rejects a token with a forged signature and a token without the required scope", async () => { + const { tenant } = rig(); + rig().useTenant(); + const forged = await mintAccessToken(await createTestKey(), { + iss: tenant.auth0.issuer, + aud: DOWNSTREAM, + sub: "user|d", + scope: "api:read", + }); + const unscoped = await mintAccessToken(tenant.key, { + iss: tenant.auth0.issuer, + aud: DOWNSTREAM, + sub: "user|d", + scope: "other:scope", + }); + + const forgedResponse = await fetch(tenant.downstreamUrl, { headers: { authorization: `Bearer ${forged}` } }); + const unscopedResponse = await fetch(tenant.downstreamUrl, { headers: { authorization: `Bearer ${unscoped}` } }); + + expect(forgedResponse.status).toBe(401); + expect(await forgedResponse.json()).toMatchObject({ reason: "bad_signature" }); + expect(unscopedResponse.status).toBe(403); + expect(await unscopedResponse.json()).toMatchObject({ reason: "missing_scope" }); + }); + + it("fails the run when a rejected token reaches the downstream API unexpectedly", async () => { + await viaTool("forward_inbound"); + + expect(() => rig().assertAllForwardedTokensVerified()).toThrow(/wrong_audience/); + }); +}); diff --git a/packages/ai-mcp/test/integration/harness.ts b/packages/ai-mcp/test/integration/harness.ts new file mode 100644 index 00000000..c9b0494a --- /dev/null +++ b/packages/ai-mcp/test/integration/harness.ts @@ -0,0 +1,475 @@ +/** + * Shared rig for the integration suites. Every test drives a real MCP SDK `Client` over a real + * `StreamableHTTPClientTransport` into a server built from this package. Only Auth0 and the + * downstream API are intercepted (MSW); requests to localhost pass through. + * + * Two wirings are supported. The node wiring is the real-socket path: the SDK client reaches an + * `http.Server` over TCP. The web wiring is exercised in-process through the Fetch API: the + * transport's `fetch` calls the web handler with a real `Request` and receives a real `Response`, + * with no socket in between. + * - `node`: `http.createServer` on an ephemeral port, mounted with `createNodeMcpHandler`. + * - `web`: `createMcpSdkHandler` called with the standard `Request` the transport builds, through + * the transport's `fetch` option. Headers and bodies are the transport's own. + */ +import { createServer } from "node:http"; +import type { Server as HttpServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpClient } from "../../src/client.js"; +import type { McpClient } from "../../src/client.js"; +import { createMcpSdkHandler, createNodeMcpHandler } from "../../src/mcp-sdk.js"; +import type { McpSdkHandlerOptions } from "../../src/mcp-sdk.js"; +import { getRequestContext } from "../../src/request-context.js"; +import { withRequiredScopes } from "../../src/scopes.js"; +import { + createAuth0Handlers, + createDownstreamOracle, + createTestKey, + exportJwks, + mintAccessToken, + scopeMode, +} from "../support/index.js"; +import type { Auth0Handlers, DownstreamOracle, MintAccessTokenOptions, TestKey } from "../support/index.js"; + +/** + * The platform `fetch`, captured before MSW patches the global one. The SDK client talks to the + * local server through it: MSW's patched `fetch` never finishes cancelling a response body that + * came over a real socket, which the SDK does after every 202 reply. Auth0 and downstream calls + * made by the server still go through the patched global `fetch` and are intercepted. + */ +const socketFetch: typeof fetch = globalThis.fetch; + +export const ORIGIN = "https://mcp.example.com"; +export const AUDIENCE = ORIGIN; +export const ENDPOINT = `${ORIGIN}/mcp`; +export const DOWNSTREAM = "https://api.example.com"; + +export type Wiring = "node" | "web"; +export const WIRINGS: Wiring[] = ["node", "web"]; + +/** A tenant plus a downstream API that only accepts tokens that tenant issued for it. */ +export interface Tenant { + key: TestKey; + auth0: Auth0Handlers; + oracle: DownstreamOracle; + /** Absolute URL of this tenant's downstream endpoint. */ + downstreamUrl: string; + domain: string; +} + +export interface Rig { + server: ReturnType; + tenant: Tenant; + /** Registers another tenant (own domain, key and downstream endpoint). */ + addTenant(domain: string): Promise; + /** Registers the handlers of a tenant with MSW. Call once per test, after reset. */ + useTenant(tenant?: Tenant): void; + /** Mints an access token for the MCP server. */ + token(options?: Omit & { tenant?: Tenant }): Promise; + /** Resets counters and handlers between tests. */ + reset(): void; + /** Every token a downstream API received was verified by its oracle. */ + assertAllForwardedTokensVerified(): void; +} + +/** What the `delegate` tool reports about the downstream call. */ +export interface DelegateOutcome { + status: number; + body: { + ok: boolean; + sub?: string; + orgId?: string | null; + scopes?: string[]; + jti?: string; + reason?: string; + }; +} + +/** Creates the tenant, MSW server and lifecycle helpers. Call at module scope of a spec file. */ +export function createRig(): { rig: () => Rig; install: (hooks: Hooks) => void } { + const server = setupServer(); + let current: Rig | undefined; + let tenants: Tenant[] = []; + + async function makeTenant(domain: string, scopeKind = scopeMode.echo): Promise { + const key = await createTestKey(); + const auth0 = createAuth0Handlers({ key, domain, scope: scopeKind }); + const downstreamUrl = `${DOWNSTREAM}/${domain}/data`; + const oracle = createDownstreamOracle({ + jwks: exportJwks(key), + issuer: auth0.issuer, + audience: DOWNSTREAM, + requiredScope: "api:read", + }); + const tenant: Tenant = { key, auth0, oracle, downstreamUrl, domain }; + tenants.push(tenant); + return tenant; + } + + function handlersFor(tenant: Tenant) { + return [ + ...tenant.auth0.handlers, + http.all(tenant.downstreamUrl, async ({ request }) => { + const verdict = await tenant.oracle.verifyAuthorization(request.headers.get("authorization")); + if (!verdict.ok) { + return HttpResponse.json( + { ok: false, reason: verdict.reason }, + { status: verdict.status }, + ); + } + const claims = verdict.claims; + return HttpResponse.json({ + ok: true, + sub: claims.sub, + orgId: (claims["org_id"] as string | undefined) ?? null, + scopes: verdict.scopes, + jti: claims.jti, + }); + }), + ]; + } + + const install = (hooks: Hooks) => { + hooks.beforeAll(async () => { + tenants = []; + const tenant = await makeTenant("tenant.example.com"); + current = { + server, + tenant, + async addTenant(domain) { + return makeTenant(domain); + }, + useTenant(target = tenant) { + server.use(...handlersFor(target)); + }, + async token(options = {}) { + const { tenant: target = tenant, ...rest } = options; + return mintAccessToken(target.key, { + iss: target.auth0.issuer, + aud: AUDIENCE, + sub: "user|1", + scope: "read write", + claims: { client_id: "client-a" }, + ...rest, + }); + }, + reset() { + server.resetHandlers(); + for (const t of tenants) { + t.auth0.state.reset(); + t.oracle.reset(); + } + }, + assertAllForwardedTokensVerified() { + for (const t of tenants) { + for (const call of t.oracle.calls) { + if (!call.verdict.ok) { + throw new Error( + `A forwarded token was rejected by the downstream API (${call.verdict.reason}).`, + ); + } + } + } + }, + }; + server.listen({ onUnhandledRequest: "error" }); + }); + hooks.afterEach(() => { + current!.reset(); + tenants = tenants.slice(0, 1); + }); + hooks.afterAll(() => server.close()); + }; + + return { + rig: () => { + if (current === undefined) throw new Error("Rig is not installed yet."); + return current; + }, + install, + }; +} + +interface Hooks { + beforeAll: (fn: () => Promise | void) => void; + afterEach: (fn: () => Promise | void) => void; + afterAll: (fn: () => Promise | void) => void; +} + +/** Releases all waiters once `count` of them have arrived. Lets a test start N operations together. */ +export function createBarrier(count: number): () => Promise { + let arrived = 0; + let release!: () => void; + const open = new Promise((resolve) => (release = resolve)); + return async () => { + arrived++; + if (arrived >= count) release(); + await open; + }; +} + +export interface ToolServerOptions { + /** Absolute URL the `delegate` tool forwards the exchanged token to. */ + downstreamUrl: string; + /** Awaited at the start of `delegate` and `whoami`, so concurrent calls overlap. */ + barrier?: () => Promise; + /** Scope requested by `delegate`. Defaults to `api:read`. */ + delegateScope?: string; + /** State shared across requests, used to look for context leaking from one request to another. */ + probe?: ContextProbe; +} + +/** Records what tool code observes about request contexts across requests. */ +export interface ContextProbe { + /** Contexts captured by `capture`, in order. */ + captured: unknown[]; + /** Released by the test; `linger` continuations read the context after it opens. */ + latch: Promise; + /** One entry per `linger` call: the subject its continuation saw after the latch opened. */ + pending: Array>; +} + +function text(value: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(value) }] }; +} + +/** The MCP server a request is served by. Tools read only from the request context. */ +export function buildToolServer(options: ToolServerOptions): McpServer { + const mcp = new McpServer({ name: "integration", version: "1.0.0" }); + + mcp.registerTool("whoami", { description: "Reports the caller seen inside the tool." }, async () => { + await options.barrier?.(); + // Yield so interleaved requests have a chance to overwrite shared state. + await sleep(1); + const context = getRequestContext(); + return text({ + contextSub: context?.principal.sub ?? null, + clientSub: context?.mcpClient.getPrincipal().sub ?? null, + orgId: context?.principal.orgId ?? null, + }); + }); + + mcp.registerTool( + "delegate", + { description: "Exchanges the caller's token and calls the downstream API with it." }, + async () => { + await options.barrier?.(); + const context = getRequestContext(); + const exchanged = await context!.mcpClient.getTokenOnBehalfOf({ + audience: DOWNSTREAM, + scope: options.delegateScope ?? "api:read", + }); + const response = await fetch(options.downstreamUrl, { + headers: { authorization: `Bearer ${exchanged.accessToken}` }, + }); + const outcome: DelegateOutcome = { + status: response.status, + body: (await response.json()) as DelegateOutcome["body"], + }; + return text(outcome); + }, + ); + + mcp.registerTool("capture", { description: "Remembers this request's context object." }, async () => { + options.probe!.captured.push(getRequestContext()); + return text({ sub: getRequestContext()?.principal.sub ?? null }); + }); + + mcp.registerTool("compare", { description: "Compares this request's context with captured ones." }, async () => { + const current = getRequestContext(); + return text({ + sub: current?.principal.sub ?? null, + sameObjectAsEarlier: options.probe!.captured.some((c) => c === current), + earlierSubs: options.probe!.captured.map((c) => (c as typeof current)?.principal.sub ?? null), + }); + }); + + mcp.registerTool("linger", { description: "Reads the context from a continuation that outlives the call." }, async () => { + const probe = options.probe!; + probe.pending.push(probe.latch.then(() => getRequestContext()?.principal.sub ?? null)); + return text({ started: true }); + }); + + mcp.registerTool( + "forward_inbound", + { description: "Sends the caller's own inbound token downstream, which a server must never do." }, + async (extra) => { + const info = (extra as { authInfo?: { token: string } }).authInfo; + const response = await fetch(options.downstreamUrl, { + headers: { authorization: `Bearer ${info!.token}` }, + }); + const outcome: DelegateOutcome = { + status: response.status, + body: (await response.json()) as DelegateOutcome["body"], + }; + return text(outcome); + }, + ); + + mcp.registerTool( + "admin", + { description: "Needs the admin scope." }, + withRequiredScopes("admin", async () => text({ secret: true })), + ); + + return mcp; +} + +export function makeMcpClient( + tenant: Tenant, + overrides: Partial[0]> = {}, +): McpClient { + return createMcpClient({ + domain: tenant.domain, + audience: AUDIENCE, + clientId: "mcp-client", + clientSecret: "secret", + ...overrides, + }); +} + +export interface App { + /** URL the transport connects to. */ + url: URL; + /** `fetch` the SDK transport uses to reach the server. */ + fetch: typeof fetch; + stop(): Promise; +} + +export type HandlerOptions = Omit & { + createServer: McpSdkHandlerOptions["createServer"]; + authorizationServers?: string[]; +}; + +/** Starts the server under test with the given wiring. */ +export async function startApp(wiring: Wiring, tenant: Tenant, options: HandlerOptions): Promise { + const full: McpSdkHandlerOptions = { + resource: AUDIENCE, + authorizationServers: [tenant.auth0.issuer], + ...options, + }; + + if (wiring === "web") { + const handle = createMcpSdkHandler(full); + return { + url: new URL(ENDPOINT), + fetch: ((input: string | URL | Request, init?: RequestInit) => + handle(new Request(input, init))) as typeof fetch, + async stop() {}, + }; + } + + const listener = createNodeMcpHandler(full); + const httpServer: HttpServer = createServer((req, res) => void listener(req, res)); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + const port = (httpServer.address() as AddressInfo).port; + return { + url: new URL(`http://127.0.0.1:${port}/mcp`), + fetch: socketFetch, + async stop() { + httpServer.closeAllConnections(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }, + }; +} + +/** A response as the SDK transport saw it. */ +export interface Seen { + status: number; + wwwAuthenticate: string | null; + contentType: string | null; +} + +export interface Session { + client: Client; + seen: Seen[]; + close(): Promise; +} + +/** + * Connects a real SDK client. `bearer` is sent as the `Authorization` header on every request. + * Rejects with the SDK's own error when the server refuses the connection; `seen` still holds + * every response the transport received, up to and including the refusal. + */ +export async function connectClient(app: App, bearer?: string | { authorization: string }): Promise { + const seen: Seen[] = []; + const base = app.fetch ?? socketFetch; + const recording = (async (input: string | URL | Request, init?: RequestInit) => { + const response = await base(input, init); + seen.push({ + status: response.status, + wwwAuthenticate: response.headers.get("www-authenticate"), + contentType: response.headers.get("content-type"), + }); + return response; + }) as typeof fetch; + + const authorization = + bearer === undefined ? undefined : typeof bearer === "string" ? `Bearer ${bearer}` : bearer.authorization; + const transport = new StreamableHTTPClientTransport(app.url, { + fetch: recording, + ...(authorization !== undefined && { requestInit: { headers: { authorization } } }), + }); + const client = new Client({ name: "integration-client", version: "1.0.0" }); + await client.connect(transport).catch(async (error: unknown) => { + await client.close().catch(() => undefined); + throw Object.assign(error as Error, { seen }); + }); + return { + client, + seen, + async close() { + await client.close(); + }, + }; +} + +/** Result of `callTool` with the JSON text payload parsed. */ +export async function callJson(session: Session, name: string): Promise<{ isError: boolean; value: T }> { + const result = (await session.client.callTool({ name, arguments: {} })) as { + isError?: boolean; + content: Array<{ type: string; text: string }>; + }; + const first = result.content[0]?.text ?? ""; + let value: unknown = first; + try { + value = JSON.parse(first); + } catch { + // Error results carry plain text. + } + return { isError: result.isError === true, value: value as T }; +} + +/** Starts a server, runs `fn` against it and always stops it. */ +export async function withApp( + wiring: Wiring, + tenant: Tenant, + options: HandlerOptions, + fn: (app: App) => Promise, +): Promise { + const app = await startApp(wiring, tenant, options); + try { + return await fn(app); + } finally { + await app.stop(); + } +} + +export { scopeMode }; + +/** Connects, runs `delegate` and returns what the downstream API saw. */ +export async function delegateOnce(app: App, bearer: string): Promise { + const session = await connectClient(app, bearer); + try { + const { isError, value } = await callJson(session, "delegate"); + if (isError) throw new Error(`delegate failed: ${JSON.stringify(value)}`); + return value; + } finally { + await session.close(); + } +} diff --git a/packages/ai-mcp/test/integration/isolation.spec.ts b/packages/ai-mcp/test/integration/isolation.spec.ts new file mode 100644 index 00000000..25409496 --- /dev/null +++ b/packages/ai-mcp/test/integration/isolation.spec.ts @@ -0,0 +1,207 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { MemoryIndexedTokenStore } from "../../src/stores/memory-indexed-store.js"; +import type { Tenant } from "./harness.js"; +import { + WIRINGS, + buildToolServer, + createRig, + delegateOnce, + makeMcpClient, + startApp, +} from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +function serverFor(tenant: Tenant) { + return () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }); +} + +describe.each(WIRINGS)("cache and exchange isolation (%s wiring)", (wiring) => { + it("reuses a cached token for the same caller, which makes the isolation cases below meaningful", async () => { + const { tenant } = rig(); + rig().useTenant(); + const app = await startApp(wiring, tenant, { + client: makeMcpClient(tenant, { tokenStore: new MemoryIndexedTokenStore() }), + createServer: serverFor(tenant), + }); + try { + const bearer = await rig().token({ sub: "user|same" }); + const first = await delegateOnce(app, bearer); + const second = await delegateOnce(app, bearer); + + expect(tenant.auth0.state.successfulExchangeCount).toBe(1); + expect(second.body.jti).toBe(first.body.jti); + } finally { + await app.stop(); + } + }); + + it("never shares a token between different subjects, organizations or caller clients", async () => { + const { tenant } = rig(); + rig().useTenant(); + const app = await startApp(wiring, tenant, { + client: makeMcpClient(tenant, { tokenStore: new MemoryIndexedTokenStore() }), + createServer: serverFor(tenant), + }); + try { + const callers = [ + { sub: "user|a", claims: { client_id: "client-a" } }, + { sub: "user|b", claims: { client_id: "client-a" } }, + { sub: "user|a", claims: { client_id: "client-a", org_id: "org_1" } }, + { sub: "user|a", claims: { client_id: "client-a", org_id: "org_2" } }, + { sub: "user|a", claims: { client_id: "client-b" } }, + ]; + const tokens = await Promise.all(callers.map((c) => rig().token(c))); + + const jtis = new Set(); + for (let round = 0; round < 2; round++) { + for (const [index, bearer] of tokens.entries()) { + const outcome = await delegateOnce(app, bearer); + const expected = callers[index]!; + expect(outcome.body.sub).toBe(expected.sub); + expect(outcome.body.orgId).toBe((expected.claims as { org_id?: string }).org_id ?? null); + jtis.add(outcome.body.jti); + } + } + + expect(tenant.auth0.state.successfulExchangeCount).toBe(callers.length); + expect(jtis.size).toBe(callers.length); + rig().assertAllForwardedTokensVerified(); + } finally { + await app.stop(); + } + }); + + it("keeps subjects, organizations and caller clients apart even when their session ids collide", async () => { + const { tenant } = rig(); + rig().useTenant(); + const app = await startApp(wiring, tenant, { + client: makeMcpClient(tenant, { tokenStore: new MemoryIndexedTokenStore() }), + createServer: serverFor(tenant), + }); + try { + const callers = [ + { sub: "user|a", claims: { client_id: "client-a", sid: "shared" } }, + { sub: "user|b", claims: { client_id: "client-a", sid: "shared" } }, + { sub: "user|a", claims: { client_id: "client-a", sid: "shared", org_id: "org_1" } }, + { sub: "user|a", claims: { client_id: "client-b", sid: "shared" } }, + ]; + const tokens = await Promise.all(callers.map((c) => rig().token(c))); + + for (let round = 0; round < 2; round++) { + for (const [index, bearer] of tokens.entries()) { + const outcome = await delegateOnce(app, bearer); + expect(outcome.body.sub).toBe(callers[index]!.sub); + expect(outcome.body.orgId).toBe((callers[index]!.claims as { org_id?: string }).org_id ?? null); + } + } + + expect(tenant.auth0.state.successfulExchangeCount).toBe(callers.length); + } finally { + await app.stop(); + } + }); + + it("never shares a token between servers whose exchanging client ids differ", async () => { + const { tenant } = rig(); + rig().useTenant(); + const store = new MemoryIndexedTokenStore(); + const a = await startApp(wiring, tenant, { + client: makeMcpClient(tenant, { clientId: "mcp-client-a", tokenStore: store }), + createServer: serverFor(tenant), + }); + const b = await startApp(wiring, tenant, { + client: makeMcpClient(tenant, { clientId: "mcp-client-b", tokenStore: store }), + createServer: serverFor(tenant), + }); + try { + const bearer = await rig().token({ sub: "user|shared" }); + for (let round = 0; round < 2; round++) { + await delegateOnce(a, bearer); + await delegateOnce(b, bearer); + } + + expect(tenant.auth0.state.successfulExchangeCount).toBe(2); + const ids = tenant.auth0.state.requests.map((r) => r.clientId); + expect(new Set(ids)).toEqual(new Set(["mcp-client-a", "mcp-client-b"])); + expect(store.size).toBe(2); + } finally { + await a.stop(); + await b.stop(); + } + }); + + it("never shares a token between servers that trust different issuers", async () => { + const { tenant } = rig(); + const other = await rig().addTenant("other.example.com"); + rig().useTenant(tenant); + rig().useTenant(other); + const store = new MemoryIndexedTokenStore(); + const a = await startApp(wiring, tenant, { + client: makeMcpClient(tenant, { tokenStore: store }), + createServer: serverFor(tenant), + }); + const b = await startApp(wiring, other, { + client: makeMcpClient(other, { tokenStore: store }), + createServer: serverFor(other), + }); + try { + // Same subject and client id at both issuers. + const tokenA = await rig().token({ sub: "user|same", tenant }); + const tokenB = await rig().token({ sub: "user|same", tenant: other }); + for (let round = 0; round < 2; round++) { + await delegateOnce(a, tokenA); + await delegateOnce(b, tokenB); + } + + expect(tenant.auth0.state.successfulExchangeCount).toBe(1); + expect(other.auth0.state.successfulExchangeCount).toBe(1); + expect(store.size).toBe(2); + // Each downstream accepted only tokens from its own issuer. + expect(tenant.oracle.calls).toHaveLength(2); + expect(other.oracle.calls).toHaveLength(2); + rig().assertAllForwardedTokensVerified(); + } finally { + await a.stop(); + await b.stop(); + } + }); + + it("does not let one tenant's server accept a token issued by another tenant", async () => { + const { tenant } = rig(); + const other = await rig().addTenant("other.example.com"); + rig().useTenant(tenant); + rig().useTenant(other); + const app = await startApp(wiring, tenant, { + client: makeMcpClient(tenant), + createServer: serverFor(tenant), + }); + try { + await expect(delegateOnce(app, await rig().token({ tenant: other }))).rejects.toMatchObject({ code: 401 }); + expect(tenant.auth0.state.exchangeCount).toBe(0); + } finally { + await app.stop(); + } + }); + + it("does not let concurrent callers for different subjects share one exchange", async () => { + const { tenant } = rig(); + rig().useTenant(); + const app = await startApp(wiring, tenant, { + client: makeMcpClient(tenant), + createServer: serverFor(tenant), + }); + try { + const subs = ["user|1", "user|2", "user|3", "user|4"]; + const outcomes = await Promise.all( + subs.map(async (sub) => delegateOnce(app, await rig().token({ sub }))), + ); + + expect(outcomes.map((o) => o.body.sub)).toEqual(subs); + expect(tenant.auth0.state.exchangeCount).toBe(subs.length); + } finally { + await app.stop(); + } + }); +}); diff --git a/packages/ai-mcp/test/integration/obo-stampede.spec.ts b/packages/ai-mcp/test/integration/obo-stampede.spec.ts new file mode 100644 index 00000000..b904a8d7 --- /dev/null +++ b/packages/ai-mcp/test/integration/obo-stampede.spec.ts @@ -0,0 +1,114 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { MemoryIndexedTokenStore } from "../../src/stores/memory-indexed-store.js"; +import { + WIRINGS, + buildToolServer, + callJson, + connectClient, + createBarrier, + createRig, + makeMcpClient, + withApp, +} from "./harness.js"; +import type { DelegateOutcome } from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +const CALLERS = 8; + +describe.each(WIRINGS)("concurrent identical exchanges (%s wiring)", (wiring) => { + async function stampede( + options: { store?: MemoryIndexedTokenStore }, + scenario: (run: () => Promise>) => Promise, + ) { + const { tenant } = rig(); + rig().useTenant(); + let barrier = createBarrier(CALLERS); + await withApp( + wiring, + tenant, + { + client: makeMcpClient(tenant, options.store ? { tokenStore: options.store } : {}), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl, barrier: () => barrier() }), + }, + async (app) => { + const bearer = await rig().token({ sub: "user|stampede" }); + const session = await connectClient(app, bearer); + try { + await scenario(async () => { + barrier = createBarrier(CALLERS); + return Promise.all( + Array.from({ length: CALLERS }, () => callJson(session, "delegate")), + ); + }); + } finally { + await session.close(); + } + }, + ); + } + + it("performs one exchange for concurrent identical calls and hands every caller a verified token", async () => { + await stampede({}, async (run) => { + const results = await run(); + + expect(rig().tenant.auth0.state.exchangeCount).toBe(1); + expect(results).toHaveLength(CALLERS); + for (const result of results) { + const outcome = result.value as DelegateOutcome; + expect(result.isError).toBe(false); + expect(outcome.status).toBe(200); + expect(outcome.body).toMatchObject({ ok: true, sub: "user|stampede", scopes: ["api:read"] }); + } + expect(new Set(results.map((r) => (r.value as DelegateOutcome).body.jti)).size).toBe(1); + expect(rig().tenant.oracle.calls).toHaveLength(CALLERS); + rig().assertAllForwardedTokensVerified(); + }); + }); + + it("performs one exchange when a store is configured too", async () => { + const store = new MemoryIndexedTokenStore(); + await stampede({ store }, async (run) => { + await run(); + + expect(rig().tenant.auth0.state.exchangeCount).toBe(1); + expect(store.size).toBe(1); + }); + }); + + it("shares a failed exchange between the waiting calls but does not remember it", async () => { + await stampede({}, async (run) => { + rig().tenant.auth0.state.failure = { status: 500, body: { error: "server_error" } }; + const failed = await run(); + + expect(failed.every((r) => r.isError)).toBe(true); + expect(rig().tenant.auth0.state.exchangeCount).toBe(1); + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(0); + + rig().tenant.auth0.state.failure = null; + const recovered = await run(); + + expect(recovered.every((r) => !r.isError)).toBe(true); + expect(rig().tenant.auth0.state.exchangeCount).toBe(2); + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(1); + rig().assertAllForwardedTokensVerified(); + }); + }); + + it("does not cache a failed exchange in the store", async () => { + const store = new MemoryIndexedTokenStore(); + await stampede({ store }, async (run) => { + rig().tenant.auth0.state.failure = { status: 500, body: { error: "server_error" } }; + await run(); + + expect(store.size).toBe(0); + + rig().tenant.auth0.state.failure = null; + await run(); + + expect(store.size).toBe(1); + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(1); + }); + }); +}); diff --git a/packages/ai-mcp/test/integration/organization-policy.spec.ts b/packages/ai-mcp/test/integration/organization-policy.spec.ts new file mode 100644 index 00000000..78aa7a61 --- /dev/null +++ b/packages/ai-mcp/test/integration/organization-policy.spec.ts @@ -0,0 +1,98 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + WIRINGS, + buildToolServer, + callJson, + connectClient, + createRig, + delegateOnce, + makeMcpClient, + startApp, +} from "./harness.js"; +import type { McpClientOptions } from "../../src/types.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +describe.each(WIRINGS)("organization policy at ingress (%s wiring)", (wiring) => { + async function serve(overrides: Partial) { + const { tenant } = rig(); + rig().useTenant(); + return startApp(wiring, tenant, { + client: makeMcpClient(tenant, overrides), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }), + }); + } + + const allowList: Partial = { organizationPolicy: "required", organizationId: ["org_allowed"] }; + + it("rejects a token without an organization when one is required", async () => { + const app = await serve(allowList); + try { + await expect(connectClient(app, await rig().token())).rejects.toMatchObject({ code: 401 }); + expect(rig().tenant.auth0.state.exchangeCount).toBe(0); + } finally { + await app.stop(); + } + }); + + it("rejects a token from an organization that is not allowed", async () => { + const app = await serve(allowList); + try { + const bearer = await rig().token({ claims: { client_id: "client-a", org_id: "org_other" } }); + + const error = await connectClient(app, bearer).then( + () => undefined, + (e: unknown) => e as { code: number; seen: Array<{ wwwAuthenticate: string | null }> }, + ); + + expect(error?.code).toBe(401); + expect(error?.seen[0]!.wwwAuthenticate).toContain('error="invalid_token"'); + expect(rig().tenant.auth0.state.exchangeCount).toBe(0); + } finally { + await app.stop(); + } + }); + + it("accepts a token from an allowed organization and carries it downstream", async () => { + const app = await serve(allowList); + try { + const bearer = await rig().token({ claims: { client_id: "client-a", org_id: "org_allowed" } }); + const session = await connectClient(app, bearer); + try { + const who = await callJson<{ orgId: string }>(session, "whoami"); + expect(who.value.orgId).toBe("org_allowed"); + } finally { + await session.close(); + } + + const outcome = await delegateOnce(app, bearer); + + expect(outcome.body).toMatchObject({ ok: true, orgId: "org_allowed" }); + rig().assertAllForwardedTokensVerified(); + } finally { + await app.stop(); + } + }); + + it("accepts any organization when the policy is required without an allow list", async () => { + const app = await serve({ organizationPolicy: "required" }); + try { + const bearer = await rig().token({ claims: { client_id: "client-a", org_id: "org_any" } }); + + expect((await delegateOnce(app, bearer)).body.orgId).toBe("org_any"); + await expect(connectClient(app, await rig().token())).rejects.toMatchObject({ code: 401 }); + } finally { + await app.stop(); + } + }); + + it("does not check organizations when the policy is allow", async () => { + const app = await serve({}); + try { + expect((await delegateOnce(app, await rig().token())).body.orgId).toBeNull(); + } finally { + await app.stop(); + } + }); +}); diff --git a/packages/ai-mcp/test/integration/scope-denial.spec.ts b/packages/ai-mcp/test/integration/scope-denial.spec.ts new file mode 100644 index 00000000..70dba4f9 --- /dev/null +++ b/packages/ai-mcp/test/integration/scope-denial.spec.ts @@ -0,0 +1,86 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + WIRINGS, + buildToolServer, + callJson, + connectClient, + createRig, + makeMcpClient, + withApp, +} from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +describe.each(WIRINGS)("scope enforcement (%s wiring)", (wiring) => { + function options(extra: Record = {}) { + const { tenant } = rig(); + rig().useTenant(); + const createServer = vi.fn(() => buildToolServer({ downstreamUrl: tenant.downstreamUrl })); + return { tenant, createServer, handler: { client: makeMcpClient(tenant), createServer, ...extra } }; + } + + it("answers a per-tool scope denial with an isError result and HTTP 200", async () => { + const { tenant, handler } = options(); + await withApp(wiring, tenant, handler, async (app) => { + const session = await connectClient(app, await rig().token({ scope: "read" })); + try { + const denied = await callJson(session, "admin"); + + expect(denied.isError).toBe(true); + expect(denied.value).toBe("Missing one or more required scopes."); + // The tool call itself was answered with 200, not an HTTP error. + const answers = session.seen.filter((r) => r.contentType?.includes("application/json")); + expect(answers.at(-1)!.status).toBe(200); + expect(session.seen.some((r) => r.status === 401 || r.status === 403)).toBe(false); + } finally { + await session.close(); + } + }); + }); + + it("runs the tool when the per-tool scope is held", async () => { + const { tenant, handler } = options(); + await withApp(wiring, tenant, handler, async (app) => { + const session = await connectClient(app, await rig().token({ scope: "read admin" })); + try { + const allowed = await callJson<{ secret: boolean }>(session, "admin"); + + expect(allowed.isError).toBe(false); + expect(allowed.value).toEqual({ secret: true }); + } finally { + await session.close(); + } + }); + }); + + it("answers 403 insufficient_scope listing only the missing server-wide scopes", async () => { + const { tenant, createServer, handler } = options({ requiredScopes: ["read", "admin"], realm: "mcp-test" }); + await withApp(wiring, tenant, handler, async (app) => { + const error = await connectClient(app, await rig().token({ scope: "read" })).then( + () => undefined, + (e: unknown) => e as { code: number; seen: Array<{ wwwAuthenticate: string | null }> }, + ); + + expect(error?.code).toBe(403); + const challenge = error!.seen[0]!.wwwAuthenticate!; + expect(challenge).toContain('error="insufficient_scope"'); + expect(challenge).toContain('scope="admin"'); + expect(challenge).toContain('realm="mcp-test"'); + expect(challenge).toContain("resource_metadata="); + expect(createServer).not.toHaveBeenCalled(); + }); + }); + + it("serves a caller who holds every server-wide scope", async () => { + const { tenant, handler } = options({ requiredScopes: ["read", "admin"] }); + await withApp(wiring, tenant, handler, async (app) => { + const session = await connectClient(app, await rig().token({ scope: "read admin write" })); + try { + expect((await callJson(session, "whoami")).isError).toBe(false); + } finally { + await session.close(); + } + }); + }); +}); diff --git a/packages/ai-mcp/test/integration/session-slot.spec.ts b/packages/ai-mcp/test/integration/session-slot.spec.ts new file mode 100644 index 00000000..b8c98e31 --- /dev/null +++ b/packages/ai-mcp/test/integration/session-slot.spec.ts @@ -0,0 +1,134 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { MemoryIndexedTokenStore } from "../../src/stores/memory-indexed-store.js"; +import { WIRINGS, buildToolServer, createRig, delegateOnce, makeMcpClient, startApp } from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +describe.each(WIRINGS)("session slot in the cache key (%s wiring)", (wiring) => { + async function serve(store?: MemoryIndexedTokenStore) { + const { tenant } = rig(); + rig().useTenant(); + return startApp(wiring, tenant, { + client: makeMcpClient(tenant, store ? { tokenStore: store } : {}), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }), + }); + } + + const same = { sub: "user|1", claims: { client_id: "client-a" } }; + + it("serves the same session from the cache", async () => { + const store = new MemoryIndexedTokenStore(); + const app = await serve(store); + try { + const bearer = await rig().token({ ...same, claims: { ...same.claims, sid: "session-1" } }); + await delegateOnce(app, bearer); + await delegateOnce(app, bearer); + + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(1); + } finally { + await app.stop(); + } + }); + + it("does not share a cached token between two sessions of the same subject", async () => { + const store = new MemoryIndexedTokenStore(); + const app = await serve(store); + try { + const one = await rig().token({ ...same, claims: { ...same.claims, sid: "session-1" } }); + const two = await rig().token({ ...same, claims: { ...same.claims, sid: "session-2" } }); + const first = await delegateOnce(app, one); + const second = await delegateOnce(app, two); + await delegateOnce(app, one); + await delegateOnce(app, two); + + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(2); + expect(store.size).toBe(2); + expect(second.body.jti).not.toBe(first.body.jti); + rig().assertAllForwardedTokensVerified(); + } finally { + await app.stop(); + } + }); + + it("does not merge concurrent exchanges of two sessions", async () => { + const app = await serve(); + try { + const one = await rig().token({ ...same, claims: { ...same.claims, sid: "session-1" } }); + const two = await rig().token({ ...same, claims: { ...same.claims, sid: "session-2" } }); + await Promise.all([delegateOnce(app, one), delegateOnce(app, two)]); + + expect(rig().tenant.auth0.state.exchangeCount).toBe(2); + } finally { + await app.stop(); + } + }); + + it("keys on the session id first: different tokens in one session share an entry", async () => { + const store = new MemoryIndexedTokenStore(); + const app = await serve(store); + try { + const claims = { ...same.claims, sid: "session-1" }; + const one = await rig().token({ ...same, claims, jti: "token-1", iat: 1_700_000_000 }); + const two = await rig().token({ ...same, claims, jti: "token-2", iat: 1_700_000_100 }); + expect(one).not.toBe(two); + await delegateOnce(app, one); + await delegateOnce(app, two); + + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(1); + expect(store.size).toBe(1); + } finally { + await app.stop(); + } + }); + + it("keys on the session id over the token id: one token id in two sessions does not share", async () => { + const store = new MemoryIndexedTokenStore(); + const app = await serve(store); + try { + const one = await rig().token({ ...same, claims: { ...same.claims, sid: "session-1" }, jti: "token-1" }); + const two = await rig().token({ ...same, claims: { ...same.claims, sid: "session-2" }, jti: "token-1" }); + await delegateOnce(app, one); + await delegateOnce(app, two); + + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(2); + expect(store.size).toBe(2); + } finally { + await app.stop(); + } + }); + + it("falls back to a digest of the token when there is neither session id nor token id", async () => { + const store = new MemoryIndexedTokenStore(); + const app = await serve(store); + try { + const one = await rig().token({ ...same, iat: 1_700_000_000 }); + const two = await rig().token({ ...same, iat: 1_700_000_100 }); + await delegateOnce(app, one); + await delegateOnce(app, one); + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(1); + + await delegateOnce(app, two); + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(2); + expect(store.size).toBe(2); + } finally { + await app.stop(); + } + }); + + it("falls back to the token id when the token carries no session id", async () => { + const store = new MemoryIndexedTokenStore(); + const app = await serve(store); + try { + const one = await rig().token({ ...same, jti: "token-1" }); + const two = await rig().token({ ...same, jti: "token-2" }); + await delegateOnce(app, one); + await delegateOnce(app, two); + await delegateOnce(app, one); + + expect(rig().tenant.auth0.state.successfulExchangeCount).toBe(2); + } finally { + await app.stop(); + } + }); +}); diff --git a/packages/ai-mcp/test/integration/transport-bearer.spec.ts b/packages/ai-mcp/test/integration/transport-bearer.spec.ts new file mode 100644 index 00000000..6aa72c8b --- /dev/null +++ b/packages/ai-mcp/test/integration/transport-bearer.spec.ts @@ -0,0 +1,175 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createTestKey, mintAccessToken } from "../support/index.js"; +import { + AUDIENCE, + ORIGIN, + WIRINGS, + buildToolServer, + callJson, + connectClient, + createRig, + makeMcpClient, + withApp, +} from "./harness.js"; +import type { Seen } from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +const PRM = `${ORIGIN}/.well-known/oauth-protected-resource`; + +async function refused(attempt: Promise): Promise<{ code: number | undefined; seen: Seen[] }> { + const error = (await attempt.then( + () => { + throw new Error("The connection was expected to be refused."); + }, + (e: unknown) => e, + )) as { code?: number; seen: Seen[] }; + return { code: error.code, seen: error.seen }; +} + +describe.each(WIRINGS)("bearer authentication through the SDK client (%s wiring)", (wiring) => { + async function run(fn: (url: Parameters[0]) => Promise) { + const { tenant } = rig(); + rig().useTenant(); + return withApp( + wiring, + tenant, + { + client: makeMcpClient(tenant), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }), + }, + fn, + ); + } + + it("answers 401 with a bare challenge when no credential is sent", async () => { + await run(async (app) => { + const { code, seen } = await refused(connectClient(app)); + + expect(code).toBe(401); + const challenge = seen[0]!.wwwAuthenticate!; + expect(challenge.startsWith(`Bearer resource_metadata="${PRM}"`)).toBe(true); + expect(challenge).not.toContain("error="); + }); + }); + + it("answers 401 invalid_token for a token that is not a JWT", async () => { + await run(async (app) => { + const { code, seen } = await refused(connectClient(app, "not-a.valid.token")); + + expect(code).toBe(401); + expect(seen[0]!.wwwAuthenticate).toContain('error="invalid_token"'); + expect(seen[0]!.wwwAuthenticate).toContain(`resource_metadata="${PRM}"`); + }); + }); + + it("answers 401 invalid_token for a token signed with an unknown key", async () => { + const { tenant } = rig(); + const other = await createTestKey(); + const forged = await mintAccessToken(other, { iss: tenant.auth0.issuer, aud: AUDIENCE, sub: "user|1" }); + await run(async (app) => { + const { code, seen } = await refused(connectClient(app, forged)); + + expect(code).toBe(401); + expect(seen[0]!.wwwAuthenticate).toContain('error="invalid_token"'); + }); + }); + + it("answers 401 invalid_token for an expired token", async () => { + const expired = await rig().token({ exp: Math.floor(Date.now() / 1000) - 120 }); + await run(async (app) => { + const { code, seen } = await refused(connectClient(app, expired)); + + expect(code).toBe(401); + expect(seen[0]!.wwwAuthenticate).toContain('error="invalid_token"'); + }); + }); + + it("answers 401 invalid_token for a token issued for another audience", async () => { + const wrongAudience = await mintAccessToken(rig().tenant.key, { + iss: rig().tenant.auth0.issuer, + aud: "https://other.example.com", + sub: "user|1", + }); + await run(async (app) => { + const { code, seen } = await refused(connectClient(app, wrongAudience)); + + expect(code).toBe(401); + expect(seen[0]!.wwwAuthenticate).toContain('error="invalid_token"'); + }); + }); + + it("does not echo verifier messages in the challenge or the body", async () => { + await run(async (app) => { + const { seen } = await refused(connectClient(app, "not-a.valid.token")); + + const description = /error_description="([^"]*)"/.exec(seen[0]!.wwwAuthenticate!)?.[1] ?? ""; + expect(description).not.toMatch(/jwt|jws|signature|payload|jose/i); + }); + }); + + it("answers 400 invalid_request for a malformed Authorization header", async () => { + await run(async (app) => { + const { code, seen } = await refused(connectClient(app, { authorization: "Bearer two tokens" })); + + expect(code).toBe(400); + expect(seen[0]!.wwwAuthenticate).toContain('error="invalid_request"'); + }); + }); + + it("answers 401 without an error value for an unsupported scheme", async () => { + await run(async (app) => { + const { code, seen } = await refused(connectClient(app, { authorization: "Basic dXNlcjpwYXNz" })); + + expect(code).toBe(401); + expect(seen[0]!.wwwAuthenticate).not.toContain("error="); + }); + }); + + it("lets a valid token reach the tool with the caller's identity", async () => { + await run(async (app) => { + const session = await connectClient(app, await rig().token({ sub: "user|valid" })); + try { + const tools = await session.client.listTools(); + expect(tools.tools.map((t) => t.name)).toEqual(expect.arrayContaining(["whoami", "delegate"])); + + const { isError, value } = await callJson<{ contextSub: string; clientSub: string }>(session, "whoami"); + + expect(isError).toBe(false); + expect(value).toMatchObject({ contextSub: "user|valid", clientSub: "user|valid" }); + expect(session.seen.every((r) => r.status === 200 || r.status === 202)).toBe(true); + } finally { + await session.close(); + } + }); + }); + + it("forwards only a verifiable, correctly scoped token downstream", async () => { + await run(async (app) => { + const session = await connectClient(app, await rig().token({ sub: "user|valid" })); + try { + const { value } = await callJson<{ status: number; body: { ok: boolean; sub: string; scopes: string[] } }>( + session, + "delegate", + ); + + expect(value.status).toBe(200); + expect(value.body).toMatchObject({ ok: true, sub: "user|valid", scopes: ["api:read"] }); + expect(rig().tenant.oracle.calls).toHaveLength(1); + rig().assertAllForwardedTokensVerified(); + } finally { + await session.close(); + } + }); + }); + + it("never runs a tool or an exchange for a rejected request", async () => { + await run(async (app) => { + await refused(connectClient(app, "not-a.valid.token")); + + expect(rig().tenant.auth0.state.exchangeCount).toBe(0); + expect(rig().tenant.oracle.calls).toHaveLength(0); + }); + }); +}); From f8afb7fe64d3f0f001213a33136bb9ba994d9c1d Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:37:41 +0530 Subject: [PATCH 16/31] fix(ai-mcp): keep token operation errors generic in tool results 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 --- packages/ai-mcp/src/request-scoped-client.ts | 156 +++++++-- .../ai-mcp/test/request-scoped-errors.spec.ts | 304 ++++++++++++++++++ 2 files changed, 427 insertions(+), 33 deletions(-) create mode 100644 packages/ai-mcp/test/request-scoped-errors.spec.ts diff --git a/packages/ai-mcp/src/request-scoped-client.ts b/packages/ai-mcp/src/request-scoped-client.ts index d15f24bb..66c4387d 100644 --- a/packages/ai-mcp/src/request-scoped-client.ts +++ b/packages/ai-mcp/src/request-scoped-client.ts @@ -1,6 +1,7 @@ -import { ApiClient, buildOboTokenKey, normalizeScopes } from '@auth0/auth0-api-js'; +import * as core from '@auth0/auth0-api-js'; +import { ApiClient, AuthError, buildOboTokenKey, normalizeScopes } from '@auth0/auth0-api-js'; import type { OboCacheKeyLayout, OnBehalfOfTokenResult, TokenStore, VerifiedToken } from '@auth0/auth0-api-js'; -import { DownscopedTokenError } from './errors.js'; +import { DownscopedTokenError, InsufficientScopeError, UnauthenticatedError } from './errors.js'; import type { SingleFlight } from './single-flight.js'; import type { ClientCredentialsOptions, @@ -89,6 +90,81 @@ function assertCovers(requested: string | undefined, grantedScopes: readonly str } } +/** The only message an error leaving a request-scoped client may carry besides the package's own errors. */ +const GENERIC_FAILURE_MESSAGE = 'The token operation failed.'; + +function withCause(error: T, cause: unknown): T { + Object.defineProperty(error, 'cause', { + value: cause, + enumerable: false, + writable: true, + configurable: true, + }); + return error; +} + +/** Prototypes of the error classes the core package exports. Only these are ever copied onto a sanitized error. */ +const CORE_ERROR_PROTOTYPES: ReadonlySet = new Set( + (Object.values(core) as unknown[]).flatMap((value) => + typeof value === 'function' && (value as { prototype?: unknown }).prototype instanceof Error + ? [(value as { prototype: object }).prototype] + : [] + ) +); + +/** The nearest core error prototype in the error's chain, or `undefined` for any other error. */ +function coreErrorPrototype(error: Error): object | undefined { + for (let proto = Object.getPrototypeOf(error); proto !== null; proto = Object.getPrototypeOf(proto)) { + if (CORE_ERROR_PROTOTYPES.has(proto)) return proto; + } + return undefined; +} + +/** Copies headers one level deep, so no sanitized error shares an object with the original or another copy. */ +function copyHeaders(headers: Record | undefined) { + if (headers === undefined) return undefined; + return Object.fromEntries( + Object.entries(headers).map(([name, value]) => [name, Array.isArray(value) ? [...value] : value]) + ); +} + +/** + * Returns an error that is safe to show to an MCP client: it carries a fixed message and + * keeps the original as `cause`. + * + * `cause` is for server-side diagnostics only. It holds the original error, whose message may + * contain upstream detail, so never serialize it into a response. + * + * The package's own errors already have generic messages and pass through unchanged. They are + * thrown per caller (after the shared exchange settles), never from inside it, so waiters never + * share one. A core error becomes a fresh instance of the same core class with the same `code`, + * `statusCode` and a copy of the `headers`, so `instanceof` checks and status mapping keep + * working. An error whose class is not exported by the core package, and any non-core error + * (network failures, for example), becomes a plain `AuthError` or `Error`, so no foreign + * prototype runs on the copy. Each call returns a new instance. + */ +export function sanitizeTokenError(error: unknown): Error { + if ( + error instanceof DownscopedTokenError || + error instanceof InsufficientScopeError || + error instanceof UnauthenticatedError + ) { + return error; + } + const known = error instanceof Error ? coreErrorPrototype(error) : undefined; + let safe: Error; + if (error instanceof AuthError) { + safe = new AuthError(GENERIC_FAILURE_MESSAGE, error.code, error.statusCode, copyHeaders(error.headers)); + } else { + safe = new Error(GENERIC_FAILURE_MESSAGE); + } + if (known !== undefined) { + Object.setPrototypeOf(safe, known); + safe.name = (known as { constructor: { name: string } }).constructor.name; + } + return withCause(safe, error); +} + /** * Creates a {@link RequestScopedMcpClient} bound to one verified caller. * @@ -116,40 +192,54 @@ export function createRequestScopedClient(init: RequestScopedClientInit): Reques { ...(tokenStore !== undefined && { store: tokenStore }), verified } ); + /** Runs an operation and rethrows any failure as a sanitized error. */ + const guarded = async (operation: () => Promise): Promise => { + try { + return await operation(); + } catch (error) { + throw sanitizeTokenError(error); + } + }; + return Object.freeze({ - async getTokenOnBehalfOf(options: OnBehalfOfOptions) { - // Concurrent identical requests from this parent client share one exchange. The - // outcome is not remembered once it settles, so a failure is retried by the next call. - const key = flightKey(exchangeIdentity, verified, options.audience, options.scope); - const shared = key === undefined ? await exchange(options) : await exchanges.run(key, () => exchange(options)); - // Each caller gets its own copy, so one caller changing its result cannot alter another's. - const result = { ...shared, grantedScopes: [...shared.grantedScopes] }; - - // The core client caches the token under the scopes it was granted and does not - // 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); - return result; - }, + getTokenOnBehalfOf: (options: OnBehalfOfOptions) => + guarded(async () => { + // Concurrent identical requests from this parent client share one exchange. The + // outcome is not remembered once it settles, so a failure is retried by the next call. + const key = flightKey(exchangeIdentity, verified, options.audience, options.scope); + const shared = key === undefined ? await exchange(options) : await exchanges.run(key, () => exchange(options)); + // Each caller gets its own copy, so one caller changing its result cannot alter another's. + const result = { ...shared, grantedScopes: [...shared.grantedScopes] }; - async getClientCredentialsToken(options: ClientCredentialsOptions): Promise { - const result = await apiClient.getClientCredentialsToken({ - audience: options.audience, - ...(options.scope !== undefined && { scope: options.scope }), - }); - const grantedScopes = normalizeScopes(result.scope ?? options.scope ?? ''); - assertCovers(options.scope, grantedScopes); - return { ...result, grantedScopes }; - }, + // The core client caches the token under the scopes it was granted and does not + // 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); + return result; + }), - getTokenForService(options: TokenForServiceOptions) { - return apiClient.getAccessTokenForConnection({ - connection: options.connection, - accessToken: verified.accessToken, - ...(options.loginHint !== undefined && { loginHint: options.loginHint }), - }); - }, + getClientCredentialsToken: (options: ClientCredentialsOptions): Promise => + guarded(async () => { + const result = await apiClient.getClientCredentialsToken({ + audience: options.audience, + ...(options.scope !== undefined && { scope: options.scope }), + }); + const grantedScopes = normalizeScopes(result.scope ?? options.scope ?? ''); + assertCovers(options.scope, grantedScopes); + return { ...result, grantedScopes }; + }), + + getTokenForService: (options: TokenForServiceOptions) => + guarded(() => + apiClient.getAccessTokenForConnection({ + connection: options.connection, + accessToken: verified.accessToken, + ...(options.loginHint !== undefined && { + loginHint: options.loginHint, + }), + }) + ), getPrincipal(): Principal { return principal; diff --git a/packages/ai-mcp/test/request-scoped-errors.spec.ts b/packages/ai-mcp/test/request-scoped-errors.spec.ts new file mode 100644 index 00000000..3a3f1cde --- /dev/null +++ b/packages/ai-mcp/test/request-scoped-errors.spec.ts @@ -0,0 +1,304 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { AuthError, InvalidRequestError, MissingClientAuthError } from '@auth0/auth0-api-js'; +import type { CachedToken } from '@auth0/auth0-api-js'; +import { sanitizeTokenError } from '../src/request-scoped-client.js'; +import { createMcpClient } from '../src/client.js'; +import type { McpClient, VerifiedCaller } from '../src/client.js'; +import { DownscopedTokenError, InsufficientScopeError, UnauthenticatedError } from '../src/errors.js'; +import { createAuth0Handlers, createTestKey, mintAccessToken, scopeMode } from './support/index.js'; +import type { TestKey } from './support/index.js'; + +const AUDIENCE = 'https://mcp.example.com'; +const DOWNSTREAM = 'https://downstream.example.com/resource'; +const GENERIC = 'The token operation failed.'; +const LEAK = 'LEAKY-UPSTREAM-DETAIL-4711'; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function useTenant(scope = scopeMode.echo) { + tenant = createAuth0Handlers({ key, scope }); + server.use(...tenant.handlers); +} + +class MapStore { + readonly entries = new Map(); + async get(k: string) { + return this.entries.get(k); + } + async set(k: string, v: CachedToken) { + this.entries.set(k, v); + } + async delete(k: string) { + this.entries.delete(k); + } +} + +function makeClient(extra: Partial[0]> = {}): McpClient { + return createMcpClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'mcp-client', + clientSecret: 'secret', + ...extra, + }); +} + +async function caller(client: McpClient, expiresIn = 3600): Promise { + const accessToken = await mintAccessToken(key, { + iss: tenant.issuer, + aud: AUDIENCE, + sub: 'user|1', + scope: 'openid', + expiresIn, + }); + return client.verifyAccessToken({ accessToken }); +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error('expected a rejection'); +} + +function expectSanitized(error: Error) { + expect(error.message).toBe(GENERIC); + expect(error.message).not.toContain(LEAK); + expect(error.cause).toBeDefined(); +} + +describe('request-scoped client: error hygiene', () => { + it.each([400, 401, 403, 500])('replaces the message of a failed exchange (%i)', async (status) => { + useTenant(); + tenant.state.failure = { + status, + body: { error: 'invalid_grant', error_description: LEAK }, + }; + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const error = await rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })); + + expectSanitized(error); + expect(error.cause).toBeInstanceOf(Error); + }); + + it('keeps the original error as the cause', async () => { + useTenant(); + tenant.state.failure = { + status: 400, + body: { error: 'invalid_grant', error_description: LEAK }, + }; + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const error = await rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })); + + expect(error.cause).toBeInstanceOf(Error); + expect(JSON.stringify(error.message)).not.toContain(LEAK); + }); + + it('hides the message when the caller claims are too close to expiry', async () => { + useTenant(); + const client = makeClient({ tokenStore: new MapStore() }); + const scoped = client.withToken(await caller(client, 2)); + + const error = await rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })); + + expectSanitized(error); + expect(error.message).not.toContain('expired'); + expect(tenant.state.exchangeCount).toBe(0); + }); + + it('hides the message of a network failure and keeps the cause', async () => { + useTenant(); + server.use(http.post(tenant.tokenEndpoint, () => HttpResponse.error())); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const error = await rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })); + + expectSanitized(error); + }); + + it('keeps the class of a core configuration error', async () => { + useTenant(); + const client = makeClient({ clientSecret: undefined }); + const scoped = client.withToken(await caller(client)); + + const error = await rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM })); + + expect(error).toBeInstanceOf(MissingClientAuthError); + expect(error.name).toBe('MissingClientAuthError'); + expectSanitized(error); + }); + + it('keeps code, status and headers when sanitizing a core AuthError', () => { + const original = new InvalidRequestError(LEAK, { 'www-authenticate': 'Bearer realm="x"' }); + + const safe = sanitizeTokenError(original) as AuthError; + + expect(safe).not.toBe(original); + expect(safe).toBeInstanceOf(InvalidRequestError); + expect(safe.name).toBe(original.name); + expect(safe.code).toBe(original.code); + expect(safe.statusCode).toBe(original.statusCode); + expect(safe.headers).toEqual(original.headers); + expect(safe.cause).toBe(original); + expect(safe.message).toBe(GENERIC); + }); + + it('copies headers, so the sanitized error shares no object with the original or another copy', () => { + const original = new AuthError(LEAK, 'invalid_request', 400, { + 'www-authenticate': 'Bearer realm="x"', + 'x-multi': ['a', 'b'], + }); + + const first = sanitizeTokenError(original) as AuthError; + const second = sanitizeTokenError(original) as AuthError; + + expect(first.headers).toEqual(original.headers); + expect(first.headers).not.toBe(original.headers); + expect(first.headers).not.toBe(second.headers); + expect(first.headers?.['x-multi']).not.toBe(original.headers?.['x-multi']); + (first.headers?.['x-multi'] as string[]).push('c'); + expect(original.headers?.['x-multi']).toEqual(['a', 'b']); + }); + + it('does not copy a foreign prototype onto the sanitized error', () => { + let ran = false; + class Foreign extends Error { + get leak() { + ran = true; + return LEAK; + } + } + class ForeignAuth extends AuthError { + get leak() { + ran = true; + return LEAK; + } + } + + const plain = sanitizeTokenError(new Foreign(LEAK)); + const auth = sanitizeTokenError(new ForeignAuth(LEAK, 'x', 418)) as AuthError; + + expect(plain).not.toBeInstanceOf(Foreign); + expect(auth).not.toBeInstanceOf(ForeignAuth); + expect(auth).toBeInstanceOf(AuthError); + expect(auth.code).toBe('x'); + expect(auth.statusCode).toBe(418); + expect((plain as unknown as { leak?: string }).leak).toBeUndefined(); + expect((auth as unknown as { leak?: string }).leak).toBeUndefined(); + expect(ran).toBe(false); + expect(plain.message).toBe(GENERIC); + expect(auth.message).toBe(GENERIC); + }); + + it('wraps a non-Error value without losing it', () => { + const safe = sanitizeTokenError(LEAK); + + expect(safe.message).toBe(GENERIC); + expect(safe.cause).toBe(LEAK); + }); + + it('returns each package error unchanged', () => { + for (const original of [ + new DownscopedTokenError(), + new InsufficientScopeError(['a']), + new UnauthenticatedError(), + ]) { + expect(sanitizeTokenError(original)).toBe(original); + } + }); + + it('hides the message of a failed client credentials grant', async () => { + useTenant(); + tenant.state.failure = { + status: 401, + grant: 'client_credentials', + body: { error: 'access_denied', error_description: LEAK }, + }; + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + expectSanitized(await rejection(scoped.getClientCredentialsToken({ audience: DOWNSTREAM }))); + }); + + it('hides the message of a failed connection exchange', async () => { + useTenant(); + server.use( + http.post(tenant.tokenEndpoint, async ({ request }) => { + const params = Object.fromEntries(new URLSearchParams(await request.text())); + if (!params['requested_token_type']) return undefined; + return HttpResponse.json({ error: 'access_denied', error_description: LEAK }, { status: 403 }); + }) + ); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + expectSanitized(await rejection(scoped.getTokenForService({ connection: 'github' }))); + }); + + it('gives every concurrent waiter its own sanitized error', async () => { + useTenant(); + tenant.state.failure = { + status: 400, + body: { error: 'invalid_grant', error_description: LEAK }, + }; + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const errors = await Promise.all( + Array.from({ length: 4 }, () => rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM }))) + ); + + expect(tenant.state.exchangeCount).toBe(1); + errors.forEach(expectSanitized); + expect(new Set(errors).size).toBe(4); + expect(new Set(errors.map((e) => e.cause)).size).toBe(1); + }); +}); + +describe('request-scoped client: package errors', () => { + it('passes a narrowed exchange through as the same DownscopedTokenError, without a cause', async () => { + useTenant(scopeMode.fixed('other:scope')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const error = await rejection(scoped.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' })); + + expect(error).toBeInstanceOf(DownscopedTokenError); + expect(error.message).toBe(new DownscopedTokenError().message); + expect(error.cause).toBeUndefined(); + }); + + it('passes a narrowed client credentials grant through unchanged', async () => { + useTenant(scopeMode.fixed('other:scope')); + const client = makeClient(); + const scoped = client.withToken(await caller(client)); + + const error = await rejection( + scoped.getClientCredentialsToken({ + audience: DOWNSTREAM, + scope: 'read:a', + }) + ); + + expect(error).toBeInstanceOf(DownscopedTokenError); + expect(error.cause).toBeUndefined(); + }); +}); From a8ab10a6634c55781792f3510f3a512b7779eb16 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:45:41 +0530 Subject: [PATCH 17/31] refactor(ai-mcp): make principal scope arrays readonly Principal.scopes and Principal.permissions are now readonly so callers cannot mutate the verified caller's grants after construction. Co-Authored-By: Claude --- packages/ai-mcp/src/types.ts | 4 ++-- packages/ai-mcp/test/types.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index ce10deb7..8881fb27 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -34,12 +34,12 @@ export interface Principal { /** Token expiry as epoch seconds (the `exp` claim). */ readonly expiresAt: number; /** Scopes parsed from the space-delimited `scope` claim. Empty when the claim is absent. */ - readonly scopes: string[]; + readonly scopes: readonly string[]; /** * RBAC permissions from the `permissions` claim. `null` when the claim is absent, * meaning permissions are unknown. An empty array means the caller has none. */ - readonly permissions: string[] | null; + readonly permissions: readonly string[] | null; /** * The OAuth client the token was issued to. Taken from the `client_id` claim, * falling back to `azp`. `null` when neither is present. diff --git a/packages/ai-mcp/test/types.spec.ts b/packages/ai-mcp/test/types.spec.ts index 147fb8ec..3d3a6267 100644 --- a/packages/ai-mcp/test/types.spec.ts +++ b/packages/ai-mcp/test/types.spec.ts @@ -29,8 +29,8 @@ describe('Principal', () => { it('has the expected field types', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); From 2566d1e65f77d87aedb3d9a29214c4265f75664c Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:45:53 +0530 Subject: [PATCH 18/31] feat(ai-mcp): re-export the core token store types 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 --- packages/ai-mcp/src/index.ts | 2 ++ .../test/smoke/fixtures/cjs-consumer.cts | 22 +++++++++++++++++-- .../test/smoke/fixtures/esm-consumer.mts | 22 +++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/ai-mcp/src/index.ts b/packages/ai-mcp/src/index.ts index b240ad10..0b6e6ce8 100644 --- a/packages/ai-mcp/src/index.ts +++ b/packages/ai-mcp/src/index.ts @@ -31,3 +31,5 @@ export { MemoryIndexedTokenStore } from './stores/memory-indexed-store.js'; export type { MemoryIndexedTokenStoreOptions } from './stores/memory-indexed-store.js'; export { createEncryptedTokenStore } from './stores/encrypted-store.js'; export type { EncryptedTokenStoreOptions } from './stores/encrypted-store.js'; +// Types a custom token store needs, re-exported so integrators do not have to depend on the core package directly. +export type { CachedToken, IndexedTokenStore, TokenIndexMember, TokenSet, TokenStore } from '@auth0/auth0-api-js'; diff --git a/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts b/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts index 4d843b9e..1d1f7a0f 100644 --- a/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts +++ b/packages/ai-mcp/test/smoke/fixtures/cjs-consumer.cts @@ -1,6 +1,24 @@ import { UnauthenticatedError } from '@auth0/ai-mcp'; -import type { McpClientOptions, Principal, RequestContext, RequestScopedMcpClient } from '@auth0/ai-mcp'; +import type { + CachedToken, + IndexedTokenStore, + McpClientOptions, + Principal, + RequestContext, + RequestScopedMcpClient, + TokenSet, + TokenStore, +} from '@auth0/ai-mcp'; const error: Error = new UnauthenticatedError(); -export type Surface = [McpClientOptions, Principal, RequestContext, RequestScopedMcpClient]; +export type Surface = [ + McpClientOptions, + Principal, + RequestContext, + RequestScopedMcpClient, + CachedToken, + IndexedTokenStore, + TokenSet, + TokenStore, +]; export default error; diff --git a/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts b/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts index 4d843b9e..1d1f7a0f 100644 --- a/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts +++ b/packages/ai-mcp/test/smoke/fixtures/esm-consumer.mts @@ -1,6 +1,24 @@ import { UnauthenticatedError } from '@auth0/ai-mcp'; -import type { McpClientOptions, Principal, RequestContext, RequestScopedMcpClient } from '@auth0/ai-mcp'; +import type { + CachedToken, + IndexedTokenStore, + McpClientOptions, + Principal, + RequestContext, + RequestScopedMcpClient, + TokenSet, + TokenStore, +} from '@auth0/ai-mcp'; const error: Error = new UnauthenticatedError(); -export type Surface = [McpClientOptions, Principal, RequestContext, RequestScopedMcpClient]; +export type Surface = [ + McpClientOptions, + Principal, + RequestContext, + RequestScopedMcpClient, + CachedToken, + IndexedTokenStore, + TokenSet, + TokenStore, +]; export default error; From f0411e030207706e9717d18f18f7c96f182de154 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:46:07 +0530 Subject: [PATCH 19/31] test(ai-mcp): snapshot the public API surface 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 --- packages/ai-mcp/test/api-surface.spec.ts | 71 +++++++++++++++++++ .../ai-mcp/test/smoke/module-formats.spec.ts | 4 +- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/ai-mcp/test/api-surface.spec.ts diff --git a/packages/ai-mcp/test/api-surface.spec.ts b/packages/ai-mcp/test/api-surface.spec.ts new file mode 100644 index 00000000..a6584b17 --- /dev/null +++ b/packages/ai-mcp/test/api-surface.spec.ts @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageDir = fileURLToPath(new URL("..", import.meta.url)); +const built = + existsSync(path.join(packageDir, "dist", "esm", "index.js")) && + existsSync(path.join(packageDir, "dist", "cjs", "index.js")); + +// CI builds before testing, so a missing build there is a failure, not a skip. +if (process.env.CI === "true" && !built) { + describe("public API surface", () => { + it("requires dist to be built in CI", () => { + expect.fail("dist/esm and dist/cjs must exist; run the build before the API surface test"); + }); + }); +} + +// Any added or removed public runtime export must be reflected here on purpose. +const ROOT_EXPORTS = [ + "DownscopedTokenError", + "ForeignCallerError", + "InsufficientScopeError", + "MemoryIndexedTokenStore", + "UnauthenticatedError", + "buildPrincipal", + "createEncryptedTokenStore", + "createMcpClient", + "createMcpHandler", + "getCallerMcpClient", + "getCallerMcpClientOrThrow", + "getCallerPrincipal", + "getRequestContext", + "requireScopes", + "runWithRequestContext", + "withRequiredScopes", +]; + +const MCP_SDK_EXPORTS = [ + "asTokenVerifier", + "createMcpSdkHandler", + "createNodeMcpHandler", + "toNodeHandler", +]; + +function names(specifier: string, format: "esm" | "cjs"): string[] { + const args = + format === "esm" + ? [ + "--input-type=module", + "-e", + `import('${specifier}').then((m) => console.log(JSON.stringify(Object.keys(m).sort())))`, + ] + : ["-e", `console.log(JSON.stringify(Object.keys(require('${specifier}')).sort()))`]; + const out = execFileSync(process.execPath, args, { cwd: packageDir, encoding: "utf8" }).trim(); + return JSON.parse(out) as string[]; +} + +describe.skipIf(!built)("public API surface", () => { + for (const format of ["esm", "cjs"] as const) { + it(`exports exactly the expected runtime names from the root entry (${format})`, () => { + expect(names("@auth0/ai-mcp", format)).toEqual(ROOT_EXPORTS); + }); + + it(`exports exactly the expected runtime names from ./mcp-sdk (${format})`, () => { + expect(names("@auth0/ai-mcp/mcp-sdk", format)).toEqual(MCP_SDK_EXPORTS); + }); + } +}); diff --git a/packages/ai-mcp/test/smoke/module-formats.spec.ts b/packages/ai-mcp/test/smoke/module-formats.spec.ts index 3e8369d1..c9e74bc2 100644 --- a/packages/ai-mcp/test/smoke/module-formats.spec.ts +++ b/packages/ai-mcp/test/smoke/module-formats.spec.ts @@ -88,7 +88,7 @@ describe.skipIf(!built)("built package formats", () => { "-e", "import('@auth0/ai-mcp/mcp-sdk').then((m) => console.log(JSON.stringify(Object.keys(m).sort())))", ]); - expect(JSON.parse(out)).toEqual(expect.arrayContaining(mcpSdkValues)); + expect(JSON.parse(out)).toEqual([...mcpSdkValues].sort()); }); it("resolves @auth0/ai-mcp/mcp-sdk through the exports map in CommonJS", () => { @@ -96,7 +96,7 @@ describe.skipIf(!built)("built package formats", () => { "-e", "const m = require('@auth0/ai-mcp/mcp-sdk'); console.log(JSON.stringify(Object.keys(m).sort()))", ]); - expect(JSON.parse(out)).toEqual(expect.arrayContaining(mcpSdkValues)); + expect(JSON.parse(out)).toEqual([...mcpSdkValues].sort()); }); it("does not export the mcp-sdk names from the root entry", () => { From 625c6be6b3886ff9d9fe00df557c9dbf35b7c97c Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:48:12 +0530 Subject: [PATCH 20/31] fix(ai-mcp): harden handler against malformed input and observer failures - 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 --- packages/ai-mcp/src/handler.ts | 28 +++++++++++++--- packages/ai-mcp/test/handler.spec.ts | 50 +++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/ai-mcp/src/handler.ts b/packages/ai-mcp/src/handler.ts index 55f995ee..c3ec3b3f 100644 --- a/packages/ai-mcp/src/handler.ts +++ b/packages/ai-mcp/src/handler.ts @@ -85,7 +85,7 @@ export interface McpHandlerOptions { }; /** * Called with the raw error whenever the handler answers 500. The error never - * reaches the response. A throwing hook is ignored. + * reaches the response. A throwing hook, or one that returns a rejected promise, is ignored. */ onError?: (error: unknown) => void; } @@ -347,7 +347,7 @@ function validateMetadataPath(path: string, origin: string): void { * * @throws {TypeError} When `resource` is not an absolute URL. * @throws {InvalidConfigurationError} When `metadata.path` is not a normalized absolute path, - * or `realm` is empty or contains characters a challenge cannot carry. + * or `realm` is empty, whitespace-only or contains characters a challenge cannot carry. */ export function createMcpHandler( options: McpHandlerOptions, @@ -356,7 +356,7 @@ export function createMcpHandler( if ( options.realm !== undefined && (typeof options.realm !== "string" || - options.realm === "" || + options.realm.trim() === "" || sanitizeChallengeValue(options.realm) !== options.realm) ) { throw new InvalidConfigurationError( @@ -420,7 +420,11 @@ export function createMcpHandler( function report(error: unknown): void { try { - options.onError?.(error); + const result: unknown = options.onError?.(error); + if (isThenable(result)) { + // An async observer that rejects must not surface as an unhandled rejection. + result.then(undefined, () => {}); + } } catch { // A failing observer must not change the response. } @@ -443,7 +447,13 @@ export function createMcpHandler( } return async function handle(request: Request): Promise { - const url = new URL(request.url); + let url: URL; + try { + url = new URL(request.url); + } catch { + // Same treatment as other malformed requests: a well-formed 400 challenge. + return fail(() => authFailure(400, "invalid_request"), undefined); + } if (url.pathname === metadataPath) { if (request.method !== "GET") { return new Response(null, { status: 405, headers: { allow: "GET" } }); @@ -534,3 +544,11 @@ export function createMcpHandler( } }; } + +function isThenable(value: unknown): value is PromiseLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} diff --git a/packages/ai-mcp/test/handler.spec.ts b/packages/ai-mcp/test/handler.spec.ts index 5ae53e6a..9f08f795 100644 --- a/packages/ai-mcp/test/handler.spec.ts +++ b/packages/ai-mcp/test/handler.spec.ts @@ -1212,7 +1212,7 @@ describe("metadata path validation", () => { }); describe("realm validation", () => { - it.each(["", 'a"b', "a\\b", "a\nb", "é", 42 as unknown as string])( + it.each(["", " ", "\t ", 'a"b', "a\\b", "a\nb", "é", 42 as unknown as string])( "rejects %j", (realm) => { expect(() => makeHandler({ realm })).toThrow(InvalidConfigurationError); @@ -1224,6 +1224,54 @@ describe("realm validation", () => { }); }); +describe("onError observer failures", () => { + it("swallows a rejected promise returned by onError without changing the response", async () => { + const unhandled: unknown[] = []; + const listener = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", listener); + try { + useTenant(); + // Plain function: vi.fn would attach its own handler to the returned promise. + let calls = 0; + const onError = () => { + calls += 1; + return Promise.reject(new Error("observer down")); + }; + const { handle } = makeHandler({ onError }, async () => { + throw new Error("boom"); + }); + const response = await handle(request(await bearer())); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(calls).toBe(1); + expect(response.status).toBe(500); + expect(await bodyOf(response)).toEqual({ + error: "server_error", + error_description: "Internal server error.", + }); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", listener); + } + }); +}); + +describe("malformed request url", () => { + it.each(["not a url", "/relative/path", ""])( + "answers 400 invalid_request with a challenge for %j instead of throwing", + async (url) => { + const { handle } = makeHandler(); + const response = await handle({ + url, + method: "POST", + headers: new Headers(), + } as unknown as Request); + expect(response.status).toBe(400); + expect(await bodyOf(response)).toMatchObject({ error: "invalid_request" }); + expect(wwwAuthenticate(response)).toContain('error="invalid_request"'); + }, + ); +}); + describe("pre-verification responses never escape", () => { it("returns a generic 500 when building the no-credential response throws", async () => { const { handle } = makeHandler({ realm: "ok" }); From cb90737a1d23d4f00734dd0e70a4e69abacb405b Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:48:27 +0530 Subject: [PATCH 21/31] chore(examples): assert the MCP server example resolves a supported MCP 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 --- examples/ai-mcp-server/README.md | 12 +++++ .../ai-mcp-server/test/sdk-version.spec.ts | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 examples/ai-mcp-server/test/sdk-version.spec.ts diff --git a/examples/ai-mcp-server/README.md b/examples/ai-mcp-server/README.md index 6626e5f9..16178c22 100644 --- a/examples/ai-mcp-server/README.md +++ b/examples/ai-mcp-server/README.md @@ -39,4 +39,16 @@ npm run build npm test ``` +The example needs `@modelcontextprotocol/sdk` 1.31 or newer, and `npm test` fails if the resolved copy is older. If a hoisted older copy wins, install a newer one next to the example without saving. Create a scratch directory outside the repository and run the install there, then copy the result in: + +```sh +SCRATCH="$(mktemp -d)" +cd "$SCRATCH" && echo '{"name":"scratch","private":true}' > package.json +npm install --no-save --no-package-lock --ignore-scripts @modelcontextprotocol/sdk@1.32.1 +cd - && mkdir -p examples/ai-mcp-server/node_modules +cp -R "$SCRATCH/node_modules/." examples/ai-mcp-server/node_modules/ +``` + +Any version at or above 1.31 works. The copy also brings the SDK's transitive dependencies. A clean checkout fails this test until the root dependency resolves 1.31 or later. + `npm test` runs both servers on loopback with a mocked Auth0 tenant. It makes no network calls. diff --git a/examples/ai-mcp-server/test/sdk-version.spec.ts b/examples/ai-mcp-server/test/sdk-version.spec.ts new file mode 100644 index 00000000..0c29ea1b --- /dev/null +++ b/examples/ai-mcp-server/test/sdk-version.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; + +const MIN = [1, 31, 0] as const; +const SDK = "@modelcontextprotocol/sdk"; + +function resolvedSdkVersion(): { version: string; dir: string } { + // Resolve the same entry the example imports, then walk up to the package root. + let dir = dirname(createRequire(import.meta.url).resolve(`${SDK}/server/mcp.js`)); + while (dir !== dirname(dir)) { + const manifest = join(dir, "package.json"); + if (existsSync(manifest)) { + const pkg = JSON.parse(readFileSync(manifest, "utf8")) as { + name?: string; + version: string; + }; + if (pkg.name === SDK) return { version: pkg.version, dir }; + } + dir = dirname(dir); + } + throw new Error(`Could not locate the package root for ${SDK}`); +} + +// Semver precedence: build metadata is ignored, and any prerelease sorts below +// its release, so 1.31.0-beta.1 does not satisfy >= 1.31.0. +function meetsMinimum(version: string): boolean { + const core = version.split("+")[0]; + const dash = core.indexOf("-"); + const hasPrerelease = dash !== -1; + const nums = (hasPrerelease ? core.slice(0, dash) : core).split(".").map(Number); + if (nums.length !== 3 || nums.some((n) => !Number.isInteger(n))) return false; + for (let i = 0; i < 3; i++) { + if (nums[i] !== MIN[i]) return nums[i] > MIN[i]; + } + return !hasPrerelease; +} + +describe("MCP SDK version", () => { + it(`resolves ${SDK} >= ${MIN.join(".")}`, () => { + const { version, dir } = resolvedSdkVersion(); + const ok = meetsMinimum(version); + expect( + ok, + `${SDK} ${version} resolved from ${dir}, but this example needs >= ${MIN.join(".")}. ` + + `Install a newer copy next to the example without touching the lockfile, see the README.`, + ).toBe(true); + }); +}); From 0af367909943c09a20af0bfc3fdbc7e4cea2f58b Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:56:48 +0530 Subject: [PATCH 22/31] docs(ai-mcp): add README, examples and guides 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 --- packages/ai-mcp/EXAMPLES.md | 242 +++++++++++++++++++++ packages/ai-mcp/README.md | 152 +++++++++++++ packages/ai-mcp/docs/Errors.md | 99 +++++++++ packages/ai-mcp/docs/Handler.md | 178 +++++++++++++++ packages/ai-mcp/docs/KnownLimitations.md | 46 ++++ packages/ai-mcp/docs/OnBehalfOf.md | 102 +++++++++ packages/ai-mcp/docs/OrganizationPolicy.md | 56 +++++ packages/ai-mcp/docs/RequestContext.md | 64 ++++++ packages/ai-mcp/docs/ScopeEnforcement.md | 111 ++++++++++ packages/ai-mcp/docs/TokenCaching.md | 177 +++++++++++++++ packages/ai-mcp/docs/TokenVault.md | 57 +++++ packages/ai-mcp/docs/TokenVerification.md | 97 +++++++++ 12 files changed, 1381 insertions(+) create mode 100644 packages/ai-mcp/EXAMPLES.md create mode 100644 packages/ai-mcp/README.md create mode 100644 packages/ai-mcp/docs/Errors.md create mode 100644 packages/ai-mcp/docs/Handler.md create mode 100644 packages/ai-mcp/docs/KnownLimitations.md create mode 100644 packages/ai-mcp/docs/OnBehalfOf.md create mode 100644 packages/ai-mcp/docs/OrganizationPolicy.md create mode 100644 packages/ai-mcp/docs/RequestContext.md create mode 100644 packages/ai-mcp/docs/ScopeEnforcement.md create mode 100644 packages/ai-mcp/docs/TokenCaching.md create mode 100644 packages/ai-mcp/docs/TokenVault.md create mode 100644 packages/ai-mcp/docs/TokenVerification.md diff --git a/packages/ai-mcp/EXAMPLES.md b/packages/ai-mcp/EXAMPLES.md new file mode 100644 index 00000000..d954b496 --- /dev/null +++ b/packages/ai-mcp/EXAMPLES.md @@ -0,0 +1,242 @@ +# Examples + +Each recipe is a complete module. Replace the placeholder domain, audiences and scopes with your own. + +- [A complete server with a token exchange](#a-complete-server-with-a-token-exchange) +- [Overlapping scopes with a shared, encrypted cache](#overlapping-scopes-with-a-shared-encrypted-cache) +- [Restricting a server to specific organizations](#restricting-a-server-to-specific-organizations) +- [Requiring DPoP](#requiring-dpop) +- [Calling a third-party API with Token Vault](#calling-a-third-party-api-with-token-vault) +- [Unit testing a tool](#unit-testing-a-tool) + +## A complete server with a token exchange + +A Node server that requires an `mcp:access` scope for every request, gates one tool on its own scope, and calls a downstream API with an exchanged token. The tool catches the package's errors and answers with generic `isError` results, so nothing from an upstream response reaches the caller. + +```ts +import { createServer } from 'node:http'; +import { + DownscopedTokenError, + InsufficientScopeError, + MemoryIndexedTokenStore, + createMcpClient, + getCallerMcpClientOrThrow, + getCallerPrincipal, + withRequiredScopes, +} from '@auth0/ai-mcp'; +import { createNodeMcpHandler } from '@auth0/ai-mcp/mcp-sdk'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const RESOURCE = 'https://mcp.example.com/mcp'; +const DOWNSTREAM = 'https://reports-api.example.com'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: RESOURCE, + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + tokenStore: new MemoryIndexedTokenStore(), +}); + +function text(value: string) { + return { content: [{ type: 'text' as const, text: value }] }; +} + +function failure(message: string) { + return { ...text(message), isError: true as const }; +} + +function buildServer(): McpServer { + const server = new McpServer({ name: 'reports', version: '1.0.0' }); + + server.registerTool( + 'whoami', + { description: 'Returns the identity of the caller.' }, + async () => { + const principal = getCallerPrincipal(); + return text(JSON.stringify({ sub: principal?.sub, org: principal?.orgId })); + }, + ); + + server.registerTool( + 'list_reports', + { description: 'Lists reports using the identity of the caller.' }, + withRequiredScopes('tool:list_reports', async () => { + try { + const token = await getCallerMcpClientOrThrow().getTokenOnBehalfOf({ + audience: DOWNSTREAM, + scope: 'read:reports', + }); + const response = await fetch(`${DOWNSTREAM}/reports`, { + headers: { authorization: `Bearer ${token.accessToken}` }, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + return failure(`The reports API answered ${response.status}.`); + } + return text(await response.text()); + } catch (error) { + // Log the class only. Messages can carry details that must not reach logs or callers. + console.error('list_reports failed', error instanceof Error ? error.name : 'unknown'); + if (error instanceof DownscopedTokenError || error instanceof InsufficientScopeError) { + return failure('The caller is not permitted to read reports.'); + } + return failure('The reports could not be retrieved.'); + } + }), + ); + + return server; +} + +const handler = createNodeMcpHandler({ + client, + resource: RESOURCE, + authorizationServers: ['https://your-tenant.us.auth0.com/'], + requiredScopes: ['mcp:access'], + metadata: { + scopesSupported: ['mcp:access', 'tool:list_reports'], + resourceName: 'Reports MCP server', + }, + createServer: buildServer, + onError: (error) => console.error('mcp error', error instanceof Error ? error.name : 'unknown'), +}); + +createServer((req, res) => void handler(req, res)).listen(3000); +``` + +## Overlapping scopes with a shared, encrypted cache + +When your tools request overlapping scope sets for the same downstream API, `non_strict` matching reuses a broader cached token for a narrower request. It needs an indexed store. The encrypted wrapper keeps tokens unreadable in the inner store. + +```ts +import { + MemoryIndexedTokenStore, + createEncryptedTokenStore, + createMcpClient, + getCallerMcpClientOrThrow, +} from '@auth0/ai-mcp'; + +const secret = process.env.TOKEN_STORE_SECRET; +if (secret === undefined) { + throw new Error('TOKEN_STORE_SECRET is not set.'); +} + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + tokenStore: createEncryptedTokenStore(new MemoryIndexedTokenStore({ maxEntries: 10_000 }), { secret }), + scopeMatching: 'non_strict', +}); + +// Inside tools: the second call reuses the token from the first. +export async function twoCalls(): Promise { + const mcpClient = getCallerMcpClientOrThrow(); + await mcpClient.getTokenOnBehalfOf({ audience: 'https://reports-api.example.com', scope: 'read:reports write:reports' }); + await mcpClient.getTokenOnBehalfOf({ audience: 'https://reports-api.example.com', scope: 'read:reports' }); +} +``` + +In production, replace `MemoryIndexedTokenStore` with a store on shared storage. See [Writing a store](./docs/TokenCaching.md#writing-a-store). + +## Restricting a server to specific organizations + +```ts +import { createMcpClient, createMcpHandler, getCallerPrincipal } from '@auth0/ai-mcp'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + organizationPolicy: 'required', + organizationId: ['org_abc123', 'org_def456'], +}); + +export const handle = createMcpHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + handler: async () => Response.json({ organization: getCallerPrincipal()?.orgId }), +}); +``` + +The handler answers tokens without an `org_id` claim, and tokens from other organizations, with a 401 before your handler runs. + +## Requiring DPoP + +```ts +import { createMcpClient, createMcpHandler } from '@auth0/ai-mcp'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + dpop: { mode: 'required' }, +}); + +export const handle = createMcpHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + handler: async () => Response.json({ ok: true }), +}); +``` + +Requests must send `Authorization: DPoP ` and a `DPoP` proof header. The handler answers Bearer requests with a DPoP challenge, and the metadata document declares DPoP-bound tokens as mandatory. The handler checks the proof against `request.url`, so behind a proxy make sure the handler sees the public URL. + +## Calling a third-party API with Token Vault + +```ts +import { getCallerMcpClientOrThrow } from '@auth0/ai-mcp'; + +export async function listFiles(): Promise { + const token = await getCallerMcpClientOrThrow().getTokenForService({ + connection: 'google-oauth2', + loginHint: 'user@example.com', + }); + const response = await fetch('https://www.googleapis.com/drive/v3/files', { + headers: { authorization: `Bearer ${token.accessToken}` }, + }); + return response.json(); +} +``` + +## Unit testing a tool + +A tool reads its caller from the request context, so a test only has to supply one. `RequestContext` is a plain object with a principal and a request-scoped client, which you can stub: + +```ts +import { getCallerPrincipal, runWithRequestContext, withRequiredScopes } from '@auth0/ai-mcp'; +import type { Principal, RequestContext } from '@auth0/ai-mcp'; + +const principal: Principal = { + sub: 'auth0|user-1', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scopes: ['tool:list_reports'], + permissions: null, + clientId: 'client-1', + orgId: null, +}; + +const context: RequestContext = { + principal, + mcpClient: { + getPrincipal: () => principal, + getTokenOnBehalfOf: async () => { + throw new Error('not used in this test'); + }, + getClientCredentialsToken: async () => { + throw new Error('not used in this test'); + }, + getTokenForService: async () => { + throw new Error('not used in this test'); + }, + }, +}; + +const tool = withRequiredScopes('tool:list_reports', async () => getCallerPrincipal()?.sub); + +export async function runTool() { + return runWithRequestContext(context, () => tool()); +} +``` diff --git a/packages/ai-mcp/README.md b/packages/ai-mcp/README.md new file mode 100644 index 00000000..ca0c4786 --- /dev/null +++ b/packages/ai-mcp/README.md @@ -0,0 +1,152 @@ +# @auth0/ai-mcp + +`@auth0/ai-mcp` secures [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers with Auth0. It is built on [`@auth0/auth0-api-js`](https://github.com/auth0/auth0-auth-js/tree/main/packages/auth0-api-js). + +An MCP server is a resource server. This package ships no MCP framework of its own. It verifies the access token on every request, hands the verified caller to your tool code, and exchanges the caller's token for tokens to downstream APIs, with an optional cache in front of the exchange. A small adapter connects all of this to the official MCP TypeScript SDK. + +[Documentation](#documentation) | [Getting started](#getting-started) | [Feedback](#feedback) + +## How it works + +1. **Verify.** Before your MCP handler runs, the request handler checks the `Authorization` header (Bearer or DPoP) against your Auth0 tenant. A missing or invalid token gets a 401 with a `WWW-Authenticate` challenge and never reaches a tool. The handler also serves the RFC 9728 protected resource metadata document. +2. **Gate.** The gate checks scopes that every request must carry and answers a missing one with HTTP 403 `insufficient_scope`. A tool checks the scopes that only it needs and answers a missing one with a tool result that has `isError: true`. +3. **Context.** On a valid token the handler builds a `Principal` from the verified claims and binds it, together with a request-scoped client, to the request through `AsyncLocalStorage`. Tool code reads the caller from there, so tool signatures stay clean. +4. **On Behalf Of.** A tool calls `getTokenOnBehalfOf({ audience, scope })` to exchange the caller's token for a token to a downstream API. A token store enables caching. See [Token caching](./docs/TokenCaching.md). +5. **Token Vault.** A tool calls `getTokenForService({ connection })` to get a federated provider token, such as a Google access token, through Auth0 Token Vault. See [Token Vault](./docs/TokenVault.md). +6. **Client credentials.** A tool calls `getClientCredentialsToken({ audience, scope })` to act as the server itself. See [On Behalf Of](./docs/OnBehalfOf.md#client-credentials). + +## Getting started + +### 1. Install + +```bash +npm install @auth0/ai-mcp @modelcontextprotocol/sdk +``` + +`@auth0/auth0-api-js` is a regular dependency, so npm installs it with the package. Your application does not need to depend on it directly, because the token store types are re-exported from `@auth0/ai-mcp`. + +`@modelcontextprotocol/sdk` (version 1.31 or later in the 1.x line) is an optional peer dependency. Only the `@auth0/ai-mcp/mcp-sdk` entry point imports it, so install it when you use that entry point. The main entry point `@auth0/ai-mcp` never loads it. + +The package runs on Node.js and on runtimes that provide `node:async_hooks` with `AsyncLocalStorage`. + +### 2. Create the client + +```ts +import { createMcpClient } from '@auth0/ai-mcp'; + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, +}); +``` + +`audience` is the identifier of your MCP server as an API in Auth0. Access tokens must be issued for it. Your tools need `clientId` and `clientSecret` only when they exchange tokens. A server that just verifies incoming tokens can omit them. + +### 3. Serve MCP over HTTP + +The `@auth0/ai-mcp/mcp-sdk` entry point connects the request handler to the official MCP SDK and to Node's `http` module: + +```ts +import { createServer } from 'node:http'; +import { createMcpClient, getCallerMcpClientOrThrow, withRequiredScopes } from '@auth0/ai-mcp'; +import { createNodeMcpHandler } from '@auth0/ai-mcp/mcp-sdk'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, +}); + +// Called once per request. Register tools here. +function buildServer(): McpServer { + const server = new McpServer({ name: 'reports', version: '1.0.0' }); + + server.registerTool( + 'list_reports', + { description: 'Lists reports for the calling user.' }, + withRequiredScopes('tool:list_reports', async () => { + const mcpClient = getCallerMcpClientOrThrow(); + const token = await mcpClient.getTokenOnBehalfOf({ + audience: 'https://reports-api.example.com', + scope: 'read:reports', + }); + const response = await fetch('https://reports-api.example.com/reports', { + headers: { authorization: `Bearer ${token.accessToken}` }, + }); + return { content: [{ type: 'text' as const, text: await response.text() }] }; + }), + ); + + return server; +} + +const handler = createNodeMcpHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + requiredScopes: ['mcp:access'], + createServer: buildServer, +}); + +createServer((req, res) => void handler(req, res)).listen(3000); +``` + +What this gives you: + +- `POST /mcp` requires a valid access token with the `mcp:access` scope. Otherwise the client gets a 401 or 403 with a `WWW-Authenticate` challenge. +- `GET /.well-known/oauth-protected-resource/mcp` serves the protected resource metadata, so MCP clients can discover your authorization server. +- `list_reports` runs only for callers whose token carries `tool:list_reports`. Other callers get a tool result with `isError: true`. +- The token exchange always acts for the caller of the current request. + +`createNodeMcpHandler` mounts on `http.createServer` and on Express with `app.all('/mcp', handler)`. Mount it before any body-parsing middleware. For other runtimes use `createMcpSdkHandler`, which takes a standard `Request` and returns a `Response`. For a different MCP stack use [`createMcpHandler`](./docs/Handler.md). + +## Documentation + +- [Handler and adapters](./docs/Handler.md): the framework-agnostic handler, the MCP SDK adapter and the Node adapter. +- [Token verification](./docs/TokenVerification.md): what the check covers, how the `Principal` is built, and DPoP support. +- [Request context](./docs/RequestContext.md): reading the caller inside a tool. +- [Scope enforcement](./docs/ScopeEnforcement.md): gate-level and tool-level scope checks. +- [On Behalf Of and client credentials](./docs/OnBehalfOf.md): downstream tokens for the caller and for the server. +- [Token caching](./docs/TokenCaching.md): token stores, scope matching modes and the encrypted store. +- [Token Vault](./docs/TokenVault.md): federated provider tokens. +- [Organization policy](./docs/OrganizationPolicy.md): accepting or requiring an organization. +- [Errors](./docs/Errors.md): every error class, with its HTTP status and code. +- [Known limitations](./docs/KnownLimitations.md) +- [Examples](./EXAMPLES.md): recipes for common setups. + +## Feedback + +### Contributing + +Feedback and contributions are welcome. Before you start, read: + +- [Auth0's general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) +- [Auth0's code of conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) + +### Raise an issue + +To report a bug or request a feature, [raise an issue on the issue tracker](https://github.com/auth0/auth0-ai-js/issues). + +## Vulnerability reporting + +Do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/responsible-disclosure-policy) explains how to report them. + +## What is Auth0? + +

+ + + + Auth0 Logo + +

+

+ Auth0 is an easy to implement, adaptable authentication and authorization platform. To learn more, see Why Auth0? +

+

+ This project is licensed under the Apache 2.0 license. See the LICENSE file for more information. +

diff --git a/packages/ai-mcp/docs/Errors.md b/packages/ai-mcp/docs/Errors.md new file mode 100644 index 00000000..7fbb6887 --- /dev/null +++ b/packages/ai-mcp/docs/Errors.md @@ -0,0 +1,99 @@ +# Errors + +Errors fall into three groups: the classes this package exports, errors from the core client that pass through it, and configuration errors thrown when you create a client or handler. + +All authentication errors extend `AuthError` from `@auth0/auth0-api-js`, which carries a `code`, an optional `statusCode` and optional `headers`. + +## Errors exported by `@auth0/ai-mcp` + +| Class | `statusCode` | `code` | Thrown when | +| --- | --- | --- | --- | +| `UnauthenticatedError` | 401 | `invalid_token` | Protected code runs without an authenticated caller, for example outside a request context, or `withToken` receives an expired token. | +| `InsufficientScopeError` | 403 | `insufficient_scope` | The caller lacks a scope that `requireScopes` checks. | +| `DownscopedTokenError` | 403 | `insufficient_scope` | A token exchange or client credentials grant succeeded but did not include every requested scope. | +| `ForeignCallerError` | none | none | `McpClient.withToken` receives a handle that this client did not issue. This is a programming error. It extends `Error`, not `AuthError`, so it is never turned into a challenge. | + +Messages are generic by design: + +- `InsufficientScopeError` never names the scopes in its message. Its `requiredScopes` property (a frozen list) is for server-side use such as logging. +- `DownscopedTokenError` carries no scope data, which keeps granted and requested scope sets out of responses and logs. +- `UnauthenticatedError` and `InsufficientScopeError` accept `headers`, which you can pass to your own response. + +## What the handler returns + +`createMcpHandler` turns failures into HTTP responses with a generic JSON body and, where RFC 6750 calls for it, a `WWW-Authenticate` challenge. + +| Failure | HTTP status | `error` value | +| --- | --- | --- | +| No credential | 401 | none in the challenge | +| Malformed `Authorization` or `DPoP` header | 400 | `invalid_request` | +| `VerifyAccessTokenError` (bad signature, expired, wrong audience or issuer) | 401 | `invalid_token` | +| `MissingOrganizationError`, `OrganizationNotAllowedError` | 401 | `invalid_token` | +| `InvalidDpopProofError` | 401 | `invalid_dpop_proof` | +| DPoP nonce required | 401 | `use_dpop_nonce` | +| `InvalidRequestError` | 400 | `invalid_request` | +| Missing scope in `requiredScopes` | 403 | `insufficient_scope` | +| `UnauthenticatedError` thrown by your handler | 401 | `invalid_token` | +| `InsufficientScopeError` or `DownscopedTokenError` thrown by your handler | 403 | `insufficient_scope` | +| Anything else thrown by your handler, including core and network errors | 500 | none, generic body | + +Only this package's three error classes become client-facing failures when they escape your handler. A failed token exchange or a `ForeignCallerError` is an internal error: the client gets a generic 500 and `onError` receives the real error. + +With the official MCP SDK's `McpServer`, the SDK turns an error thrown inside a tool callback into a tool result with `isError: true`, so the error does not reach the handler. Catch errors in the tool when you want control over the message. + +## Core errors + +These come from `@auth0/auth0-api-js` and keep their class when they pass through the package. + +| Class | `statusCode` | `code` | +| --- | --- | --- | +| `VerifyAccessTokenError` | 401 | `verify_access_token_error` | +| `MissingOrganizationError` | 401 | `missing_organization` | +| `OrganizationNotAllowedError` | 401 | `organization_not_allowed` | +| `InvalidDpopProofError` | 400 | `invalid_dpop_proof` | +| `InvalidRequestError` | 400 | `invalid_request` | + +`MissingOrganizationError` and `OrganizationNotAllowedError` extend `VerifyAccessTokenError`. Their `code` is for application logic and is never written to the challenge. Failures from the token endpoint, such as `TokenExchangeError` and `TokenByClientCredentialsError`, are also core classes. + +A request-scoped client method sanitizes the errors it throws: it replaces the message with `The token operation failed.`, keeps the class, `code`, `statusCode` and `headers`, and attaches the original error as `cause`. The package's own errors above pass through unchanged. Never serialize `cause` into a response. + +## Configuration errors + +`InvalidConfigurationError` (from `@auth0/auth0-api-js`) is thrown at construction time when: + +- `scopeMatching` is `'non_strict'` and `tokenStore` is missing or not an indexed store, +- you pass `apiClient` together with `clientId`, `clientSecret`, `scopeMatching`, `organizationPolicy`, `organizationId` or `dpop`, +- `organizationId` is set while `organizationPolicy` is not `'required'`, +- `createEncryptedTokenStore` receives a `secret` shorter than 32 characters, +- `MemoryIndexedTokenStore` receives a `maxEntries` that is not a positive integer, +- `createMcpHandler` receives an invalid `metadata.path` or `realm`. + +Handling an error in a tool: + +```ts +import { + DownscopedTokenError, + InsufficientScopeError, + UnauthenticatedError, + getCallerMcpClientOrThrow, +} from '@auth0/ai-mcp'; + +export async function callDownstream() { + try { + const token = await getCallerMcpClientOrThrow().getTokenOnBehalfOf({ + audience: 'https://reports-api.example.com', + scope: 'read:reports', + }); + return { content: [{ type: 'text' as const, text: token.grantedScopes.join(' ') }] }; + } catch (error) { + if (error instanceof DownscopedTokenError || error instanceof InsufficientScopeError) { + return { isError: true as const, content: [{ type: 'text' as const, text: 'Not permitted.' }] }; + } + if (error instanceof UnauthenticatedError) { + throw error; + } + console.error('downstream call failed', error instanceof Error ? error.name : 'unknown'); + return { isError: true as const, content: [{ type: 'text' as const, text: 'The request failed.' }] }; + } +} +``` diff --git a/packages/ai-mcp/docs/Handler.md b/packages/ai-mcp/docs/Handler.md new file mode 100644 index 00000000..70fae5ad --- /dev/null +++ b/packages/ai-mcp/docs/Handler.md @@ -0,0 +1,178 @@ +# Handler and adapters + +The package is built in layers. `createMcpHandler` is the framework-agnostic core: it takes a standard Fetch API `Request` and returns a `Response`. The `@auth0/ai-mcp/mcp-sdk` entry point adds adapters for the official MCP TypeScript SDK and for Node's `http` module on top of it. + +| Function | Entry point | Use it when | +| --- | --- | --- | +| `createMcpHandler` | `@auth0/ai-mcp` | You serve MCP with your own stack, or you want to manage the transport yourself. | +| `createMcpSdkHandler` | `@auth0/ai-mcp/mcp-sdk` | You use the official MCP SDK and your runtime speaks `Request` and `Response`. | +| `createNodeMcpHandler` | `@auth0/ai-mcp/mcp-sdk` | You use the official MCP SDK on Node's `http` module or Express. | +| `toNodeHandler` | `@auth0/ai-mcp/mcp-sdk` | You have a `Request` to `Response` function and want a Node request listener. | +| `asTokenVerifier` | `@auth0/ai-mcp/mcp-sdk` | You already use the SDK's `requireBearerAuth` middleware. See [limitations](./KnownLimitations.md). | + +## The framework-agnostic handler + +For every request `createMcpHandler` does the following, in order: + +1. If the path is the metadata path, it serves the RFC 9728 protected resource metadata document and does nothing else. +2. It reads the credential from the `Authorization` header (and the `DPoP` header for DPoP-bound tokens). +3. It verifies the token with the `McpClient`. The client enforces the organization policy and DPoP rules you configured. +4. It checks `requiredScopes` against the `scope` claim. +5. It binds the caller to the request context and calls your `handler` with the original `Request` and an `AuthInfo` object. + +```ts +import { createMcpClient, createMcpHandler, getCallerPrincipal } from '@auth0/ai-mcp'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', +}); + +export const handle = createMcpHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + requiredScopes: ['mcp:access'], + realm: 'mcp', + metadata: { + scopesSupported: ['mcp:access', 'tool:list_reports'], + resourceName: 'Reports MCP server', + }, + onError: (error) => { + // Called for every 500. The error never reaches the response. + console.error('mcp request failed', error instanceof Error ? error.name : 'unknown'); + }, + handler: async (request, authInfo) => { + const principal = getCallerPrincipal(); + return Response.json({ + method: request.method, + sub: authInfo.extra?.sub, + scopes: authInfo.scopes, + sameCaller: principal?.sub === authInfo.extra?.sub, + }); + }, +}); +``` + +`handle` is a plain function, so it plugs into anything that speaks the Fetch API, for example `export default { fetch: handle }` on a Workers-style runtime. + +### Options + +| Option | Description | +| --- | --- | +| `client` | The `McpClient` from `createMcpClient`. Verifies tokens and creates the request-scoped client. | +| `resource` | Absolute URL that identifies this server. It is the `resource` of the metadata document. | +| `authorizationServers` | Issuer URLs of the authorization servers that issue tokens for this server. | +| `handler` | Serves the authenticated request. It runs inside the request context. | +| `requiredScopes` | Scopes every request must carry. The token needs all of them. | +| `realm` | Protection space reported as `realm` in challenges. Must be a non-empty string without quotes, backslashes or control characters. | +| `metadata.path` | Custom path of the metadata route. It must be an absolute, normalized path without query or fragment. | +| `metadata.scopesSupported`, `metadata.resourceName`, `metadata.resourceDocumentation` | Extra fields published in the metadata document. | +| `onError` | Observer for internal errors. The handler ignores a hook that throws. | + +`createMcpHandler` throws `InvalidConfigurationError` for an invalid `metadata.path` or `realm`, and a `TypeError` when `resource` is not an absolute URL. + +### What the handler answers + +Failures that happen before your handler runs use a generic JSON body (`error` and `error_description`) and a rebuilt `WWW-Authenticate` challenge. Messages from the verifier are never echoed. + +| Situation | Status | `error` in the challenge | +| --- | --- | --- | +| No credential, or a scheme the configured DPoP mode does not accept | 401 | none | +| Malformed `Authorization` or `DPoP` header | 400 | `invalid_request` | +| Token fails verification, including organization policy failures | 401 | `invalid_token` | +| DPoP proof fails verification | 401 | `invalid_dpop_proof` | +| Server requires a DPoP nonce | 401 | `use_dpop_nonce` | +| Token lacks a scope in `requiredScopes` | 403 | `insufficient_scope`, with `scope` listing the missing scopes | +| Your handler throws `UnauthenticatedError` | 401 | `invalid_token` | +| Your handler throws `InsufficientScopeError` or `DownscopedTokenError` | 403 | `insufficient_scope` | +| Anything else | 500 | no challenge, generic body | + +A missing credential gets a challenge without an `error` value, because RFC 6750 section 3.1 reserves error values for requests that did carry authentication. The challenge always advertises the schemes the DPoP mode accepts and the `resource_metadata` URL. + +Every path other than the metadata path goes through authentication. The handler does not route, so mount it only on the path that serves MCP. + +### Proxies + +DPoP proofs are bound to the request URL, and the handler checks them against `request.url`. Behind a proxy that rewrites the URL, pass the handler a `Request` whose URL is the public one the client used. The handler has no option to override it. + +## The MCP SDK adapter + +`createMcpSdkHandler` wraps `createMcpHandler` and serves each authenticated request with the SDK's web standard Streamable HTTP transport. + +```ts +import { createMcpClient, getCallerPrincipal } from '@auth0/ai-mcp'; +import { createMcpSdkHandler } from '@auth0/ai-mcp/mcp-sdk'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', +}); + +export const handle = createMcpSdkHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + requiredScopes: ['mcp:access'], + createServer: () => { + const server = new McpServer({ name: 'reports', version: '1.0.0' }); + server.registerTool( + 'whoami', + { description: 'Returns the subject of the caller.' }, + async (extra) => ({ + content: [ + { + type: 'text' as const, + text: `${getCallerPrincipal()?.sub} / ${extra.authInfo?.clientId}`, + }, + ], + }), + ); + return server; + }, +}); +``` + +The verified caller reaches your tools in two ways: as `extra.authInfo` in every tool callback, which is the SDK's own mechanism, and through the request context, so `getCallerMcpClient()`, `getCallerPrincipal()` and `withRequiredScopes()` work inside tools. `authInfo.clientId` is an empty string when the token carries neither `client_id` nor `azp`. + +`createServer` runs once per request, and the adapter closes the server when the request ends. Register tools inside it and keep expensive setup outside of it. + +The adapter is stateless and answers with JSON bodies. See [known limitations](./KnownLimitations.md#the-mcp-sdk-adapter-is-stateless). + +## Node adapter + +`createNodeMcpHandler` combines `createMcpSdkHandler` with `toNodeHandler`. Use `toNodeHandler` directly to put any `Request` to `Response` function behind Node's `http`: + +```ts +import { createServer } from 'node:http'; +import { createMcpClient, createMcpHandler } from '@auth0/ai-mcp'; +import { toNodeHandler } from '@auth0/ai-mcp/mcp-sdk'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', +}); + +const web = createMcpHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + handler: async () => Response.json({ ok: true }), +}); + +const listener = toNodeHandler(web, { + resource: 'https://mcp.example.com/mcp', + maxBodyBytes: 256 * 1024, +}); + +createServer((req, res) => void listener(req, res)).listen(3000); +``` + +`toNodeHandler` only converts messages. All authentication stays in the web handler. + +- It rebuilds the absolute request URL from the origin of `resource`, so the `Host` header of the incoming request is never trusted. +- Headers, including `Authorization` and `DPoP`, pass through unchanged. +- The body is read up to `maxBodyBytes` (1 MiB by default). A larger body gets a 413 and the adapter closes the connection. A malformed `Content-Length`, or a path that does not start with a single `/`, gets a 400. +- Mount it before body-parsing middleware. If a parser already consumed the stream, the adapter serializes the parsed `req.body` again with the same size limit, so the bytes the handler sees can differ from the original. +- The returned function never rejects. Any other error becomes a generic 500. diff --git a/packages/ai-mcp/docs/KnownLimitations.md b/packages/ai-mcp/docs/KnownLimitations.md new file mode 100644 index 00000000..9e54b973 --- /dev/null +++ b/packages/ai-mcp/docs/KnownLimitations.md @@ -0,0 +1,46 @@ +# Known limitations + +## A token in its last seconds passes the gate but cannot be exchanged + +Token verification applies no clock tolerance to the `exp` claim: the handler accepts a token until the moment it expires. The On Behalf Of exchange is stricter. It refuses a subject token that expires within the next five seconds, and the cache treats an entry within five seconds of expiry as a miss. + +A request that arrives with a token in its last five seconds therefore passes authentication and runs your tool, but any token exchange inside that tool fails. The client recovers by refreshing its access token and retrying. This does not affect tools that do not exchange tokens. This package adds no workaround, so plan for it in the error path of tools that call downstream APIs. + +## Verified claims cannot cross a process boundary + +`McpClient.verifyAccessToken` returns an opaque `VerifiedCaller` handle. The core client recognizes verified claims by object identity rather than by content, so the claims cannot be serialized, sent to another process and trusted there. The handle redacts itself when logged or serialized, and `withToken` rejects anything it did not issue itself. + +If one process verifies a request and a different process needs to exchange the token, for example across a queue, the second process must verify the access token again with its own `McpClient`. + +## The encrypted store has no secret rotation + +`createEncryptedTokenStore` supports a single secret. There is no key identifier and no way to read entries written with an old secret while writing new ones. Changing the secret makes every existing entry unreadable, and the cache treats unreadable entries as misses, so the effect is a burst of fresh exchanges after a change. Rotate the secret during a quiet period, or flush the inner store when you change it. + +## `requireBearerAuth` does not restore the request context + +`asTokenVerifier` adapts an `McpClient` to the MCP SDK's `OAuthTokenVerifier`, for use with the SDK's `requireBearerAuth` Express middleware. Prefer `createMcpHandler`, `createMcpSdkHandler` or `createNodeMcpHandler`. The shim has these differences: + +- **No request context.** `requireBearerAuth` does not run this package's context binding. Inside your routes `getRequestContext()`, `getCallerPrincipal()` and `getCallerMcpClient()` return `undefined`, and no request-scoped client exists. To get them back, call `client.verifyAccessToken` and `client.withToken` in your own wrapper and use `runWithRequestContext`, or switch to the handler. +- **The SDK's challenge behavior.** The middleware answers a missing credential with `error="invalid_token"`, although RFC 6750 section 3.1 says to send no error value in that case. It echoes the SDK's own messages in `error_description` and lists the configured scopes instead of only the missing ones. +- **Bearer only.** The shim does not know DPoP. A DPoP-bound token fails verification. + +Failed verification maps to the SDK's invalid token error, a 403 from the core maps to its insufficient scope error, and everything else is rethrown so the middleware answers 500. + +## The MCP SDK adapter is stateless + +`createMcpSdkHandler` and `createNodeMcpHandler` run the SDK's Streamable HTTP transport in stateless mode with JSON responses: + +- Each POST gets one JSON body and no session id, so any instance can serve any request. +- The adapter creates the server per request and closes it when the request ends. A streamed response would be cut off, so streaming is not offered. +- Server-to-client notifications and standalone event streams (GET requests) are not available. +- Tools that depend on state kept inside an SDK session must keep that state elsewhere. + +If you need sessions or streaming, use `createMcpHandler` and manage the transport yourself. + +## Other things to know + +- `MemoryIndexedTokenStore` lives in one process. Workers do not share it, and it is empty after a restart. +- Client credentials tokens and Token Vault tokens are not cached by this package. +- The exchange request to Auth0 carries no DPoP proof. See [DPoP](./TokenVerification.md#dpop). +- The handler routes nothing but the metadata document. Mount it only on the path that serves MCP. +- The metadata route sends no CORS headers. Add them in your framework if browser-based clients read the document. diff --git a/packages/ai-mcp/docs/OnBehalfOf.md b/packages/ai-mcp/docs/OnBehalfOf.md new file mode 100644 index 00000000..02750876 --- /dev/null +++ b/packages/ai-mcp/docs/OnBehalfOf.md @@ -0,0 +1,102 @@ +# On Behalf Of and client credentials + +A tool often needs to call a downstream API as the user who invoked it. Forwarding the incoming token would be wrong: Auth0 issued it for your MCP server, not for the downstream API. The On Behalf Of exchange (RFC 8693) trades the caller's token for a new token with the downstream API as its audience. The new token keeps the caller's identity. + +## Prerequisites + +- `clientId` and `clientSecret` on `createMcpClient`. The application must be allowed to perform the exchange in your Auth0 tenant. See the [Auth0 documentation](https://auth0.com/docs) for how to enable it. +- A request context. The exchange is available on the request-scoped client, which the handler binds to the request. + +## Exchanging the caller's token + +```ts +import { getCallerMcpClientOrThrow } from '@auth0/ai-mcp'; + +export async function listReports(): Promise { + const mcpClient = getCallerMcpClientOrThrow(); + const token = await mcpClient.getTokenOnBehalfOf({ + audience: 'https://reports-api.example.com', + scope: 'read:reports', + }); + + const response = await fetch('https://reports-api.example.com/reports', { + headers: { authorization: `Bearer ${token.accessToken}` }, + }); + return response.json(); +} +``` + +`getTokenOnBehalfOf` takes: + +| Option | Description | +| --- | --- | +| `audience` | Identifier of the downstream API the new token is for. | +| `scope` | Optional, space-separated scopes to request. | + +It resolves to an `OnBehalfOfTokenResult`: + +| Field | Description | +| --- | --- | +| `accessToken` | The token for the downstream API. | +| `expiresAt` | Expiry in epoch seconds. | +| `scope` | The scope string exactly as the authorization server returned it. Absent when the server omitted it. | +| `grantedScopes` | The scopes that were actually granted, normalized, deduplicated and sorted. Always present. | +| `tokenType`, `issuedTokenType` | Token type values returned by the exchange, when present. | + +Use `grantedScopes`, not `scope`, to decide what a token covers. An empty `scope` string means Auth0 granted nothing, which a truthiness test on `scope` would misread as a full grant. + +## Narrowed grants + +Auth0 can grant fewer scopes than you asked for. When you request scopes and the grant does not include all of them, `getTokenOnBehalfOf` throws `DownscopedTokenError`, so a tool never runs with less authority than it asked for. The error carries no scope data. + +The narrower token is still cached under the scopes it was actually granted. A later, narrower request can reuse it. See [Token caching](./TokenCaching.md). + +When you request no scopes, the package enforces nothing and returns whatever Auth0 granted. + +```ts +import { DownscopedTokenError, getCallerMcpClientOrThrow } from '@auth0/ai-mcp'; + +export async function tryExchange(): Promise { + try { + const token = await getCallerMcpClientOrThrow().getTokenOnBehalfOf({ + audience: 'https://reports-api.example.com', + scope: 'read:reports write:reports', + }); + return token.accessToken; + } catch (error) { + if (error instanceof DownscopedTokenError) { + return undefined; + } + throw error; + } +} +``` + +## Concurrent requests + +Concurrent identical exchanges from one `McpClient` share a single request to Auth0. Two exchanges are identical when they would be cached under the same key: the same caller, session, downstream audience and scope set. The package does not remember the shared outcome once it settles, so the next call retries after a failure. Each caller receives its own copy of the result. + +## Errors from the exchange + +Errors leave the request-scoped client with a fixed generic message. The original error is kept as `cause` for server-side diagnostics and must not be serialized into a response. Core errors keep their class, `code`, `statusCode` and `headers`, so `instanceof` checks against the error classes of `@auth0/auth0-api-js` still work. Catch these in your tool and return an `isError` result, as the example in [Examples](../EXAMPLES.md) does. + +## Client credentials + +`getClientCredentialsToken` obtains a token for the server itself, using its own client credentials, with no caller involved. Use it when the tool acts as the service, for example to call an internal API that is not user-scoped. + +```ts +import { getCallerMcpClientOrThrow } from '@auth0/ai-mcp'; + +export async function readConfig(): Promise { + const mcpClient = getCallerMcpClientOrThrow(); + const token = await mcpClient.getClientCredentialsToken({ + audience: 'https://internal-api.example.com', + scope: 'read:config', + }); + return token.grantedScopes.join(' '); +} +``` + +The result is the core `TokenSet` (`accessToken`, `expiresAt`, `scope`, `tokenType`) plus `grantedScopes`. When the server returned no `scope` field, `grantedScopes` holds the normalized requested scopes. As with the exchange, requesting scopes that the grant does not include throws `DownscopedTokenError`. + +This package does not cache client credentials tokens. diff --git a/packages/ai-mcp/docs/OrganizationPolicy.md b/packages/ai-mcp/docs/OrganizationPolicy.md new file mode 100644 index 00000000..30a5a082 --- /dev/null +++ b/packages/ai-mcp/docs/OrganizationPolicy.md @@ -0,0 +1,56 @@ +# Organization policy + +Auth0 Organizations let you model the business customers of a multi-tenant application. A token issued in an organization context carries an `org_id` claim. The organization policy decides what your MCP server does with it. + +`createMcpClient` accepts two options: + +| Option | Description | +| --- | --- | +| `organizationPolicy` | `'allow'` (default) performs no organization check. `'required'` rejects tokens that carry no `org_id` claim. | +| `organizationId` | One organization id or a list of ids. With `'required'`, the token's `org_id` must be in the list. | + +`organizationId` takes `org_...` identifiers, not organization names, and the check compares entries exactly, so trim values that come from a delimited source such as an environment variable. + +## Accepting any organization + +```ts +import { createMcpClient } from '@auth0/ai-mcp'; + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + organizationPolicy: 'required', +}); +``` + +Every request must come from a token issued in some organization context. Read the organization in tool code from `principal.orgId`. + +## Restricting to specific organizations + +```ts +import { createMcpClient } from '@auth0/ai-mcp'; + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + organizationPolicy: 'required', + organizationId: ['org_abc123', 'org_def456'], +}); +``` + +Setting `organizationId` with any policy other than `'required'`, including the default, makes `createMcpClient` throw `InvalidConfigurationError`, so a restriction is never silently ignored. The same error is thrown for an invalid policy value or an unusable allowlist. + +## Failures + +The client enforces the policy during token verification, before any tool runs. The handler answers a rejected token with HTTP 401 and `error="invalid_token"`, because RFC 6750 defines no organization-specific error value. Server-side code can tell the two cases apart: + +| Case | Error class from `@auth0/auth0-api-js` | `code` | +| --- | --- | --- | +| No `org_id` claim | `MissingOrganizationError` | `missing_organization` | +| `org_id` not in the allowlist | `OrganizationNotAllowedError` | `organization_not_allowed` | + +Both extend `VerifyAccessTokenError`. Their messages never include the rejected organization id. + +The client validates the policy when you create it. Changing the options object afterwards has no effect. + +Pass `apiClient` when you need to configure the policy on a core client yourself. Combining `apiClient` with `organizationPolicy` or `organizationId` throws `InvalidConfigurationError`, so configure them on the client you pass in. diff --git a/packages/ai-mcp/docs/RequestContext.md b/packages/ai-mcp/docs/RequestContext.md new file mode 100644 index 00000000..652aff8b --- /dev/null +++ b/packages/ai-mcp/docs/RequestContext.md @@ -0,0 +1,64 @@ +# Request context + +Tool code needs to know who is calling without receiving the caller as an argument on every function. The request handler binds the caller to the request with Node's `AsyncLocalStorage`, and the functions below read it back. + +The context holds two things: + +- `principal`: the authenticated caller. See [Token verification](./TokenVerification.md#the-principal). +- `mcpClient`: a `RequestScopedMcpClient` bound to this caller's verified token. + +## Reading the context + +| Function | Returns | +| --- | --- | +| `getRequestContext()` | The whole `RequestContext`, or `undefined` outside a request. | +| `getCallerPrincipal()` | The `Principal`, or `undefined` outside a request. | +| `getCallerMcpClient()` | The `RequestScopedMcpClient`, or `undefined` outside a request. | +| `getCallerMcpClientOrThrow()` | The `RequestScopedMcpClient`. Throws `UnauthenticatedError` outside a request. | + +```ts +import { getCallerMcpClientOrThrow, getCallerPrincipal } from '@auth0/ai-mcp'; + +export async function describeCaller(): Promise { + const principal = getCallerPrincipal(); + if (principal === undefined) { + return 'anonymous'; + } + const mcpClient = getCallerMcpClientOrThrow(); + return `${principal.sub} via ${mcpClient.getPrincipal().clientId ?? 'unknown client'}`; +} +``` + +The context is visible to everything the handler starts, including code after `await`, timers and promise chains. Concurrent requests do not see each other's context, and nested contexts restore the outer one when the inner one finishes. + +## The request-scoped client + +`RequestScopedMcpClient` is what tool code uses to get tokens. It always acts for the caller of the current request. + +| Method | Purpose | +| --- | --- | +| `getTokenOnBehalfOf({ audience, scope? })` | Exchanges the caller's token for a downstream token. See [On Behalf Of](./OnBehalfOf.md). | +| `getClientCredentialsToken({ audience, scope? })` | Gets a token for the server itself. See [client credentials](./OnBehalfOf.md#client-credentials). | +| `getTokenForService({ connection, loginHint? })` | Gets a federated provider token. See [Token Vault](./TokenVault.md). | +| `getPrincipal()` | Returns the caller's `Principal`. | + +The verified access token is held privately by the implementation. The object is frozen, has no data properties, and exposes no way to read the token, so tool code cannot forward or log it by accident. + +Errors that leave the request-scoped client carry a fixed generic message. They keep the original class, `code` and `statusCode` so `instanceof` checks and status mapping still work, and the original error is available as `cause` for server-side diagnostics. Do not serialize `cause` into a response. + +## Setting the context yourself + +If you build your own transport, call `runWithRequestContext` after verifying the token. See the example in [Token verification](./TokenVerification.md#verifying-a-token-yourself). + +```ts +import { getCallerPrincipal, runWithRequestContext } from '@auth0/ai-mcp'; +import type { RequestContext } from '@auth0/ai-mcp'; + +export function readSubject(context: RequestContext): string | undefined { + return runWithRequestContext(context, () => getCallerPrincipal()?.sub); +} +``` + +## Outside the handler + +Work that runs outside any request, such as a job queue consumer, has no request context, and `getCallerPrincipal()` returns `undefined` there. A request-scoped client stops working once the caller's token expires, so do not keep one beyond the request. Pass the job what it needs explicitly. diff --git a/packages/ai-mcp/docs/ScopeEnforcement.md b/packages/ai-mcp/docs/ScopeEnforcement.md new file mode 100644 index 00000000..3458be5d --- /dev/null +++ b/packages/ai-mcp/docs/ScopeEnforcement.md @@ -0,0 +1,111 @@ +# Scope enforcement + +Scope enforcement checks the scopes in the caller's incoming access token, which are the permissions Auth0 granted the caller for your MCP server. It is separate from the scopes of any downstream token that an [On Behalf Of exchange](./OnBehalfOf.md) produces. + +There are two levels, and they fail differently on purpose. + +| Level | Where | Failure | +| --- | --- | --- | +| Server-wide | `requiredScopes` option of the handler | HTTP 403 with `WWW-Authenticate: ... error="insufficient_scope"`. The request never reaches MCP. | +| Per tool | `withRequiredScopes` or `requireScopes` inside a tool | A tool result with `isError: true` and HTTP 200. Other tools keep working. | + +Both compare against the `scope` claim only. The `permissions` claim is never consulted. Every listed scope must be present (AND semantics). + +## Server-wide scopes + +Scopes every request needs go in `requiredScopes`. The handler checks them after verification and before the request reaches MCP: + +```ts +import { createMcpClient, createMcpHandler } from '@auth0/ai-mcp'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', +}); + +export const handle = createMcpHandler({ + client, + resource: 'https://mcp.example.com/mcp', + authorizationServers: ['https://your-tenant.us.auth0.com/'], + requiredScopes: ['mcp:access'], + handler: async () => Response.json({ ok: true }), +}); +``` + +When a scope is missing the response is a 403 whose challenge lists only the missing scopes in its `scope` parameter, so a client learns what to request without learning what else the server knows about: + +```http +HTTP/1.1 403 Forbidden +WWW-Authenticate: Bearer error="insufficient_scope", error_description="The access token does not include the required scope.", scope="mcp:access", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" +WWW-Authenticate: DPoP algs="ES256", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" +``` + +The handler sends one challenge per scheme the DPoP mode accepts, and the error parameters go on the scheme the client used. With `dpop.mode` set to `'disabled'` only the Bearer line is sent. + +## Per-tool scopes + +`withRequiredScopes(required, handler)` wraps a tool callback. It reads the caller from the [request context](./RequestContext.md) and runs the callback only when the caller holds every required scope. Otherwise it returns a sanitized MCP error result instead of throwing: + +```ts +import { withRequiredScopes } from '@auth0/ai-mcp'; + +export const getOrderSummary = withRequiredScopes( + ['read:orders', 'read:payments'], + async (orderId: string) => ({ + content: [{ type: 'text' as const, text: `Summary for ${orderId}` }], + }), +); +``` + +Calling `getOrderSummary('42')` for a caller without `read:payments` resolves to: + +```json +{ + "content": [{ "type": "text", "text": "Missing one or more required scopes." }], + "isError": true, + "_meta": { "error": "insufficient_scope" } +} +``` + +The text is generic and never names the scopes the server expects or the caller holds. Every other error, including errors thrown by your callback, propagates unchanged. + +`required` accepts one scope, a space-delimited string of scopes, or an array. An empty requirement always passes. + +## Imperative checks + +`requireScopes(required, principal?)` throws instead of returning a result. It is useful for a secondary check partway through a tool, or when you want to turn the failure into your own response. + +```ts +import { InsufficientScopeError, getCallerPrincipal, requireScopes } from '@auth0/ai-mcp'; + +export function canWriteOrders(): boolean { + try { + requireScopes('write:orders', getCallerPrincipal()); + return true; + } catch (error) { + if (error instanceof InsufficientScopeError) { + return false; + } + throw error; + } +} +``` + +It throws: + +- `UnauthenticatedError` when you pass no principal and no request context is active. Passing `undefined` explicitly means "use the current caller". +- `InsufficientScopeError` when any scope is missing. The error's `requiredScopes` property holds the normalized list for server-side use. Do not echo it to clients. + +When a tool callback lets `InsufficientScopeError` escape to the handler, the handler answers HTTP 403 with `insufficient_scope`. With the official MCP SDK's `McpServer`, the SDK turns an error thrown inside a tool callback into a tool result with `isError: true` instead, using the error's message. + +## Tenant-side setup + +Auth0 puts a scope in a token only when you define the scope on the API (resource server) and the application's client grant lists it. Defining a scope on the API alone is not enough. If the client grant for the application that requests tokens for your MCP server does not include the scope, the check fails for every caller, even after a fresh login. + +When you add a gated tool, define the scope on your MCP API and add it to the client grant. A client grant update replaces the whole scope list, so include the existing scopes as well. + +```bash +auth0 api get "client-grants?audience=https://mcp.example.com/mcp" +auth0 api patch "client-grants/YOUR_GRANT_ID" \ + --data '{"scope":["mcp:access","tool:list_reports","tool:new_tool"]}' +``` diff --git a/packages/ai-mcp/docs/TokenCaching.md b/packages/ai-mcp/docs/TokenCaching.md new file mode 100644 index 00000000..40dac57e --- /dev/null +++ b/packages/ai-mcp/docs/TokenCaching.md @@ -0,0 +1,177 @@ +# Token caching + +Caching of On Behalf Of tokens is optional and off by default. Without a `tokenStore`, every call to `getTokenOnBehalfOf` performs a fresh exchange and nothing persists anywhere. + +To enable it, pass a token store to `createMcpClient`: + +```ts +import { MemoryIndexedTokenStore, createMcpClient } from '@auth0/ai-mcp'; + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + tokenStore: new MemoryIndexedTokenStore({ maxEntries: 5000 }), + scopeMatching: 'strict', +}); +``` + +The package caches only On Behalf Of tokens. It requests client credentials tokens and Token Vault tokens every time. + +## The store interface + +A `TokenStore` has three asynchronous methods. `@auth0/ai-mcp` exports the types. + +| Method | Purpose | +| --- | --- | +| `get(key)` | Returns the `CachedToken` stored under `key`, or `undefined`. | +| `set(key, value)` | Stores `value` under `key`. | +| `delete(key)` | Removes the entry. | + +A `CachedToken` holds the access token, its `expiresAt` (epoch seconds), the `grantedScopes`, and optional `scope`, `tokenType` and `issuedTokenType`. + +The package ships two stores: + +- `MemoryIndexedTokenStore`: an in-memory store with a hard size limit (`maxEntries`, default 1000) and least-recently-used eviction. It lives in the memory of one process, so it is not shared between workers and does not survive a restart. It copies values on write and on read, and drops expired entries lazily. +- `createEncryptedTokenStore`: wraps any store so that it holds only encrypted values. See [Encrypted store](#encrypted-store). + +For several instances that must share tokens, implement the interface on top of shared storage such as Redis and wrap it with `createEncryptedTokenStore`. + +## Cache keys + +The package, not the store, builds the keys. A key combines: + +- the tenant, client and audience that perform the exchange, +- the subject token's issuer, client, subject and organization, +- the session of the subject token (its `sid` claim, else `jti`, else a digest of the token), +- the audience you request, and +- the normalized scope set. + +Because the subject is part of the key, one caller's token is never returned for another caller, and the session is part of the key, so separate sessions of the same user stay apart. Keys are opaque hashes that carry no claim value, but they are stable pseudonymous identifiers, so treat the store as sensitive. + +## Scope matching + +`scopeMatching` controls when a cached token counts as a hit. Both modes compare the scopes the exchange was **granted**, never the scopes a previous call requested. + +- `'strict'` (default): reuse a token only when its granted scopes are exactly the requested set. +- `'non_strict'`: also reuse a token whose granted scopes cover the requested set, preferring the smallest such token. Several tokens then coexist per caller, so the store has to be searched rather than looked up. A request for no scopes never reuses a cached token in this mode. + +`'strict'` never widens access silently. Use `'non_strict'` when your tools request overlapping scope sets and you want to avoid repeated exchanges. + +`'non_strict'` needs an `IndexedTokenStore`, which adds two methods: + +- `addIndexMember(indexKey, member)` adds a `TokenIndexMember` (`tokenKey`, `grantedScopes`, `expiresAt`), replacing any member with the same `tokenKey`. It must be atomic. Two concurrent exchanges for the same caller can add to the same index at once, and a read-modify-write without a lock loses one of them. A Redis hash field write is an example of an atomic operation. +- `listIndexMembers(indexKey)` returns every member, or `[]` when the index does not exist. It may include expired members, which the package skips on read. + +`createMcpClient` throws `InvalidConfigurationError` at construction when `scopeMatching` is `'non_strict'` and the store is missing or not indexed. When you pass your own `apiClient` the check cannot run, and the core client reports the mismatch on the first token request. + +```ts +import { MemoryIndexedTokenStore, createMcpClient } from '@auth0/ai-mcp'; + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + tokenStore: new MemoryIndexedTokenStore(), + scopeMatching: 'non_strict', +}); +``` + +The index is a hint kept next to the data, not an authority. The package considers a member only when the key it points at is exactly the key this caller would have written for the scopes the member claims. + +## What a hit looks like + +- Both modes cache a token under the scopes Auth0 granted it, including a token whose grant came back narrower than the request. The caller that asked for more still gets `DownscopedTokenError`. See [On Behalf Of](./OnBehalfOf.md#narrowed-grants). +- A token within five seconds of its expiry counts as a miss. +- Store failures never fail a tool call. A read that throws counts as a miss, and the package ignores a write that throws, so the freshly exchanged token is still returned. A store outage costs extra exchanges. +- The package never deletes entries. Your store must expire them, using each value's `expiresAt`. For an index, expire the whole index key and push its expiry out to the latest member's `expiresAt` on every add. + +## Encrypted store + +`createEncryptedTokenStore(inner, { secret })` returns a store with the same interface as `inner`, and index methods when `inner` has them. The wrapper seals everything before it writes to `inner`, so the inner store never sees an access token in the clear. + +```ts +import { MemoryIndexedTokenStore, createEncryptedTokenStore, createMcpClient } from '@auth0/ai-mcp'; + +const secret = process.env.TOKEN_STORE_SECRET; +if (secret === undefined) { + throw new Error('TOKEN_STORE_SECRET is not set.'); +} + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + tokenStore: createEncryptedTokenStore(new MemoryIndexedTokenStore(), { secret }), + scopeMatching: 'non_strict', +}); +``` + +**Secret.** `secret` must be a string of at least 32 characters, otherwise the call throws `InvalidConfigurationError`. The check counts characters and cannot measure entropy, so a long but guessable string passes it and is still weak. Use 32 random bytes from a secret manager, for example the hex or base64 encoding of them. Every process that shares the inner store must use the same secret. + +**Format.** The wrapper seals values as compact JWE (`dir` with `A256CBC-HS512`) with a key it derives from the secret using HKDF-SHA-256. The authenticated header binds each ciphertext to the storage key it was written under, so a ciphertext copied to another key fails to decrypt and reads as a miss. The sealed payload repeats the token's expiry, and the wrapper checks it on read. + +**What stays readable.** The storage keys, each entry's expiry time, and the token key and expiry of each index member remain in the clear, because the inner store needs them to find and expire entries. The wrapper encrypts the granted scopes of an index member, so the inner indexed store must treat members as opaque: their `grantedScopes` hold ciphertext, not scope names. + +**Failures.** Reads fail open: tampered or malformed ciphertext, a different secret or a key mismatch produces a miss, never an exception, and the wrapper logs nothing about the value. Failures raised by the inner store are not swallowed by the wrapper. The core client then treats them as described above. + +**What it does not protect against.** An attacker with write access to the inner store can delete entries or replay an older ciphertext under the same key until it expires. Encryption does not defend against that. There is also no secret rotation. See [known limitations](./KnownLimitations.md#the-encrypted-store-has-no-secret-rotation). + +## Writing a store + +A store on shared storage implements `TokenStore`, or `IndexedTokenStore` for `'non_strict'`. This sketch uses a minimal Redis-shaped client: + +```ts +import { createEncryptedTokenStore } from '@auth0/ai-mcp'; +import type { CachedToken, IndexedTokenStore, TokenIndexMember } from '@auth0/ai-mcp'; + +interface RedisLike { + get(key: string): Promise; + set(key: string, value: string): Promise; + del(key: string): Promise; + expireat(key: string, epochSeconds: number): Promise; + hset(key: string, field: string, value: string): Promise; + hvals(key: string): Promise; +} + +class RedisTokenStore implements IndexedTokenStore { + constructor(private readonly redis: RedisLike) {} + + async get(key: string): Promise { + const raw = await this.redis.get(`mcp:token:${key}`); + return raw === null ? undefined : (JSON.parse(raw) as CachedToken); + } + + async set(key: string, value: CachedToken): Promise { + await this.redis.set(`mcp:token:${key}`, JSON.stringify(value)); + await this.redis.expireat(`mcp:token:${key}`, value.expiresAt); + } + + async delete(key: string): Promise { + await this.redis.del(`mcp:token:${key}`); + } + + async addIndexMember(indexKey: string, member: TokenIndexMember): Promise { + const key = `mcp:index:${indexKey}`; + await this.redis.hset(key, member.tokenKey, JSON.stringify(member)); + // Keep the index alive as long as its newest token. + const members = await this.listIndexMembers(indexKey); + const latest = Math.max(member.expiresAt, ...members.map((entry) => entry.expiresAt)); + await this.redis.expireat(key, latest); + } + + async listIndexMembers(indexKey: string): Promise { + const values = await this.redis.hvals(`mcp:index:${indexKey}`); + return values.map((value) => JSON.parse(value) as TokenIndexMember); + } +} + +export function createSharedStore(redis: RedisLike, secret: string): IndexedTokenStore { + return createEncryptedTokenStore(new RedisTokenStore(redis), { secret }); +} +``` + +Stores must expire entries on their own: set a time to live that matches `expiresAt` when you write. diff --git a/packages/ai-mcp/docs/TokenVault.md b/packages/ai-mcp/docs/TokenVault.md new file mode 100644 index 00000000..65cc84bd --- /dev/null +++ b/packages/ai-mcp/docs/TokenVault.md @@ -0,0 +1,57 @@ +# Token Vault + +`getTokenForService` exchanges the caller's Auth0 token for an access token issued by a federated provider such as Google or Slack, through Auth0 Token Vault. A tool can then call the third-party API as the user, and the user's original token never leaves the server. + +## Using it in a tool + +```ts +import { getCallerMcpClientOrThrow } from '@auth0/ai-mcp'; + +export async function listCalendarEvents(): Promise { + const mcpClient = getCallerMcpClientOrThrow(); + const token = await mcpClient.getTokenForService({ connection: 'google-oauth2' }); + + const response = await fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { + headers: { authorization: `Bearer ${token.accessToken}` }, + }); + return response.json(); +} +``` + +`getTokenForService` takes: + +| Option | Description | +| --- | --- | +| `connection` | Name of the connection that holds the caller's linked account. | +| `loginHint` | Optional hint that selects among several linked accounts. | + +It resolves to the core client's connection token set: + +| Field | Description | +| --- | --- | +| `accessToken` | The provider's access token. | +| `expiresAt` | Expiry in epoch seconds. | +| `scope` | The scope granted by the connection, or `undefined`. | +| `connection` | The connection you requested the token for. | +| `loginHint` | The hint passed with the request, if any. | + +Pass `loginHint` when the user has more than one linked account for the connection. + +## Requirements + +- `clientId` and `clientSecret` on `createMcpClient`. +- You enabled the Token Vault grant on that application, and you configured and enabled the federated connection for it. +- The user has linked their external account through Auth0's connected accounts flow. Without a linked account the exchange fails. + +For the tenant setup, follow the Auth0 guides: + +- [Configure Token Vault](https://auth0.com/docs/secure/tokens/token-vault/configure-token-vault) +- [Connected Accounts for Token Vault](https://auth0.com/docs/secure/tokens/token-vault/connected-accounts-for-token-vault) + +## Caching + +This package does not cache connection tokens. Each call performs an exchange, so cache the result in your tool if the provider rate-limits you. + +## Errors + +Errors leave the request-scoped client with a fixed generic message, the original class and `code` of the core error, and the original error as `cause`. See [Errors](./Errors.md). diff --git a/packages/ai-mcp/docs/TokenVerification.md b/packages/ai-mcp/docs/TokenVerification.md new file mode 100644 index 00000000..c0aa410f --- /dev/null +++ b/packages/ai-mcp/docs/TokenVerification.md @@ -0,0 +1,97 @@ +# Token verification + +Every request to a protected MCP server carries an access token that Auth0 issued for your server. `McpClient.verifyAccessToken` checks it, and the result is a `Principal` that describes the caller. Request handlers call it for you. This page describes what it does and how to call it yourself. + +## What verification checks + +Verification is done by the core client, `@auth0/auth0-api-js`. It checks the signature against the tenant's JWKS, the issuer, the audience (the `audience` you passed to `createMcpClient`), and the expiry. On top of that, the `McpClient` enforces the [organization policy](./OrganizationPolicy.md) and the DPoP rules you configured. + +The core client applies no clock tolerance to the `exp` claim. See [known limitations](./KnownLimitations.md#a-token-in-its-last-seconds-passes-the-gate-but-cannot-be-exchanged). + +## The Principal + +A `Principal` describes the caller. It holds no credential and is safe to log. The object and its arrays are frozen. + +| Field | Type | Source | +| --- | --- | --- | +| `sub` | `string` | The `sub` claim. Never blank. | +| `expiresAt` | `number` | The `exp` claim, in epoch seconds. | +| `scopes` | `readonly string[]` | The space-delimited `scope` claim. Empty when the claim is absent. | +| `permissions` | `readonly string[] \| null` | The `permissions` claim (RBAC). `null` when the claim is absent, which means permissions are unknown. An empty array means the caller has none. | +| `clientId` | `string \| null` | The `client_id` claim, falling back to `azp`. `null` when neither is present. | +| `orgId` | `string \| null` | The `org_id` claim. `null` outside an organization context. | + +`buildPrincipal(claims)` builds a `Principal` from claims you have already verified. It does not verify anything and throws `UnauthenticatedError` when `sub` is blank or `exp` is missing. It reads every claim as an own property, so it ignores values inherited through the prototype chain. + +Scope checks use the `scopes` field only. The `permissions` claim is there for your own logic. Neither `requireScopes` nor the handler consults it. + +## Verifying a token yourself + +The handler does this for you. Call `verifyAccessToken` directly when you build your own transport: + +```ts +import { createMcpClient, runWithRequestContext, UnauthenticatedError } from '@auth0/ai-mcp'; + +const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, +}); + +export async function serve(accessToken: string, work: () => Promise): Promise { + const caller = await client.verifyAccessToken({ accessToken, scheme: 'bearer' }); + if (caller.principal.scopes.length === 0) { + throw new UnauthenticatedError('The token carries no scopes.'); + } + const mcpClient = client.withToken(caller); + return runWithRequestContext({ principal: caller.principal, mcpClient }, work); +} +``` + +`verifyAccessToken` returns a `VerifiedCaller`, an opaque handle that exposes only the `principal`. The verified token stays inside the client that issued the handle, and the handle redacts itself when you serialize or log it. `withToken` accepts only handles from the same client. A copy, a hand-built object or a handle from another client throws `ForeignCallerError`, which indicates a programming error and maps to a 500. `withToken` throws `UnauthenticatedError` when the token has expired. + +Errors from the core client, such as `VerifyAccessTokenError`, propagate unchanged. See [Errors](./Errors.md). + +## DPoP + +DPoP (RFC 9449) binds an access token to a key held by the client. You configure it with the `dpop` option of `createMcpClient`, which the package forwards to the core client: + +```ts +import { createMcpClient } from '@auth0/ai-mcp'; + +export const client = createMcpClient({ + domain: 'your-tenant.us.auth0.com', + audience: 'https://mcp.example.com/mcp', + dpop: { + mode: 'required', + iatOffset: 300, + iatLeeway: 30, + }, +}); +``` + +| Setting | Meaning | +| --- | --- | +| `mode: 'allowed'` (default) | Accepts Bearer and DPoP. The client validates the proof and binding when the request uses DPoP. | +| `mode: 'required'` | Accepts DPoP only. The metadata document sets `dpop_bound_access_tokens_required`. | +| `mode: 'disabled'` | Bearer only. | +| `iatOffset` | Maximum age of a proof's `iat` claim, in seconds. Defaults to 300. | +| `iatLeeway` | Allowed future skew of a proof's `iat` claim, in seconds. Defaults to 30. | + +### Supported + +- **Incoming DPoP-bound access tokens.** The handler accepts `Authorization: DPoP ` with a `DPoP: ` header and passes the proof, the HTTP method and the request URL to the core client for validation. +- **Mode-aware schemes.** The handler accepts and advertises Bearer, DPoP or both according to `dpop.mode`. A scheme the mode does not accept counts as no credential and gets a challenge without an error value. +- **Proof errors.** The handler answers a failed proof with 401 and `error="invalid_dpop_proof"` on the `DPoP` challenge. When the core client supplies a valid `DPoP-Nonce` value the handler returns it with `error="use_dpop_nonce"`, so the client can retry. +- **Metadata.** Unless the mode is `disabled`, the metadata document declares `ES256` in `dpop_signing_alg_values_supported`. +- **Exchange of a DPoP-bound token.** A DPoP-bound subject token can be exchanged on behalf of the caller, because the gate verified it and passes the verified claims to the core client. + +### Not supported + +- **DPoP on outgoing requests.** The token exchange request to Auth0 carries no DPoP proof, and this package does not request DPoP-bound downstream tokens. If the authorization server rejects the exchange, the error propagates to the tool. +- **Nonce issuing.** This package does not generate nonces. It relays one only when the core client supplies it. Without a usable nonce the response says `invalid_dpop_proof`. +- **Overriding the URL checked in the proof.** The handler checks `request.url`. See [Proxies](./Handler.md#proxies). +- **DPoP through the MCP SDK's `requireBearerAuth` shim.** `asTokenVerifier` verifies Bearer tokens only, so a DPoP-bound token fails verification there. +- **Passing your own `apiClient`.** The package cannot read the mode of a client you supply, so `McpClient.dpopMode` reports `'allowed'`. Build the client from options when challenges must reflect `'required'` or `'disabled'`. +- **`mode: 'disabled'` is not a rejection of DPoP-bound tokens.** The core client then ignores DPoP, so a DPoP-bound token presented as a Bearer token passes verification. Leave DPoP enabled unless you intend that. From 70ca6b27e015907651bb575301a573f8d7090e11 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 15:56:56 +0530 Subject: [PATCH 23/31] test(ai-mcp): typecheck documentation snippets and pin documented behaviour 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 --- .../ai-mcp/test/docs/docs-behaviour.spec.ts | 409 ++++++++++++++++++ .../ai-mcp/test/docs/docs-snippets.spec.ts | 189 ++++++++ 2 files changed, 598 insertions(+) create mode 100644 packages/ai-mcp/test/docs/docs-behaviour.spec.ts create mode 100644 packages/ai-mcp/test/docs/docs-snippets.spec.ts diff --git a/packages/ai-mcp/test/docs/docs-behaviour.spec.ts b/packages/ai-mcp/test/docs/docs-behaviour.spec.ts new file mode 100644 index 00000000..d949898e --- /dev/null +++ b/packages/ai-mcp/test/docs/docs-behaviour.spec.ts @@ -0,0 +1,409 @@ +import { + InvalidConfigurationError, + VerifyAccessTokenError, +} from "@auth0/auth0-api-js"; +import type { ApiClient } from "@auth0/auth0-api-js"; +import { describe, expect, it } from "vitest"; +import { + DownscopedTokenError, + InsufficientScopeError, + MemoryIndexedTokenStore, + UnauthenticatedError, + createEncryptedTokenStore, + createMcpClient, + createMcpHandler, + getCallerPrincipal, + requireScopes, + runWithRequestContext, + withRequiredScopes, +} from "../../src/index.js"; +import type { + McpClient, + Principal, + RequestContext, + RequestScopedMcpClient, +} from "../../src/index.js"; + +/** + * Pins the behaviour statements made in README.md and docs/*.md. Each test names the + * statement it backs. + */ + +const RESOURCE = "https://mcp.example.com/mcp"; + +function principalWith(scopes: string[]): Principal { + return { + sub: "auth0|user-1", + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scopes, + permissions: null, + clientId: "client-1", + orgId: null, + }; +} + +function scopedClient(principal: Principal): RequestScopedMcpClient { + const unused = async (): Promise => { + throw new Error("not used"); + }; + return { + getPrincipal: () => principal, + getTokenOnBehalfOf: unused, + getClientCredentialsToken: unused, + getTokenForService: unused, + }; +} + +function stubClient( + verify: () => Promise<{ principal: Principal }>, + dpopMode: McpClient["dpopMode"] = "allowed", +): McpClient { + return { + dpopMode, + verifyAccessToken: verify as McpClient["verifyAccessToken"], + withToken: ((caller: { principal: Principal }) => + scopedClient(caller.principal)) as McpClient["withToken"], + }; +} + +function handlerFor(client: McpClient, extra: { requiredScopes?: string[] } = {}) { + return createMcpHandler({ + client, + resource: RESOURCE, + authorizationServers: ["https://your-tenant.us.auth0.com/"], + handler: async (_request, authInfo) => + Response.json({ scopes: authInfo.scopes, sub: authInfo.extra?.sub }), + ...extra, + }); +} + +function post(headers: Record = {}): Request { + return new Request(RESOURCE, { method: "POST", headers }); +} + +describe("handler responses (Handler.md, ScopeEnforcement.md, Errors.md)", () => { + it("answers a missing credential with 401 and a challenge without an error value", async () => { + const response = await handlerFor( + stubClient(async () => ({ principal: principalWith([]) })), + )(post()); + expect(response.status).toBe(401); + const challenges = response.headers.get("www-authenticate") ?? ""; + expect(challenges).toContain("Bearer"); + expect(challenges).toContain( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"', + ); + expect(challenges).not.toContain("error="); + }); + + it("answers a malformed header with 400 invalid_request", async () => { + const response = await handlerFor( + stubClient(async () => ({ principal: principalWith([]) })), + )(post({ authorization: "Bearer a b" })); + expect(response.status).toBe(400); + expect(response.headers.get("www-authenticate")).toContain( + 'error="invalid_request"', + ); + }); + + it("answers a token that fails verification with 401 invalid_token and a generic body", async () => { + const response = await handlerFor( + stubClient(async () => { + throw new VerifyAccessTokenError("signature secret detail"); + }), + )(post({ authorization: "Bearer abc.def.ghi" })); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain( + 'error="invalid_token"', + ); + expect(await response.text()).not.toContain("secret detail"); + }); + + it("answers a missing server-wide scope with 403 and lists only the missing scopes", async () => { + const response = await handlerFor( + stubClient(async () => ({ principal: principalWith(["other"]) })), + { requiredScopes: ["mcp:access"] }, + )(post({ authorization: "Bearer abc.def.ghi" })); + expect(response.status).toBe(403); + const challenges = response.headers.get("www-authenticate") ?? ""; + expect(challenges).toContain('error="insufficient_scope"'); + expect(challenges).toContain('scope="mcp:access"'); + expect(challenges).not.toContain("other"); + }); + + it("sends one challenge per accepted scheme in the default mode, Bearer only when disabled", async () => { + const verify = async () => ({ principal: principalWith([]) }); + const allowed = await handlerFor(stubClient(verify, "allowed"))(post()); + expect(allowed.headers.get("www-authenticate")).toMatch(/^Bearer .*, DPoP /); + const disabled = await handlerFor(stubClient(verify, "disabled"))(post()); + expect(disabled.headers.get("www-authenticate")).not.toContain("DPoP"); + }); + + it("treats a Bearer request as unauthenticated when DPoP is required", async () => { + const response = await handlerFor( + stubClient(async () => ({ principal: principalWith([]) }), "required"), + )(post({ authorization: "Bearer abc.def.ghi" })); + expect(response.status).toBe(401); + const challenges = response.headers.get("www-authenticate") ?? ""; + expect(challenges).toContain("DPoP"); + expect(challenges).not.toContain("Bearer"); + expect(challenges).not.toContain("error="); + }); + + it("serves the metadata document at the RFC 9728 well-known path", async () => { + const response = await handlerFor( + stubClient(async () => ({ principal: principalWith([]) })), + )( + new Request("https://mcp.example.com/.well-known/oauth-protected-resource/mcp"), + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + resource: RESOURCE, + authorization_servers: ["https://your-tenant.us.auth0.com/"], + }); + }); + + it("maps this package's errors to statuses and turns other errors into a generic 500", async () => { + const authenticated = stubClient(async () => ({ + principal: principalWith(["mcp:access"]), + })); + const thrower = (error: unknown) => + createMcpHandler({ + client: authenticated, + resource: RESOURCE, + authorizationServers: ["https://your-tenant.us.auth0.com/"], + handler: async () => { + throw error; + }, + }); + const request = () => post({ authorization: "Bearer abc.def.ghi" }); + expect((await thrower(new UnauthenticatedError())(request())).status).toBe(401); + expect( + (await thrower(new InsufficientScopeError(["a"]))(request())).status, + ).toBe(403); + expect((await thrower(new DownscopedTokenError())(request())).status).toBe(403); + const internal = await thrower(new Error("database password"))(request()); + expect(internal.status).toBe(500); + expect(internal.headers.get("www-authenticate")).toBeNull(); + expect(await internal.text()).not.toContain("password"); + }); +}); + +describe("scope enforcement (ScopeEnforcement.md)", () => { + it("returns the documented error result for a tool the caller may not use", async () => { + const principal = principalWith(["read:orders"]); + const tool = withRequiredScopes(["read:orders", "read:payments"], async () => "ran"); + const result = await runWithRequestContext( + { principal, mcpClient: scopedClient(principal) }, + () => tool(), + ); + expect(result).toEqual({ + content: [{ type: "text", text: "Missing one or more required scopes." }], + isError: true, + _meta: { error: "insufficient_scope" }, + }); + }); + + it("runs the tool when every scope is held and ignores the permissions claim", async () => { + const principal: Principal = { + ...principalWith(["read:orders"]), + permissions: ["read:payments"], + }; + const context: RequestContext = { + principal, + mcpClient: scopedClient(principal), + }; + const allowed = withRequiredScopes("read:orders", async () => "ran"); + expect(await runWithRequestContext(context, () => allowed())).toBe("ran"); + const denied = withRequiredScopes("read:payments", async () => "ran"); + expect(await runWithRequestContext(context, () => denied())).toMatchObject({ + isError: true, + }); + }); + + it("requireScopes throws UnauthenticatedError without a context and accepts space-delimited input", () => { + expect(() => requireScopes("a")).toThrow(UnauthenticatedError); + const principal = principalWith(["a", "b"]); + expect(() => requireScopes("a b", principal)).not.toThrow(); + expect(() => requireScopes("a c", principal)).toThrow(InsufficientScopeError); + expect(() => requireScopes([], principal)).not.toThrow(); + }); + + it("keeps the required scopes out of the message", () => { + try { + requireScopes(["secret:scope"], principalWith([])); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(InsufficientScopeError); + expect((error as Error).message).not.toContain("secret:scope"); + expect((error as InsufficientScopeError).requiredScopes).toEqual(["secret:scope"]); + } + }); +}); + +describe("request context (RequestContext.md)", () => { + it("is undefined outside a request and visible after await inside one", async () => { + expect(getCallerPrincipal()).toBeUndefined(); + const principal = principalWith([]); + const seen = await runWithRequestContext( + { principal, mcpClient: scopedClient(principal) }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return getCallerPrincipal()?.sub; + }, + ); + expect(seen).toBe("auth0|user-1"); + }); +}); + +describe("error reference (Errors.md)", () => { + it("has the documented status and code per class", () => { + expect(new UnauthenticatedError()).toMatchObject({ statusCode: 401, code: "invalid_token" }); + expect(new InsufficientScopeError([])).toMatchObject({ statusCode: 403, code: "insufficient_scope" }); + expect(new DownscopedTokenError()).toMatchObject({ statusCode: 403, code: "insufficient_scope" }); + }); +}); + +describe("configuration (TokenCaching.md, OrganizationPolicy.md, Errors.md)", () => { + const base = { domain: "your-tenant.us.auth0.com", audience: RESOURCE }; + + it("rejects non_strict without an indexed store", () => { + expect(() => createMcpClient({ ...base, scopeMatching: "non_strict" })).toThrow( + InvalidConfigurationError, + ); + expect(() => + createMcpClient({ + ...base, + scopeMatching: "non_strict", + tokenStore: { get: async () => undefined, set: async () => {}, delete: async () => {} }, + }), + ).toThrow(InvalidConfigurationError); + }); + + it("accepts non_strict with an indexed store", () => { + expect(() => + createMcpClient({ + ...base, + scopeMatching: "non_strict", + tokenStore: new MemoryIndexedTokenStore(), + }), + ).not.toThrow(); + }); + + it("rejects an allowlist without the required policy", () => { + expect(() => createMcpClient({ ...base, organizationId: ["org_abc123"] })).toThrow( + InvalidConfigurationError, + ); + expect(() => + createMcpClient({ + ...base, + organizationPolicy: "required", + organizationId: ["org_abc123"], + }), + ).not.toThrow(); + }); + + it("rejects apiClient combined with core options", () => { + expect(() => + createMcpClient({ ...base, apiClient: {} as ApiClient, clientId: "x" }), + ).toThrow(InvalidConfigurationError); + }); + + it("requires an encryption secret of at least 32 characters", () => { + const inner = new MemoryIndexedTokenStore(); + expect(() => createEncryptedTokenStore(inner, { secret: "a".repeat(31) })).toThrow( + InvalidConfigurationError, + ); + expect(() => createEncryptedTokenStore(inner, { secret: "a".repeat(32) })).not.toThrow(); + }); + + it("limits the memory store to a positive size", () => { + expect(() => new MemoryIndexedTokenStore({ maxEntries: 0 })).toThrow( + InvalidConfigurationError, + ); + expect(new MemoryIndexedTokenStore().size).toBe(0); + }); + + it("reports the configured DPoP mode", () => { + expect(createMcpClient(base).dpopMode).toBe("allowed"); + expect(createMcpClient({ ...base, dpop: { mode: "required" } }).dpopMode).toBe("required"); + }); +}); + +describe("narrowed grants (OnBehalfOf.md)", () => { + const claims = { + iss: "https://your-tenant.us.auth0.com/", + sub: "auth0|user-1", + aud: RESOURCE, + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + scope: "mcp:access", + client_id: "client-1", + }; + + function clientWithGrant(grantedScopes: string[], scope?: string) { + const apiClient = { + verifyAccessToken: async () => claims, + getTokenOnBehalfOf: async () => ({ + accessToken: "downstream-token", + expiresAt: claims.exp, + grantedScopes, + ...(scope !== undefined && { scope }), + }), + getClientCredentialsToken: async () => ({ + accessToken: "server-token", + expiresAt: claims.exp, + ...(scope !== undefined && { scope }), + }), + } as unknown as ApiClient; + return createMcpClient({ domain: "your-tenant.us.auth0.com", audience: RESOURCE, apiClient }); + } + + async function scopedFor(client: McpClient) { + const caller = await client.verifyAccessToken({ accessToken: "t", scheme: "bearer" }); + return client.withToken(caller); + } + + it("throws DownscopedTokenError when the grant lacks a requested scope", async () => { + const scoped = await scopedFor(clientWithGrant(["read:reports"], "read:reports")); + await expect( + scoped.getTokenOnBehalfOf({ + audience: "https://reports-api.example.com", + scope: "read:reports write:reports", + }), + ).rejects.toBeInstanceOf(DownscopedTokenError); + }); + + it("returns the grant when it covers the request, and enforces nothing when no scope is requested", async () => { + const scoped = await scopedFor(clientWithGrant(["read:reports"])); + const covered = await scoped.getTokenOnBehalfOf({ + audience: "https://reports-api.example.com", + scope: "read:reports", + }); + expect(covered.grantedScopes).toEqual(["read:reports"]); + const unrestricted = await scoped.getTokenOnBehalfOf({ + audience: "https://reports-api.example.com", + }); + expect(unrestricted.accessToken).toBe("downstream-token"); + }); + + it("derives grantedScopes for client credentials from the request when the server omits scope", async () => { + const scoped = await scopedFor(clientWithGrant([])); + const token = await scoped.getClientCredentialsToken({ + audience: "https://internal-api.example.com", + scope: "write:config read:config", + }); + expect(token.grantedScopes).toEqual(["read:config", "write:config"]); + }); + + it("rejects an expired subject token with UnauthenticatedError", async () => { + const client = createMcpClient({ + domain: "your-tenant.us.auth0.com", + audience: RESOURCE, + apiClient: { + verifyAccessToken: async () => ({ ...claims, exp: Math.floor(Date.now() / 1000) - 1 }), + } as unknown as ApiClient, + }); + const caller = await client.verifyAccessToken({ accessToken: "t", scheme: "bearer" }); + expect(() => client.withToken(caller)).toThrow(UnauthenticatedError); + }); +}); diff --git a/packages/ai-mcp/test/docs/docs-snippets.spec.ts b/packages/ai-mcp/test/docs/docs-snippets.spec.ts new file mode 100644 index 00000000..9acd45e3 --- /dev/null +++ b/packages/ai-mcp/test/docs/docs-snippets.spec.ts @@ -0,0 +1,189 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * Every `ts` code block in the documentation is compiled against the package source. + * A snippet that uses a name or signature the package does not export fails here. + * The suite also checks relative links, anchors and wording rules of the documents. + */ + +const packageDir = fileURLToPath(new URL("../..", import.meta.url)); + +function documentPaths(): string[] { + const docsDir = path.join(packageDir, "docs"); + const guides = readdirSync(docsDir) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => path.join(docsDir, name)); + return [ + path.join(packageDir, "README.md"), + path.join(packageDir, "EXAMPLES.md"), + ...guides, + ]; +} + +interface Snippet { + id: string; + fileName: string; + code: string; +} + +function extractSnippets(): Snippet[] { + const snippets: Snippet[] = []; + for (const file of documentPaths()) { + const relative = path.relative(packageDir, file); + const text = readFileSync(file, "utf8"); + const pattern = /^```(?:ts|tsx|typescript)\n([\s\S]*?)^```$/gm; + let index = 0; + for (const match of text.matchAll(pattern)) { + index += 1; + const base = relative.replace(/[^A-Za-z0-9]+/g, "_"); + snippets.push({ + id: `${relative} block ${index}`, + fileName: path.join(packageDir, "test", "docs", "__snippets__", `${base}_${index}.ts`), + code: match[1] ?? "", + }); + } + } + return snippets; +} + +const snippets = extractSnippets(); + +describe("documentation snippets", () => { + let program: ts.Program; + const virtualFiles = new Map(snippets.map((s) => [s.fileName, s.code])); + + beforeAll(() => { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ["lib.es2022.d.ts", "lib.dom.d.ts"], + strict: true, + noEmit: true, + skipLibCheck: true, + esModuleInterop: true, + isolatedModules: true, + types: ["node"], + typeRoots: [ + path.join(packageDir, "node_modules", "@types"), + path.join(packageDir, "..", "..", "node_modules", "@types"), + ], + baseUrl: packageDir, + paths: { + "@auth0/ai-mcp": ["src/index.ts"], + "@auth0/ai-mcp/mcp-sdk": ["src/mcp-sdk.ts"], + }, + }; + const host = ts.createCompilerHost(options); + const { fileExists, readFile, getSourceFile } = host; + host.fileExists = (name) => virtualFiles.has(path.resolve(name)) || fileExists(name); + host.readFile = (name) => virtualFiles.get(path.resolve(name)) ?? readFile(name); + host.getSourceFile = (name, languageVersion, onError, shouldCreate) => { + const virtual = virtualFiles.get(path.resolve(name)); + return virtual === undefined + ? getSourceFile(name, languageVersion, onError, shouldCreate) + : ts.createSourceFile(name, virtual, languageVersion, true); + }; + program = ts.createProgram([...virtualFiles.keys()], options, host); + }, 120_000); + + it("finds the documented code blocks", () => { + expect(snippets.length).toBe(29); + const fenceCount = documentPaths().reduce( + (total, file) => + total + (readFileSync(file, "utf8").match(/^```(?:ts|tsx|typescript|js|jsx|javascript)$/gm) ?? []).length, + 0, + ); + expect(fenceCount).toBe(snippets.length); + }); + + it.each(snippets.map((s) => [s.id, s.fileName] as const))( + "%s compiles against the package", + (_id, fileName) => { + const source = program.getSourceFile(fileName); + expect(source, "source file is part of the program").toBeDefined(); + const diagnostics = [ + ...program.getSyntacticDiagnostics(source), + ...program.getSemanticDiagnostics(source), + ]; + const messages = diagnostics.map((d) => { + const position = + d.file !== undefined && d.start !== undefined + ? d.file.getLineAndCharacterOfPosition(d.start) + : undefined; + return `${position ? `${position.line + 1}:${position.character + 1} ` : ""}${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`; + }); + expect(messages).toEqual([]); + }, + 60_000, + ); +}); + +function slugify(heading: string): string { + return heading + .trim() + .toLowerCase() + .replace(/`/g, "") + .replace(/[^a-z0-9 _-]/g, "") + .replace(/ /g, "-"); +} + +function anchorsOf(text: string): Set { + const anchors = new Set(); + let fenced = false; + for (const line of text.split("\n")) { + if (line.startsWith("```")) fenced = !fenced; + if (fenced) continue; + const match = /^#{1,6}\s+(.*)$/.exec(line); + if (match?.[1] !== undefined) anchors.add(slugify(match[1])); + } + return anchors; +} + +describe("documentation structure", () => { + const files = documentPaths(); + + it.each(files.map((f) => [path.relative(packageDir, f), f] as const))( + "%s has working relative links and anchors", + (_name, file) => { + const text = readFileSync(file, "utf8"); + const prose = text.replace(/^```[\s\S]*?^```$/gm, ""); + const broken: string[] = []; + for (const match of prose.matchAll(/\]\((?!https?:|mailto:)([^)\s]+)\)/g)) { + const target = match[1] ?? ""; + const [rawPath = "", anchor] = target.split("#"); + const resolved = rawPath === "" ? file : path.resolve(path.dirname(file), rawPath); + if (!existsSync(resolved)) { + broken.push(`${target} (missing file)`); + continue; + } + if (anchor !== undefined && resolved.endsWith(".md")) { + if (!anchorsOf(readFileSync(resolved, "utf8")).has(anchor)) { + broken.push(`${target} (missing anchor)`); + } + } + } + expect(broken).toEqual([]); + }, + ); + + it.each(files.map((f) => [path.relative(packageDir, f), f] as const))( + "%s has no dashes used as punctuation and no internal identifiers", + (_name, file) => { + const text = readFileSync(file, "utf8"); + expect(text).not.toMatch(/[–—]/); + expect(text).not.toMatch(/ -- /); + expect(text).not.toMatch(/\b(?:SDK|ROAD|PSREV|SEC|AIMCP)-\d+\b/); + expect(text).not.toMatch(/\bD\d{1,3}\b/); + expect(text).not.toMatch(/phase\s*[12]/i); + expect(text).not.toMatch(/\bdev-[a-z0-9]{8,}/i); + expect(text).not.toMatch(/obo-poc|\batko\b/i); + expect(text).not.toMatch(/atlassian|jira|confluence/i); + }, + ); +}); From 216bf274ceac90871ad68a9bf4b447d4bcb66436 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 16:12:50 +0530 Subject: [PATCH 24/31] fix(ai-mcp): redact the bearer from AuthInfo and reject non-POST on the 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 --- packages/ai-mcp/src/handler.ts | 14 ++++++- packages/ai-mcp/src/mcp-sdk.ts | 20 +++++++++- packages/ai-mcp/test/handler.spec.ts | 4 +- packages/ai-mcp/test/integration/harness.ts | 9 ++++- .../test/integration/transport-bearer.spec.ts | 6 ++- packages/ai-mcp/test/mcp-sdk.spec.ts | 40 ++++++++++++++++++- 6 files changed, 83 insertions(+), 10 deletions(-) diff --git a/packages/ai-mcp/src/handler.ts b/packages/ai-mcp/src/handler.ts index c3ec3b3f..ca9949b3 100644 --- a/packages/ai-mcp/src/handler.ts +++ b/packages/ai-mcp/src/handler.ts @@ -25,7 +25,14 @@ import type { Principal, RequestScopedMcpClient } from "./types.js"; * handed to that SDK as is. */ export interface AuthInfo { - /** The access token the caller presented. */ + /** + * Always the empty string. The presented access token is intentionally + * withheld so tool code cannot forward or log it by accident; tools should use + * the request-scoped client (`getCallerMcpClient()`) to call downstream APIs. + * Note that the MCP SDK transport still exposes the raw request headers through + * `extra.requestInfo.headers`, so the `Authorization` header remains reachable + * there. + */ token: string; /** The OAuth client the token was issued to. Empty when the token names none. */ clientId: string; @@ -294,6 +301,9 @@ function classifyAuthentication(error: unknown): Failure { * Maps an error thrown by the downstream handler. Only the wrapper's own errors * become client-facing failures; anything else, including core errors such as a * failed token exchange, is an internal error and must not turn into a challenge. + * These errors only reach this function when they escape the downstream handler. + * Under `createMcpSdkHandler` the MCP SDK catches errors thrown by tools first, so + * a downscoped exchange there becomes an `isError` tool result with HTTP 200. */ function classifyDownstream(error: unknown, held: readonly string[]): Failure { if (error instanceof InsufficientScopeError) { @@ -518,7 +528,7 @@ export function createMcpHandler( } const authInfo: AuthInfo = { - token: credentials.token, + token: '', clientId: principal.clientId ?? "", scopes: [...principal.scopes], expiresAt: principal.expiresAt, diff --git a/packages/ai-mcp/src/mcp-sdk.ts b/packages/ai-mcp/src/mcp-sdk.ts index aa074ad2..2415b068 100644 --- a/packages/ai-mcp/src/mcp-sdk.ts +++ b/packages/ai-mcp/src/mcp-sdk.ts @@ -50,6 +50,10 @@ export interface McpSdkHandlerOptions extends Omit * inside tools. * * `AuthInfo.clientId` is `''` when the token carries no `client_id` or `azp`. + * `AuthInfo.token` is always `''`: the bearer is deliberately withheld, so tools + * should call downstream APIs through the request-scoped client. The SDK + * transport still exposes raw request headers as `extra.requestInfo.headers`, so + * the `Authorization` header is reachable there. * * Mode: stateless and JSON responses. The server is closed as soon as * `handleRequest` returns, which is only valid for JSON response mode (a streamed @@ -63,7 +67,14 @@ export interface McpSdkHandlerOptions extends Omit * Failures that happen before the server runs (no credential, bad token, missing * server-wide scope) follow the challenge rules of {@link createMcpHandler}. * A tool that needs a scope only for itself should use `withRequiredScopes`, - * which answers with an `isError` tool result and HTTP 200. + * which answers with an `isError` tool result and HTTP 200. The same holds for a + * downscoped token exchange inside a tool: the MCP SDK catches errors thrown by + * tools, so on this path the caller sees a sanitized tool result with + * `isError: true` and HTTP 200. A 403 `insufficient_scope` response applies only + * when such an error escapes a custom {@link createMcpHandler} handler. + * + * Only POST is served on the MCP endpoint. After authentication, any other method + * gets 405 with `Allow: POST`. * * @param options - Handler options plus {@link McpSdkHandlerOptions.createServer}. * @returns A function from `Request` to `Response`. @@ -75,6 +86,11 @@ export function createMcpSdkHandler( return createMcpHandler({ ...handlerOptions, handler: async (request: Request, authInfo: AuthInfo): Promise => { + // Only POST is served. This runs after authentication (the metadata route is + // answered before it), so an unauthenticated GET gets the usual 401 challenge. + if (request.method !== 'POST') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }); + } const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, @@ -365,7 +381,7 @@ export function asTokenVerifier(client: McpClient): OAuthTokenVerifier { } const principal = caller.principal; return { - token, + token: '', clientId: principal.clientId ?? '', scopes: [...principal.scopes], expiresAt: principal.expiresAt, diff --git a/packages/ai-mcp/test/handler.spec.ts b/packages/ai-mcp/test/handler.spec.ts index 9f08f795..f7c34be3 100644 --- a/packages/ai-mcp/test/handler.spec.ts +++ b/packages/ai-mcp/test/handler.spec.ts @@ -476,7 +476,7 @@ describe("DPoP modes", () => { request({ authorization: `DPoP ${accessToken}`, dpop: proof }), ); expect(response.status).toBe(200); - expect(calls[0]?.authInfo.token).toBe(accessToken); + expect(calls[0]?.authInfo.token).toBe(''); }); it("allowed: accepts a DPoP request and a Bearer request", async () => { @@ -625,7 +625,7 @@ describe("AuthInfo", () => { expect(response.status).toBe(200); const info = calls[0]?.authInfo; expect(info).toEqual({ - token: headers.authorization.slice("Bearer ".length), + token: '', clientId: "client-a", scopes: ["read", "write"], expiresAt: exp, diff --git a/packages/ai-mcp/test/integration/harness.ts b/packages/ai-mcp/test/integration/harness.ts index c9b0494a..2f42c9c1 100644 --- a/packages/ai-mcp/test/integration/harness.ts +++ b/packages/ai-mcp/test/integration/harness.ts @@ -299,9 +299,14 @@ export function buildToolServer(options: ToolServerOptions): McpServer { "forward_inbound", { description: "Sends the caller's own inbound token downstream, which a server must never do." }, async (extra) => { - const info = (extra as { authInfo?: { token: string } }).authInfo; + // AuthInfo.token is withheld (always ''), so a server that forwards the inbound token has to + // read the raw Authorization header, the one remaining channel the transport exposes. + const headers = (extra as { requestInfo?: { headers?: Record } }) + .requestInfo?.headers; + const raw = headers?.authorization; + const authorization = Array.isArray(raw) ? raw[0] : raw; const response = await fetch(options.downstreamUrl, { - headers: { authorization: `Bearer ${info!.token}` }, + headers: { authorization: authorization! }, }); const outcome: DelegateOutcome = { status: response.status, diff --git a/packages/ai-mcp/test/integration/transport-bearer.spec.ts b/packages/ai-mcp/test/integration/transport-bearer.spec.ts index 6aa72c8b..d38b99a1 100644 --- a/packages/ai-mcp/test/integration/transport-bearer.spec.ts +++ b/packages/ai-mcp/test/integration/transport-bearer.spec.ts @@ -138,7 +138,11 @@ describe.each(WIRINGS)("bearer authentication through the SDK client (%s wiring) expect(isError).toBe(false); expect(value).toMatchObject({ contextSub: "user|valid", clientSub: "user|valid" }); - expect(session.seen.every((r) => r.status === 200 || r.status === 202)).toBe(true); + // The SDK client opens a GET for an SSE stream after initialize. The handler serves POST + // only and answers 405, which the client treats as "no stream" without retrying. + const gets = session.seen.filter((r) => r.status === 405); + expect(gets.length).toBeLessThanOrEqual(1); + expect(session.seen.every((r) => r.status === 200 || r.status === 202 || r.status === 405)).toBe(true); } finally { await session.close(); } diff --git a/packages/ai-mcp/test/mcp-sdk.spec.ts b/packages/ai-mcp/test/mcp-sdk.spec.ts index b2f9d776..253122c3 100644 --- a/packages/ai-mcp/test/mcp-sdk.spec.ts +++ b/packages/ai-mcp/test/mcp-sdk.spec.ts @@ -176,11 +176,49 @@ describe("createMcpSdkHandler", () => { scopes: ["read", "write"], extra: { sub: "user|1" }, }); - expect(typeof seen[0]!.authInfo!.token).toBe("string"); + expect(seen[0]!.authInfo!.token).toBe(""); expect(seen[0]!.contextSub).toBe("user|1"); expect(seen[0]!.hasClient).toBe(true); }); + it("answers 405 with Allow: POST for GET and DELETE on the MCP path", async () => { + useTenant(); + const factory = vi.fn(() => buildMcpServer([])); + const handle = makeHandler([], { createServer: factory }); + for (const method of ["GET", "DELETE"]) { + const response = await handle( + new Request(ENDPOINT, { + method, + headers: { authorization: `Bearer ${await token()}`, accept: "text/event-stream" }, + }), + ); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + } + expect(factory).not.toHaveBeenCalled(); + }); + + it("still serves the metadata document with GET", async () => { + useTenant(); + const response = await makeHandler([])( + new Request(`${ORIGIN}/.well-known/oauth-protected-resource`), + ); + expect(response.status).toBe(200); + }); + + it("surfaces a downscoped exchange inside a tool as an isError result with HTTP 200", async () => { + useTenant(); + tenant.state.scope = scopeMode.fixed("other:scope"); + const response = await makeHandler([])( + toolCall("delegate", { authorization: `Bearer ${await token()}` }), + ); + const body = (await response.json()) as ToolResult; + + expect(response.status).toBe(200); + expect(body.result?.isError).toBe(true); + expect(body.result?.content[0]?.text).not.toContain("api:read"); + }); + it("lets a tool exchange the caller's token through the request-scoped client", async () => { useTenant(); const response = await makeHandler([])( From 26dc83482995be62e6a05449e0574058aec0278e Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 16:13:05 +0530 Subject: [PATCH 25/31] fix(ai-mcp): treat a narrowed client credentials grant as a server failure 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 --- packages/ai-mcp/src/request-scoped-client.ts | 41 ++++++++++++----- .../ai-mcp/test/request-scoped-client.spec.ts | 18 +++++--- .../ai-mcp/test/request-scoped-errors.spec.ts | 44 +++++++++++++++++-- 3 files changed, 80 insertions(+), 23 deletions(-) diff --git a/packages/ai-mcp/src/request-scoped-client.ts b/packages/ai-mcp/src/request-scoped-client.ts index 66c4387d..3cf62829 100644 --- a/packages/ai-mcp/src/request-scoped-client.ts +++ b/packages/ai-mcp/src/request-scoped-client.ts @@ -82,11 +82,26 @@ function flightKey(identity: ExchangeIdentity, verified: VerifiedToken, audience * Throws when scopes were requested and the grant does not include all of them. * No scope requested means nothing to enforce. */ -function assertCovers(requested: string | undefined, grantedScopes: readonly string[]): void { - if (requested === undefined) return; +function covers(requested: string | undefined, grantedScopes: readonly string[]): boolean { + if (requested === undefined) return true; const granted = new Set(grantedScopes); - if (!normalizeScopes(requested).every((scope) => granted.has(scope))) { - throw new DownscopedTokenError(); + return normalizeScopes(requested).every((scope) => granted.has(scope)); +} + +/** Rejects an exchanged token whose grant is narrower than requested, as a caller-facing error. */ +function assertCovers(requested: string | undefined, grantedScopes: readonly string[]): void { + if (!covers(requested, grantedScopes)) throw new DownscopedTokenError(); +} + +/** + * Rejects a client credentials grant narrower than requested. The credentials belong to this + * server, so the caller's token has no part in it and the caller cannot fix it. The failure + * is therefore a plain server-side error rather than an auth error, and carries the + * {@link DownscopedTokenError} as its `cause` for diagnostics. + */ +function assertServerGrantCovers(requested: string | undefined, grantedScopes: readonly string[]): void { + if (!covers(requested, grantedScopes)) { + throw withCause(new Error(GENERIC_FAILURE_MESSAGE), new DownscopedTokenError()); } } @@ -219,16 +234,18 @@ export function createRequestScopedClient(init: RequestScopedClientInit): Reques return result; }), - getClientCredentialsToken: (options: ClientCredentialsOptions): Promise => - guarded(async () => { - const result = await apiClient.getClientCredentialsToken({ + getClientCredentialsToken: async (options: ClientCredentialsOptions): Promise => { + const result = await guarded(() => + apiClient.getClientCredentialsToken({ audience: options.audience, ...(options.scope !== undefined && { scope: options.scope }), - }); - const grantedScopes = normalizeScopes(result.scope ?? options.scope ?? ''); - assertCovers(options.scope, grantedScopes); - return { ...result, grantedScopes }; - }), + }) + ); + const grantedScopes = normalizeScopes(result.scope ?? options.scope ?? ''); + // Thrown outside `guarded`: this error is already sanitized and must keep its own cause. + assertServerGrantCovers(options.scope, grantedScopes); + return { ...result, grantedScopes }; + }, getTokenForService: (options: TokenForServiceOptions) => guarded(() => diff --git a/packages/ai-mcp/test/request-scoped-client.spec.ts b/packages/ai-mcp/test/request-scoped-client.spec.ts index 5cf128a2..a12e3d94 100644 --- a/packages/ai-mcp/test/request-scoped-client.spec.ts +++ b/packages/ai-mcp/test/request-scoped-client.spec.ts @@ -3,7 +3,7 @@ import { inspect } from 'node:util'; import { HttpResponse, http } from 'msw'; import { setupServer } from 'msw/node'; import { decodeJwt } from 'jose'; -import { MissingClientAuthError } from '@auth0/auth0-api-js'; +import { AuthError, MissingClientAuthError } from '@auth0/auth0-api-js'; import type { CachedToken, IndexedTokenStore, TokenIndexMember } from '@auth0/auth0-api-js'; import { createMcpClient } from '../src/client.js'; import type { McpClient, VerifiedCaller } from '../src/client.js'; @@ -301,9 +301,12 @@ describe('request-scoped client: other operations', () => { const client = makeClient(); const scoped = client.withToken(await caller(client)); - await expect( - scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a b' }) - ).rejects.toBeInstanceOf(DownscopedTokenError); + const err = await scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a b' }).catch((e) => e); + + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(AuthError); + expect(err).not.toBeInstanceOf(DownscopedTokenError); + expect(err.cause).toBeInstanceOf(DownscopedTokenError); }); it('rejects an empty client credentials grant when scopes were requested', async () => { @@ -311,9 +314,10 @@ describe('request-scoped client: other operations', () => { const client = makeClient(); const scoped = client.withToken(await caller(client)); - await expect(scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a' })).rejects.toBeInstanceOf( - DownscopedTokenError - ); + const err = await scoped.getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'a' }).catch((e) => e); + + expect(err).not.toBeInstanceOf(AuthError); + expect(err.cause).toBeInstanceOf(DownscopedTokenError); }); it('reports no granted scopes for an empty client credentials response when none were requested', async () => { diff --git a/packages/ai-mcp/test/request-scoped-errors.spec.ts b/packages/ai-mcp/test/request-scoped-errors.spec.ts index 3a3f1cde..df6fa689 100644 --- a/packages/ai-mcp/test/request-scoped-errors.spec.ts +++ b/packages/ai-mcp/test/request-scoped-errors.spec.ts @@ -1,10 +1,12 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { HttpResponse, http } from 'msw'; import { setupServer } from 'msw/node'; import { AuthError, InvalidRequestError, MissingClientAuthError } from '@auth0/auth0-api-js'; import type { CachedToken } from '@auth0/auth0-api-js'; import { sanitizeTokenError } from '../src/request-scoped-client.js'; import { createMcpClient } from '../src/client.js'; +import { createMcpHandler } from '../src/handler.js'; +import { getCallerMcpClientOrThrow } from '../src/index.js'; import type { McpClient, VerifiedCaller } from '../src/client.js'; import { DownscopedTokenError, InsufficientScopeError, UnauthenticatedError } from '../src/errors.js'; import { createAuth0Handlers, createTestKey, mintAccessToken, scopeMode } from './support/index.js'; @@ -286,7 +288,7 @@ describe('request-scoped client: package errors', () => { expect(error.cause).toBeUndefined(); }); - it('passes a narrowed client credentials grant through unchanged', async () => { + it('fails a narrowed client credentials grant with a non-auth error that carries the downscope cause', async () => { useTenant(scopeMode.fixed('other:scope')); const client = makeClient(); const scoped = client.withToken(await caller(client)); @@ -298,7 +300,41 @@ describe('request-scoped client: package errors', () => { }) ); - expect(error).toBeInstanceOf(DownscopedTokenError); - expect(error.cause).toBeUndefined(); + expect(error).not.toBeInstanceOf(AuthError); + expect(error).not.toBeInstanceOf(DownscopedTokenError); + expect(error.message).toBe(GENERIC); + expect(error.cause).toBeInstanceOf(DownscopedTokenError); + }); + + it('answers a generic 500 without a scope challenge when a handler hits a narrowed client credentials grant', async () => { + useTenant(scopeMode.fixed('other:scope')); + const client = makeClient(); + const onError = vi.fn(); + const handle = createMcpHandler({ + client, + resource: AUDIENCE, + authorizationServers: [tenant.issuer], + onError, + handler: async () => { + await getCallerMcpClientOrThrow().getClientCredentialsToken({ audience: DOWNSTREAM, scope: 'read:a' }); + return new Response('ok'); + }, + }); + const accessToken = await mintAccessToken(key, { + iss: tenant.issuer, + aud: AUDIENCE, + sub: 'user|1', + scope: 'openid', + }); + + const response = await handle( + new Request(`${AUDIENCE}/mcp`, { method: 'POST', headers: { authorization: `Bearer ${accessToken}` } }) + ); + + expect(response.status).toBe(500); + expect(response.headers.get('www-authenticate')).toBeNull(); + expect(await response.json()).toEqual({ error: 'server_error', error_description: 'Internal server error.' }); + expect(onError).toHaveBeenCalledTimes(1); + expect((onError.mock.calls[0]?.[0] as Error).cause).toBeInstanceOf(DownscopedTokenError); }); }); From 1db6e2079c670b0987ac1747563b06ce20635f3a Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 16:13:17 +0530 Subject: [PATCH 26/31] docs(ai-mcp): route the metadata path and document handler status behaviour 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 --- packages/ai-mcp/README.md | 9 ++++++++- packages/ai-mcp/docs/Errors.md | 6 +++--- packages/ai-mcp/docs/Handler.md | 13 +++++++++++-- packages/ai-mcp/docs/OnBehalfOf.md | 4 ++-- packages/ai-mcp/docs/ScopeEnforcement.md | 2 +- packages/ai-mcp/docs/TokenVerification.md | 4 ++++ packages/ai-mcp/src/types.ts | 4 ++++ packages/ai-mcp/test/docs/docs-snippets.spec.ts | 10 ++-------- 8 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/ai-mcp/README.md b/packages/ai-mcp/README.md index ca0c4786..001b0359 100644 --- a/packages/ai-mcp/README.md +++ b/packages/ai-mcp/README.md @@ -102,7 +102,14 @@ What this gives you: - `list_reports` runs only for callers whose token carries `tool:list_reports`. Other callers get a tool result with `isError: true`. - The token exchange always acts for the caller of the current request. -`createNodeMcpHandler` mounts on `http.createServer` and on Express with `app.all('/mcp', handler)`. Mount it before any body-parsing middleware. For other runtimes use `createMcpSdkHandler`, which takes a standard `Request` and returns a `Response`. For a different MCP stack use [`createMcpHandler`](./docs/Handler.md). +`createNodeMcpHandler` mounts on `http.createServer` as shown above. On Express, route both the MCP path and the protected resource metadata path to the handler, or MCP clients cannot discover your authorization server: + +```text +app.all('/mcp', handler); +app.get('/.well-known/oauth-protected-resource/mcp', handler); +``` + +The metadata path is `/.well-known/oauth-protected-resource` followed by the path of `resource`. Set `metadata.path` to serve it elsewhere. Mount the handler before any body-parsing middleware. For other runtimes use `createMcpSdkHandler`, which takes a standard `Request` and returns a `Response`. For a different MCP stack use [`createMcpHandler`](./docs/Handler.md). ## Documentation diff --git a/packages/ai-mcp/docs/Errors.md b/packages/ai-mcp/docs/Errors.md index 7fbb6887..616abbaf 100644 --- a/packages/ai-mcp/docs/Errors.md +++ b/packages/ai-mcp/docs/Errors.md @@ -10,7 +10,7 @@ All authentication errors extend `AuthError` from `@auth0/auth0-api-js`, which c | --- | --- | --- | --- | | `UnauthenticatedError` | 401 | `invalid_token` | Protected code runs without an authenticated caller, for example outside a request context, or `withToken` receives an expired token. | | `InsufficientScopeError` | 403 | `insufficient_scope` | The caller lacks a scope that `requireScopes` checks. | -| `DownscopedTokenError` | 403 | `insufficient_scope` | A token exchange or client credentials grant succeeded but did not include every requested scope. | +| `DownscopedTokenError` | 403 | `insufficient_scope` | An on-behalf-of token exchange succeeded but did not include every requested scope. A client credentials grant that falls short is a server configuration failure instead, see [On Behalf Of](./OnBehalfOf.md#client-credentials). | | `ForeignCallerError` | none | none | `McpClient.withToken` receives a handle that this client did not issue. This is a programming error. It extends `Error`, not `AuthError`, so it is never turned into a challenge. | Messages are generic by design: @@ -34,12 +34,12 @@ Messages are generic by design: | `InvalidRequestError` | 400 | `invalid_request` | | Missing scope in `requiredScopes` | 403 | `insufficient_scope` | | `UnauthenticatedError` thrown by your handler | 401 | `invalid_token` | -| `InsufficientScopeError` or `DownscopedTokenError` thrown by your handler | 403 | `insufficient_scope` | +| `InsufficientScopeError` or `DownscopedTokenError` escaping a custom `createMcpHandler` handler | 403 | `insufficient_scope` | | Anything else thrown by your handler, including core and network errors | 500 | none, generic body | Only this package's three error classes become client-facing failures when they escape your handler. A failed token exchange or a `ForeignCallerError` is an internal error: the client gets a generic 500 and `onError` receives the real error. -With the official MCP SDK's `McpServer`, the SDK turns an error thrown inside a tool callback into a tool result with `isError: true`, so the error does not reach the handler. Catch errors in the tool when you want control over the message. +With the official MCP SDK's `McpServer` (`createMcpSdkHandler` and `createNodeMcpHandler`), the SDK turns an error thrown inside a tool callback into a tool result with `isError: true` and a sanitized message, so the error does not reach the handler and the HTTP status stays 200. That includes `DownscopedTokenError` from a downscoped exchange. Catch errors in the tool when you want control over the message. ## Core errors diff --git a/packages/ai-mcp/docs/Handler.md b/packages/ai-mcp/docs/Handler.md index 70fae5ad..f4300719 100644 --- a/packages/ai-mcp/docs/Handler.md +++ b/packages/ai-mcp/docs/Handler.md @@ -85,12 +85,17 @@ Failures that happen before your handler runs use a generic JSON body (`error` a | Server requires a DPoP nonce | 401 | `use_dpop_nonce` | | Token lacks a scope in `requiredScopes` | 403 | `insufficient_scope`, with `scope` listing the missing scopes | | Your handler throws `UnauthenticatedError` | 401 | `invalid_token` | -| Your handler throws `InsufficientScopeError` or `DownscopedTokenError` | 403 | `insufficient_scope` | +| A custom `createMcpHandler` handler throws `InsufficientScopeError` or `DownscopedTokenError` | 403 | `insufficient_scope` | | Anything else | 500 | no challenge, generic body | A missing credential gets a challenge without an `error` value, because RFC 6750 section 3.1 reserves error values for requests that did carry authentication. The challenge always advertises the schemes the DPoP mode accepts and the `resource_metadata` URL. -Every path other than the metadata path goes through authentication. The handler does not route, so mount it only on the path that serves MCP. +Every path other than the metadata path goes through authentication. The handler does not route, so mount it only on the path that serves MCP, and also on the metadata path. The metadata path is `/.well-known/oauth-protected-resource` followed by the path of `resource` (for `https://mcp.example.com/mcp` that is `/.well-known/oauth-protected-resource/mcp`), unless you set `metadata.path`. A framework that routes by path, such as Express, needs both routes or discovery fails: + +```text +app.all('/mcp', handler); +app.get('/.well-known/oauth-protected-resource/mcp', handler); +``` ### Proxies @@ -136,6 +141,10 @@ export const handle = createMcpSdkHandler({ The verified caller reaches your tools in two ways: as `extra.authInfo` in every tool callback, which is the SDK's own mechanism, and through the request context, so `getCallerMcpClient()`, `getCallerPrincipal()` and `withRequiredScopes()` work inside tools. `authInfo.clientId` is an empty string when the token carries neither `client_id` nor `azp`. +The verified caller reaches your tools in the ways above, but the raw access token does not: `extra.authInfo.token` is intentionally an empty string. Call downstream APIs through the request-scoped client (`getCallerMcpClient()`), never with a token you read yourself. The MCP SDK still exposes the raw request headers, including `Authorization`, to tools through `extra.requestInfo.headers`, so tool code must not log or forward headers. + +A downscoped token exchange inside a tool is not an HTTP failure on this adapter. The SDK turns the error into a tool result with `isError: true` and a generic message, and the response stays HTTP 200. The 403 `insufficient_scope` response applies only when the error escapes a custom `createMcpHandler` handler, as in the table above. A request to the MCP endpoint with any method other than `POST` gets a 405 with `Allow: POST`. + `createServer` runs once per request, and the adapter closes the server when the request ends. Register tools inside it and keep expensive setup outside of it. The adapter is stateless and answers with JSON bodies. See [known limitations](./KnownLimitations.md#the-mcp-sdk-adapter-is-stateless). diff --git a/packages/ai-mcp/docs/OnBehalfOf.md b/packages/ai-mcp/docs/OnBehalfOf.md index 02750876..ee3fd35b 100644 --- a/packages/ai-mcp/docs/OnBehalfOf.md +++ b/packages/ai-mcp/docs/OnBehalfOf.md @@ -47,7 +47,7 @@ Use `grantedScopes`, not `scope`, to decide what a token covers. An empty `scope ## Narrowed grants -Auth0 can grant fewer scopes than you asked for. When you request scopes and the grant does not include all of them, `getTokenOnBehalfOf` throws `DownscopedTokenError`, so a tool never runs with less authority than it asked for. The error carries no scope data. +Auth0 can grant fewer scopes than you asked for. When you request scopes and the grant does not include all of them, `getTokenOnBehalfOf` throws `DownscopedTokenError`, so a tool never runs with less authority than it asked for. The error carries no scope data. On `createMcpSdkHandler` and `createNodeMcpHandler`, the MCP SDK turns the error into a sanitized tool result with `isError: true` and the response stays HTTP 200. A 403 `insufficient_scope` is sent only when the error escapes a custom `createMcpHandler` handler. The narrower token is still cached under the scopes it was actually granted. A later, narrower request can reuse it. See [Token caching](./TokenCaching.md). @@ -97,6 +97,6 @@ export async function readConfig(): Promise { } ``` -The result is the core `TokenSet` (`accessToken`, `expiresAt`, `scope`, `tokenType`) plus `grantedScopes`. When the server returned no `scope` field, `grantedScopes` holds the normalized requested scopes. As with the exchange, requesting scopes that the grant does not include throws `DownscopedTokenError`. +The result is the core `TokenSet` (`accessToken`, `expiresAt`, `scope`, `tokenType`) plus `grantedScopes`. When the server returned no `scope` field, `grantedScopes` holds the normalized requested scopes. A client credentials grant that is narrower than requested is a server-side configuration failure, because the scopes belong to the server's own client and the caller cannot fix them. It surfaces as a generic 500 through `onError`, with the downscope detail as the error's `cause`, and not as a 403. This package does not cache client credentials tokens. diff --git a/packages/ai-mcp/docs/ScopeEnforcement.md b/packages/ai-mcp/docs/ScopeEnforcement.md index 3458be5d..4693294f 100644 --- a/packages/ai-mcp/docs/ScopeEnforcement.md +++ b/packages/ai-mcp/docs/ScopeEnforcement.md @@ -96,7 +96,7 @@ It throws: - `UnauthenticatedError` when you pass no principal and no request context is active. Passing `undefined` explicitly means "use the current caller". - `InsufficientScopeError` when any scope is missing. The error's `requiredScopes` property holds the normalized list for server-side use. Do not echo it to clients. -When a tool callback lets `InsufficientScopeError` escape to the handler, the handler answers HTTP 403 with `insufficient_scope`. With the official MCP SDK's `McpServer`, the SDK turns an error thrown inside a tool callback into a tool result with `isError: true` instead, using the error's message. +When `InsufficientScopeError` escapes a custom `createMcpHandler` handler, the handler answers HTTP 403 with `insufficient_scope`. On `createMcpSdkHandler` and `createNodeMcpHandler`, the official MCP SDK's `McpServer` turns an error thrown inside a tool callback into a tool result with `isError: true` and a sanitized message, and the HTTP status stays 200. The same applies to `DownscopedTokenError` from a downscoped exchange inside a tool. ## Tenant-side setup diff --git a/packages/ai-mcp/docs/TokenVerification.md b/packages/ai-mcp/docs/TokenVerification.md index c0aa410f..0947a729 100644 --- a/packages/ai-mcp/docs/TokenVerification.md +++ b/packages/ai-mcp/docs/TokenVerification.md @@ -25,6 +25,10 @@ A `Principal` describes the caller. It holds no credential and is safe to log. T Scope checks use the `scopes` field only. The `permissions` claim is there for your own logic. Neither `requireScopes` nor the handler consults it. +## The token inside tools + +Tools never receive the raw access token. On the MCP SDK adapter, `extra.authInfo.token` is intentionally an empty string, and tools call downstream APIs through the request-scoped client. The MCP SDK still exposes the raw request headers, including `Authorization`, to tools through `extra.requestInfo.headers`, so tool code must not log or forward headers. + ## Verifying a token yourself The handler does this for you. Call `verifyAccessToken` directly when you build your own transport: diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index 8881fb27..a0785c81 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -94,6 +94,10 @@ export type ServiceTokenResult = Awaited { ); it.each(files.map((f) => [path.relative(packageDir, f), f] as const))( - "%s has no dashes used as punctuation and no internal identifiers", + "%s has no dashes used as punctuation", (_name, file) => { const text = readFileSync(file, "utf8"); - expect(text).not.toMatch(/[–—]/); + expect(text).not.toMatch(/\u2013|\u2014/); expect(text).not.toMatch(/ -- /); - expect(text).not.toMatch(/\b(?:SDK|ROAD|PSREV|SEC|AIMCP)-\d+\b/); - expect(text).not.toMatch(/\bD\d{1,3}\b/); - expect(text).not.toMatch(/phase\s*[12]/i); - expect(text).not.toMatch(/\bdev-[a-z0-9]{8,}/i); - expect(text).not.toMatch(/obo-poc|\batko\b/i); - expect(text).not.toMatch(/atlassian|jira|confluence/i); }, ); }); From fc22405f30919e93ad9939c0f80d3927e28720e3 Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Fri, 16 Oct 2026 16:15:34 +0530 Subject: [PATCH 27/31] test(ai-mcp): tie the tolerated 405 to the SSE GET in transport tests 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 --- packages/ai-mcp/test/integration/harness.ts | 9 ++++++++- .../ai-mcp/test/integration/transport-bearer.spec.ts | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ai-mcp/test/integration/harness.ts b/packages/ai-mcp/test/integration/harness.ts index 2f42c9c1..e576743d 100644 --- a/packages/ai-mcp/test/integration/harness.ts +++ b/packages/ai-mcp/test/integration/harness.ts @@ -305,8 +305,11 @@ export function buildToolServer(options: ToolServerOptions): McpServer { .requestInfo?.headers; const raw = headers?.authorization; const authorization = Array.isArray(raw) ? raw[0] : raw; + if (authorization === undefined) { + throw new Error("forward_inbound: the request carried no Authorization header to forward"); + } const response = await fetch(options.downstreamUrl, { - headers: { authorization: authorization! }, + headers: { authorization }, }); const outcome: DelegateOutcome = { status: response.status, @@ -385,6 +388,8 @@ export async function startApp(wiring: Wiring, tenant: Tenant, options: HandlerO /** A response as the SDK transport saw it. */ export interface Seen { + /** Upper-case HTTP method of the request that produced this response. */ + method: string; status: number; wwwAuthenticate: string | null; contentType: string | null; @@ -406,7 +411,9 @@ export async function connectClient(app: App, bearer?: string | { authorization: const base = app.fetch ?? socketFetch; const recording = (async (input: string | URL | Request, init?: RequestInit) => { const response = await base(input, init); + const method = (input instanceof Request ? input.method : (init?.method ?? "GET")).toUpperCase(); seen.push({ + method, status: response.status, wwwAuthenticate: response.headers.get("www-authenticate"), contentType: response.headers.get("content-type"), diff --git a/packages/ai-mcp/test/integration/transport-bearer.spec.ts b/packages/ai-mcp/test/integration/transport-bearer.spec.ts index d38b99a1..1307f663 100644 --- a/packages/ai-mcp/test/integration/transport-bearer.spec.ts +++ b/packages/ai-mcp/test/integration/transport-bearer.spec.ts @@ -142,6 +142,10 @@ describe.each(WIRINGS)("bearer authentication through the SDK client (%s wiring) // only and answers 405, which the client treats as "no stream" without retrying. const gets = session.seen.filter((r) => r.status === 405); expect(gets.length).toBeLessThanOrEqual(1); + expect(session.seen.filter((r) => r.method !== "GET").every((r) => r.status === 200 || r.status === 202)).toBe( + true, + ); + expect(gets.every((r) => r.method === "GET")).toBe(true); expect(session.seen.every((r) => r.status === 200 || r.status === 202 || r.status === 405)).toBe(true); } finally { await session.close(); From 17e3d214775b42a7469cdcce5ce39c38668f077e Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Mon, 19 Oct 2026 10:41:17 +0530 Subject: [PATCH 28/31] docs(ai-mcp): state how an absent or empty token response scope is read 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 --- packages/ai-mcp/docs/OnBehalfOf.md | 9 +++++++-- packages/ai-mcp/src/request-scoped-client.ts | 2 ++ packages/ai-mcp/src/types.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ai-mcp/docs/OnBehalfOf.md b/packages/ai-mcp/docs/OnBehalfOf.md index ee3fd35b..b1b562a5 100644 --- a/packages/ai-mcp/docs/OnBehalfOf.md +++ b/packages/ai-mcp/docs/OnBehalfOf.md @@ -43,7 +43,12 @@ It resolves to an `OnBehalfOfTokenResult`: | `grantedScopes` | The scopes that were actually granted, normalized, deduplicated and sorted. Always present. | | `tokenType`, `issuedTokenType` | Token type values returned by the exchange, when present. | -Use `grantedScopes`, not `scope`, to decide what a token covers. An empty `scope` string means Auth0 granted nothing, which a truthiness test on `scope` would misread as a full grant. +Use `grantedScopes`, not `scope`, to decide what a token covers. The token response is read the way RFC 6749 section 5.1 defines it: + +- **`scope` absent.** The server omitted the field, which the RFC allows only when the grant is identical to the request. `grantedScopes` holds the normalized requested scopes. +- **`scope` empty (`""`).** Nothing was granted, and `grantedScopes` is empty. When you requested scopes, this is a narrowed grant and the call fails as described below. A truthiness test on `scope` would misread it as a full grant. + +The same rule applies to client credentials grants. ## Narrowed grants @@ -97,6 +102,6 @@ export async function readConfig(): Promise { } ``` -The result is the core `TokenSet` (`accessToken`, `expiresAt`, `scope`, `tokenType`) plus `grantedScopes`. When the server returned no `scope` field, `grantedScopes` holds the normalized requested scopes. A client credentials grant that is narrower than requested is a server-side configuration failure, because the scopes belong to the server's own client and the caller cannot fix them. It surfaces as a generic 500 through `onError`, with the downscope detail as the error's `cause`, and not as a 403. +The result is the core `TokenSet` (`accessToken`, `expiresAt`, `scope`, `tokenType`) plus `grantedScopes`. When the server returned no `scope` field, `grantedScopes` holds the normalized requested scopes. An empty `scope` string means nothing was granted, and `grantedScopes` is empty. A client credentials grant that is narrower than requested is a server-side configuration failure, because the scopes belong to the server's own client and the caller cannot fix them. It surfaces as a generic 500 through `onError`, with the downscope detail as the error's `cause`, and not as a 403. This package does not cache client credentials tokens. diff --git a/packages/ai-mcp/src/request-scoped-client.ts b/packages/ai-mcp/src/request-scoped-client.ts index 3cf62829..d332515c 100644 --- a/packages/ai-mcp/src/request-scoped-client.ts +++ b/packages/ai-mcp/src/request-scoped-client.ts @@ -241,6 +241,8 @@ export function createRequestScopedClient(init: RequestScopedClientInit): Reques ...(options.scope !== undefined && { scope: options.scope }), }) ); + // RFC 6749 section 5.1: an absent `scope` means the grant equals the request, while an + // empty string means nothing was granted. `??` keeps those two apart; `||` would not. const grantedScopes = normalizeScopes(result.scope ?? options.scope ?? ''); // Thrown outside `guarded`: this error is already sanitized and must keep its own cause. assertServerGrantCovers(options.scope, grantedScopes); diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index a0785c81..66f310ee 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -80,7 +80,8 @@ export interface TokenForServiceOptions { export interface ClientCredentialsTokenResult extends TokenSet { /** * The granted scopes, deduplicated and sorted. When the server returned no - * `scope` field this holds the normalized requested scopes. + * `scope` field this holds the normalized requested scopes (RFC 6749 section + * 5.1). An empty `scope` string means nothing was granted and this is empty. */ grantedScopes: string[]; } From 4a12338354ba95ce43f5527c3e833a57ef180cfc Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 21 Oct 2026 12:55:40 +0530 Subject: [PATCH 29/31] feat(ai-mcp): add a configurable token exchange timeout A slow token endpoint stalled the MCP request and every caller sharing the single-flight exchange, because nothing bounded the outbound HTTP calls. createMcpClient now accepts requestTimeoutMs (default 10000). It is applied through the core client's customFetch, so a request that runs over is aborted instead of left dangling. A timeout writes nothing to the cache, releases the single-flight entry so the next call retries, rejects every waiter, and surfaces as a generic server error rather than an authentication challenge. The value is validated and rejected together with a pre-built apiClient. Co-Authored-By: Claude --- packages/ai-mcp/docs/Errors.md | 3 +- packages/ai-mcp/docs/OnBehalfOf.md | 18 ++ packages/ai-mcp/src/client.ts | 16 +- packages/ai-mcp/src/timeout-fetch.ts | 52 ++++++ packages/ai-mcp/src/types.ts | 12 +- .../ai-mcp/test/docs/docs-snippets.spec.ts | 2 +- .../test/request-scoped-timeout.spec.ts | 169 ++++++++++++++++++ packages/ai-mcp/test/timeout-fetch.spec.ts | 86 +++++++++ 8 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 packages/ai-mcp/src/timeout-fetch.ts create mode 100644 packages/ai-mcp/test/request-scoped-timeout.spec.ts create mode 100644 packages/ai-mcp/test/timeout-fetch.spec.ts diff --git a/packages/ai-mcp/docs/Errors.md b/packages/ai-mcp/docs/Errors.md index 616abbaf..6fe095bc 100644 --- a/packages/ai-mcp/docs/Errors.md +++ b/packages/ai-mcp/docs/Errors.md @@ -62,7 +62,8 @@ A request-scoped client method sanitizes the errors it throws: it replaces the m `InvalidConfigurationError` (from `@auth0/auth0-api-js`) is thrown at construction time when: - `scopeMatching` is `'non_strict'` and `tokenStore` is missing or not an indexed store, -- you pass `apiClient` together with `clientId`, `clientSecret`, `scopeMatching`, `organizationPolicy`, `organizationId` or `dpop`, +- you pass `apiClient` together with `clientId`, `clientSecret`, `scopeMatching`, `organizationPolicy`, `organizationId`, `dpop` or `requestTimeoutMs`, +- `requestTimeoutMs` is not a finite number greater than 0 and at most 2147483647, - `organizationId` is set while `organizationPolicy` is not `'required'`, - `createEncryptedTokenStore` receives a `secret` shorter than 32 characters, - `MemoryIndexedTokenStore` receives a `maxEntries` that is not a positive integer, diff --git a/packages/ai-mcp/docs/OnBehalfOf.md b/packages/ai-mcp/docs/OnBehalfOf.md index b1b562a5..561a64de 100644 --- a/packages/ai-mcp/docs/OnBehalfOf.md +++ b/packages/ai-mcp/docs/OnBehalfOf.md @@ -85,6 +85,24 @@ Concurrent identical exchanges from one `McpClient` share a single request to Au Errors leave the request-scoped client with a fixed generic message. The original error is kept as `cause` for server-side diagnostics and must not be serialized into a response. Core errors keep their class, `code`, `statusCode` and `headers`, so `instanceof` checks against the error classes of `@auth0/auth0-api-js` still work. Catch these in your tool and return an `isError` result, as the example in [Examples](../EXAMPLES.md) does. +## Timeouts + +Each outbound HTTP request the client makes (the token exchange, discovery and JWKS fetches) is aborted after `requestTimeoutMs`, which defaults to 10 seconds: + +```ts +import { createMcpClient } from '@auth0/ai-mcp'; + +const client = createMcpClient({ + domain: 'example.auth0.com', + audience: 'https://mcp.example.com', + clientId: process.env.AUTH0_CLIENT_ID, + clientSecret: process.env.AUTH0_CLIENT_SECRET, + requestTimeoutMs: 5_000, +}); +``` + +The value must be a finite number greater than 0 and at most 2147483647. When a request times out, the underlying call is cancelled, nothing is cached, and every caller that shared that exchange fails with the same error. The next call starts a fresh exchange. The failure is a server error, so `createMcpHandler` answers 500 and never a 401 challenge. The limit applies to the client the package builds. If you pass your own `apiClient`, set a `customFetch` on it instead; combining `apiClient` with `requestTimeoutMs` throws `InvalidConfigurationError`. + ## Client credentials `getClientCredentialsToken` obtains a token for the server itself, using its own client credentials, with no caller involved. Use it when the tool acts as the service, for example to call an internal API that is not user-scoped. diff --git a/packages/ai-mcp/src/client.ts b/packages/ai-mcp/src/client.ts index 9e058d81..8902fb10 100644 --- a/packages/ai-mcp/src/client.ts +++ b/packages/ai-mcp/src/client.ts @@ -10,6 +10,7 @@ import { UnauthenticatedError } from './errors.js'; import { buildPrincipal } from './principal.js'; import { createRequestScopedClient } from './request-scoped-client.js'; import { SingleFlight } from './single-flight.js'; +import { DEFAULT_REQUEST_TIMEOUT_MS, assertValidTimeout, createTimeoutFetch } from './timeout-fetch.js'; import type { McpClientOptions, Principal, RequestScopedMcpClient } from './types.js'; /** @@ -90,6 +91,7 @@ const CORE_CLIENT_OPTIONS = [ 'organizationPolicy', 'organizationId', 'dpop', + 'requestTimeoutMs', ] as const; /** @@ -102,7 +104,7 @@ const CORE_CLIENT_OPTIONS = [ * the core client is rejected, so no setting is silently ignored. * * @throws {InvalidConfigurationError} On an unusable scope matching and store combination, - * on `apiClient` combined with core client options, or on any invalid option the core + * on an invalid `requestTimeoutMs`, on `apiClient` combined with core client options, or on any invalid option the core * client rejects. */ export function createMcpClient(options: McpClientOptions): McpClient { @@ -119,6 +121,17 @@ export function createMcpClient(options: McpClientOptions): McpClient { ); } + let timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; + if (options.apiClient === undefined && options.requestTimeoutMs !== undefined) { + try { + timeoutMs = assertValidTimeout(options.requestTimeoutMs); + } catch (error) { + throw new InvalidConfigurationError( + `Invalid configuration: "requestTimeoutMs" ${(error as RangeError).message}.` + ); + } + } + const apiClient = options.apiClient ?? new ApiClient({ @@ -130,6 +143,7 @@ export function createMcpClient(options: McpClientOptions): McpClient { ...(options.organizationPolicy !== undefined && { organizationPolicy: options.organizationPolicy }), ...(options.organizationId !== undefined && { organizationId: options.organizationId }), ...(options.dpop !== undefined && { dpop: options.dpop }), + customFetch: createTimeoutFetch(timeoutMs), }); const tokenStore = options.tokenStore; diff --git a/packages/ai-mcp/src/timeout-fetch.ts b/packages/ai-mcp/src/timeout-fetch.ts new file mode 100644 index 00000000..f7bde95e --- /dev/null +++ b/packages/ai-mcp/src/timeout-fetch.ts @@ -0,0 +1,52 @@ +/** Default limit, in milliseconds, for each outbound HTTP request of the core client. */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +/** Largest delay `setTimeout` accepts. A larger value would fire immediately. */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Returns `value` when it is a usable timeout, otherwise throws a `RangeError`. + * A usable timeout is a finite number greater than zero and at most 2,147,483,647. + */ +export function assertValidTimeout(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > MAX_TIMEOUT_MS) { + throw new RangeError('must be a finite number greater than 0 and at most 2147483647'); + } + return value; +} + +/** + * Wraps `fetch` so each request is aborted after `timeoutMs`. + * + * The abort cancels the network call itself, so nothing is left running. The timer is not + * cleared when the response headers arrive, which keeps a stalled response body bounded + * too. It is unref'd, so it never holds a process open. A caller supplied `signal` is + * honoured as well. `globalThis.fetch` is read on every call, so test doubles installed + * later still apply. + */ +export function createTimeoutFetch(timeoutMs: number): typeof fetch { + return (input, init) => { + const controller = new AbortController(); + const upstream = init?.signal; + const onUpstreamAbort = () => controller.abort(upstream?.reason); + + if (upstream?.aborted) { + controller.abort(upstream.reason); + } else { + upstream?.addEventListener('abort', onUpstreamAbort, { once: true }); + } + + const timer = setTimeout(() => { + upstream?.removeEventListener('abort', onUpstreamAbort); + controller.abort(new DOMException(`The request timed out after ${timeoutMs} ms.`, 'TimeoutError')); + }, timeoutMs); + timer.unref?.(); + + // On success the caller signal stays linked, so aborting it still cancels the body read. + return globalThis.fetch(input, { ...init, signal: controller.signal }).catch((error: unknown) => { + clearTimeout(timer); + upstream?.removeEventListener('abort', onUpstreamAbort); + throw error; + }); + }; +} diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index 66f310ee..aa43a1c4 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -160,9 +160,19 @@ export interface McpClientOptions { * DPoP-bound access tokens at ingress. */ dpop?: ApiClientOptions['dpop']; + /** + * Upper limit, in milliseconds, for each outbound HTTP request the core client makes: + * the token exchange, discovery and JWKS fetches. A request that exceeds it is aborted + * and fails as a server error, never as an authentication challenge. Nothing is cached, + * and concurrent callers that shared the exchange all fail; the next call starts a new + * one. Must be a finite number greater than 0 and at most 2147483647, otherwise + * `createMcpClient` throws `InvalidConfigurationError`. + * @defaultValue `10000` + */ + requestTimeoutMs?: number; /** * A pre-built core client, used as is. Combining it with `clientId`, `clientSecret`, - * `scopeMatching`, `organizationPolicy`, `organizationId` or `dpop` makes + * `scopeMatching`, `organizationPolicy`, `organizationId`, `dpop` or `requestTimeoutMs` makes * `createMcpClient` throw `InvalidConfigurationError`, so no setting is silently * ignored: configure those on the client you pass in. `tokenStore` is still honoured * because it is supplied per call. The client is not exposed on the returned diff --git a/packages/ai-mcp/test/docs/docs-snippets.spec.ts b/packages/ai-mcp/test/docs/docs-snippets.spec.ts index b2ea014d..3725b84e 100644 --- a/packages/ai-mcp/test/docs/docs-snippets.spec.ts +++ b/packages/ai-mcp/test/docs/docs-snippets.spec.ts @@ -93,7 +93,7 @@ describe("documentation snippets", () => { }, 120_000); it("finds the documented code blocks", () => { - expect(snippets.length).toBe(29); + expect(snippets.length).toBe(30); const fenceCount = documentPaths().reduce( (total, file) => total + (readFileSync(file, "utf8").match(/^```(?:ts|tsx|typescript|js|jsx|javascript)$/gm) ?? []).length, diff --git a/packages/ai-mcp/test/request-scoped-timeout.spec.ts b/packages/ai-mcp/test/request-scoped-timeout.spec.ts new file mode 100644 index 00000000..8e0d74c0 --- /dev/null +++ b/packages/ai-mcp/test/request-scoped-timeout.spec.ts @@ -0,0 +1,169 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { HttpResponse, delay, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { ApiClient, InvalidConfigurationError } from '@auth0/auth0-api-js'; +import type { CachedToken } from '@auth0/auth0-api-js'; +import { createMcpClient } from '../src/client.js'; +import type { McpClient } from '../src/client.js'; +import { UnauthenticatedError } from '../src/errors.js'; +import { createAuth0Handlers, createTestKey, mintAccessToken, scopeMode } from './support/index.js'; +import type { TestKey } from './support/index.js'; + +const AUDIENCE = 'https://mcp.example.com'; +const DOWNSTREAM = 'https://downstream.example.com/resource'; +const TOKEN_URL = 'https://tenant.example.com/oauth/token'; +const TIMEOUT_MS = 150; + +let key: TestKey; +let tenant: ReturnType; +const server = setupServer(); + +beforeAll(async () => { + key = await createTestKey(); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +class MapStore { + readonly entries = new Map(); + sets = 0; + async get(k: string) { + return this.entries.get(k); + } + async set(k: string, v: CachedToken) { + this.sets++; + this.entries.set(k, v); + } + async delete(k: string) { + this.entries.delete(k); + } +} + +function useTenant() { + tenant = createAuth0Handlers({ key, scope: scopeMode.echo }); + server.use(...tenant.handlers); +} + +/** Makes the token endpoint hang until the request is aborted. Counts the calls it receives. */ +function hangTokenEndpoint() { + const state = { calls: 0 }; + server.use( + http.post(TOKEN_URL, async () => { + state.calls++; + await delay('infinite'); + return HttpResponse.json({}); + }) + ); + return state; +} + +function makeClient(extra: Partial[0]> = {}): McpClient { + return createMcpClient({ + domain: 'tenant.example.com', + audience: AUDIENCE, + clientId: 'mcp-client', + clientSecret: 'secret', + ...extra, + }); +} + +async function scoped(client: McpClient) { + const accessToken = await mintAccessToken(key, { + iss: tenant.issuer, + aud: AUDIENCE, + sub: 'user|1', + scope: 'openid', + claims: { sid: 's1', jti: 't1' }, + }); + return client.withToken(await client.verifyAccessToken({ accessToken })); +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error as Error; + } + throw new Error('expected a rejection'); +} + +describe('requestTimeoutMs', () => { + it('fails a stalled exchange as a generic server error, not an authentication failure', async () => { + useTenant(); + const hung = hangTokenEndpoint(); + const client = makeClient({ requestTimeoutMs: TIMEOUT_MS }); + const c = await scoped(client); + + const started = Date.now(); + const error = await rejection(c.getTokenOnBehalfOf({ audience: DOWNSTREAM })); + + expect(Date.now() - started).toBeLessThan(TIMEOUT_MS * 10); + expect(hung.calls).toBe(1); + expect(error.message).toBe('The token operation failed.'); + expect(error).not.toBeInstanceOf(UnauthenticatedError); + expect((error as { statusCode?: number }).statusCode).toBeUndefined(); + }); + + it('rejects every waiter that shared the stalled exchange, then lets the next call retry', async () => { + useTenant(); + const hung = hangTokenEndpoint(); + const store = new MapStore(); + const client = makeClient({ requestTimeoutMs: TIMEOUT_MS, tokenStore: store }); + const c = await scoped(client); + + const results = await Promise.allSettled( + Array.from({ length: 5 }, () => c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' })) + ); + + expect(results.every((r) => r.status === 'rejected')).toBe(true); + expect(hung.calls).toBe(1); + expect(store.sets).toBe(0); + expect(store.entries.size).toBe(0); + + server.resetHandlers(); + useTenant(); + const recovered = await c.getTokenOnBehalfOf({ audience: DOWNSTREAM, scope: 'read:a' }); + + expect(recovered.accessToken).toBeTruthy(); + expect(tenant.state.exchangeCount).toBe(1); + expect(store.entries.size).toBe(1); + }); + + it('does not time out an exchange that answers in time', async () => { + useTenant(); + server.use( + http.post(TOKEN_URL, async () => { + await delay(20); + return undefined; + }) + ); + const client = makeClient({ requestTimeoutMs: 5_000 }); + const c = await scoped(client); + const result = await c.getTokenOnBehalfOf({ audience: DOWNSTREAM }); + expect(result.accessToken).toBeTruthy(); + }); + + it('applies a default limit when none is set', async () => { + useTenant(); + const client = makeClient(); + const c = await scoped(client); + const result = await c.getTokenOnBehalfOf({ audience: DOWNSTREAM }); + expect(result.accessToken).toBeTruthy(); + }); + + it.each([0, -5, Number.NaN, Infinity, 2_147_483_648, '100' as unknown as number])( + 'rejects an invalid value (%s) at construction', + (requestTimeoutMs) => { + expect(() => makeClient({ requestTimeoutMs })).toThrow(InvalidConfigurationError); + expect(() => makeClient({ requestTimeoutMs })).toThrow(/requestTimeoutMs/); + } + ); + + it('rejects the option together with a pre-built apiClient', () => { + const apiClient = new ApiClient({ domain: 'tenant.example.com', audience: AUDIENCE }); + expect(() => createMcpClient({ domain: 'tenant.example.com', audience: AUDIENCE, apiClient, requestTimeoutMs: 100 })).toThrow( + /requestTimeoutMs/ + ); + }); +}); diff --git a/packages/ai-mcp/test/timeout-fetch.spec.ts b/packages/ai-mcp/test/timeout-fetch.spec.ts new file mode 100644 index 00000000..827c1630 --- /dev/null +++ b/packages/ai-mcp/test/timeout-fetch.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { assertValidTimeout, createTimeoutFetch } from '../src/timeout-fetch.js'; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +/** A fetch that never answers and rejects when its signal aborts. */ +function hangingFetch() { + const seen: AbortSignal[] = []; + const impl = vi.fn((_input: unknown, init?: RequestInit) => { + const signal = init?.signal as AbortSignal; + seen.push(signal); + return new Promise((_resolve, reject) => { + if (signal.aborted) return reject(signal.reason); + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + vi.stubGlobal('fetch', impl); + return { impl, seen }; +} + +describe('createTimeoutFetch', () => { + it('aborts the underlying request with a TimeoutError once the limit passes', async () => { + vi.useFakeTimers(); + const { seen } = hangingFetch(); + const pending = createTimeoutFetch(1000)('https://tenant.example.com/oauth/token'); + const outcome = pending.then( + () => undefined, + (error: Error) => error + ); + + await vi.advanceTimersByTimeAsync(999); + expect(seen[0]?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + const error = await outcome; + expect(seen[0]?.aborted).toBe(true); + expect(error?.name).toBe('TimeoutError'); + }); + + it('passes a response through when it arrives in time', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('ok'))); + const response = await createTimeoutFetch(1000)('https://tenant.example.com/x'); + expect(await response.text()).toBe('ok'); + }); + + it('forwards the request init apart from the signal', async () => { + const impl = vi.fn(async () => new Response('ok')); + vi.stubGlobal('fetch', impl); + await createTimeoutFetch(1000)('https://tenant.example.com/x', { method: 'POST', body: 'a=b' }); + const init = (impl.mock.calls[0] as unknown as [unknown, RequestInit])[1]; + expect(init.method).toBe('POST'); + expect(init.body).toBe('a=b'); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('honours a caller supplied signal', async () => { + const { seen } = hangingFetch(); + const caller = new AbortController(); + const pending = createTimeoutFetch(60_000)('https://tenant.example.com/x', { signal: caller.signal }); + caller.abort(new Error('caller gave up')); + await expect(pending).rejects.toThrow('caller gave up'); + expect(seen[0]?.aborted).toBe(true); + }); + + it('aborts at once when the caller signal is already aborted', async () => { + hangingFetch(); + const caller = new AbortController(); + caller.abort(new Error('already')); + await expect(createTimeoutFetch(60_000)('https://tenant.example.com/x', { signal: caller.signal })).rejects.toThrow( + 'already' + ); + }); +}); + +describe('assertValidTimeout', () => { + it.each([1, 10_000, 2_147_483_647, 0.5])('accepts %s', (value) => { + expect(assertValidTimeout(value)).toBe(value); + }); + + it.each([0, -1, Number.NaN, Infinity, 2_147_483_648, '1000', null, undefined, {}])('rejects %s', (value) => { + expect(() => assertValidTimeout(value)).toThrow(RangeError); + }); +}); From 14f5b9a216e2668db36096de4a62c3587a379c8b Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 21 Oct 2026 13:01:19 +0530 Subject: [PATCH 30/31] fix(ai-mcp): clear request timers when the body settles The request timeout now releases its timer and caller-signal listeners when the response body ends, errors or is cancelled, while a stalled body stays bounded. A Request passed as fetch input has its signal merged with init.signal. A hung discovery or JWKS endpoint is covered by a test that pins the ingress status: core reports it as a verification failure, so the request gets 401 invalid_token. The docs and option JSDoc now claim a 500 only for the token exchange. Co-Authored-By: Claude --- packages/ai-mcp/docs/Errors.md | 2 +- packages/ai-mcp/docs/OnBehalfOf.md | 2 +- packages/ai-mcp/src/timeout-fetch.ts | 89 +++++++++--- packages/ai-mcp/src/types.ts | 11 +- .../test/integration/jwks-timeout.spec.ts | 52 +++++++ packages/ai-mcp/test/timeout-fetch.spec.ts | 131 ++++++++++++++++++ 6 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 packages/ai-mcp/test/integration/jwks-timeout.spec.ts diff --git a/packages/ai-mcp/docs/Errors.md b/packages/ai-mcp/docs/Errors.md index 6fe095bc..3aa7b051 100644 --- a/packages/ai-mcp/docs/Errors.md +++ b/packages/ai-mcp/docs/Errors.md @@ -37,7 +37,7 @@ Messages are generic by design: | `InsufficientScopeError` or `DownscopedTokenError` escaping a custom `createMcpHandler` handler | 403 | `insufficient_scope` | | Anything else thrown by your handler, including core and network errors | 500 | none, generic body | -Only this package's three error classes become client-facing failures when they escape your handler. A failed token exchange or a `ForeignCallerError` is an internal error: the client gets a generic 500 and `onError` receives the real error. +Only this package's three error classes become client-facing failures when they escape your handler. A failed token exchange or a `ForeignCallerError` is an internal error: the client gets a generic 500 and `onError` receives the real error. That includes an exchange that exceeds `requestTimeoutMs`. A discovery or JWKS fetch that exceeds it is different: core reports it as a failed token verification, so that request currently gets 401 `invalid_token`. With the official MCP SDK's `McpServer` (`createMcpSdkHandler` and `createNodeMcpHandler`), the SDK turns an error thrown inside a tool callback into a tool result with `isError: true` and a sanitized message, so the error does not reach the handler and the HTTP status stays 200. That includes `DownscopedTokenError` from a downscoped exchange. Catch errors in the tool when you want control over the message. diff --git a/packages/ai-mcp/docs/OnBehalfOf.md b/packages/ai-mcp/docs/OnBehalfOf.md index 561a64de..c41b7e97 100644 --- a/packages/ai-mcp/docs/OnBehalfOf.md +++ b/packages/ai-mcp/docs/OnBehalfOf.md @@ -101,7 +101,7 @@ const client = createMcpClient({ }); ``` -The value must be a finite number greater than 0 and at most 2147483647. When a request times out, the underlying call is cancelled, nothing is cached, and every caller that shared that exchange fails with the same error. The next call starts a fresh exchange. The failure is a server error, so `createMcpHandler` answers 500 and never a 401 challenge. The limit applies to the client the package builds. If you pass your own `apiClient`, set a `customFetch` on it instead; combining `apiClient` with `requestTimeoutMs` throws `InvalidConfigurationError`. +The value must be a finite number greater than 0 and at most 2147483647. The limit covers the response body as well as the headers. When a token exchange times out, the underlying call is cancelled, nothing is cached, and every caller that shared that exchange fails with the same error. The next call starts a fresh exchange. The failure is a server error, so `createMcpHandler` answers 500 and never a 401 challenge. Discovery and JWKS fetches are bounded by the same limit, but their failure reaches you as a token verification failure: a request whose discovery or JWKS fetch times out currently gets 401 `invalid_token`, not 500. The limit applies to the client the package builds. If you pass your own `apiClient`, set a `customFetch` on it instead; combining `apiClient` with `requestTimeoutMs` throws `InvalidConfigurationError`. ## Client credentials diff --git a/packages/ai-mcp/src/timeout-fetch.ts b/packages/ai-mcp/src/timeout-fetch.ts index f7bde95e..abe7bbd2 100644 --- a/packages/ai-mcp/src/timeout-fetch.ts +++ b/packages/ai-mcp/src/timeout-fetch.ts @@ -18,35 +18,88 @@ export function assertValidTimeout(value: unknown): number { /** * Wraps `fetch` so each request is aborted after `timeoutMs`. * - * The abort cancels the network call itself, so nothing is left running. The timer is not - * cleared when the response headers arrive, which keeps a stalled response body bounded - * too. It is unref'd, so it never holds a process open. A caller supplied `signal` is - * honoured as well. `globalThis.fetch` is read on every call, so test doubles installed - * later still apply. + * The abort cancels the network call itself, so nothing is left running. The limit covers + * the whole exchange, response body included: the timer keeps running after the headers + * arrive and is cleared when the body is read to the end, cancelled or fails. A response + * whose body is never consumed keeps its timer until it fires. The timer is unref'd, so it + * never holds a process open. Signals from the caller (`init.signal`, and the signal of a + * `Request` passed as `input`) are honoured as well, and their listeners are removed with + * the timer. `globalThis.fetch` is read on every call, so test doubles installed later + * still apply. */ export function createTimeoutFetch(timeoutMs: number): typeof fetch { return (input, init) => { const controller = new AbortController(); - const upstream = init?.signal; - const onUpstreamAbort = () => controller.abort(upstream?.reason); + const upstreams: AbortSignal[] = []; + if (init?.signal) upstreams.push(init.signal); + if (typeof Request !== 'undefined' && input instanceof Request) upstreams.push(input.signal); - if (upstream?.aborted) { - controller.abort(upstream.reason); - } else { - upstream?.addEventListener('abort', onUpstreamAbort, { once: true }); + const listeners: Array<[AbortSignal, () => void]> = []; + let released = false; + const release = () => { + if (released) return; + released = true; + clearTimeout(timer); + for (const [signal, listener] of listeners) signal.removeEventListener('abort', listener); + }; + + for (const upstream of upstreams) { + if (upstream.aborted) { + controller.abort(upstream.reason); + continue; + } + const listener = () => controller.abort(upstream.reason); + upstream.addEventListener('abort', listener, { once: true }); + listeners.push([upstream, listener]); } const timer = setTimeout(() => { - upstream?.removeEventListener('abort', onUpstreamAbort); + release(); controller.abort(new DOMException(`The request timed out after ${timeoutMs} ms.`, 'TimeoutError')); }, timeoutMs); timer.unref?.(); - // On success the caller signal stays linked, so aborting it still cancels the body read. - return globalThis.fetch(input, { ...init, signal: controller.signal }).catch((error: unknown) => { - clearTimeout(timer); - upstream?.removeEventListener('abort', onUpstreamAbort); - throw error; - }); + return globalThis + .fetch(input, { ...init, signal: controller.signal }) + .then((response) => (response.body ? withReleaseOnSettle(response, release) : (release(), response))) + .catch((error: unknown) => { + release(); + throw error; + }); }; } + +/** + * Returns a copy of `response` whose body calls `release` once it ends, errors or is + * cancelled. Status, headers and url carry over. + */ +function withReleaseOnSettle(response: Response, release: () => void): Response { + const reader = (response.body as ReadableStream).getReader(); + const body = new ReadableStream({ + async pull(stream) { + try { + const { done, value } = await reader.read(); + if (done) { + release(); + stream.close(); + } else { + stream.enqueue(value); + } + } catch (error) { + release(); + stream.error(error); + } + }, + async cancel(reason) { + release(); + await reader.cancel(reason); + }, + }); + const wrapped = new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + Object.defineProperty(wrapped, 'url', { value: response.url }); + return wrapped; +} diff --git a/packages/ai-mcp/src/types.ts b/packages/ai-mcp/src/types.ts index aa43a1c4..b51778dd 100644 --- a/packages/ai-mcp/src/types.ts +++ b/packages/ai-mcp/src/types.ts @@ -162,10 +162,13 @@ export interface McpClientOptions { dpop?: ApiClientOptions['dpop']; /** * Upper limit, in milliseconds, for each outbound HTTP request the core client makes: - * the token exchange, discovery and JWKS fetches. A request that exceeds it is aborted - * and fails as a server error, never as an authentication challenge. Nothing is cached, - * and concurrent callers that shared the exchange all fail; the next call starts a new - * one. Must be a finite number greater than 0 and at most 2147483647, otherwise + * the token exchange, discovery and JWKS fetches. The limit covers the response body + * as well as the headers. A request that exceeds it is aborted. A stalled token exchange + * fails as a server error (the handler answers 500), never as an authentication + * challenge; nothing is cached, concurrent callers that shared the exchange all fail, and + * the next call starts a new one. A stalled discovery or JWKS fetch surfaces from core as + * a token verification failure, so at ingress it currently answers 401 `invalid_token`, + * not 500. Must be a finite number greater than 0 and at most 2147483647, otherwise * `createMcpClient` throws `InvalidConfigurationError`. * @defaultValue `10000` */ diff --git a/packages/ai-mcp/test/integration/jwks-timeout.spec.ts b/packages/ai-mcp/test/integration/jwks-timeout.spec.ts new file mode 100644 index 00000000..34d8f2ce --- /dev/null +++ b/packages/ai-mcp/test/integration/jwks-timeout.spec.ts @@ -0,0 +1,52 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { HttpResponse, delay, http } from "msw"; +import { WIRINGS, buildToolServer, connectClient, createRig, makeMcpClient, withApp } from "./harness.js"; + +const { rig, install } = createRig(); +install({ beforeAll, afterEach, afterAll }); + +const TIMEOUT_MS = 200; + +describe.each(WIRINGS)("a hung discovery or JWKS endpoint at ingress (%s wiring)", (wiring) => { + async function refusedWith(endpoint: "jwks" | "discovery") { + const { tenant, server } = rig(); + rig().useTenant(); + const path = endpoint === "jwks" ? tenant.auth0.jwksUri : `https://${tenant.domain}/.well-known/openid-configuration`; + server.use( + http.get(path, async () => { + await delay("infinite"); + return HttpResponse.json({}); + }), + ); + const token = await rig().token(); + return withApp( + wiring, + tenant, + { + client: makeMcpClient(tenant, { requestTimeoutMs: TIMEOUT_MS }), + createServer: () => buildToolServer({ downstreamUrl: tenant.downstreamUrl }), + }, + async (app) => { + const started = Date.now(); + const error = (await connectClient(app, token).then( + () => { + throw new Error("The connection was expected to be refused."); + }, + (e: unknown) => e, + )) as { code?: number; seen: { wwwAuthenticate?: string }[] }; + return { code: error.code, challenge: error.seen[0]?.wwwAuthenticate, elapsed: Date.now() - started }; + }, + ); + } + + it.each(["jwks", "discovery"] as const)( + "currently answers 401 invalid_token, not 500, when %s never responds", + async (endpoint) => { + const { code, challenge, elapsed } = await refusedWith(endpoint); + + expect(elapsed).toBeLessThan(TIMEOUT_MS * 10); + expect(code).toBe(401); + expect(challenge).toContain('error="invalid_token"'); + }, + ); +}); diff --git a/packages/ai-mcp/test/timeout-fetch.spec.ts b/packages/ai-mcp/test/timeout-fetch.spec.ts index 827c1630..c6e34f82 100644 --- a/packages/ai-mcp/test/timeout-fetch.spec.ts +++ b/packages/ai-mcp/test/timeout-fetch.spec.ts @@ -75,6 +75,137 @@ describe('createTimeoutFetch', () => { }); }); +describe('createTimeoutFetch body handling', () => { + /** A response whose body yields one chunk and then stays open until `finish` is called. */ + function openBody() { + let finish!: () => void; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('abc')); + finish = () => controller.close(); + }, + }); + return { response: new Response(stream, { status: 201, statusText: 'Made', headers: { 'x-a': 'b' } }), finish }; + } + + it('clears the timer once the body has been read to the end', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(async () => new Response('hello'))); + const response = await createTimeoutFetch(1000)('https://tenant.example.com/x'); + expect(vi.getTimerCount()).toBe(1); + expect(await response.text()).toBe('hello'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('keeps status, headers and url on the returned response', async () => { + const { response, finish } = openBody(); + Object.defineProperty(response, 'url', { value: 'https://tenant.example.com/x' }); + vi.stubGlobal('fetch', vi.fn(async () => response)); + const out = await createTimeoutFetch(1000)('https://tenant.example.com/x'); + expect([out.status, out.statusText, out.headers.get('x-a'), out.url]).toEqual([ + 201, + 'Made', + 'b', + 'https://tenant.example.com/x', + ]); + finish(); + expect(await out.text()).toBe('abc'); + }); + + it('clears the timer when the body is cancelled', async () => { + vi.useFakeTimers(); + const { response } = openBody(); + vi.stubGlobal('fetch', vi.fn(async () => response)); + const out = await createTimeoutFetch(1000)('https://tenant.example.com/x'); + expect(vi.getTimerCount()).toBe(1); + await out.body?.cancel(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('clears the timer when the body errors', async () => { + vi.useFakeTimers(); + const failing = new ReadableStream({ + pull(controller) { + controller.error(new Error('socket reset')); + }, + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(failing))); + const out = await createTimeoutFetch(1000)('https://tenant.example.com/x'); + await expect(out.text()).rejects.toThrow('socket reset'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('still bounds a body that stalls after the headers arrive', async () => { + vi.useFakeTimers(); + let signal!: AbortSignal; + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: unknown, init?: RequestInit) => { + signal = init?.signal as AbortSignal; + return new Response( + new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => controller.error(signal.reason), { once: true }); + }, + }) + ); + }) + ); + const out = await createTimeoutFetch(1000)('https://tenant.example.com/x'); + const outcome = out.text().then( + () => undefined, + (error: Error) => error + ); + await vi.advanceTimersByTimeAsync(1000); + expect((await outcome)?.name).toBe('TimeoutError'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('removes the caller signal listener when the body ends', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('x'))); + const caller = new AbortController(); + const added = vi.spyOn(caller.signal, 'addEventListener'); + const removed = vi.spyOn(caller.signal, 'removeEventListener'); + const response = await createTimeoutFetch(1000)('https://tenant.example.com/x', { signal: caller.signal }); + expect(added).toHaveBeenCalledTimes(1); + expect(removed).not.toHaveBeenCalled(); + await response.text(); + expect(removed).toHaveBeenCalledTimes(1); + }); + + it('clears the timer and listener for a response without a body', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))); + const caller = new AbortController(); + const removed = vi.spyOn(caller.signal, 'removeEventListener'); + const out = await createTimeoutFetch(1000)('https://tenant.example.com/x', { signal: caller.signal }); + expect(out.status).toBe(204); + expect(vi.getTimerCount()).toBe(0); + expect(removed).toHaveBeenCalledTimes(1); + }); + + it('honours the signal of a Request passed as input', async () => { + const { seen } = hangingFetch(); + const caller = new AbortController(); + const request = new Request('https://tenant.example.com/x', { signal: caller.signal }); + const pending = createTimeoutFetch(60_000)(request); + caller.abort(new Error('request cancelled')); + await expect(pending).rejects.toThrow('request cancelled'); + expect(seen[0]?.aborted).toBe(true); + }); + + it('honours both the Request signal and init.signal', async () => { + const { seen } = hangingFetch(); + const fromRequest = new AbortController(); + const fromInit = new AbortController(); + const request = new Request('https://tenant.example.com/x', { signal: fromRequest.signal }); + const pending = createTimeoutFetch(60_000)(request, { signal: fromInit.signal }); + fromInit.abort(new Error('init cancelled')); + await expect(pending).rejects.toThrow('init cancelled'); + expect(seen[0]?.aborted).toBe(true); + }); +}); + describe('assertValidTimeout', () => { it.each([1, 10_000, 2_147_483_647, 0.5])('accepts %s', (value) => { expect(assertValidTimeout(value)).toBe(value); From 407501d5cdf6f546fa599ce3bed2d375b6f77d9b Mon Sep 17 00:00:00 2001 From: Tushar Pandey Date: Wed, 21 Oct 2026 16:22:02 +0530 Subject: [PATCH 31/31] docs(ai-mcp): turn the example into an online store with tenant setup steps Re-skin examples/ai-mcp-server as a small online store: order_status_report, fulfill_order and sales_analytics_report, each gated by its own tool scope. The store API now plays Orders, Inventory and Analytics, verifying the audience and scope of every exchanged or client credentials token. The README gains a tenant setup section with Auth0 CLI commands and Claude Code steps. Co-Authored-By: Claude --- examples/ai-mcp-server/.env.example | 19 +- examples/ai-mcp-server/README.md | 75 +++-- examples/ai-mcp-server/package.json | 5 +- examples/ai-mcp-server/src/config.ts | 31 +- examples/ai-mcp-server/src/downstream-api.ts | 201 ++++++++++--- examples/ai-mcp-server/src/mcp-app.ts | 270 +++++++++++++++--- examples/ai-mcp-server/src/scopes.ts | 16 ++ examples/ai-mcp-server/src/store-data.ts | 54 ++++ examples/ai-mcp-server/test/smoke.spec.ts | 281 +++++++++++++------ 9 files changed, 751 insertions(+), 201 deletions(-) create mode 100644 examples/ai-mcp-server/src/scopes.ts create mode 100644 examples/ai-mcp-server/src/store-data.ts diff --git a/examples/ai-mcp-server/.env.example b/examples/ai-mcp-server/.env.example index 2a04aa43..459903e9 100644 --- a/examples/ai-mcp-server/.env.example +++ b/examples/ai-mcp-server/.env.example @@ -5,24 +5,23 @@ AUTH0_DOMAIN=your-tenant.us.auth0.com # it: access tokens sent to the server must be issued for it. MCP_SERVER_URL=http://localhost:3001/mcp -# Application that performs the on-behalf-of token exchange. +# Application that performs the token exchanges and the client credentials grant. AUTH0_CLIENT_ID=your-client-id AUTH0_CLIENT_SECRET=your-client-secret # Scopes every request to the MCP server must carry (space separated). Optional. MCP_REQUIRED_SCOPES= -# Scope a caller needs to use the "admin_summary" tool. -MCP_ADMIN_SCOPE=read:admin +# Identifiers (audiences) of the three store APIs. +ORDERS_AUDIENCE=https://orders.api/ +INVENTORY_AUDIENCE=https://inventory.api/ +ANALYTICS_AUDIENCE=https://analytics.api/ -# Downstream API: identifier (audience), base URL, scope requested for it, listen port. -DOWNSTREAM_AUDIENCE=https://reports.example.com -DOWNSTREAM_URL=http://localhost:3002 -DOWNSTREAM_SCOPE=read:reports -DOWNSTREAM_REQUIRED_SCOPE=read:reports -# Longest wait for the downstream API, in milliseconds. Optional, defaults to 5000. +# Base URL of the store API process the tools call. +STORE_API_URL=http://localhost:3003 +# Longest wait for the store API, in milliseconds. Optional, defaults to 5000. DOWNSTREAM_TIMEOUT_MS=5000 # Listen ports. MCP_PORT=3001 -DOWNSTREAM_PORT=3002 +DOWNSTREAM_PORT=3003 diff --git a/examples/ai-mcp-server/README.md b/examples/ai-mcp-server/README.md index 16178c22..6eb4ac6c 100644 --- a/examples/ai-mcp-server/README.md +++ b/examples/ai-mcp-server/README.md @@ -1,32 +1,75 @@ -# MCP server with Auth0 token delegation +# Online store MCP server with Auth0 token delegation -A small MCP server built with `@auth0/ai-mcp` and the MCP TypeScript SDK. It checks the access token on every request, then calls a downstream API as the signed-in user through an on-behalf-of token exchange. A second small server plays the downstream API. +A small MCP server built with `@auth0/ai-mcp` and the MCP TypeScript SDK. It plays an online store assistant. It checks the access token on every request, then calls the store's Orders, Inventory and Analytics APIs, either as the signed-in shopper (on-behalf-of token exchange) or as itself (client credentials). A second small process plays the store APIs and verifies every token it receives. ## What it shows - `src/mcp-app.ts` wires `createNodeMcpHandler` to an `McpServer` over Streamable HTTP. Requests without a valid token are answered with a `WWW-Authenticate` challenge before any tool runs. The server also publishes its protected resource metadata (RFC 9728). -- `list_reports` exchanges the caller's token for a token to the downstream API with the request-scoped client, then calls the API with it. It sends only the exchanged token, whose audience is `DOWNSTREAM_AUDIENCE`, and never the caller's own token. Failures come back as a fixed error message, and the call stops after `DOWNSTREAM_TIMEOUT_MS`. -- `admin_summary` is wrapped in `withRequiredScopes`. A caller without the admin scope gets an error result for that tool only (HTTP 200); the rest of the server stays available. -- `src/downstream-api.ts` verifies every token it receives (signature from the tenant JWKS, issuer, audience, expiry and scope) before it returns data. +- Every tool is wrapped in `withRequiredScopes`. A caller without the tool's scope gets an error result for that tool only (HTTP 200); the other tools stay available. +- `order_status_report` (scope `tool:order_status_report`, optional `order_id`) exchanges the caller's token twice, once for Orders (`read:orders`) and once for Inventory (`read:inventory`), and lists the orders with stock levels. +- `fulfill_order` (scope `tool:fulfill_order`, required `order_id`) asks Orders for the same `read:orders` token, so a call right after a status report reuses the saved token, and asks Inventory for `read:inventory write:inventory` to reserve stock. Fulfilling the same order twice changes stock only once. +- `sales_analytics_report` (scope `tool:sales_analytics_report`) uses `getClientCredentialsToken`: the server acts as itself, with no shopper involved. +- Only the exchanged token is sent downstream, never the caller's own token. Failures come back as fixed messages, and each call stops after `DOWNSTREAM_TIMEOUT_MS`. +- `src/downstream-api.ts` verifies every token (RS256 signature from the tenant JWKS, issuer, the audience of the service being called, expiry, subject and scope) before it returns data. Each answer includes a `verified` block naming the audience, scope and acting client it checked, never the token. + +Seed data lives in memory and resets on restart: orders ORD-1001 to ORD-1003, and stock for SKU-HEAD-01 (24), SKU-KEYB-02 (12) and SKU-DOCK-03 (7). + +## Set up your tenant + +On-behalf-of token exchange must be enabled on your tenant. Enabling it may require Auth0 support; ask them if the exchange is rejected with an `on_behalf_of_token_exchange` error. The commands use the [Auth0 CLI](https://github.com/auth0/auth0-cli). Replace each `YOUR_...` with the id from the previous command's output. + +```bash +auth0 login --scopes create:client_grants,update:connections +# 1. APIs: the MCP server (identifier = MCP_SERVER_URL) and the three store services +auth0 apis create --name "Store MCP" --identifier "http://localhost:3001/mcp" --signing-alg RS256 \ + --scopes "tool:order_status_report,tool:fulfill_order,tool:sales_analytics_report" +auth0 apis create --name "Orders Service" --identifier "https://orders.api/" --signing-alg RS256 --scopes "read:orders" +auth0 apis create --name "Inventory Service" --identifier "https://inventory.api/" --signing-alg RS256 --scopes "read:inventory,write:inventory" +auth0 apis create --name "Analytics Service" --identifier "https://analytics.api/" --signing-alg RS256 --scopes "read:analytics" +# 2. Exchange client: a Custom API client tied to the MCP API (a regular app cannot exchange tokens) +auth0 api post clients --data '{"name":"Store exchange client","app_type":"resource_server","resource_server_identifier":"http://localhost:3001/mcp"}' +auth0 api patch "clients/YOUR_EXCHANGE_CLIENT_ID" --data '{"token_exchange":{"allow_any_profile_of_type":["on_behalf_of_token_exchange"]}}' +# A PATCH replaces grant_types, so read the list first and send it back with client_credentials added +auth0 api get "clients/YOUR_EXCHANGE_CLIENT_ID?fields=grant_types" +auth0 api patch "clients/YOUR_EXCHANGE_CLIENT_ID" --data '{"grant_types":["EXISTING_GRANT_TYPES","client_credentials"]}' +# 3. Grants for the exchange client (subject_type user = on behalf of a shopper; none = its own token) +auth0 api post client-grants --data '{"client_id":"YOUR_EXCHANGE_CLIENT_ID","audience":"https://orders.api/","scope":["read:orders"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_EXCHANGE_CLIENT_ID","audience":"https://inventory.api/","scope":["read:inventory","write:inventory"],"subject_type":"user"}' +auth0 api post client-grants --data '{"client_id":"YOUR_EXCHANGE_CLIENT_ID","audience":"https://analytics.api/","scope":["read:analytics"]}' +# 4. Skip the consent screen for the exchanged tokens +auth0 api patch "resource-servers/YOUR_ORDERS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +auth0 api patch "resource-servers/YOUR_INVENTORY_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}' +# 5. Test shopper in a database connection that third-party clients (Claude Code) may use +auth0 users create --connection-name Username-Password-Authentication --email shopper@example.com --name "Test Shopper" --password "CHOOSE_A_STRONG_PASSWORD" +auth0 api patch "connections/YOUR_CONNECTION_ID" --data '{"is_domain_connection":true}' +# 6. Claude Code client: turn on Settings > Advanced > Client ID Metadata Document Registration, then register it +auth0 api post "clients/cimd/register" --data '{"external_client_id":"https://claude.ai/oauth/claude-code-client-metadata"}' +auth0 api post client-grants --data '{"client_id":"YOUR_TPC_CLIENT_ID","audience":"http://localhost:3001/mcp","scope":["tool:order_status_report","tool:fulfill_order","tool:sales_analytics_report"],"subject_type":"user"}' +``` -## Auth0 setup +Making the connection domain-level (step 5) lets every client on the tenant use it, so run it on a development tenant. The exchange client's id and secret go in `AUTH0_CLIENT_ID` and `AUTH0_CLIENT_SECRET`. -Create these in your tenant: +## Run -1. An API for the MCP server. Its identifier is the public URL of the server (`MCP_SERVER_URL`). Add the scope you use for `MCP_ADMIN_SCOPE`. -2. An API for the downstream service, with the identifier you set as `DOWNSTREAM_AUDIENCE` and a scope matching `DOWNSTREAM_SCOPE`. -3. An application allowed to perform on-behalf-of token exchange for the downstream API. Its client id and secret go in `AUTH0_CLIENT_ID` and `AUTH0_CLIENT_SECRET`. Authorize it for the downstream API and its scope. Enabling on-behalf-of exchange on a tenant may require Auth0 support; see the Auth0 documentation for token exchange. -4. A client application that signs users in and requests an access token for the MCP server API. +```sh +cp .env.example .env # fill in the domain, client id and secret +npm run start:api # store APIs on port 3003 +npm run start # MCP server on port 3001, in a second terminal +``` -## Run +Connect Claude Code. The URL must end in `/mcp`. ```sh -cp .env.example .env # fill in the values -npm run start:api # downstream API -npm run start # MCP server, in a second terminal +claude mcp add --transport http store http://localhost:3001/mcp ``` -Point an MCP client at `MCP_SERVER_URL` and send the user's access token as a bearer token. +Start `claude`, type `/mcp`, pick `store` and choose Authenticate. Sign in as the test shopper in the browser and allow the three permissions if asked. Then try: + +- `show my recent orders`: two token exchanges, for Orders and Inventory, and the three orders with stock. +- `fulfill order ORD-1002`: stock for the keyboard goes from 12 to 11. Only the Inventory write token is new. +- `run the sales analytics report`: the server fetches revenue with its own token. + +With `MCP_REQUIRED_SCOPES` unset, any signed-in caller reaches the server and each tool checks its own scope. Remove the server again with `claude mcp remove store`. The `.env` file is read with Node's `--env-file` support, so no extra package is needed. diff --git a/examples/ai-mcp-server/package.json b/examples/ai-mcp-server/package.json index 7c29425b..437a7b56 100644 --- a/examples/ai-mcp-server/package.json +++ b/examples/ai-mcp-server/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.1.0", "type": "module", - "description": "MCP server that authenticates callers with Auth0 and calls a downstream API on their behalf", + "description": "Online store MCP server that authenticates callers with Auth0 and calls store APIs on their behalf", "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json", "start": "tsx --env-file=.env src/server.ts", @@ -20,7 +20,8 @@ "dependencies": { "@auth0/ai-mcp": "*", "@modelcontextprotocol/sdk": "^1.31.0", - "jose": "^6.0.0" + "jose": "^6.0.0", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.18.0", diff --git a/examples/ai-mcp-server/src/config.ts b/examples/ai-mcp-server/src/config.ts index 045c9960..adb968d5 100644 --- a/examples/ai-mcp-server/src/config.ts +++ b/examples/ai-mcp-server/src/config.ts @@ -43,19 +43,21 @@ export interface McpServerConfig { clientId: string; clientSecret: string; requiredScopes: string[]; - adminScope: string; - downstreamAudience: string; - downstreamUrl: string; - downstreamScope: string; - /** Longest wait for the downstream API, in milliseconds. */ + ordersAudience: string; + inventoryAudience: string; + analyticsAudience: string; + /** Base URL of the store API. */ + storeApiUrl: string; + /** Longest wait for the store API, in milliseconds. */ downstreamTimeoutMs: number; port: number; } export interface DownstreamConfig { domain: string; - audience: string; - requiredScope: string; + ordersAudience: string; + inventoryAudience: string; + analyticsAudience: string; port: number; } @@ -68,10 +70,10 @@ export function loadMcpServerConfig( clientId: required(env, "AUTH0_CLIENT_ID"), clientSecret: required(env, "AUTH0_CLIENT_SECRET"), requiredScopes: list(env["MCP_REQUIRED_SCOPES"]), - adminScope: required(env, "MCP_ADMIN_SCOPE"), - downstreamAudience: required(env, "DOWNSTREAM_AUDIENCE"), - downstreamUrl: required(env, "DOWNSTREAM_URL"), - downstreamScope: required(env, "DOWNSTREAM_SCOPE"), + ordersAudience: required(env, "ORDERS_AUDIENCE"), + inventoryAudience: required(env, "INVENTORY_AUDIENCE"), + analyticsAudience: required(env, "ANALYTICS_AUDIENCE"), + storeApiUrl: required(env, "STORE_API_URL"), downstreamTimeoutMs: positiveInt(env, "DOWNSTREAM_TIMEOUT_MS", 5000), port: port(env, "MCP_PORT", 3001), }; @@ -82,8 +84,9 @@ export function loadDownstreamConfig( ): DownstreamConfig { return { domain: required(env, "AUTH0_DOMAIN"), - audience: required(env, "DOWNSTREAM_AUDIENCE"), - requiredScope: required(env, "DOWNSTREAM_REQUIRED_SCOPE"), - port: port(env, "DOWNSTREAM_PORT", 3002), + ordersAudience: required(env, "ORDERS_AUDIENCE"), + inventoryAudience: required(env, "INVENTORY_AUDIENCE"), + analyticsAudience: required(env, "ANALYTICS_AUDIENCE"), + port: port(env, "DOWNSTREAM_PORT", 3003), }; } diff --git a/examples/ai-mcp-server/src/downstream-api.ts b/examples/ai-mcp-server/src/downstream-api.ts index 79af23e8..5ebf4c09 100644 --- a/examples/ai-mcp-server/src/downstream-api.ts +++ b/examples/ai-mcp-server/src/downstream-api.ts @@ -1,19 +1,31 @@ import { createServer } from "node:http"; import type { IncomingMessage, Server, ServerResponse } from "node:http"; import { createRemoteJWKSet, jwtVerify } from "jose"; -import type { JWTVerifyGetKey } from "jose"; +import type { JWTPayload, JWTVerifyGetKey } from "jose"; import type { DownstreamConfig } from "./config.js"; +import { + READ_ANALYTICS, + READ_INVENTORY, + READ_ORDERS, + WRITE_INVENTORY, +} from "./scopes.js"; +import { createStoreData } from "./store-data.js"; +import type { StoreData } from "./store-data.js"; /** - * A small resource server. It verifies every token before it answers: signature - * (from the tenant's JWKS), issuer, audience, expiry and scope. A request that - * fails any check never reaches the data. + * One process that plays three small services: Orders, Inventory and Analytics. + * Each has its own audience and scope. Every request is verified before it + * reaches any data: signature (from the tenant's JWKS), issuer, the audience of + * the service being called, expiry, subject and scope. */ -const REPORTS = [ - { id: "r-1", title: "Quarterly summary" }, - { id: "r-2", title: "Usage by region" }, -]; +interface Verified { + audience: string; + scope: string; + actor: string | null; + subject: string; + payload: JWTPayload; +} function send( res: ServerResponse, @@ -36,62 +48,187 @@ function bearerToken(req: IncomingMessage): string | undefined { return match?.[1]; } +function actorOf(payload: JWTPayload): string | null { + const act = payload["act"]; + if (act && typeof act === "object") { + const sub = (act as { sub?: unknown }).sub; + if (typeof sub === "string") return sub; + } + return null; +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > 16_384) throw new Error("body too large"); + chunks.push(chunk as Buffer); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + export function createDownstreamListener( config: DownstreamConfig, keys?: JWTVerifyGetKey, + data: StoreData = createStoreData(), ): (req: IncomingMessage, res: ServerResponse) => Promise { const issuer = `https://${config.domain}/`; const getKey = keys ?? createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`)); - return async (req, res) => { + /** Verifies the request's token for one service. Answers the error itself on failure. */ + async function verify( + req: IncomingMessage, + res: ServerResponse, + audience: string, + requiredScope: string, + kind: "user" | "service", + ): Promise { const token = bearerToken(req); - if (token === undefined) { + const invalid = () => send( res, 401, { error: "invalid_token" }, - { "www-authenticate": "Bearer" }, + { + "www-authenticate": + token === undefined ? "Bearer" : 'Bearer error="invalid_token"', + }, ); - return; + if (token === undefined) { + invalid(); + return undefined; } - let scopes: string[]; + let payload: JWTPayload; try { - const { payload } = await jwtVerify(token, getKey, { + ({ payload } = await jwtVerify(token, getKey, { issuer, - audience: config.audience, + audience, algorithms: ["RS256"], requiredClaims: ["exp", "sub"], - }); - scopes = - typeof payload["scope"] === "string" - ? payload["scope"].split(/\s+/) - : []; + })); } catch { - send( - res, - 401, - { error: "invalid_token" }, - { "www-authenticate": 'Bearer error="invalid_token"' }, - ); - return; + invalid(); + return undefined; } - if (!scopes.includes(config.requiredScope)) { + const subject = payload.sub ?? ""; + const actor = actorOf(payload); + // A user token has a person as subject; a service token belongs to a client. + const isServiceToken = subject.endsWith("@clients") && actor === null; + if ((kind === "service") !== isServiceToken) { + invalid(); + return undefined; + } + const scopes = + typeof payload["scope"] === "string" ? payload["scope"].split(/\s+/) : []; + if (!scopes.includes(requiredScope)) { send( res, 403, { error: "insufficient_scope" }, { - "www-authenticate": `Bearer error="insufficient_scope", scope="${config.requiredScope}"`, + "www-authenticate": `Bearer error="insufficient_scope", scope="${requiredScope}"`, }, ); + return undefined; + } + return { audience, scope: requiredScope, actor, subject, payload }; + } + + const proof = (v: Verified) => ({ + audience: v.audience, + scope: v.scope, + actor: v.actor, + subject: v.subject, + }); + + return async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const parts = url.pathname.split("/").filter(Boolean); + const method = req.method ?? "GET"; + const [service, id, action] = parts; + + if (service === "orders" && method === "GET" && parts.length <= 2) { + const v = await verify(req, res, config.ordersAudience, READ_ORDERS, "user"); + if (!v) return; + if (id === undefined) { + send(res, 200, { + orders: data.orders().map((o) => ({ ...o, customer: v.subject })), + verified: proof(v), + }); + return; + } + const order = data.order(id); + if (!order) return send(res, 404, { error: "not_found" }); + send(res, 200, { + order: { ...order, customer: v.subject }, + verified: proof(v), + }); return; } - if (new URL(req.url ?? "/", "http://localhost").pathname !== "/reports") { - send(res, 404, { error: "not_found" }); + + if (service === "inventory" && id !== undefined) { + if (method === "GET" && parts.length === 2) { + const v = await verify(req, res, config.inventoryAudience, READ_INVENTORY, "user"); + if (!v) return; + const stock = data.stock(id); + if (stock === undefined) return send(res, 404, { error: "not_found" }); + send(res, 200, { sku: id, stock, verified: proof(v) }); + return; + } + if (method === "POST" && action === "reserve" && parts.length === 3) { + const v = await verify(req, res, config.inventoryAudience, WRITE_INVENTORY, "user"); + if (!v) return; + let body: unknown; + try { + body = await readJson(req); + } catch { + return send(res, 400, { error: "invalid_request" }); + } + const { qty, order_id: orderId } = (body ?? {}) as { + qty?: unknown; + order_id?: unknown; + }; + if ( + typeof qty !== "number" || + !Number.isInteger(qty) || + qty < 1 || + typeof orderId !== "string" + ) { + return send(res, 400, { error: "invalid_request" }); + } + if (!data.order(orderId)) return send(res, 404, { error: "not_found" }); + const result = data.reserve(id, qty, orderId); + if (!result) return send(res, 404, { error: "not_found" }); + send(res, 200, { + sku: id, + already_reserved: result.alreadyReserved, + stock_before: result.before, + stock_after: result.after, + verified: proof(v), + }); + return; + } + } + + if ( + service === "analytics" && + id === "monthly-revenue" && + parts.length === 2 && + method === "GET" + ) { + const v = await verify(req, res, config.analyticsAudience, READ_ANALYTICS, "service"); + if (!v) return; + send(res, 200, { + revenue_usd: 142000, + top_product: "Enterprise Suite", + verified: proof(v), + }); return; } - send(res, 200, { reports: REPORTS }); + + send(res, 404, { error: "not_found" }); }; } diff --git a/examples/ai-mcp-server/src/mcp-app.ts b/examples/ai-mcp-server/src/mcp-app.ts index 393c25a6..739c4edd 100644 --- a/examples/ai-mcp-server/src/mcp-app.ts +++ b/examples/ai-mcp-server/src/mcp-app.ts @@ -9,7 +9,17 @@ import { } from "@auth0/ai-mcp"; import { createNodeMcpHandler } from "@auth0/ai-mcp/mcp-sdk"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; import type { McpServerConfig } from "./config.js"; +import { + READ_ANALYTICS, + READ_INVENTORY, + READ_ORDERS, + SCOPE_ANALYTICS, + SCOPE_FULFILL, + SCOPE_ORDER_STATUS, + WRITE_INVENTORY, +} from "./scopes.js"; function failure(message: string) { return { ...text(message), isError: true as const }; @@ -19,61 +29,241 @@ function text(value: string) { return { content: [{ type: "text" as const, text: value }] }; } +/** An expected, user-facing failure. Its message is safe to return as is. */ +class ToolFailure extends Error {} + +interface Proof { + audience: string; + scope: string; + actor: string | null; +} + +interface ApiCall { + status: number; + body: Record; +} + +/** Calls the store API with an exchanged token. Only the exchanged token is sent. */ +async function callApi( + config: McpServerConfig, + token: string, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + const response = await fetch(new URL(path, config.storeApiUrl), { + method: init.method ?? "GET", + headers: { + authorization: `Bearer ${token}`, + ...(init.body !== undefined && { "content-type": "application/json" }), + }, + ...(init.body !== undefined && { body: JSON.stringify(init.body) }), + signal: AbortSignal.timeout(config.downstreamTimeoutMs), + }); + const body = (await response.json().catch(() => ({}))) as Record; + return { status: response.status, body }; +} + +function proofOf(call: ApiCall): Proof { + return call.body["verified"] as Proof; +} + +function checkedBy(parts: Array<[string, Proof]>): string { + const rendered = parts.map( + ([name, p]) => + `${name} (aud ${p.audience}, scope ${p.scope}, acting client ${p.actor ?? "n/a"})`, + ); + return `Checked by: ${rendered.join(", ")}`; +} + +interface OrderBody { + id: string; + item: string; + sku: string; + qty: number; + status: string; +} + +/** Turns thrown errors into fixed messages. Logs the error name only, never its message. */ +async function guarded( + tool: string, + run: () => Promise>, +) { + try { + return await run(); + } catch (error) { + if (error instanceof ToolFailure) return failure(error.message); + console.error( + `${tool} failed`, + error instanceof Error ? error.name : "unknown", + ); + if ( + error instanceof DownscopedTokenError || + error instanceof InsufficientScopeError + ) { + return failure("The store API did not grant the required access."); + } + return failure("Could not reach the store API."); + } +} + +function expectOk(call: ApiCall): void { + if (call.status !== 200) { + throw new ToolFailure(`The store API answered ${call.status}.`); + } +} + /** Builds the MCP server for one request. Tools read the caller from the request context. */ function buildServer(config: McpServerConfig): McpServer { - const server = new McpServer({ name: "reports-example", version: "0.1.0" }); + const server = new McpServer({ + name: "store-example", + title: "Store MCP server", + version: "0.1.0", + }); - // Tool 1: calls the downstream API with a token obtained by on-behalf-of - // exchange. The request-scoped client always acts for the caller of this request. + // Tool 1: two on-behalf-of exchanges, one per store API. The request-scoped + // client always acts for the caller of this request. server.registerTool( - "list_reports", + "order_status_report", { description: - "Lists reports from the downstream API, using the caller's identity.", + "Shows the signed-in shopper's recent orders with current stock levels.", + inputSchema: { order_id: z.string().min(1).optional() }, + annotations: { readOnlyHint: true }, }, - async () => { - try { - const exchanged = await getCallerMcpClientOrThrow().getTokenOnBehalfOf({ - audience: config.downstreamAudience, - scope: config.downstreamScope, + withRequiredScopes(SCOPE_ORDER_STATUS, async ({ order_id: orderId }) => + guarded("order_status_report", async () => { + const client = getCallerMcpClientOrThrow(); + const orders = await client.getTokenOnBehalfOf({ + audience: config.ordersAudience, + scope: READ_ORDERS, + }); + const inventory = await client.getTokenOnBehalfOf({ + audience: config.inventoryAudience, + scope: READ_INVENTORY, }); - const response = await fetch( - new URL("/reports", config.downstreamUrl), - { - headers: { authorization: `Bearer ${exchanged.accessToken}` }, - signal: AbortSignal.timeout(config.downstreamTimeoutMs), - }, + const list = await callApi( + config, + orders.accessToken, + orderId === undefined ? "/orders" : `/orders/${encodeURIComponent(orderId)}`, ); - if (!response.ok) { - return failure(`The downstream API answered ${response.status}.`); + if (orderId !== undefined && list.status === 404) { + throw new ToolFailure(`Order ${orderId} was not found.`); } - return text(JSON.stringify(await response.json())); - } catch (error) { - // Log the error name only. Messages can carry details that must not reach logs or callers. - console.error( - "list_reports failed", - error instanceof Error ? error.name : "unknown", - ); - if ( - error instanceof DownscopedTokenError || - error instanceof InsufficientScopeError - ) { - return failure( - "The downstream API did not grant the required access.", + expectOk(list); + const found = + orderId === undefined + ? (list.body["orders"] as OrderBody[]) + : [list.body["order"] as OrderBody]; + const lines: string[] = []; + let inventoryProof: Proof | undefined; + for (const order of found) { + const stock = await callApi( + config, + inventory.accessToken, + `/inventory/${encodeURIComponent(order.sku)}`, + ); + expectOk(stock); + inventoryProof = proofOf(stock); + lines.push( + `- ${order.id} ${order.item} x${order.qty}, ${order.status}, stock left ${String(stock.body["stock"])}`, ); } - return failure("Could not reach the downstream API."); - } + const subject = (list.body["verified"] as { subject: string }).subject; + const header = + orderId === undefined + ? `Recent orders for ${subject}:` + : `Status of ${orderId} for ${subject}:`; + const proofs: Array<[string, Proof]> = [["Orders", proofOf(list)]]; + if (inventoryProof) proofs.push(["Inventory", inventoryProof]); + return text([header, ...lines, checkedBy(proofs)].join("\n")); + }), + ), + ); + + // Tool 2: changes stock. Asks Orders for the same token as tool 1, so a repeat + // call right after a status report reuses the cached Orders token. + server.registerTool( + "fulfill_order", + { + description: "Fulfills an order by reserving its items in inventory.", + inputSchema: { order_id: z.string().min(1) }, + annotations: { destructiveHint: true }, }, + withRequiredScopes(SCOPE_FULFILL, async ({ order_id: orderId }) => + guarded("fulfill_order", async () => { + const client = getCallerMcpClientOrThrow(); + const orders = await client.getTokenOnBehalfOf({ + audience: config.ordersAudience, + scope: READ_ORDERS, + }); + const inventory = await client.getTokenOnBehalfOf({ + audience: config.inventoryAudience, + scope: `${READ_INVENTORY} ${WRITE_INVENTORY}`, + }); + const lookup = await callApi( + config, + orders.accessToken, + `/orders/${encodeURIComponent(orderId)}`, + ); + if (lookup.status === 404) { + throw new ToolFailure(`Order ${orderId} was not found.`); + } + expectOk(lookup); + const order = lookup.body["order"] as OrderBody; + const reserve = await callApi( + config, + inventory.accessToken, + `/inventory/${encodeURIComponent(order.sku)}/reserve`, + { method: "POST", body: { qty: order.qty, order_id: order.id } }, + ); + expectOk(reserve); + const before = reserve.body["stock_before"]; + const after = reserve.body["stock_after"]; + const headline = + reserve.body["already_reserved"] === true + ? `Order ${order.id} was already fulfilled (stock stays ${String(after)})` + : `Order ${order.id} fulfilled: reserved ${order.qty} x ${order.item} (stock ${String(before)} -> ${String(after)})`; + return text( + [ + headline, + checkedBy([ + ["Orders", proofOf(lookup)], + ["Inventory", proofOf(reserve)], + ]), + ].join("\n"), + ); + }), + ), ); - // Tool 2: only for callers whose token carries the admin scope. A caller - // without it gets an error result for this tool; the rest of the server works. + // Tool 3: the server acts as itself, with no shopper involved. server.registerTool( - "admin_summary", - { description: "Summary for administrators. Needs the admin scope." }, - withRequiredScopes(config.adminScope, async () => - text("Administrator summary: all systems normal."), + "sales_analytics_report", + { + description: "Shows last month's revenue, fetched with the server's own credentials.", + annotations: { readOnlyHint: true }, + }, + withRequiredScopes(SCOPE_ANALYTICS, async () => + guarded("sales_analytics_report", async () => { + const token = await getCallerMcpClientOrThrow().getClientCredentialsToken({ + audience: config.analyticsAudience, + scope: READ_ANALYTICS, + }); + const call = await callApi( + config, + token.accessToken, + "/analytics/monthly-revenue", + ); + expectOk(call); + const revenue = Number(call.body["revenue_usd"]); + const p = proofOf(call); + return text( + [ + `Monthly revenue: $${revenue.toLocaleString("en-US")} | Top product: ${String(call.body["top_product"])}`, + `Checked by: Analytics (aud ${p.audience}, scope ${p.scope}, service token for the server itself)`, + ].join("\n"), + ); + }), ), ); @@ -98,7 +288,7 @@ export function createMcpApp( resource: config.serverUrl, authorizationServers: [`https://${config.domain}/`], requiredScopes: config.requiredScopes, - metadata: { resourceName: "Reports example" }, + metadata: { resourceName: "Store example" }, onError: (error) => console.error( "request failed", diff --git a/examples/ai-mcp-server/src/scopes.ts b/examples/ai-mcp-server/src/scopes.ts new file mode 100644 index 00000000..ef47c333 --- /dev/null +++ b/examples/ai-mcp-server/src/scopes.ts @@ -0,0 +1,16 @@ +/** Scopes a caller needs to run each tool. These are part of the server's contract. */ +export const SCOPE_ORDER_STATUS = "tool:order_status_report"; +export const SCOPE_FULFILL = "tool:fulfill_order"; +export const SCOPE_ANALYTICS = "tool:sales_analytics_report"; + +export const TOOL_SCOPES = [ + SCOPE_ORDER_STATUS, + SCOPE_FULFILL, + SCOPE_ANALYTICS, +] as const; + +/** Scopes the server asks the store APIs for. */ +export const READ_ORDERS = "read:orders"; +export const READ_INVENTORY = "read:inventory"; +export const WRITE_INVENTORY = "write:inventory"; +export const READ_ANALYTICS = "read:analytics"; diff --git a/examples/ai-mcp-server/src/store-data.ts b/examples/ai-mcp-server/src/store-data.ts new file mode 100644 index 00000000..774023c6 --- /dev/null +++ b/examples/ai-mcp-server/src/store-data.ts @@ -0,0 +1,54 @@ +/** In-memory seed data for the store API. It resets when the process restarts. */ + +export interface OrderRecord { + id: string; + item: string; + sku: string; + qty: number; + status: string; +} + +export interface StoreData { + orders: () => OrderRecord[]; + order: (id: string) => OrderRecord | undefined; + stock: (sku: string) => number | undefined; + /** Reserves stock once per order id. Returns undefined for an unknown sku. */ + reserve: ( + sku: string, + qty: number, + orderId: string, + ) => { alreadyReserved: boolean; before: number; after: number } | undefined; +} + +export function createStoreData(): StoreData { + const orders: OrderRecord[] = [ + { id: "ORD-1001", item: "Wireless Headphones", sku: "SKU-HEAD-01", qty: 1, status: "shipped" }, + { id: "ORD-1002", item: "Mechanical Keyboard", sku: "SKU-KEYB-02", qty: 1, status: "processing" }, + { id: "ORD-1003", item: "USB-C Dock", sku: "SKU-DOCK-03", qty: 2, status: "processing" }, + ]; + const stock = new Map([ + ["SKU-HEAD-01", 24], + ["SKU-KEYB-02", 12], + ["SKU-DOCK-03", 7], + ]); + const reserved = new Set(); + + return { + orders: () => orders.map((o) => ({ ...o })), + order: (id) => { + const found = orders.find((o) => o.id === id); + return found && { ...found }; + }, + stock: (sku) => stock.get(sku), + reserve: (sku, qty, orderId) => { + const before = stock.get(sku); + if (before === undefined) return undefined; + if (reserved.has(orderId)) { + return { alreadyReserved: true, before, after: before }; + } + reserved.add(orderId); + stock.set(sku, before - qty); + return { alreadyReserved: false, before, after: before - qty }; + }, + }; +} diff --git a/examples/ai-mcp-server/test/smoke.spec.ts b/examples/ai-mcp-server/test/smoke.spec.ts index 03790ed3..b9252c90 100644 --- a/examples/ai-mcp-server/test/smoke.spec.ts +++ b/examples/ai-mcp-server/test/smoke.spec.ts @@ -19,7 +19,10 @@ import { createMcpApp } from "../src/mcp-app.js"; // Offline: Auth0 is replaced by MSW handlers; both servers listen on loopback. const MCP_URL = "https://mcp.example.com/mcp"; -const DOWNSTREAM_AUDIENCE = "https://reports.example.com"; +const ORDERS = "https://orders.example.com"; +const INVENTORY = "https://inventory.example.com"; +const ANALYTICS = "https://analytics.example.com"; +const ALL_TOOLS = "openid tool:order_status_report tool:fulfill_order tool:sales_analytics_report"; let key: TestKey; let tenant: ReturnType; @@ -52,8 +55,9 @@ beforeAll(async () => { const downstreamConfig: DownstreamConfig = { domain: "tenant.example.com", - audience: DOWNSTREAM_AUDIENCE, - requiredScope: "read:reports", + ordersAudience: ORDERS, + inventoryAudience: INVENTORY, + analyticsAudience: ANALYTICS, port: 0, }; downstream = createDownstreamServer( @@ -70,10 +74,10 @@ beforeAll(async () => { clientId: "mcp-client", clientSecret: "secret", requiredScopes: [], - adminScope: "read:admin", - downstreamAudience: DOWNSTREAM_AUDIENCE, - downstreamUrl: `http://127.0.0.1:${downstreamPort}`, - downstreamScope: "read:reports", + ordersAudience: ORDERS, + inventoryAudience: INVENTORY, + analyticsAudience: ANALYTICS, + storeApiUrl: `http://127.0.0.1:${downstreamPort}`, downstreamTimeoutMs: 2000, port: 0, }; @@ -100,7 +104,11 @@ function callerToken(scope: string, sub = "user|1") { }); } -async function callTool(name: string, token?: string) { +async function callTool( + name: string, + token?: string, + args: Record = {}, +) { return fetch(`http://127.0.0.1:${mcpPort}/mcp`, { method: "POST", headers: { @@ -112,7 +120,7 @@ async function callTool(name: string, token?: string) { jsonrpc: "2.0", id: 1, method: "tools/call", - params: { name, arguments: {} }, + params: { name, arguments: args }, }), }); } @@ -121,29 +129,119 @@ interface ToolResult { result?: { isError?: boolean; content: Array<{ text: string }> }; } +async function run( + name: string, + scope: string, + sub: string, + args: Record = {}, +) { + const response = await callTool(name, await callerToken(scope, sub), args); + expect(response.status).toBe(200); + const body = (await response.json()) as ToolResult; + return { text: body.result?.content[0]?.text ?? "", isError: body.result?.isError }; +} + describe("example MCP server", () => { it("rejects a request without a credential", async () => { - const response = await callTool("list_reports"); + const response = await callTool("order_status_report"); expect(response.status).toBe(401); expect(response.headers.get("www-authenticate")).toMatch(/^Bearer /); }); - it("lists reports from the downstream API with an exchanged token", async () => { - const response = await callTool( - "list_reports", - await callerToken("openid"), + it("lists recent orders with two exchanged tokens", async () => { + const out = await run("order_status_report", ALL_TOOLS, "user|status"); + + expect(out.isError).toBeFalsy(); + expect(out.text).toContain("Recent orders for user|status:"); + expect(out.text).toContain("- ORD-1001 Wireless Headphones x1, shipped, stock left 24"); + expect(out.text).toContain("- ORD-1002 Mechanical Keyboard x1, processing, stock left 12"); + expect(out.text).toContain(`Orders (aud ${ORDERS}, scope read:orders`); + expect(out.text).toContain(`Inventory (aud ${INVENTORY}, scope read:inventory`); + expect(tenant.state.successfulExchangeCount).toBe(2); + expect( + tenant.state.requests.map((r) => r.params["audience"]).sort(), + ).toEqual([INVENTORY, ORDERS]); + }); + + it("reuses saved tokens on a repeat lookup", async () => { + await run("order_status_report", ALL_TOOLS, "user|repeat"); + expect(tenant.state.successfulExchangeCount).toBe(2); + tenant.state.requests.length = 0; + tenant.state.exchangeCount = 0; + await run("order_status_report", ALL_TOOLS, "user|repeat"); + expect(tenant.state.exchangeCount).toBe(0); + }); + + it("reuses the Orders token when fulfilling right after a status report", async () => { + await run("order_status_report", ALL_TOOLS, "user|reuse"); + tenant.state.exchangeCount = 0; + await run("fulfill_order", ALL_TOOLS, "user|reuse", { order_id: "ORD-1001" }); + // Only the Inventory write token is new. + expect(tenant.state.exchangeCount).toBe(1); + expect(tenant.state.lastExchangeRequest?.params["audience"]).toBe(INVENTORY); + }); + + it("shows one order, and reports an unknown one", async () => { + const one = await run("order_status_report", ALL_TOOLS, "user|one", { + order_id: "ORD-1003", + }); + expect(one.text).toContain("Status of ORD-1003 for user|one:"); + expect(one.text).not.toContain("ORD-1001"); + + const missing = await run("order_status_report", ALL_TOOLS, "user|one", { + order_id: "ORD-9999", + }); + expect(missing.isError).toBe(true); + expect(missing.text).toBe("Order ORD-9999 was not found."); + }); + + it("fulfills an order once and changes stock", async () => { + const first = await run("fulfill_order", ALL_TOOLS, "user|fulfill", { + order_id: "ORD-1002", + }); + expect(first.isError).toBeFalsy(); + expect(first.text).toContain( + "Order ORD-1002 fulfilled: reserved 1 x Mechanical Keyboard (stock 12 -> 11)", ); - const body = (await response.json()) as ToolResult; + expect(first.text).toContain(`Inventory (aud ${INVENTORY}, scope write:inventory`); - expect(response.status).toBe(200); - expect(body.result?.isError).toBeFalsy(); - expect(JSON.parse(body.result!.content[0]!.text)).toMatchObject({ - reports: [{ id: "r-1" }, { id: "r-2" }], + const again = await run("fulfill_order", ALL_TOOLS, "user|fulfill", { + order_id: "ORD-1002", }); - expect(tenant.state.successfulExchangeCount).toBe(1); - expect(tenant.state.lastExchangeRequest?.params["audience"]).toBe( - DOWNSTREAM_AUDIENCE, + expect(again.text).toContain( + "Order ORD-1002 was already fulfilled (stock stays 11)", + ); + + const report = await run("order_status_report", ALL_TOOLS, "user|fulfill"); + expect(report.text).toContain("ORD-1002 Mechanical Keyboard x1, processing, stock left 11"); + }); + + it("fetches sales numbers with the server's own token", async () => { + const out = await run("sales_analytics_report", ALL_TOOLS, "user|sales"); + + expect(out.text).toContain("Monthly revenue: $142,000 | Top product: Enterprise Suite"); + expect(out.text).toContain( + `Checked by: Analytics (aud ${ANALYTICS}, scope read:analytics, service token for the server itself)`, + ); + expect(tenant.state.clientCredentialsCount).toBe(1); + expect(tenant.state.successfulExchangeCount).toBe(0); + }); + + it("denies a tool per tool, with HTTP 200 and an error result", async () => { + const denied = await run( + "sales_analytics_report", + "openid tool:order_status_report", + "user|limited", + ); + expect(denied.isError).toBe(true); + expect(tenant.state.clientCredentialsCount).toBe(0); + + const allowed = await run( + "order_status_report", + "openid tool:order_status_report", + "user|limited", ); + expect(allowed.isError).toBeFalsy(); }); it("never returns exchange error text in the tool result", async () => { @@ -156,115 +254,124 @@ describe("example MCP server", () => { }; // A caller no earlier test used, so a cached exchange cannot hide the failure. const response = await callTool( - "list_reports", - await callerToken("openid", "user|failing"), + "order_status_report", + await callerToken(ALL_TOOLS, "user|failing"), ); const raw = await response.text(); const body = JSON.parse(raw) as ToolResult; expect(body.result?.isError).toBe(true); - expect(body.result?.content[0]?.text).toBe( - "Could not reach the downstream API.", - ); + expect(body.result?.content[0]?.text).toBe("Could not reach the store API."); expect(raw).not.toContain("SECRET-INTERNAL-DETAIL"); expect(raw).not.toContain("invalid_grant"); }); - - it("denies the admin tool per tool, with HTTP 200 and an error result", async () => { - const response = await callTool( - "admin_summary", - await callerToken("openid"), - ); - const body = (await response.json()) as ToolResult; - - expect(response.status).toBe(200); - expect(body.result?.isError).toBe(true); - }); - - it("runs the admin tool for a caller with the admin scope", async () => { - const response = await callTool( - "admin_summary", - await callerToken("openid read:admin"), - ); - const body = (await response.json()) as ToolResult; - - expect(body.result?.isError).toBeFalsy(); - expect(body.result?.content[0]?.text).toContain("Administrator summary"); - }); }); -describe("example downstream API", () => { - function get(token?: string) { - return fetch(`http://127.0.0.1:${downstreamPort}/reports`, { +describe("example store API", () => { + function get(path: string, token?: string) { + return fetch(`http://127.0.0.1:${downstreamPort}${path}`, { headers: token ? { authorization: `Bearer ${token}` } : {}, }); } + function mint( + aud: string, + scope: string, + extra: { sub?: string; exp?: number } = {}, + ) { + return mintAccessToken(key, { + iss: tenant.issuer, + aud, + sub: extra.sub ?? "user|1", + scope, + ...(extra.exp !== undefined && { exp: extra.exp }), + }); + } + it("answers 401 without a token", async () => { - expect((await get()).status).toBe(401); + const res = await get("/orders"); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBe("Bearer"); }); - it("answers 401 for a token issued for another audience", async () => { - const token = await mintAccessToken(key, { - iss: tenant.issuer, - aud: "https://other.example.com", - sub: "user|1", - scope: "read:reports", - }); - expect((await get(token)).status).toBe(401); + it("answers 401 for a token issued for another service", async () => { + expect((await get("/orders", await mint(INVENTORY, "read:orders"))).status).toBe(401); + expect((await get("/inventory/SKU-HEAD-01", await mint(ORDERS, "read:inventory"))).status).toBe(401); }); it("answers 403 when the scope is missing", async () => { - const token = await mintAccessToken(key, { - iss: tenant.issuer, - aud: DOWNSTREAM_AUDIENCE, - sub: "user|1", - scope: "other:scope", - }); - expect((await get(token)).status).toBe(403); + const res = await get("/orders", await mint(ORDERS, "other:scope")); + expect(res.status).toBe(403); + expect(res.headers.get("www-authenticate")).toContain("insufficient_scope"); }); - it("answers 200 for a valid token", async () => { - const token = await mintAccessToken(key, { - iss: tenant.issuer, - aud: DOWNSTREAM_AUDIENCE, - sub: "user|1", - scope: "read:reports", + it("answers 200 with proof of what was checked", async () => { + const res = await get("/orders", await mint(ORDERS, "read:orders")); + const body = (await res.json()) as { orders: unknown[]; verified: object }; + expect(res.status).toBe(200); + expect(body.orders).toHaveLength(3); + expect(body.verified).toMatchObject({ + audience: ORDERS, + scope: "read:orders", + subject: "user|1", }); - expect((await get(token)).status).toBe(200); + }); + + it("answers 404 for an unknown order, sku or route", async () => { + expect((await get("/orders/ORD-0", await mint(ORDERS, "read:orders"))).status).toBe(404); + expect((await get("/inventory/SKU-NONE", await mint(INVENTORY, "read:inventory"))).status).toBe(404); + expect((await get("/nothing", await mint(ORDERS, "read:orders"))).status).toBe(404); + }); + + it("needs the write scope to reserve stock, and rejects bad bodies", async () => { + const post = (token: string, body: string) => + fetch(`http://127.0.0.1:${downstreamPort}/inventory/SKU-DOCK-03/reserve`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body, + }); + const readOnly = await mint(INVENTORY, "read:inventory"); + expect((await post(readOnly, "{}")).status).toBe(403); + const writer = await mint(INVENTORY, "read:inventory write:inventory"); + expect((await post(writer, "not json")).status).toBe(400); + expect((await post(writer, JSON.stringify({ qty: 0, order_id: "ORD-1003" }))).status).toBe(400); + expect((await post(writer, JSON.stringify({ qty: 1, order_id: "ORD-0" }))).status).toBe(404); + }); + + it("serves analytics only to a service token", async () => { + const service = await mint(ANALYTICS, "read:analytics", { sub: "mcp-client@clients" }); + expect((await get("/analytics/monthly-revenue", service)).status).toBe(200); + const user = await mint(ANALYTICS, "read:analytics"); + expect((await get("/analytics/monthly-revenue", user)).status).toBe(401); }); it("answers 401 for an unsigned alg:none token", async () => { const token = mintUnsignedToken({ iss: tenant.issuer, - aud: DOWNSTREAM_AUDIENCE, + aud: ORDERS, sub: "user|1", - scope: "read:reports", + scope: "read:orders", exp: Math.floor(Date.now() / 1000) + 3600, }); - expect((await get(token)).status).toBe(401); + expect((await get("/orders", token)).status).toBe(401); }); it("answers 401 for an HS256 token signed with the public key material", async () => { const secret = new TextEncoder().encode(JSON.stringify(key.publicJwk)); - const token = await new SignJWT({ scope: "read:reports" }) + const token = await new SignJWT({ scope: "read:orders" }) .setProtectedHeader({ alg: "HS256", kid: key.kid }) .setIssuer(tenant.issuer) - .setAudience(DOWNSTREAM_AUDIENCE) + .setAudience(ORDERS) .setSubject("user|1") .setExpirationTime("1h") .sign(secret); - expect((await get(token)).status).toBe(401); + expect((await get("/orders", token)).status).toBe(401); }); it("answers 401 for an expired token", async () => { - const token = await mintAccessToken(key, { - iss: tenant.issuer, - aud: DOWNSTREAM_AUDIENCE, - sub: "user|1", - scope: "read:reports", + const token = await mint(ORDERS, "read:orders", { exp: Math.floor(Date.now() / 1000) - 3600, }); - expect((await get(token)).status).toBe(401); + expect((await get("/orders", token)).status).toBe(401); }); });