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