diff --git a/.changeset/fluffy-poems-live.md b/.changeset/fluffy-poems-live.md new file mode 100644 index 000000000000..fb9c9df41f97 --- /dev/null +++ b/.changeset/fluffy-poems-live.md @@ -0,0 +1,20 @@ +--- +'ai': patch +'@ai-sdk/mcp': major +--- + +feat(ai): add OAuth for MCP clients + refactor to new package + +This change replaces + +```ts +import { experimental_createMCPClient } from 'ai'; +import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio'; +``` + +with + +```ts +import { experimental_createMCPClient } from '@ai-sdk/mcp'; +import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; +``` diff --git a/content/cookbook/01-next/73-mcp-tools.mdx b/content/cookbook/01-next/73-mcp-tools.mdx index f6ea163630f8..694d6b87f46d 100644 --- a/content/cookbook/01-next/73-mcp-tools.mdx +++ b/content/cookbook/01-next/73-mcp-tools.mdx @@ -12,24 +12,25 @@ The AI SDK supports Model Context Protocol (MCP) tools by offering a lightweight Let's create a route handler for `/api/completion` that will generate text based on the input prompt and MCP tools that can be called at any time during a generation. The route will call the `streamText` function from the `ai` module, which will then generate text based on the input prompt and stream it to the client. -To use the `StreamableHTTPClientTransport`, you will need to install the official Typescript SDK for Model Context Protocol: +If you prefer to use the official transports (optional), install the official TypeScript SDK for Model Context Protocol: ```ts filename="app/api/completion/route.ts" -import { experimental_createMCPClient, streamText } from 'ai'; -import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio'; +import { experimental_createMCPClient, streamText } from '@ai-sdk/mcp'; +import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; import { openai } from '@ai-sdk/openai'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; +// Optional: Official transports if you prefer them +// import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; +// import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; +// import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; export async function POST(req: Request) { const { prompt }: { prompt: string } = await req.json(); try { - // Initialize an MCP client to connect to a `stdio` MCP server: - const transport = new StdioClientTransport({ + // Initialize an MCP client to connect to a `stdio` MCP server (local only): + const transport = new Experimental_StdioMCPTransport({ command: 'node', args: ['src/stdio/dist/server.js'], }); @@ -38,22 +39,40 @@ export async function POST(req: Request) { transport, }); - // You can also connect to StreamableHTTP MCP servers - const httpTransport = new StreamableHTTPClientTransport( - new URL('http://localhost:3000/mcp'), - ); + // Connect to an HTTP MCP server directly via the client transport config const httpClient = await experimental_createMCPClient({ - transport: httpTransport, + transport: { + type: 'http', + url: 'http://localhost:3000/mcp', + + // optional: configure headers + // headers: { Authorization: 'Bearer my-api-key' }, + + // optional: provide an OAuth client provider for automatic authorization + // authProvider: myOAuthClientProvider, + }, }); - // Alternatively, you can connect to a Server-Sent Events (SSE) MCP server: - const sseTransport = new SSEClientTransport( - new URL('http://localhost:3000/sse'), - ); + // Connect to a Server-Sent Events (SSE) MCP server directly via the client transport config const sseClient = await experimental_createMCPClient({ - transport: sseTransport, + transport: { + type: 'sse', + url: 'http://localhost:3000/sse', + + // optional: configure headers + // headers: { Authorization: 'Bearer my-api-key' }, + + // optional: provide an OAuth client provider for automatic authorization + // authProvider: myOAuthClientProvider, + }, }); + // Alternatively, you can create transports with the official SDKs instead of direct config: + // const httpTransport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + // const httpClient = await experimental_createMCPClient({ transport: httpTransport }); + // const sseTransport = new SSEClientTransport(new URL('http://localhost:3000/sse')); + // const sseClient = await experimental_createMCPClient({ transport: sseTransport }); + const toolSetOne = await stdioClient.tools(); const toolSetTwo = await httpClient.tools(); const toolSetThree = await sseClient.tools(); diff --git a/content/cookbook/05-node/54-mcp-tools.mdx b/content/cookbook/05-node/54-mcp-tools.mdx index 8b8bb0018aec..9f8663dbfa84 100644 --- a/content/cookbook/05-node/54-mcp-tools.mdx +++ b/content/cookbook/05-node/54-mcp-tools.mdx @@ -8,25 +8,30 @@ tags: ['node', 'tool use', 'agent', 'mcp'] The AI SDK supports Model Context Protocol (MCP) tools by offering a lightweight client that exposes a `tools` method for retrieving tools from a MCP server. After use, the client should always be closed to release resources. -Use the official Model Context Protocol Typescript SDK to create the connection to the MCP server. +If you prefer to use the official transports (optional), install the official Model Context Protocol TypeScript SDK. ```ts -import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; -import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio'; +import { + experimental_createMCPClient, + generateText, + stepCountIs, +} from '@ai-sdk/mcp'; +import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; import { openai } from '@ai-sdk/openai'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; +// Optional: Official transports if you prefer them +// import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; +// import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; +// import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; let clientOne; let clientTwo; let clientThree; try { - // Initialize an MCP client to connect to a `stdio` MCP server: - const transport = new StdioClientTransport({ + // Initialize an MCP client to connect to a `stdio` MCP server (local only): + const transport = new Experimental_StdioMCPTransport({ command: 'node', args: ['src/stdio/dist/server.js'], }); @@ -35,22 +40,40 @@ try { transport, }); - // You can also connect to StreamableHTTP MCP servers - const httpTransport = new StreamableHTTPClientTransport( - new URL('http://localhost:3000/mcp'), - ); + // Connect to an HTTP MCP server directly via the client transport config const clientTwo = await experimental_createMCPClient({ - transport: httpTransport, + transport: { + type: 'http', + url: 'http://localhost:3000/mcp', + + // optional: configure headers + // headers: { Authorization: 'Bearer my-api-key' }, + + // optional: provide an OAuth client provider for automatic authorization + // authProvider: myOAuthClientProvider, + }, }); - // Alternatively, you can connect to a Server-Sent Events (SSE) MCP server: - const sseTransport = new SSEClientTransport( - new URL('http://localhost:3000/sse'), - ); + // Connect to a Server-Sent Events (SSE) MCP server directly via the client transport config const clientThree = await experimental_createMCPClient({ - transport: sseTransport, + transport: { + type: 'sse', + url: 'http://localhost:3000/sse', + + // optional: configure headers + // headers: { Authorization: 'Bearer my-api-key' }, + + // optional: provide an OAuth client provider for automatic authorization + // authProvider: myOAuthClientProvider, + }, }); + // Alternatively, you can create transports with the official SDKs instead of direct config: + // const httpTransport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + // clientTwo = await experimental_createMCPClient({ transport: httpTransport }); + // const sseTransport = new SSEClientTransport(new URL('http://localhost:3000/sse')); + // clientThree = await experimental_createMCPClient({ transport: sseTransport }); + const toolSetOne = await clientOne.tools(); const toolSetTwo = await clientTwo.tools(); const toolSetThree = await clientThree.tools(); diff --git a/content/docs/03-ai-sdk-core/16-mcp-tools.mdx b/content/docs/03-ai-sdk-core/16-mcp-tools.mdx index 05d40b9a7910..e3095ab41672 100644 --- a/content/docs/03-ai-sdk-core/16-mcp-tools.mdx +++ b/content/docs/03-ai-sdk-core/16-mcp-tools.mdx @@ -18,16 +18,35 @@ We recommend using HTTP transport (like `StreamableHTTPClientTransport`) for pro Create an MCP client using one of the following transport options: -- **HTTP transport (Recommended)**: Use transports from MCP's official TypeScript SDK like `StreamableHTTPClientTransport` for production deployments +- **HTTP transport (Recommended)**: Either configure HTTP directly via the client using `transport: { type: 'http', ... }`, or use MCP's official TypeScript SDK `StreamableHTTPClientTransport` - SSE (Server-Sent Events): An alternative HTTP-based transport - `stdio`: For local development only. Uses standard input/output streams for local MCP servers ### HTTP Transport (Recommended) -For production deployments, we recommend using HTTP transports like `StreamableHTTPClientTransport` from MCP's official TypeScript SDK: +For production deployments, we recommend using the HTTP transport. You can configure it directly on the client: ```typescript -import { experimental_createMCPClient as createMCPClient } from 'ai'; +import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; + +const mcpClient = await createMCPClient({ + transport: { + type: 'http', + url: 'https://your-server.com/mcp', + + // optional: configure HTTP headers + headers: { Authorization: 'Bearer my-api-key' }, + + // optional: provide an OAuth client provider for automatic authorization + authProvider: myOAuthClientProvider, + }, +}); +``` + +Alternatively, you can use `StreamableHTTPClientTransport` from MCP's official TypeScript SDK: + +```typescript +import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const url = new URL('https://your-server.com/mcp'); @@ -40,20 +59,21 @@ const mcpClient = await createMCPClient({ ### SSE Transport -SSE provides an alternative HTTP-based transport option. Configure it with a `type` and `url` property: +SSE provides an alternative HTTP-based transport option. Configure it with a `type` and `url` property. You can also provide an `authProvider` for OAuth: ```typescript -import { experimental_createMCPClient as createMCPClient } from 'ai'; +import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; const mcpClient = await createMCPClient({ transport: { type: 'sse', url: 'https://my-server.com/sse', - // optional: configure HTTP headers, e.g. for authentication - headers: { - Authorization: 'Bearer my-api-key', - }, + // optional: configure HTTP headers + headers: { Authorization: 'Bearer my-api-key' }, + + // optional: provide an OAuth client provider for automatic authorization + authProvider: myOAuthClientProvider, }, }); ``` @@ -67,10 +87,10 @@ const mcpClient = await createMCPClient({ The Stdio transport can be imported from either the MCP SDK or the AI SDK: ```typescript -import { experimental_createMCPClient as createMCPClient } from 'ai'; +import { experimental_createMCPClient as createMCPClient } from '@ai-sdk/mcp'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; // Or use the AI SDK's stdio transport: -// import { Experimental_StdioMCPTransport as StdioClientTransport } from 'ai/mcp-stdio'; +// import { Experimental_StdioMCPTransport as StdioClientTransport } from '@ai-sdk/mcp/mcp-stdio'; const mcpClient = await createMCPClient({ transport: new StdioClientTransport({ @@ -87,8 +107,12 @@ You can also bring your own transport by implementing the `MCPTransport` interfa The client returned by the `experimental_createMCPClient` function is a lightweight client intended for use in tool conversion. It currently does not - support all features of the full MCP client, such as: authorization, session + support all features of the full MCP client, such as: session management, resumable streams, and receiving notifications. + +Authorization via OAuth is supported when using the AI SDK MCP HTTP or SSE +transports by providing an `authProvider`. + ### Closing the MCP Client diff --git a/content/docs/07-reference/01-ai-sdk-core/23-create-mcp-client.mdx b/content/docs/07-reference/01-ai-sdk-core/23-create-mcp-client.mdx index 9a2a19ea8d09..d7e8929403a5 100644 --- a/content/docs/07-reference/01-ai-sdk-core/23-create-mcp-client.mdx +++ b/content/docs/07-reference/01-ai-sdk-core/23-create-mcp-client.mdx @@ -14,7 +14,7 @@ This feature is experimental and may change or be removed in the future. ## Import @@ -79,11 +79,11 @@ This feature is experimental and may change or be removed in the future. ], }, { - type: 'McpSSEServerConfig', + type: 'MCPTransportConfig', parameters: [ { name: 'type', - type: "'sse'", + type: "'sse' | 'http", description: 'Use Server-Sent Events for communication', }, { @@ -98,6 +98,13 @@ This feature is experimental and may change or be removed in the future. description: 'Additional HTTP headers to be sent with requests.', }, + { + name: 'authProvider', + type: 'OAuthClientProvider', + isOptional: true, + description: + 'Optional OAuth provider for authorization to access protected remote MCP servers.', + }, ], }, ], @@ -160,8 +167,8 @@ Returns a Promise that resolves to an `MCPClient` with the following methods: ## Example ```typescript -import { experimental_createMCPClient, generateText } from 'ai'; -import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio'; +import { experimental_createMCPClient, generateText } from '@ai-sdk/mcp'; +import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; import { openai } from '@ai-sdk/openai'; let client; diff --git a/examples/mcp/package.json b/examples/mcp/package.json index 14b2806cf5f7..fcb576f6d7a9 100644 --- a/examples/mcp/package.json +++ b/examples/mcp/package.json @@ -5,6 +5,8 @@ "scripts": { "sse:server": "tsx src/sse/server.ts", "sse:client": "tsx src/sse/client.ts", + "sse-auth:server": "tsx src/mcp-with-auth/server.ts", + "sse-auth:client": "tsx src/mcp-with-auth/client.ts", "stdio:build": "tsc src/stdio/server.ts --outDir src/stdio/dist --target es2023 --module nodenext", "stdio:client": "tsx src/stdio/client.ts", "http:server": "tsx src/http/server.ts", @@ -22,6 +24,7 @@ "zod": "3.25.76" }, "devDependencies": { + "@ai-sdk/mcp": "workspace:*", "@types/express": "5.0.0", "@types/node": "20.17.24", "tsx": "4.19.2", diff --git a/examples/mcp/src/http/client.ts b/examples/mcp/src/http/client.ts index 048f576c5e93..c285089c060c 100644 --- a/examples/mcp/src/http/client.ts +++ b/examples/mcp/src/http/client.ts @@ -1,12 +1,11 @@ import { openai } from '@ai-sdk/openai'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { generateText, stepCountIs } from 'ai'; +import 'dotenv/config'; import { experimental_createMCPClient as createMCPClient, experimental_MCPClient as MCPClient, - generateText, - stepCountIs, -} from 'ai'; -import 'dotenv/config'; +} from '@ai-sdk/mcp'; async function main() { const transport = new StreamableHTTPClientTransport( diff --git a/examples/mcp/src/mcp-with-auth/client.ts b/examples/mcp/src/mcp-with-auth/client.ts new file mode 100644 index 000000000000..ae3f2168cd82 --- /dev/null +++ b/examples/mcp/src/mcp-with-auth/client.ts @@ -0,0 +1,224 @@ +import { openai } from '@ai-sdk/openai'; +import { generateText, stepCountIs } from 'ai'; + +/** + * @deprecated Use the `@ai-sdk/mcp` package instead. + * +import { experimental_createMCPClient, auth } from 'ai'; +import type { + OAuthClientProvider, + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from 'ai'; +*/ + +import { experimental_createMCPClient, auth } from '@ai-sdk/mcp'; +import 'dotenv/config'; +import type { + OAuthClientProvider, + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from '@ai-sdk/mcp'; +import { createServer } from 'node:http'; +import { exec } from 'node:child_process'; + +class InMemoryOAuthClientProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _clientInformation?: OAuthClientInformation; + private _redirectUrl: string | URL = + `http://localhost:${process.env.MCP_CALLBACK_PORT ?? 8090}/callback`; + + async tokens(): Promise { + return this._tokens; + } + async saveTokens(tokens: OAuthTokens): Promise { + this._tokens = tokens; + } + async redirectToAuthorization(authorizationUrl: URL): Promise { + const cmd = + process.platform === 'win32' + ? `start ${authorizationUrl.toString()}` + : process.platform === 'darwin' + ? `open "${authorizationUrl.toString()}"` + : `xdg-open "${authorizationUrl.toString()}"`; + exec(cmd, error => { + if (error) { + console.error( + 'Open this URL to continue:', + authorizationUrl.toString(), + ); + } + }); + } + async saveCodeVerifier(codeVerifier: string): Promise { + this._codeVerifier = codeVerifier; + } + async codeVerifier(): Promise { + if (!this._codeVerifier) throw new Error('No code verifier saved'); + return this._codeVerifier; + } + get redirectUrl(): string | URL { + return this._redirectUrl; + } + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'AI SDK MCP OAuth Example', + redirect_uris: [String(this._redirectUrl)], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + }; + } + async clientInformation(): Promise { + return this._clientInformation; + } + async saveClientInformation(info: OAuthClientInformation): Promise { + this._clientInformation = info; + } + addClientAuthentication = async ( + headers: Headers, + params: URLSearchParams, + _url: string | URL, + ): Promise => { + const info = this._clientInformation; + if (!info) { + return; + } + + const method = (info as any).token_endpoint_auth_method as + | 'client_secret_post' + | 'client_secret_basic' + | 'none' + | undefined; + + const hasSecret = Boolean((info as any).client_secret); + const clientId = info.client_id; + const clientSecret = (info as any).client_secret as string | undefined; + + // Prefer the method assigned at registration; fall back sensibly + const chosen = method ?? (hasSecret ? 'client_secret_post' : 'none'); + + if (chosen === 'client_secret_basic') { + if (!clientSecret) { + params.set('client_id', clientId); + return; + } + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64', + ); + headers.set('Authorization', `Basic ${credentials}`); + return; + } + + if (chosen === 'client_secret_post') { + params.set('client_id', clientId); + if (clientSecret) params.set('client_secret', clientSecret); + return; + } + + // none (public client) + params.set('client_id', clientId); + }; + async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier') { + if (scope === 'all' || scope === 'tokens') this._tokens = undefined; + if (scope === 'all' || scope === 'client') + this._clientInformation = undefined; + if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined; + } +} + +async function authorizeWithPkceOnce( + authProvider: OAuthClientProvider, + serverUrl: string, + waitForCode: () => Promise, +): Promise { + const result = await auth(authProvider, { serverUrl: new URL(serverUrl) }); + if (result !== 'AUTHORIZED') { + const authorizationCode = await waitForCode(); + await auth(authProvider, { + serverUrl: new URL(serverUrl), + authorizationCode, + }); + } +} + +function waitForAuthorizationCode(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (!req.url) { + res.writeHead(400).end('Bad request'); + return; + } + const url = new URL(req.url, `http://localhost:${port}`); + if (url.pathname !== '/callback') { + res.writeHead(404).end('Not found'); + return; + } + const code = url.searchParams.get('code'); + const err = url.searchParams.get('error'); + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end( + '

