From 0be98c0e99e98113cf5fef74953213b898bd5d29 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:36 -0400 Subject: [PATCH 1/8] chore(mcp-tools): scaffold the package Package manifest, build, test and TypeScript configuration. The package is listed in the changeset ignore list, so nothing publishes yet. Co-Authored-By: Claude Fable 5.1 --- .changeset/config.json | 4 +- packages/mcp-tools/LICENSE | 21 +++++ packages/mcp-tools/package.json | 123 +++++++++++++++++++++++++++ packages/mcp-tools/tsconfig.json | 17 ++++ packages/mcp-tools/tsdown.config.mts | 21 +++++ packages/mcp-tools/vitest.config.mts | 11 +++ pnpm-lock.yaml | 85 ++++++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-tools/LICENSE create mode 100644 packages/mcp-tools/package.json create mode 100644 packages/mcp-tools/tsconfig.json create mode 100644 packages/mcp-tools/tsdown.config.mts create mode 100644 packages/mcp-tools/vitest.config.mts diff --git a/.changeset/config.json b/.changeset/config.json index c09617a58d8..8f531b96897 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,9 @@ } ], "commit": false, - "ignore": [], + "ignore": [ + "@clerk/mcp-tools" + ], "fixed": [ [ "@clerk/electron-passkeys", diff --git a/packages/mcp-tools/LICENSE b/packages/mcp-tools/LICENSE new file mode 100644 index 00000000000..012593b8e5c --- /dev/null +++ b/packages/mcp-tools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Clerk Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mcp-tools/package.json b/packages/mcp-tools/package.json new file mode 100644 index 00000000000..40cb8968df3 --- /dev/null +++ b/packages/mcp-tools/package.json @@ -0,0 +1,123 @@ +{ + "name": "@clerk/mcp-tools", + "version": "0.6.0", + "description": "Clerk authentication for MCP servers built on the MCP TypeScript SDK v2", + "keywords": [ + "auth", + "authentication", + "clerk", + "mcp", + "model context protocol", + "oauth" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/mcp-tools" + }, + "license": "MIT", + "author": "Clerk", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./hono": { + "import": { + "types": "./dist/hono.d.mts", + "default": "./dist/hono.mjs" + }, + "require": { + "types": "./dist/hono.d.ts", + "default": "./dist/hono.js" + } + }, + "./express": { + "import": { + "types": "./dist/express.d.mts", + "default": "./dist/express.mjs" + }, + "require": { + "types": "./dist/express.d.ts", + "default": "./dist/express.js" + } + }, + "./next": { + "import": { + "types": "./dist/next.d.mts", + "default": "./dist/next.mjs" + }, + "require": { + "types": "./dist/next.d.ts", + "default": "./dist/next.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "clean": "rimraf ./dist", + "dev": "tsdown --watch", + "dev:pub": "pnpm dev -- --env.publish", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "lint:attw": "attw --pack . --profile node16", + "lint:publint": "publint", + "test": "vitest run", + "test:watch": "vitest watch" + }, + "dependencies": { + "@clerk/backend": "workspace:^", + "@clerk/shared": "workspace:^" + }, + "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@types/express": "^4.17.25", + "@types/supertest": "^6.0.3", + "express": "^4.22.2", + "hono": "^4.12.34", + "supertest": "^6.3.4", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "express": "^4.17.0 || ^5.0.0", + "hono": ">=4" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/node": { + "optional": true + }, + "express": { + "optional": true + }, + "hono": { + "optional": true + } + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mcp-tools/tsconfig.json b/packages/mcp-tools/tsconfig.json new file mode 100644 index 00000000000..ffa09e4e241 --- /dev/null +++ b/packages/mcp-tools/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "moduleResolution": "NodeNext", + "module": "NodeNext", + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowJs": true, + "target": "ES2020", + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/packages/mcp-tools/tsdown.config.mts b/packages/mcp-tools/tsdown.config.mts new file mode 100644 index 00000000000..71b2b8e668c --- /dev/null +++ b/packages/mcp-tools/tsdown.config.mts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig(overrideOptions => { + const shouldPublish = !!overrideOptions.env?.publish; + + return { + entry: { + index: './src/index.ts', + hono: './src/hono.ts', + express: './src/express.ts', + next: './src/next.ts', + }, + format: ['cjs', 'esm'], + fixedExtension: false, + clean: true, + minify: false, + sourcemap: true, + dts: true, + onSuccess: shouldPublish ? 'pkglab pub --ping' : undefined, + }; +}); diff --git a/packages/mcp-tools/vitest.config.mts b/packages/mcp-tools/vitest.config.mts new file mode 100644 index 00000000000..70b6ca300d8 --- /dev/null +++ b/packages/mcp-tools/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + enabled: true, + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1b73c7804b..02d67eb0a9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,6 +845,43 @@ importers: specifier: workspace:^ version: link:../shared + packages/mcp-tools: + dependencies: + '@clerk/backend': + specifier: workspace:^ + version: link:../backend + '@clerk/shared': + specifier: workspace:^ + version: link:../shared + devDependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 + '@modelcontextprotocol/node': + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.4) + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 + '@types/express': + specifier: ^4.17.25 + version: 4.17.25 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + express: + specifier: ^4.22.2 + version: 4.22.2 + hono: + specifier: ^4.12.34 + version: 4.13.4 + supertest: + specifier: ^6.3.4 + version: 6.3.4 + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/mosaic: dependencies: '@clerk/shared': @@ -3949,6 +3986,24 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/node@2.0.0': + resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -3959,6 +4014,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@mswjs/interceptors@0.41.9': resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} @@ -18670,6 +18729,27 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + jose: 6.2.2 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.4)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.13.4) + '@modelcontextprotocol/server': 2.0.0 + optionalDependencies: + hono: 4.13.4 + '@modelcontextprotocol/sdk@1.26.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.13.4) @@ -18694,6 +18774,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 From 238862a10e81e2881d7ae32f8f99602b969a0a60 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:37 -0400 Subject: [PATCH 2/8] feat(mcp-tools): add the scope helpers, error type and telemetry events Reads the tools/call messages of a request through the SDK's classifyInboundRequest, resolves per-tool scopes from a Map, and orders scopes by the configured catalog. Co-Authored-By: Claude Fable 5.1 --- packages/mcp-tools/src/__tests__/helpers.ts | 66 +++++++++++++++++ .../mcp-tools/src/__tests__/scopes.test.ts | 71 ++++++++++++++++++ packages/mcp-tools/src/errors.ts | 20 +++++ packages/mcp-tools/src/scopes.ts | 73 +++++++++++++++++++ packages/mcp-tools/src/telemetry.ts | 31 ++++++++ 5 files changed, 261 insertions(+) create mode 100644 packages/mcp-tools/src/__tests__/helpers.ts create mode 100644 packages/mcp-tools/src/__tests__/scopes.test.ts create mode 100644 packages/mcp-tools/src/errors.ts create mode 100644 packages/mcp-tools/src/scopes.ts create mode 100644 packages/mcp-tools/src/telemetry.ts diff --git a/packages/mcp-tools/src/__tests__/helpers.ts b/packages/mcp-tools/src/__tests__/helpers.ts new file mode 100644 index 00000000000..98b4fb205f2 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/helpers.ts @@ -0,0 +1,66 @@ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +export const PUBLISHABLE_KEY = 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k'; +export const RESOURCE = 'https://example.com/mcp'; +export const RESOURCE_METADATA_URL = 'https://example.com/.well-known/oauth-protected-resource/mcp'; + +export function jwt(payload: Record): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return `${encode({ alg: 'RS256', typ: 'at+jwt', kid: 'ins_123' })}.${encode(payload)}.${encode('signature')}`; +} + +export function authInfoFor(overrides: Partial = {}): AuthInfo { + return { + token: 'mcp-access-token', + clientId: 'client_123', + scopes: ['user:org:read', 'applications:read'], + expiresAt: Math.floor(Date.now() / 1000) + 3600, + resource: new URL(RESOURCE), + extra: { userId: 'user_123' }, + ...overrides, + }; +} + +export function toolCall(name: string, args: Record = {}, id = 1) { + return { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }; +} + +export function legacyInitialize() { + return { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + }; +} + +export function modernToolCall(name: string, args: Record = {}) { + return { + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': name, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name, + arguments: args, + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'vitest', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }), + }; +} diff --git a/packages/mcp-tools/src/__tests__/scopes.test.ts b/packages/mcp-tools/src/__tests__/scopes.test.ts new file mode 100644 index 00000000000..93ff0f19485 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/scopes.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { missingScopes, orderScopes, requestedToolCalls, resolveToolScopes, toolScopeLookup } from '../scopes'; +import { modernToolCall, toolCall } from './helpers'; + +const catalog = ['user:org:read', 'applications:read', 'applications:manage']; + +describe('orderScopes', () => { + it('keeps catalog order and appends unknown scopes once', () => { + expect(orderScopes(catalog, ['applications:manage', 'custom:x', 'user:org:read', 'custom:x'])).toEqual([ + 'user:org:read', + 'applications:manage', + 'custom:x', + ]); + }); +}); + +describe('missingScopes', () => { + it('lists required scopes the grant lacks without duplicates', () => { + expect(missingScopes(['a'], ['a', 'b', 'b', 'c'])).toEqual(['b', 'c']); + }); +}); + +describe('resolveToolScopes', () => { + const tools = toolScopeLookup({ + list: ['applications:read'], + keys: (args: unknown) => + typeof args === 'object' && args !== null && (args as { secret?: boolean }).secret + ? ['applications:read', 'secret:read'] + : ['applications:read'], + }); + + it('returns static scopes, argument-dependent scopes, and nothing for unknown or inherited names', () => { + expect(resolveToolScopes(tools, 'list', undefined)).toEqual(['applications:read']); + expect(resolveToolScopes(tools, 'keys', { secret: true })).toEqual(['applications:read', 'secret:read']); + expect(resolveToolScopes(tools, 'keys', {})).toEqual(['applications:read']); + expect(resolveToolScopes(tools, 'unknown', undefined)).toEqual([]); + expect(resolveToolScopes(tools, 'constructor', undefined)).toEqual([]); + expect(resolveToolScopes(tools, '__proto__', undefined)).toEqual([]); + }); +}); + +describe('requestedToolCalls', () => { + const request = (init: { method?: string; headers?: Record } = {}) => ({ + method: init.method ?? 'POST', + headers: new Headers({ 'content-type': 'application/json', ...init.headers }), + }); + + it('reads tool calls from a legacy request body and from batches', () => { + expect(requestedToolCalls(request(), toolCall('list', { a: 1 }))).toEqual([{ name: 'list', arguments: { a: 1 } }]); + expect(requestedToolCalls(request(), [toolCall('list'), toolCall('keys', { secret: true }, 2)])).toEqual([ + { name: 'list', arguments: {} }, + { name: 'keys', arguments: { secret: true } }, + ]); + }); + + it('reads the body of a modern request rather than its routing headers', () => { + const { headers, body } = modernToolCall('keys', { secret: true }); + + expect(requestedToolCalls(request({ headers }), JSON.parse(body))).toEqual([ + { name: 'keys', arguments: { secret: true } }, + ]); + }); + + it('ignores requests without tool calls', () => { + expect(requestedToolCalls(request({ method: 'GET' }), undefined)).toEqual([]); + expect(requestedToolCalls(request(), { jsonrpc: '2.0', id: 1, method: 'tools/list' })).toEqual([]); + expect(requestedToolCalls(request(), { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {} })).toEqual([]); + expect(requestedToolCalls(request(), 'not json-rpc')).toEqual([]); + }); +}); diff --git a/packages/mcp-tools/src/errors.ts b/packages/mcp-tools/src/errors.ts new file mode 100644 index 00000000000..6a0190b95a4 --- /dev/null +++ b/packages/mcp-tools/src/errors.ts @@ -0,0 +1,20 @@ +export type ClerkMcpErrorCode = + | 'configuration' + | 'insufficient_scope' + | 'rejected' + | 'forbidden' + | 'rate_limited' + | 'unavailable'; + +/** + * Thrown for invalid configuration and for failed token exchanges. Branch on `code`, which is stable. + */ +export class ClerkMcpError extends Error { + readonly code: ClerkMcpErrorCode; + + constructor(code: ClerkMcpErrorCode, message?: string) { + super(message ?? code); + this.name = 'ClerkMcpError'; + this.code = code; + } +} diff --git a/packages/mcp-tools/src/scopes.ts b/packages/mcp-tools/src/scopes.ts new file mode 100644 index 00000000000..0bbe6a74bf3 --- /dev/null +++ b/packages/mcp-tools/src/scopes.ts @@ -0,0 +1,73 @@ +import { classifyInboundRequest } from '@modelcontextprotocol/server'; + +export type ToolScopes = readonly string[] | ((args: unknown) => readonly string[]); +export type ToolScopeMap = Readonly>; +export type ToolCall = { name: string; arguments?: unknown }; + +const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/; + +export function isScopeToken(scope: string): boolean { + return SCOPE_TOKEN.test(scope); +} + +// Tool names arrive in untrusted request bodies, so they are looked up in a Map and never on an object. +export function toolScopeLookup(tools: ToolScopeMap): ReadonlyMap { + return new Map(Object.entries(tools)); +} + +export function resolveToolScopes( + tools: ReadonlyMap, + name: string, + args: unknown, +): readonly string[] { + if (!tools.has(name)) { + return []; + } + const scopes = tools.get(name); + return typeof scopes === 'function' ? scopes(args) : (scopes ?? []); +} + +export function orderScopes(catalog: readonly string[], scopes: Iterable): string[] { + const wanted = new Set(scopes); + const ordered = catalog.filter(scope => wanted.has(scope)); + for (const scope of wanted) { + if (!catalog.includes(scope)) { + ordered.push(scope); + } + } + return ordered; +} + +export function missingScopes(granted: readonly string[], required: readonly string[]): string[] { + return [...new Set(required.filter(scope => !granted.includes(scope)))]; +} + +export function requestedToolCalls(request: { method: string; headers: Headers }, body: unknown): ToolCall[] { + const classification = classifyInboundRequest({ + httpMethod: request.method, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, + body, + }); + if (classification.kind === 'reject') { + return []; + } + const messages = Array.isArray(body) ? body : [classification.kind === 'modern' ? classification.message : body]; + return messages.flatMap(message => { + const call = toolCall(message); + return call ? [call] : []; + }); +} + +function toolCall(message: unknown): ToolCall | undefined { + if (typeof message !== 'object' || message === null) { + return undefined; + } + const { method, params } = message as { method?: unknown; params?: unknown }; + if (method !== 'tools/call' || typeof params !== 'object' || params === null) { + return undefined; + } + const { name, arguments: args } = params as { name?: unknown; arguments?: unknown }; + return typeof name === 'string' ? { name, arguments: args } : undefined; +} diff --git a/packages/mcp-tools/src/telemetry.ts b/packages/mcp-tools/src/telemetry.ts new file mode 100644 index 00000000000..093593144fe --- /dev/null +++ b/packages/mcp-tools/src/telemetry.ts @@ -0,0 +1,31 @@ +export type ClerkMcpAuthFailureReason = + | 'authentication_required' + | 'malformed_bearer' + | 'invalid_token' + | 'verification_error' + | 'missing_expiration' + | 'expired' + | 'audience_missing' + | 'audience_mismatch' + | 'insufficient_scope'; + +export type ClerkMcpTelemetryEvent = + | { type: 'auth'; success: true } + | { type: 'auth'; success: false; reason: ClerkMcpAuthFailureReason } + | { type: 'tool'; tool: string; durationMs: number; success: boolean; error?: string } + | { + type: 'token_exchange'; + resource: string; + durationMs: number; + success: boolean; + code?: string; + /** + * The token endpoint's HTTP status, when it answered. + */ + status?: number; + }; + +/** + * Receives authentication, tool and token exchange outcomes. Events never contain tokens or secrets. + */ +export type ClerkMcpTelemetry = (event: ClerkMcpTelemetryEvent) => void; From 5d1b2b13e99c7fd958144eb3fda08ae5cf31e46a Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:38 -0400 Subject: [PATCH 3/8] feat(mcp-tools): verify Clerk OAuth access tokens An MCP SDK token verifier backed by @clerk/backend. It reports the token's single audience as AuthInfo.resource and enforces its own resource option. Co-Authored-By: Claude Fable 5.1 --- .../mcp-tools/src/__tests__/verifier.test.ts | 164 ++++++++++++++++++ packages/mcp-tools/src/verifier.ts | 99 +++++++++++ 2 files changed, 263 insertions(+) create mode 100644 packages/mcp-tools/src/__tests__/verifier.test.ts create mode 100644 packages/mcp-tools/src/verifier.ts diff --git a/packages/mcp-tools/src/__tests__/verifier.test.ts b/packages/mcp-tools/src/__tests__/verifier.test.ts new file mode 100644 index 00000000000..a37840f74b8 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/verifier.test.ts @@ -0,0 +1,164 @@ +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { verifyMachineAuthToken } = vi.hoisted(() => ({ verifyMachineAuthToken: vi.fn() })); + +vi.mock('@clerk/backend/internal', () => ({ verifyMachineAuthToken })); + +import { boundResource, createClerkOAuthTokenVerifier } from '../verifier'; +import { jwt, RESOURCE } from './helpers'; + +const accessToken = { + id: 'oat_123', + clientId: 'client_123', + type: 'oauth_token', + subject: 'user_123', + scopes: ['notes:read'], + revoked: false, + revocationReason: null, + expired: false, + expiration: 1_800_000_000_999, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, +}; + +function verified(overrides: Partial & { aud?: string[] } = {}) { + verifyMachineAuthToken.mockResolvedValue({ + data: { ...accessToken, ...overrides }, + tokenType: 'oauth_token', + errors: undefined, + }); +} + +async function failure(promise: Promise) { + return promise.then( + () => undefined, + error => error as unknown, + ); +} + +describe('createClerkOAuthTokenVerifier', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps a verified Clerk access token to MCP auth info', async () => { + verified(); + const token = jwt({ aud: RESOURCE }); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123', resource: RESOURCE }); + + await expect(verifier.verifyAccessToken(token)).resolves.toEqual({ + token, + clientId: 'client_123', + scopes: ['notes:read'], + expiresAt: 1_800_000_000, + resource: new URL(RESOURCE), + extra: { userId: 'user_123', accessTokenId: 'oat_123' }, + }); + expect(verifyMachineAuthToken).toHaveBeenCalledWith(token, { secretKey: 'sk_test_123' }); + }); + + it.each([ + ['issued for another resource', jwt({ aud: 'https://other.example.com/mcp' })], + ['without an audience', jwt({ sub: 'user_123' })], + ['that is opaque and carries no audience', 'oat_opaque'], + ])('refuses a token %s when a resource is configured', async (_name, token) => { + verified(); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123', resource: RESOURCE }); + + await expect(verifier.verifyAccessToken(token)).rejects.toMatchObject({ code: OAuthErrorCode.InvalidToken }); + }); + + it('binds an opaque token through the audience the Backend API verified', async () => { + verified({ aud: [RESOURCE] }); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + const authInfo = await verifier.verifyAccessToken('oat_opaque'); + + expect(authInfo.resource).toEqual(new URL(RESOURCE)); + }); + + it('prefers the verified audience over the one a JWT claims', async () => { + verified({ aud: ['https://other.example.com/mcp'] }); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + const authInfo = await verifier.verifyAccessToken(jwt({ aud: RESOURCE })); + + expect(authInfo.resource).toEqual(new URL('https://other.example.com/mcp')); + }); + + it('leaves the resource undefined for an opaque token when @clerk/backend reports no audience', async () => { + verified(); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + const authInfo = await verifier.verifyAccessToken('oat_opaque'); + + expect(authInfo.resource).toBeUndefined(); + }); + + it.each([ + ['revoked', { revoked: true }], + ['expired', { expired: true }], + ['non-expiring', { expiration: null }], + ])('rejects a %s token as invalid', async (_state, overrides) => { + verified(overrides as Partial); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + const error = await failure(verifier.verifyAccessToken('oat_opaque')); + + expect(OAuthError.isInstance(error)).toBe(true); + expect(error).toMatchObject({ code: OAuthErrorCode.InvalidToken }); + }); + + it('rejects machine tokens of another type', async () => { + verifyMachineAuthToken.mockResolvedValue({ data: { id: 'ak_1' }, tokenType: 'api_key', errors: undefined }); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + await expect(verifier.verifyAccessToken('ak_secret')).rejects.toMatchObject({ code: OAuthErrorCode.InvalidToken }); + }); + + it.each(['token-invalid', 'token-verification-failed'])( + 'maps a %s verification error to invalid_token', + async code => { + verifyMachineAuthToken.mockResolvedValue({ data: undefined, tokenType: 'oauth_token', errors: [{ code }] }); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + await expect(verifier.verifyAccessToken('oat_opaque')).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken, + }); + }, + ); + + it.each(['secret-key-invalid', 'unexpected-error'])('surfaces a %s error as a server error', async code => { + verifyMachineAuthToken.mockResolvedValue({ data: undefined, tokenType: 'oauth_token', errors: [{ code }] }); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + await expect(verifier.verifyAccessToken('oat_opaque')).rejects.toMatchObject({ code: OAuthErrorCode.ServerError }); + }); + + it('rejects tokens the SDK cannot classify', async () => { + verifyMachineAuthToken.mockRejectedValue(new Error('Unknown machine token type')); + const verifier = createClerkOAuthTokenVerifier({ secretKey: 'sk_test_123' }); + + await expect(verifier.verifyAccessToken('not-a-token')).rejects.toMatchObject({ + code: OAuthErrorCode.InvalidToken, + }); + }); +}); + +describe('boundResource', () => { + it.each([ + ['no audience', undefined], + ['an empty audience list', []], + ['several audiences', [RESOURCE, 'https://other.example.com/mcp']], + ['a non-URL audience', 'not a url'], + ['a numeric audience', 42], + ])('returns undefined for %s', (_name, audience) => { + expect(boundResource(audience)).toBeUndefined(); + }); + + it('returns the single bound resource', () => { + expect(boundResource(RESOURCE)).toEqual(new URL(RESOURCE)); + expect(boundResource([RESOURCE])).toEqual(new URL(RESOURCE)); + }); +}); diff --git a/packages/mcp-tools/src/verifier.ts b/packages/mcp-tools/src/verifier.ts new file mode 100644 index 00000000000..3facbd28755 --- /dev/null +++ b/packages/mcp-tools/src/verifier.ts @@ -0,0 +1,99 @@ +import type { VerifyTokenOptions } from '@clerk/backend'; +import { MachineTokenVerificationErrorCode } from '@clerk/backend/errors'; +import { verifyMachineAuthToken } from '@clerk/backend/internal'; +import { decodeJwt } from '@clerk/backend/jwt'; +import type { AuthInfo, OAuthTokenVerifier } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; + +export type ClerkOAuthTokenVerifierOptions = Pick< + VerifyTokenOptions, + 'secretKey' | 'jwtKey' | 'apiUrl' | 'apiVersion' | 'clockSkewInMs' | 'skipJwksCache' +> & { + /** + * Refuse tokens that were not issued for this resource. + */ + resource?: string | URL; +}; + +type VerifiedMachineToken = NonNullable>['data']>; +type OAuthAccessToken = Extract; + +// A token that several resource servers accept can be replayed between them, so only a single audience is a binding. +export function boundResource(audience: unknown): URL | undefined { + const audiences = Array.isArray(audience) ? audience : [audience]; + if (audiences.length !== 1 || typeof audiences[0] !== 'string') { + return undefined; + } + try { + return new URL(audiences[0]); + } catch { + return undefined; + } +} + +function jwtAudience(token: string): unknown { + try { + return decodeJwt(token).payload.aud; + } catch { + return undefined; + } +} + +function invalidToken(): OAuthError { + return new OAuthError(OAuthErrorCode.InvalidToken, 'The access token is invalid.'); +} + +/** + * Creates an MCP SDK token verifier backed by Clerk. JWT access tokens are verified locally with the cached JWKS, + * opaque tokens through the Backend API. `AuthInfo.resource` carries the token's audience when it has exactly one. + */ +export function createClerkOAuthTokenVerifier(options: ClerkOAuthTokenVerifierOptions): OAuthTokenVerifier { + const { resource, ...verifyOptions } = options; + const expected = resource === undefined ? undefined : new URL(resource).href; + + return { + async verifyAccessToken(token: string): Promise { + let result: Awaited>; + try { + result = await verifyMachineAuthToken(token, verifyOptions); + } catch { + throw invalidToken(); + } + + if (result.errors) { + const [error] = result.errors; + if ( + error.code === MachineTokenVerificationErrorCode.InvalidSecretKey || + error.code === MachineTokenVerificationErrorCode.UnexpectedError + ) { + throw new OAuthError(OAuthErrorCode.ServerError, 'Access token verification failed.'); + } + throw invalidToken(); + } + + if (result.tokenType !== 'oauth_token') { + throw invalidToken(); + } + + const accessToken = result.data as OAuthAccessToken; + if (accessToken.revoked || accessToken.expired || accessToken.expiration === null) { + throw invalidToken(); + } + + // Newer @clerk/backend versions expose the verified audience, which is the only source for opaque tokens. + const bound = boundResource('aud' in accessToken ? accessToken.aud : jwtAudience(token)); + if (expected !== undefined && bound?.href !== expected) { + throw invalidToken(); + } + + return { + token, + clientId: accessToken.clientId, + scopes: accessToken.scopes, + expiresAt: Math.floor(accessToken.expiration / 1000), + resource: bound, + extra: { userId: accessToken.subject, accessTokenId: accessToken.id }, + }; + }, + }; +} From c53b1b803a668c654f7315ec0a6dea7e307016ee Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:39 -0400 Subject: [PATCH 4/8] feat(mcp-tools): serve protected resource metadata and relay authorization server metadata RFC 9728 document with only the fields the RFC defines. The RFC 8414 document is relayed from the authorization server and cached, so it cannot drift from the instance's settings. Co-Authored-By: Claude Fable 5.1 --- .../mcp-tools/src/__tests__/metadata.test.ts | 83 ++++++++++++++ packages/mcp-tools/src/metadata.ts | 107 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/mcp-tools/src/__tests__/metadata.test.ts create mode 100644 packages/mcp-tools/src/metadata.ts diff --git a/packages/mcp-tools/src/__tests__/metadata.test.ts b/packages/mcp-tools/src/__tests__/metadata.test.ts new file mode 100644 index 00000000000..b8c7f03f646 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/metadata.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { ClerkMcpError } from '../errors'; +import { + clerkAuthorizationServerUrl, + metadataResponse, + protectedResourceMetadata, + trimTrailingSlash, +} from '../metadata'; +import { PUBLISHABLE_KEY, RESOURCE } from './helpers'; + +describe('clerkAuthorizationServerUrl', () => { + it('derives the Frontend API origin from the publishable key', () => { + expect(clerkAuthorizationServerUrl(PUBLISHABLE_KEY)).toBe('https://clerk.example.com'); + }); + + it('fails loudly on an invalid key', () => { + expect(() => clerkAuthorizationServerUrl('sk_test_nope')).toThrow(ClerkMcpError); + }); +}); + +describe('trimTrailingSlash', () => { + it('removes every trailing slash and nothing else', () => { + expect(trimTrailingSlash('https://clerk.example.com///')).toBe('https://clerk.example.com'); + expect(trimTrailingSlash(new URL('https://clerk.example.com/a/b/'))).toBe('https://clerk.example.com/a/b'); + expect(trimTrailingSlash('https://clerk.example.com')).toBe('https://clerk.example.com'); + }); +}); + +describe('protectedResourceMetadata', () => { + it('contains only RFC 9728 properties', () => { + const metadata = protectedResourceMetadata({ + authorizationServerUrl: 'https://clerk.example.com', + resource: new URL(RESOURCE), + scopesSupported: ['user:org:read'], + }); + + expect(metadata).toEqual({ + resource: RESOURCE, + authorization_servers: ['https://clerk.example.com'], + bearer_methods_supported: ['header'], + scopes_supported: ['user:org:read'], + }); + }); + + it('omits an empty scope list and lets properties override defaults', () => { + const metadata = protectedResourceMetadata({ + authorizationServerUrl: 'https://auth.example.com', + resource: new URL(RESOURCE), + scopesSupported: [], + properties: { resource_name: 'Notes', bearer_methods_supported: ['header', 'body'] }, + }); + + expect(metadata).not.toHaveProperty('scopes_supported'); + expect(metadata.resource_name).toBe('Notes'); + expect(metadata.bearer_methods_supported).toEqual(['header', 'body']); + }); +}); + +describe('metadataResponse', () => { + const document = { resource: RESOURCE }; + + it('serves the document with CORS and caching headers', async () => { + const response = metadataResponse( + new Request('https://example.com/.well-known/oauth-protected-resource/mcp'), + document, + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Cache-Control')).toBe('max-age=3600'); + await expect(response.json()).resolves.toEqual(document); + }); + + it('answers preflight requests and refuses other methods', () => { + const url = 'https://example.com/.well-known/oauth-protected-resource/mcp'; + + expect(metadataResponse(new Request(url, { method: 'OPTIONS' }), document).status).toBe(204); + const refused = metadataResponse(new Request(url, { method: 'POST' }), document); + expect(refused.status).toBe(405); + expect(refused.headers.get('Allow')).toBe('GET, OPTIONS'); + }); +}); diff --git a/packages/mcp-tools/src/metadata.ts b/packages/mcp-tools/src/metadata.ts new file mode 100644 index 00000000000..152c1687f4d --- /dev/null +++ b/packages/mcp-tools/src/metadata.ts @@ -0,0 +1,107 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; + +import { ClerkMcpError } from './errors'; + +export type ProtectedResourceMetadata = { + resource: string; + authorization_servers: string[]; + bearer_methods_supported: string[]; + scopes_supported?: string[]; + [property: string]: unknown; +}; + +const AUTHORIZATION_SERVER_METADATA_TTL_MS = 3_600_000; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Max-Age': '86400', +}; + +export function trimTrailingSlash(url: string | URL): string { + let value = String(url); + while (value.endsWith('/')) { + value = value.slice(0, -1); + } + return value; +} + +export function clerkAuthorizationServerUrl(publishableKey: string): string { + const key = parsePublishableKey(publishableKey); + if (!key) { + throw new ClerkMcpError( + 'configuration', + 'Clerk MCP: invalid publishable key. Expected a key starting with pk_test_ or pk_live_.', + ); + } + return `https://${key.frontendApi}`; +} + +export function protectedResourceMetadata({ + authorizationServerUrl, + resource, + scopesSupported, + properties, +}: { + authorizationServerUrl: string; + resource: URL; + scopesSupported: readonly string[]; + properties?: Record; +}): ProtectedResourceMetadata { + return { + resource: resource.href, + authorization_servers: [authorizationServerUrl], + bearer_methods_supported: ['header'], + ...(scopesSupported.length ? { scopes_supported: [...scopesSupported] } : {}), + ...properties, + }; +} + +function refuseMethod(request: Request): Response | undefined { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response(null, { status: 405, headers: { ...CORS_HEADERS, Allow: 'GET, OPTIONS' } }); + } + return undefined; +} + +export function metadataResponse(request: Request, document: unknown): Response { + return ( + refuseMethod(request) ?? Response.json(document, { headers: { ...CORS_HEADERS, 'Cache-Control': 'max-age=3600' } }) + ); +} + +// Clients that predate protected resource metadata look for this document on the MCP server's own origin. +// It is relayed from the authorization server so that it never drifts from the instance's real settings. +export function authorizationServerMetadataHandler( + authorizationServerUrl: () => string, +): (request: Request) => Promise { + let cached: { document: unknown; expiresAt: number } | undefined; + + return async request => { + const refused = refuseMethod(request); + if (refused) { + return refused; + } + if (!cached || cached.expiresAt <= Date.now()) { + try { + const response = await fetch(`${authorizationServerUrl()}/.well-known/oauth-authorization-server`); + if (!response.ok) { + throw new Error(`status ${response.status}`); + } + cached = { document: await response.json(), expiresAt: Date.now() + AUTHORIZATION_SERVER_METADATA_TTL_MS }; + } catch { + if (!cached) { + return Response.json( + { error: 'temporarily_unavailable' }, + { status: 502, headers: { ...CORS_HEADERS, 'Cache-Control': 'no-store' } }, + ); + } + } + } + return metadataResponse(request, cached.document); + }; +} From 4c1eb30cb41c09b7a7a6a1a677078ac4ca9cf520 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:40 -0400 Subject: [PATCH 5/8] feat(mcp-tools): add the RFC 8693 token exchange client Cached per subject, resource and scope set, with a scope-escalation check and stable error codes. Co-Authored-By: Claude Fable 5.1 --- .../mcp-tools/src/__tests__/exchange.test.ts | 203 ++++++++++++++++++ packages/mcp-tools/src/exchange.ts | 185 ++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 packages/mcp-tools/src/__tests__/exchange.test.ts create mode 100644 packages/mcp-tools/src/exchange.ts diff --git a/packages/mcp-tools/src/__tests__/exchange.test.ts b/packages/mcp-tools/src/__tests__/exchange.test.ts new file mode 100644 index 00000000000..2855eb377c3 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/exchange.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkMcpError } from '../errors'; +import { createTokenExchange } from '../exchange'; + +const accessTokenType = 'urn:ietf:params:oauth:token-type:access_token'; +const tokenEndpoint = 'https://clerk.example.com/oauth/token'; +const subjectToken = 'at1-mcp-resource'; +const resource = 'https://api.example.com/resource'; + +function oauthResponse(overrides: Record = {}) { + return Response.json({ + access_token: 'at2-api-resource', + token_type: 'Bearer', + issued_token_type: accessTokenType, + expires_in: 300, + scope: 'data:read', + ...overrides, + }); +} + +function createExchange(request: typeof fetch, options: Partial[0]> = {}) { + return createTokenExchange({ + tokenEndpoint, + clientId: 'client-id', + clientSecret: 'client-secret', + fetch: request, + ...options, + }); +} + +function formBody(request: ReturnType>, call = 0) { + return request.mock.calls[call][1]?.body as URLSearchParams; +} + +describe('createTokenExchange', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('sends the RFC 8693 form with Basic client credentials', async () => { + const request = vi.fn().mockResolvedValueOnce(oauthResponse()); + + await expect(createExchange(request)({ subjectToken, resource, scopes: ['data:read'] })).resolves.toMatchObject({ + accessToken: 'at2-api-resource', + expiresIn: 300, + scope: 'data:read', + }); + + const [url, init] = request.mock.calls[0]; + const headers = new Headers(init?.headers); + expect(url).toBe(tokenEndpoint); + expect(init?.method).toBe('POST'); + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(headers.get('content-type')).toBe('application/x-www-form-urlencoded'); + expect(headers.get('authorization')).toBe(`Basic ${btoa('client-id:client-secret')}`); + expect([...formBody(request).entries()]).toEqual([ + ['grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange'], + ['subject_token', subjectToken], + ['subject_token_type', accessTokenType], + ['requested_token_type', accessTokenType], + ['resource', resource], + ['scope', 'data:read'], + ]); + }); + + it('form-encodes client credentials before Basic authentication', async () => { + const request = vi.fn().mockResolvedValueOnce(oauthResponse()); + + await createExchange(request, { clientId: 'client id', clientSecret: 'secret:word' })({ subjectToken, resource }); + + expect(new Headers(request.mock.calls[0][1]?.headers).get('authorization')).toBe( + `Basic ${btoa('client+id:secret%3Aword')}`, + ); + }); + + it('omits an absent or blank scope and deduplicates requested scopes', async () => { + const request = vi + .fn() + .mockImplementation(() => Promise.resolve(oauthResponse({ scope: undefined }))); + const exchange = createExchange(request, { cache: false }); + + await exchange({ subjectToken, resource }); + await exchange({ subjectToken, resource, scopes: [' '] }); + await exchange({ subjectToken, resource, scopes: ['data:read', 'data:read'] }); + + expect(formBody(request, 0).has('scope')).toBe(false); + expect(formBody(request, 1).has('scope')).toBe(false); + expect(formBody(request, 2).get('scope')).toBe('data:read'); + }); + + it('caches exchanged tokens per subject, resource and scope set until they near expiry', async () => { + vi.useFakeTimers(); + const request = vi.fn().mockImplementation(() => Promise.resolve(oauthResponse())); + const exchange = createExchange(request); + + await exchange({ subjectToken, resource, scopes: ['data:read'] }); + await exchange({ subjectToken, resource, scopes: ['data:read'] }); + expect(request).toHaveBeenCalledTimes(1); + + await exchange({ subjectToken, resource, scopes: ['data:read', 'data:write'] }); + await exchange({ subjectToken: 'other-subject', resource, scopes: ['data:read'] }); + await exchange({ subjectToken, resource: 'https://api.example.com/other', scopes: ['data:read'] }); + expect(request).toHaveBeenCalledTimes(4); + + vi.advanceTimersByTime(271_000); + await exchange({ subjectToken, resource, scopes: ['data:read'] }); + expect(request).toHaveBeenCalledTimes(5); + }); + + it('can run without a cache', async () => { + const request = vi.fn().mockImplementation(() => Promise.resolve(oauthResponse())); + const exchange = createExchange(request, { cache: false }); + + await exchange({ subjectToken, resource, scopes: ['data:read'] }); + await exchange({ subjectToken, resource, scopes: ['data:read'] }); + + expect(request).toHaveBeenCalledTimes(2); + }); + + it('accepts a reordered or narrowed scope grant and a lowercase token type', async () => { + const request = vi + .fn() + .mockResolvedValueOnce(oauthResponse({ scope: 'data:write data:read' })) + .mockResolvedValueOnce(oauthResponse({ scope: 'data:read' })) + .mockResolvedValueOnce(oauthResponse({ token_type: 'bearer', scope: undefined })); + const exchange = createExchange(request, { cache: false }); + + await expect(exchange({ subjectToken, resource, scopes: ['data:read', 'data:write'] })).resolves.toMatchObject({ + scope: 'data:write data:read', + }); + await expect(exchange({ subjectToken, resource, scopes: ['data:read', 'data:write'] })).resolves.toMatchObject({ + scope: 'data:read', + }); + await expect(exchange({ subjectToken, resource, scopes: ['data:read'] })).resolves.not.toHaveProperty('scope'); + }); + + it('rejects a broader grant, a malformed response and a failed request as unavailable', async () => { + const telemetry = vi.fn(); + const request = vi + .fn() + .mockResolvedValueOnce(oauthResponse({ scope: 'data:read data:write' })) + .mockResolvedValueOnce(oauthResponse({ access_token: '' })) + .mockResolvedValueOnce(new Response('not json')) + .mockRejectedValueOnce(new TypeError('network unavailable')); + const exchange = createExchange(request, { cache: false, telemetry }); + + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(exchange({ subjectToken, resource, scopes: ['data:read'] })).rejects.toMatchObject({ + code: 'unavailable', + }); + } + expect(telemetry).toHaveBeenCalledTimes(4); + expect(telemetry.mock.calls.every(([event]) => event.success === false && event.code === 'unavailable')).toBe(true); + expect(JSON.stringify(telemetry.mock.calls)).not.toContain(subjectToken); + }); + + it.each([ + [400, 'rejected'], + [401, 'unavailable'], + [403, 'forbidden'], + [404, 'unavailable'], + [429, 'rate_limited'], + [500, 'unavailable'], + ] as const)('maps an HTTP %i response to %s', async (status, code) => { + const request = vi.fn().mockResolvedValueOnce(new Response('{}', { status })); + + await expect(createExchange(request)({ subjectToken, resource })).rejects.toMatchObject({ code }); + }); + + it('reports the token endpoint status to telemetry without the subject token', async () => { + const telemetry = vi.fn(); + const request = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 429 })); + + await expect(createExchange(request, { telemetry })({ subjectToken, resource })).rejects.toMatchObject({ + code: 'rate_limited', + }); + + expect(telemetry).toHaveBeenCalledExactlyOnceWith({ + type: 'token_exchange', + resource, + durationMs: expect.any(Number), + success: false, + code: 'rate_limited', + status: 429, + }); + }); + + it('rejects a relative resource or a resource with a fragment before any request', async () => { + const request = vi.fn(); + const exchange = createExchange(request); + + await expect(exchange({ subjectToken, resource: '/relative' })).rejects.toMatchObject({ code: 'configuration' }); + await expect(exchange({ subjectToken, resource: `${resource}#fragment` })).rejects.toMatchObject({ + code: 'configuration', + }); + expect(request).not.toHaveBeenCalled(); + }); + + it('fails loudly without client credentials', () => { + expect(() => createTokenExchange({ tokenEndpoint, clientId: '', clientSecret: 'x' })).toThrow(ClerkMcpError); + }); +}); diff --git a/packages/mcp-tools/src/exchange.ts b/packages/mcp-tools/src/exchange.ts new file mode 100644 index 00000000000..a5e25e4caed --- /dev/null +++ b/packages/mcp-tools/src/exchange.ts @@ -0,0 +1,185 @@ +import { ClerkMcpError } from './errors'; +import type { ClerkMcpTelemetry } from './telemetry'; + +const TOKEN_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +const DEFAULT_TIMEOUT_MS = 10_000; +const CACHE_SKEW_MS = 30_000; +const MAX_CACHE_ENTRIES = 1_000; + +export type TokenExchangeOptions = { + tokenEndpoint: string | URL; + clientId: string; + clientSecret: string; + fetch?: typeof fetch; + timeoutMs?: number; + cache?: boolean; + telemetry?: ClerkMcpTelemetry; +}; + +export type TokenExchangeInput = { + subjectToken: string; + resource: string | URL; + scopes?: readonly string[]; +}; + +export type ExchangedToken = { + accessToken: string; + /** + * Lifetime in seconds, as reported by the token endpoint. + */ + expiresIn: number; + /** + * Expiry as a Unix timestamp in milliseconds. + */ + expiresAt: number; + scope?: string; +}; + +export type TokenExchange = (input: TokenExchangeInput) => Promise; + +function formComponent(value: string): string { + return new URLSearchParams({ value }).toString().slice('value='.length); +} + +function targetResource(resource: string | URL): string { + let url: URL; + try { + url = new URL(resource); + } catch { + throw new ClerkMcpError('configuration', 'Token exchange resource must be an absolute URL.'); + } + if (url.hash || url.href.endsWith('#')) { + throw new ClerkMcpError('configuration', 'Token exchange resource must not include a fragment.'); + } + return url.href; +} + +function exchangeErrorCode(status: number): ClerkMcpError['code'] { + if (status === 400) { + return 'rejected'; + } + if (status === 403) { + return 'forbidden'; + } + if (status === 429) { + return 'rate_limited'; + } + return 'unavailable'; +} + +function parseTokenResponse(payload: unknown): { accessToken: string; expiresIn: number; scope?: string } | undefined { + if (typeof payload !== 'object' || payload === null) { + return undefined; + } + const token = payload as Record; + if ( + typeof token.access_token !== 'string' || + !token.access_token || + typeof token.token_type !== 'string' || + token.token_type.toLowerCase() !== 'bearer' || + token.issued_token_type !== ACCESS_TOKEN_TYPE || + typeof token.expires_in !== 'number' || + !Number.isInteger(token.expires_in) || + token.expires_in <= 0 || + (token.scope !== undefined && (typeof token.scope !== 'string' || !token.scope)) + ) { + return undefined; + } + return { + accessToken: token.access_token, + expiresIn: token.expires_in, + ...(typeof token.scope === 'string' ? { scope: token.scope } : {}), + }; +} + +async function cacheKey(subjectToken: string, resource: string, scopes: readonly string[]): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(subjectToken)); + const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + return `${hash}|${resource}|${[...scopes].sort().join(' ')}`; +} + +export function createTokenExchange(options: TokenExchangeOptions): TokenExchange { + if (!options.clientId || !options.clientSecret) { + throw new ClerkMcpError('configuration', 'Clerk MCP: token exchange requires a clientId and a clientSecret.'); + } + const request = options.fetch ?? fetch; + const tokenEndpoint = new URL(options.tokenEndpoint).href; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const authorization = `Basic ${btoa(`${formComponent(options.clientId)}:${formComponent(options.clientSecret)}`)}`; + const cache = options.cache === false ? undefined : new Map(); + + return async ({ subjectToken, resource, scopes = [] }) => { + const start = Date.now(); + const target = targetResource(resource); + const fail = (code: ClerkMcpError['code'], message: string, status?: number) => { + options.telemetry?.({ + type: 'token_exchange', + resource: target, + durationMs: Date.now() - start, + success: false, + code, + ...(status === undefined ? {} : { status }), + }); + return new ClerkMcpError(code, message); + }; + + const requested = [...new Set(scopes.map(scope => scope.trim()).filter(Boolean))]; + const key = cache ? await cacheKey(subjectToken, target, requested) : undefined; + const cached = key === undefined ? undefined : cache?.get(key); + if (cached && cached.expiresAt - CACHE_SKEW_MS > Date.now()) { + return cached; + } + + const body = new URLSearchParams({ + grant_type: TOKEN_EXCHANGE_GRANT_TYPE, + subject_token: subjectToken, + subject_token_type: ACCESS_TOKEN_TYPE, + requested_token_type: ACCESS_TOKEN_TYPE, + resource: target, + }); + if (requested.length) { + body.set('scope', requested.join(' ')); + } + + let response: Response; + try { + response = await request(tokenEndpoint, { + method: 'POST', + headers: { authorization, 'content-type': 'application/x-www-form-urlencoded' }, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + throw fail('unavailable', 'Token exchange request failed.'); + } + + if (!response.ok) { + throw fail( + exchangeErrorCode(response.status), + `Token exchange failed with status ${response.status}.`, + response.status, + ); + } + + const token = parseTokenResponse(await response.json().catch(() => undefined)); + if (!token) { + throw fail('unavailable', 'Token exchange returned a malformed token response.', response.status); + } + const granted = token.scope?.split(/\s+/) ?? []; + if (requested.length && granted.some(scope => !requested.includes(scope))) { + throw fail('unavailable', 'Token exchange granted scopes that were not requested.', response.status); + } + + const exchanged: ExchangedToken = { ...token, expiresAt: Date.now() + token.expiresIn * 1000 }; + if (cache && key !== undefined) { + cache.delete(key); + if (cache.size >= MAX_CACHE_ENTRIES) { + cache.delete(cache.keys().next().value as string); + } + cache.set(key, exchanged); + } + options.telemetry?.({ type: 'token_exchange', resource: target, durationMs: Date.now() - start, success: true }); + return exchanged; + }; +} From b5f508e83d1c9e56d4f6a0ec9d5bed7129264a0c Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:41 -0400 Subject: [PATCH 6/8] feat(mcp-tools): add createClerkMcpAuth The 401 and 403 challenges, audience binding, the per-tool scope gate, withScopes(), mcpHandler() and exchangeToken(). Keys and credentials are read on first use. Co-Authored-By: Claude Fable 5.1 --- .../__snapshots__/exports.test.ts.snap | 9 + packages/mcp-tools/src/__tests__/auth.test.ts | 811 ++++++++++++++++++ .../mcp-tools/src/__tests__/exports.test.ts | 9 + packages/mcp-tools/src/auth.ts | 598 +++++++++++++ packages/mcp-tools/src/index.ts | 20 + 5 files changed, 1447 insertions(+) create mode 100644 packages/mcp-tools/src/__tests__/__snapshots__/exports.test.ts.snap create mode 100644 packages/mcp-tools/src/__tests__/auth.test.ts create mode 100644 packages/mcp-tools/src/__tests__/exports.test.ts create mode 100644 packages/mcp-tools/src/auth.ts create mode 100644 packages/mcp-tools/src/index.ts diff --git a/packages/mcp-tools/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/mcp-tools/src/__tests__/__snapshots__/exports.test.ts.snap new file mode 100644 index 00000000000..61a335c1877 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/__snapshots__/exports.test.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@clerk/mcp-tools public exports > should not include a breaking change 1`] = ` +[ + "ClerkMcpError", + "createClerkMcpAuth", + "createClerkOAuthTokenVerifier", +] +`; diff --git a/packages/mcp-tools/src/__tests__/auth.test.ts b/packages/mcp-tools/src/__tests__/auth.test.ts new file mode 100644 index 00000000000..1bfa3f906f3 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/auth.test.ts @@ -0,0 +1,811 @@ +import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { createClerkMcpAuth } from '../auth'; +import { ClerkMcpError } from '../errors'; +import type { ClerkMcpTelemetryEvent } from '../telemetry'; +import { + authInfoFor, + legacyInitialize, + modernToolCall, + PUBLISHABLE_KEY, + RESOURCE, + RESOURCE_METADATA_URL, + toolCall, +} from './helpers'; + +const verifyAccessToken = vi.fn(); +const verifier = { verifyAccessToken }; +const catalog = ['user:org:read', 'applications:read', 'applications:manage', 'application_secret_keys:read']; +const tools = { + whoami: ['user:org:read'], + get_application: ['user:org:read', 'applications:read'], + create_application: ['user:org:read', 'applications:manage'], + get_instance_keys: (args: unknown) => + typeof args === 'object' && args !== null && (args as { include_secret_key?: boolean }).include_secret_key + ? ['user:org:read', 'applications:read', 'application_secret_keys:read'] + : ['user:org:read', 'applications:read'], +}; +const baselineChallenge = `Bearer scope="user:org:read applications:read", resource_metadata="${RESOURCE_METADATA_URL}"`; + +function create(overrides: Record = {}) { + return createClerkMcpAuth({ + resource: RESOURCE, + publishableKey: PUBLISHABLE_KEY, + verifier, + scopes: catalog, + baselineScopes: ['user:org:read', 'applications:read'], + tools, + ...overrides, + }); +} + +function request(init: { method?: string; headers?: Record; body?: unknown } = {}) { + const hasBody = init.body !== undefined; + return new Request(RESOURCE, { + method: init.method ?? (hasBody ? 'POST' : 'GET'), + headers: { ...(hasBody ? { 'content-type': 'application/json' } : {}), ...init.headers }, + body: hasBody ? JSON.stringify(init.body) : undefined, + }); +} + +function bearer(token = 'mcp-access-token') { + return { authorization: `Bearer ${token}` }; +} + +describe('createClerkMcpAuth configuration', () => { + beforeEach(() => { + vi.stubEnv('CLERK_PUBLISHABLE_KEY', ''); + vi.stubEnv('CLERK_SECRET_KEY', ''); + vi.stubEnv('CLERK_JWT_KEY', ''); + }); + + it.each([ + ['a relative resource', { resource: '/mcp' }], + ['a resource with a fragment', { resource: `${RESOURCE}#frag` }], + ['a resource with a query string', { resource: `${RESOURCE}?x=1` }], + ['a non-http resource', { resource: 'ftp://example.com/mcp' }], + ['an invalid scope token', { scopes: ['bad scope'] }], + ['a baseline scope outside the catalog', { baselineScopes: ['notes:read'] }], + ['a tool scope outside the catalog', { tools: { list_notes: ['notes:read'] } }], + ['an unknown insufficient scope mode', { insufficientScope: 'nope' }], + ])('fails loudly on %s', (_name, overrides) => { + expect(() => create(overrides)).toThrow(ClerkMcpError); + expect(() => create(overrides)).toThrow(/^Clerk MCP: /); + }); + + it('accepts an explicit authorization server and a secret key from the environment', async () => { + vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_123'); + const clerkMcp = createClerkMcpAuth({ + resource: RESOURCE, + authorizationServerUrl: 'https://auth.example.com/', + scopes: ['notes:read'], + }); + + const metadata = await clerkMcp.protectedResourceMetadata()(new Request(RESOURCE_METADATA_URL)).json(); + + expect(metadata.authorization_servers).toEqual(['https://auth.example.com']); + }); + + it('derives the authorization server and the metadata URL from the publishable key and the resource', async () => { + const clerkMcp = create(); + + const challenge = (await clerkMcp.authenticate(request())) as Response; + const metadata = await clerkMcp.protectedResourceMetadata()(new Request(RESOURCE_METADATA_URL)).json(); + + expect(challenge.headers.get('WWW-Authenticate')).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(metadata).toMatchObject({ resource: RESOURCE, authorization_servers: ['https://clerk.example.com'] }); + }); + + it('derives the catalog from the baseline and tool scopes when none is given', async () => { + const clerkMcp = createClerkMcpAuth({ + resource: RESOURCE, + publishableKey: PUBLISHABLE_KEY, + verifier, + baselineScopes: ['user:org:read'], + tools: { create: ['applications:manage', 'user:org:read'], read: ['applications:read'] }, + }); + + const challenge = (await clerkMcp.authenticate( + request({ body: [toolCall('read'), toolCall('create', {}, 2)] }), + )) as Response; + + expect(challenge.headers.get('WWW-Authenticate')).toContain( + 'scope="user:org:read applications:manage applications:read"', + ); + }); + + it('requests every scope at sign-in when no baseline is given', async () => { + const clerkMcp = create({ baselineScopes: undefined }); + + const challenge = (await clerkMcp.authenticate(request())) as Response; + + expect(challenge.headers.get('WWW-Authenticate')).toContain(`scope="${catalog.join(' ')}"`); + }); +}); + +describe('keys and credentials', () => { + const metadataRequest = () => new Request(RESOURCE_METADATA_URL); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('CLERK_PUBLISHABLE_KEY', ''); + vi.stubEnv('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', ''); + vi.stubEnv('CLERK_SECRET_KEY', ''); + vi.stubEnv('CLERK_JWT_KEY', ''); + }); + + it('are not needed to construct the server or to challenge an anonymous request', async () => { + const clerkMcp = createClerkMcpAuth({ resource: RESOURCE, scopes: ['notes:read'] }); + + const response = (await clerkMcp.authenticate(request())) as Response; + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer scope="notes:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + }); + + it.each([ + ['a missing publishable key', undefined], + ['an invalid publishable key', 'sk_test_nope'], + ])('fail loudly on %s when the metadata is first served', (_name, publishableKey) => { + const clerkMcp = createClerkMcpAuth({ resource: RESOURCE, publishableKey, verifier }); + + expect(() => clerkMcp.protectedResourceMetadata()(metadataRequest())).toThrow(/^Clerk MCP: /); + }); + + it('fail loudly on a missing secret key when a token is first verified, instead of answering a bare 500', async () => { + const clerkMcp = createClerkMcpAuth({ resource: RESOURCE, publishableKey: PUBLISHABLE_KEY }); + + await expect(clerkMcp.authenticate(request({ headers: bearer() }))).rejects.toMatchObject({ + code: 'configuration', + message: expect.stringContaining('"secretKey"'), + }); + }); + + it('fail loudly on missing client credentials when a token is first exchanged', async () => { + const clerkMcp = create({ tokenExchange: { clientId: 'client', clientSecret: '' } }); + + await expect( + clerkMcp.exchangeToken(authInfoFor(), { resource: 'https://api.example.com', scopes: ['applications:read'] }), + ).rejects.toMatchObject({ code: 'configuration' }); + }); + + it('are read from the environment on first use, not at construction', async () => { + const clerkMcp = createClerkMcpAuth({ resource: RESOURCE, verifier }); + vi.stubEnv('CLERK_PUBLISHABLE_KEY', PUBLISHABLE_KEY); + + const metadata = await clerkMcp.protectedResourceMetadata()(metadataRequest()).json(); + + expect(metadata.authorization_servers).toEqual(['https://clerk.example.com']); + }); +}); + +describe('a resource derived from the request', () => { + const derived = () => create({ resource: (req: Request) => new URL('/mcp', req.url) }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('binds tokens, challenges and metadata to the origin that was called', async () => { + const clerkMcp = derived(); + const authInfo = authInfoFor({ resource: new URL('https://mcp.example.dev/mcp') }); + verifyAccessToken.mockResolvedValue(authInfo); + + const accepted = await clerkMcp.authenticate(new Request('https://mcp.example.dev/mcp', { headers: bearer() })); + const refused = (await clerkMcp.authenticate(new Request(RESOURCE, { headers: bearer() }))) as Response; + const metadata = await clerkMcp + .protectedResourceMetadata()(new Request('https://mcp.example.dev/.well-known/oauth-protected-resource/mcp')) + .json(); + + expect(accepted).toBe(authInfo); + expect(refused.status).toBe(401); + expect(refused.headers.get('WWW-Authenticate')).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(metadata.resource).toBe('https://mcp.example.dev/mcp'); + }); + + it('fails loudly when the function returns an invalid resource', async () => { + const clerkMcp = create({ resource: () => '/mcp' }); + + await expect(clerkMcp.authenticate(request())).rejects.toMatchObject({ code: 'configuration' }); + }); +}); + +describe('authenticate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(['GET', 'HEAD', 'PUT', 'PATCH', 'DELETE'])('challenges an anonymous %s request', async method => { + const response = (await create().authenticate(request({ method }))) as Response; + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe(baselineChallenge); + expect(response.headers.get('Access-Control-Expose-Headers')).toBe('WWW-Authenticate'); + await expect(response.json()).resolves.toEqual({ error: 'unauthorized' }); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); + + it.each(['Basic credentials', 'Bearer', 'Bearer token with-spaces'])( + 'returns the sign-in challenge for a malformed authorization header: %s', + async authorization => { + const response = (await create().authenticate(request({ headers: { authorization } }))) as Response; + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe(baselineChallenge); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }, + ); + + it('attaches verified auth info for a bound token', async () => { + const authInfo = authInfoFor(); + verifyAccessToken.mockResolvedValue(authInfo); + + await expect(create().authenticate(request({ headers: bearer() }))).resolves.toBe(authInfo); + expect(verifyAccessToken).toHaveBeenCalledWith('mcp-access-token'); + }); + + it('marks an invalid token in the challenge without leaking verifier details', async () => { + verifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidToken, 'jwks kid mismatch')); + + const response = (await create().authenticate(request({ headers: bearer('expired') }))) as Response; + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer error="invalid_token", error_description="The access token is invalid.", scope="user:org:read applications:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + expect(await response.text()).not.toContain('kid'); + }); + + it.each([ + ['an unexpected verifier failure', new Error('sensitive verifier failure')], + ['a verifier server error', new OAuthError(OAuthErrorCode.ServerError, 'sensitive backend detail')], + ])('answers %s with a bare 500', async (_name, error) => { + verifyAccessToken.mockRejectedValue(error); + + const response = (await create().authenticate(request({ headers: bearer() }))) as Response; + + const text = await response.text(); + expect(response.status).toBe(500); + expect(JSON.parse(text)).toEqual({ error: 'server_error' }); + expect(text).not.toContain('sensitive'); + }); + + it.each([ + ['without an expiration', { expiresAt: undefined }], + ['with a NaN expiration', { expiresAt: Number.NaN }], + ['that has expired', { expiresAt: Math.floor(Date.now() / 1000) - 1 }], + ])('refuses a token %s', async (_name, overrides) => { + verifyAccessToken.mockResolvedValue(authInfoFor(overrides)); + + const response = (await create().authenticate(request({ headers: bearer() }))) as Response; + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toContain('error="invalid_token"'); + }); + + describe('audience binding', () => { + it.each([ + ['an unbound token', { resource: undefined }, 'The access token is not bound to a resource.'], + [ + 'a token bound to another server', + { resource: new URL('https://other.example.com/mcp') }, + 'The access token is bound to another resource.', + ], + [ + 'a token bound to another path on this origin', + { resource: new URL('https://example.com/other') }, + 'The access token is bound to another resource.', + ], + ])('refuses %s even though it verified', async (_name, overrides, description) => { + verifyAccessToken.mockResolvedValue(authInfoFor(overrides)); + + const response = (await create().authenticate(request({ headers: bearer() }))) as Response; + + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer error="invalid_token", error_description="${description}", scope="user:org:read applications:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + }); + + it('accepts a resource serialized as a string', async () => { + const authInfo = authInfoFor({ resource: RESOURCE as unknown as URL }); + verifyAccessToken.mockResolvedValue(authInfo); + + await expect(create().authenticate(request({ headers: bearer() }))).resolves.toBe(authInfo); + }); + + it('can accept unbound tokens while still refusing mismatched ones', async () => { + const clerkMcp = create({ requireResourceBinding: false }); + const unbound = authInfoFor({ resource: undefined }); + verifyAccessToken.mockResolvedValueOnce(unbound); + verifyAccessToken.mockResolvedValueOnce(authInfoFor({ resource: new URL('https://other.example.com/mcp') })); + + await expect(clerkMcp.authenticate(request({ headers: bearer() }))).resolves.toBe(unbound); + const refused = (await clerkMcp.authenticate(request({ headers: bearer() }))) as Response; + expect(refused.status).toBe(401); + }); + }); + + describe('tool scopes', () => { + it('asks anonymous tool calls for the baseline plus the tool scopes, in catalog order', async () => { + const clerkMcp = create(); + + const single = (await clerkMcp.authenticate(request({ body: toolCall('create_application') }))) as Response; + const batch = (await clerkMcp.authenticate( + request({ body: [toolCall('whoami'), toolCall('get_instance_keys', { include_secret_key: true }, 2)] }), + )) as Response; + + expect(single.headers.get('WWW-Authenticate')).toBe( + `Bearer scope="user:org:read applications:read applications:manage", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + expect(batch.headers.get('WWW-Authenticate')).toBe( + `Bearer scope="user:org:read applications:read application_secret_keys:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + }); + + it('challenges a valid token that lacks the tool scope for step-up', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + + const response = (await create().authenticate( + request({ headers: bearer(), body: toolCall('get_application') }), + )) as Response; + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toBe( + `Bearer error="insufficient_scope", error_description="Additional permissions are required for this tool.", scope="user:org:read applications:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + await expect(response.json()).resolves.toEqual({ + error: 'insufficient_scope', + error_description: 'Additional permissions are required for this tool.', + }); + }); + + it('keeps what an older token has and adds what the tool needs', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['applications:read'] })); + + const response = (await create().authenticate( + request({ headers: bearer(), body: toolCall('create_application') }), + )) as Response; + + expect(response.headers.get('WWW-Authenticate')).toContain( + 'scope="user:org:read applications:read applications:manage"', + ); + }); + + it('resolves argument-dependent scopes from the request body', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const clerkMcp = create(); + + const allowed = await clerkMcp.authenticate( + request({ headers: bearer(), body: toolCall('get_instance_keys', { include_secret_key: false }) }), + ); + const stepUp = (await clerkMcp.authenticate( + request({ headers: bearer(), body: toolCall('get_instance_keys', { include_secret_key: true }) }), + )) as Response; + + expect(allowed).not.toBeInstanceOf(Response); + expect(stepUp.status).toBe(403); + expect(stepUp.headers.get('WWW-Authenticate')).toContain( + 'scope="user:org:read applications:read application_secret_keys:read"', + ); + }); + + it('lets the body decide the scopes of a modern request', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const { headers, body } = modernToolCall('get_instance_keys', { include_secret_key: true }); + + const response = (await create().authenticate( + new Request(RESOURCE, { method: 'POST', headers: { ...headers, ...bearer() }, body }), + )) as Response; + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain('application_secret_keys:read'); + }); + + it('appends scopes outside the catalog to the challenge', async () => { + const clerkMcp = create({ tools: { ...tools, custom: () => ['custom:write'] } }); + + const response = (await clerkMcp.authenticate(request({ body: toolCall('custom') }))) as Response; + + expect(response.headers.get('WWW-Authenticate')).toContain( + 'scope="user:org:read applications:read custom:write"', + ); + }); + + it('prefers a pre-parsed body over reading the request', async () => { + const clerkMcp = create(); + + const response = (await clerkMcp.authenticate(request({ method: 'POST' }), { + parsedBody: toolCall('create_application'), + })) as Response; + + expect(response.headers.get('WWW-Authenticate')).toContain('applications:manage'); + }); + + it('leaves the request body readable after inspecting it', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const req = request({ headers: bearer(), body: toolCall('get_application') }); + + await create().authenticate(req); + + await expect(req.json()).resolves.toEqual(toolCall('get_application')); + }); + + it('skips the pre-dispatch check in tool-error mode', async () => { + const authInfo = authInfoFor({ scopes: ['user:org:read'] }); + verifyAccessToken.mockResolvedValue(authInfo); + + await expect( + create({ insufficientScope: 'tool-error' }).authenticate( + request({ headers: bearer(), body: toolCall('get_application') }), + ), + ).resolves.toBe(authInfo); + }); + }); + + it('reports every outcome to the telemetry sink and survives a broken sink', async () => { + const events: ClerkMcpTelemetryEvent[] = []; + const clerkMcp = create({ + telemetry: (event: ClerkMcpTelemetryEvent) => { + events.push(event); + throw new Error('sink failure'); + }, + }); + verifyAccessToken + .mockRejectedValueOnce(new OAuthError(OAuthErrorCode.InvalidToken, 'nope')) + .mockResolvedValueOnce(authInfoFor({ resource: undefined })) + .mockResolvedValueOnce(authInfoFor({ resource: new URL('https://other.example.com/mcp') })) + .mockResolvedValueOnce(authInfoFor({ scopes: [] })) + .mockResolvedValueOnce(authInfoFor()); + + await clerkMcp.authenticate(request()); + await clerkMcp.authenticate(request({ headers: bearer() })); + await clerkMcp.authenticate(request({ headers: bearer() })); + await clerkMcp.authenticate(request({ headers: bearer() })); + await clerkMcp.authenticate(request({ headers: bearer(), body: toolCall('get_application') })); + await clerkMcp.authenticate(request({ headers: bearer() })); + + expect(events).toEqual([ + { type: 'auth', success: false, reason: 'authentication_required' }, + { type: 'auth', success: false, reason: 'invalid_token' }, + { type: 'auth', success: false, reason: 'audience_missing' }, + { type: 'auth', success: false, reason: 'audience_mismatch' }, + { type: 'auth', success: false, reason: 'insufficient_scope' }, + { type: 'auth', success: true }, + ]); + }); +}); + +describe('a request that is authenticated twice', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('is verified and reported once, and its scopes are still checked once the body is known', async () => { + const events: ClerkMcpTelemetryEvent[] = []; + const clerkMcp = create({ telemetry: (event: ClerkMcpTelemetryEvent) => events.push(event) }); + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + const req = request({ method: 'POST', headers: bearer() }); + + const withoutBody = await clerkMcp.authenticate(req); + const again = await clerkMcp.authenticate(req); + const withBody = (await clerkMcp.authenticate(req, { parsedBody: toolCall('create_application') })) as Response; + + expect(withoutBody).not.toBeInstanceOf(Response); + expect(again).toBe(withoutBody); + expect(withBody.status).toBe(403); + expect(verifyAccessToken).toHaveBeenCalledOnce(); + expect(events).toEqual([ + { type: 'auth', success: true }, + { type: 'auth', success: false, reason: 'insufficient_scope' }, + ]); + }); +}); + +describe('requireAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('wraps a handler with the gate', async () => { + const authInfo = authInfoFor(); + verifyAccessToken.mockResolvedValue(authInfo); + const handler = vi.fn(() => Promise.resolve(Response.json({ ok: true }))); + const protectedHandler = create().requireAuth(handler); + + const refused = await protectedHandler(request()); + const served = await protectedHandler(request({ headers: bearer() })); + + expect(refused.status).toBe(401); + expect(served.status).toBe(200); + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith(expect.any(Request), authInfo); + }); +}); + +describe('withScopes', () => { + const context = (scopes: string[] | undefined) => ({ http: scopes ? { authInfo: authInfoFor({ scopes }) } : {} }); + + it('fails loudly for a tool without configured scopes', () => { + expect(() => create().withScopes('unknown' as never, () => ({ content: [] }))).toThrow(ClerkMcpError); + }); + + it('runs the tool with its arguments when the grant covers it', async () => { + const events: ClerkMcpTelemetryEvent[] = []; + const clerkMcp = create({ telemetry: (event: ClerkMcpTelemetryEvent) => events.push(event) }); + const tool = clerkMcp.withScopes('get_application', (args: { id: string }, ctx: unknown) => ({ + content: [{ type: 'text' as const, text: `${args.id}:${(ctx as { tag: string }).tag}` }], + })); + + const result = await tool({ id: 'app_1' }, { ...context(['user:org:read', 'applications:read']), tag: 'ctx' }); + + expect(result).toEqual({ content: [{ type: 'text', text: 'app_1:ctx' }] }); + expect(events).toEqual([{ type: 'tool', tool: 'get_application', durationMs: expect.any(Number), success: true }]); + }); + + it('returns a labeled permission error instead of running the tool', async () => { + const events: ClerkMcpTelemetryEvent[] = []; + const clerkMcp = create({ + scopes: [ + 'user:org:read', + 'applications:read', + { scope: 'applications:manage', label: 'Manage applications' }, + 'application_secret_keys:read', + ], + telemetry: (event: ClerkMcpTelemetryEvent) => events.push(event), + }); + const callback = vi.fn(); + const tool = clerkMcp.withScopes('create_application', callback); + + const result = await tool({}, context(['user:org:read'])); + + expect(result).toEqual({ + isError: true, + content: [ + { + type: 'text', + text: 'Permission denied. This connection is missing: Manage applications (applications:manage). Reconnect to grant access.', + }, + ], + }); + expect(callback).not.toHaveBeenCalled(); + expect(events).toEqual([ + { + type: 'tool', + tool: 'create_application', + durationMs: expect.any(Number), + success: false, + error: 'insufficient_scope', + }, + ]); + }); + + it('treats a missing auth context as no grant and reads the context of argument-less tools', async () => { + const clerkMcp = create(); + const tool = clerkMcp.withScopes('whoami', (ctx: { http?: { authInfo?: { extra?: unknown } } }) => ({ + content: [{ type: 'text' as const, text: JSON.stringify(ctx.http?.authInfo?.extra) }], + })); + + await expect(tool(context(undefined))).resolves.toMatchObject({ isError: true }); + await expect(tool(context(['user:org:read']))).resolves.toEqual({ + content: [{ type: 'text', text: '{"userId":"user_123"}' }], + }); + }); + + it('records tool errors and rethrows failures without their messages', async () => { + const events: ClerkMcpTelemetryEvent[] = []; + const clerkMcp = create({ telemetry: (event: ClerkMcpTelemetryEvent) => events.push(event) }); + const failing = clerkMcp.withScopes('whoami', (_ctx: unknown) => ({ isError: true, content: [] })); + const throwing = clerkMcp.withScopes('whoami', (_ctx: unknown) => Promise.reject(new TypeError('secret detail'))); + + await failing(context(['user:org:read'])); + await expect(throwing(context(['user:org:read']))).rejects.toThrow('secret detail'); + + expect(events.map(event => (event.type === 'tool' ? [event.success, event.error] : event))).toEqual([ + [false, undefined], + [false, 'TypeError'], + ]); + }); +}); + +describe('mcpHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function createHandler(clerkMcp = create()) { + const factory = vi.fn(() => { + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + server.registerTool( + 'get_application', + { inputSchema: z.object({ application_id: z.string() }) }, + clerkMcp.withScopes('get_application', (args, ctx) => ({ + content: [{ type: 'text', text: `${args.application_id}:${String(ctx.http?.authInfo?.extra?.userId)}` }], + })), + ); + return server; + }); + return { handler: clerkMcp.mcpHandler(factory), factory }; + } + + it('challenges anonymous requests before constructing a server', async () => { + const { handler, factory } = createHandler(); + + const response = await handler(request({ body: legacyInitialize() })); + + expect(response.status).toBe(401); + expect(factory).not.toHaveBeenCalled(); + }); + + it('serves a legacy initialize exchange for a valid token', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const { handler } = createHandler(); + + const response = await handler( + request({ + headers: { ...bearer(), accept: 'application/json, text/event-stream' }, + body: legacyInitialize(), + }), + ); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('"protocolVersion":"2025-06-18"'); + }); + + it('runs a scoped tool with the verified auth info', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const { handler } = createHandler(); + const { headers, body } = modernToolCall('get_application', { application_id: 'app_1' }); + + const response = await handler( + new Request(RESOURCE, { method: 'POST', headers: { ...headers, ...bearer() }, body }), + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { result: { content: { text: string }[] } }; + expect(payload.result.content[0]?.text).toBe('app_1:user_123'); + }); + + it('refuses a tool call that lacks its scope before dispatch', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + const { handler, factory } = createHandler(); + const { headers, body } = modernToolCall('get_application', { application_id: 'app_1' }); + + const response = await handler( + new Request(RESOURCE, { method: 'POST', headers: { ...headers, ...bearer() }, body }), + ); + + expect(response.status).toBe(403); + expect(factory).not.toHaveBeenCalled(); + }); + + it('returns a tool error instead of a challenge in tool-error mode', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + const { handler } = createHandler(create({ insufficientScope: 'tool-error' })); + const { headers, body } = modernToolCall('get_application', { application_id: 'app_1' }); + + const response = await handler( + new Request(RESOURCE, { method: 'POST', headers: { ...headers, ...bearer() }, body }), + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { result: { isError?: boolean; content: { text: string }[] } }; + expect(payload.result.isError).toBe(true); + expect(payload.result.content[0]?.text).toContain('applications:read'); + }); +}); + +describe('token exchange', () => { + it('requires configuration', async () => { + const clerkMcp = create(); + + await expect(clerkMcp.exchangeToken(authInfoFor(), { resource: 'https://api.example.com' })).rejects.toMatchObject({ + code: 'configuration', + }); + }); + + it('refuses scopes the MCP token does not carry before calling the token endpoint', async () => { + const request = vi.fn(); + const clerkMcp = create({ tokenExchange: { clientId: 'client', clientSecret: 'secret', fetch: request } }); + + await expect( + clerkMcp.exchangeToken(authInfoFor(), { resource: 'https://api.example.com', scopes: ['applications:manage'] }), + ).rejects.toMatchObject({ code: 'insufficient_scope' }); + expect(request).not.toHaveBeenCalled(); + }); + + it('exchanges the MCP token at the Clerk token endpoint', async () => { + const request = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'upstream-token', + token_type: 'Bearer', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + expires_in: 300, + scope: 'applications:read', + }), + ); + const clerkMcp = create({ tokenExchange: { clientId: 'client', clientSecret: 'secret', fetch: request } }); + + await expect( + clerkMcp.exchangeToken(authInfoFor(), { resource: 'https://api.example.com', scopes: ['applications:read'] }), + ).resolves.toMatchObject({ accessToken: 'upstream-token', scope: 'applications:read' }); + + const [url, init] = request.mock.calls[0]; + expect(url).toBe('https://clerk.example.com/oauth/token'); + expect((init?.body as URLSearchParams).get('subject_token')).toBe('mcp-access-token'); + }); +}); + +describe('metadata handlers', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('serves protected resource metadata that advertises the baseline scopes', async () => { + const clerkMcp = create({ metadata: { protectedResource: { resource_name: 'Notes' } } }); + + const response = clerkMcp.protectedResourceMetadata()(new Request(RESOURCE_METADATA_URL)); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + await expect(response.json()).resolves.toEqual({ + resource: RESOURCE, + authorization_servers: ['https://clerk.example.com'], + bearer_methods_supported: ['header'], + scopes_supported: ['user:org:read', 'applications:read'], + resource_name: 'Notes', + }); + }); + + it('relays the authorization server metadata and reuses it', async () => { + const document = { + issuer: 'https://clerk.example.com', + registration_endpoint: 'https://clerk.example.com/oauth/register', + }; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve(Response.json(document))); + const handler = create().authorizationServerMetadata(); + const url = 'https://example.com/.well-known/oauth-authorization-server'; + + const first = await handler(new Request(url)); + const second = await handler(new Request(url)); + + expect(fetchMock).toHaveBeenCalledExactlyOnceWith( + 'https://clerk.example.com/.well-known/oauth-authorization-server', + ); + expect(first.headers.get('Access-Control-Allow-Origin')).toBe('*'); + await expect(first.json()).resolves.toEqual(document); + await expect(second.json()).resolves.toEqual(document); + }); + + it('keeps serving the last document when the authorization server cannot be reached', async () => { + vi.useFakeTimers(); + const document = { issuer: 'https://clerk.example.com' }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json(document)) + .mockRejectedValueOnce(new TypeError('network unavailable')); + const handler = create().authorizationServerMetadata(); + const url = 'https://example.com/.well-known/oauth-authorization-server'; + + await handler(new Request(url)); + vi.advanceTimersByTime(3_600_001); + const stale = await handler(new Request(url)); + + expect(stale.status).toBe(200); + await expect(stale.json()).resolves.toEqual(document); + }); + + it('answers 502 when the authorization server has never been reached', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 503 })); + + const response = await create().authorizationServerMetadata()( + new Request('https://example.com/.well-known/oauth-authorization-server'), + ); + + expect(response.status).toBe(502); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); +}); diff --git a/packages/mcp-tools/src/__tests__/exports.test.ts b/packages/mcp-tools/src/__tests__/exports.test.ts new file mode 100644 index 00000000000..ba168d81d14 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/exports.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; + +import * as publicExports from '../index'; + +describe('@clerk/mcp-tools public exports', () => { + it('should not include a breaking change', () => { + expect(Object.keys(publicExports).sort()).toMatchSnapshot(); + }); +}); diff --git a/packages/mcp-tools/src/auth.ts b/packages/mcp-tools/src/auth.ts new file mode 100644 index 00000000000..3a91f53dfb4 --- /dev/null +++ b/packages/mcp-tools/src/auth.ts @@ -0,0 +1,598 @@ +import type { + AuthInfo, + CallToolResult, + CreateMcpHandlerOptions, + InputRequiredResult, + McpServerFactory, + OAuthTokenVerifier, + ServerContext, +} from '@modelcontextprotocol/server'; +import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, OAuthError } from '@modelcontextprotocol/server'; + +import { ClerkMcpError } from './errors'; +import { createTokenExchange, type ExchangedToken, type TokenExchange } from './exchange'; +import { + authorizationServerMetadataHandler, + clerkAuthorizationServerUrl, + metadataResponse, + protectedResourceMetadata, + trimTrailingSlash, +} from './metadata'; +import { + isScopeToken, + missingScopes, + orderScopes, + requestedToolCalls, + resolveToolScopes, + toolScopeLookup, + type ToolScopeMap, +} from './scopes'; +import type { ClerkMcpAuthFailureReason, ClerkMcpTelemetry, ClerkMcpTelemetryEvent } from './telemetry'; +import { createClerkOAuthTokenVerifier } from './verifier'; + +export type ScopeDefinition = string | { scope: string; label?: string }; + +export type ClerkMcpAuthOptions = { + /** + * The absolute URL of your MCP endpoint, for example `https://mcp.example.com/mcp`. Tokens must be issued for it. + * + * Pass a function to serve more than one hostname. It runs for every request this package handles, metadata + * requests included, so derive the resource from the request's origin. The returned URL decides which tokens are + * accepted: only use a function behind Host header validation. + */ + resource: string | URL | ((request: Request) => string | URL); + /** + * Derives the Clerk authorization server. Like the other keys, it is read on the first request that needs it, + * so a build step or a test can import your server without it. + * + * @default process.env.CLERK_PUBLISHABLE_KEY, then process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY + */ + publishableKey?: string; + /** + * The authorization server's origin. Replaces the one derived from `publishableKey`. + */ + authorizationServerUrl?: string; + /** + * Verifies tokens. One of `secretKey` and `jwtKey` is required unless you pass a `verifier`. + * + * @default process.env.CLERK_SECRET_KEY + */ + secretKey?: string; + /** + * The instance's JWKS public key, for networkless verification of JWT access tokens. + * + * @default process.env.CLERK_JWT_KEY + */ + jwtKey?: string; + /** + * The Clerk Backend API origin. + * + * @default process.env.CLERK_API_URL + */ + apiUrl?: string; + /** + * The Clerk Backend API version. + * + * @default process.env.CLERK_API_VERSION + */ + apiVersion?: string; + /** + * Tolerated clock difference between Clerk and this server, in milliseconds. + */ + clockSkewInMs?: number; + /** + * Every scope this server understands, with optional labels for permission errors. Sets the order of the scopes + * in challenges. Defaults to the scopes named by `baselineScopes` and `tools`. + */ + scopes?: readonly ScopeDefinition[]; + /** + * The scopes requested when a client first signs in, and advertised as `scopes_supported`. Keep it to what basic + * use needs and let the rest arrive through step-up. + * + * @default Every scope in `scopes`. + */ + baselineScopes?: readonly string[]; + /** + * The scopes each tool needs, as a list or as a function of the tool's arguments. A tool that is not listed is + * open to every valid token. + */ + tools?: TTools; + /** + * What a tool call gets when its token lacks a scope: `'challenge'` answers `403 insufficient_scope` before + * dispatch so the client can step up, `'tool-error'` lets `withScopes()` return a tool error instead. + * + * @default 'challenge' + */ + insufficientScope?: 'challenge' | 'tool-error'; + /** + * Refuse tokens that carry no audience. Tokens issued for another resource are always refused. + * + * @default true + */ + requireResourceBinding?: boolean; + /** + * Replaces Clerk token verification. + */ + verifier?: OAuthTokenVerifier; + /** + * The confidential OAuth client that represents this server at the token endpoint. Enables `exchangeToken()`. + */ + tokenExchange?: { + clientId: string; + clientSecret: string; + /** + * @default The authorization server's `/oauth/token`. + */ + tokenEndpoint?: string | URL; + fetch?: typeof fetch; + /** + * Reuse exchanged tokens until they near expiry. A cached token outlives the revocation of the token it was + * exchanged for. + * + * @default true + */ + cache?: boolean; + }; + metadata?: { + /** + * Extra RFC 9728 properties for the protected resource metadata document, such as `resource_name`. + */ + protectedResource?: Record; + }; + telemetry?: ClerkMcpTelemetry; +}; + +export type AuthenticateOptions = { + /** + * The JSON body, when a framework has already consumed the request stream. + */ + parsedBody?: unknown; +}; +export type FetchHandler = (request: Request, options?: AuthenticateOptions) => Promise; +export type AuthenticatedFetchHandler = (request: Request, authInfo: AuthInfo) => Response | Promise; +export type ToolResult = CallToolResult | InputRequiredResult; +export type ToolHandler = (...args: TArgs) => ToolResult | Promise; +export type ScopedToolHandler = (...args: TArgs) => Promise; +export type ExchangeTokenOptions = { resource: string | URL; scopes?: readonly string[] }; + +export type ClerkMcpAuth = { + /** + * The token verifier, for use with the MCP SDK's own bearer auth helpers. + */ + readonly verifier: OAuthTokenVerifier; + /** + * Authorizes a request. Resolves to the verified `AuthInfo`, or to the `401` or `403` challenge to send back. + */ + authenticate(request: Request, options?: AuthenticateOptions): Promise; + /** + * Wraps a handler so that it only runs for authorized requests. + */ + requireAuth(handler: AuthenticatedFetchHandler): FetchHandler; + /** + * Serves the RFC 9728 protected resource metadata document. + */ + protectedResourceMetadata(): (request: Request) => Response; + /** + * Relays the authorization server's RFC 8414 metadata, for clients that look for it on the MCP server's origin. + */ + authorizationServerMetadata(): (request: Request) => Promise; + /** + * Serves MCP requests. Authorizes each request, then dispatches it to a server built by `factory`. + */ + mcpHandler(factory: McpServerFactory, options?: CreateMcpHandlerOptions): FetchHandler; + /** + * Guards a tool callback with the scopes configured for `name` and reports the call to `telemetry`. + */ + withScopes( + name: K, + callback: ToolHandler, + ): ScopedToolHandler; + /** + * Exchanges the caller's token for one scoped to a downstream API (RFC 8693). Never forward the caller's own token. + */ + exchangeToken(authInfo: AuthInfo, options: ExchangeTokenOptions): Promise; +}; + +type ScopeCatalog = { scopes: string[]; labels: Record }; + +function envValue(name: string): string | undefined { + const value = (globalThis as { process?: { env?: Record } }).process?.env?.[name]; + return value || undefined; +} + +function once(create: () => T): () => T { + let value: T | undefined; + return () => (value ??= create()); +} + +function configurationError(message: string): ClerkMcpError { + return new ClerkMcpError('configuration', `Clerk MCP: ${message}`); +} + +function parseResource(resource: string | URL): URL { + let url: URL; + try { + url = new URL(resource); + } catch { + throw configurationError( + '"resource" must be the absolute URL of this MCP server, for example https://mcp.example.com/mcp.', + ); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw configurationError('"resource" must use the https or http scheme.'); + } + if (url.hash || url.href.endsWith('#')) { + throw configurationError('"resource" must not include a fragment.'); + } + if (url.search) { + throw configurationError('"resource" must not include a query string.'); + } + return url; +} + +function assertScopes(source: string, scopes: Iterable): void { + for (const scope of scopes) { + if (typeof scope !== 'string' || !isScopeToken(scope)) { + throw configurationError(`${source} contains an invalid OAuth scope: ${JSON.stringify(scope)}.`); + } + } +} + +function parseCatalog(definitions: readonly ScopeDefinition[] | undefined, fallback: Iterable): ScopeCatalog { + const catalog: ScopeCatalog = { scopes: [], labels: {} }; + const entries = definitions ?? [...fallback].map(scope => ({ scope })); + for (const definition of entries) { + const entry: { scope: string; label?: string } = + typeof definition === 'string' ? { scope: definition } : definition; + assertScopes('"scopes"', [entry.scope]); + if (!catalog.scopes.includes(entry.scope)) { + catalog.scopes.push(entry.scope); + } + if (entry.label) { + catalog.labels[entry.scope] = entry.label; + } + } + return catalog; +} + +function staticToolScopes(tools: ToolScopeMap): string[] { + return Object.values(tools).flatMap(scopes => (typeof scopes === 'function' ? [] : [...scopes])); +} + +function assertCovered(source: string, catalog: readonly string[], scopes: readonly string[]): void { + const unknown = scopes.filter(scope => !catalog.includes(scope)); + if (unknown.length) { + throw configurationError(`${source} references scopes that are not in "scopes": ${unknown.join(', ')}.`); + } +} + +function headerValue(value: string): string { + return value.replace(/["\\]/g, character => `\\${character}`); +} + +function isJsonRequest(request: Request): boolean { + const contentType = request.headers.get('content-type') ?? ''; + return request.method === 'POST' && /^application\/json\b/i.test(contentType.trim()); +} + +async function readJsonBody(request: Request): Promise { + if (!isJsonRequest(request) || request.bodyUsed) { + return undefined; + } + try { + return await request.clone().json(); + } catch { + return undefined; + } +} + +function resourceHref(value: unknown): string | undefined { + if (value instanceof URL) { + return value.href; + } + if (typeof value !== 'string') { + return undefined; + } + try { + return new URL(value).href; + } catch { + return undefined; + } +} + +/** + * Turns an MCP server built on the MCP TypeScript SDK v2 into an OAuth 2.0 resource server protected by Clerk. + * + * @example + * ```ts + * const clerkMcp = createClerkMcpAuth({ + * resource: 'https://mcp.example.com/mcp', + * baselineScopes: ['notes:read'], + * tools: { list_notes: ['notes:read'], create_note: ['notes:write'] }, + * }); + * + * export default { fetch: clerkMcp.mcpHandler(createServer) }; + * ``` + */ +export function createClerkMcpAuth>( + options: ClerkMcpAuthOptions, +): ClerkMcpAuth { + let resourceFor: (request: Request) => URL; + if (typeof options.resource === 'function') { + const resolve = options.resource; + resourceFor = request => parseResource(resolve(request)); + } else { + const resource = parseResource(options.resource); + resourceFor = () => resource; + } + + const tools = (options.tools ?? {}) as TTools; + const toolScopes = toolScopeLookup(tools); + const mode = options.insufficientScope ?? 'challenge'; + if (mode !== 'challenge' && mode !== 'tool-error') { + throw configurationError( + `"insufficientScope" must be "challenge" or "tool-error", received ${JSON.stringify(mode)}.`, + ); + } + + const catalog = parseCatalog(options.scopes, [...(options.baselineScopes ?? []), ...staticToolScopes(tools)]); + const baselineScopes = [...(options.baselineScopes ?? catalog.scopes)]; + assertScopes('"baselineScopes"', baselineScopes); + assertCovered('"baselineScopes"', catalog.scopes, baselineScopes); + for (const [name, scopes] of Object.entries(tools)) { + if (typeof scopes !== 'function') { + assertScopes(`"tools.${name}"`, scopes); + assertCovered(`"tools.${name}"`, catalog.scopes, scopes); + } + } + + const requireResourceBinding = options.requireResourceBinding ?? true; + + // Keys and credentials are resolved on first use. Builds and tests import a server without them, and other + // Clerk SDKs report a missing key on the request that needs it too. + const authorizationServerUrl = once(() => { + const publishableKey = + options.publishableKey ?? envValue('CLERK_PUBLISHABLE_KEY') ?? envValue('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY'); + if (!options.authorizationServerUrl && !publishableKey) { + throw configurationError( + 'missing "publishableKey". Pass it to createClerkMcpAuth() or set CLERK_PUBLISHABLE_KEY.', + ); + } + return trimTrailingSlash(options.authorizationServerUrl ?? clerkAuthorizationServerUrl(publishableKey as string)); + }); + const clerkVerifier = once(() => { + const secretKey = options.secretKey ?? envValue('CLERK_SECRET_KEY'); + const jwtKey = options.jwtKey ?? envValue('CLERK_JWT_KEY'); + if (!secretKey && !jwtKey) { + throw configurationError('missing "secretKey". Pass it to createClerkMcpAuth() or set CLERK_SECRET_KEY.'); + } + return createClerkOAuthTokenVerifier({ + secretKey, + jwtKey, + apiUrl: options.apiUrl ?? envValue('CLERK_API_URL'), + apiVersion: options.apiVersion ?? envValue('CLERK_API_VERSION'), + clockSkewInMs: options.clockSkewInMs, + }); + }); + const verifier: OAuthTokenVerifier = options.verifier ?? { + verifyAccessToken: async token => clerkVerifier().verifyAccessToken(token), + }; + + const emit = (event: ClerkMcpTelemetryEvent): void => { + try { + options.telemetry?.(event); + } catch { + return; + } + }; + + const exchange = once((): TokenExchange => { + if (!options.tokenExchange) { + throw configurationError('token exchange is not configured. Pass "tokenExchange" to createClerkMcpAuth().'); + } + return createTokenExchange({ + tokenEndpoint: options.tokenExchange.tokenEndpoint ?? `${authorizationServerUrl()}/oauth/token`, + clientId: options.tokenExchange.clientId, + clientSecret: options.tokenExchange.clientSecret, + fetch: options.tokenExchange.fetch, + cache: options.tokenExchange.cache, + telemetry: emit, + }); + }); + + function challenge( + status: 401 | 403, + resource: URL, + scopes: readonly string[], + error?: 'invalid_token' | 'insufficient_scope', + description?: string, + ): Response { + const parts: string[] = []; + if (error) { + parts.push(`error="${error}"`); + } + if (description) { + parts.push(`error_description="${headerValue(description)}"`); + } + if (scopes.length) { + parts.push(`scope="${scopes.join(' ')}"`); + } + parts.push(`resource_metadata="${getOAuthProtectedResourceMetadataUrl(resource)}"`); + return Response.json( + { error: error ?? 'unauthorized', ...(description ? { error_description: description } : {}) }, + { + status, + headers: { + 'WWW-Authenticate': `Bearer ${parts.join(', ')}`, + // Browser clients can only read the challenge from a cross-origin response when it is exposed. + 'Access-Control-Expose-Headers': 'WWW-Authenticate', + }, + }, + ); + } + + function refuse(reason: ClerkMcpAuthFailureReason, response: Response): Response { + emit({ type: 'auth', success: false, reason }); + return response; + } + + // `requireAuth()` mounted in front of `mcpHandler()` is how 0.x was wired. Such a request is verified once, and + // every other check still runs on both passes because only the second one may know the body. + const verified = new WeakMap(); + + async function authenticate( + request: Request, + { parsedBody }: AuthenticateOptions = {}, + ): Promise { + const resource = resourceFor(request); + const body = parsedBody === undefined ? await readJsonBody(request) : parsedBody; + const required = orderScopes( + catalog.scopes, + requestedToolCalls(request, body).flatMap(call => resolveToolScopes(toolScopes, call.name, call.arguments)), + ); + const signInScopes = orderScopes(catalog.scopes, [...baselineScopes, ...required]); + const invalidToken = (description: string) => challenge(401, resource, signInScopes, 'invalid_token', description); + + const authorization = request.headers.get('authorization'); + if (!authorization) { + return refuse('authentication_required', challenge(401, resource, signInScopes)); + } + const match = /^Bearer ([^\s]+)$/i.exec(authorization); + if (!match) { + return refuse('malformed_bearer', challenge(401, resource, signInScopes)); + } + + const alreadyVerified = verified.get(request); + let authInfo: AuthInfo; + try { + authInfo = alreadyVerified ?? (await verifier.verifyAccessToken(match[1])); + } catch (error) { + if (error instanceof ClerkMcpError && error.code === 'configuration') { + throw error; + } + if (OAuthError.isInstance(error) && error.code === 'invalid_token') { + return refuse('invalid_token', invalidToken('The access token is invalid.')); + } + return refuse('verification_error', Response.json({ error: 'server_error' }, { status: 500 })); + } + + if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) { + return refuse('missing_expiration', invalidToken('The access token has no expiration.')); + } + if (authInfo.expiresAt < Date.now() / 1000) { + return refuse('expired', invalidToken('The access token has expired.')); + } + const boundTo = resourceHref(authInfo.resource); + if (boundTo === undefined && requireResourceBinding) { + return refuse('audience_missing', invalidToken('The access token is not bound to a resource.')); + } + if (boundTo !== undefined && boundTo !== resource.href) { + return refuse('audience_mismatch', invalidToken('The access token is bound to another resource.')); + } + if (mode === 'challenge' && missingScopes(authInfo.scopes, required).length) { + return refuse( + 'insufficient_scope', + challenge( + 403, + resource, + orderScopes(catalog.scopes, [...authInfo.scopes, ...required]), + 'insufficient_scope', + 'Additional permissions are required for this tool.', + ), + ); + } + + if (!alreadyVerified) { + emit({ type: 'auth', success: true }); + verified.set(request, authInfo); + } + return authInfo; + } + + function permissionDenied(missing: readonly string[]): CallToolResult { + const permissions = missing.map(scope => (catalog.labels[scope] ? `${catalog.labels[scope]} (${scope})` : scope)); + return { + isError: true, + content: [ + { + type: 'text', + text: `Permission denied. This connection is missing: ${permissions.join(', ')}. Reconnect to grant access.`, + }, + ], + }; + } + + function withScopes( + name: K, + callback: ToolHandler, + ): ScopedToolHandler { + if (!toolScopes.has(name)) { + throw configurationError(`no scopes are configured for tool "${name}". Add it to the "tools" option.`); + } + return async (...args: TArgs) => { + const start = Date.now(); + // The SDK calls a tool with (args, ctx) when it has an input schema and with (ctx) when it has none. + const context = args[args.length - 1] as ServerContext | undefined; + const toolArgs = args.length > 1 ? args[0] : undefined; + const granted = context?.http?.authInfo?.scopes ?? []; + const missing = missingScopes(granted, resolveToolScopes(toolScopes, name, toolArgs)); + if (missing.length) { + emit({ type: 'tool', tool: name, durationMs: Date.now() - start, success: false, error: 'insufficient_scope' }); + return permissionDenied(missing); + } + try { + const result = await callback(...args); + const failed = + typeof result === 'object' && result !== null && (result as { isError?: unknown }).isError === true; + emit({ type: 'tool', tool: name, durationMs: Date.now() - start, success: !failed }); + return result; + } catch (error) { + emit({ + type: 'tool', + tool: name, + durationMs: Date.now() - start, + success: false, + error: error instanceof Error ? error.name : 'Error', + }); + throw error; + } + }; + } + + return { + verifier, + authenticate, + requireAuth: handler => async (request, authenticateOptions) => { + const result = await authenticate(request, authenticateOptions); + return result instanceof Response ? result : handler(request, result); + }, + protectedResourceMetadata: () => request => + metadataResponse( + request, + protectedResourceMetadata({ + authorizationServerUrl: authorizationServerUrl(), + resource: resourceFor(request), + scopesSupported: baselineScopes, + properties: options.metadata?.protectedResource, + }), + ), + authorizationServerMetadata: () => authorizationServerMetadataHandler(authorizationServerUrl), + mcpHandler: (factory, handlerOptions) => { + const handler = createMcpHandler(factory, handlerOptions); + return async (request, authenticateOptions) => { + const parsedBody = authenticateOptions?.parsedBody ?? (await readJsonBody(request)); + const result = await authenticate(request, { parsedBody }); + return result instanceof Response ? result : handler.fetch(request, { authInfo: result, parsedBody }); + }; + }, + withScopes, + exchangeToken: async (authInfo, { resource, scopes = [] }) => { + const requested = [...new Set(scopes)]; + const missing = missingScopes(authInfo.scopes, requested); + if (missing.length) { + throw new ClerkMcpError('insufficient_scope', `The access token does not include: ${missing.join(' ')}.`); + } + return exchange()({ subjectToken: authInfo.token, resource, scopes: requested }); + }, + }; +} diff --git a/packages/mcp-tools/src/index.ts b/packages/mcp-tools/src/index.ts new file mode 100644 index 00000000000..48897542644 --- /dev/null +++ b/packages/mcp-tools/src/index.ts @@ -0,0 +1,20 @@ +export { createClerkMcpAuth } from './auth'; +export type { + AuthenticateOptions, + AuthenticatedFetchHandler, + ClerkMcpAuth, + ClerkMcpAuthOptions, + ExchangeTokenOptions, + FetchHandler, + ScopeDefinition, + ScopedToolHandler, + ToolHandler, + ToolResult, +} from './auth'; +export { ClerkMcpError } from './errors'; +export type { ClerkMcpErrorCode } from './errors'; +export type { ExchangedToken } from './exchange'; +export type { ToolScopeMap, ToolScopes } from './scopes'; +export type { ClerkMcpAuthFailureReason, ClerkMcpTelemetry, ClerkMcpTelemetryEvent } from './telemetry'; +export { createClerkOAuthTokenVerifier } from './verifier'; +export type { ClerkOAuthTokenVerifierOptions } from './verifier'; From f32178f5f393a3453e1d34aaf3f4a1c30287b9cf Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:43 -0400 Subject: [PATCH 7/8] feat(mcp-tools): add the Hono, Express and Next.js bindings Each returns the same object with handlers in the framework's shape. mcpHandler() authorizes in every binding, and the Express binding parses JSON itself so the scope gate always sees the body. Co-Authored-By: Claude Fable 5.1 --- .../mcp-tools/src/__tests__/express.test.ts | 234 ++++++++++++++++++ packages/mcp-tools/src/__tests__/hono.test.ts | 164 ++++++++++++ packages/mcp-tools/src/__tests__/next.test.ts | 69 ++++++ packages/mcp-tools/src/express.ts | 119 +++++++++ packages/mcp-tools/src/hono.ts | 64 +++++ packages/mcp-tools/src/next.ts | 34 +++ 6 files changed, 684 insertions(+) create mode 100644 packages/mcp-tools/src/__tests__/express.test.ts create mode 100644 packages/mcp-tools/src/__tests__/hono.test.ts create mode 100644 packages/mcp-tools/src/__tests__/next.test.ts create mode 100644 packages/mcp-tools/src/express.ts create mode 100644 packages/mcp-tools/src/hono.ts create mode 100644 packages/mcp-tools/src/next.ts diff --git a/packages/mcp-tools/src/__tests__/express.test.ts b/packages/mcp-tools/src/__tests__/express.test.ts new file mode 100644 index 00000000000..3f59d381486 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/express.test.ts @@ -0,0 +1,234 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import express from 'express'; +import supertest from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { createClerkMcpAuth } from '../express'; +import { + authInfoFor, + legacyInitialize, + modernToolCall, + PUBLISHABLE_KEY, + RESOURCE, + RESOURCE_METADATA_URL, + toolCall, +} from './helpers'; + +const verifyAccessToken = vi.fn(); + +function create() { + return createClerkMcpAuth({ + resource: RESOURCE, + publishableKey: PUBLISHABLE_KEY, + verifier: { verifyAccessToken }, + scopes: ['user:org:read', 'applications:manage'], + baselineScopes: ['user:org:read'], + tools: { create_application: ['applications:manage'] }, + }); +} + +function app(clerkMcp = create()) { + const server = express(); + server.use(express.json()); + server.get('/.well-known/oauth-protected-resource/mcp', clerkMcp.protectedResourceMetadata()); + server.get('/.well-known/oauth-authorization-server', clerkMcp.authorizationServerMetadata()); + server.use('/mcp', clerkMcp.requireAuth()); + server.all('/mcp', (req, res) => { + res.json({ auth: (req as express.Request & { auth?: unknown }).auth ?? null }); + }); + return server; +} + +describe('@clerk/mcp-tools/express', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('challenges anonymous requests and skips preflight', async () => { + const server = app(); + + const refused = await supertest(server).get('/mcp'); + const preflight = await supertest(server).options('/mcp'); + + expect(refused.status).toBe(401); + expect(refused.headers['www-authenticate']).toBe( + `Bearer scope="user:org:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + expect(refused.body).toEqual({ error: 'unauthorized' }); + expect(preflight.status).toBe(200); + }); + + it('attaches verified auth info to the request', async () => { + const authInfo = authInfoFor(); + verifyAccessToken.mockResolvedValue(authInfo); + + const response = await supertest(app()).get('/mcp').set('Authorization', 'Bearer mcp-access-token'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ auth: { ...authInfo, resource: RESOURCE } }); + }); + + it('uses the parsed body for step-up challenges', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + + const response = await supertest(app()) + .post('/mcp') + .set('Authorization', 'Bearer mcp-access-token') + .send(toolCall('create_application')); + + expect(response.status).toBe(403); + expect(response.headers['www-authenticate']).toContain('scope="user:org:read applications:manage"'); + }); + + it('checks tool scopes when no body parser is mounted', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + const clerkMcp = create(); + const server = express(); + server.use('/mcp', clerkMcp.requireAuth()); + server.all('/mcp', (req, res) => { + res.json({ body: req.body }); + }); + + const refused = await supertest(server) + .post('/mcp') + .set('Authorization', 'Bearer mcp-access-token') + .send(toolCall('create_application')); + + expect(refused.status).toBe(403); + expect(refused.headers['www-authenticate']).toContain('scope="user:org:read applications:manage"'); + }); + + it('refuses a malformed JSON body instead of skipping the scope check', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + const handler = vi.fn(); + const server = express(); + server.all('/mcp', create().requireAuth(), handler); + + const response = await supertest(server) + .post('/mcp') + .set('Authorization', 'Bearer mcp-access-token') + .set('Content-Type', 'application/json') + .send('{"method":"tools/call"'); + + expect(response.status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); + + it('answers verifier failures with a bare 500', async () => { + verifyAccessToken.mockRejectedValue(new Error('sensitive')); + + const response = await supertest(app()).get('/mcp').set('Authorization', 'Bearer mcp-access-token'); + + expect(response.status).toBe(500); + expect(response.text).not.toContain('sensitive'); + }); + + it('serves the discovery documents with CORS headers', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ issuer: 'https://clerk.example.com' })); + const server = app(); + + const resource = await supertest(server).get('/.well-known/oauth-protected-resource/mcp'); + const authorizationServer = await supertest(server).get('/.well-known/oauth-authorization-server'); + + expect(resource.status).toBe(200); + expect(resource.headers['access-control-allow-origin']).toBe('*'); + expect(resource.body.resource).toBe(RESOURCE); + expect(authorizationServer.body.issuer).toBe('https://clerk.example.com'); + }); + + describe('mcpHandler', () => { + const ran = vi.fn(); + + function mcpApp() { + const clerkMcp = create(); + const server = express(); + server.all( + '/mcp', + clerkMcp.mcpHandler(() => { + const mcp = new McpServer({ name: 'test-server', version: '1.0.0' }); + mcp.registerTool( + 'create_application', + { inputSchema: z.object({}) }, + clerkMcp.withScopes('create_application', () => { + ran(); + return { content: [{ type: 'text', text: 'created' }] }; + }), + ); + return mcp; + }), + ); + return server; + } + + function callTool(server: express.Express) { + const { headers, body } = modernToolCall('create_application'); + return supertest(server) + .post('/mcp') + .set({ ...headers, Authorization: 'Bearer mcp-access-token' }) + .send(body); + } + + it('serves an MCP exchange for a valid token', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + + const response = await supertest(mcpApp()) + .post('/mcp') + .set('Authorization', 'Bearer mcp-access-token') + .set('Accept', 'application/json, text/event-stream') + .send(legacyInitialize()); + + expect(response.status).toBe(200); + expect(response.text).toContain('"protocolVersion":"2025-06-18"'); + }); + + it('verifies a request once when requireAuth() sits in front of it', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const clerkMcp = create(); + const server = express(); + server.all( + '/mcp', + clerkMcp.requireAuth(), + clerkMcp.mcpHandler(() => new McpServer({ name: 'test-server', version: '1.0.0' })), + ); + + const response = await supertest(server) + .post('/mcp') + .set('Authorization', 'Bearer mcp-access-token') + .set('Accept', 'application/json, text/event-stream') + .send(legacyInitialize()); + + expect(response.status).toBe(200); + expect(verifyAccessToken).toHaveBeenCalledOnce(); + }); + + it('challenges anonymous requests on its own', async () => { + const response = await supertest(mcpApp()).post('/mcp').send(legacyInitialize()); + + expect(response.status).toBe(401); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); + + it('refuses an underscoped tool call before dispatch, without a body parser', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + + const response = await callTool(mcpApp()); + + expect(response.status).toBe(403); + expect(ran).not.toHaveBeenCalled(); + }); + + it('runs a tool call the grant covers', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read', 'applications:manage'] })); + + const response = await callTool(mcpApp()); + + expect(response.status).toBe(200); + expect(ran).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/mcp-tools/src/__tests__/hono.test.ts b/packages/mcp-tools/src/__tests__/hono.test.ts new file mode 100644 index 00000000000..b6e49d9135d --- /dev/null +++ b/packages/mcp-tools/src/__tests__/hono.test.ts @@ -0,0 +1,164 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { Hono } from 'hono'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { createClerkMcpAuth } from '../hono'; +import { authInfoFor, legacyInitialize, PUBLISHABLE_KEY, RESOURCE, RESOURCE_METADATA_URL, toolCall } from './helpers'; + +const verifyAccessToken = vi.fn(); + +function create() { + return createClerkMcpAuth({ + resource: RESOURCE, + publishableKey: PUBLISHABLE_KEY, + verifier: { verifyAccessToken }, + scopes: ['user:org:read', 'applications:read', 'applications:manage'], + baselineScopes: ['user:org:read'], + tools: { get_application: ['applications:read'], create_application: ['applications:manage'] }, + }); +} + +function app(clerkMcp = create()) { + const hono = new Hono(); + hono.get('/.well-known/oauth-protected-resource/mcp', clerkMcp.protectedResourceMetadata()); + hono.get('/.well-known/oauth-authorization-server', clerkMcp.authorizationServerMetadata()); + hono.use('/mcp', async (c, next) => { + if (c.req.method === 'POST') { + c.set('parsedBody', await c.req.raw.clone().json()); + } + return next(); + }); + hono.use('/mcp', clerkMcp.requireAuth()); + hono.all('/mcp', c => c.json({ authInfo: c.get('authInfo') ?? null })); + return hono; +} + +describe('@clerk/mcp-tools/hono', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('challenges anonymous requests and skips preflight', async () => { + const hono = app(); + + const refused = await hono.request(RESOURCE); + const preflight = await hono.request(RESOURCE, { method: 'OPTIONS' }); + + expect(refused.status).toBe(401); + expect(refused.headers.get('WWW-Authenticate')).toBe( + `Bearer scope="user:org:read", resource_metadata="${RESOURCE_METADATA_URL}"`, + ); + expect(preflight.status).toBe(200); + expect(verifyAccessToken).not.toHaveBeenCalled(); + }); + + it('stores verified auth info on the context', async () => { + const authInfo = authInfoFor(); + verifyAccessToken.mockResolvedValue(authInfo); + + const response = await app().request(RESOURCE, { headers: { Authorization: 'Bearer mcp-access-token' } }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ authInfo: { ...authInfo, resource: RESOURCE } }); + }); + + it('uses the parsed body from the context for step-up', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read'] })); + + const response = await app().request(RESOURCE, { + method: 'POST', + headers: { Authorization: 'Bearer mcp-access-token', 'Content-Type': 'application/json' }, + body: JSON.stringify(toolCall('create_application')), + }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain('scope="user:org:read applications:manage"'); + }); + + it('verifies a request once when requireAuth() sits in front of mcpHandler()', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const clerkMcp = create(); + const hono = new Hono(); + hono.use('/mcp', clerkMcp.requireAuth()); + hono.all( + '/mcp', + clerkMcp.mcpHandler(() => new McpServer({ name: 'test-server', version: '1.0.0' })), + ); + + const response = await hono.request(RESOURCE, { + method: 'POST', + headers: { + Authorization: 'Bearer mcp-access-token', + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(legacyInitialize()), + }); + + expect(response.status).toBe(200); + expect(verifyAccessToken).toHaveBeenCalledOnce(); + }); + + it('serves both discovery documents', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ issuer: 'https://clerk.example.com' })); + const hono = app(); + + const resource = await hono.request('https://example.com/.well-known/oauth-protected-resource/mcp'); + const server = await hono.request('https://example.com/.well-known/oauth-authorization-server'); + + expect((await resource.json()).resource).toBe(RESOURCE); + expect((await server.json()).issuer).toBe('https://clerk.example.com'); + }); + + it('serves an MCP client end to end with scoped tools', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor({ scopes: ['user:org:read', 'applications:read'] })); + const clerkMcp = create(); + const hono = new Hono(); + hono.all( + '/mcp', + clerkMcp.mcpHandler(() => { + const server = new McpServer({ name: 'test-server', version: '1.0.0' }); + server.registerTool( + 'get_application', + { inputSchema: z.object({ application_id: z.string() }) }, + clerkMcp.withScopes('get_application', args => ({ + content: [{ type: 'text', text: args.application_id }], + })), + ); + server.registerTool( + 'create_application', + { inputSchema: z.object({}) }, + clerkMcp.withScopes('create_application', () => ({ content: [{ type: 'text', text: 'created' }] })), + ); + return server; + }), + ); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(new URL(RESOURCE), { + fetch: (input, init) => + Promise.resolve( + hono.request(input, { + ...init, + headers: { ...Object.fromEntries(new Headers(init?.headers)), Authorization: 'Bearer mcp-access-token' }, + }), + ), + }); + + const anonymous = await hono.request(RESOURCE, { method: 'POST', body: '{}' }); + await client.connect(transport); + const allowed = await client.callTool({ name: 'get_application', arguments: { application_id: 'app_1' } }); + const refused = client.callTool({ name: 'create_application', arguments: {} }); + + expect(anonymous.status).toBe(401); + expect(client.getProtocolEra()).toBe('modern'); + expect(allowed.content).toEqual([{ type: 'text', text: 'app_1' }]); + await expect(refused).rejects.toThrow(); + await client.close(); + }); +}); diff --git a/packages/mcp-tools/src/__tests__/next.test.ts b/packages/mcp-tools/src/__tests__/next.test.ts new file mode 100644 index 00000000000..e82aa913132 --- /dev/null +++ b/packages/mcp-tools/src/__tests__/next.test.ts @@ -0,0 +1,69 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createClerkMcpAuth } from '../next'; +import { authInfoFor, legacyInitialize, PUBLISHABLE_KEY, RESOURCE } from './helpers'; + +const verifyAccessToken = vi.fn(); + +function create(publishableKey: string | undefined = PUBLISHABLE_KEY) { + return createClerkMcpAuth({ + resource: RESOURCE, + publishableKey, + verifier: { verifyAccessToken }, + scopes: ['user:org:read'], + }); +} + +describe('@clerk/mcp-tools/next', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('gates a route handler and exposes auth info on the request', async () => { + const authInfo = authInfoFor(); + verifyAccessToken.mockResolvedValue(authInfo); + const handler = vi.fn((request: Request) => + Promise.resolve(Response.json({ auth: (request as Request & { auth?: unknown }).auth ?? null })), + ); + const GET = create().requireAuth(handler); + + const refused = await GET(new Request(RESOURCE)); + const served = await GET(new Request(RESOURCE, { headers: { Authorization: 'Bearer mcp-access-token' } })); + + expect(refused.status).toBe(401); + expect(handler).toHaveBeenCalledOnce(); + await expect(served.json()).resolves.toEqual({ auth: { ...authInfo, resource: RESOURCE } }); + }); + + it('reads the publishable key from NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', async () => { + vi.stubEnv('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', PUBLISHABLE_KEY); + + const metadata = await create(undefined) + .protectedResourceMetadata()(new Request('https://example.com/.well-known/oauth-protected-resource/mcp')) + .json(); + + expect(metadata.authorization_servers).toEqual(['https://clerk.example.com']); + }); + + it('serves MCP route handlers', async () => { + verifyAccessToken.mockResolvedValue(authInfoFor()); + const POST = create().mcpHandler(() => new McpServer({ name: 'test-server', version: '1.0.0' })); + + const response = await POST( + new Request(RESOURCE, { + method: 'POST', + headers: { + Authorization: 'Bearer mcp-access-token', + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(legacyInitialize()), + }), + ); + + expect(response.status).toBe(200); + expect(await response.text()).toContain('"protocolVersion":"2025-06-18"'); + }); +}); diff --git a/packages/mcp-tools/src/express.ts b/packages/mcp-tools/src/express.ts new file mode 100644 index 00000000000..7d7cae21daf --- /dev/null +++ b/packages/mcp-tools/src/express.ts @@ -0,0 +1,119 @@ +import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { AuthInfo, CreateMcpHandlerOptions, McpServerFactory } from '@modelcontextprotocol/server'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import type { Request as ExpressRequest, RequestHandler, Response as ExpressResponse } from 'express'; +import express from 'express'; + +import { type ClerkMcpAuth, type ClerkMcpAuthOptions, createClerkMcpAuth as createCore } from './auth'; +import type { ToolScopeMap } from './scopes'; + +export type { ClerkMcpAuthOptions } from './auth'; + +export type ClerkMcpExpressAuth = Omit< + ClerkMcpAuth, + 'requireAuth' | 'protectedResourceMetadata' | 'authorizationServerMetadata' | 'mcpHandler' +> & { + /** + * Authorizes requests to your own handlers and stores the verified `AuthInfo` as `req.auth`. + * `mcpHandler()` authorizes on its own and does not need it. + */ + requireAuth(): RequestHandler; + protectedResourceMetadata(): RequestHandler; + authorizationServerMetadata(): RequestHandler; + /** + * Serves MCP requests. Authorizes each request, then dispatches it to a server built by `factory`. + */ + mcpHandler(factory: McpServerFactory, options?: CreateMcpHandlerOptions): RequestHandler; +}; + +// One web request per Express request, so that a request passing through two of these handlers is verified once. +const webRequests = new WeakMap(); + +function toWebRequest(req: ExpressRequest): Request { + const known = webRequests.get(req); + if (known) { + return known; + } + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + value.forEach(entry => headers.append(key, entry)); + } else if (value !== undefined) { + headers.set(key, value); + } + } + const request = new Request(`${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`, { + method: req.method, + headers, + }); + webRequests.set(req, request); + return request; +} + +async function send(response: Response, res: ExpressResponse): Promise { + res.status(response.status); + response.headers.forEach((value, key) => res.setHeader(key, value)); + res.send(await response.text()); +} + +/** + * `createClerkMcpAuth()` with handlers in Express's shape. Needs `@modelcontextprotocol/node`. + */ +export function createClerkMcpAuth>( + options: ClerkMcpAuthOptions, +): ClerkMcpExpressAuth { + const core = createCore(options); + const parseJson = express.json(); + const serve = + (handler: (request: Request) => Response | Promise): RequestHandler => + (req, res, next) => { + Promise.resolve(handler(toWebRequest(req))) + .then(response => send(response, res)) + .catch(next); + }; + + const requireAuth: RequestHandler = (req, res, next) => { + if (req.method === 'OPTIONS') { + return next(); + } + const authorize = (error?: unknown) => { + if (error) { + return next(error); + } + core + .authenticate(toWebRequest(req), { parsedBody: req.body }) + .then(result => { + if (result instanceof Response) { + return send(result, res); + } + (req as ExpressRequest & { auth?: AuthInfo }).auth = result; + next(); + }) + .catch(next); + }; + // The scopes a tool call needs are read from the body, so an unparsed body would skip the scope check. + if (req.body === undefined) { + parseJson(req, res, authorize); + } else { + authorize(); + } + }; + + return { + ...core, + requireAuth: () => requireAuth, + protectedResourceMetadata: () => serve(core.protectedResourceMetadata()), + authorizationServerMetadata: () => serve(core.authorizationServerMetadata()), + mcpHandler: (factory, handlerOptions) => { + const handler = toNodeHandler(createMcpHandler(factory, handlerOptions)); + return (req, res, next) => { + requireAuth(req, res, (error?: unknown) => { + if (error) { + return next(error); + } + handler(req, res, req.body).catch(next); + }); + }; + }, + }; +} diff --git a/packages/mcp-tools/src/hono.ts b/packages/mcp-tools/src/hono.ts new file mode 100644 index 00000000000..c860f238604 --- /dev/null +++ b/packages/mcp-tools/src/hono.ts @@ -0,0 +1,64 @@ +import type { AuthInfo, CreateMcpHandlerOptions, McpServerFactory } from '@modelcontextprotocol/server'; +import type { Context, Handler, MiddlewareHandler } from 'hono'; + +import { type ClerkMcpAuth, type ClerkMcpAuthOptions, createClerkMcpAuth as createCore } from './auth'; +import type { ToolScopeMap } from './scopes'; + +export type { ClerkMcpAuthOptions } from './auth'; + +declare module 'hono' { + interface ContextVariableMap { + authInfo?: AuthInfo; + parsedBody?: unknown; + } +} + +export type ClerkMcpHonoAuth = Omit< + ClerkMcpAuth, + 'requireAuth' | 'protectedResourceMetadata' | 'authorizationServerMetadata' | 'mcpHandler' +> & { + /** + * Authorizes requests to your own handlers and stores the verified `AuthInfo` as `c.get('authInfo')`. + * `mcpHandler()` authorizes on its own and does not need it. + */ + requireAuth(): MiddlewareHandler; + protectedResourceMetadata(): Handler; + authorizationServerMetadata(): Handler; + /** + * Serves MCP requests. Authorizes each request, then dispatches it to a server built by `factory`. + */ + mcpHandler(factory: McpServerFactory, options?: CreateMcpHandlerOptions): Handler; +}; + +/** + * `createClerkMcpAuth()` with handlers in Hono's shape. Reads the body that `createMcpHonoApp()` from + * `@modelcontextprotocol/hono` parses, and falls back to reading the request. + */ +export function createClerkMcpAuth>( + options: ClerkMcpAuthOptions, +): ClerkMcpHonoAuth { + const core = createCore(options); + const protectedResource = core.protectedResourceMetadata(); + const authorizationServer = core.authorizationServerMetadata(); + + return { + ...core, + requireAuth: () => async (c, next) => { + if (c.req.method === 'OPTIONS') { + return next(); + } + const result = await core.authenticate(c.req.raw, { parsedBody: c.get('parsedBody') }); + if (result instanceof Response) { + return result; + } + c.set('authInfo', result); + await next(); + }, + protectedResourceMetadata: () => (c: Context) => protectedResource(c.req.raw), + authorizationServerMetadata: () => (c: Context) => authorizationServer(c.req.raw), + mcpHandler: (factory, handlerOptions) => { + const handler = core.mcpHandler(factory, handlerOptions); + return (c: Context) => handler(c.req.raw, { parsedBody: c.get('parsedBody') }); + }, + }; +} diff --git a/packages/mcp-tools/src/next.ts b/packages/mcp-tools/src/next.ts new file mode 100644 index 00000000000..4bf8926b698 --- /dev/null +++ b/packages/mcp-tools/src/next.ts @@ -0,0 +1,34 @@ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +import { type ClerkMcpAuth, type ClerkMcpAuthOptions, createClerkMcpAuth as createCore } from './auth'; +import type { ToolScopeMap } from './scopes'; + +export type { ClerkMcpAuthOptions } from './auth'; + +export type RouteHandler = (request: Request) => Response | Promise; + +export type ClerkMcpNextAuth = Omit, 'requireAuth'> & { + /** + * Wraps a route handler so that it only runs for authorized requests, with the verified `AuthInfo` as + * `request.auth`. `mcpHandler()` authorizes on its own and does not need it. + */ + requireAuth(handler: RouteHandler): (request: Request) => Promise; +}; + +/** + * `createClerkMcpAuth()` for Next.js route handlers. + */ +export function createClerkMcpAuth>( + options: ClerkMcpAuthOptions, +): ClerkMcpNextAuth { + const core = createCore(options); + + return { + ...core, + requireAuth: handler => + core.requireAuth((request, authInfo) => { + (request as Request & { auth?: AuthInfo }).auth = authInfo; + return handler(request); + }), + }; +} From af7fec983faed75e9ce44e1fb633a5ab666602d8 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Thu, 17 Sep 2026 12:10:44 -0400 Subject: [PATCH 8/8] docs(mcp-tools): add the README and the changeset Usage per runtime, how requests are authorized, troubleshooting, and the migration table. Co-Authored-By: Claude Fable 5.1 --- .changeset/mcp-tools-v2.md | 17 ++ packages/mcp-tools/README.md | 312 +++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 .changeset/mcp-tools-v2.md create mode 100644 packages/mcp-tools/README.md diff --git a/.changeset/mcp-tools-v2.md b/.changeset/mcp-tools-v2.md new file mode 100644 index 00000000000..e1ea4048170 --- /dev/null +++ b/.changeset/mcp-tools-v2.md @@ -0,0 +1,17 @@ +--- +'@clerk/mcp-tools': minor +--- + +Rewrite `@clerk/mcp-tools` around the MCP TypeScript SDK v2 (`@modelcontextprotocol/server`). + +`createClerkMcpAuth()` replaces the per-framework helpers and covers every resource-server concern in one place: + +- The `401` challenge with the `WWW-Authenticate` scope list and `resource_metadata` pointer, and the `403 insufficient_scope` step-up challenge driven by a per-tool scope map. Missing scopes can instead surface as a tool error through `insufficientScope: 'tool-error'`. +- Token verification through `@clerk/backend` that refuses tokens issued for another resource and populates `expiresAt` as SDK v2 requires. `resource` can be a function of the request for servers with more than one hostname. +- `mcpHandler()`, which authorizes every request before it builds a server, and `withScopes()` to guard tool callbacks at call time and record telemetry. +- RFC 9728 protected resource metadata that advertises `baselineScopes` as `scopes_supported`, and a relay of the authorization server's RFC 8414 metadata for clients that look for it on the MCP server's origin. +- `exchangeToken()`, an RFC 8693 token exchange client with a per-subject cache and stable `ClerkMcpError` codes. +- A `telemetry` sink for auth, tool and exchange events. +- Bindings for Hono (`@clerk/mcp-tools/hono`), Express (`@clerk/mcp-tools/express`), Next.js (`@clerk/mcp-tools/next`) and plain `fetch` runtimes such as Cloudflare Workers (`@clerk/mcp-tools`). + +Breaking changes: `verifyClerkToken`, `mcpAuthClerk`, `protectedResourceHandlerClerk`, `authServerMetadataHandlerClerk` and `streamableHttpHandler` are replaced by the methods on `createClerkMcpAuth()`, the resource URL is configured instead of derived from each request, and the MCP client helpers and stores are not included. diff --git a/packages/mcp-tools/README.md b/packages/mcp-tools/README.md new file mode 100644 index 00000000000..81a6221bef3 --- /dev/null +++ b/packages/mcp-tools/README.md @@ -0,0 +1,312 @@ +

+ + + + + + +
+

+ +# @clerk/mcp-tools + +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_mcp_tools) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/mcp-tools/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_mcp_tools) + +
+ +## Getting Started + +`@clerk/mcp-tools` turns an MCP server built on the [MCP TypeScript SDK v2](https://github.com/modelcontextprotocol/typescript-sdk) into an OAuth 2.0 resource server protected by Clerk: + +- The `401` challenge with the `WWW-Authenticate` scope list and `resource_metadata` pointer +- Token verification through `@clerk/backend`, with every token bound to your server +- A per-tool scope map, the `403 insufficient_scope` step-up challenge, and a call-time guard +- RFC 9728 protected resource metadata +- RFC 8693 token exchange for calling downstream APIs +- A telemetry sink + +### Prerequisites + +- `@modelcontextprotocol/server` 2.x +- Node.js `>=20.9.0`, or any runtime with `fetch` and `Request`, such as Cloudflare Workers +- **Generate access tokens as JWTs** enabled for your Clerk instance, under **OAuth applications** then **Settings**. See [Troubleshooting](#troubleshooting). + +### Installation + +```sh +npm install @clerk/mcp-tools @modelcontextprotocol/server +``` + +### Configuration + +Set your Clerk keys as environment variables, or pass them to `createClerkMcpAuth()`: + +```sh +CLERK_PUBLISHABLE_KEY=pk_**** +CLERK_SECRET_KEY=sk_**** +``` + +Keys are read on the first request that needs them, so a build step or a test can import your server without them. A missing key fails that request with a `ClerkMcpError` that names it. Invalid scopes, tools and resource URLs still fail at startup. + +On Cloudflare Workers, pass `publishableKey` and `secretKey` from `env` unless `process.env` is populated, which needs `nodejs_compat` and a compatibility date of `2025-04-01` or later. + +### Usage + +`createClerkMcpAuth()` is the single entry point. Import it from the root for plain `fetch` runtimes (Cloudflare Workers, Deno, Bun), or from a framework subpath for handlers in that framework's shape. + +```ts +import { createClerkMcpAuth } from '@clerk/mcp-tools/hono'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +const clerkMcp = createClerkMcpAuth({ + resource: 'https://mcp.example.com/mcp', + scopes: [ + { scope: 'notes:read', label: 'Read notes' }, + { scope: 'notes:write', label: 'Write notes' }, + ], + baselineScopes: ['notes:read'], + tools: { + list_notes: ['notes:read'], + create_note: ['notes:write'], + }, +}); + +function createServer() { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + + server.registerTool( + 'list_notes', + { inputSchema: z.object({}) }, + clerkMcp.withScopes('list_notes', async (_args, ctx) => { + const userId = ctx.http?.authInfo?.extra?.userId; + return { content: [{ type: 'text', text: `notes for ${userId}` }] }; + }), + ); + + return server; +} + +// Validates the Host and Origin headers and parses JSON bodies. +const app = createMcpHonoApp({ allowedHosts: ['mcp.example.com'] }); + +app.get('/.well-known/oauth-protected-resource/mcp', clerkMcp.protectedResourceMetadata()); +app.all('/mcp', clerkMcp.mcpHandler(createServer)); + +export default app; +``` + +`mcpHandler()` authorizes every request before it builds a server. `requireAuth()` is for your own routes. + +### How requests are authorized + +1. The `tools/call` messages in the request body are looked up in `tools`. +2. Without a bearer token the answer is `401`, with `scope` set to `baselineScopes` plus the scopes of the requested tools, in the order of `scopes`. +3. A token is verified, and refused when it has expired or was not issued for `resource`. +4. A token that lacks a requested tool's scopes gets `403 insufficient_scope`, listing the token's scopes plus the missing ones so the client can step up. Set `insufficientScope: 'tool-error'` to let the call through and return a tool error instead. +5. `withScopes()` checks the grant again when the tool runs, records telemetry, and returns a labeled permission error when scopes are missing. + +A tool that is not in `tools` needs only a valid token. `withScopes()` throws at startup for a tool without an entry, so register every scoped tool through it. + +`baselineScopes` keeps the first consent small and is what `scopes_supported` advertises. Tools outside the baseline are only reachable from clients that handle the `403` step-up challenge. Leave `baselineScopes` unset to request every scope at sign-in, which works with every client. + +Argument-dependent scopes are functions: + +```ts +tools: { + get_instance_keys: args => + (args as { include_secret_key?: boolean }).include_secret_key + ? ['applications:read', 'application_secret_keys:read'] + : ['applications:read'], +} +``` + +### Serving more than one hostname + +Pass a function to derive the resource from each request, for preview deployments or a server with several domains: + +```ts +const clerkMcp = createClerkMcpAuth({ + resource: request => new URL('/mcp', request.url), +}); +``` + +The function decides which tokens are accepted. Only use it behind Host header validation, such as `createMcpHonoApp({ allowedHosts })`. + +### Express + +```ts +import { createClerkMcpAuth } from '@clerk/mcp-tools/express'; +import express from 'express'; + +const clerkMcp = createClerkMcpAuth({ resource: 'https://mcp.example.com/mcp', tools: { list_notes: ['notes:read'] } }); +const app = express(); + +app.get('/.well-known/oauth-protected-resource/mcp', clerkMcp.protectedResourceMetadata()); +app.all('/mcp', clerkMcp.mcpHandler(createServer)); +``` + +The Express binding needs `@modelcontextprotocol/node`. It parses JSON bodies itself when no body parser ran. `requireAuth()` protects your own routes and exposes the verified `AuthInfo` as `req.auth`. + +### Next.js + +```ts +// app/mcp/route.ts +import { createClerkMcpAuth } from '@clerk/mcp-tools/next'; + +const clerkMcp = createClerkMcpAuth({ resource: 'https://mcp.example.com/mcp', tools: { list_notes: ['notes:read'] } }); +const handler = clerkMcp.mcpHandler(createServer); + +export { handler as GET, handler as POST, handler as DELETE }; +``` + +```ts +// app/.well-known/oauth-protected-resource/mcp/route.ts +const handler = clerkMcp.protectedResourceMetadata(); + +export { handler as GET, handler as OPTIONS }; +``` + +`requireAuth(handler)` wraps any `(request: Request) => Response` route handler and exposes the verified `AuthInfo` as `request.auth`. + +### Cloudflare Workers and plain fetch + +```ts +import { createClerkMcpAuth } from '@clerk/mcp-tools'; + +const clerkMcp = createClerkMcpAuth({ resource: 'https://mcp.example.com/mcp', tools: { list_notes: ['notes:read'] } }); +const mcp = clerkMcp.mcpHandler(createServer); +const metadata = clerkMcp.protectedResourceMetadata(); + +export default { + fetch(request: Request) { + const { pathname } = new URL(request.url); + if (pathname === '/.well-known/oauth-protected-resource/mcp') return metadata(request); + if (pathname === '/mcp') return mcp(request); + return new Response('Not found', { status: 404 }); + }, +}; +``` + +### Calling downstream APIs + +Never forward the caller's token. Configure `tokenExchange` with the OAuth client that represents your server, then exchange the caller's token for one scoped to the downstream API: + +```ts +const clerkMcp = createClerkMcpAuth({ + resource: 'https://mcp.example.com/mcp', + tools: { list_notes: ['notes:read'] }, + tokenExchange: { clientId: process.env.OAUTH_CLIENT_ID!, clientSecret: process.env.OAUTH_CLIENT_SECRET! }, +}); + +clerkMcp.withScopes('list_notes', async (_args, ctx) => { + const { accessToken } = await clerkMcp.exchangeToken(ctx.http!.authInfo!, { + resource: 'https://api.example.com', + scopes: ['notes:read'], + }); + const response = await fetch('https://api.example.com/v1/notes', { + headers: { authorization: `Bearer ${accessToken}` }, + }); + return { content: [{ type: 'text', text: await response.text() }] }; +}); +``` + +`exchangeToken()` refuses scopes the caller's token does not carry, and fails with a `ClerkMcpError` whose `code` is stable: `insufficient_scope`, `rejected`, `forbidden`, `rate_limited`, `unavailable` or `configuration`. + +Exchanged tokens are cached per subject, resource and scope set until they near expiry, so a cached token outlives the revocation of the caller's token. Set `tokenExchange.cache` to `false` to exchange on every call. + +### Browser-based clients + +Clients that run in a browser, such as the MCP Inspector, need CORS on the MCP route. Allow the `Authorization`, `Content-Type`, `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` request headers. Challenges expose `WWW-Authenticate` on their own, and the metadata handlers send their own CORS headers. + +### Clients that predate protected resource metadata + +Older clients look for `/.well-known/oauth-authorization-server` on the MCP server's origin. `clerkMcp.authorizationServerMetadata()` relays that document from Clerk and caches it for an hour, so it always matches your instance's settings, dynamic client registration included. + +### Telemetry + +```ts +const clerkMcp = createClerkMcpAuth({ + resource: 'https://mcp.example.com/mcp', + telemetry: event => console.log(JSON.stringify(event)), +}); +``` + +Events cover authentication outcomes with a failure reason, tool invocations with their duration, and token exchanges with the token endpoint's status. Tokens and secrets are never included. + +### Options + +| Option | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `resource` | The absolute URL of your MCP endpoint, or a function of the request. Tokens must be issued for it. | +| `publishableKey` | Derives the Clerk authorization server. Defaults to `CLERK_PUBLISHABLE_KEY`. | +| `authorizationServerUrl` | The authorization server's origin. Replaces the one derived from `publishableKey`. | +| `secretKey`, `jwtKey` | Used to verify tokens. Default to `CLERK_SECRET_KEY` and `CLERK_JWT_KEY`. At least one is required. | +| `apiUrl`, `apiVersion` | The Clerk Backend API origin and version. Default to `CLERK_API_URL` and `CLERK_API_VERSION`. | +| `clockSkewInMs` | Tolerated clock difference between Clerk and your server. | +| `scopes` | Every scope this server understands, with optional labels. Sets the order of challenge scopes. | +| `baselineScopes` | Scopes requested at the first sign-in and advertised as `scopes_supported`. Defaults to all of `scopes`. | +| `tools` | Scopes each tool needs, as a list or a function of the tool's arguments. | +| `insufficientScope` | `'challenge'` (default) answers `403` before dispatch, `'tool-error'` lets `withScopes()` return a tool error instead. | +| `requireResourceBinding` | Refuse tokens without an audience. Defaults to `true`. | +| `verifier` | A custom `OAuthTokenVerifier`. Replaces Clerk token verification. | +| `tokenExchange` | The OAuth client credentials used by `exchangeToken()`, an optional `tokenEndpoint`, and `cache`. | +| `metadata.protectedResource` | Extra RFC 9728 properties for the protected resource metadata document, such as `resource_name`. | +| `telemetry` | A sink for telemetry events. | + +### Troubleshooting + +**`The access token is not bound to a resource.`** The token has no audience. Either the client sent no `resource` parameter when it requested the token, or the token is opaque and the installed `@clerk/backend` does not report its audience. Enable **Generate access tokens as JWTs** for the instance. A token with several audiences also counts as unbound, and Clerk issues tokens for a single resource. `requireResourceBinding: false` accepts unbound tokens, including ones that were requested for other servers of the same instance. + +**`The access token is bound to another resource.`** The token's audience is not `resource`. Compare `resource` with the URL the client connects to, including the path and any trailing slash. + +### Migrating from 0.x + +| Before | Now | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `verifyClerkToken(auth, token)` | `createClerkMcpAuth({ ... }).verifier` or `createClerkOAuthTokenVerifier()` | +| `mcpAuthClerk` and `streamableHttpHandler(server)` | `clerkMcp.mcpHandler(() => server)`, which authorizes on its own, with a fresh server per request | +| `protectedResourceHandlerClerk(properties)` | `clerkMcp.protectedResourceMetadata()` | +| `authServerMetadataHandlerClerk` | `clerkMcp.authorizationServerMetadata()` | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/server` | + +The resource URL is now configured rather than derived from each request, and tokens are refused unless they were issued for it. The client helpers from `@clerk/mcp-tools/client` and the stores are not part of this package yet. + +## Support + +You can get in touch with us in any of the following ways: + +- Join our official community [Discord server](https://clerk.com/discord) +- Create a [GitHub Discussion](https://github.com/clerk/javascript/discussions) +- Contact options listed on [our Support page](https://clerk.com/support?utm_source=github&utm_medium=clerk_mcp_tools) + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md). + +## Security + +`@clerk/mcp-tools` follows good practices of security, but 100% security cannot be assured. + +`@clerk/mcp-tools` is provided **"as is"** without any **warranty**. Use at your own risk. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/mcp-tools/LICENSE) for more information.