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/.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/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/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
+
+
+
+[](https://clerk.com/discord)
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_mcp_tools)
+[](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.
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/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__/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/__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/__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__/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__/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__/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/__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/__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/__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/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/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/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;
+ };
+}
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/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';
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);
+ };
+}
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);
+ }),
+ };
+}
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;
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 },
+ };
+ },
+ };
+}
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