Authorization Successful

You can close this window.

', + ); + setTimeout(() => server.close(), 100); + resolve(code); + } else { + res + .writeHead(400) + .end(`Authorization failed: ${err ?? 'missing code'}`); + setTimeout(() => server.close(), 100); + reject(new Error(`Authorization failed: ${err ?? 'missing code'}`)); + } + }); + server.listen(port, () => { + console.log(`OAuth callback: http://localhost:${port}/callback`); + }); + }); +} + +async function main() { + const authProvider = new InMemoryOAuthClientProvider(); + const serverUrl = 'https://mcp.vercel.com/'; + + await authorizeWithPkceOnce(authProvider, serverUrl, () => + waitForAuthorizationCode(Number(8090)), + ); + + const mcpClient = await experimental_createMCPClient({ + transport: { type: 'http', url: serverUrl, authProvider }, + }); + const tools = await mcpClient.tools(); + + console.log(`Retrieved ${Object.keys(tools).length} protected tools`); + console.log(`Available tools: ${Object.keys(tools).join(', ')}`); + + const { text: answer } = await generateText({ + model: openai('gpt-4o-mini'), + tools, + stopWhen: stepCountIs(10), + onStepFinish: async ({ toolResults }) => { + if (toolResults.length > 0) { + console.log('Tool execution results:'); + toolResults.forEach(result => { + console.log( + ` - ${result.toolName}:`, + JSON.stringify(result, null, 2), + ); + }); + } + }, + system: 'You are a helpful assistant with access to protected tools.', + prompt: + 'List the tools available for me to call. Arrange them in alphabetical order.', + }); + + await mcpClient.close(); + + console.log(`FINAL ANSWER: ${answer}`); +} + +main().catch(console.error); diff --git a/examples/mcp/src/mcp-with-auth/server.ts b/examples/mcp/src/mcp-with-auth/server.ts new file mode 100644 index 000000000000..c603f849c8e0 --- /dev/null +++ b/examples/mcp/src/mcp-with-auth/server.ts @@ -0,0 +1,218 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import 'dotenv/config'; +import express from 'express'; +import { z } from 'zod'; + +const mcpServer = new McpServer({ + name: 'example-auth-server', + version: '1.0.0', +}); + +// Protected tool: requires auth +mcpServer.tool( + 'get-secret-data', + 'Retrieve protected secret data (requires authentication)', + { + secretKey: z.string(), + }, + async ({ secretKey }) => { + return { + content: [ + { + type: 'text', + text: `Secret data for key "${secretKey}": This is highly confidential information!`, + }, + ], + }; + }, +); + +// Another protected tool +mcpServer.tool( + 'list-user-resources', + 'List all resources for the authenticated user', + async () => { + return { + content: [ + { + type: 'text', + text: 'User Resources: [Resource A, Resource B, Resource C]', + }, + ], + }; + }, +); + +// Simple in-memory token store (for demo purposes) +const validTokens = new Set(['demo-access-token-123']); +const clientRegistry = new Map< + string, + { client_id: string; client_secret: string; redirect_uris: string[] } +>(); + +let transport: SSEServerTransport; + +const app = express(); + +// Middleware to check Authorization header +function requireAuth( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): void { + const authHeader = req.headers.authorization; + console.log( + `[${req.method} ${req.path}] Authorization header:`, + authHeader ? `Bearer ${authHeader.substring(7, 27)}...` : 'missing', + ); + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + console.log(' → 401: No authorization header, sending WWW-Authenticate'); + res.status(401).set({ + 'WWW-Authenticate': + 'Bearer resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"', + }); + res.send('Unauthorized'); + return; + } + + const token = authHeader.substring(7); + if (!validTokens.has(token)) { + console.log(' → 401: Invalid token'); + res.status(401).set({ + 'WWW-Authenticate': + 'Bearer error="invalid_token", resource_metadata="http://localhost:8081/.well-known/oauth-protected-resource"', + }); + res.send('Invalid token'); + return; + } + + console.log(' → ✓ Token valid, allowing access'); + next(); +} + +// OAuth 2.0 Protected Resource Metadata (RFC 9728) +app.get('/.well-known/oauth-protected-resource', (req, res) => { + res.json({ + resource: 'http://localhost:8081', + authorization_servers: ['http://localhost:8081'], + }); +}); + +// OAuth 2.0 Authorization Server Metadata (RFC 8414) +app.get('/.well-known/oauth-authorization-server', (req, res) => { + res.json({ + issuer: 'http://localhost:8081', + authorization_endpoint: 'http://localhost:8081/authorize', + token_endpoint: 'http://localhost:8081/token', + registration_endpoint: 'http://localhost:8081/register', + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + code_challenge_methods_supported: ['S256'], + }); +}); + +// Dynamic Client Registration (RFC 7591) +app.post('/register', express.json(), (req, res) => { + const clientId = `client-${Date.now()}`; + const clientSecret = `secret-${Math.random().toString(36).substring(7)}`; + + clientRegistry.set(clientId, { + client_id: clientId, + client_secret: clientSecret, + redirect_uris: req.body.redirect_uris || [], + }); + + res.json({ + client_id: clientId, + client_secret: clientSecret, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: req.body.redirect_uris || [], + }); +}); + +// Authorization endpoint (simplified for demo) +app.get('/authorize', (req, res) => { + // In a real implementation, this would show a login page + // For demo purposes, we auto-approve and redirect + const { redirect_uri, state, code_challenge } = req.query; + + // Generate a simple authorization code + const authCode = `auth-code-${Date.now()}`; + + // Store code_challenge for PKCE verification (in production, use a database) + (global as any).pendingAuthorizations = + (global as any).pendingAuthorizations || new Map(); + (global as any).pendingAuthorizations.set(authCode, { + code_challenge, + client_id: req.query.client_id, + }); + + const redirectUrl = new URL(redirect_uri as string); + redirectUrl.searchParams.set('code', authCode); + if (state) redirectUrl.searchParams.set('state', state as string); + + res.redirect(redirectUrl.toString()); +}); + +// Token endpoint +app.post('/token', express.urlencoded({ extended: true }), (req, res) => { + const { grant_type, code, code_verifier, refresh_token, client_id } = + req.body; + + if (grant_type === 'authorization_code') { + // Verify PKCE + const pending = (global as any).pendingAuthorizations?.get(code); + if (!pending) { + res.status(400).json({ error: 'invalid_grant' }); + return; + } + + // In production, verify code_challenge matches code_verifier using SHA256 + // For demo, we skip full PKCE verification + + // Issue token + const accessToken = 'demo-access-token-123'; + validTokens.add(accessToken); + + res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: `refresh-${Date.now()}`, + }); + } else if (grant_type === 'refresh_token') { + // Issue new token from refresh token + const accessToken = 'demo-access-token-123'; + validTokens.add(accessToken); + + res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + }); + } else { + res.status(400).json({ error: 'unsupported_grant_type' }); + } +}); + +// Protected MCP SSE endpoint +app.get('/sse', requireAuth, async (req, res) => { + console.log('✓ SSE connection authenticated, starting MCP transport...'); + transport = new SSEServerTransport('/messages', res); + await mcpServer.connect(transport); + console.log('✓ MCP server connected to transport'); +}); + +// Protected MCP messages endpoint +app.post('/messages', requireAuth, async (req, res) => { + await transport.handlePostMessage(req, res); +}); + +app.listen(8081, () => { + console.log('Example OAuth-protected SSE MCP server listening on port 8081'); + console.log('Authorization endpoint: http://localhost:8081/authorize'); + console.log('Token endpoint: http://localhost:8081/token'); +}); diff --git a/examples/mcp/src/sse/client.ts b/examples/mcp/src/sse/client.ts index 1da17889f46e..db2ef5935cb3 100644 --- a/examples/mcp/src/sse/client.ts +++ b/examples/mcp/src/sse/client.ts @@ -1,5 +1,7 @@ import { openai } from '@ai-sdk/openai'; -import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; +import { generateText, stepCountIs } from 'ai'; +import { experimental_createMCPClient } from '@ai-sdk/mcp'; + import 'dotenv/config'; async function main() { diff --git a/examples/mcp/src/stdio/client.ts b/examples/mcp/src/stdio/client.ts index b3f5e137e27a..935598879bd5 100644 --- a/examples/mcp/src/stdio/client.ts +++ b/examples/mcp/src/stdio/client.ts @@ -1,6 +1,7 @@ import { openai } from '@ai-sdk/openai'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; +import { generateText, stepCountIs } from 'ai'; +import { experimental_createMCPClient } from '@ai-sdk/mcp'; import 'dotenv/config'; import { z } from 'zod'; diff --git a/examples/next-openai/app/api/mcp-with-auth/route.ts b/examples/next-openai/app/api/mcp-with-auth/route.ts new file mode 100644 index 000000000000..cf12018fbbd0 --- /dev/null +++ b/examples/next-openai/app/api/mcp-with-auth/route.ts @@ -0,0 +1,263 @@ +import { openai } from '@ai-sdk/openai'; +import { + convertToModelMessages, + stepCountIs, + streamText, + createUIMessageStream, + createUIMessageStreamResponse, +} from 'ai'; +import { + experimental_createMCPClient, + auth, + type OAuthClientInformation, + type OAuthClientMetadata, + type OAuthTokens, +} from '@ai-sdk/mcp'; +import { createServer } from 'node:http'; + +type AuthGlobalState = { + pendingAuthorizationUrl: string | null; +}; + +const AUTH_GLOBAL_KEY = '__mcpAuth'; + +function getAuthState(): AuthGlobalState { + const g = globalThis as any; + if (!g[AUTH_GLOBAL_KEY]) { + g[AUTH_GLOBAL_KEY] = { + pendingAuthorizationUrl: null, + } as AuthGlobalState; + } + return g[AUTH_GLOBAL_KEY] as AuthGlobalState; +} + +function setPendingAuthorizationUrl(url: string | null): void { + getAuthState().pendingAuthorizationUrl = url; +} + +// In-memory storage for OAuth state per server origin +const oauthStateStore = new Map< + string, + { + tokens?: OAuthTokens; + codeVerifier?: string; + clientInformation?: OAuthClientInformation; + } +>(); + +class InMemoryOAuthClientProvider { + private serverOrigin: string; + private _redirectUrl: string; + + constructor(serverUrl: string | URL, callbackPort: number) { + this.serverOrigin = new URL(serverUrl).origin; + this._redirectUrl = `http://localhost:${callbackPort}/callback`; + } + + private getState() { + if (!oauthStateStore.has(this.serverOrigin)) { + oauthStateStore.set(this.serverOrigin, {}); + } + return oauthStateStore.get(this.serverOrigin)!; + } + + async tokens(): Promise { + return this.getState().tokens; + } + + async saveTokens(tokens: OAuthTokens): Promise { + this.getState().tokens = tokens; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + setPendingAuthorizationUrl(authorizationUrl.toString()); + } + + async saveCodeVerifier(codeVerifier: string): Promise { + this.getState().codeVerifier = codeVerifier; + } + + async codeVerifier(): Promise { + const verifier = this.getState().codeVerifier; + if (!verifier) throw new Error('No code verifier saved'); + return verifier; + } + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'AI SDK MCP OAuth Example (Next.js)', + redirect_uris: [String(this._redirectUrl)], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + } as any; + } + + async clientInformation(): Promise { + return this.getState().clientInformation; + } + + async saveClientInformation(info: OAuthClientInformation): Promise { + this.getState().clientInformation = info; + } + + addClientAuthentication = async ( + headers: Headers, + params: URLSearchParams, + _url: string | URL, + ): Promise => { + const info = this.getState().clientInformation; + if (!info) { + return; + } + + const method = (info as any).token_endpoint_auth_method as + | 'client_secret_post' + | 'client_secret_basic' + | 'none' + | undefined; + + const hasSecret = Boolean((info as any).client_secret); + const clientId = info.client_id; + const clientSecret = (info as any).client_secret as string | undefined; + + const chosen = method ?? (hasSecret ? 'client_secret_post' : 'none'); + + if (chosen === 'client_secret_basic') { + if (!clientSecret) { + params.set('client_id', clientId); + return; + } + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString( + 'base64', + ); + headers.set('Authorization', `Basic ${credentials}`); + return; + } + + if (chosen === 'client_secret_post') { + params.set('client_id', clientId); + if (clientSecret) params.set('client_secret', clientSecret); + return; + } + + params.set('client_id', clientId); + }; + + async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier') { + const state = this.getState(); + if (scope === 'all' || scope === 'tokens') state.tokens = undefined; + if (scope === 'all' || scope === 'client') + state.clientInformation = undefined; + if (scope === 'all' || scope === 'verifier') state.codeVerifier = undefined; + } +} + +function waitForAuthorizationCode(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (!req.url) { + res.writeHead(400).end('Bad request'); + return; + } + const url = new URL(req.url, `http://localhost:${port}`); + if (url.pathname !== '/callback') { + res.writeHead(404).end('Not found'); + return; + } + const code = url.searchParams.get('code'); + const err = url.searchParams.get('error'); + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end( + '

Authorization Successful

You can close this window.

', + ); + setTimeout(() => server.close(), 100); + resolve(code); + } else { + res + .writeHead(400) + .end(`Authorization failed: ${err ?? 'missing code'}`); + setTimeout(() => server.close(), 100); + reject(new Error(`Authorization failed: ${err ?? 'missing code'}`)); + } + }); + server.listen(port, () => { + console.log(`OAuth callback server: http://localhost:${port}/callback`); + }); + }); +} + +export async function POST(req: Request) { + const body = await req.json(); + const messages = body.messages; + const serverUrl: string = 'https://mcp.vercel.com/'; + const callbackPort = 8090; + + try { + const stream = createUIMessageStream({ + originalMessages: messages, + execute: async ({ writer }) => { + const authProvider = new InMemoryOAuthClientProvider( + serverUrl, + callbackPort, + ); + + // Attempt auth; if redirect is needed, instruct client to open URL, then wait and complete. + const result = await auth(authProvider, { + serverUrl: new URL(serverUrl), + }); + + if (result !== 'AUTHORIZED') { + const url = getAuthState().pendingAuthorizationUrl; + if (url) { + writer.write({ + type: 'data-oauth', + data: { url }, + transient: true, + }); + } + + const authorizationCode = + await waitForAuthorizationCode(callbackPort); + await auth(authProvider, { + serverUrl: new URL(serverUrl), + authorizationCode, + }); + } + + const mcpClient = await experimental_createMCPClient({ + transport: { type: 'http', url: serverUrl, authProvider }, + }); + + try { + const tools = await mcpClient.tools(); + + const result = streamText({ + model: openai('gpt-4o-mini'), + tools, + stopWhen: stepCountIs(10), + system: + 'You are a helpful assistant with access to protected tools.', + messages: convertToModelMessages(messages), + }); + + writer.merge( + result.toUIMessageStream({ originalMessages: messages }), + ); + } finally { + await mcpClient.close(); + } + }, + }); + + return createUIMessageStreamResponse({ stream }); + } catch (error) { + console.error('MCP with auth error:', error); + return Response.json({ error: 'Unexpected error' }, { status: 500 }); + } +} diff --git a/examples/next-openai/app/api/mcp-zapier/route.ts b/examples/next-openai/app/api/mcp-zapier/route.ts index 86147d393f7b..6d3fe514e6be 100644 --- a/examples/next-openai/app/api/mcp-zapier/route.ts +++ b/examples/next-openai/app/api/mcp-zapier/route.ts @@ -1,5 +1,6 @@ import { openai } from '@ai-sdk/openai'; -import { experimental_createMCPClient, stepCountIs, streamText } from 'ai'; +import { convertToModelMessages, stepCountIs, streamText } from 'ai'; +import { experimental_createMCPClient } from '@ai-sdk/mcp'; export const maxDuration = 30; @@ -8,8 +9,8 @@ export async function POST(req: Request) { const mcpClient = await experimental_createMCPClient({ transport: { - type: 'sse', - url: 'https://actions.zapier.com/mcp/[YOUR_KEY]/sse', + type: 'http', + url: 'https://mcp.zapier.com/api/mcp/s/[YOUR_SERVER_ID]/mcp', }, }); @@ -18,7 +19,7 @@ export async function POST(req: Request) { const result = streamText({ model: openai('gpt-4o'), - messages, + messages: convertToModelMessages(messages), tools: zapierTools, onFinish: async () => { await mcpClient.close(); diff --git a/examples/next-openai/app/mcp-with-auth/page.tsx b/examples/next-openai/app/mcp-with-auth/page.tsx new file mode 100644 index 000000000000..67a9a18d3d4b --- /dev/null +++ b/examples/next-openai/app/mcp-with-auth/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import ChatInput from '@/components/chat-input'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; + +export default function Chat() { + const { error, status, sendMessage, messages, regenerate, stop } = useChat({ + transport: new DefaultChatTransport({ api: '/api/mcp-with-auth' }), + onData: dataPart => { + if (dataPart.type === 'data-oauth') { + const url = (dataPart as any).data?.url as string | undefined; + if (url) { + try { + window.open(url, '_blank', 'noopener,noreferrer'); + } catch {} + } + } + }, + }); + + return ( +
+ {messages.map(m => ( +
+ {m.role === 'user' ? 'User: ' : 'AI: '} + {m.parts + .map(part => (part.type === 'text' ? part.text : '')) + .join('')} +
+ ))} + + {(status === 'submitted' || status === 'streaming') && ( +
+ {status === 'submitted' &&
Loading...
} + +
+ )} + + {error && ( +
+
An error occurred.
+ +
+ )} + + sendMessage({ text })} /> +
+ ); +} diff --git a/examples/next-openai/app/mcp/chat/route.ts b/examples/next-openai/app/mcp/chat/route.ts index f275eecb0140..dfb5c570a4ce 100644 --- a/examples/next-openai/app/mcp/chat/route.ts +++ b/examples/next-openai/app/mcp/chat/route.ts @@ -1,11 +1,7 @@ import { openai } from '@ai-sdk/openai'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { - convertToModelMessages, - experimental_createMCPClient, - stepCountIs, - streamText, -} from 'ai'; +import { convertToModelMessages, stepCountIs, streamText } from 'ai'; +import { experimental_createMCPClient } from '@ai-sdk/mcp'; export async function POST(req: Request) { const url = new URL('http://localhost:3000/mcp/server'); diff --git a/examples/next-openai/package.json b/examples/next-openai/package.json index 04a0fc96631c..0986e8461727 100644 --- a/examples/next-openai/package.json +++ b/examples/next-openai/package.json @@ -17,6 +17,7 @@ "@ai-sdk/google": "3.0.0-beta.27", "@ai-sdk/google-vertex": "4.0.0-beta.45", "@ai-sdk/groq": "3.0.0-beta.20", + "@ai-sdk/mcp": "0.0.0", "@ai-sdk/mistral": "3.0.0-beta.21", "@ai-sdk/openai": "3.0.0-beta.34", "@ai-sdk/perplexity": "3.0.0-beta.20", diff --git a/packages/ai/mcp-stdio.d.ts b/packages/ai/mcp-stdio.d.ts deleted file mode 100644 index 8e853fa3a825..000000000000 --- a/packages/ai/mcp-stdio.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/mcp-stdio'; diff --git a/packages/ai/package.json b/packages/ai/package.json index 21cd2ca5883e..50b937fcc162 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -11,7 +11,6 @@ "dist/**/*", "CHANGELOG.md", "internal.d.ts", - "mcp-stdio.d.ts", "test.d.ts" ], "scripts": { @@ -46,12 +45,6 @@ "import": "./dist/test/index.mjs", "module": "./dist/test/index.mjs", "require": "./dist/test/index.js" - }, - "./mcp-stdio": { - "types": "./dist/mcp-stdio/index.d.ts", - "import": "./dist/mcp-stdio/index.mjs", - "module": "./dist/mcp-stdio/index.mjs", - "require": "./dist/mcp-stdio/index.js" } }, "dependencies": { diff --git a/packages/ai/src/error/index.ts b/packages/ai/src/error/index.ts index f704123b8b85..73392c0797b8 100644 --- a/packages/ai/src/error/index.ts +++ b/packages/ai/src/error/index.ts @@ -17,7 +17,6 @@ export { export { InvalidArgumentError } from './invalid-argument-error'; export { InvalidStreamPartError } from './invalid-stream-part-error'; export { InvalidToolInputError } from './invalid-tool-input-error'; -export { MCPClientError } from './mcp-client-error'; export { NoImageGeneratedError } from './no-image-generated-error'; export { NoObjectGeneratedError } from './no-object-generated-error'; export { NoOutputGeneratedError } from './no-output-generated-error'; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index a0d9cab23e37..ff831f5c2f95 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -35,7 +35,6 @@ export * from './middleware'; export * from './prompt'; export * from './registry'; export * from './text-stream'; -export * from './tool'; export * from './transcribe'; export * from './types'; export * from './ui'; diff --git a/packages/ai/src/tool/index.ts b/packages/ai/src/tool/index.ts deleted file mode 100644 index 8a81ecae344c..000000000000 --- a/packages/ai/src/tool/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type { - JSONRPCError, - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, -} from './mcp/json-rpc-message'; -export { - createMCPClient as experimental_createMCPClient, - type MCPClientConfig as experimental_MCPClientConfig, - type MCPClient as experimental_MCPClient, -} from './mcp/mcp-client'; -export type { MCPTransport } from './mcp/mcp-transport'; diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index 7dedfdb70326..fcf889404b88 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -15,7 +15,6 @@ "node_modules", "tsup.config.ts", "internal.d.ts", - "mcp-stdio.d.ts", "test.d.ts" ], "references": [ diff --git a/packages/ai/tsup.config.ts b/packages/ai/tsup.config.ts index 40b286074930..d3c2ec813e04 100644 --- a/packages/ai/tsup.config.ts +++ b/packages/ai/tsup.config.ts @@ -62,21 +62,4 @@ export default defineConfig([ ), }, }, - // MCP stdio - { - entry: ['mcp-stdio/index.ts'], - outDir: 'dist/mcp-stdio', - format: ['cjs', 'esm'], - external: ['chai', 'chai/*'], - dts: true, - sourcemap: true, - target: 'es2018', - platform: 'node', - define: { - __PACKAGE_VERSION__: JSON.stringify( - (await import('./package.json', { with: { type: 'json' } })).default - .version, - ), - }, - }, ]); diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000000..1cbaafaa6938 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,73 @@ +{ + "name": "@ai-sdk/mcp", + "version": "0.0.0", + "license": "Apache-2.0", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**/*", + "CHANGELOG.md", + "internal.d.ts" + ], + "scripts": { + "build": "pnpm clean && tsup --tsconfig tsconfig.build.json", + "build:watch": "pnpm clean && tsup --watch", + "clean": "rm -rf dist *.tsbuildinfo", + "lint": "eslint \"./**/*.ts*\"", + "type-check": "tsc --build", + "prettier-check": "prettier --check \"./**/*.ts*\"", + "test": "pnpm test:node && pnpm test:edge", + "test:update": "pnpm test:node -u", + "test:watch": "vitest --config vitest.node.config.js", + "test:edge": "vitest --config vitest.edge.config.js --run", + "test:node": "vitest --config vitest.node.config.js --run" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./mcp-stdio": { + "types": "./dist/mcp-stdio/index.d.ts", + "import": "./dist/mcp-stdio/index.mjs", + "module": "./dist/mcp-stdio/index.mjs", + "require": "./dist/mcp-stdio/index.js" + } + }, + "dependencies": { + "@ai-sdk/provider": "workspace:*", + "@ai-sdk/provider-utils": "workspace:*", + "pkce-challenge": "^5.0.0" + }, + "devDependencies": { + "@ai-sdk/test-server": "workspace:*", + "@types/node": "20.17.24", + "@vercel/ai-tsconfig": "workspace:*", + "tsup": "^8", + "typescript": "5.8.3", + "zod": "3.25.76" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://ai-sdk.dev/docs", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/ai.git" + }, + "bugs": { + "url": "https://github.com/vercel/ai/issues" + }, + "keywords": ["ai", "mcp"] +} + diff --git a/packages/ai/src/error/mcp-client-error.ts b/packages/mcp/src/error/mcp-client-error.ts similarity index 100% rename from packages/ai/src/error/mcp-client-error.ts rename to packages/mcp/src/error/mcp-client-error.ts diff --git a/packages/mcp/src/error/oauth-error.ts b/packages/mcp/src/error/oauth-error.ts new file mode 100644 index 000000000000..c855096b55db --- /dev/null +++ b/packages/mcp/src/error/oauth-error.ts @@ -0,0 +1,50 @@ +import { AISDKError } from '@ai-sdk/provider'; + +const name = 'AI_MCPClientOAuthError'; +const marker = `vercel.ai.error.${name}`; +const symbol = Symbol.for(marker); + +/** + * An error occurred with the MCP client within the OAuth flow. + */ +export class MCPClientOAuthError extends AISDKError { + private readonly [symbol] = true; + + constructor({ + name = 'MCPClientOAuthError', + message, + cause, + }: { + name?: string; + message: string; + cause?: unknown; + }) { + super({ name, message, cause }); + } + + static isInstance(error: unknown): error is MCPClientOAuthError { + return AISDKError.hasMarker(error, marker); + } +} +export class ServerError extends MCPClientOAuthError { + static errorCode = 'server_error'; +} + +export class InvalidClientError extends MCPClientOAuthError { + static errorCode = 'invalid_client'; +} + +export class InvalidGrantError extends MCPClientOAuthError { + static errorCode = 'invalid_grant'; +} + +export class UnauthorizedClientError extends MCPClientOAuthError { + static errorCode = 'unauthorized_client'; +} + +export const OAUTH_ERRORS = { + [ServerError.errorCode]: ServerError, + [InvalidClientError.errorCode]: InvalidClientError, + [InvalidGrantError.errorCode]: InvalidGrantError, + [UnauthorizedClientError.errorCode]: UnauthorizedClientError, +}; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 000000000000..3463aac84753 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,20 @@ +export type { + JSONRPCError, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, +} from './tool/json-rpc-message'; +export { + createMCPClient as experimental_createMCPClient, + type MCPClientConfig as experimental_MCPClientConfig, + type MCPClient as experimental_MCPClient, +} from './tool/mcp-client'; +export { auth, UnauthorizedError } from './tool/oauth'; +export type { OAuthClientProvider } from './tool/oauth'; +export type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from './tool/oauth-types'; +export type { MCPTransport } from './tool/mcp-transport'; diff --git a/packages/mcp/src/tool/index.ts b/packages/mcp/src/tool/index.ts new file mode 100644 index 000000000000..2be81e420c6c --- /dev/null +++ b/packages/mcp/src/tool/index.ts @@ -0,0 +1,5 @@ +export * from './json-rpc-message'; +export * from './mcp-client'; +export * from './oauth'; +export * from './oauth-types'; +export * from './mcp-transport'; diff --git a/packages/ai/src/tool/mcp/json-rpc-message.ts b/packages/mcp/src/tool/json-rpc-message.ts similarity index 100% rename from packages/ai/src/tool/mcp/json-rpc-message.ts rename to packages/mcp/src/tool/json-rpc-message.ts diff --git a/packages/ai/src/tool/mcp/mcp-client.test.ts b/packages/mcp/src/tool/mcp-client.test.ts similarity index 99% rename from packages/ai/src/tool/mcp/mcp-client.test.ts rename to packages/mcp/src/tool/mcp-client.test.ts index f5b9833838f8..a9fc5d081692 100644 --- a/packages/ai/src/tool/mcp/mcp-client.test.ts +++ b/packages/mcp/src/tool/mcp-client.test.ts @@ -1,5 +1,5 @@ import { z } from 'zod/v4'; -import { MCPClientError } from '../../error/mcp-client-error'; +import { MCPClientError } from '../error/mcp-client-error'; import { createMCPClient } from './mcp-client'; import { MockMCPTransport } from './mock-mcp-transport'; import { CallToolResult } from './types'; diff --git a/packages/ai/src/tool/mcp/mcp-client.ts b/packages/mcp/src/tool/mcp-client.ts similarity index 99% rename from packages/ai/src/tool/mcp/mcp-client.ts rename to packages/mcp/src/tool/mcp-client.ts index 5581d12632d1..1cc788687f09 100644 --- a/packages/ai/src/tool/mcp/mcp-client.ts +++ b/packages/mcp/src/tool/mcp-client.ts @@ -7,7 +7,7 @@ import { ToolCallOptions, } from '@ai-sdk/provider-utils'; import { z } from 'zod/v4'; -import { MCPClientError } from '../../error/mcp-client-error'; +import { MCPClientError } from '../error/mcp-client-error'; import { JSONRPCError, JSONRPCNotification, diff --git a/packages/mcp/src/tool/mcp-http-transport.test.ts b/packages/mcp/src/tool/mcp-http-transport.test.ts new file mode 100644 index 000000000000..35fb076446d5 --- /dev/null +++ b/packages/mcp/src/tool/mcp-http-transport.test.ts @@ -0,0 +1,303 @@ +import { + createTestServer, + TestResponseController, +} from '@ai-sdk/test-server/with-vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HttpMCPTransport } from './mcp-http-transport'; +import { LATEST_PROTOCOL_VERSION } from './types'; +import { MCPClientError } from '../error/mcp-client-error'; + +describe('HttpMCPTransport', () => { + const server = createTestServer({ + 'http://localhost:4000/mcp': { + response: { + type: 'json-value', + body: { jsonrpc: '2.0', id: 1, result: { ok: true } }, + headers: { 'mcp-session-id': 'abc123' }, + }, + }, + 'http://localhost:4000/stream': {}, + }); + + let transport: HttpMCPTransport; + + beforeEach(() => { + transport = new HttpMCPTransport({ url: 'http://localhost:4000/mcp' }); + }); + + it('should POST JSON and receive JSON response', async () => { + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + method: 'initialize', + id: 1, + params: {}, + }; + + const messagePromise = new Promise(resolve => { + transport.onmessage = msg => resolve(msg); + }); + + await transport.send(message); + + const received = await messagePromise; + expect(received).toEqual({ jsonrpc: '2.0', id: 1, result: { ok: true } }); + + expect(server.calls[1].requestMethod).toBe('POST'); + expect(server.calls[1].requestHeaders).toEqual({ + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }); + }); + + it('should handle text/event-stream responses', async () => { + transport = new HttpMCPTransport({ url: 'http://localhost:4000/stream' }); + const controller = new TestResponseController(); + + // Avoid locking a single ReadableStream for both GET (start) and POST (send) + // GET from start -> 405 (no inbound SSE) + // POST send -> controlled stream with text/event-stream + server.urls['http://localhost:4000/stream'].response = ({ callNumber }) => { + switch (callNumber) { + case 0: + return { type: 'error', status: 405 }; + case 1: + return { + type: 'controlled-stream', + controller, + headers: { 'content-type': 'text/event-stream' }, + }; + default: + return { type: 'empty', status: 200 }; + } + }; + + await transport.start(); + + const msgPromise = new Promise(resolve => { + transport.onmessage = msg => resolve(msg); + }); + + const message = { + jsonrpc: '2.0' as const, + method: 'initialize', + id: 2, + params: {}, + }; + await transport.send(message); + + controller.write( + `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 2, result: { ok: true } })}\n\n`, + ); + + expect(await msgPromise).toEqual({ + jsonrpc: '2.0', + id: 2, + result: { ok: true }, + }); + }); + + it('should (re)open inbound SSE after 202 Accepted', async () => { + const controller = new TestResponseController(); + + // Call 0 (GET from start): 405 -> no inbound SSE + // Call 1 (POST send): 202 -> should trigger inbound SSE open + // Call 2 (GET after 202): controlled stream opens successfully + server.urls['http://localhost:4000/mcp'].response = ({ callNumber }) => { + switch (callNumber) { + case 0: + return { type: 'error', status: 405 }; + case 1: + return { type: 'empty', status: 202 }; + case 2: + return { type: 'controlled-stream', controller }; + default: + return { type: 'empty', status: 200 }; + } + }; + + await transport.start(); + + // POST a request that gets 202 + await transport.send({ + jsonrpc: '2.0' as const, + method: 'initialize', + id: 1, + params: {}, + }); + + expect(server.calls[2].requestMethod).toBe('GET'); + expect(server.calls[2].requestHeaders.accept).toBe('text/event-stream'); + }); + + it('should DELETE to terminate session on close when session exists', async () => { + // Call 0: GET from start returns 405 (skip SSE) + // Call 1: POST returns JSON and sets mcp-session-id header + // Call 2: DELETE on close to terminate session + server.urls['http://localhost:4000/mcp'].response = ({ callNumber }) => { + switch (callNumber) { + case 0: + return { type: 'error', status: 405 }; + case 1: + return { + type: 'json-value', + headers: { 'mcp-session-id': 'xyz-session' }, + body: { jsonrpc: '2.0', id: 1, result: { ok: true } }, + }; + case 2: + return { type: 'empty', status: 200 }; + default: + return { type: 'empty', status: 200 }; + } + }; + + await transport.start(); + await transport.send({ + jsonrpc: '2.0' as const, + method: 'initialize', + id: 1, + params: {}, + }); + + await transport.close(); + + expect(server.calls[2].requestMethod).toBe('DELETE'); + expect(server.calls[2].requestHeaders['mcp-session-id']).toBe( + 'xyz-session', + ); + }); + + it('should report HTTP errors from POST', async () => { + transport = new HttpMCPTransport({ url: 'http://localhost:4000/mcp' }); + + const controller = new TestResponseController(); + server.urls['http://localhost:4000/mcp'].response = { + type: 'controlled-stream', + controller, + headers: { 'content-type': 'text/event-stream' }, + }; + + await transport.start(); + + await new Promise(resolve => { + const check = () => { + if ( + server.calls.length > 0 && + server.calls[0].requestMethod === 'GET' + ) { + resolve(); + } else { + setTimeout(check, 0); + } + }; + check(); + }); + + // Now make POST fail + server.urls['http://localhost:4000/mcp'].response = { + type: 'error', + status: 500, + body: 'Internal Server Error', + }; + + const errorPromise = new Promise(resolve => { + transport.onerror = e => resolve(e); + }); + + await expect( + transport.send({ + jsonrpc: '2.0' as const, + method: 'test', + id: 3, + params: {}, + }), + ).rejects.toThrow('POSTing to endpoint'); + + const error = await errorPromise; + expect(error).toBeInstanceOf(MCPClientError); + expect((error as Error).message).toContain('POSTing to endpoint'); + }); + + it('should handle invalid JSON-RPC messages from inbound SSE', async () => { + const controller = new TestResponseController(); + server.urls['http://localhost:4000/mcp'].response = { + type: 'controlled-stream', + controller, + headers: { 'content-type': 'text/event-stream' }, + }; + + const errorPromise = new Promise(resolve => { + transport.onerror = e => resolve(e); + }); + + await transport.start(); + + controller.write( + `event: message\ndata: ${JSON.stringify({ foo: 'bar' })}\n\n`, + ); + + const error = await errorPromise; + expect(error).toBeInstanceOf(MCPClientError); + expect((error as Error).message).toContain('Failed to parse message'); + }); + + it('should send custom headers with all requests', async () => { + const controller = new TestResponseController(); + + const customHeaders = { + authorization: 'Bearer test-token', + 'x-custom-header': 'test-value', + } as const; + + transport = new HttpMCPTransport({ + url: 'http://localhost:4000/mcp', + headers: customHeaders as unknown as Record, + }); + + // Avoid reusing the same stream across GET (start) and POST (send) + // GET from start -> 405 (no inbound SSE) + // POST send -> JSON OK + server.urls['http://localhost:4000/mcp'].response = ({ callNumber }) => { + switch (callNumber) { + case 0: + return { type: 'error', status: 405 }; + case 1: + return { + type: 'json-value', + body: { jsonrpc: '2.0', id: 1, result: { ok: true } }, + headers: { 'content-type': 'application/json' }, + }; + default: + return { type: 'empty', status: 200 }; + } + }; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + method: 'test', + params: { foo: 'bar' }, + id: '1', + }; + + await transport.send(message); + + expect(server.calls[0].requestHeaders).toEqual({ + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, + accept: 'text/event-stream', + ...customHeaders, + }); + expect(server.calls[0].requestUserAgent).toContain('ai-sdk/'); + + expect(server.calls[1].requestHeaders).toEqual({ + 'content-type': 'application/json', + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, + accept: 'application/json, text/event-stream', + ...customHeaders, + }); + expect(server.calls[1].requestUserAgent).toContain('ai-sdk/'); + }); +}); diff --git a/packages/mcp/src/tool/mcp-http-transport.ts b/packages/mcp/src/tool/mcp-http-transport.ts new file mode 100644 index 000000000000..7599d487decf --- /dev/null +++ b/packages/mcp/src/tool/mcp-http-transport.ts @@ -0,0 +1,405 @@ +import { + EventSourceParserStream, + withUserAgentSuffix, + getRuntimeEnvironmentUserAgent, +} from '@ai-sdk/provider-utils'; +import { MCPClientError } from '../error/mcp-client-error'; +import { JSONRPCMessage, JSONRPCMessageSchema } from './json-rpc-message'; +import { MCPTransport } from './mcp-transport'; +import { VERSION } from '../version'; +import { + OAuthClientProvider, + extractResourceMetadataUrl, + UnauthorizedError, + auth, +} from './oauth'; +import { LATEST_PROTOCOL_VERSION } from './types'; + +/** + * HTTP MCP transport implementing the Streamable HTTP style. + * + * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events + * for receiving messages. + */ +export class HttpMCPTransport implements MCPTransport { + private url: URL; + private abortController?: AbortController; + private headers?: Record; + private authProvider?: OAuthClientProvider; + private resourceMetadataUrl?: URL; + private sessionId?: string; + private inboundSseConnection?: { close: () => void }; + + // Inbound SSE resumption and reconnection state + private lastInboundEventId?: string; + private inboundReconnectAttempts = 0; + private readonly reconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + } as const; + + onclose?: () => void; + onerror?: (error: unknown) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor({ + url, + headers, + authProvider, + }: { + url: string; + headers?: Record; + authProvider?: OAuthClientProvider; + }) { + this.url = new URL(url); + this.headers = headers; + this.authProvider = authProvider; + } + + private async commonHeaders( + base: Record, + ): Promise> { + const headers: Record = { + ...this.headers, + ...base, + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, + }; + + if (this.sessionId) { + headers['mcp-session-id'] = this.sessionId; + } + + if (this.authProvider) { + const tokens = await this.authProvider.tokens(); + if (tokens?.access_token) { + headers['Authorization'] = `Bearer ${tokens.access_token}`; + } + } + + return withUserAgentSuffix( + headers, + `ai-sdk/${VERSION}`, + getRuntimeEnvironmentUserAgent(), + ); + } + + async start(): Promise { + if (this.abortController) { + throw new MCPClientError({ + message: + 'MCP HTTP Transport Error: Transport already started. Note: client.connect() calls start() automatically.', + }); + } + this.abortController = new AbortController(); + + void this.openInboundSse(); + } + + async close(): Promise { + this.inboundSseConnection?.close(); + try { + if ( + this.sessionId && + this.abortController && + !this.abortController.signal.aborted + ) { + const headers = await this.commonHeaders({}); + await fetch(this.url, { + method: 'DELETE', + headers, + signal: this.abortController.signal, + }).catch(() => undefined); + } + } catch {} + + this.abortController?.abort(); + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + const attempt = async (triedAuth: boolean = false): Promise => { + try { + const headers = await this.commonHeaders({ + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }); + + const init = { + method: 'POST', + headers, + body: JSON.stringify(message), + signal: this.abortController?.signal, + } satisfies RequestInit; + + const response = await fetch(this.url, init); + + const sessionId = response.headers.get('mcp-session-id'); + if (sessionId) { + this.sessionId = sessionId; + } + + if (response.status === 401 && this.authProvider && !triedAuth) { + this.resourceMetadataUrl = extractResourceMetadataUrl(response); + try { + const result = await auth(this.authProvider, { + serverUrl: this.url, + resourceMetadataUrl: this.resourceMetadataUrl, + }); + if (result !== 'AUTHORIZED') { + const error = new UnauthorizedError(); + throw error; + } + } catch (error) { + this.onerror?.(error); + throw error; + } + return attempt(true); + } + + // If server accepted the message (e.g. initialized notification), optionally (re)start inbound SSE + if (response.status === 202) { + // If inbound SSE was not available earlier (e.g. 405 before init), try again now + // Do not await to avoid blocking send() + if (!this.inboundSseConnection) { + void this.openInboundSse(); + } + return; + } + + if (!response.ok) { + const text = await response.text().catch(() => null); + let errorMessage = `MCP HTTP Transport Error: POSTing to endpoint (HTTP ${response.status}): ${text}`; + + // 404 since this is a GET request which the server does not support + if (response.status === 404) { + errorMessage += + '. This server does not support HTTP transport. Try using `sse` transport instead'; + } + + const error = new MCPClientError({ + message: errorMessage, + }); + this.onerror?.(error); + throw error; + } + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const data = await response.json(); + const messages: JSONRPCMessage[] = Array.isArray(data) + ? data.map((m: unknown) => JSONRPCMessageSchema.parse(m)) + : [JSONRPCMessageSchema.parse(data)]; + for (const m of messages) this.onmessage?.(m); + return; + } + + if (contentType.includes('text/event-stream')) { + if (!response.body) { + const error = new MCPClientError({ + message: + 'MCP HTTP Transport Error: text/event-stream response without body', + }); + this.onerror?.(error); + throw error; + } + + const stream = response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()); + const reader = stream.getReader(); + + const processEvents = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + const { event, data } = value; + if (event === 'message') { + try { + const msg = JSONRPCMessageSchema.parse(JSON.parse(data)); + this.onmessage?.(msg); + } catch (error) { + const e = new MCPClientError({ + message: + 'MCP HTTP Transport Error: Failed to parse message', + cause: error, + }); + this.onerror?.(e); + } + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + this.onerror?.(error); + } + }; + + processEvents(); + return; + } + + const error = new MCPClientError({ + message: `MCP HTTP Transport Error: Unexpected content type: ${contentType}`, + }); + this.onerror?.(error); + throw error; + } catch (error) { + this.onerror?.(error); + throw error; + } + }; + + await attempt(); + } + + private getNextReconnectionDelay(attempt: number): number { + const { + initialReconnectionDelay, + reconnectionDelayGrowFactor, + maxReconnectionDelay, + } = this.reconnectionOptions; + return Math.min( + initialReconnectionDelay * Math.pow(reconnectionDelayGrowFactor, attempt), + maxReconnectionDelay, + ); + } + + private scheduleInboundSseReconnection(): void { + const { maxRetries } = this.reconnectionOptions; + if (maxRetries > 0 && this.inboundReconnectAttempts >= maxRetries) { + this.onerror?.( + new MCPClientError({ + message: `MCP HTTP Transport Error: Maximum reconnection attempts (${maxRetries}) exceeded.`, + }), + ); + return; + } + + const delay = this.getNextReconnectionDelay(this.inboundReconnectAttempts); + this.inboundReconnectAttempts += 1; + setTimeout(async () => { + if (this.abortController?.signal.aborted) return; + await this.openInboundSse(false, this.lastInboundEventId); + }, delay); + } + + // Open optional inbound SSE stream; best-effort and resumable + private async openInboundSse( + triedAuth: boolean = false, + resumeToken?: string, + ): Promise { + try { + const headers = await this.commonHeaders({ + Accept: 'text/event-stream', + }); + if (resumeToken) { + headers['last-event-id'] = resumeToken; + } + + const response = await fetch(this.url.href, { + method: 'GET', + headers, + signal: this.abortController?.signal, + }); + + const sessionId = response.headers.get('mcp-session-id'); + if (sessionId) { + this.sessionId = sessionId; + } + + if (response.status === 401 && this.authProvider && !triedAuth) { + this.resourceMetadataUrl = extractResourceMetadataUrl(response); + try { + const result = await auth(this.authProvider, { + serverUrl: this.url, + resourceMetadataUrl: this.resourceMetadataUrl, + }); + if (result !== 'AUTHORIZED') { + const error = new UnauthorizedError(); + this.onerror?.(error); + return; + } + } catch (error) { + this.onerror?.(error); + return; + } + return this.openInboundSse(true, resumeToken); + } + + if (response.status === 405) { + return; + } + + if (!response.ok || !response.body) { + const error = new MCPClientError({ + message: `MCP HTTP Transport Error: GET SSE failed: ${response.status} ${response.statusText}`, + }); + this.onerror?.(error); + return; + } + + const stream = response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()); + const reader = stream.getReader(); + + const processEvents = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + const { event, data, id } = value as { + event?: string; + data: string; + id?: string; + }; + + if (id) { + this.lastInboundEventId = id; + } + + if (event === 'message') { + try { + const msg = JSONRPCMessageSchema.parse(JSON.parse(data)); + this.onmessage?.(msg); + } catch (error) { + const e = new MCPClientError({ + message: 'MCP HTTP Transport Error: Failed to parse message', + cause: error, + }); + this.onerror?.(e); + } + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + this.onerror?.(error); + if (!this.abortController?.signal.aborted) { + this.scheduleInboundSseReconnection(); + } + } + }; + + this.inboundSseConnection = { + close: () => reader.cancel(), + }; + this.inboundReconnectAttempts = 0; + processEvents(); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + this.onerror?.(error); + if (!this.abortController?.signal.aborted) { + this.scheduleInboundSseReconnection(); + } + } + } +} diff --git a/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts b/packages/mcp/src/tool/mcp-sse-transport.test.ts similarity index 96% rename from packages/ai/src/tool/mcp/mcp-sse-transport.test.ts rename to packages/mcp/src/tool/mcp-sse-transport.test.ts index 4fafb87718e4..9efaedc3c32c 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts +++ b/packages/mcp/src/tool/mcp-sse-transport.test.ts @@ -2,9 +2,10 @@ import { createTestServer, TestResponseController, } from '@ai-sdk/test-server/with-vitest'; -import { MCPClientError } from '../../error/mcp-client-error'; +import { MCPClientError } from '../error/mcp-client-error'; import { SseMCPTransport } from './mcp-sse-transport'; import { beforeEach, describe, expect, it } from 'vitest'; +import { LATEST_PROTOCOL_VERSION } from './types'; describe('SseMCPTransport', () => { const server = createTestServer({ @@ -51,6 +52,7 @@ describe('SseMCPTransport', () => { expect(server.calls[0].requestMethod).toBe('GET'); expect(server.calls[0].requestUrl).toBe('http://localhost:3000/sse'); expect(server.calls[0].requestHeaders).toEqual({ + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, accept: 'text/event-stream', }); }); @@ -266,6 +268,7 @@ describe('SseMCPTransport', () => { // Verify SSE connection headers expect(server.calls[0].requestHeaders).toEqual({ + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, accept: 'text/event-stream', ...customHeaders, }); @@ -274,6 +277,7 @@ describe('SseMCPTransport', () => { // Verify POST request headers expect(server.calls[1].requestHeaders).toEqual({ 'content-type': 'application/json', + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, ...customHeaders, }); expect(server.calls[1].requestUserAgent).toContain('ai-sdk/'); diff --git a/packages/ai/src/tool/mcp/mcp-sse-transport.ts b/packages/mcp/src/tool/mcp-sse-transport.ts similarity index 54% rename from packages/ai/src/tool/mcp/mcp-sse-transport.ts rename to packages/mcp/src/tool/mcp-sse-transport.ts index 5ca2be0b93f4..fb437572186c 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.ts +++ b/packages/mcp/src/tool/mcp-sse-transport.ts @@ -3,10 +3,17 @@ import { withUserAgentSuffix, getRuntimeEnvironmentUserAgent, } from '@ai-sdk/provider-utils'; -import { MCPClientError } from '../../error/mcp-client-error'; +import { MCPClientError } from '../error/mcp-client-error'; import { JSONRPCMessage, JSONRPCMessageSchema } from './json-rpc-message'; import { MCPTransport } from './mcp-transport'; -import { VERSION } from '../../version'; +import { VERSION } from '../version'; +import { + OAuthClientProvider, + extractResourceMetadataUrl, + UnauthorizedError, + auth, +} from './oauth'; +import { LATEST_PROTOCOL_VERSION } from './types'; export class SseMCPTransport implements MCPTransport { private endpoint?: URL; @@ -17,6 +24,8 @@ export class SseMCPTransport implements MCPTransport { close: () => void; }; private headers?: Record; + private authProvider?: OAuthClientProvider; + private resourceMetadataUrl?: URL; onclose?: () => void; onerror?: (error: unknown) => void; @@ -25,12 +34,38 @@ export class SseMCPTransport implements MCPTransport { constructor({ url, headers, + authProvider, }: { url: string; headers?: Record; + authProvider?: OAuthClientProvider; }) { this.url = new URL(url); this.headers = headers; + this.authProvider = authProvider; + } + + private async commonHeaders( + base: Record, + ): Promise> { + const headers: Record = { + ...this.headers, + ...base, + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, + }; + + if (this.authProvider) { + const tokens = await this.authProvider.tokens(); + if (tokens?.access_token) { + headers['Authorization'] = `Bearer ${tokens.access_token}`; + } + } + + return withUserAgentSuffix( + headers, + `ai-sdk/${VERSION}`, + getRuntimeEnvironmentUserAgent(), + ); } async start(): Promise { @@ -41,24 +76,45 @@ export class SseMCPTransport implements MCPTransport { this.abortController = new AbortController(); - const establishConnection = async () => { + const establishConnection = async (triedAuth: boolean = false) => { try { - const headers = withUserAgentSuffix( - { - ...this.headers, - Accept: 'text/event-stream', - }, - `ai-sdk/${VERSION}`, - getRuntimeEnvironmentUserAgent(), - ); + const headers = await this.commonHeaders({ + Accept: 'text/event-stream', + }); const response = await fetch(this.url.href, { headers, signal: this.abortController?.signal, }); + if (response.status === 401 && this.authProvider && !triedAuth) { + this.resourceMetadataUrl = extractResourceMetadataUrl(response); + try { + const result = await auth(this.authProvider, { + serverUrl: this.url, + resourceMetadataUrl: this.resourceMetadataUrl, + }); + if (result !== 'AUTHORIZED') { + const error = new UnauthorizedError(); + this.onerror?.(error); + return reject(error); + } + } catch (error) { + this.onerror?.(error); + return reject(error); + } + return establishConnection(true); + } + if (!response.ok || !response.body) { + let errorMessage = `MCP SSE Transport Error: ${response.status} ${response.statusText}`; + + if (response.status === 405) { + errorMessage += + '. This server does not support SSE transport. Try using `http` transport instead'; + } + const error = new MCPClientError({ - message: `MCP SSE Transport Error: ${response.status} ${response.statusText}`, + message: errorMessage, }); this.onerror?.(error); return reject(error); @@ -141,7 +197,7 @@ export class SseMCPTransport implements MCPTransport { } }; - establishConnection(); + void establishConnection(); }); } @@ -159,36 +215,55 @@ export class SseMCPTransport implements MCPTransport { }); } - try { - const headers = withUserAgentSuffix( - { - ...this.headers, + const endpoint = this.endpoint as URL; + + const attempt = async (triedAuth: boolean = false): Promise => { + try { + const headers = await this.commonHeaders({ 'Content-Type': 'application/json', - }, - `ai-sdk/${VERSION}`, - getRuntimeEnvironmentUserAgent(), - ); - const init = { - method: 'POST', - headers, - body: JSON.stringify(message), - signal: this.abortController?.signal, - }; + }); + const init = { + method: 'POST', + headers, + body: JSON.stringify(message), + signal: this.abortController?.signal, + }; - const response = await fetch(this.endpoint, init); + const response = await fetch(endpoint, init); - if (!response.ok) { - const text = await response.text().catch(() => null); - const error = new MCPClientError({ - message: `MCP SSE Transport Error: POSTing to endpoint (HTTP ${response.status}): ${text}`, - }); + if (response.status === 401 && this.authProvider && !triedAuth) { + this.resourceMetadataUrl = extractResourceMetadataUrl(response); + try { + const result = await auth(this.authProvider, { + serverUrl: this.url, + resourceMetadataUrl: this.resourceMetadataUrl, + }); + if (result !== 'AUTHORIZED') { + const error = new UnauthorizedError(); + this.onerror?.(error); + return; + } + } catch (error) { + this.onerror?.(error); + return; + } + return attempt(true); + } + + if (!response.ok) { + const text = await response.text().catch(() => null); + const error = new MCPClientError({ + message: `MCP SSE Transport Error: POSTing to endpoint (HTTP ${response.status}): ${text}`, + }); + this.onerror?.(error); + return; + } + } catch (error) { this.onerror?.(error); return; } - } catch (error) { - this.onerror?.(error); - return; - } + }; + await attempt(); } } diff --git a/packages/ai/mcp-stdio/create-child-process.test.ts b/packages/mcp/src/tool/mcp-stdio/create-child-process.test.ts similarity index 100% rename from packages/ai/mcp-stdio/create-child-process.test.ts rename to packages/mcp/src/tool/mcp-stdio/create-child-process.test.ts diff --git a/packages/ai/mcp-stdio/create-child-process.ts b/packages/mcp/src/tool/mcp-stdio/create-child-process.ts similarity index 100% rename from packages/ai/mcp-stdio/create-child-process.ts rename to packages/mcp/src/tool/mcp-stdio/create-child-process.ts diff --git a/packages/ai/mcp-stdio/get-environment.test.ts b/packages/mcp/src/tool/mcp-stdio/get-environment.test.ts similarity index 100% rename from packages/ai/mcp-stdio/get-environment.test.ts rename to packages/mcp/src/tool/mcp-stdio/get-environment.test.ts diff --git a/packages/ai/mcp-stdio/get-environment.ts b/packages/mcp/src/tool/mcp-stdio/get-environment.ts similarity index 100% rename from packages/ai/mcp-stdio/get-environment.ts rename to packages/mcp/src/tool/mcp-stdio/get-environment.ts diff --git a/packages/ai/mcp-stdio/index.ts b/packages/mcp/src/tool/mcp-stdio/index.ts similarity index 100% rename from packages/ai/mcp-stdio/index.ts rename to packages/mcp/src/tool/mcp-stdio/index.ts diff --git a/packages/ai/mcp-stdio/mcp-stdio-transport.test.ts b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts similarity index 98% rename from packages/ai/mcp-stdio/mcp-stdio-transport.test.ts rename to packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts index 2e627651786b..12326edfa0fc 100644 --- a/packages/ai/mcp-stdio/mcp-stdio-transport.test.ts +++ b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts @@ -1,8 +1,8 @@ import type { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { JSONRPCMessage } from '../src/tool/mcp/json-rpc-message'; -import { MCPClientError } from '../src/error/mcp-client-error'; +import { JSONRPCMessage } from '../json-rpc-message'; +import { MCPClientError } from '../../error/mcp-client-error'; import { createChildProcess } from './create-child-process'; import { StdioMCPTransport } from './mcp-stdio-transport'; diff --git a/packages/ai/mcp-stdio/mcp-stdio-transport.ts b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts similarity index 94% rename from packages/ai/mcp-stdio/mcp-stdio-transport.ts rename to packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts index c4ab4ac6c64f..11c34b180d86 100644 --- a/packages/ai/mcp-stdio/mcp-stdio-transport.ts +++ b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts @@ -1,11 +1,8 @@ import type { ChildProcess, IOType } from 'node:child_process'; import { Stream } from 'node:stream'; -import { - JSONRPCMessage, - JSONRPCMessageSchema, -} from '../src/tool/mcp/json-rpc-message'; -import { MCPTransport } from '../src/tool/mcp/mcp-transport'; -import { MCPClientError } from '../src/error/mcp-client-error'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '../json-rpc-message'; +import { MCPTransport } from '../mcp-transport'; +import { MCPClientError } from '../../error/mcp-client-error'; import { createChildProcess } from './create-child-process'; export interface StdioConfig { diff --git a/packages/ai/src/tool/mcp/mcp-transport.ts b/packages/mcp/src/tool/mcp-transport.ts similarity index 68% rename from packages/ai/src/tool/mcp/mcp-transport.ts rename to packages/mcp/src/tool/mcp-transport.ts index e631eadd0538..b9a8fb76b019 100644 --- a/packages/ai/src/tool/mcp/mcp-transport.ts +++ b/packages/mcp/src/tool/mcp-transport.ts @@ -1,6 +1,8 @@ -import { MCPClientError } from '../../error/mcp-client-error'; +import { MCPClientError } from '../error/mcp-client-error'; import { JSONRPCMessage } from './json-rpc-message'; import { SseMCPTransport } from './mcp-sse-transport'; +import { HttpMCPTransport } from './mcp-http-transport'; +import { OAuthClientProvider } from './oauth'; /** * Transport interface for MCP (Model Context Protocol) communication. @@ -40,7 +42,7 @@ export interface MCPTransport { } export type MCPTransportConfig = { - type: 'sse'; + type: 'sse' | 'http'; /** * The URL of the MCP server. @@ -51,17 +53,25 @@ export type MCPTransportConfig = { * Additional HTTP headers to be sent with requests. */ headers?: Record; + + /** + * An optional OAuth client provider to use for authentication for MCP servers. + */ + authProvider?: OAuthClientProvider; }; export function createMcpTransport(config: MCPTransportConfig): MCPTransport { - if (config.type !== 'sse') { - throw new MCPClientError({ - message: - 'Unsupported or invalid transport configuration. If you are using a custom transport, make sure it implements the MCPTransport interface.', - }); + switch (config.type) { + case 'sse': + return new SseMCPTransport(config); + case 'http': + return new HttpMCPTransport(config); + default: + throw new MCPClientError({ + message: + 'Unsupported or invalid transport configuration. If you are using a custom transport, make sure it implements the MCPTransport interface.', + }); } - - return new SseMCPTransport(config); } export function isCustomMcpTransport( diff --git a/packages/ai/src/tool/mcp/mock-mcp-transport.ts b/packages/mcp/src/tool/mock-mcp-transport.ts similarity index 100% rename from packages/ai/src/tool/mcp/mock-mcp-transport.ts rename to packages/mcp/src/tool/mock-mcp-transport.ts diff --git a/packages/mcp/src/tool/oauth-types.ts b/packages/mcp/src/tool/oauth-types.ts new file mode 100644 index 000000000000..e25778fe4256 --- /dev/null +++ b/packages/mcp/src/tool/oauth-types.ts @@ -0,0 +1,171 @@ +import { z } from 'zod/v4'; +/** + * OAuth 2.1 token response + */ +export const OAuthTokensSchema = z + .object({ + access_token: z.string(), + id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect + token_type: z.string(), + expires_in: z.number().optional(), + scope: z.string().optional(), + refresh_token: z.string().optional(), + }) + .strip(); + +/** + * Reusable URL validation that disallows javascript: scheme + */ +export const SafeUrlSchema = z + .string() + .url() + .superRefine((val, ctx) => { + if (!URL.canParse(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'URL must be parseable', + fatal: true, + }); + + return z.NEVER; + } + }) + .refine( + url => { + const u = new URL(url); + return ( + u.protocol !== 'javascript:' && + u.protocol !== 'data:' && + u.protocol !== 'vbscript:' + ); + }, + { message: 'URL cannot use javascript:, data:, or vbscript: scheme' }, + ); + +export const OAuthProtectedResourceMetadataSchema = z + .object({ + resource: z.string().url(), + authorization_servers: z.array(SafeUrlSchema).optional(), + jwks_uri: z.string().url().optional(), + scopes_supported: z.array(z.string()).optional(), + bearer_methods_supported: z.array(z.string()).optional(), + resource_signing_alg_values_supported: z.array(z.string()).optional(), + resource_name: z.string().optional(), + resource_documentation: z.string().optional(), + resource_policy_uri: z.string().url().optional(), + resource_tos_uri: z.string().url().optional(), + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + authorization_details_types_supported: z.array(z.string()).optional(), + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + dpop_bound_access_tokens_required: z.boolean().optional(), + }) + .passthrough(); + +export const OAuthMetadataSchema = z + .object({ + issuer: z.string(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + grant_types_supported: z.array(z.string()).optional(), + code_challenge_methods_supported: z.array(z.string()), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + token_endpoint_auth_signing_alg_values_supported: z + .array(z.string()) + .optional(), + }) + .passthrough(); + +/** + * OpenID Connect Discovery 1.0 Provider Metadata + * see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ +export const OpenIdProviderMetadataSchema = z + .object({ + issuer: z.string(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + userinfo_endpoint: SafeUrlSchema.optional(), + jwks_uri: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + grant_types_supported: z.array(z.string()).optional(), + subject_types_supported: z.array(z.string()), + id_token_signing_alg_values_supported: z.array(z.string()), + claims_supported: z.array(z.string()).optional(), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + }) + .passthrough(); + +/** + * OpenID Connect Discovery metadata that may include OAuth 2.0 fields + * This schema represents the real-world scenario where OIDC providers + * return a mix of OpenID Connect and OAuth 2.0 metadata fields + */ +export const OpenIdProviderDiscoveryMetadataSchema = + OpenIdProviderMetadataSchema.merge( + OAuthMetadataSchema.pick({ + code_challenge_methods_supported: true, + }), + ); + +export const OAuthClientInformationSchema = z + .object({ + client_id: z.string(), + client_secret: z.string().optional(), + client_id_issued_at: z.number().optional(), + client_secret_expires_at: z.number().optional(), + }) + .strip(); + +export const OAuthClientMetadataSchema = z + .object({ + redirect_uris: z.array(SafeUrlSchema), + token_endpoint_auth_method: z.string().optional(), + grant_types: z.array(z.string()).optional(), + response_types: z.array(z.string()).optional(), + client_name: z.string().optional(), + client_uri: SafeUrlSchema.optional(), + logo_uri: SafeUrlSchema.optional(), + scope: z.string().optional(), + contacts: z.array(z.string()).optional(), + tos_uri: SafeUrlSchema.optional(), + policy_uri: z.string().optional(), + jwks_uri: SafeUrlSchema.optional(), + jwks: z.any().optional(), + software_id: z.string().optional(), + software_version: z.string().optional(), + software_statement: z.string().optional(), + }) + .strip(); + +export type OAuthMetadata = z.infer; +export type OpenIdProviderDiscoveryMetadata = z.infer< + typeof OpenIdProviderDiscoveryMetadataSchema +>; +export type OAuthTokens = z.infer; +export type OAuthProtectedResourceMetadata = z.infer< + typeof OAuthProtectedResourceMetadataSchema +>; +export type OAuthClientInformation = z.infer< + typeof OAuthClientInformationSchema +>; +export type AuthorizationServerMetadata = + | OAuthMetadata + | OpenIdProviderDiscoveryMetadata; + +export const OAuthErrorResponseSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), + error_uri: z.string().optional(), +}); +export const OAuthClientInformationFullSchema = OAuthClientMetadataSchema.merge( + OAuthClientInformationSchema, +); +export type OAuthClientMetadata = z.infer; +export type OAuthClientInformationFull = z.infer< + typeof OAuthClientInformationFullSchema +>; diff --git a/packages/mcp/src/tool/oauth.test.ts b/packages/mcp/src/tool/oauth.test.ts new file mode 100644 index 000000000000..f44b52fa7e22 --- /dev/null +++ b/packages/mcp/src/tool/oauth.test.ts @@ -0,0 +1,2144 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { + extractResourceMetadataUrl, + type OAuthClientProvider, + type AuthResult, + discoverOAuthProtectedResourceMetadata, + buildDiscoveryUrls, + discoverAuthorizationServerMetadata, + startAuthorization, + exchangeAuthorization, + refreshAuthorization, + registerClient, + auth, +} from './oauth'; +import { AuthorizationServerMetadata } from './oauth-types'; +import { ServerError } from '../error/oauth-error'; +import { LATEST_PROTOCOL_VERSION } from './types'; + +// Mock the pkce-challenge module +vi.mock('pkce-challenge', () => ({ + default: vi.fn(() => ({ + code_verifier: 'test_verifier', + code_challenge: 'test_challenge', + })), +})); + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('extractResourceMetadataUrl', () => { + it('returns resource metadata url when present', async () => { + const resourceUrl = + 'https://resource.example.com/.well-known/oauth-protected-resource'; + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' + ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` + : null, + ), + }, + } as unknown as Response; + + expect(extractResourceMetadataUrl(mockResponse)).toEqual( + new URL(resourceUrl), + ); + }); + + it('returns undefined if not bearer', async () => { + const resourceUrl = + 'https://resource.example.com/.well-known/oauth-protected-resource'; + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' + ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` + : null, + ), + }, + } as unknown as Response; + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + + it('returns undefined if resource_metadata not present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' ? `Basic realm="mcp"` : null, + ), + }, + } as unknown as Response; + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); + + it('returns undefined on invalid url', async () => { + const resourceUrl = 'invalid-url'; + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' + ? `Basic realm="mcp", resource_metadata="${resourceUrl}"` + : null, + ), + }, + } as unknown as Response; + + expect(extractResourceMetadataUrl(mockResponse)).toBeUndefined(); + }); +}); + +describe('discoverOAuthProtectedResourceMetadata', () => { + const validMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + }; + + it('returns metadata when discovery succeeds', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com', + ); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + }); + + it('returns metadata when first fetch fails but second without MCP header succeeds', async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call with MCP header - fail with TypeError (simulating CORS error) + // We need to use TypeError specifically because that's what the implementation checks for + return Promise.reject(new TypeError('Network error')); + } else { + // Second call without header - succeed + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + } + }); + + // Should succeed with the second call + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com', + ); + expect(metadata).toEqual(validMetadata); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify first call had MCP header + expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty( + 'MCP-Protocol-Version', + ); + }); + + it('throws an error when all fetch attempts fail', async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call - fail with TypeError + return Promise.reject(new TypeError('First failure')); + } else { + // Second call - fail with different error + return Promise.reject(new Error('Second failure')); + } + }); + + // Should fail with the second error + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com'), + ).rejects.toThrow('Second failure'); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws on 404 errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com'), + ).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.', + ); + }); + + it('throws on non-404 errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com'), + ).rejects.toThrow('HTTP 500'); + }); + + it('validates metadata schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + scopes_supported: ['email', 'mcp'], + }), + }); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com'), + ).rejects.toThrow(); + }); + + it('returns metadata when discovery succeeds with path', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path/name', + ); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource/path/name', + ); + }); + + it('preserves query parameters in path-aware discovery', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path?param=value', + ); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]; + expect(url.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource/path?param=value', + ); + }); + + it.each([400, 401, 403, 404, 410, 422, 429])( + 'falls back to root discovery when path-aware discovery returns %d', + async statusCode => { + // First call (path-aware) returns 4xx + mockFetch.mockResolvedValueOnce({ + ok: false, + status: statusCode, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path/name', + ); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]; + expect(firstUrl.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource/path/name', + ); + expect(firstOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]; + expect(secondUrl.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + expect(secondOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + }); + }, + ); + + it('throws error when both path-aware and root discovery return 404', async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path/name', + ), + ).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.', + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it('throws error on 500 status and does not fallback', async () => { + // First call (path-aware) returns 500 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect( + discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path/name', + ), + ).rejects.toThrow(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + }); + + it('does not fallback when the original URL is already at root path', async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com/'), + ).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.', + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + }); + + it('does not fallback when the original URL has no path', async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com'), + ).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.', + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]; + expect(url.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + }); + + it('falls back when path-aware discovery encounters CORS error', async () => { + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => + Promise.reject(new TypeError('CORS error')), + ); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/deep/path', + ); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]; + expect(lastUrl.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + expect(lastOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + }); + }); + + it('does not fallback when resourceMetadataUrl is provided', async () => { + // Call with explicit URL returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + await expect( + discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path', + { + resourceMetadataUrl: 'https://custom.example.com/metadata', + }, + ), + ).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.', + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided + + const [url] = calls[0]; + expect(url.toString()).toBe('https://custom.example.com/metadata'); + }); + + it('supports overriding the fetch function used for requests', async () => { + const validMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + }; + + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validMetadata, + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com', + undefined, + customFetch, + ); + + expect(metadata).toEqual(validMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]; + expect(url.toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION, + }); + }); +}); + +describe('buildDiscoveryUrls', () => { + it('generates correct URLs for server without path', () => { + const urls = buildDiscoveryUrls('https://auth.example.com'); + + expect(urls).toHaveLength(2); + expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ + { + url: 'https://auth.example.com/.well-known/oauth-authorization-server', + type: 'oauth', + }, + { + url: 'https://auth.example.com/.well-known/openid-configuration', + type: 'oidc', + }, + ]); + }); + + it('generates correct URLs for server with path', () => { + const urls = buildDiscoveryUrls('https://auth.example.com/tenant1'); + + expect(urls).toHaveLength(4); + expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ + { + url: 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + type: 'oauth', + }, + { + url: 'https://auth.example.com/.well-known/oauth-authorization-server', + type: 'oauth', + }, + { + url: 'https://auth.example.com/.well-known/openid-configuration/tenant1', + type: 'oidc', + }, + { + url: 'https://auth.example.com/tenant1/.well-known/openid-configuration', + type: 'oidc', + }, + ]); + }); + + it('handles URL object input', () => { + const urls = buildDiscoveryUrls( + new URL('https://auth.example.com/tenant1'), + ); + + expect(urls).toHaveLength(4); + expect(urls[0].url.toString()).toBe( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + ); + }); +}); + +describe('discoverAuthorizationServerMetadata', () => { + const validOAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }; + + const validOpenIdMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + jwks_uri: 'https://auth.example.com/jwks', + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }; + + it('tries URLs in order and returns first successful metadata', async () => { + // First OAuth URL fails with 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Second OAuth URL (root) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + 'https://auth.example.com/tenant1', + ); + + expect(metadata).toEqual(validOAuthMetadata); + + // Verify it tried the URLs in the correct order + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + expect(calls[0][0].toString()).toBe( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + ); + expect(calls[1][0].toString()).toBe( + 'https://auth.example.com/.well-known/oauth-authorization-server', + ); + }); + + it('throws error when OIDC provider does not support S256 PKCE', async () => { + // OAuth discovery fails + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // OpenID Connect discovery succeeds but without S256 support + const invalidOpenIdMetadata = { + ...validOpenIdMetadata, + code_challenge_methods_supported: ['plain'], // Missing S256 + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => invalidOpenIdMetadata, + }); + + await expect( + discoverAuthorizationServerMetadata('https://auth.example.com'), + ).rejects.toThrow( + 'does not support S256 code challenge method required by MCP specification', + ); + }); + + it('continues on 4xx errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + 'https://mcp.example.com', + ); + + expect(metadata).toEqual(validOpenIdMetadata); + }); + + it('throws on non-4xx errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect( + discoverAuthorizationServerMetadata('https://mcp.example.com'), + ).rejects.toThrow('HTTP 500'); + }); + + it('handles CORS errors with retry', async () => { + // First call fails with CORS + mockFetch.mockImplementationOnce(() => + Promise.reject(new TypeError('CORS error')), + ); + + // Retry without headers succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + 'https://auth.example.com', + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should have headers + expect(calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version'); + + // Second call should not have headers (CORS retry) + expect(calls[1][1]?.headers).toBeUndefined(); + }); + + it('supports custom fetch function', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + 'https://auth.example.com', + { fetchFn: customFetch }, + ); + + expect(metadata).toEqual(validOAuthMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('supports custom protocol version', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata, + }); + + const metadata = await discoverAuthorizationServerMetadata( + 'https://auth.example.com', + { protocolVersion: '2025-01-01' }, + ); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + const [, options] = calls[0]; + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': '2025-01-01', + }); + }); + + it('returns undefined when all URLs fail with CORS errors', async () => { + // All fetch attempts fail with CORS errors (TypeError) + mockFetch.mockImplementation(() => + Promise.reject(new TypeError('CORS error')), + ); + + const metadata = await discoverAuthorizationServerMetadata( + 'https://auth.example.com/tenant1', + ); + + expect(metadata).toBeUndefined(); + + // Verify that all discovery URLs were attempted + expect(mockFetch).toHaveBeenCalledTimes(8); // 4 URLs × 2 attempts each (with and without headers) + }); +}); + +describe('startAuthorization', () => { + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/tkn', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + }; + + it('generates authorization URL with PKCE challenge', async () => { + const { authorizationUrl, codeVerifier } = await startAuthorization( + 'https://auth.example.com', + { + metadata: undefined, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server'), + }, + ); + + expect(authorizationUrl.toString()).toMatch( + /^https:\/\/auth\.example\.com\/authorize\?/, + ); + expect(authorizationUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizationUrl.searchParams.get('code_challenge')).toBe( + 'test_challenge', + ); + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe( + 'S256', + ); + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe( + 'http://localhost:3000/callback', + ); + expect(authorizationUrl.searchParams.get('resource')).toBe( + 'https://api.example.com/mcp-server', + ); + expect(codeVerifier).toBe('test_verifier'); + }); + + it('includes scope parameter when provided', async () => { + const { authorizationUrl } = await startAuthorization( + 'https://auth.example.com', + { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + scope: 'read write profile', + }, + ); + + expect(authorizationUrl.searchParams.get('scope')).toBe( + 'read write profile', + ); + }); + + it('excludes scope parameter when not provided', async () => { + const { authorizationUrl } = await startAuthorization( + 'https://auth.example.com', + { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + }, + ); + + expect(authorizationUrl.searchParams.has('scope')).toBe(false); + }); + + it('includes state parameter when provided', async () => { + const { authorizationUrl } = await startAuthorization( + 'https://auth.example.com', + { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + state: 'foobar', + }, + ); + + expect(authorizationUrl.searchParams.get('state')).toBe('foobar'); + }); + + it('excludes state parameter when not provided', async () => { + const { authorizationUrl } = await startAuthorization( + 'https://auth.example.com', + { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + }, + ); + + expect(authorizationUrl.searchParams.has('state')).toBe(false); + }); + + // OpenID Connect requires that the user is prompted for consent if the scope includes 'offline_access' + it("includes consent prompt parameter if scope includes 'offline_access'", async () => { + const { authorizationUrl } = await startAuthorization( + 'https://auth.example.com', + { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + scope: 'read write profile offline_access', + }, + ); + + expect(authorizationUrl.searchParams.get('prompt')).toBe('consent'); + }); + + it('uses metadata authorization_endpoint when provided', async () => { + const { authorizationUrl } = await startAuthorization( + 'https://auth.example.com', + { + metadata: validMetadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + }, + ); + + expect(authorizationUrl.toString()).toMatch( + /^https:\/\/auth\.example\.com\/auth\?/, + ); + }); + + it('validates response type support', async () => { + const metadata = { + ...validMetadata, + response_types_supported: ['token'], // Does not support 'code' + }; + + await expect( + startAuthorization('https://auth.example.com', { + metadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + }), + ).rejects.toThrow(/does not support response type/); + }); + + it('validates PKCE support', async () => { + const metadata = { + ...validMetadata, + response_types_supported: ['code'], + code_challenge_methods_supported: ['plain'], // Does not support 'S256' + }; + + await expect( + startAuthorization('https://auth.example.com', { + metadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + }), + ).rejects.toThrow(/does not support code challenge method/); + }); +}); + +describe('exchangeAuthorization', () => { + const validTokens = { + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123', + }; + + const validMetadata: AuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + jwks_uri: 'https://auth.example.com/jwks', + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + code_challenge_methods_supported: ['S256'], + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + }; + + it('exchanges code for tokens', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server'), + }); + + expect(tokens).toEqual(validTokens); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token', + }), + expect.objectContaining({ + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), + }), + ); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + + it('exchanges code for tokens with auth', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + metadata: validMetadata as AuthorizationServerMetadata, + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + addClientAuthentication: ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata: AuthorizationServerMetadata, + ) => { + headers.set( + 'Authorization', + 'Basic ' + + btoa( + validClientInfo.client_id + ':' + validClientInfo.client_secret, + ), + ); + params.set( + 'example_url', + typeof url === 'string' ? url : url.toString(), + ); + params.set('example_metadata', metadata.authorization_endpoint); + params.set('example_param', 'example_value'); + }, + }); + + expect(tokens).toEqual(validTokens); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token', + }), + expect.objectContaining({ + method: 'POST', + }), + ); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded', + ); + expect(headers.get('Authorization')).toBe( + 'Basic Y2xpZW50MTIzOnNlY3JldDEyMw==', + ); + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('example_url')).toBe('https://auth.example.com'); + expect(body.get('example_metadata')).toBe( + 'https://auth.example.com/authorize', + ); + expect(body.get('example_param')).toBe('example_value'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('validates token response schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + access_token: 'access123', + }), + }); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + }), + ).rejects.toThrow(); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: ServerError.errorCode, + error_description: 'Token exchange failed', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + }), + ).rejects.toThrow('Token exchange failed'); + }); + + it('supports overriding the fetch function used for requests', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server'), + fetchFn: customFetch, + }); + + expect(tokens).toEqual(validTokens); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]; + expect(url.toString()).toBe('https://auth.example.com/token'); + expect(options).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: expect.any(URLSearchParams), + }), + ); + + const body = options.body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); +}); + +describe('refreshAuthorization', () => { + const validTokens = { + access_token: 'newaccess123', + token_type: 'Bearer', + expires_in: 3600, + }; + const validTokensWithNewRefreshToken = { + ...validTokens, + refresh_token: 'newrefresh123', + }; + + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + }; + + it('exchanges refresh token for new tokens', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + resource: new URL('https://api.example.com/mcp-server'), + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token', + }), + expect.objectContaining({ + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + }), + }), + ); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + + it('exchanges refresh token for new tokens with auth', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + metadata: validMetadata as AuthorizationServerMetadata, + clientInformation: validClientInfo, + refreshToken: 'refresh123', + addClientAuthentication: ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata, + ) => { + headers.set( + 'Authorization', + 'Basic ' + + btoa( + validClientInfo.client_id + ':' + validClientInfo.client_secret, + ), + ); + params.set( + 'example_url', + typeof url === 'string' ? url : url.toString(), + ); + params.set('example_metadata', metadata?.authorization_endpoint ?? '?'); + params.set('example_param', 'example_value'); + }, + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token', + }), + expect.objectContaining({ + method: 'POST', + }), + ); + + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded', + ); + expect(headers.get('Authorization')).toBe( + 'Basic Y2xpZW50MTIzOnNlY3JldDEyMw==', + ); + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('example_url')).toBe('https://auth.example.com'); + expect(body.get('example_metadata')).toBe( + 'https://auth.example.com/authorize', + ); + expect(body.get('example_param')).toBe('example_value'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('exchanges refresh token for new tokens and keep existing refresh token if none is returned', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const refreshToken = 'refresh123'; + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken, + }); + + expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); + }); + + it('validates token response schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + access_token: 'newaccess123', + }), + }); + + await expect( + refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + }), + ).rejects.toThrow(); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: ServerError.errorCode, + error_description: 'Token refresh failed', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + }), + ).rejects.toThrow('Token refresh failed'); + }); +}); + +describe('registerClient', () => { + const validClientMetadata = { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + client_id_issued_at: 1612137600, + client_secret_expires_at: 1612224000, + ...validClientMetadata, + }; + + it('registers client and returns client information', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo, + }); + + const clientInfo = await registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata, + }); + + expect(clientInfo).toEqual(validClientInfo); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/register', + }), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(validClientMetadata), + }), + ); + }); + + it('validates client information response schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + client_secret: 'secret123', + }), + }); + + await expect( + registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata, + }), + ).rejects.toThrow(); + }); + + it('throws when registration endpoint not available in metadata', async () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + }; + + await expect( + registerClient('https://auth.example.com', { + metadata: metadata as AuthorizationServerMetadata, + clientMetadata: validClientMetadata, + }), + ).rejects.toThrow(/does not support dynamic client registration/); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: ServerError.errorCode, + error_description: 'Dynamic client registration failed', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + await expect( + registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata, + }), + ).rejects.toThrow('Dynamic client registration failed'); + }); +}); + +describe('auth function', () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + }; + }, + clientInformation: vi.fn(), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata', async () => { + // Setup: First call to protected resource metadata fails (404) + // Second call to auth server metadata succeeds + let callCount = 0; + mockFetch.mockImplementation(url => { + callCount++; + + const urlString = url.toString(); + + if ( + callCount === 1 && + urlString.includes('/.well-known/oauth-protected-resource') + ) { + // First call - protected resource metadata fails with 404 + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if ( + callCount === 2 && + urlString.includes('/.well-known/oauth-authorization-server') + ) { + // Second call - auth server metadata succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } else if (callCount === 3 && urlString.includes('/register')) { + // Third call - client registration succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1612137600, + client_secret_expires_at: 1612224000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + }), + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue(undefined); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = vi.fn(); + + // Call the auth function + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + }); + + // Verify the result + expect(result).toBe('REDIRECT'); + + // Verify the sequence of calls + expect(mockFetch).toHaveBeenCalledTimes(3); + + // First call should be to protected resource metadata + expect(mockFetch.mock.calls[0][0].toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + + // Second call should be to oauth metadata + expect(mockFetch.mock.calls[1][0].toString()).toBe( + 'https://resource.example.com/.well-known/oauth-authorization-server', + ); + }); + + it('passes resource parameter through authorization flow', async () => { + // Mock successful metadata discovery - need to include protected resource metadata + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'], + }), + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for authorization flow + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL includes the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }), + ); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock + .calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('resource')).toBe( + 'https://api.example.com/mcp-server', + ); + }); + + it('includes resource in token exchange when authorization code is provided', async () => { + // Mock successful metadata discovery and token exchange - need protected resource metadata + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'], + }), + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123', + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + authorizationCode: 'auth-code-123', + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes('/token'), + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + expect(body.get('code')).toBe('auth-code-123'); + }); + + it('includes resource in token refresh', async () => { + // Mock successful metadata discovery and token refresh - need protected resource metadata + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'], + }), + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123', + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes('/token'), + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + }); + + it('skips default PRM resource validation when custom validateResourceURL is provided', async () => { + const mockValidateResourceURL = vi.fn().mockResolvedValue(undefined); + const providerWithCustomValidation = { + ...mockProvider, + validateResourceURL: mockValidateResourceURL, + }; + + // Mock protected resource metadata with mismatched resource URL + // This would normally throw an error in default validation, but should be skipped + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://different-resource.example.com/mcp-server', // Mismatched resource + authorization_servers: ['https://auth.example.com'], + }), + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (providerWithCustomValidation.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (providerWithCustomValidation.tokens as Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.saveCodeVerifier as Mock).mockResolvedValue( + undefined, + ); + ( + providerWithCustomValidation.redirectToAuthorization as Mock + ).mockResolvedValue(undefined); + + // Call auth - should succeed despite resource mismatch because custom validation overrides default + const result = await auth(providerWithCustomValidation, { + serverUrl: 'https://api.example.com/mcp-server', + }); + + expect(result).toBe('REDIRECT'); + + // Verify custom validation method was called + expect(mockValidateResourceURL).toHaveBeenCalledWith( + new URL('https://api.example.com/mcp-server'), + 'https://different-resource.example.com/mcp-server', + ); + }); + + it('uses prefix of server URL from PRM resource as resource parameter', async () => { + // Mock successful metadata discovery with resource URL that is a prefix of requested URL + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Resource is a prefix of the requested server URL + resource: 'https://api.example.com/', + authorization_servers: ['https://auth.example.com'], + }), + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth with a URL that has the resource as prefix + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server/endpoint', + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL includes the resource parameter from PRM + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }), + ); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock + .calls[0]; + const authUrl: URL = redirectCall[0]; + // Should use the PRM's resource value, not the full requested URL + expect(authUrl.searchParams.get('resource')).toBe( + 'https://api.example.com/', + ); + }); + + it('excludes resource parameter when Protected Resource Metadata is not present', async () => { + // Mock metadata discovery where protected resource metadata is not available (404) + // but authorization server metadata is available + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + // Protected resource metadata not available + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth - should not include resource parameter + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL does NOT include the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }), + ); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock + .calls[0]; + const authUrl: URL = redirectCall[0]; + // Resource parameter should not be present when PRM is not available + expect(authUrl.searchParams.has('resource')).toBe(false); + }); + + it('excludes resource parameter in token exchange when Protected Resource Metadata is not present', async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123', + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + authorizationCode: 'auth-code-123', + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes('/token'), + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has('resource')).toBe(false); + expect(body.get('code')).toBe('auth-code-123'); + }); + + it('excludes resource parameter in token refresh when Protected Resource Metadata is not present', async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404, + }); + } else if ( + urlString.includes('/.well-known/oauth-authorization-server') + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123', + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes('/token'), + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has('resource')).toBe(false); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + }); + + it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => { + // Mock PRM discovery that returns an external AS + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if ( + urlString === + 'https://my.resource.com/.well-known/oauth-protected-resource/path/name' + ) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://my.resource.com/', + authorization_servers: ['https://auth.example.com/oauth'], + }), + }); + } else if ( + urlString === + 'https://auth.example.com/.well-known/oauth-authorization-server/path/name' + ) { + // Path-aware discovery on AS with path from serverUrl + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret', + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth with serverUrl that has a path + const result = await auth(mockProvider, { + serverUrl: 'https://my.resource.com/path/name', + }); + + expect(result).toBe('REDIRECT'); + + // Verify the correct URLs were fetched + const calls = mockFetch.mock.calls; + + // First call should be to PRM + expect(calls[0][0].toString()).toBe( + 'https://my.resource.com/.well-known/oauth-protected-resource/path/name', + ); + + // Second call should be to AS metadata with the path from authorization server + expect(calls[1][0].toString()).toBe( + 'https://auth.example.com/.well-known/oauth-authorization-server/oauth', + ); + }); + + it('supports overriding the fetch function used for requests', async () => { + const customFetch = vi.fn(); + + // Mock PRM discovery + customFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + }), + }); + + // Mock AS metadata discovery + customFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + }), + }); + + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + client_name: 'Test Client', + redirect_uris: ['http://localhost:3000/callback'], + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client123', + client_secret: 'secret123', + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('verifier123'), + }; + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + fetchFn: customFetch, + }); + + expect(result).toBe('REDIRECT'); + expect(customFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).not.toHaveBeenCalled(); + + // Verify custom fetch was called for PRM discovery + expect(customFetch.mock.calls[0][0].toString()).toBe( + 'https://resource.example.com/.well-known/oauth-protected-resource', + ); + + // Verify custom fetch was called for AS metadata discovery + expect(customFetch.mock.calls[1][0].toString()).toBe( + 'https://auth.example.com/.well-known/oauth-authorization-server', + ); + }); +}); diff --git a/packages/mcp/src/tool/oauth.ts b/packages/mcp/src/tool/oauth.ts new file mode 100644 index 000000000000..8409708ac687 --- /dev/null +++ b/packages/mcp/src/tool/oauth.ts @@ -0,0 +1,1027 @@ +import pkceChallenge from 'pkce-challenge'; +import { + OAuthTokens, + OAuthProtectedResourceMetadata, + OAuthProtectedResourceMetadataSchema, + OAuthMetadataSchema, + OpenIdProviderDiscoveryMetadataSchema, + AuthorizationServerMetadata, + OAuthClientInformation, + OAuthTokensSchema, + OAuthErrorResponseSchema, + OAuthClientMetadata, + OAuthClientInformationFull, + OAuthClientInformationFullSchema, +} from './oauth-types'; +import { + MCPClientOAuthError, + ServerError, + OAUTH_ERRORS, + InvalidClientError, + InvalidGrantError, + UnauthorizedClientError, +} from '../error/oauth-error'; +import { + resourceUrlFromServerUrl, + checkResourceAllowed, +} from '../util/oauth-util'; +import { LATEST_PROTOCOL_VERSION } from './types'; +import { FetchFunction } from '@ai-sdk/provider-utils'; + +export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; + +export interface OAuthClientProvider { + /** + * Returns current access token if present; undefined otherwise. + */ + tokens(): OAuthTokens | undefined | Promise; + saveTokens(tokens: OAuthTokens): void | Promise; + redirectToAuthorization(authorizationUrl: URL): void | Promise; + saveCodeVerifier(codeVerifier: string): void | Promise; + codeVerifier(): string | Promise; + + /** + * Adds custom client authentication to OAuth token requests. + * + * This optional method allows implementations to customize how client credentials + * are included in token exchange and refresh requests. When provided, this method + * is called instead of the default authentication logic, giving full control over + * the authentication mechanism. + * + * Common use cases include: + * - Supporting authentication methods beyond the standard OAuth 2.0 methods + * - Adding custom headers for proprietary authentication schemes + * - Implementing client assertion-based authentication (e.g., JWT bearer tokens) + * + * @param headers - The request headers (can be modified to add authentication) + * @param params - The request body parameters (can be modified to add credentials) + * @param url - The token endpoint URL being called + * @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods + */ + addClientAuthentication?( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata, + ): void | Promise; + + /** + * If implemented, provides a way for the client to invalidate (e.g. delete) the specified + * credentials, in the case where the server has indicated that they are no longer valid. + * This avoids requiring the user to intervene manually. + */ + invalidateCredentials?( + scope: 'all' | 'client' | 'tokens' | 'verifier', + ): void | Promise; + get redirectUrl(): string | URL; + get clientMetadata(): OAuthClientMetadata; + clientInformation(): + | OAuthClientInformation + | undefined + | Promise; + saveClientInformation?( + clientInformation: OAuthClientInformation, + ): void | Promise; + state?(): string | Promise; + validateResourceURL?( + serverUrl: string | URL, + resource?: string, + ): Promise; +} + +export class UnauthorizedError extends Error { + constructor(message = 'Unauthorized') { + super(message); + this.name = 'UnauthorizedError'; + } +} + +/** + * Extracts the OAuth 2.0 Protected Resource Metadata URL from a WWW-Authenticate header (RFC9728). + * Looks for a resource="..." parameter. + */ +export function extractResourceMetadataUrl( + response: Response, +): URL | undefined { + const header = + response.headers.get('www-authenticate') ?? + response.headers.get('WWW-Authenticate'); + if (!header) { + return undefined; + } + + const [type, scheme] = header.split(' '); + if (type.toLowerCase() !== 'bearer' || !scheme) { + return undefined; + } + + // regex taken from MCP spec + const regex = /resource_metadata="([^"]*)"/; + const match = header.match(regex); + if (!match) { + return undefined; + } + + try { + return new URL(match[1]); + } catch { + return undefined; + } +} + +/** + * Constructs the well-known path for auth-related metadata discovery + */ +function buildWellKnownPath( + wellKnownPrefix: + | 'oauth-authorization-server' + | 'oauth-protected-resource' + | 'openid-configuration', + pathname: string = '', + options: { prependPathname?: boolean } = {}, +): string { + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + return options.prependPathname + ? `${pathname}/.well-known/${wellKnownPrefix}` + : `/.well-known/${wellKnownPrefix}${pathname}`; +} + +async function fetchWithCorsRetry( + url: URL, + headers?: Record, + fetchFn: FetchFunction = fetch, +): Promise { + try { + return await fetchFn(url, { headers }); + } catch (error) { + if (error instanceof TypeError) { + if (headers) { + return fetchWithCorsRetry(url, undefined, fetchFn); + } else { + return undefined; + } + } + throw error; + } +} + +/** + * Tries to discover OAuth metadata at a specific URL + */ +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, + fetchFn: FetchFunction = fetch, +): Promise { + const headers = { + 'MCP-Protocol-Version': protocolVersion, + }; + return await fetchWithCorsRetry(url, headers, fetchFn); +} + +/** + * Determines if fallback to root discovery should be attempted + */ +function shouldAttemptFallback( + response: Response | undefined, + pathname: string, +): boolean { + return ( + !response || + (response.status >= 400 && response.status < 500 && pathname !== '/') + ); +} + +/** + * Generic function for discovering OAuth metadata with fallback support + */ +async function discoverMetadataWithFallback( + serverUrl: string | URL, + wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', + fetchFn: FetchFunction, + opts?: { + protocolVersion?: string; + metadataUrl?: string | URL; + metadataServerUrl?: string | URL; + }, +): Promise { + const issuer = new URL(serverUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + + let url: URL; + if (opts?.metadataUrl) { + url = new URL(opts.metadataUrl); + } else { + const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); + url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer); + url.search = issuer.search; + } + + let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); + + if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { + const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); + } + + return response; +} + +export async function discoverOAuthProtectedResourceMetadata( + serverUrl: string | URL, + opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL }, + fetchFn: FetchFunction = fetch, +): Promise { + const response = await discoverMetadataWithFallback( + serverUrl, + 'oauth-protected-resource', + fetchFn, + { + protocolVersion: opts?.protocolVersion, + metadataUrl: opts?.resourceMetadataUrl, + }, + ); + + if (!response || response.status === 404) { + throw new Error( + `Resource server does not implement OAuth 2.0 Protected Resource Metadata.`, + ); + } + + if (!response.ok) { + throw new Error( + `HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`, + ); + } + return OAuthProtectedResourceMetadataSchema.parse(await response.json()); +} + +/** + * Builds a list of discovery URLs to try for authorization server metadata. + * URLs are returned in priority order: + * 1. OAuth metadata at the given URL + * 2. OAuth metadata at root (if URL has path) + * 3. OIDC metadata endpoints + */ +export function buildDiscoveryUrls( + authorizationServerUrl: string | URL, +): { url: URL; type: 'oauth' | 'oidc' }[] { + const url = + typeof authorizationServerUrl === 'string' + ? new URL(authorizationServerUrl) + : authorizationServerUrl; + const hasPath = url.pathname !== '/'; + const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = []; + + if (!hasPath) { + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth', + }); + + urlsToTry.push({ + url: new URL('/.well-known/openid-configuration', url.origin), + type: 'oidc', + }); + + return urlsToTry; + } + + let pathname = url.pathname; + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + urlsToTry.push({ + url: new URL( + `/.well-known/oauth-authorization-server${pathname}`, + url.origin, + ), + type: 'oauth', + }); + + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth', + }); + + urlsToTry.push({ + url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin), + type: 'oidc', + }); + + urlsToTry.push({ + url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin), + type: 'oidc', + }); + + return urlsToTry; +} + +export async function discoverAuthorizationServerMetadata( + authorizationServerUrl: string | URL, + { + fetchFn = fetch, + protocolVersion = LATEST_PROTOCOL_VERSION, + }: { + fetchFn?: FetchFunction; + protocolVersion?: string; + } = {}, +): Promise { + const headers = { 'MCP-Protocol-Version': protocolVersion }; + + const urlsToTry = buildDiscoveryUrls(authorizationServerUrl); + + for (const { url: endpointUrl, type } of urlsToTry) { + const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn); + + if (!response) { + /** + * CORS error occurred - don't throw as the endpoint may not allow CORS, + * continue trying other possible endpoints + */ + continue; + } + + if (!response.ok) { + // Continue looking for any 4xx response code. + if (response.status >= 400 && response.status < 500) { + continue; + } + throw new Error( + `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`, + ); + } + + if (type === 'oauth') { + return OAuthMetadataSchema.parse(await response.json()); + } else { + const metadata = OpenIdProviderDiscoveryMetadataSchema.parse( + await response.json(), + ); + + // MCP spec requires OIDC providers to support S256 PKCE + if (!metadata.code_challenge_methods_supported?.includes('S256')) { + throw new Error( + `Incompatible OIDC provider at ${endpointUrl}: does not support S256 code challenge method required by MCP specification`, + ); + } + + return metadata; + } + } + + return undefined; +} + +export async function startAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + redirectUrl, + scope, + state, + resource, + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformation; + redirectUrl: string | URL; + scope?: string; + state?: string; + resource?: URL; + }, +): Promise<{ authorizationUrl: URL; codeVerifier: string }> { + const responseType = 'code'; + const codeChallengeMethod = 'S256'; + + let authorizationUrl: URL; + if (metadata) { + authorizationUrl = new URL(metadata.authorization_endpoint); + + if (!metadata.response_types_supported.includes(responseType)) { + throw new Error( + `Incompatible auth server: does not support response type ${responseType}`, + ); + } + + if ( + !metadata.code_challenge_methods_supported || + !metadata.code_challenge_methods_supported.includes(codeChallengeMethod) + ) { + throw new Error( + `Incompatible auth server: does not support code challenge method ${codeChallengeMethod}`, + ); + } + } else { + authorizationUrl = new URL('/authorize', authorizationServerUrl); + } + + const challenge = await pkceChallenge(); + const codeVerifier = challenge.code_verifier; + const codeChallenge = challenge.code_challenge; + + authorizationUrl.searchParams.set('response_type', responseType); + authorizationUrl.searchParams.set('client_id', clientInformation.client_id); + authorizationUrl.searchParams.set('code_challenge', codeChallenge); + authorizationUrl.searchParams.set( + 'code_challenge_method', + codeChallengeMethod, + ); + authorizationUrl.searchParams.set('redirect_uri', String(redirectUrl)); + + if (state) { + authorizationUrl.searchParams.set('state', state); + } + + if (scope) { + authorizationUrl.searchParams.set('scope', scope); + } + + if (scope?.includes('offline_access')) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + authorizationUrl.searchParams.append('prompt', 'consent'); + } + + if (resource) { + authorizationUrl.searchParams.set('resource', resource.href); + } + + return { authorizationUrl, codeVerifier }; +} + +type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +/** + * Determines the best client authentication method to use based on server support and client configuration. + * + * Priority order (highest to lowest): + * 1. client_secret_basic (if client secret is available) + * 2. client_secret_post (if client secret is available) + * 3. none (for public clients) + * + * @param clientInformation - OAuth client information containing credentials + * @param supportedMethods - Authentication methods supported by the authorization server + * @returns The selected authentication method + */ +function selectClientAuthMethod( + clientInformation: OAuthClientInformation, + supportedMethods: string[], +): ClientAuthMethod { + const hasClientSecret = clientInformation.client_secret !== undefined; + + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_post' : 'none'; + } + + if (hasClientSecret && supportedMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + + if (hasClientSecret && supportedMethods.includes('client_secret_post')) { + return 'client_secret_post'; + } + + if (supportedMethods.includes('none')) { + return 'none'; + } + + return hasClientSecret ? 'client_secret_post' : 'none'; +} + +/** + * Applies client authentication to the request based on the specified method. + * + * Implements OAuth 2.1 client authentication methods: + * - client_secret_basic: HTTP Basic authentication (RFC 6749 Section 2.3.1) + * - client_secret_post: Credentials in request body (RFC 6749 Section 2.3.1) + * - none: Public client authentication (RFC 6749 Section 2.1) + * + * @param method - The authentication method to use + * @param clientInformation - OAuth client information containing credentials + * @param headers - HTTP headers object to modify + * @param params - URL search parameters to modify + * @throws {Error} When required credentials are missing + */ +function applyClientAuthentication( + method: ClientAuthMethod, + clientInformation: OAuthClientInformation, + headers: Headers, + params: URLSearchParams, +): void { + const { client_id, client_secret } = clientInformation; + + switch (method) { + case 'client_secret_basic': + applyBasicAuth(client_id, client_secret, headers); + return; + case 'client_secret_post': + applyPostAuth(client_id, client_secret, params); + return; + case 'none': + applyPublicAuth(client_id, params); + return; + default: + throw new Error(`Unsupported client authentication method: ${method}`); + } +} + +function applyBasicAuth( + clientId: string, + clientSecret: string | undefined, + headers: Headers, +): void { + if (!clientSecret) { + throw new Error( + 'client_secret_basic authentication requires a client_secret', + ); + } + + const credentials = btoa(`${clientId}:${clientSecret}`); + headers.set('Authorization', `Basic ${credentials}`); +} + +/** + * Applies POST body authentication (RFC 6749 Section 2.3.1) + */ +function applyPostAuth( + clientId: string, + clientSecret: string | undefined, + params: URLSearchParams, +): void { + params.set('client_id', clientId); + if (clientSecret) { + params.set('client_secret', clientSecret); + } +} + +/** + * Applies public client authentication (RFC 6749 Section 2.1) + */ +function applyPublicAuth(clientId: string, params: URLSearchParams): void { + params.set('client_id', clientId); +} + +/** + * Parses an OAuth error response from a string or Response object. + * + * If the input is a standard OAuth2.0 error response, it will be parsed according to the spec + * and an instance of the appropriate OAuthError subclass will be returned. + * If parsing fails, it falls back to a generic ServerError that includes + * the response status (if available) and original content. + * + * @param input - A Response object or string containing the error response + * @returns A Promise that resolves to an OAuthError instance + */ +export async function parseErrorResponse( + input: Response | string, +): Promise { + const statusCode = input instanceof Response ? input.status : undefined; + const body = input instanceof Response ? await input.text() : input; + + try { + const result = OAuthErrorResponseSchema.parse(JSON.parse(body)); + const { error, error_description, error_uri } = result; + const errorClass = OAUTH_ERRORS[error] || ServerError; + return new errorClass({ + message: error_description || '', + cause: error_uri, + }); + } catch (error) { + // Not a valid OAuth error response, but try to inform the user of the raw data anyway + const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`; + return new ServerError({ message: errorMessage }); + } +} + +/** + * Exchanges an authorization code for an access token with the given server. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Falls back to appropriate defaults when server metadata is unavailable + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, auth code, etc. + * @returns Promise resolving to OAuth tokens + * @throws {Error} When token exchange fails or authentication is invalid + */ +export async function exchangeAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + authorizationCode, + codeVerifier, + redirectUri, + resource, + addClientAuthentication, + fetchFn, + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformation; + authorizationCode: string; + codeVerifier: string; + redirectUri: string | URL; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchFunction; + }, +): Promise { + const grantType = 'authorization_code'; + + const tokenUrl = metadata?.token_endpoint + ? new URL(metadata.token_endpoint) + : new URL('/token', authorizationServerUrl); + + if ( + metadata?.grant_types_supported && + !metadata.grant_types_supported.includes(grantType) + ) { + throw new Error( + `Incompatible auth server: does not support grant type ${grantType}`, + ); + } + + const headers = new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }); + const params = new URLSearchParams({ + grant_type: grantType, + code: authorizationCode, + code_verifier: codeVerifier, + redirect_uri: String(redirectUri), + }); + + if (addClientAuthentication) { + addClientAuthentication(headers, params, authorizationServerUrl, metadata); + } else { + const supportedMethods = + metadata?.token_endpoint_auth_methods_supported ?? []; + const authMethod = selectClientAuthMethod( + clientInformation, + supportedMethods, + ); + + applyClientAuthentication(authMethod, clientInformation, headers, params); + } + + if (resource) { + params.set('resource', resource.href); + } + + const response = await (fetchFn ?? fetch)(tokenUrl, { + method: 'POST', + headers, + body: params, + }); + + if (!response.ok) { + throw await parseErrorResponse(response); + } + + return OAuthTokensSchema.parse(await response.json()); +} + +/** + * Exchange a refresh token for an updated access token. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Preserves the original refresh token if a new one is not returned + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, refresh token, etc. + * @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced) + * @throws {Error} When token refresh fails or authentication is invalid + */ +export async function refreshAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + refreshToken, + resource, + addClientAuthentication, + fetchFn, + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformation; + refreshToken: string; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchFunction; + }, +): Promise { + const grantType = 'refresh_token'; + + let tokenUrl: URL; + if (metadata) { + tokenUrl = new URL(metadata.token_endpoint); + + if ( + metadata.grant_types_supported && + !metadata.grant_types_supported.includes(grantType) + ) { + throw new Error( + `Incompatible auth server: does not support grant type ${grantType}`, + ); + } + } else { + tokenUrl = new URL('/token', authorizationServerUrl); + } + + const headers = new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + const params = new URLSearchParams({ + grant_type: grantType, + refresh_token: refreshToken, + }); + + if (addClientAuthentication) { + addClientAuthentication(headers, params, authorizationServerUrl, metadata); + } else { + const supportedMethods = + metadata?.token_endpoint_auth_methods_supported ?? []; + const authMethod = selectClientAuthMethod( + clientInformation, + supportedMethods, + ); + + applyClientAuthentication(authMethod, clientInformation, headers, params); + } + + if (resource) { + params.set('resource', resource.href); + } + + const response = await (fetchFn ?? fetch)(tokenUrl, { + method: 'POST', + headers, + body: params, + }); + if (!response.ok) { + throw await parseErrorResponse(response); + } + + return OAuthTokensSchema.parse({ + refresh_token: refreshToken, + ...(await response.json()), + }); +} + +/** + * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. + */ +export async function registerClient( + authorizationServerUrl: string | URL, + { + metadata, + clientMetadata, + fetchFn, + }: { + metadata?: AuthorizationServerMetadata; + clientMetadata: OAuthClientMetadata; + fetchFn?: FetchFunction; + }, +): Promise { + let registrationUrl: URL; + + if (metadata) { + if (!metadata.registration_endpoint) { + throw new Error( + 'Incompatible auth server: does not support dynamic client registration', + ); + } + + registrationUrl = new URL(metadata.registration_endpoint); + } else { + registrationUrl = new URL('/register', authorizationServerUrl); + } + + const response = await (fetchFn ?? fetch)(registrationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(clientMetadata), + }); + + if (!response.ok) { + throw await parseErrorResponse(response); + } + + return OAuthClientInformationFullSchema.parse(await response.json()); +} + +export async function auth( + provider: OAuthClientProvider, + options: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchFunction; + }, +): Promise { + try { + return await authInternal(provider, options); + } catch (error) { + if ( + error instanceof InvalidClientError || + error instanceof UnauthorizedClientError + ) { + await provider.invalidateCredentials?.('all'); + return await authInternal(provider, options); + } else if (error instanceof InvalidGrantError) { + await provider.invalidateCredentials?.('tokens'); + return await authInternal(provider, options); + } + + throw error; + } +} + +export async function selectResourceURL( + serverUrl: string | URL, + provider: OAuthClientProvider, + resourceMetadata?: OAuthProtectedResourceMetadata, +): Promise { + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + if (provider.validateResourceURL) { + return await provider.validateResourceURL( + defaultResource, + resourceMetadata?.resource, + ); + } + + if (!resourceMetadata) { + return undefined; + } + + if ( + !checkResourceAllowed({ + requestedResource: defaultResource, + configuredResource: resourceMetadata.resource, + }) + ) { + throw new Error( + `Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`, + ); + } + return new URL(resourceMetadata.resource); +} + +async function authInternal( + provider: OAuthClientProvider, + { + serverUrl, + authorizationCode, + scope, + resourceMetadataUrl, + fetchFn, + }: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchFunction; + }, +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | URL | undefined; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl }, + fetchFn, + ); + if ( + resourceMetadata.authorization_servers && + resourceMetadata.authorization_servers.length > 0 + ) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch {} + + /** + * If we don't get a valid authorization server metadata from protected resource metadata, + * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server. + */ + if (!authorizationServerUrl) { + authorizationServerUrl = serverUrl; + } + + const resource: URL | undefined = await selectResourceURL( + serverUrl, + provider, + resourceMetadata, + ); + + const metadata = await discoverAuthorizationServerMetadata( + authorizationServerUrl, + { + fetchFn, + }, + ); + + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { + if (authorizationCode !== undefined) { + throw new Error( + 'Existing OAuth client information is required when exchanging an authorization code', + ); + } + + if (!provider.saveClientInformation) { + throw new Error( + 'OAuth client information must be saveable for dynamic registration', + ); + } + + const fullInformation = await registerClient(authorizationServerUrl, { + metadata, + clientMetadata: provider.clientMetadata, + fetchFn, + }); + + await provider.saveClientInformation(fullInformation); + clientInformation = fullInformation; + } + + // Exchange authorization code for tokens + if (authorizationCode !== undefined) { + const codeVerifier = await provider.codeVerifier(); + const tokens = await exchangeAuthorization(authorizationServerUrl, { + metadata, + clientInformation, + authorizationCode, + codeVerifier, + redirectUri: provider.redirectUrl, + resource, + addClientAuthentication: provider.addClientAuthentication, + fetchFn: fetchFn, + }); + + await provider.saveTokens(tokens); + return 'AUTHORIZED'; + } + + const tokens = await provider.tokens(); + + // Handle token refresh or new authorization + if (tokens?.refresh_token) { + try { + // Attempt to refresh the token + const newTokens = await refreshAuthorization(authorizationServerUrl, { + metadata, + clientInformation, + refreshToken: tokens.refresh_token, + resource, + addClientAuthentication: provider.addClientAuthentication, + fetchFn, + }); + + await provider.saveTokens(newTokens); + return 'AUTHORIZED'; + } catch (error) { + if ( + // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. + !(error instanceof MCPClientOAuthError) || + error instanceof ServerError + ) { + // Could not refresh OAuth tokens + } else { + // Refresh failed for another reason, re-throw + throw error; + } + } + } + + const state = provider.state ? await provider.state() : undefined; + + // Start new authorization flow + const { authorizationUrl, codeVerifier } = await startAuthorization( + authorizationServerUrl, + { + metadata, + clientInformation, + state, + redirectUrl: provider.redirectUrl, + scope: scope || provider.clientMetadata.scope, + resource, + }, + ); + + await provider.saveCodeVerifier(codeVerifier); + await provider.redirectToAuthorization(authorizationUrl); + return 'REDIRECT'; +} diff --git a/packages/ai/src/tool/mcp/types.ts b/packages/mcp/src/tool/types.ts similarity index 100% rename from packages/ai/src/tool/mcp/types.ts rename to packages/mcp/src/tool/types.ts diff --git a/packages/mcp/src/util/oauth-util.ts b/packages/mcp/src/util/oauth-util.ts new file mode 100644 index 000000000000..7be5a0a8e350 --- /dev/null +++ b/packages/mcp/src/util/oauth-util.ts @@ -0,0 +1,66 @@ +/** + * Utilities for handling OAuth resource URIs. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". + * Keeps everything else unchanged (scheme, domain, port, path, query). + */ +export function resourceUrlFromServerUrl(url: URL | string): URL { + const resourceURL = + typeof url === 'string' ? new URL(url) : new URL(url.href); + resourceURL.hash = ''; // Remove fragment + return resourceURL; +} + +/** + * Checks if a requested resource URL matches a configured resource URL. + * A requested resource matches if it has the same scheme, domain, port, + * and its path starts with the configured resource's path. + * + * @param requestedResource The resource URL being requested + * @param configuredResource The resource URL that has been configured + * @returns true if the requested resource matches the configured resource, false otherwise + */ +export function checkResourceAllowed({ + requestedResource, + configuredResource, +}: { + requestedResource: URL | string; + configuredResource: URL | string; +}): boolean { + const requested = + typeof requestedResource === 'string' + ? new URL(requestedResource) + : new URL(requestedResource.href); + const configured = + typeof configuredResource === 'string' + ? new URL(configuredResource) + : new URL(configuredResource.href); + + // Compare the origin (scheme, domain, and port) + if (requested.origin !== configured.origin) { + return false; + } + + // Handle cases like requested=/foo and configured=/foo/ + if (requested.pathname.length < configured.pathname.length) { + return false; + } + + // Check if the requested path starts with the configured path + // Ensure both paths end with / for proper comparison + // This ensures that if we have paths like "/api" and "/api/users", + // we properly detect that "/api/users" is a subpath of "/api" + // By adding a trailing slash if missing, we avoid false positives + // where paths like "/api123" would incorrectly match "/api" + const requestedPath = requested.pathname.endsWith('/') + ? requested.pathname + : requested.pathname + '/'; + const configuredPath = configured.pathname.endsWith('/') + ? configured.pathname + : configured.pathname + '/'; + + return requestedPath.startsWith(configuredPath); +} diff --git a/packages/mcp/src/util/oauth.util.test.ts b/packages/mcp/src/util/oauth.util.test.ts new file mode 100644 index 000000000000..a197af271005 --- /dev/null +++ b/packages/mcp/src/util/oauth.util.test.ts @@ -0,0 +1,146 @@ +import { resourceUrlFromServerUrl, checkResourceAllowed } from './oauth-util'; +import { describe, it, expect } from 'vitest'; + +describe('auth-utils', () => { + describe('resourceUrlFromServerUrl', () => { + it('should remove fragments', () => { + expect( + resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')) + .href, + ).toBe('https://example.com/path'); + expect( + resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href, + ).toBe('https://example.com/'); + expect( + resourceUrlFromServerUrl( + new URL('https://example.com/path?query=1#fragment'), + ).href, + ).toBe('https://example.com/path?query=1'); + }); + + it('should return URL unchanged if no fragment', () => { + expect( + resourceUrlFromServerUrl(new URL('https://example.com')).href, + ).toBe('https://example.com/'); + expect( + resourceUrlFromServerUrl(new URL('https://example.com/path')).href, + ).toBe('https://example.com/path'); + expect( + resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')) + .href, + ).toBe('https://example.com/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Case sensitivity preserved + expect( + resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href, + ).toBe('https://example.com/PATH'); + // Ports preserved + expect( + resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href, + ).toBe('https://example.com/path'); + expect( + resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href, + ).toBe('https://example.com:8080/path'); + // Query parameters preserved + expect( + resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')) + .href, + ).toBe('https://example.com/?foo=bar&baz=qux'); + // Trailing slashes preserved + expect( + resourceUrlFromServerUrl(new URL('https://example.com/')).href, + ).toBe('https://example.com/'); + expect( + resourceUrlFromServerUrl(new URL('https://example.com/path/')).href, + ).toBe('https://example.com/path/'); + }); + }); + + describe('resourceMatches', () => { + it('should match identical URLs', () => { + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/path', + configuredResource: 'https://example.com/path', + }), + ).toBe(true); + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/', + configuredResource: 'https://example.com/', + }), + ).toBe(true); + }); + + it('should not match URLs with different paths', () => { + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/path1', + configuredResource: 'https://example.com/path2', + }), + ).toBe(false); + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/', + configuredResource: 'https://example.com/path', + }), + ).toBe(false); + }); + + it('should not match URLs with different domains', () => { + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/path', + configuredResource: 'https://example.org/path', + }), + ).toBe(false); + }); + + it('should not match URLs with different ports', () => { + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com:8080/path', + configuredResource: 'https://example.com/path', + }), + ).toBe(false); + }); + + it('should not match URLs where one path is a sub-path of another', () => { + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/mcpxxxx', + configuredResource: 'https://example.com/mcp', + }), + ).toBe(false); + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/folder', + configuredResource: 'https://example.com/folder/subfolder', + }), + ).toBe(false); + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/api/v1', + configuredResource: 'https://example.com/api', + }), + ).toBe(true); + }); + + it('should handle trailing slashes vs no trailing slashes', () => { + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/mcp/', + configuredResource: 'https://example.com/mcp', + }), + ).toBe(true); + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/folder', + configuredResource: 'https://example.com/folder/', + }), + ).toBe(false); + }); + }); +}); diff --git a/packages/mcp/src/version.ts b/packages/mcp/src/version.ts new file mode 100644 index 000000000000..8fda877d6d33 --- /dev/null +++ b/packages/mcp/src/version.ts @@ -0,0 +1,5 @@ +declare const __PACKAGE_VERSION__: string | undefined; +export const VERSION: string = + typeof __PACKAGE_VERSION__ !== 'undefined' + ? __PACKAGE_VERSION__ + : '0.0.0-test'; diff --git a/packages/mcp/tsconfig.build.json b/packages/mcp/tsconfig.build.json new file mode 100644 index 000000000000..904d00c6f97b --- /dev/null +++ b/packages/mcp/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Disable project configuration for tsup builds + "composite": false + } +} + diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 000000000000..083e082aec5d --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "./node_modules/@vercel/ai-tsconfig/ts-library.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "dist", + "build", + "node_modules", + "tsup.config.ts" + ], + "references": [ + { + "path": "../provider" + } + ] +} diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts new file mode 100644 index 000000000000..99634b2f52bb --- /dev/null +++ b/packages/mcp/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + }, + { + entry: ['src/tool/mcp-stdio/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/mcp-stdio', + }, +]); diff --git a/packages/mcp/vitest.edge.config.js b/packages/mcp/vitest.edge.config.js new file mode 100644 index 000000000000..b3233a9bf983 --- /dev/null +++ b/packages/mcp/vitest.edge.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import { readFileSync } from 'node:fs'; +const version = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf-8'), +).version; + +export default defineConfig({ + test: { + environment: 'edge-runtime', + include: ['**/*.test.ts', '**/*.test.tsx'], + }, + define: { + __PACKAGE_VERSION__: JSON.stringify(version), + }, +}); diff --git a/packages/mcp/vitest.node.config.js b/packages/mcp/vitest.node.config.js new file mode 100644 index 000000000000..2db06cf25c62 --- /dev/null +++ b/packages/mcp/vitest.node.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import { readFileSync } from 'node:fs'; +const version = JSON.parse( + readFileSync(new URL('./package.json', import.meta.url), 'utf-8'), +).version; + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts', '**/*.test.tsx'], + }, + define: { + __PACKAGE_VERSION__: JSON.stringify(version), + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f0f9be53c96..4689086ac9db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -398,6 +398,9 @@ importers: specifier: 3.25.76 version: 3.25.76 devDependencies: + '@ai-sdk/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@types/express': specifier: 5.0.0 version: 5.0.0 @@ -800,6 +803,9 @@ importers: '@ai-sdk/groq': specifier: 3.0.0-beta.20 version: link:../../packages/groq + '@ai-sdk/mcp': + specifier: 0.0.0 + version: link:../../packages/mcp '@ai-sdk/mistral': specifier: 3.0.0-beta.21 version: link:../../packages/mistral @@ -2213,6 +2219,37 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/mcp: + dependencies: + '@ai-sdk/provider': + specifier: workspace:* + version: link:../provider + '@ai-sdk/provider-utils': + specifier: workspace:* + version: link:../provider-utils + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 + devDependencies: + '@ai-sdk/test-server': + specifier: workspace:* + version: link:../test-server + '@types/node': + specifier: 20.17.24 + version: 20.17.24 + '@vercel/ai-tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + tsup: + specifier: ^8 + version: 8.3.0(jiti@2.4.0)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.7.0) + typescript: + specifier: 5.8.3 + version: 5.8.3 + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/mistral: dependencies: '@ai-sdk/provider': @@ -18632,7 +18669,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.3(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2) + '@angular-devkit/build-webpack': 0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9)) '@angular-devkit/core': 20.3.3(chokidar@4.0.3) '@angular/build': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(@angular/compiler@20.3.2)(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@20.17.24)(chokidar@4.0.3)(jiti@2.4.0)(less@4.4.0)(postcss@8.5.6)(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.3)))(terser@5.43.1)(tslib@2.8.1)(tsx@4.19.2)(typescript@5.8.3)(vitest@2.1.4(@edge-runtime/vm@5.0.0)(@types/node@22.7.4)(jsdom@26.0.0)(less@4.4.0)(msw@2.7.0(@types/node@22.7.4)(typescript@5.8.3))(sass@1.90.0)(terser@5.43.1))(yaml@2.7.0) '@angular/compiler-cli': 20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3) @@ -18646,13 +18683,13 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2) + '@ngtools/webpack': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) browserslist: 4.25.1 - copy-webpack-plugin: 13.0.1(webpack@5.101.2) - css-loader: 7.1.2(webpack@5.101.2) + copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) + css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -18660,22 +18697,22 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) - license-webpack-plugin: 4.0.2(webpack@5.101.2) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) + license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2) + source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 @@ -18685,7 +18722,7 @@ snapshots: webpack-dev-middleware: 7.4.2(webpack@5.101.2) webpack-dev-server: 5.2.2(webpack@5.101.2) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) optionalDependencies: '@angular/core': 20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)) @@ -18715,7 +18752,7 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-webpack@0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)': + '@angular-devkit/build-webpack@0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular-devkit/architect': 0.2003.3(chokidar@4.0.3) rxjs: 7.8.2 @@ -23717,7 +23754,7 @@ snapshots: '@next/swc-win32-x64-msvc@15.6.0-canary.39': optional: true - '@ngtools/webpack@20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2)': + '@ngtools/webpack@20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular/compiler-cli': 20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3) typescript: 5.8.3 @@ -28299,7 +28336,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2): + babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: '@babel/core': 7.28.3 find-up: 5.0.0 @@ -29016,7 +29053,7 @@ snapshots: dependencies: is-what: 4.1.16 - copy-webpack-plugin@13.0.1(webpack@5.101.2): + copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 @@ -29108,7 +29145,7 @@ snapshots: dependencies: postcss: 8.5.3 - css-loader@7.1.2(webpack@5.101.2): + css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -33061,7 +33098,7 @@ snapshots: dependencies: readable-stream: 2.3.8 - less-loader@12.3.0(less@4.4.0)(webpack@5.101.2): + less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: less: 4.4.0 optionalDependencies: @@ -33088,7 +33125,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.101.2): + license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: webpack-sources: 3.3.3 optionalDependencies: @@ -33863,7 +33900,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.9.4(webpack@5.101.2): + mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)): dependencies: schema-utils: 4.3.2 tapable: 2.2.1 @@ -35289,7 +35326,7 @@ snapshots: tsx: 4.19.2 yaml: 2.7.0 - postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2): + postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: cosmiconfig: 9.0.0(typescript@5.8.3) jiti: 1.21.6 @@ -36201,7 +36238,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2): + sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -36554,7 +36591,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.101.2): + source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -38619,7 +38656,7 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.101.2): + webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: typed-assert: 1.0.9 webpack: 5.101.2(esbuild@0.25.9) diff --git a/tsconfig.json b/tsconfig.json index c9df9aab1a11..4aca4d137c72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "references": [ { "path": "packages/ai" }, + { "path": "packages/mcp" }, { "path": "packages/amazon-bedrock" }, { "path": "packages/angular" }, { "path": "packages/anthropic" },