From bde0cd61ca6cd327cd7c82e5dc6a621902afd7d7 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 1 Oct 2025 16:35:56 -0400 Subject: [PATCH 01/61] stdio transport removed --- .../ai/mcp-stdio/create-child-process.test.ts | 93 ------- packages/ai/mcp-stdio/create-child-process.ts | 21 -- packages/ai/mcp-stdio/get-environment.test.ts | 13 - packages/ai/mcp-stdio/get-environment.ts | 43 --- packages/ai/mcp-stdio/index.ts | 4 - .../ai/mcp-stdio/mcp-stdio-transport.test.ts | 249 ------------------ packages/ai/mcp-stdio/mcp-stdio-transport.ts | 157 ----------- packages/ai/tsup.config.ts | 17 -- 8 files changed, 597 deletions(-) delete mode 100644 packages/ai/mcp-stdio/create-child-process.test.ts delete mode 100644 packages/ai/mcp-stdio/create-child-process.ts delete mode 100644 packages/ai/mcp-stdio/get-environment.test.ts delete mode 100644 packages/ai/mcp-stdio/get-environment.ts delete mode 100644 packages/ai/mcp-stdio/index.ts delete mode 100644 packages/ai/mcp-stdio/mcp-stdio-transport.test.ts delete mode 100644 packages/ai/mcp-stdio/mcp-stdio-transport.ts diff --git a/packages/ai/mcp-stdio/create-child-process.test.ts b/packages/ai/mcp-stdio/create-child-process.test.ts deleted file mode 100644 index 7fb1f08e6f94..000000000000 --- a/packages/ai/mcp-stdio/create-child-process.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as getEnvironmentModule from './get-environment'; - -const DEFAULT_ENV = { - PATH: 'path', -}; - -const mockGetEnvironment = vi - .fn() - .mockImplementation((customEnv?: Record) => { - return { - ...DEFAULT_ENV, - ...customEnv, - }; - }); -vi.spyOn(getEnvironmentModule, 'getEnvironment').mockImplementation( - mockGetEnvironment, -); - -// important: import after mocking getEnv -const { createChildProcess } = await import('./create-child-process'); - -describe('createChildProcess', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should spawn a child process', async () => { - const childProcess = createChildProcess( - { command: process.execPath }, - new AbortController().signal, - ); - - expect(childProcess.pid).toBeDefined(); - expect(mockGetEnvironment).toHaveBeenCalledWith(undefined); - childProcess.kill(); - }); - - it('should spawn a child process with custom env', async () => { - const customEnv = { FOO: 'bar' }; - const childProcessWithCustomEnv = createChildProcess( - { command: process.execPath, env: customEnv }, - new AbortController().signal, - ); - - expect(childProcessWithCustomEnv.pid).toBeDefined(); - expect(mockGetEnvironment).toHaveBeenCalledWith(customEnv); - expect(mockGetEnvironment).toHaveReturnedWith({ - ...DEFAULT_ENV, - ...customEnv, - }); - childProcessWithCustomEnv.kill(); - }); - - it('should spawn a child process with args', async () => { - const childProcessWithArgs = createChildProcess( - { command: process.execPath, args: ['-c', 'echo', 'test'] }, - new AbortController().signal, - ); - - expect(childProcessWithArgs.pid).toBeDefined(); - expect(childProcessWithArgs.spawnargs).toContain(process.execPath); - expect(childProcessWithArgs.spawnargs).toEqual([ - process.execPath, - '-c', - 'echo', - 'test', - ]); - - childProcessWithArgs.kill(); - }); - - it('should spawn a child process with cwd', async () => { - const childProcessWithCwd = createChildProcess( - { command: process.execPath, cwd: '/tmp' }, - new AbortController().signal, - ); - - expect(childProcessWithCwd.pid).toBeDefined(); - childProcessWithCwd.kill(); - }); - - it('should spawn a child process with stderr', async () => { - const childProcessWithStderr = createChildProcess( - { command: process.execPath, stderr: 'pipe' }, - new AbortController().signal, - ); - - expect(childProcessWithStderr.pid).toBeDefined(); - expect(childProcessWithStderr.stderr).toBeDefined(); - childProcessWithStderr.kill(); - }); -}); diff --git a/packages/ai/mcp-stdio/create-child-process.ts b/packages/ai/mcp-stdio/create-child-process.ts deleted file mode 100644 index 005c1b2a0a1f..000000000000 --- a/packages/ai/mcp-stdio/create-child-process.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ChildProcess, spawn } from 'node:child_process'; -import { getEnvironment } from './get-environment'; -import { StdioConfig } from './mcp-stdio-transport'; - -export function createChildProcess( - config: StdioConfig, - signal: AbortSignal, -): ChildProcess { - return spawn(config.command, config.args ?? [], { - env: getEnvironment(config.env), - stdio: ['pipe', 'pipe', config.stderr ?? 'inherit'], - shell: false, - signal, - windowsHide: globalThis.process.platform === 'win32' && isElectron(), - cwd: config.cwd, - }); -} - -function isElectron() { - return 'type' in globalThis.process; -} diff --git a/packages/ai/mcp-stdio/get-environment.test.ts b/packages/ai/mcp-stdio/get-environment.test.ts deleted file mode 100644 index 1ea0bb900766..000000000000 --- a/packages/ai/mcp-stdio/get-environment.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getEnvironment } from './get-environment'; - -describe('getEnvironment', () => { - it('should not mutate the original custom environment object', () => { - const customEnv = { CUSTOM_VAR: 'custom_value' }; - - const result = getEnvironment(customEnv); - - expect(customEnv).toStrictEqual({ CUSTOM_VAR: 'custom_value' }); - expect(result).not.toBe(customEnv); - }); -}); diff --git a/packages/ai/mcp-stdio/get-environment.ts b/packages/ai/mcp-stdio/get-environment.ts deleted file mode 100644 index d22b7f854caf..000000000000 --- a/packages/ai/mcp-stdio/get-environment.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Constructs the environment variables for the child process. - * - * @param customEnv - Custom environment variables to merge with default environment variables. - * @returns The environment variables for the child process. - */ -export function getEnvironment( - customEnv?: Record, -): Record { - const DEFAULT_INHERITED_ENV_VARS = - globalThis.process.platform === 'win32' - ? [ - 'APPDATA', - 'HOMEDRIVE', - 'HOMEPATH', - 'LOCALAPPDATA', - 'PATH', - 'PROCESSOR_ARCHITECTURE', - 'SYSTEMDRIVE', - 'SYSTEMROOT', - 'TEMP', - 'USERNAME', - 'USERPROFILE', - ] - : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; - - const env: Record = customEnv ? { ...customEnv } : {}; - - for (const key of DEFAULT_INHERITED_ENV_VARS) { - const value = globalThis.process.env[key]; - if (value === undefined) { - continue; - } - - if (value.startsWith('()')) { - continue; - } - - env[key] = value; - } - - return env; -} diff --git a/packages/ai/mcp-stdio/index.ts b/packages/ai/mcp-stdio/index.ts deleted file mode 100644 index f23ec3378ad7..000000000000 --- a/packages/ai/mcp-stdio/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - StdioMCPTransport as Experimental_StdioMCPTransport, - type StdioConfig, -} from './mcp-stdio-transport'; diff --git a/packages/ai/mcp-stdio/mcp-stdio-transport.test.ts b/packages/ai/mcp-stdio/mcp-stdio-transport.test.ts deleted file mode 100644 index 2e627651786b..000000000000 --- a/packages/ai/mcp-stdio/mcp-stdio-transport.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -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 { createChildProcess } from './create-child-process'; -import { StdioMCPTransport } from './mcp-stdio-transport'; - -vi.mock('./create-child-process', { spy: true }); - -interface MockChildProcess { - stdin: EventEmitter & { write?: ReturnType }; - stdout: EventEmitter; - stderr: EventEmitter; - on: ReturnType; - removeAllListeners: ReturnType; -} - -describe('StdioMCPTransport', () => { - let transport: StdioMCPTransport; - let mockChildProcess: MockChildProcess; - let mockStdin: EventEmitter & { write?: ReturnType }; - let mockStdout: EventEmitter; - - beforeEach(() => { - vi.clearAllMocks(); - - mockStdin = new EventEmitter(); - mockStdout = new EventEmitter(); - mockChildProcess = { - stdin: mockStdin, - stdout: mockStdout, - stderr: new EventEmitter(), - on: vi.fn(), - removeAllListeners: vi.fn(), - }; - - vi.mocked(createChildProcess).mockReturnValue( - mockChildProcess as unknown as ChildProcess, - ); - - transport = new StdioMCPTransport({ - command: 'test-command', - args: ['--test'], - }); - }); - - afterEach(() => { - transport.close(); - }); - - describe('start', () => { - it('should successfully start the transport', async () => { - const stdinOnSpy = vi.spyOn(mockStdin, 'on'); - const stdoutOnSpy = vi.spyOn(mockStdout, 'on'); - - mockChildProcess.on.mockImplementation( - (event: string, callback: () => void) => { - if (event === 'spawn') { - callback(); - } - }, - ); - - const startPromise = transport.start(); - await expect(startPromise).resolves.toBeUndefined(); - - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'error', - expect.any(Function), - ); - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'spawn', - expect.any(Function), - ); - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'close', - expect.any(Function), - ); - - expect(stdinOnSpy).toHaveBeenCalledWith('error', expect.any(Function)); - expect(stdoutOnSpy).toHaveBeenCalledWith('error', expect.any(Function)); - expect(stdoutOnSpy).toHaveBeenCalledWith('data', expect.any(Function)); - }); - - it('should throw error if already started', async () => { - mockChildProcess.on.mockImplementation( - (event: string, callback: () => void) => { - if (event === 'spawn') { - callback(); - } - }, - ); - const firstStart = transport.start(); - await expect(firstStart).resolves.toBeUndefined(); - const secondStart = transport.start(); - await expect(secondStart).rejects.toThrow(MCPClientError); - }); - - it('should handle spawn errors', async () => { - const error = new Error('Spawn failed'); - const onErrorSpy = vi.fn(); - transport.onerror = onErrorSpy; - - // simulate `spawn` failure by emitting error event after returning child process - mockChildProcess.on.mockImplementation( - (event: string, callback: (err: Error) => void) => { - if (event === 'error') { - callback(error); - } - }, - ); - - const startPromise = transport.start(); - await expect(startPromise).rejects.toThrow('Spawn failed'); - expect(onErrorSpy).toHaveBeenCalledWith(error); - }); - }); - - describe('send', () => { - beforeEach(async () => { - mockChildProcess.on.mockImplementation( - (event: string, callback: () => void) => { - if (event === 'spawn') { - callback(); - } - }, - ); - await transport.start(); - }); - - it('should successfully send a message', async () => { - const message: JSONRPCMessage = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {}, - }; - - mockStdin.write = vi.fn().mockReturnValue(true); - - await transport.send(message); - - expect(mockStdin.write).toHaveBeenCalledWith( - JSON.stringify(message) + '\n', - ); - }); - - it('should handle write backpressure', async () => { - const message: JSONRPCMessage = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {}, - }; - - mockStdin.write = vi.fn().mockReturnValue(false); - - const sendPromise = transport.send(message); - - mockStdin.emit('drain'); - - await expect(sendPromise).resolves.toBeUndefined(); - }); - - it('should throw error if transport is not connected', async () => { - await transport.close(); - - const message: JSONRPCMessage = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {}, - }; - - await expect(transport.send(message)).rejects.toThrow(MCPClientError); - }); - }); - - describe('message handling', () => { - const onMessageSpy = vi.fn(); - - beforeEach(async () => { - mockChildProcess.on.mockImplementation( - (event: string, callback: () => void) => { - if (event === 'spawn') { - callback(); - } - }, - ); - transport.onmessage = onMessageSpy; - await transport.start(); - }); - - it('should handle incoming messages correctly', async () => { - const message: JSONRPCMessage = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {}, - }; - - mockStdout.emit('data', Buffer.from(JSON.stringify(message) + '\n')); - expect(onMessageSpy).toHaveBeenCalledWith(message); - }); - - it('should handle partial messages correctly', async () => { - const message = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {}, - }; - - const messageStr = JSON.stringify(message); - mockStdout.emit('data', Buffer.from(messageStr.slice(0, 10))); - mockStdout.emit('data', Buffer.from(messageStr.slice(10) + '\n')); - expect(onMessageSpy).toHaveBeenCalledWith(message); - }); - }); - - describe('close', () => { - const onCloseSpy = vi.fn(); - - beforeEach(async () => { - mockChildProcess.on.mockImplementation( - (event: string, callback: (code?: number) => void) => { - if (event === 'spawn') { - callback(); - } else if (event === 'close') { - callback(0); - } - }, - ); - transport.onclose = onCloseSpy; - await transport.start(); - }); - - it('should close the transport successfully', async () => { - await transport.close(); - - expect(mockChildProcess.on).toHaveBeenCalledWith( - 'close', - expect.any(Function), - ); - expect(onCloseSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/ai/mcp-stdio/mcp-stdio-transport.ts b/packages/ai/mcp-stdio/mcp-stdio-transport.ts deleted file mode 100644 index c4ab4ac6c64f..000000000000 --- a/packages/ai/mcp-stdio/mcp-stdio-transport.ts +++ /dev/null @@ -1,157 +0,0 @@ -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 { createChildProcess } from './create-child-process'; - -export interface StdioConfig { - command: string; - args?: string[]; - env?: Record; - stderr?: IOType | Stream | number; - cwd?: string; -} - -export class StdioMCPTransport implements MCPTransport { - private process?: ChildProcess; - private abortController: AbortController = new AbortController(); - private readBuffer: ReadBuffer = new ReadBuffer(); - private serverParams: StdioConfig; - - onclose?: () => void; - onerror?: (error: unknown) => void; - onmessage?: (message: JSONRPCMessage) => void; - - constructor(server: StdioConfig) { - this.serverParams = server; - } - - async start(): Promise { - if (this.process) { - throw new MCPClientError({ - message: 'StdioMCPTransport already started.', - }); - } - - return new Promise((resolve, reject) => { - try { - const process = createChildProcess( - this.serverParams, - this.abortController.signal, - ); - - this.process = process; - - this.process.on('error', error => { - if (error.name === 'AbortError') { - this.onclose?.(); - return; - } - - reject(error); - this.onerror?.(error); - }); - - this.process.on('spawn', () => { - resolve(); - }); - - this.process.on('close', _code => { - this.process = undefined; - this.onclose?.(); - }); - - this.process.stdin?.on('error', error => { - this.onerror?.(error); - }); - - this.process.stdout?.on('data', chunk => { - this.readBuffer.append(chunk); - this.processReadBuffer(); - }); - - this.process.stdout?.on('error', error => { - this.onerror?.(error); - }); - } catch (error) { - reject(error); - this.onerror?.(error); - } - }); - } - - private processReadBuffer() { - while (true) { - try { - const message = this.readBuffer.readMessage(); - if (message === null) { - break; - } - - this.onmessage?.(message); - } catch (error) { - this.onerror?.(error as Error); - } - } - } - - async close(): Promise { - this.abortController.abort(); - this.process = undefined; - this.readBuffer.clear(); - } - - send(message: JSONRPCMessage): Promise { - return new Promise(resolve => { - if (!this.process?.stdin) { - throw new MCPClientError({ - message: 'StdioClientTransport not connected', - }); - } - - const json = serializeMessage(message); - if (this.process.stdin.write(json)) { - resolve(); - } else { - this.process.stdin.once('drain', resolve); - } - }); - } -} - -class ReadBuffer { - private buffer?: Buffer; - - append(chunk: Buffer): void { - this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk; - } - - readMessage(): JSONRPCMessage | null { - if (!this.buffer) return null; - - const index = this.buffer.indexOf('\n'); - if (index === -1) { - return null; - } - - const line = this.buffer.toString('utf8', 0, index); - this.buffer = this.buffer.subarray(index + 1); - return deserializeMessage(line); - } - - clear(): void { - this.buffer = undefined; - } -} - -function serializeMessage(message: JSONRPCMessage): string { - return JSON.stringify(message) + '\n'; -} - -export function deserializeMessage(line: string): JSONRPCMessage { - return JSONRPCMessageSchema.parse(JSON.parse(line)); -} 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, - ), - }, - }, ]); From f6d5478525f5d35e8b38b3761f469e55317c1711 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 1 Oct 2025 17:13:02 -0400 Subject: [PATCH 02/61] cs --- .changeset/fluffy-poems-live.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fluffy-poems-live.md diff --git a/.changeset/fluffy-poems-live.md b/.changeset/fluffy-poems-live.md new file mode 100644 index 000000000000..b397ff8c21f0 --- /dev/null +++ b/.changeset/fluffy-poems-live.md @@ -0,0 +1,5 @@ +--- +'ai': patch +--- + +fix(ai): refactor mcp From cb98c982d1906c5df5cf7f26f66a8bc76d632728 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 3 Oct 2025 14:04:47 -0400 Subject: [PATCH 03/61] client oauth provider added --- packages/ai/src/tool/mcp/oauth.test.ts | 80 ++++++++++++++++++++++++++ packages/ai/src/tool/mcp/oauth.ts | 46 +++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/ai/src/tool/mcp/oauth.test.ts create mode 100644 packages/ai/src/tool/mcp/oauth.ts diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts new file mode 100644 index 000000000000..e0fa63ca6ba9 --- /dev/null +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + extractResourceMetadataUrl, + type OAuthClientProvider, + type AuthResult, +} from './oauth'; + +const makeResponse = (status: number, headers: Record): Response => + new Response('', { status, headers }); + +describe('extractResourceMetadataUrl', () => { + it('extracts URL from WWW-Authenticate resource parameter', () => { + const url = 'https://mcp.example.com/.well-known/oauth-protected-resource'; + const response = makeResponse(401, { + 'WWW-Authenticate': `Bearer resource="${url}"`, + }); + + const result = extractResourceMetadataUrl(response); + expect(result).toBeInstanceOf(URL); + expect(result?.href).toBe(`${url}`); + }); + + it('returns undefined when header is missing', () => { + const response = makeResponse(401, {}); + const result = extractResourceMetadataUrl(response); + expect(result).toBeUndefined(); + }); + + it('returns undefined when resource parameter is missing', () => { + const response = makeResponse(401, { + 'WWW-Authenticate': 'Bearer error=', + }); + const result = extractResourceMetadataUrl(response); + expect(result).toBeUndefined(); + }); + + it('returns undefined when resource parameter is not a valid URL', () => { + const url = 'www.mcp.example.com/.well-known/oauth-protected-resource'; // invalid url example (missing https://) + const response = makeResponse(401, { + 'WWW-Authenticate': `Bearer resource="${url}"`, + }); + const result = extractResourceMetadataUrl(response); + expect(result).toBeUndefined(); + }); +}); + + +describe('OAuthClientProvider (example implementation)', () => { + class MemoryOAuthProvider implements OAuthClientProvider { + private token?: string; + + async tokens(): Promise<{ access_token: string } | null> { + return this.token ? { access_token: this.token } : null; + } + + async authorize(options: { + serverUrl: URL; + resourceMetadataUrl?: URL; + }): Promise { + this.token = `test-token-${options.serverUrl.host}`; + return 'AUTHORIZED'; + } + } + + it('returns null before authorize and a token after authorize', async () => { + const provider = new MemoryOAuthProvider(); + + const before = await provider.tokens(); + expect(before).toBeNull(); + + const result = await provider.authorize({ + serverUrl: new URL('https://mcp.example.com'), + }); + expect(result).toBe('AUTHORIZED'); + + const after = await provider.tokens(); + expect(after).not.toBeNull(); + expect(after?.access_token).toBe('test-token-mcp.example.com'); + }); +}); \ No newline at end of file diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts new file mode 100644 index 000000000000..7f5bf1b60d33 --- /dev/null +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -0,0 +1,46 @@ +export type AuthResult = 'AUTHORIZED' | 'UNAUTHORIZED'; + +export interface OAuthClientProvider { + /** + * Returns current access token if present; null otherwise. + */ + tokens(): Promise<{ access_token: string } | null>; + + /** + * Performs (or completes) OAuth for the given server. + * Should persist tokens so subsequent tokens() calls return the new access_token. + */ + authorize(options: { + serverUrl: URL; + resourceMetadataUrl?: URL; + }): 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; + + // Example: WWW-Authenticate: Bearer resource="https://mcp.example.com/.well-known/oauth-protected-resource" + // covers https, http, wss + const match = header.match(/resource="([^"]+)"/i); + if (!match) return undefined; + + try { + return new URL(match[1]); + } catch { + return undefined; + } +} From 4027242fc4e24284db467c6b0bf27cc43256f0e1 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 3 Oct 2025 16:21:10 -0400 Subject: [PATCH 04/61] adding authProvider to transport config --- .../ai/src/tool/mcp/mcp-sse-transport.test.ts | 4 + packages/ai/src/tool/mcp/mcp-sse-transport.ts | 128 +++++++++++++----- packages/ai/src/tool/mcp/mcp-transport.ts | 6 + packages/ai/src/tool/mcp/oauth.test.ts | 9 +- packages/ai/src/tool/mcp/oauth.ts | 6 +- 5 files changed, 112 insertions(+), 41 deletions(-) diff --git a/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts b/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts index 4fafb87718e4..11ac168af71d 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts +++ b/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts @@ -5,6 +5,7 @@ import { 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/ai/src/tool/mcp/mcp-sse-transport.ts index 5ca2be0b93f4..7d9c0523c0b3 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.ts +++ b/packages/ai/src/tool/mcp/mcp-sse-transport.ts @@ -7,6 +7,12 @@ 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, +} from './oauth'; +import { LATEST_PROTOCOL_VERSION } from './types'; export class SseMCPTransport implements MCPTransport { private endpoint?: URL; @@ -17,6 +23,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 +33,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,21 +75,31 @@ 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); + const result = await this.authProvider.authorize({ + serverUrl: this.url, + resourceMetadataUrl: this.resourceMetadataUrl, + }); + + if (result !== 'AUTHORIZED') { + const error = new UnauthorizedError(); + this.onerror?.(error); + return reject(error); + } + return establishConnection(true); + } + if (!response.ok || !response.body) { const error = new MCPClientError({ message: `MCP SSE Transport Error: ${response.status} ${response.statusText}`, @@ -141,7 +185,7 @@ export class SseMCPTransport implements MCPTransport { } }; - establishConnection(); + void establishConnection(); }); } @@ -159,36 +203,50 @@ export class SseMCPTransport implements MCPTransport { }); } - try { - const headers = withUserAgentSuffix( - { - ...this.headers, - 'Content-Type': 'application/json', - }, - `ai-sdk/${VERSION}`, - getRuntimeEnvironmentUserAgent(), - ); - const init = { - method: 'POST', - headers, - body: JSON.stringify(message), - signal: this.abortController?.signal, - }; - - const response = await fetch(this.endpoint, init); + const endpoint = this.endpoint as URL; - 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}`, + const attempt = async (triedAuth: boolean = false): Promise => { + try { + const headers = await this.commonHeaders({ + 'Content-Type': 'application/json', }); + const init = { + method: 'POST', + headers, + body: JSON.stringify(message), + signal: this.abortController?.signal, + }; + + const response = await fetch(endpoint, init); + + if (response.status === 401 && this.authProvider && !triedAuth) { + this.resourceMetadataUrl = extractResourceMetadataUrl(response); + const result = await this.authProvider.authorize({ + serverUrl: this.url, + resourceMetadataUrl: this.resourceMetadataUrl, + }); + if (result !== 'AUTHORIZED') { + const error = new UnauthorizedError(); + 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/src/tool/mcp/mcp-transport.ts b/packages/ai/src/tool/mcp/mcp-transport.ts index e631eadd0538..2a3405d3405e 100644 --- a/packages/ai/src/tool/mcp/mcp-transport.ts +++ b/packages/ai/src/tool/mcp/mcp-transport.ts @@ -1,6 +1,7 @@ import { MCPClientError } from '../../error/mcp-client-error'; import { JSONRPCMessage } from './json-rpc-message'; import { SseMCPTransport } from './mcp-sse-transport'; +import { OAuthClientProvider } from './oauth'; /** * Transport interface for MCP (Model Context Protocol) communication. @@ -51,6 +52,11 @@ 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 { diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index e0fa63ca6ba9..157a07692798 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -5,8 +5,10 @@ import { type AuthResult, } from './oauth'; -const makeResponse = (status: number, headers: Record): Response => - new Response('', { status, headers }); +const makeResponse = ( + status: number, + headers: Record, +): Response => new Response('', { status, headers }); describe('extractResourceMetadataUrl', () => { it('extracts URL from WWW-Authenticate resource parameter', () => { @@ -44,7 +46,6 @@ describe('extractResourceMetadataUrl', () => { }); }); - describe('OAuthClientProvider (example implementation)', () => { class MemoryOAuthProvider implements OAuthClientProvider { private token?: string; @@ -77,4 +78,4 @@ describe('OAuthClientProvider (example implementation)', () => { expect(after).not.toBeNull(); expect(after?.access_token).toBe('test-token-mcp.example.com'); }); -}); \ No newline at end of file +}); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 7f5bf1b60d33..9f9c88b77a86 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -27,14 +27,16 @@ export class UnauthorizedError extends Error { * 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 { +export function extractResourceMetadataUrl( + response: Response, +): URL | undefined { const header = response.headers.get('www-authenticate') ?? response.headers.get('WWW-Authenticate'); if (!header) return undefined; // Example: WWW-Authenticate: Bearer resource="https://mcp.example.com/.well-known/oauth-protected-resource" - // covers https, http, wss + // covers https, http, wss const match = header.match(/resource="([^"]+)"/i); if (!match) return undefined; From 66e9d374d70e0db4cf6cd133f030785a3f0a5773 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 3 Oct 2025 17:31:18 -0400 Subject: [PATCH 05/61] example to iterate on --- examples/mcp/package.json | 2 + examples/mcp/src/sse-with-auth/client.ts | 106 +++++++++++ examples/mcp/src/sse-with-auth/server.ts | 218 +++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 examples/mcp/src/sse-with-auth/client.ts create mode 100644 examples/mcp/src/sse-with-auth/server.ts diff --git a/examples/mcp/package.json b/examples/mcp/package.json index a524f258f74f..044202f9f792 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/sse-with-auth/server.ts", + "sse-auth:client": "tsx src/sse-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", diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts new file mode 100644 index 000000000000..fd87b5c8b643 --- /dev/null +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -0,0 +1,106 @@ +import { openai } from '@ai-sdk/openai'; +import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; +import 'dotenv/config'; +import type { + OAuthClientProvider, + AuthResult, +} from '../../../../packages/ai/src/tool/mcp/oauth.js'; + +// Simple OAuth provider that pre-configures a token for demo purposes +class DemoOAuthProvider implements OAuthClientProvider { + private accessToken: string | null = null; + + async tokens(): Promise<{ access_token: string } | null> { + return this.accessToken ? { access_token: this.accessToken } : null; + } + + async authorize(options: { + serverUrl: URL; + resourceMetadataUrl?: URL; + }): Promise { + console.log(' → Authorizing with server:', options.serverUrl.toString()); + if (options.resourceMetadataUrl) { + console.log( + ' → Resource metadata URL:', + options.resourceMetadataUrl.toString(), + ); + } + + // In a real implementation, this would: + // 1. Discover OAuth endpoints via metadata + // 2. Register as a client (if needed) + // 3. Get authorization code + // 4. Exchange code for token + + // For this demo, we use a pre-configured token that the server accepts + this.accessToken = 'demo-access-token-123'; + console.log(' → Token acquired:', this.accessToken); + console.log(' → Authorization complete, transport will retry with token'); + + return 'AUTHORIZED'; + } +} + +async function main() { + const authProvider = new DemoOAuthProvider(); + + console.log('Creating MCP client with OAuth...'); + + try { + // Attempt to create MCP client with auth + const mcpClient = await experimental_createMCPClient({ + transport: { + type: 'sse', + url: 'http://localhost:8081/sse', + authProvider, + }, + onUncaughtError: error => { + console.error('MCP Client uncaught error:', error); + }, + }); + + console.log('✓ MCP client connected with OAuth authentication'); + + 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 resources.', + prompt: + 'List the user resources, then retrieve secret data for key "api-key-1".', + }); + + await mcpClient.close(); + + console.log('\n=== Final Answer ==='); + console.log(answer); + console.log('\n✓ MCP client closed'); + } catch (error) { + console.error('\n❌ Error during MCP client execution:'); + console.error(error); + throw error; + } +} + +// Handle OAuth callback in a real application +// For this demo, the server auto-approves and the provider handles it internally +main().catch(error => { + console.error('\n❌ Fatal error:', error); + process.exit(1); +}); diff --git a/examples/mcp/src/sse-with-auth/server.ts b/examples/mcp/src/sse-with-auth/server.ts new file mode 100644 index 000000000000..c603f849c8e0 --- /dev/null +++ b/examples/mcp/src/sse-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'); +}); From 07a41a7e00314f6c40c1a330e582cdb7d304326a Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 11:36:19 -0400 Subject: [PATCH 06/61] adding proper types and schema --- packages/ai/src/tool/mcp/oauth-types.ts | 67 ++++ packages/ai/src/tool/mcp/oauth.test.ts | 490 +++++++++++++++++++++--- packages/ai/src/tool/mcp/oauth.ts | 167 +++++++- 3 files changed, 665 insertions(+), 59 deletions(-) create mode 100644 packages/ai/src/tool/mcp/oauth-types.ts diff --git a/packages/ai/src/tool/mcp/oauth-types.ts b/packages/ai/src/tool/mcp/oauth-types.ts new file mode 100644 index 000000000000..beba9a767f91 --- /dev/null +++ b/packages/ai/src/tool/mcp/oauth-types.ts @@ -0,0 +1,67 @@ +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 type OAuthTokens = z.infer; +export type OAuthProtectedResourceMetadata = z.infer< + typeof OAuthProtectedResourceMetadataSchema +>; diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 157a07692798..917cdf24c34d 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -1,81 +1,467 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { extractResourceMetadataUrl, type OAuthClientProvider, type AuthResult, + discoverOAuthProtectedResourceMetadata, } from './oauth'; +import { LATEST_PROTOCOL_VERSION } from './types'; -const makeResponse = ( - status: number, - headers: Record, -): Response => new Response('', { status, headers }); +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +beforeEach(() => { + mockFetch.mockReset(); +}); describe('extractResourceMetadataUrl', () => { - it('extracts URL from WWW-Authenticate resource parameter', () => { - const url = 'https://mcp.example.com/.well-known/oauth-protected-resource'; - const response = makeResponse(401, { - 'WWW-Authenticate': `Bearer resource="${url}"`, + 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 result = extractResourceMetadataUrl(response); - expect(result).toBeInstanceOf(URL); - expect(result?.href).toBe(`${url}`); + 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 undefined when header is missing', () => { - const response = makeResponse(401, {}); - const result = extractResourceMetadataUrl(response); - expect(result).toBeUndefined(); + 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('returns undefined when resource parameter is missing', () => { - const response = makeResponse(401, { - 'WWW-Authenticate': 'Bearer error=', + 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')); + } }); - const result = extractResourceMetadataUrl(response); - expect(result).toBeUndefined(); + + // 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('returns undefined when resource parameter is not a valid URL', () => { - const url = 'www.mcp.example.com/.well-known/oauth-protected-resource'; // invalid url example (missing https://) - const response = makeResponse(401, { - 'WWW-Authenticate': `Bearer resource="${url}"`, + it('throws on 404 errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, }); - const result = extractResourceMetadataUrl(response); - expect(result).toBeUndefined(); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com'), + ).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.', + ); }); -}); -describe('OAuthClientProvider (example implementation)', () => { - class MemoryOAuthProvider implements OAuthClientProvider { - private token?: string; + 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, + }); - async tokens(): Promise<{ access_token: string } | null> { - return this.token ? { access_token: this.token } : null; - } + const metadata = await discoverOAuthProtectedResourceMetadata( + 'https://resource.example.com/path/name', + ); + expect(metadata).toEqual(validMetadata); - async authorize(options: { - serverUrl: URL; - resourceMetadataUrl?: URL; - }): Promise { - this.token = `test-token-${options.serverUrl.host}`; - return 'AUTHORIZED'; - } - } + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); - it('returns null before authorize and a token after authorize', async () => { - const provider = new MemoryOAuthProvider(); + // 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, + }); - const before = await provider.tokens(); - expect(before).toBeNull(); + // 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, + }); + }, + ); - const result = await provider.authorize({ - serverUrl: new URL('https://mcp.example.com'), + 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, }); - expect(result).toBe('AUTHORIZED'); - const after = await provider.tokens(); - expect(after).not.toBeNull(); - expect(after?.access_token).toBe('test-token-mcp.example.com'); + // 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, + }); }); }); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 9f9c88b77a86..52a24a8e21f7 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -1,10 +1,18 @@ +import { + OAuthTokens, + OAuthProtectedResourceMetadata, + OAuthProtectedResourceMetadataSchema, +} from './oauth-types'; +import { LATEST_PROTOCOL_VERSION } from './types'; +import { FetchFunction } from '@ai-sdk/provider-utils'; + export type AuthResult = 'AUTHORIZED' | 'UNAUTHORIZED'; export interface OAuthClientProvider { /** - * Returns current access token if present; null otherwise. + * Returns current access token if present; undefined otherwise. */ - tokens(): Promise<{ access_token: string } | null>; + tokens(): OAuthTokens | undefined | Promise; /** * Performs (or completes) OAuth for the given server. @@ -33,12 +41,22 @@ export function extractResourceMetadataUrl( const header = response.headers.get('www-authenticate') ?? response.headers.get('WWW-Authenticate'); - if (!header) return undefined; + if (!header) { + return undefined; + } - // Example: WWW-Authenticate: Bearer resource="https://mcp.example.com/.well-known/oauth-protected-resource" - // covers https, http, wss - const match = header.match(/resource="([^"]+)"/i); - if (!match) return undefined; + const [type, scheme] = header.split(' '); + if (type.toLowerCase() !== 'bearer' || !scheme) { + return undefined; + } + + // Example: WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" + // regex taken from MCP spec + const regex = /resource_metadata="([^"]*)"/; + const match = header.match(regex); + if (!match) { + return undefined; + } try { return new URL(match[1]); @@ -46,3 +64,138 @@ export function extractResourceMetadataUrl( 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 { + // Strip trailing slash from pathname to avoid double slashes + 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) { + // CORS errors come back as TypeError, retry without headers + return fetchWithCorsRetry(url, undefined, fetchFn); + } else { + // We're getting CORS errors on retry too, return undefined + 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 { + // Try path-aware discovery first + 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 path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery + 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()); +} From 5d4ca2c24be8cbc95ecf67159df8c2939c35ea83 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 13:13:52 -0400 Subject: [PATCH 07/61] build discovery URLs (for oauth 2.0 and oidc) to get auth server metadata --- packages/ai/src/tool/mcp/oauth-types.ts | 67 +++++++ packages/ai/src/tool/mcp/oauth.test.ts | 245 ++++++++++++++++++++++++ packages/ai/src/tool/mcp/oauth.ts | 131 +++++++++++++ 3 files changed, 443 insertions(+) diff --git a/packages/ai/src/tool/mcp/oauth-types.ts b/packages/ai/src/tool/mcp/oauth-types.ts index beba9a767f91..18dc191fe75d 100644 --- a/packages/ai/src/tool/mcp/oauth-types.ts +++ b/packages/ai/src/tool/mcp/oauth-types.ts @@ -61,7 +61,74 @@ export const OAuthProtectedResourceMetadataSchema = z }) .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()), + 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()), + subject_types_supported: z.array(z.string()), + id_token_signing_alg_values_supported: z.array(z.string()), + claims_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 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; diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 917cdf24c34d..63da1977f956 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -4,6 +4,8 @@ import { type OAuthClientProvider, type AuthResult, discoverOAuthProtectedResourceMetadata, + buildDiscoveryUrls, + discoverAuthorizationServerMetadata, } from './oauth'; import { LATEST_PROTOCOL_VERSION } from './types'; @@ -465,3 +467,246 @@ describe('discoverOAuthProtectedResourceMetadata', () => { }); }); }); + +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) + }); +}); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 52a24a8e21f7..40edb37082b0 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -2,6 +2,10 @@ import { OAuthTokens, OAuthProtectedResourceMetadata, OAuthProtectedResourceMetadataSchema, + OAuthMetadataSchema, + OpenIdProviderDiscoveryMetadataSchema, + AuthorizationServerMetadata, + OAuthClientInformation, } from './oauth-types'; import { LATEST_PROTOCOL_VERSION } from './types'; import { FetchFunction } from '@ai-sdk/provider-utils'; @@ -199,3 +203,130 @@ export async function discoverOAuthProtectedResourceMetadata( } 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) { + // Root path: https://example.com/.well-known/oauth-authorization-server + 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; + } + + // Strip trailing slash from pathname to avoid double slashes + let pathname = url.pathname; + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + // 1. OAuth metadata at the given URL + // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1 + urlsToTry.push({ + url: new URL( + `/.well-known/oauth-authorization-server${pathname}`, + url.origin, + ), + type: 'oauth', + }); + + // Root path: https://example.com/.well-known/oauth-authorization-server + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth', + }); + + // 3. OIDC metadata endpoints + //RFC 8414 style: Insert /.well-known/openid-configuration before the path + urlsToTry.push({ + url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin), + type: 'oidc', + }); + + // OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path + 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; // Try next URL + } + throw new Error( + `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`, + ); + } + + // Parse and validate based on type + 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; +} From 542943b86ba832c3d0c72b8838fad426c043b66a Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 14:01:47 -0400 Subject: [PATCH 08/61] start auth flow with pkce generation --- packages/ai/package.json | 3 +- packages/ai/src/tool/mcp/oauth.test.ts | 168 +++++++++++++++++++++++++ packages/ai/src/tool/mcp/oauth.ts | 80 ++++++++++++ pnpm-lock.yaml | 157 ++++++++++++++--------- 4 files changed, 350 insertions(+), 58 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index 5d77db68abc6..0178ce4df252 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -57,7 +57,8 @@ "@ai-sdk/gateway": "workspace:*", "@ai-sdk/provider": "workspace:*", "@ai-sdk/provider-utils": "workspace:*", - "@opentelemetry/api": "1.9.0" + "@opentelemetry/api": "1.9.0", + "pkce-challenge": "^5.0.0" }, "devDependencies": { "@ai-sdk/test-server": "workspace:*", diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 63da1977f956..0a731e79e189 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -6,9 +6,18 @@ import { discoverOAuthProtectedResourceMetadata, buildDiscoveryUrls, discoverAuthorizationServerMetadata, + startAuthorization, } from './oauth'; 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; @@ -710,3 +719,162 @@ describe('discoverAuthorizationServerMetadata', () => { 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/); + }); +}); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 40edb37082b0..859e1c62818c 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -1,3 +1,4 @@ +import pkceChallenge from 'pkce-challenge'; import { OAuthTokens, OAuthProtectedResourceMetadata, @@ -330,3 +331,82 @@ export async function discoverAuthorizationServerMetadata( 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); + } + + // Generate PKCE challenge + 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 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 420e814675d4..d4e0f35533e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1341,7 +1341,7 @@ importers: version: link:../../packages/ai autoprefixer: specifier: ^10.4.20 - version: 10.4.21(postcss@8.5.3) + version: 10.4.21(postcss@8.5.6) bits-ui: specifier: ^1.3.9 version: 1.3.9(svelte@5.32.1) @@ -1405,6 +1405,9 @@ importers: '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: '@ai-sdk/test-server': specifier: workspace:* @@ -2590,7 +2593,7 @@ importers: version: link:../../../../ai next: specifier: canary - version: 15.6.0-canary.39(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0) + version: 15.6.0-canary.45(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0) react: specifier: rc version: 19.0.0-rc.1 @@ -7026,8 +7029,8 @@ packages: '@next/env@15.5.4': resolution: {integrity: sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==} - '@next/env@15.6.0-canary.39': - resolution: {integrity: sha512-WvJxtTel5Yt+z1QmCfgdFTOwk3edEzvhh6G1AuV45g2JJfx/8PljYEGVApJlUe2pjfKpW3K3K56qmeaaa+h7pw==} + '@next/env@15.6.0-canary.45': + resolution: {integrity: sha512-War8PoLqM7B5j+CnC585dTyNRTHmCBi6V5Qgg1zavikzhIoZ1913iMR6+B8nezz2VlaPzAp5me3uthvLuo1tAA==} '@next/eslint-plugin-next@14.2.3': resolution: {integrity: sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==} @@ -7044,8 +7047,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@15.6.0-canary.39': - resolution: {integrity: sha512-/JP8D3GHAHiPhRw4Tl1sRDvkijzciieWgmYdhEsg14V4ElZmk8cTZVJIybh9Vv/BN3avOfMZ0N2KT+b2K/ZdRg==} + '@next/swc-darwin-arm64@15.6.0-canary.45': + resolution: {integrity: sha512-RW48Ho4j1vm5wjF2CY6hBkjiU5WRrWKQUsTGiNPyqE6zJXTOYB45zVkK7GQKwG4g0vCvZq4QeM9iI0qzQlYyxA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -7062,8 +7065,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@15.6.0-canary.39': - resolution: {integrity: sha512-ia/xFDPLliYYzPTdJ+LhQTuaxvgmWYuddutA1JfZyBu511Q5P2h8NnxOonME+N4kXgi1dp0wezgZ4H58J31fnQ==} + '@next/swc-darwin-x64@15.6.0-canary.45': + resolution: {integrity: sha512-WiFOeob23e2A0L6fD1mkU2M8zX//q4N6lqNm7By+NNUNqM87TMkzo1aa+6ixSLLG6wytfM38TyjcVMTweEfCUQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -7080,8 +7083,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@15.6.0-canary.39': - resolution: {integrity: sha512-/FzkyPY0bOJ2OrGQMf55Jv/2MKCe6gUny1JTU0eyhJnjKleDGWTqH6NAULa0eElr8sxxooaUFtog/jnBd35CPQ==} + '@next/swc-linux-arm64-gnu@15.6.0-canary.45': + resolution: {integrity: sha512-mt4QZGldr+N1yDZa8B3wF8tENxHWfzf/ZGp4PzpcNo0zc43vtC/t2qWDTLa1v2YLYKcf1lJPQdc8lLvdBX7DTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -7098,8 +7101,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.6.0-canary.39': - resolution: {integrity: sha512-v4yzGa1wlQV9SHGNMLOFTLJuZc71G+j+6TOGa9DcLf6jLH+KXKiJZG0djfclscReVID4UOJ+aZwEivU0AsYEbg==} + '@next/swc-linux-arm64-musl@15.6.0-canary.45': + resolution: {integrity: sha512-TuzPOAfDNpTdifWFacVH0jigmELSWbpM1EX7mB4awl4w6jhcxITHYCfO0NKf3XxT6TbR16ct/9Ezi55dFmZR/A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -7116,8 +7119,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@15.6.0-canary.39': - resolution: {integrity: sha512-w7GUgYcIjp1tzi/qNqMACLfQ+piMtJu/f942ysLnbGVBxCyoZ/XtkoiF8H76y0JkpbxrYnT7TqpeUITvZTuZPA==} + '@next/swc-linux-x64-gnu@15.6.0-canary.45': + resolution: {integrity: sha512-luzawDyOqNBD3UU7Txip536DrPEel7ylO/2pL6IVrswW2hrVW5LvhhzaS4nHiRK78x74YGYKaoAj/L6gH4v+Jg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -7134,8 +7137,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.6.0-canary.39': - resolution: {integrity: sha512-WNWb3nb6XjFpit8U0OpjgkGB6sluZ+IGftSCS89U4gbS8iaDT7vBssgWf8GiXGaydbI7t+g+6c2Q9hWJXmq0dQ==} + '@next/swc-linux-x64-musl@15.6.0-canary.45': + resolution: {integrity: sha512-peoJyKsOssj+xX7g7GsIsCpt65nBthEm0mC3GPR3cBIfCrXQk2rN+8rW5jqo7knB13Kr+noVzyHXS+f0lKfkTw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -7152,8 +7155,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@15.6.0-canary.39': - resolution: {integrity: sha512-c0JMiJj+6V1k7WX8T3olM4XLpRwN5PJ18dm0z15rRCsd7syCZnnkD0wuYB/ybUxeovJMOwwhWRpayxEdtVrpFA==} + '@next/swc-win32-arm64-msvc@15.6.0-canary.45': + resolution: {integrity: sha512-XfCEHgkgboEyucJR+QjEknABslyIqB0qsl4rL4shv1UplhzKwap2mBBUlV046e/fZvMGwYGCu6HrQSw1LJfxdA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -7176,8 +7179,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@15.6.0-canary.39': - resolution: {integrity: sha512-PRHFb/FwmBLND8VfN0LPhkNw6T4x6vF58ZSiQXHAkH96K5NnxgHdVAZHibzflJgcIh6l2g9UU11qA4rjrhe5cA==} + '@next/swc-win32-x64-msvc@15.6.0-canary.45': + resolution: {integrity: sha512-vKYGgsXOKDpU+V/CuEtE53Qv45Nh9BrxxmaVlcGKwU3R97yiETFnAR8iTKmS3fcWZo3Aw2AULn9DDS5SgwquDQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -14344,8 +14347,8 @@ packages: sass: optional: true - next@15.6.0-canary.39: - resolution: {integrity: sha512-pF/49/XGll60UBL+d57XwzsmHpdfSBwY5JxoKEgrrW4oHUfoQoxDN4AdexGfTI5fpo/GNuO92tXC5jLExI3rCg==} + next@15.6.0-canary.45: + resolution: {integrity: sha512-A9BbchMZahAVeTSM7Xh7IxbfiKmjsQlOiDJ63J7svfA47W4uq9nbuHO6FHDdDYN1N5yNmvo3+PW8RLVCesNMpQ==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -23078,7 +23081,7 @@ snapshots: '@next/env@15.5.4': {} - '@next/env@15.6.0-canary.39': {} + '@next/env@15.6.0-canary.45': {} '@next/eslint-plugin-next@14.2.3': dependencies: @@ -23090,7 +23093,7 @@ snapshots: '@next/swc-darwin-arm64@15.5.4': optional: true - '@next/swc-darwin-arm64@15.6.0-canary.39': + '@next/swc-darwin-arm64@15.6.0-canary.45': optional: true '@next/swc-darwin-x64@15.0.0-canary.23': @@ -23099,7 +23102,7 @@ snapshots: '@next/swc-darwin-x64@15.5.4': optional: true - '@next/swc-darwin-x64@15.6.0-canary.39': + '@next/swc-darwin-x64@15.6.0-canary.45': optional: true '@next/swc-linux-arm64-gnu@15.0.0-canary.23': @@ -23108,7 +23111,7 @@ snapshots: '@next/swc-linux-arm64-gnu@15.5.4': optional: true - '@next/swc-linux-arm64-gnu@15.6.0-canary.39': + '@next/swc-linux-arm64-gnu@15.6.0-canary.45': optional: true '@next/swc-linux-arm64-musl@15.0.0-canary.23': @@ -23117,7 +23120,7 @@ snapshots: '@next/swc-linux-arm64-musl@15.5.4': optional: true - '@next/swc-linux-arm64-musl@15.6.0-canary.39': + '@next/swc-linux-arm64-musl@15.6.0-canary.45': optional: true '@next/swc-linux-x64-gnu@15.0.0-canary.23': @@ -23126,7 +23129,7 @@ snapshots: '@next/swc-linux-x64-gnu@15.5.4': optional: true - '@next/swc-linux-x64-gnu@15.6.0-canary.39': + '@next/swc-linux-x64-gnu@15.6.0-canary.45': optional: true '@next/swc-linux-x64-musl@15.0.0-canary.23': @@ -23135,7 +23138,7 @@ snapshots: '@next/swc-linux-x64-musl@15.5.4': optional: true - '@next/swc-linux-x64-musl@15.6.0-canary.39': + '@next/swc-linux-x64-musl@15.6.0-canary.45': optional: true '@next/swc-win32-arm64-msvc@15.0.0-canary.23': @@ -23144,7 +23147,7 @@ snapshots: '@next/swc-win32-arm64-msvc@15.5.4': optional: true - '@next/swc-win32-arm64-msvc@15.6.0-canary.39': + '@next/swc-win32-arm64-msvc@15.6.0-canary.45': optional: true '@next/swc-win32-ia32-msvc@15.0.0-canary.23': @@ -23156,7 +23159,7 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.4': optional: true - '@next/swc-win32-x64-msvc@15.6.0-canary.39': + '@next/swc-win32-x64-msvc@15.6.0-canary.45': 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(esbuild@0.25.9))': @@ -25294,7 +25297,7 @@ snapshots: '@sentry/bundler-plugin-core': 4.3.0(encoding@0.1.13) unplugin: 1.0.1 uuid: 9.0.1 - webpack: 5.101.2(esbuild@0.25.9) + webpack: 5.101.2 transitivePeerDependencies: - encoding - supports-color @@ -26663,6 +26666,15 @@ snapshots: msw: 2.6.4(@types/node@20.17.24)(typescript@5.8.3) vite: 5.4.11(@types/node@20.17.24)(less@4.4.0)(sass@1.90.0)(terser@5.43.1) + '@vitest/mocker@2.1.4(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(vite@5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1))': + dependencies: + '@vitest/spy': 2.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + msw: 2.6.4(@types/node@20.17.24)(typescript@5.8.3) + vite: 5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1) + '@vitest/mocker@2.1.4(msw@2.7.0(@types/node@22.7.4)(typescript@5.8.3))(vite@5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1))': dependencies: '@vitest/spy': 2.1.4 @@ -27441,16 +27453,6 @@ snapshots: postcss: 8.5.3 postcss-value-parser: 4.2.0 - autoprefixer@10.4.21(postcss@8.5.3): - dependencies: - browserslist: 4.25.1 - caniuse-lite: 1.0.30001727 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.5.3 - postcss-value-parser: 4.2.0 - autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.25.1 @@ -29093,7 +29095,7 @@ snapshots: '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.1) eslint-plugin-react: 7.34.1(eslint@8.57.1) @@ -29177,7 +29179,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): dependencies: debug: 4.4.1(supports-color@9.4.0) enhanced-resolve: 5.17.1 @@ -29240,7 +29242,7 @@ snapshots: '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -32883,9 +32885,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.6.0-canary.39(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0): + next@15.6.0-canary.45(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0): dependencies: - '@next/env': 15.6.0-canary.39 + '@next/env': 15.6.0-canary.45 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001727 postcss: 8.4.31 @@ -32893,14 +32895,14 @@ snapshots: react-dom: 19.0.0-rc.1(react@19.0.0-rc.1) styled-jsx: 5.1.6(react@19.0.0-rc.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.6.0-canary.39 - '@next/swc-darwin-x64': 15.6.0-canary.39 - '@next/swc-linux-arm64-gnu': 15.6.0-canary.39 - '@next/swc-linux-arm64-musl': 15.6.0-canary.39 - '@next/swc-linux-x64-gnu': 15.6.0-canary.39 - '@next/swc-linux-x64-musl': 15.6.0-canary.39 - '@next/swc-win32-arm64-msvc': 15.6.0-canary.39 - '@next/swc-win32-x64-msvc': 15.6.0-canary.39 + '@next/swc-darwin-arm64': 15.6.0-canary.45 + '@next/swc-darwin-x64': 15.6.0-canary.45 + '@next/swc-linux-arm64-gnu': 15.6.0-canary.45 + '@next/swc-linux-arm64-musl': 15.6.0-canary.45 + '@next/swc-linux-x64-gnu': 15.6.0-canary.45 + '@next/swc-linux-x64-musl': 15.6.0-canary.45 + '@next/swc-win32-arm64-msvc': 15.6.0-canary.45 + '@next/swc-win32-x64-msvc': 15.6.0-canary.45 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.50.1 sass: 1.90.0 @@ -34328,7 +34330,7 @@ snapshots: neo-async: 2.6.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - webpack: 5.101.2(esbuild@0.25.9) + webpack: 5.101.2 react@18.3.1: dependencies: @@ -35725,6 +35727,15 @@ snapshots: optionalDependencies: esbuild: 0.25.9 + terser-webpack-plugin@5.3.14(webpack@5.101.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + jest-worker: 27.5.1 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + terser: 5.43.1 + webpack: 5.101.2 + terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -36796,7 +36807,7 @@ snapshots: vitest@2.1.4(@edge-runtime/vm@5.0.0)(@types/node@20.17.24)(jsdom@26.0.0)(less@4.4.0)(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(sass@1.90.0)(terser@5.43.1): dependencies: '@vitest/expect': 2.1.4 - '@vitest/mocker': 2.1.4(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(vite@5.4.11(@types/node@20.17.24)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)) + '@vitest/mocker': 2.1.4(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(vite@5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)) '@vitest/pretty-format': 2.1.4 '@vitest/runner': 2.1.4 '@vitest/snapshot': 2.1.4 @@ -37104,6 +37115,38 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.101.2: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.25.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.6.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.2 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.14(webpack@5.101.2) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.101.2(esbuild@0.25.9): dependencies: '@types/eslint-scope': 3.7.7 From cc82f1577136189c86d06ce39b9a9a482f799574 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 14:45:10 -0400 Subject: [PATCH 09/61] exchange auth flow added --- packages/ai/src/error/oauth-error.ts | 35 +++ packages/ai/src/tool/mcp/oauth-types.ts | 9 + packages/ai/src/tool/mcp/oauth.test.ts | 215 +++++++++++++++++++ packages/ai/src/tool/mcp/oauth.ts | 270 ++++++++++++++++++++++++ 4 files changed, 529 insertions(+) create mode 100644 packages/ai/src/error/oauth-error.ts diff --git a/packages/ai/src/error/oauth-error.ts b/packages/ai/src/error/oauth-error.ts new file mode 100644 index 000000000000..f9f33a820e8d --- /dev/null +++ b/packages/ai/src/error/oauth-error.ts @@ -0,0 +1,35 @@ +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 const OAUTH_ERRORS = { + [ServerError.errorCode]: ServerError, +}; diff --git a/packages/ai/src/tool/mcp/oauth-types.ts b/packages/ai/src/tool/mcp/oauth-types.ts index 18dc191fe75d..aafb5bb0b29e 100644 --- a/packages/ai/src/tool/mcp/oauth-types.ts +++ b/packages/ai/src/tool/mcp/oauth-types.ts @@ -69,6 +69,7 @@ export const OAuthMetadataSchema = z 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 @@ -91,9 +92,11 @@ export const OpenIdProviderMetadataSchema = z 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(); @@ -132,3 +135,9 @@ export type OAuthClientInformation = z.infer< export type AuthorizationServerMetadata = | OAuthMetadata | OpenIdProviderDiscoveryMetadata; + +export const OAuthErrorResponseSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), + error_uri: z.string().optional(), +}); diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 0a731e79e189..00d1fef2efbb 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -7,7 +7,10 @@ import { buildDiscoveryUrls, discoverAuthorizationServerMetadata, startAuthorization, + exchangeAuthorization, } from './oauth'; +import { AuthorizationServerMetadata } from './oauth-types'; +import { ServerError } from '../../error/oauth-error'; import { LATEST_PROTOCOL_VERSION } from './types'; // Mock the pkce-challenge module @@ -878,3 +881,215 @@ describe('startAuthorization', () => { ).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'); + }); +}); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 859e1c62818c..5c211d72d6ad 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -7,7 +7,14 @@ import { OpenIdProviderDiscoveryMetadataSchema, AuthorizationServerMetadata, OAuthClientInformation, + OAuthTokensSchema, + OAuthErrorResponseSchema, } from './oauth-types'; +import { + MCPClientOAuthError, + ServerError, + OAUTH_ERRORS, +} from '../../error/oauth-error'; import { LATEST_PROTOCOL_VERSION } from './types'; import { FetchFunction } from '@ai-sdk/provider-utils'; @@ -27,6 +34,30 @@ export interface OAuthClientProvider { serverUrl: URL; resourceMetadataUrl?: URL; }): 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; } export class UnauthorizedError extends Error { @@ -410,3 +441,242 @@ export async function startAuthorization( 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 server doesn't specify supported methods, use RFC 6749 defaults + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_post' : 'none'; + } + + // Try methods in priority order (most secure first) + 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'; + } + + // Fallback: use what we have + 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}`, + ); + } + + // Exchange code for tokens + 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 { + // Determine and apply client authentication method + 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()); +} From 4b00d5a2f3a5f3ae4af5e8f6dde5e4df1aa71d5e Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 14:52:31 -0400 Subject: [PATCH 10/61] refresh auth token flow added --- packages/ai/src/tool/mcp/oauth.test.ts | 176 +++++++++++++++++++++++++ packages/ai/src/tool/mcp/oauth.ts | 90 +++++++++++++ 2 files changed, 266 insertions(+) diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 00d1fef2efbb..8dea4f756915 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -8,6 +8,7 @@ import { discoverAuthorizationServerMetadata, startAuthorization, exchangeAuthorization, + refreshAuthorization, } from './oauth'; import { AuthorizationServerMetadata } from './oauth-types'; import { ServerError } from '../../error/oauth-error'; @@ -1093,3 +1094,178 @@ describe('exchangeAuthorization', () => { 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'); + }); +}); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 5c211d72d6ad..b8a0f10d4e20 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -680,3 +680,93 @@ export async function exchangeAuthorization( 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); + } + + // Exchange refresh token + 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 { + // Determine and apply client authentication method + 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()), + }); +} From 6992cc2b2dd43a89abe5ef48e36d73a9f45417c9 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 15:13:24 -0400 Subject: [PATCH 11/61] register-client flow + ci fix --- packages/ai/src/tool/mcp/oauth-types.ts | 28 + packages/ai/src/tool/mcp/oauth.test.ts | 813 ++++++++++++++++++++++++ packages/ai/src/tool/mcp/oauth.ts | 47 ++ 3 files changed, 888 insertions(+) diff --git a/packages/ai/src/tool/mcp/oauth-types.ts b/packages/ai/src/tool/mcp/oauth-types.ts index aafb5bb0b29e..e25778fe4256 100644 --- a/packages/ai/src/tool/mcp/oauth-types.ts +++ b/packages/ai/src/tool/mcp/oauth-types.ts @@ -121,6 +121,27 @@ export const OAuthClientInformationSchema = z }) .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 @@ -141,3 +162,10 @@ export const OAuthErrorResponseSchema = z.object({ 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/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 8dea4f756915..762b2debdc6f 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -9,6 +9,7 @@ import { startAuthorization, exchangeAuthorization, refreshAuthorization, + registerClient, } from './oauth'; import { AuthorizationServerMetadata } from './oauth-types'; import { ServerError } from '../../error/oauth-error'; @@ -1269,3 +1270,815 @@ describe('refreshAuthorization', () => { ).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: jest.fn(), +// tokens: jest.fn(), +// saveTokens: jest.fn(), +// redirectToAuthorization: jest.fn(), +// saveCodeVerifier: jest.fn(), +// codeVerifier: jest.fn() +// }; + +// beforeEach(() => { +// jest.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 jest.Mock).mockResolvedValue(undefined); +// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); +// mockProvider.saveClientInformation = jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.redirectToAuthorization as jest.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 jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.codeVerifier as jest.Mock).mockResolvedValue('test-verifier'); +// (mockProvider.saveTokens as jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.tokens as jest.Mock).mockResolvedValue({ +// access_token: 'old-access', +// refresh_token: 'refresh123' +// }); +// (mockProvider.saveTokens as jest.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 = jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); +// (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); +// (providerWithCustomValidation.redirectToAuthorization as jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.redirectToAuthorization as jest.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 jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.redirectToAuthorization as jest.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 jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.codeVerifier as jest.Mock).mockResolvedValue('test-verifier'); +// (mockProvider.saveTokens as jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.tokens as jest.Mock).mockResolvedValue({ +// access_token: 'old-access', +// refresh_token: 'refresh123' +// }); +// (mockProvider.saveTokens as jest.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 jest.Mock).mockResolvedValue({ +// client_id: 'test-client', +// client_secret: 'test-secret' +// }); +// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); +// (mockProvider.redirectToAuthorization as jest.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 = jest.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: jest.fn().mockResolvedValue({ +// client_id: 'client123', +// client_secret: 'secret123' +// }), +// tokens: jest.fn().mockResolvedValue(undefined), +// saveTokens: jest.fn(), +// redirectToAuthorization: jest.fn(), +// saveCodeVerifier: jest.fn(), +// codeVerifier: jest.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/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index b8a0f10d4e20..ef0ba6aa06b5 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -9,6 +9,9 @@ import { OAuthClientInformation, OAuthTokensSchema, OAuthErrorResponseSchema, + OAuthClientMetadata, + OAuthClientInformationFull, + OAuthClientInformationFullSchema, } from './oauth-types'; import { MCPClientOAuthError, @@ -770,3 +773,47 @@ export async function refreshAuthorization( ...(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()); +} From d64abe038e02235797d721d529344e274457df6a Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 15:13:36 -0400 Subject: [PATCH 12/61] ci fix --- pnpm-lock.yaml | 166 +++++++++++++++++++------------------------------ 1 file changed, 63 insertions(+), 103 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4e0f35533e4..147e3998a43a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1341,7 +1341,7 @@ importers: version: link:../../packages/ai autoprefixer: specifier: ^10.4.20 - version: 10.4.21(postcss@8.5.6) + version: 10.4.21(postcss@8.5.3) bits-ui: specifier: ^1.3.9 version: 1.3.9(svelte@5.32.1) @@ -2593,7 +2593,7 @@ importers: version: link:../../../../ai next: specifier: canary - version: 15.6.0-canary.45(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0) + version: 15.6.0-canary.39(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0) react: specifier: rc version: 19.0.0-rc.1 @@ -7029,8 +7029,8 @@ packages: '@next/env@15.5.4': resolution: {integrity: sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==} - '@next/env@15.6.0-canary.45': - resolution: {integrity: sha512-War8PoLqM7B5j+CnC585dTyNRTHmCBi6V5Qgg1zavikzhIoZ1913iMR6+B8nezz2VlaPzAp5me3uthvLuo1tAA==} + '@next/env@15.6.0-canary.39': + resolution: {integrity: sha512-WvJxtTel5Yt+z1QmCfgdFTOwk3edEzvhh6G1AuV45g2JJfx/8PljYEGVApJlUe2pjfKpW3K3K56qmeaaa+h7pw==} '@next/eslint-plugin-next@14.2.3': resolution: {integrity: sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==} @@ -7047,8 +7047,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@15.6.0-canary.45': - resolution: {integrity: sha512-RW48Ho4j1vm5wjF2CY6hBkjiU5WRrWKQUsTGiNPyqE6zJXTOYB45zVkK7GQKwG4g0vCvZq4QeM9iI0qzQlYyxA==} + '@next/swc-darwin-arm64@15.6.0-canary.39': + resolution: {integrity: sha512-/JP8D3GHAHiPhRw4Tl1sRDvkijzciieWgmYdhEsg14V4ElZmk8cTZVJIybh9Vv/BN3avOfMZ0N2KT+b2K/ZdRg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -7065,8 +7065,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@15.6.0-canary.45': - resolution: {integrity: sha512-WiFOeob23e2A0L6fD1mkU2M8zX//q4N6lqNm7By+NNUNqM87TMkzo1aa+6ixSLLG6wytfM38TyjcVMTweEfCUQ==} + '@next/swc-darwin-x64@15.6.0-canary.39': + resolution: {integrity: sha512-ia/xFDPLliYYzPTdJ+LhQTuaxvgmWYuddutA1JfZyBu511Q5P2h8NnxOonME+N4kXgi1dp0wezgZ4H58J31fnQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -7083,8 +7083,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@15.6.0-canary.45': - resolution: {integrity: sha512-mt4QZGldr+N1yDZa8B3wF8tENxHWfzf/ZGp4PzpcNo0zc43vtC/t2qWDTLa1v2YLYKcf1lJPQdc8lLvdBX7DTw==} + '@next/swc-linux-arm64-gnu@15.6.0-canary.39': + resolution: {integrity: sha512-/FzkyPY0bOJ2OrGQMf55Jv/2MKCe6gUny1JTU0eyhJnjKleDGWTqH6NAULa0eElr8sxxooaUFtog/jnBd35CPQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -7101,8 +7101,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.6.0-canary.45': - resolution: {integrity: sha512-TuzPOAfDNpTdifWFacVH0jigmELSWbpM1EX7mB4awl4w6jhcxITHYCfO0NKf3XxT6TbR16ct/9Ezi55dFmZR/A==} + '@next/swc-linux-arm64-musl@15.6.0-canary.39': + resolution: {integrity: sha512-v4yzGa1wlQV9SHGNMLOFTLJuZc71G+j+6TOGa9DcLf6jLH+KXKiJZG0djfclscReVID4UOJ+aZwEivU0AsYEbg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -7119,8 +7119,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@15.6.0-canary.45': - resolution: {integrity: sha512-luzawDyOqNBD3UU7Txip536DrPEel7ylO/2pL6IVrswW2hrVW5LvhhzaS4nHiRK78x74YGYKaoAj/L6gH4v+Jg==} + '@next/swc-linux-x64-gnu@15.6.0-canary.39': + resolution: {integrity: sha512-w7GUgYcIjp1tzi/qNqMACLfQ+piMtJu/f942ysLnbGVBxCyoZ/XtkoiF8H76y0JkpbxrYnT7TqpeUITvZTuZPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -7137,8 +7137,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.6.0-canary.45': - resolution: {integrity: sha512-peoJyKsOssj+xX7g7GsIsCpt65nBthEm0mC3GPR3cBIfCrXQk2rN+8rW5jqo7knB13Kr+noVzyHXS+f0lKfkTw==} + '@next/swc-linux-x64-musl@15.6.0-canary.39': + resolution: {integrity: sha512-WNWb3nb6XjFpit8U0OpjgkGB6sluZ+IGftSCS89U4gbS8iaDT7vBssgWf8GiXGaydbI7t+g+6c2Q9hWJXmq0dQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -7155,8 +7155,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@15.6.0-canary.45': - resolution: {integrity: sha512-XfCEHgkgboEyucJR+QjEknABslyIqB0qsl4rL4shv1UplhzKwap2mBBUlV046e/fZvMGwYGCu6HrQSw1LJfxdA==} + '@next/swc-win32-arm64-msvc@15.6.0-canary.39': + resolution: {integrity: sha512-c0JMiJj+6V1k7WX8T3olM4XLpRwN5PJ18dm0z15rRCsd7syCZnnkD0wuYB/ybUxeovJMOwwhWRpayxEdtVrpFA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -7179,8 +7179,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@15.6.0-canary.45': - resolution: {integrity: sha512-vKYGgsXOKDpU+V/CuEtE53Qv45Nh9BrxxmaVlcGKwU3R97yiETFnAR8iTKmS3fcWZo3Aw2AULn9DDS5SgwquDQ==} + '@next/swc-win32-x64-msvc@15.6.0-canary.39': + resolution: {integrity: sha512-PRHFb/FwmBLND8VfN0LPhkNw6T4x6vF58ZSiQXHAkH96K5NnxgHdVAZHibzflJgcIh6l2g9UU11qA4rjrhe5cA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -14347,8 +14347,8 @@ packages: sass: optional: true - next@15.6.0-canary.45: - resolution: {integrity: sha512-A9BbchMZahAVeTSM7Xh7IxbfiKmjsQlOiDJ63J7svfA47W4uq9nbuHO6FHDdDYN1N5yNmvo3+PW8RLVCesNMpQ==} + next@15.6.0-canary.39: + resolution: {integrity: sha512-pF/49/XGll60UBL+d57XwzsmHpdfSBwY5JxoKEgrrW4oHUfoQoxDN4AdexGfTI5fpo/GNuO92tXC5jLExI3rCg==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -23081,7 +23081,7 @@ snapshots: '@next/env@15.5.4': {} - '@next/env@15.6.0-canary.45': {} + '@next/env@15.6.0-canary.39': {} '@next/eslint-plugin-next@14.2.3': dependencies: @@ -23093,7 +23093,7 @@ snapshots: '@next/swc-darwin-arm64@15.5.4': optional: true - '@next/swc-darwin-arm64@15.6.0-canary.45': + '@next/swc-darwin-arm64@15.6.0-canary.39': optional: true '@next/swc-darwin-x64@15.0.0-canary.23': @@ -23102,7 +23102,7 @@ snapshots: '@next/swc-darwin-x64@15.5.4': optional: true - '@next/swc-darwin-x64@15.6.0-canary.45': + '@next/swc-darwin-x64@15.6.0-canary.39': optional: true '@next/swc-linux-arm64-gnu@15.0.0-canary.23': @@ -23111,7 +23111,7 @@ snapshots: '@next/swc-linux-arm64-gnu@15.5.4': optional: true - '@next/swc-linux-arm64-gnu@15.6.0-canary.45': + '@next/swc-linux-arm64-gnu@15.6.0-canary.39': optional: true '@next/swc-linux-arm64-musl@15.0.0-canary.23': @@ -23120,7 +23120,7 @@ snapshots: '@next/swc-linux-arm64-musl@15.5.4': optional: true - '@next/swc-linux-arm64-musl@15.6.0-canary.45': + '@next/swc-linux-arm64-musl@15.6.0-canary.39': optional: true '@next/swc-linux-x64-gnu@15.0.0-canary.23': @@ -23129,7 +23129,7 @@ snapshots: '@next/swc-linux-x64-gnu@15.5.4': optional: true - '@next/swc-linux-x64-gnu@15.6.0-canary.45': + '@next/swc-linux-x64-gnu@15.6.0-canary.39': optional: true '@next/swc-linux-x64-musl@15.0.0-canary.23': @@ -23138,7 +23138,7 @@ snapshots: '@next/swc-linux-x64-musl@15.5.4': optional: true - '@next/swc-linux-x64-musl@15.6.0-canary.45': + '@next/swc-linux-x64-musl@15.6.0-canary.39': optional: true '@next/swc-win32-arm64-msvc@15.0.0-canary.23': @@ -23147,7 +23147,7 @@ snapshots: '@next/swc-win32-arm64-msvc@15.5.4': optional: true - '@next/swc-win32-arm64-msvc@15.6.0-canary.45': + '@next/swc-win32-arm64-msvc@15.6.0-canary.39': optional: true '@next/swc-win32-ia32-msvc@15.0.0-canary.23': @@ -23159,7 +23159,7 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.4': optional: true - '@next/swc-win32-x64-msvc@15.6.0-canary.45': + '@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(esbuild@0.25.9))': @@ -25297,7 +25297,7 @@ snapshots: '@sentry/bundler-plugin-core': 4.3.0(encoding@0.1.13) unplugin: 1.0.1 uuid: 9.0.1 - webpack: 5.101.2 + webpack: 5.101.2(esbuild@0.25.9) transitivePeerDependencies: - encoding - supports-color @@ -26666,15 +26666,6 @@ snapshots: msw: 2.6.4(@types/node@20.17.24)(typescript@5.8.3) vite: 5.4.11(@types/node@20.17.24)(less@4.4.0)(sass@1.90.0)(terser@5.43.1) - '@vitest/mocker@2.1.4(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(vite@5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1))': - dependencies: - '@vitest/spy': 2.1.4 - estree-walker: 3.0.3 - magic-string: 0.30.17 - optionalDependencies: - msw: 2.6.4(@types/node@20.17.24)(typescript@5.8.3) - vite: 5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1) - '@vitest/mocker@2.1.4(msw@2.7.0(@types/node@22.7.4)(typescript@5.8.3))(vite@5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1))': dependencies: '@vitest/spy': 2.1.4 @@ -27453,6 +27444,16 @@ snapshots: postcss: 8.5.3 postcss-value-parser: 4.2.0 + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.25.1 + caniuse-lite: 1.0.30001727 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.25.1 @@ -29095,8 +29096,8 @@ snapshots: '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.1) eslint-plugin-react: 7.34.1(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -29179,13 +29180,13 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 4.4.1(supports-color@9.4.0) enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-core-module: 2.16.1 @@ -29235,14 +29236,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -29311,7 +29312,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -29321,7 +29322,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -32885,9 +32886,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.6.0-canary.45(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0): + next@15.6.0-canary.39(@opentelemetry/api@1.9.0)(@playwright/test@1.50.1)(react-dom@19.0.0-rc.1(react@19.0.0-rc.1))(react@19.0.0-rc.1)(sass@1.90.0): dependencies: - '@next/env': 15.6.0-canary.45 + '@next/env': 15.6.0-canary.39 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001727 postcss: 8.4.31 @@ -32895,14 +32896,14 @@ snapshots: react-dom: 19.0.0-rc.1(react@19.0.0-rc.1) styled-jsx: 5.1.6(react@19.0.0-rc.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.6.0-canary.45 - '@next/swc-darwin-x64': 15.6.0-canary.45 - '@next/swc-linux-arm64-gnu': 15.6.0-canary.45 - '@next/swc-linux-arm64-musl': 15.6.0-canary.45 - '@next/swc-linux-x64-gnu': 15.6.0-canary.45 - '@next/swc-linux-x64-musl': 15.6.0-canary.45 - '@next/swc-win32-arm64-msvc': 15.6.0-canary.45 - '@next/swc-win32-x64-msvc': 15.6.0-canary.45 + '@next/swc-darwin-arm64': 15.6.0-canary.39 + '@next/swc-darwin-x64': 15.6.0-canary.39 + '@next/swc-linux-arm64-gnu': 15.6.0-canary.39 + '@next/swc-linux-arm64-musl': 15.6.0-canary.39 + '@next/swc-linux-x64-gnu': 15.6.0-canary.39 + '@next/swc-linux-x64-musl': 15.6.0-canary.39 + '@next/swc-win32-arm64-msvc': 15.6.0-canary.39 + '@next/swc-win32-x64-msvc': 15.6.0-canary.39 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.50.1 sass: 1.90.0 @@ -34330,7 +34331,7 @@ snapshots: neo-async: 2.6.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - webpack: 5.101.2 + webpack: 5.101.2(esbuild@0.25.9) react@18.3.1: dependencies: @@ -35727,15 +35728,6 @@ snapshots: optionalDependencies: esbuild: 0.25.9 - terser-webpack-plugin@5.3.14(webpack@5.101.2): - dependencies: - '@jridgewell/trace-mapping': 0.3.29 - jest-worker: 27.5.1 - schema-utils: 4.3.2 - serialize-javascript: 6.0.2 - terser: 5.43.1 - webpack: 5.101.2 - terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -36807,7 +36799,7 @@ snapshots: vitest@2.1.4(@edge-runtime/vm@5.0.0)(@types/node@20.17.24)(jsdom@26.0.0)(less@4.4.0)(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(sass@1.90.0)(terser@5.43.1): dependencies: '@vitest/expect': 2.1.4 - '@vitest/mocker': 2.1.4(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(vite@5.4.11(@types/node@22.7.4)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)) + '@vitest/mocker': 2.1.4(msw@2.6.4(@types/node@20.17.24)(typescript@5.8.3))(vite@5.4.11(@types/node@20.17.24)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)) '@vitest/pretty-format': 2.1.4 '@vitest/runner': 2.1.4 '@vitest/snapshot': 2.1.4 @@ -37115,38 +37107,6 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.101.2: - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.25.1 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.6.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.2 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.14(webpack@5.101.2) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.101.2(esbuild@0.25.9): dependencies: '@types/eslint-scope': 3.7.7 From 173dc59212e686e967fa7de20472c169a4b51190 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 16:03:46 -0400 Subject: [PATCH 13/61] add final auth function --- packages/ai/src/error/oauth-error.ts | 12 + packages/ai/src/tool/mcp/oauth.test.ts | 1500 ++++++++++++----------- packages/ai/src/tool/mcp/oauth.ts | 258 +++- packages/ai/src/util/oauth-util.test.ts | 146 +++ packages/ai/src/util/oauth-util.ts | 66 + 5 files changed, 1253 insertions(+), 729 deletions(-) create mode 100644 packages/ai/src/util/oauth-util.test.ts create mode 100644 packages/ai/src/util/oauth-util.ts diff --git a/packages/ai/src/error/oauth-error.ts b/packages/ai/src/error/oauth-error.ts index f9f33a820e8d..7f34a6f61bd0 100644 --- a/packages/ai/src/error/oauth-error.ts +++ b/packages/ai/src/error/oauth-error.ts @@ -33,3 +33,15 @@ export class ServerError extends MCPClientOAuthError { export const OAUTH_ERRORS = { [ServerError.errorCode]: ServerError, }; + +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'; +} diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/ai/src/tool/mcp/oauth.test.ts index 762b2debdc6f..1ab62b93699d 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/ai/src/tool/mcp/oauth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { extractResourceMetadataUrl, type OAuthClientProvider, @@ -10,6 +10,7 @@ import { exchangeAuthorization, refreshAuthorization, registerClient, + auth, } from './oauth'; import { AuthorizationServerMetadata } from './oauth-types'; import { ServerError } from '../../error/oauth-error'; @@ -1363,722 +1364,781 @@ describe('registerClient', () => { }); }); -// 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: jest.fn(), -// tokens: jest.fn(), -// saveTokens: jest.fn(), -// redirectToAuthorization: jest.fn(), -// saveCodeVerifier: jest.fn(), -// codeVerifier: jest.fn() -// }; - -// beforeEach(() => { -// jest.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 jest.Mock).mockResolvedValue(undefined); -// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); -// mockProvider.saveClientInformation = jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.redirectToAuthorization as jest.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 jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.codeVerifier as jest.Mock).mockResolvedValue('test-verifier'); -// (mockProvider.saveTokens as jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.tokens as jest.Mock).mockResolvedValue({ -// access_token: 'old-access', -// refresh_token: 'refresh123' -// }); -// (mockProvider.saveTokens as jest.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 = jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); -// (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); -// (providerWithCustomValidation.redirectToAuthorization as jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.redirectToAuthorization as jest.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 jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.redirectToAuthorization as jest.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 jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.codeVerifier as jest.Mock).mockResolvedValue('test-verifier'); -// (mockProvider.saveTokens as jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.tokens as jest.Mock).mockResolvedValue({ -// access_token: 'old-access', -// refresh_token: 'refresh123' -// }); -// (mockProvider.saveTokens as jest.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 jest.Mock).mockResolvedValue({ -// client_id: 'test-client', -// client_secret: 'test-secret' -// }); -// (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); -// (mockProvider.redirectToAuthorization as jest.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 = jest.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: jest.fn().mockResolvedValue({ -// client_id: 'client123', -// client_secret: 'secret123' -// }), -// tokens: jest.fn().mockResolvedValue(undefined), -// saveTokens: jest.fn(), -// redirectToAuthorization: jest.fn(), -// saveCodeVerifier: jest.fn(), -// codeVerifier: jest.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'); -// }); -// }); +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/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index ef0ba6aa06b5..34119f9b1f12 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -17,26 +17,29 @@ 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' | 'UNAUTHORIZED'; +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; - /** - * Performs (or completes) OAuth for the given server. - * Should persist tokens so subsequent tokens() calls return the new access_token. - */ - authorize(options: { - serverUrl: URL; - resourceMetadataUrl?: URL; - }): Promise; /** * Adds custom client authentication to OAuth token requests. * @@ -61,6 +64,29 @@ export interface OAuthClientProvider { 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 { @@ -817,3 +843,217 @@ export async function registerClient( 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) { + // Handle recoverable error types by invalidating credentials and retrying + 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 otherwise + throw error; + } +} + +export async function selectResourceURL( + serverUrl: string | URL, + provider: OAuthClientProvider, + resourceMetadata?: OAuthProtectedResourceMetadata, +): Promise { + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + // If provider has custom validation, delegate to it + if (provider.validateResourceURL) { + return await provider.validateResourceURL( + defaultResource, + resourceMetadata?.resource, + ); + } + + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; + } + + // Validate that the metadata's resource is compatible with our request + if ( + !checkResourceAllowed({ + requestedResource: defaultResource, + configuredResource: resourceMetadata.resource, + }) + ) { + throw new Error( + `Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`, + ); + } + // Prefer the resource from metadata since it's what the server is telling us to request + 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 { + // Ignore errors and fall back to /.well-known/oauth-authorization-server + } + + /** + * 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, + }, + ); + + // Handle client registration if needed + 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 this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. + if ( + !(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/util/oauth-util.test.ts b/packages/ai/src/util/oauth-util.test.ts new file mode 100644 index 000000000000..a197af271005 --- /dev/null +++ b/packages/ai/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/ai/src/util/oauth-util.ts b/packages/ai/src/util/oauth-util.ts new file mode 100644 index 000000000000..7be5a0a8e350 --- /dev/null +++ b/packages/ai/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); +} From 2e8352739698f708d9b67019b1d396db5ab01ebb Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 16:20:50 -0400 Subject: [PATCH 14/61] correct auth function used --- packages/ai/src/tool/mcp/mcp-sse-transport.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/tool/mcp/mcp-sse-transport.ts b/packages/ai/src/tool/mcp/mcp-sse-transport.ts index 7d9c0523c0b3..9e85749d11c9 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.ts +++ b/packages/ai/src/tool/mcp/mcp-sse-transport.ts @@ -11,6 +11,7 @@ import { OAuthClientProvider, extractResourceMetadataUrl, UnauthorizedError, + auth, } from './oauth'; import { LATEST_PROTOCOL_VERSION } from './types'; @@ -87,13 +88,17 @@ export class SseMCPTransport implements MCPTransport { if (response.status === 401 && this.authProvider && !triedAuth) { this.resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await this.authProvider.authorize({ - serverUrl: this.url, - resourceMetadataUrl: this.resourceMetadataUrl, - }); - - if (result !== 'AUTHORIZED') { - const error = new UnauthorizedError(); + 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); } @@ -221,12 +226,17 @@ export class SseMCPTransport implements MCPTransport { if (response.status === 401 && this.authProvider && !triedAuth) { this.resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await this.authProvider.authorize({ - serverUrl: this.url, - resourceMetadataUrl: this.resourceMetadataUrl, - }); - if (result !== 'AUTHORIZED') { - const error = new UnauthorizedError(); + 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; } From f6f180b0f4476b8e2aa388e08cd2710d57e29e1c Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 16:36:35 -0400 Subject: [PATCH 15/61] example updated to use new auth --- examples/mcp/src/sse-with-auth/client.ts | 82 ++++++++++++++++-------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index fd87b5c8b643..23451dd9c157 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -1,43 +1,73 @@ import { openai } from '@ai-sdk/openai'; import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; import 'dotenv/config'; +import type { OAuthClientProvider } from '../../../../packages/ai/src/tool/mcp/oauth.js'; import type { - OAuthClientProvider, - AuthResult, -} from '../../../../packages/ai/src/tool/mcp/oauth.js'; + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from '../../../../packages/ai/src/tool/mcp/oauth-types.js'; // Simple OAuth provider that pre-configures a token for demo purposes class DemoOAuthProvider implements OAuthClientProvider { - private accessToken: string | null = null; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _clientInformation?: OAuthClientInformation; + private _redirectUrl: string | URL = 'http://localhost:8090/callback'; + + // Return the current tokens; for the demo we pre-configure an access token + async tokens(): Promise { + if (!this._tokens) { + this._tokens = { access_token: 'demo-access-token-123' } as OAuthTokens; + } + return this._tokens; + } + + async saveTokens(tokens: OAuthTokens): Promise { + this._tokens = tokens; + } + + // Redirect handler used by the auth() flow when interactive authorization is needed + async redirectToAuthorization(authorizationUrl: URL): Promise { + console.log(' → Redirect to authorization:', authorizationUrl.toString()); + } - async tokens(): Promise<{ access_token: string } | null> { - return this.accessToken ? { access_token: this.accessToken } : null; + async saveCodeVerifier(codeVerifier: string): Promise { + this._codeVerifier = codeVerifier; } - async authorize(options: { - serverUrl: URL; - resourceMetadataUrl?: URL; - }): Promise { - console.log(' → Authorizing with server:', options.serverUrl.toString()); - if (options.resourceMetadataUrl) { - console.log( - ' → Resource metadata URL:', - options.resourceMetadataUrl.toString(), - ); + async codeVerifier(): Promise { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); } + return this._codeVerifier; + } + + get redirectUrl(): string | URL { + return this._redirectUrl; + } - // In a real implementation, this would: - // 1. Discover OAuth endpoints via metadata - // 2. Register as a client (if needed) - // 3. Get authorization code - // 4. Exchange code for token + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'Demo OAuth MCP Client', + redirect_uris: [String(this._redirectUrl)], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'mcp:tools', + }; + } - // For this demo, we use a pre-configured token that the server accepts - this.accessToken = 'demo-access-token-123'; - console.log(' → Token acquired:', this.accessToken); - console.log(' → Authorization complete, transport will retry with token'); + async clientInformation(): Promise { + // For demo purposes, we return a static public client id; dynamic registration is handled by auth() if needed + if (!this._clientInformation) { + this._clientInformation = { client_id: 'demo-client' } as OAuthClientInformation; + } + return this._clientInformation; + } - return 'AUTHORIZED'; + async saveClientInformation(info: OAuthClientInformation): Promise { + this._clientInformation = info; } } From a4cb0aa629c42464a97947b7589b9de88523589a Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Mon, 6 Oct 2025 16:37:52 -0400 Subject: [PATCH 16/61] pretty --- examples/mcp/src/sse-with-auth/client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 23451dd9c157..3471b422a8d5 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -61,7 +61,9 @@ class DemoOAuthProvider implements OAuthClientProvider { async clientInformation(): Promise { // For demo purposes, we return a static public client id; dynamic registration is handled by auth() if needed if (!this._clientInformation) { - this._clientInformation = { client_id: 'demo-client' } as OAuthClientInformation; + this._clientInformation = { + client_id: 'demo-client', + } as OAuthClientInformation; } return this._clientInformation; } From d91e8725283098e8a2c892232ac6ef7859b1ca77 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 7 Oct 2025 11:12:48 -0400 Subject: [PATCH 17/61] mcp client updated --- examples/mcp/src/sse-with-auth/client.ts | 142 ++++++++++++++++------- 1 file changed, 101 insertions(+), 41 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 3471b422a8d5..0443a2c31f85 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -2,54 +2,60 @@ import { openai } from '@ai-sdk/openai'; import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; import 'dotenv/config'; import type { OAuthClientProvider } from '../../../../packages/ai/src/tool/mcp/oauth.js'; +import { + auth, + UnauthorizedError, +} from '../../../../packages/ai/src/tool/mcp/oauth.js'; import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens, } from '../../../../packages/ai/src/tool/mcp/oauth-types.js'; +import { createServer } from 'node:http'; +import { exec } from 'node:child_process'; -// Simple OAuth provider that pre-configures a token for demo purposes -class DemoOAuthProvider implements OAuthClientProvider { +class InMemoryOAuthClientProvider implements OAuthClientProvider { private _tokens?: OAuthTokens; private _codeVerifier?: string; private _clientInformation?: OAuthClientInformation; - private _redirectUrl: string | URL = 'http://localhost:8090/callback'; + private _redirectUrl: string | URL = + `http://localhost:${process.env.MCP_CALLBACK_PORT ?? 8090}/callback`; - // Return the current tokens; for the demo we pre-configure an access token async tokens(): Promise { - if (!this._tokens) { - this._tokens = { access_token: 'demo-access-token-123' } as OAuthTokens; - } return this._tokens; } - async saveTokens(tokens: OAuthTokens): Promise { this._tokens = tokens; } - - // Redirect handler used by the auth() flow when interactive authorization is needed async redirectToAuthorization(authorizationUrl: URL): Promise { - console.log(' → Redirect to authorization:', authorizationUrl.toString()); + 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'); - } + 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: 'Demo OAuth MCP Client', + client_name: 'MCP OAuth Client', redirect_uris: [String(this._redirectUrl)], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], @@ -57,39 +63,93 @@ class DemoOAuthProvider implements OAuthClientProvider { scope: 'mcp:tools', }; } - async clientInformation(): Promise { - // For demo purposes, we return a static public client id; dynamic registration is handled by auth() if needed - if (!this._clientInformation) { - this._clientInformation = { - client_id: 'demo-client', - } as OAuthClientInformation; - } return this._clientInformation; } - async saveClientInformation(info: OAuthClientInformation): Promise { this._clientInformation = info; } } +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 DemoOAuthProvider(); + const authProvider = new InMemoryOAuthClientProvider(); console.log('Creating MCP client with OAuth...'); try { - // Attempt to create MCP client with auth - const mcpClient = await experimental_createMCPClient({ - transport: { - type: 'sse', - url: 'http://localhost:8081/sse', - authProvider, - }, - onUncaughtError: error => { - console.error('MCP Client uncaught error:', error); - }, - }); + const serverUrl = process.env.MCP_SERVER_URL || 'http://localhost:8081/sse'; + const callbackPromise = waitForAuthorizationCode( + Number(process.env.MCP_CALLBACK_PORT ?? 8090), + ); + + const connect = async () => + experimental_createMCPClient({ + transport: { type: 'sse', url: serverUrl, authProvider }, + onUncaughtError: error => + console.error('MCP Client uncaught error:', error), + }); + + let mcpClient; + try { + mcpClient = await connect(); + } catch (error) { + const unauthorized = + error instanceof UnauthorizedError || + (error && + typeof error === 'object' && + (error as any).name === 'UnauthorizedError'); + if (unauthorized) { + console.log('🔐 Authorization required. Waiting for OAuth callback...'); + + const authorizationCode = await callbackPromise; + + console.log('↪ Exchanging authorization code for tokens...'); + await auth(authProvider, { + serverUrl: new URL(serverUrl), + authorizationCode, + }); + + console.log('↪ Retrying connection with authorized tokens...'); + mcpClient = await connect(); + } else { + throw error; + } + } console.log('✓ MCP client connected with OAuth authentication'); @@ -124,7 +184,7 @@ async function main() { console.log(answer); console.log('\n✓ MCP client closed'); } catch (error) { - console.error('\n❌ Error during MCP client execution:'); + console.error('\nError during MCP client execution:'); console.error(error); throw error; } @@ -133,6 +193,6 @@ async function main() { // Handle OAuth callback in a real application // For this demo, the server auto-approves and the provider handles it internally main().catch(error => { - console.error('\n❌ Fatal error:', error); + console.error('\nFatal error:', error); process.exit(1); }); From fe8045d55df5647ea2ec4c7094c04663be762f26 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 7 Oct 2025 18:04:34 -0400 Subject: [PATCH 18/61] working example --- examples/mcp/src/sse-with-auth/client.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 0443a2c31f85..c6f1509fde28 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -55,12 +55,11 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { } get clientMetadata(): OAuthClientMetadata { return { - client_name: 'MCP OAuth Client', + 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', - scope: 'mcp:tools', }; } async clientInformation(): Promise { @@ -112,7 +111,8 @@ async function main() { console.log('Creating MCP client with OAuth...'); try { - const serverUrl = process.env.MCP_SERVER_URL || 'http://localhost:8081/sse'; + const serverUrl = + process.env.MCP_SERVER_URL || 'https://mcp.notion.com/sse'; const callbackPromise = waitForAuthorizationCode( Number(process.env.MCP_CALLBACK_PORT ?? 8090), ); @@ -174,8 +174,7 @@ async function main() { } }, system: 'You are a helpful assistant with access to protected resources.', - prompt: - 'List the user resources, then retrieve secret data for key "api-key-1".', + prompt: 'List the user resources.', }); await mcpClient.close(); From b459de02e86927d051ab04c14a8432e960c73fb8 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 11:15:32 -0400 Subject: [PATCH 19/61] http transport method added --- .../src/tool/mcp/mcp-http-transport.test.ts | 212 ++++++++++++ .../ai/src/tool/mcp/mcp-http-transport.ts | 313 ++++++++++++++++++ packages/ai/src/tool/mcp/mcp-transport.ts | 20 +- 3 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 packages/ai/src/tool/mcp/mcp-http-transport.test.ts create mode 100644 packages/ai/src/tool/mcp/mcp-http-transport.ts diff --git a/packages/ai/src/tool/mcp/mcp-http-transport.test.ts b/packages/ai/src/tool/mcp/mcp-http-transport.test.ts new file mode 100644 index 000000000000..14f8441b8fbc --- /dev/null +++ b/packages/ai/src/tool/mcp/mcp-http-transport.test.ts @@ -0,0 +1,212 @@ +import { + createTestServer, + TestResponseController, +} from '@ai-sdk/test-server/with-vitest'; +import { beforeEach, describe, expect, it } 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(); + + server.urls['http://localhost:4000/stream'].response = { + type: 'controlled-stream', + controller, + headers: { 'content-type': 'text/event-stream' }, + }; + + 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 report HTTP errors from POST', async () => { + transport = new HttpMCPTransport({ url: 'http://localhost:4000/mcp' }); + + // Ensure the optional inbound SSE GET succeeds first + const controller = new TestResponseController(); + server.urls['http://localhost:4000/mcp'].response = { + type: 'controlled-stream', + controller, + headers: { 'content-type': 'text/event-stream' }, + }; + + await transport.start(); + + // Wait until the optional inbound SSE GET has actually been initiated + 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 transport.send({ + jsonrpc: '2.0' as const, + method: 'test', + id: 3, + params: {}, + }); + 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(); + + // Send invalid message over inbound SSE + 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, + }); + + // Make inbound SSE succeed + server.urls['http://localhost:4000/mcp'].response = { + type: 'controlled-stream', + controller, + headers: { 'content-type': 'text/event-stream' }, + }; + + await transport.start(); + + const message = { + jsonrpc: '2.0' as const, + method: 'test', + params: { foo: 'bar' }, + id: '1', + }; + + await transport.send(message); + + // Verify inbound SSE GET headers + 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/'); + + // Verify POST request headers + 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/ai/src/tool/mcp/mcp-http-transport.ts b/packages/ai/src/tool/mcp/mcp-http-transport.ts new file mode 100644 index 000000000000..286fe0c113c3 --- /dev/null +++ b/packages/ai/src/tool/mcp/mcp-http-transport.ts @@ -0,0 +1,313 @@ +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. + * + * - Sends JSON-RPC requests via HTTP POST to a single endpoint (this.url) + * - Handles responses as either JSON or text/event-stream + * - Performs OAuth authorization on 401 and retries once + * - Propagates 'mcp-session-id' headers across requests when provided by server + */ +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 }; + + 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(); + + // Attempt to open an optional inbound SSE stream for server-initiated messages. + // This is best-effort: servers may not support it (405). Auth is attempted once on 401. + const establishInboundSse = async (triedAuth: boolean = false) => { + try { + const headers = await this.commonHeaders({ + Accept: 'text/event-stream', + }); + + const response = await fetch(this.url.href, { + method: 'GET', + headers, + signal: this.abortController?.signal, + }); + + // Capture session id if provided + 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 establishInboundSse(true); + } + + 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 } = 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); + } + }; + + this.inboundSseConnection = { + close: () => reader.cancel(), + }; + + processEvents(); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + this.onerror?.(error); + } + }; + + void establishInboundSse(); + } + + async close(): Promise { + this.inboundSseConnection?.close(); + 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(); + 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 HTTP Transport Error: POSTing to endpoint (HTTP ${response.status}): ${text}`, + }); + this.onerror?.(error); + return; + } + + 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); + 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 } = 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); + } catch (error) { + this.onerror?.(error); + } + }; + + await attempt(); + } +} diff --git a/packages/ai/src/tool/mcp/mcp-transport.ts b/packages/ai/src/tool/mcp/mcp-transport.ts index 2a3405d3405e..3b67a548916f 100644 --- a/packages/ai/src/tool/mcp/mcp-transport.ts +++ b/packages/ai/src/tool/mcp/mcp-transport.ts @@ -1,6 +1,7 @@ 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'; /** @@ -41,7 +42,7 @@ export interface MCPTransport { } export type MCPTransportConfig = { - type: 'sse'; + type: 'sse' | 'http'; /** * The URL of the MCP server. @@ -60,14 +61,17 @@ export type MCPTransportConfig = { }; 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( From 3706cee58ccb9f9a29ec53a1a47be23b1a7e367d Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 11:43:29 -0400 Subject: [PATCH 20/61] proper imports --- examples/mcp/src/sse-with-auth/client.ts | 12 +++++++----- packages/ai/src/tool/index.ts | 7 +++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index c6f1509fde28..ec1dcf5522c7 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -1,16 +1,18 @@ import { openai } from '@ai-sdk/openai'; -import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; -import 'dotenv/config'; -import type { OAuthClientProvider } from '../../../../packages/ai/src/tool/mcp/oauth.js'; import { + experimental_createMCPClient, + generateText, + stepCountIs, auth, UnauthorizedError, -} from '../../../../packages/ai/src/tool/mcp/oauth.js'; +} from 'ai'; +import 'dotenv/config'; import type { + OAuthClientProvider, OAuthClientInformation, OAuthClientMetadata, OAuthTokens, -} from '../../../../packages/ai/src/tool/mcp/oauth-types.js'; +} from 'ai'; import { createServer } from 'node:http'; import { exec } from 'node:child_process'; diff --git a/packages/ai/src/tool/index.ts b/packages/ai/src/tool/index.ts index 8a81ecae344c..8683e4460a70 100644 --- a/packages/ai/src/tool/index.ts +++ b/packages/ai/src/tool/index.ts @@ -10,4 +10,11 @@ export { type MCPClientConfig as experimental_MCPClientConfig, type MCPClient as experimental_MCPClient, } from './mcp/mcp-client'; +export { auth, UnauthorizedError } from './mcp/oauth'; +export type { OAuthClientProvider } from './mcp/oauth'; +export type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from './mcp/oauth-types'; export type { MCPTransport } from './mcp/mcp-transport'; From 726b897f93105f21722ca74bd14b1bc1b0b70ebe Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 15:24:49 -0400 Subject: [PATCH 21/61] failing test for http transport --- .../src/tool/mcp/mcp-http-transport.test.ts | 78 +++++++++++++++++-- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/tool/mcp/mcp-http-transport.test.ts b/packages/ai/src/tool/mcp/mcp-http-transport.test.ts index 14f8441b8fbc..cdf1d81326aa 100644 --- a/packages/ai/src/tool/mcp/mcp-http-transport.test.ts +++ b/packages/ai/src/tool/mcp/mcp-http-transport.test.ts @@ -2,7 +2,7 @@ import { createTestServer, TestResponseController, } from '@ai-sdk/test-server/with-vitest'; -import { beforeEach, describe, expect, it } from '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'; @@ -87,10 +87,79 @@ describe('HttpMCPTransport', () => { }); }); + 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' }); - // Ensure the optional inbound SSE GET succeeds first const controller = new TestResponseController(); server.urls['http://localhost:4000/mcp'].response = { type: 'controlled-stream', @@ -100,7 +169,6 @@ describe('HttpMCPTransport', () => { await transport.start(); - // Wait until the optional inbound SSE GET has actually been initiated await new Promise(resolve => { const check = () => { if ( @@ -151,7 +219,6 @@ describe('HttpMCPTransport', () => { await transport.start(); - // Send invalid message over inbound SSE controller.write( `event: message\ndata: ${JSON.stringify({ foo: 'bar' })}\n\n`, ); @@ -174,7 +241,6 @@ describe('HttpMCPTransport', () => { headers: customHeaders as unknown as Record, }); - // Make inbound SSE succeed server.urls['http://localhost:4000/mcp'].response = { type: 'controlled-stream', controller, @@ -192,7 +258,6 @@ describe('HttpMCPTransport', () => { await transport.send(message); - // Verify inbound SSE GET headers expect(server.calls[0].requestHeaders).toEqual({ 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, accept: 'text/event-stream', @@ -200,7 +265,6 @@ describe('HttpMCPTransport', () => { }); expect(server.calls[0].requestUserAgent).toContain('ai-sdk/'); - // Verify POST request headers expect(server.calls[1].requestHeaders).toEqual({ 'content-type': 'application/json', 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, From 77689bacdafc795b1f9e5e8baaea977f7eec1ef1 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 15:26:16 -0400 Subject: [PATCH 22/61] fix for sse resumption --- .../ai/src/tool/mcp/mcp-http-transport.ts | 295 ++++++++++++------ 1 file changed, 192 insertions(+), 103 deletions(-) diff --git a/packages/ai/src/tool/mcp/mcp-http-transport.ts b/packages/ai/src/tool/mcp/mcp-http-transport.ts index 286fe0c113c3..a9269c1eb624 100644 --- a/packages/ai/src/tool/mcp/mcp-http-transport.ts +++ b/packages/ai/src/tool/mcp/mcp-http-transport.ts @@ -18,10 +18,9 @@ import { LATEST_PROTOCOL_VERSION } from './types'; /** * HTTP MCP transport implementing the Streamable HTTP style. * - * - Sends JSON-RPC requests via HTTP POST to a single endpoint (this.url) - * - Handles responses as either JSON or text/event-stream - * - Performs OAuth authorization on 401 and retries once - * - Propagates 'mcp-session-id' headers across requests when provided by server + * 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; @@ -32,6 +31,16 @@ export class HttpMCPTransport implements MCPTransport { 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; @@ -86,108 +95,26 @@ export class HttpMCPTransport implements MCPTransport { } this.abortController = new AbortController(); - // Attempt to open an optional inbound SSE stream for server-initiated messages. - // This is best-effort: servers may not support it (405). Auth is attempted once on 401. - const establishInboundSse = async (triedAuth: boolean = false) => { - try { - const headers = await this.commonHeaders({ - Accept: 'text/event-stream', - }); - - const response = await fetch(this.url.href, { - method: 'GET', - headers, - signal: this.abortController?.signal, - }); - - // Capture session id if provided - 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 establishInboundSse(true); - } - - 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 } = 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); - } - }; - - this.inboundSseConnection = { - close: () => reader.cancel(), - }; - - processEvents(); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return; - } - this.onerror?.(error); - } - }; - - void establishInboundSse(); + 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?.(); } @@ -224,15 +151,28 @@ export class HttpMCPTransport implements MCPTransport { if (result !== 'AUTHORIZED') { const error = new UnauthorizedError(); this.onerror?.(error); - return; + throw error; } } catch (error) { this.onerror?.(error); + if (error instanceof UnauthorizedError) { + throw error; + } return; } 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); const error = new MCPClientError({ @@ -305,9 +245,158 @@ export class HttpMCPTransport implements MCPTransport { this.onerror?.(error); } catch (error) { this.onerror?.(error); + if (error instanceof UnauthorizedError) { + 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(); + } + } + } } From 72c8db2bca393bf69bf28f20995ea405858a814a Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 16:40:27 -0400 Subject: [PATCH 23/61] working example --- examples/mcp/src/sse-with-auth/client.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index ec1dcf5522c7..77fb44655d46 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -113,17 +113,14 @@ async function main() { console.log('Creating MCP client with OAuth...'); try { - const serverUrl = - process.env.MCP_SERVER_URL || 'https://mcp.notion.com/sse'; + const serverUrl = process.env.MCP_SERVER_URL || 'https://mcp.vercel.com/'; const callbackPromise = waitForAuthorizationCode( Number(process.env.MCP_CALLBACK_PORT ?? 8090), ); const connect = async () => experimental_createMCPClient({ - transport: { type: 'sse', url: serverUrl, authProvider }, - onUncaughtError: error => - console.error('MCP Client uncaught error:', error), + transport: { type: 'http', url: serverUrl, authProvider }, }); let mcpClient; @@ -175,8 +172,9 @@ async function main() { }); } }, - system: 'You are a helpful assistant with access to protected resources.', - prompt: 'List the user resources.', + 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(); @@ -191,8 +189,6 @@ async function main() { } } -// Handle OAuth callback in a real application -// For this demo, the server auto-approves and the provider handles it internally main().catch(error => { console.error('\nFatal error:', error); process.exit(1); From 9e2a153e0bff2add27f6ef0b0f3feddf7cd38a83 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 17:12:33 -0400 Subject: [PATCH 24/61] working example pt2 --- examples/mcp/src/sse-with-auth/client.ts | 51 ++++++++++-------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 77fb44655d46..1e2c97eb3ec1 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -7,12 +7,13 @@ import { UnauthorizedError, } from 'ai'; import 'dotenv/config'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { - OAuthClientProvider, OAuthClientInformation, OAuthClientMetadata, OAuthTokens, -} from 'ai'; +} from '@modelcontextprotocol/sdk/shared/auth.js'; import { createServer } from 'node:http'; import { exec } from 'node:child_process'; @@ -118,37 +119,27 @@ async function main() { Number(process.env.MCP_CALLBACK_PORT ?? 8090), ); - const connect = async () => - experimental_createMCPClient({ - transport: { type: 'http', url: serverUrl, authProvider }, + // Proactively start/refresh auth. If redirect is needed, wait for code and exchange. + const startResult = await auth(authProvider, { serverUrl: new URL(serverUrl) }); + if (startResult !== 'AUTHORIZED') { + console.log('🔐 Authorization required. Waiting for OAuth callback...'); + const authorizationCode = await callbackPromise; + console.log('↪ Exchanging authorization code for tokens...'); + await auth(authProvider, { + serverUrl: new URL(serverUrl), + authorizationCode, + }); + } + + const connect = async () => { + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider, }); + return experimental_createMCPClient({ transport }); + }; let mcpClient; - try { - mcpClient = await connect(); - } catch (error) { - const unauthorized = - error instanceof UnauthorizedError || - (error && - typeof error === 'object' && - (error as any).name === 'UnauthorizedError'); - if (unauthorized) { - console.log('🔐 Authorization required. Waiting for OAuth callback...'); - - const authorizationCode = await callbackPromise; - - console.log('↪ Exchanging authorization code for tokens...'); - await auth(authProvider, { - serverUrl: new URL(serverUrl), - authorizationCode, - }); - - console.log('↪ Retrying connection with authorized tokens...'); - mcpClient = await connect(); - } else { - throw error; - } - } + mcpClient = await connect(); console.log('✓ MCP client connected with OAuth authentication'); From d34c51ef3958fb22063d6926e43ee92142325df3 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 17:15:06 -0400 Subject: [PATCH 25/61] pretty --- examples/mcp/src/sse-with-auth/client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 1e2c97eb3ec1..cf8aa72086d8 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -8,7 +8,7 @@ import { } from 'ai'; import 'dotenv/config'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; import type { OAuthClientInformation, OAuthClientMetadata, @@ -120,7 +120,9 @@ async function main() { ); // Proactively start/refresh auth. If redirect is needed, wait for code and exchange. - const startResult = await auth(authProvider, { serverUrl: new URL(serverUrl) }); + const startResult = await auth(authProvider, { + serverUrl: new URL(serverUrl), + }); if (startResult !== 'AUTHORIZED') { console.log('🔐 Authorization required. Waiting for OAuth callback...'); const authorizationCode = await callbackPromise; From 26275dc8e908ae17baca1060ef1aee8277c12417 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 8 Oct 2025 18:05:02 -0400 Subject: [PATCH 26/61] neater example --- examples/mcp/src/sse-with-auth/client.ts | 30 ++++++------------------ 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index cf8aa72086d8..43eedffa4723 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -4,7 +4,6 @@ import { generateText, stepCountIs, auth, - UnauthorizedError, } from 'ai'; import 'dotenv/config'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -111,22 +110,15 @@ function waitForAuthorizationCode(port: number): Promise { async function main() { const authProvider = new InMemoryOAuthClientProvider(); - console.log('Creating MCP client with OAuth...'); - try { - const serverUrl = process.env.MCP_SERVER_URL || 'https://mcp.vercel.com/'; - const callbackPromise = waitForAuthorizationCode( - Number(process.env.MCP_CALLBACK_PORT ?? 8090), - ); - - // Proactively start/refresh auth. If redirect is needed, wait for code and exchange. + const serverUrl = 'https://mcp.vercel.com/'; + const callbackPromise = waitForAuthorizationCode(Number(8090)); const startResult = await auth(authProvider, { serverUrl: new URL(serverUrl), }); if (startResult !== 'AUTHORIZED') { - console.log('🔐 Authorization required. Waiting for OAuth callback...'); const authorizationCode = await callbackPromise; - console.log('↪ Exchanging authorization code for tokens...'); + await auth(authProvider, { serverUrl: new URL(serverUrl), authorizationCode, @@ -143,12 +135,10 @@ async function main() { let mcpClient; mcpClient = await connect(); - console.log('✓ MCP client connected with OAuth authentication'); - const tools = await mcpClient.tools(); - console.log(`✓ Retrieved ${Object.keys(tools).length} protected tools`); - console.log(` Available tools: ${Object.keys(tools).join(', ')}`); + 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'), @@ -172,17 +162,11 @@ async function main() { await mcpClient.close(); - console.log('\n=== Final Answer ==='); - console.log(answer); - console.log('\n✓ MCP client closed'); + console.log(`FINAL ANSWER: ${answer}`); } catch (error) { - console.error('\nError during MCP client execution:'); console.error(error); throw error; } } -main().catch(error => { - console.error('\nFatal error:', error); - process.exit(1); -}); +main().catch(console.error); From 798632a00fcde10d6298efb1fafe7f2061b09540 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 9 Oct 2025 12:07:07 -0400 Subject: [PATCH 27/61] example refined --- examples/mcp/src/sse-with-auth/client.ts | 103 +++++++++++------------ packages/ai/src/tool/mcp/oauth.ts | 39 +-------- 2 files changed, 50 insertions(+), 92 deletions(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 43eedffa4723..3b3cf45945f9 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -6,13 +6,12 @@ import { auth, } from 'ai'; import 'dotenv/config'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; import type { + OAuthClientProvider, OAuthClientInformation, OAuthClientMetadata, OAuthTokens, -} from '@modelcontextprotocol/sdk/shared/auth.js'; +} from 'ai'; import { createServer } from 'node:http'; import { exec } from 'node:child_process'; @@ -72,6 +71,21 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { } } +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) => { @@ -109,64 +123,43 @@ function waitForAuthorizationCode(port: number): Promise { async function main() { const authProvider = new InMemoryOAuthClientProvider(); + const serverUrl = 'https://example-server.modelcontextprotocol.io/mcp'; - try { - const serverUrl = 'https://mcp.vercel.com/'; - const callbackPromise = waitForAuthorizationCode(Number(8090)); - const startResult = await auth(authProvider, { - serverUrl: new URL(serverUrl), - }); - if (startResult !== 'AUTHORIZED') { - const authorizationCode = await callbackPromise; - - await auth(authProvider, { - serverUrl: new URL(serverUrl), - authorizationCode, - }); - } + await authorizeWithPkceOnce(authProvider, serverUrl, () => + waitForAuthorizationCode(Number(8090)), + ); - const connect = async () => { - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - authProvider, - }); - return experimental_createMCPClient({ transport }); - }; - - let mcpClient; - mcpClient = await connect(); - - const tools = await mcpClient.tools(); + 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(', ')}`); + 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.', - }); + 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(); + await mcpClient.close(); - console.log(`FINAL ANSWER: ${answer}`); - } catch (error) { - console.error(error); - throw error; - } + console.log(`FINAL ANSWER: ${answer}`); } main().catch(console.error); diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/ai/src/tool/mcp/oauth.ts index 34119f9b1f12..cbe1c90e9508 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/ai/src/tool/mcp/oauth.ts @@ -115,7 +115,6 @@ export function extractResourceMetadataUrl( return undefined; } - // Example: WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource" // regex taken from MCP spec const regex = /resource_metadata="([^"]*)"/; const match = header.match(regex); @@ -141,7 +140,6 @@ function buildWellKnownPath( pathname: string = '', options: { prependPathname?: boolean } = {}, ): string { - // Strip trailing slash from pathname to avoid double slashes if (pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } @@ -161,10 +159,8 @@ async function fetchWithCorsRetry( } catch (error) { if (error instanceof TypeError) { if (headers) { - // CORS errors come back as TypeError, retry without headers return fetchWithCorsRetry(url, undefined, fetchFn); } else { - // We're getting CORS errors on retry too, return undefined return undefined; } } @@ -219,7 +215,6 @@ async function discoverMetadataWithFallback( if (opts?.metadataUrl) { url = new URL(opts.metadataUrl); } else { - // Try path-aware discovery first const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer); url.search = issuer.search; @@ -227,7 +222,6 @@ async function discoverMetadataWithFallback( let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); - // If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); @@ -283,7 +277,6 @@ export function buildDiscoveryUrls( const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = []; if (!hasPath) { - // Root path: https://example.com/.well-known/oauth-authorization-server urlsToTry.push({ url: new URL('/.well-known/oauth-authorization-server', url.origin), type: 'oauth', @@ -297,14 +290,11 @@ export function buildDiscoveryUrls( return urlsToTry; } - // Strip trailing slash from pathname to avoid double slashes let pathname = url.pathname; if (pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } - // 1. OAuth metadata at the given URL - // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1 urlsToTry.push({ url: new URL( `/.well-known/oauth-authorization-server${pathname}`, @@ -313,20 +303,16 @@ export function buildDiscoveryUrls( type: 'oauth', }); - // Root path: https://example.com/.well-known/oauth-authorization-server urlsToTry.push({ url: new URL('/.well-known/oauth-authorization-server', url.origin), type: 'oauth', }); - // 3. OIDC metadata endpoints - //RFC 8414 style: Insert /.well-known/openid-configuration before the path urlsToTry.push({ url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin), type: 'oidc', }); - // OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path urlsToTry.push({ url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin), type: 'oidc', @@ -363,14 +349,13 @@ export async function discoverAuthorizationServerMetadata( if (!response.ok) { // Continue looking for any 4xx response code. if (response.status >= 400 && response.status < 500) { - continue; // Try next URL + continue; } throw new Error( `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}`, ); } - // Parse and validate based on type if (type === 'oauth') { return OAuthMetadataSchema.parse(await response.json()); } else { @@ -435,7 +420,6 @@ export async function startAuthorization( authorizationUrl = new URL('/authorize', authorizationServerUrl); } - // Generate PKCE challenge const challenge = await pkceChallenge(); const codeVerifier = challenge.code_verifier; const codeChallenge = challenge.code_challenge; @@ -491,12 +475,10 @@ function selectClientAuthMethod( ): ClientAuthMethod { const hasClientSecret = clientInformation.client_secret !== undefined; - // If server doesn't specify supported methods, use RFC 6749 defaults if (supportedMethods.length === 0) { return hasClientSecret ? 'client_secret_post' : 'none'; } - // Try methods in priority order (most secure first) if (hasClientSecret && supportedMethods.includes('client_secret_basic')) { return 'client_secret_basic'; } @@ -509,7 +491,6 @@ function selectClientAuthMethod( return 'none'; } - // Fallback: use what we have return hasClientSecret ? 'client_secret_post' : 'none'; } @@ -667,7 +648,6 @@ export async function exchangeAuthorization( ); } - // Exchange code for tokens const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', @@ -682,7 +662,6 @@ export async function exchangeAuthorization( if (addClientAuthentication) { addClientAuthentication(headers, params, authorizationServerUrl, metadata); } else { - // Determine and apply client authentication method const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; const authMethod = selectClientAuthMethod( @@ -758,7 +737,6 @@ export async function refreshAuthorization( tokenUrl = new URL('/token', authorizationServerUrl); } - // Exchange refresh token const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', }); @@ -770,7 +748,6 @@ export async function refreshAuthorization( if (addClientAuthentication) { addClientAuthentication(headers, params, authorizationServerUrl, metadata); } else { - // Determine and apply client authentication method const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; const authMethod = selectClientAuthMethod( @@ -857,7 +834,6 @@ export async function auth( try { return await authInternal(provider, options); } catch (error) { - // Handle recoverable error types by invalidating credentials and retrying if ( error instanceof InvalidClientError || error instanceof UnauthorizedClientError @@ -869,7 +845,6 @@ export async function auth( return await authInternal(provider, options); } - // Throw otherwise throw error; } } @@ -881,7 +856,6 @@ export async function selectResourceURL( ): Promise { const defaultResource = resourceUrlFromServerUrl(serverUrl); - // If provider has custom validation, delegate to it if (provider.validateResourceURL) { return await provider.validateResourceURL( defaultResource, @@ -889,12 +863,10 @@ export async function selectResourceURL( ); } - // Only include resource parameter when Protected Resource Metadata is present if (!resourceMetadata) { return undefined; } - // Validate that the metadata's resource is compatible with our request if ( !checkResourceAllowed({ requestedResource: defaultResource, @@ -905,7 +877,6 @@ export async function selectResourceURL( `Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`, ); } - // Prefer the resource from metadata since it's what the server is telling us to request return new URL(resourceMetadata.resource); } @@ -939,9 +910,7 @@ async function authInternal( ) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } + } catch {} /** * If we don't get a valid authorization server metadata from protected resource metadata, @@ -964,7 +933,6 @@ async function authInternal( }, ); - // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { if (authorizationCode !== undefined) { @@ -1025,14 +993,11 @@ async function authInternal( await provider.saveTokens(newTokens); return 'AUTHORIZED'; } catch (error) { - // 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. if ( !(error instanceof MCPClientOAuthError) || error instanceof ServerError ) { - // Could not refresh OAuth tokens } else { - // Refresh failed for another reason, re-throw throw error; } } From 509a1ccd7cea667681c337b1961f7a4f5fd3e434 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 9 Oct 2025 15:58:52 -0400 Subject: [PATCH 28/61] updated example - works with most mcp servers --- examples/mcp/src/sse-with-auth/client.ts | 53 +++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 3b3cf45945f9..9acb4251fb58 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -61,6 +61,7 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', + scope: 'read', }; } async clientInformation(): Promise { @@ -69,6 +70,56 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { 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( @@ -123,7 +174,7 @@ function waitForAuthorizationCode(port: number): Promise { async function main() { const authProvider = new InMemoryOAuthClientProvider(); - const serverUrl = 'https://example-server.modelcontextprotocol.io/mcp'; + const serverUrl = 'https://mcp.jam.dev/mcp'; await authorizeWithPkceOnce(authProvider, serverUrl, () => waitForAuthorizationCode(Number(8090)), From d75f2796853dbf32044a84d2c110df315525517d Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 9 Oct 2025 16:50:57 -0400 Subject: [PATCH 29/61] refactor into new package --- examples/mcp/package.json | 1 + examples/mcp/src/http/client.ts | 7 +- examples/mcp/src/sse-with-auth/client.ts | 10 +- examples/mcp/src/sse/client.ts | 4 +- examples/mcp/src/stdio/client.ts | 3 +- .../next-openai/app/api/mcp-zapier/route.ts | 3 +- examples/next-openai/app/mcp/chat/route.ts | 2 +- examples/next-openai/package.json | 1 + packages/ai/src/error/index.ts | 1 - packages/ai/src/index.ts | 1 - packages/mcp/package.json | 67 +++++ .../{ai => mcp}/src/error/mcp-client-error.ts | 0 packages/{ai => mcp}/src/error/oauth-error.ts | 0 packages/{ai/src/tool => mcp/src}/index.ts | 12 +- packages/mcp/src/tool/index.ts | 5 + .../mcp => mcp/src/tool}/json-rpc-message.ts | 0 .../mcp => mcp/src/tool}/mcp-client.test.ts | 2 +- .../tool/mcp => mcp/src/tool}/mcp-client.ts | 2 +- .../src/tool}/mcp-http-transport.test.ts | 2 +- .../src/tool}/mcp-http-transport.ts | 4 +- .../src/tool}/mcp-sse-transport.test.ts | 2 +- .../mcp => mcp/src/tool}/mcp-sse-transport.ts | 4 +- .../mcp-stdio/create-child-process.test.ts | 93 +++++++ .../tool/mcp-stdio/create-child-process.ts | 21 ++ .../tool/mcp-stdio/get-environment.test.ts | 13 + .../mcp/src/tool/mcp-stdio/get-environment.ts | 43 +++ packages/mcp/src/tool/mcp-stdio/index.ts | 4 + .../mcp-stdio/mcp-stdio-transport.test.ts | 249 ++++++++++++++++++ .../src/tool/mcp-stdio/mcp-stdio-transport.ts | 154 +++++++++++ .../mcp => mcp/src/tool}/mcp-transport.ts | 2 +- .../src/tool}/mock-mcp-transport.ts | 0 .../tool/mcp => mcp/src/tool}/oauth-types.ts | 0 .../tool/mcp => mcp/src/tool}/oauth.test.ts | 2 +- .../src/tool/mcp => mcp/src/tool}/oauth.ts | 4 +- .../src/tool/mcp => mcp/src/tool}/types.ts | 0 packages/{ai => mcp}/src/util/oauth-util.ts | 0 .../src/util/oauth.util.test.ts} | 0 packages/mcp/src/version.ts | 5 + packages/mcp/tsconfig.build.json | 8 + packages/mcp/tsconfig.json | 22 ++ packages/mcp/tsup.config.ts | 10 + packages/mcp/vitest.edge.config.js | 15 ++ packages/mcp/vitest.node.config.js | 15 ++ pnpm-lock.yaml | 37 +++ tsconfig.json | 1 + 45 files changed, 796 insertions(+), 35 deletions(-) create mode 100644 packages/mcp/package.json rename packages/{ai => mcp}/src/error/mcp-client-error.ts (100%) rename packages/{ai => mcp}/src/error/oauth-error.ts (100%) rename packages/{ai/src/tool => mcp/src}/index.ts (56%) create mode 100644 packages/mcp/src/tool/index.ts rename packages/{ai/src/tool/mcp => mcp/src/tool}/json-rpc-message.ts (100%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-client.test.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-client.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-http-transport.test.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-http-transport.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-sse-transport.test.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-sse-transport.ts (98%) create mode 100644 packages/mcp/src/tool/mcp-stdio/create-child-process.test.ts create mode 100644 packages/mcp/src/tool/mcp-stdio/create-child-process.ts create mode 100644 packages/mcp/src/tool/mcp-stdio/get-environment.test.ts create mode 100644 packages/mcp/src/tool/mcp-stdio/get-environment.ts create mode 100644 packages/mcp/src/tool/mcp-stdio/index.ts create mode 100644 packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts create mode 100644 packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts rename packages/{ai/src/tool/mcp => mcp/src/tool}/mcp-transport.ts (97%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/mock-mcp-transport.ts (100%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/oauth-types.ts (100%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/oauth.test.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/oauth.ts (99%) rename packages/{ai/src/tool/mcp => mcp/src/tool}/types.ts (100%) rename packages/{ai => mcp}/src/util/oauth-util.ts (100%) rename packages/{ai/src/util/oauth-util.test.ts => mcp/src/util/oauth.util.test.ts} (100%) create mode 100644 packages/mcp/src/version.ts create mode 100644 packages/mcp/tsconfig.build.json create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/tsup.config.ts create mode 100644 packages/mcp/vitest.edge.config.js create mode 100644 packages/mcp/vitest.node.config.js diff --git a/examples/mcp/package.json b/examples/mcp/package.json index 044202f9f792..0d77b23fd542 100644 --- a/examples/mcp/package.json +++ b/examples/mcp/package.json @@ -24,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/sse-with-auth/client.ts b/examples/mcp/src/sse-with-auth/client.ts index 9acb4251fb58..d01bfd8bf784 100644 --- a/examples/mcp/src/sse-with-auth/client.ts +++ b/examples/mcp/src/sse-with-auth/client.ts @@ -1,17 +1,13 @@ import { openai } from '@ai-sdk/openai'; -import { - experimental_createMCPClient, - generateText, - stepCountIs, - auth, -} from 'ai'; +import { generateText, stepCountIs } from 'ai'; import 'dotenv/config'; +import { experimental_createMCPClient, auth } from '@ai-sdk/mcp'; import type { OAuthClientProvider, OAuthClientInformation, OAuthClientMetadata, OAuthTokens, -} from 'ai'; +} from '@ai-sdk/mcp'; import { createServer } from 'node:http'; import { exec } from 'node:child_process'; 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-zapier/route.ts b/examples/next-openai/app/api/mcp-zapier/route.ts index 86147d393f7b..1827c3aa51ce 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 { stepCountIs, streamText } from 'ai'; +import { experimental_createMCPClient } from '@ai-sdk/mcp'; export const maxDuration = 30; diff --git a/examples/next-openai/app/mcp/chat/route.ts b/examples/next-openai/app/mcp/chat/route.ts index f275eecb0140..5334b12deebf 100644 --- a/examples/next-openai/app/mcp/chat/route.ts +++ b/examples/next-openai/app/mcp/chat/route.ts @@ -2,10 +2,10 @@ import { openai } from '@ai-sdk/openai'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { convertToModelMessages, - experimental_createMCPClient, 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 7f48495e5db6..991a1aba705c 100644 --- a/examples/next-openai/package.json +++ b/examples/next-openai/package.json @@ -24,6 +24,7 @@ "@ai-sdk/react": "workspace:*", "@ai-sdk/rsc": "workspace:*", "@ai-sdk/xai": "workspace:*", + "@ai-sdk/mcp": "workspace:*", "@modelcontextprotocol/sdk": "^1.10.2", "@vercel/blob": "^0.26.0", "@vercel/sandbox": "^0.0.21", diff --git a/packages/ai/src/error/index.ts b/packages/ai/src/error/index.ts index 6ae97caf268f..9bb72c91eae5 100644 --- a/packages/ai/src/error/index.ts +++ b/packages/ai/src/error/index.ts @@ -16,7 +16,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 68dd9bee72f8..cb5f46990e0e 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -33,7 +33,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/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000000..ff22d0cf6700 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,67 @@ +{ + "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" + } + }, + "dependencies": { + "@ai-sdk/provider": "workspace:*", + "@ai-sdk/provider-utils": "workspace:*" + }, + "devDependencies": { + "@ai-sdk/test-server": "workspace:*", + "@types/node": "20.17.24", + "@vercel/ai-tsconfig": "workspace:*", + "tsup": "^8", + "typescript": "5.8.3", + "pkce-challenge": "^5.0.0", + "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/ai/src/error/oauth-error.ts b/packages/mcp/src/error/oauth-error.ts similarity index 100% rename from packages/ai/src/error/oauth-error.ts rename to packages/mcp/src/error/oauth-error.ts diff --git a/packages/ai/src/tool/index.ts b/packages/mcp/src/index.ts similarity index 56% rename from packages/ai/src/tool/index.ts rename to packages/mcp/src/index.ts index 8683e4460a70..3463aac84753 100644 --- a/packages/ai/src/tool/index.ts +++ b/packages/mcp/src/index.ts @@ -4,17 +4,17 @@ export type { JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, -} from './mcp/json-rpc-message'; +} from './tool/json-rpc-message'; export { createMCPClient as experimental_createMCPClient, type MCPClientConfig as experimental_MCPClientConfig, type MCPClient as experimental_MCPClient, -} from './mcp/mcp-client'; -export { auth, UnauthorizedError } from './mcp/oauth'; -export type { OAuthClientProvider } from './mcp/oauth'; +} from './tool/mcp-client'; +export { auth, UnauthorizedError } from './tool/oauth'; +export type { OAuthClientProvider } from './tool/oauth'; export type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens, -} from './mcp/oauth-types'; -export type { MCPTransport } from './mcp/mcp-transport'; +} 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 6a82e5628143..d28f9c721e05 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 * as 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 12764c5dcb9e..390c4a9d1951 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 * as 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/ai/src/tool/mcp/mcp-http-transport.test.ts b/packages/mcp/src/tool/mcp-http-transport.test.ts similarity index 99% rename from packages/ai/src/tool/mcp/mcp-http-transport.test.ts rename to packages/mcp/src/tool/mcp-http-transport.test.ts index cdf1d81326aa..462cc0b262a2 100644 --- a/packages/ai/src/tool/mcp/mcp-http-transport.test.ts +++ b/packages/mcp/src/tool/mcp-http-transport.test.ts @@ -5,7 +5,7 @@ import { 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'; +import { MCPClientError } from '../error/mcp-client-error'; describe('HttpMCPTransport', () => { const server = createTestServer({ diff --git a/packages/ai/src/tool/mcp/mcp-http-transport.ts b/packages/mcp/src/tool/mcp-http-transport.ts similarity index 99% rename from packages/ai/src/tool/mcp/mcp-http-transport.ts rename to packages/mcp/src/tool/mcp-http-transport.ts index a9269c1eb624..c3522cc01eb4 100644 --- a/packages/ai/src/tool/mcp/mcp-http-transport.ts +++ b/packages/mcp/src/tool/mcp-http-transport.ts @@ -3,10 +3,10 @@ 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, 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 99% rename from packages/ai/src/tool/mcp/mcp-sse-transport.test.ts rename to packages/mcp/src/tool/mcp-sse-transport.test.ts index 11ac168af71d..9efaedc3c32c 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.test.ts +++ b/packages/mcp/src/tool/mcp-sse-transport.test.ts @@ -2,7 +2,7 @@ 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'; diff --git a/packages/ai/src/tool/mcp/mcp-sse-transport.ts b/packages/mcp/src/tool/mcp-sse-transport.ts similarity index 98% rename from packages/ai/src/tool/mcp/mcp-sse-transport.ts rename to packages/mcp/src/tool/mcp-sse-transport.ts index 9e85749d11c9..01bbfe1f93b3 100644 --- a/packages/ai/src/tool/mcp/mcp-sse-transport.ts +++ b/packages/mcp/src/tool/mcp-sse-transport.ts @@ -3,10 +3,10 @@ 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, diff --git a/packages/mcp/src/tool/mcp-stdio/create-child-process.test.ts b/packages/mcp/src/tool/mcp-stdio/create-child-process.test.ts new file mode 100644 index 000000000000..7fb1f08e6f94 --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/create-child-process.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as getEnvironmentModule from './get-environment'; + +const DEFAULT_ENV = { + PATH: 'path', +}; + +const mockGetEnvironment = vi + .fn() + .mockImplementation((customEnv?: Record) => { + return { + ...DEFAULT_ENV, + ...customEnv, + }; + }); +vi.spyOn(getEnvironmentModule, 'getEnvironment').mockImplementation( + mockGetEnvironment, +); + +// important: import after mocking getEnv +const { createChildProcess } = await import('./create-child-process'); + +describe('createChildProcess', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should spawn a child process', async () => { + const childProcess = createChildProcess( + { command: process.execPath }, + new AbortController().signal, + ); + + expect(childProcess.pid).toBeDefined(); + expect(mockGetEnvironment).toHaveBeenCalledWith(undefined); + childProcess.kill(); + }); + + it('should spawn a child process with custom env', async () => { + const customEnv = { FOO: 'bar' }; + const childProcessWithCustomEnv = createChildProcess( + { command: process.execPath, env: customEnv }, + new AbortController().signal, + ); + + expect(childProcessWithCustomEnv.pid).toBeDefined(); + expect(mockGetEnvironment).toHaveBeenCalledWith(customEnv); + expect(mockGetEnvironment).toHaveReturnedWith({ + ...DEFAULT_ENV, + ...customEnv, + }); + childProcessWithCustomEnv.kill(); + }); + + it('should spawn a child process with args', async () => { + const childProcessWithArgs = createChildProcess( + { command: process.execPath, args: ['-c', 'echo', 'test'] }, + new AbortController().signal, + ); + + expect(childProcessWithArgs.pid).toBeDefined(); + expect(childProcessWithArgs.spawnargs).toContain(process.execPath); + expect(childProcessWithArgs.spawnargs).toEqual([ + process.execPath, + '-c', + 'echo', + 'test', + ]); + + childProcessWithArgs.kill(); + }); + + it('should spawn a child process with cwd', async () => { + const childProcessWithCwd = createChildProcess( + { command: process.execPath, cwd: '/tmp' }, + new AbortController().signal, + ); + + expect(childProcessWithCwd.pid).toBeDefined(); + childProcessWithCwd.kill(); + }); + + it('should spawn a child process with stderr', async () => { + const childProcessWithStderr = createChildProcess( + { command: process.execPath, stderr: 'pipe' }, + new AbortController().signal, + ); + + expect(childProcessWithStderr.pid).toBeDefined(); + expect(childProcessWithStderr.stderr).toBeDefined(); + childProcessWithStderr.kill(); + }); +}); diff --git a/packages/mcp/src/tool/mcp-stdio/create-child-process.ts b/packages/mcp/src/tool/mcp-stdio/create-child-process.ts new file mode 100644 index 000000000000..005c1b2a0a1f --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/create-child-process.ts @@ -0,0 +1,21 @@ +import { ChildProcess, spawn } from 'node:child_process'; +import { getEnvironment } from './get-environment'; +import { StdioConfig } from './mcp-stdio-transport'; + +export function createChildProcess( + config: StdioConfig, + signal: AbortSignal, +): ChildProcess { + return spawn(config.command, config.args ?? [], { + env: getEnvironment(config.env), + stdio: ['pipe', 'pipe', config.stderr ?? 'inherit'], + shell: false, + signal, + windowsHide: globalThis.process.platform === 'win32' && isElectron(), + cwd: config.cwd, + }); +} + +function isElectron() { + return 'type' in globalThis.process; +} diff --git a/packages/mcp/src/tool/mcp-stdio/get-environment.test.ts b/packages/mcp/src/tool/mcp-stdio/get-environment.test.ts new file mode 100644 index 000000000000..1ea0bb900766 --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/get-environment.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import { getEnvironment } from './get-environment'; + +describe('getEnvironment', () => { + it('should not mutate the original custom environment object', () => { + const customEnv = { CUSTOM_VAR: 'custom_value' }; + + const result = getEnvironment(customEnv); + + expect(customEnv).toStrictEqual({ CUSTOM_VAR: 'custom_value' }); + expect(result).not.toBe(customEnv); + }); +}); diff --git a/packages/mcp/src/tool/mcp-stdio/get-environment.ts b/packages/mcp/src/tool/mcp-stdio/get-environment.ts new file mode 100644 index 000000000000..d22b7f854caf --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/get-environment.ts @@ -0,0 +1,43 @@ +/** + * Constructs the environment variables for the child process. + * + * @param customEnv - Custom environment variables to merge with default environment variables. + * @returns The environment variables for the child process. + */ +export function getEnvironment( + customEnv?: Record, +): Record { + const DEFAULT_INHERITED_ENV_VARS = + globalThis.process.platform === 'win32' + ? [ + 'APPDATA', + 'HOMEDRIVE', + 'HOMEPATH', + 'LOCALAPPDATA', + 'PATH', + 'PROCESSOR_ARCHITECTURE', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'USERNAME', + 'USERPROFILE', + ] + : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; + + const env: Record = customEnv ? { ...customEnv } : {}; + + for (const key of DEFAULT_INHERITED_ENV_VARS) { + const value = globalThis.process.env[key]; + if (value === undefined) { + continue; + } + + if (value.startsWith('()')) { + continue; + } + + env[key] = value; + } + + return env; +} diff --git a/packages/mcp/src/tool/mcp-stdio/index.ts b/packages/mcp/src/tool/mcp-stdio/index.ts new file mode 100644 index 000000000000..f23ec3378ad7 --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/index.ts @@ -0,0 +1,4 @@ +export { + StdioMCPTransport as Experimental_StdioMCPTransport, + type StdioConfig, +} from './mcp-stdio-transport'; diff --git a/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts new file mode 100644 index 000000000000..12326edfa0fc --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.test.ts @@ -0,0 +1,249 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +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'; + +vi.mock('./create-child-process', { spy: true }); + +interface MockChildProcess { + stdin: EventEmitter & { write?: ReturnType }; + stdout: EventEmitter; + stderr: EventEmitter; + on: ReturnType; + removeAllListeners: ReturnType; +} + +describe('StdioMCPTransport', () => { + let transport: StdioMCPTransport; + let mockChildProcess: MockChildProcess; + let mockStdin: EventEmitter & { write?: ReturnType }; + let mockStdout: EventEmitter; + + beforeEach(() => { + vi.clearAllMocks(); + + mockStdin = new EventEmitter(); + mockStdout = new EventEmitter(); + mockChildProcess = { + stdin: mockStdin, + stdout: mockStdout, + stderr: new EventEmitter(), + on: vi.fn(), + removeAllListeners: vi.fn(), + }; + + vi.mocked(createChildProcess).mockReturnValue( + mockChildProcess as unknown as ChildProcess, + ); + + transport = new StdioMCPTransport({ + command: 'test-command', + args: ['--test'], + }); + }); + + afterEach(() => { + transport.close(); + }); + + describe('start', () => { + it('should successfully start the transport', async () => { + const stdinOnSpy = vi.spyOn(mockStdin, 'on'); + const stdoutOnSpy = vi.spyOn(mockStdout, 'on'); + + mockChildProcess.on.mockImplementation( + (event: string, callback: () => void) => { + if (event === 'spawn') { + callback(); + } + }, + ); + + const startPromise = transport.start(); + await expect(startPromise).resolves.toBeUndefined(); + + expect(mockChildProcess.on).toHaveBeenCalledWith( + 'error', + expect.any(Function), + ); + expect(mockChildProcess.on).toHaveBeenCalledWith( + 'spawn', + expect.any(Function), + ); + expect(mockChildProcess.on).toHaveBeenCalledWith( + 'close', + expect.any(Function), + ); + + expect(stdinOnSpy).toHaveBeenCalledWith('error', expect.any(Function)); + expect(stdoutOnSpy).toHaveBeenCalledWith('error', expect.any(Function)); + expect(stdoutOnSpy).toHaveBeenCalledWith('data', expect.any(Function)); + }); + + it('should throw error if already started', async () => { + mockChildProcess.on.mockImplementation( + (event: string, callback: () => void) => { + if (event === 'spawn') { + callback(); + } + }, + ); + const firstStart = transport.start(); + await expect(firstStart).resolves.toBeUndefined(); + const secondStart = transport.start(); + await expect(secondStart).rejects.toThrow(MCPClientError); + }); + + it('should handle spawn errors', async () => { + const error = new Error('Spawn failed'); + const onErrorSpy = vi.fn(); + transport.onerror = onErrorSpy; + + // simulate `spawn` failure by emitting error event after returning child process + mockChildProcess.on.mockImplementation( + (event: string, callback: (err: Error) => void) => { + if (event === 'error') { + callback(error); + } + }, + ); + + const startPromise = transport.start(); + await expect(startPromise).rejects.toThrow('Spawn failed'); + expect(onErrorSpy).toHaveBeenCalledWith(error); + }); + }); + + describe('send', () => { + beforeEach(async () => { + mockChildProcess.on.mockImplementation( + (event: string, callback: () => void) => { + if (event === 'spawn') { + callback(); + } + }, + ); + await transport.start(); + }); + + it('should successfully send a message', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {}, + }; + + mockStdin.write = vi.fn().mockReturnValue(true); + + await transport.send(message); + + expect(mockStdin.write).toHaveBeenCalledWith( + JSON.stringify(message) + '\n', + ); + }); + + it('should handle write backpressure', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {}, + }; + + mockStdin.write = vi.fn().mockReturnValue(false); + + const sendPromise = transport.send(message); + + mockStdin.emit('drain'); + + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it('should throw error if transport is not connected', async () => { + await transport.close(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {}, + }; + + await expect(transport.send(message)).rejects.toThrow(MCPClientError); + }); + }); + + describe('message handling', () => { + const onMessageSpy = vi.fn(); + + beforeEach(async () => { + mockChildProcess.on.mockImplementation( + (event: string, callback: () => void) => { + if (event === 'spawn') { + callback(); + } + }, + ); + transport.onmessage = onMessageSpy; + await transport.start(); + }); + + it('should handle incoming messages correctly', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {}, + }; + + mockStdout.emit('data', Buffer.from(JSON.stringify(message) + '\n')); + expect(onMessageSpy).toHaveBeenCalledWith(message); + }); + + it('should handle partial messages correctly', async () => { + const message = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {}, + }; + + const messageStr = JSON.stringify(message); + mockStdout.emit('data', Buffer.from(messageStr.slice(0, 10))); + mockStdout.emit('data', Buffer.from(messageStr.slice(10) + '\n')); + expect(onMessageSpy).toHaveBeenCalledWith(message); + }); + }); + + describe('close', () => { + const onCloseSpy = vi.fn(); + + beforeEach(async () => { + mockChildProcess.on.mockImplementation( + (event: string, callback: (code?: number) => void) => { + if (event === 'spawn') { + callback(); + } else if (event === 'close') { + callback(0); + } + }, + ); + transport.onclose = onCloseSpy; + await transport.start(); + }); + + it('should close the transport successfully', async () => { + await transport.close(); + + expect(mockChildProcess.on).toHaveBeenCalledWith( + 'close', + expect.any(Function), + ); + expect(onCloseSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts new file mode 100644 index 000000000000..11c34b180d86 --- /dev/null +++ b/packages/mcp/src/tool/mcp-stdio/mcp-stdio-transport.ts @@ -0,0 +1,154 @@ +import type { ChildProcess, IOType } from 'node:child_process'; +import { Stream } from 'node:stream'; +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 { + command: string; + args?: string[]; + env?: Record; + stderr?: IOType | Stream | number; + cwd?: string; +} + +export class StdioMCPTransport implements MCPTransport { + private process?: ChildProcess; + private abortController: AbortController = new AbortController(); + private readBuffer: ReadBuffer = new ReadBuffer(); + private serverParams: StdioConfig; + + onclose?: () => void; + onerror?: (error: unknown) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(server: StdioConfig) { + this.serverParams = server; + } + + async start(): Promise { + if (this.process) { + throw new MCPClientError({ + message: 'StdioMCPTransport already started.', + }); + } + + return new Promise((resolve, reject) => { + try { + const process = createChildProcess( + this.serverParams, + this.abortController.signal, + ); + + this.process = process; + + this.process.on('error', error => { + if (error.name === 'AbortError') { + this.onclose?.(); + return; + } + + reject(error); + this.onerror?.(error); + }); + + this.process.on('spawn', () => { + resolve(); + }); + + this.process.on('close', _code => { + this.process = undefined; + this.onclose?.(); + }); + + this.process.stdin?.on('error', error => { + this.onerror?.(error); + }); + + this.process.stdout?.on('data', chunk => { + this.readBuffer.append(chunk); + this.processReadBuffer(); + }); + + this.process.stdout?.on('error', error => { + this.onerror?.(error); + }); + } catch (error) { + reject(error); + this.onerror?.(error); + } + }); + } + + private processReadBuffer() { + while (true) { + try { + const message = this.readBuffer.readMessage(); + if (message === null) { + break; + } + + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + + async close(): Promise { + this.abortController.abort(); + this.process = undefined; + this.readBuffer.clear(); + } + + send(message: JSONRPCMessage): Promise { + return new Promise(resolve => { + if (!this.process?.stdin) { + throw new MCPClientError({ + message: 'StdioClientTransport not connected', + }); + } + + const json = serializeMessage(message); + if (this.process.stdin.write(json)) { + resolve(); + } else { + this.process.stdin.once('drain', resolve); + } + }); + } +} + +class ReadBuffer { + private buffer?: Buffer; + + append(chunk: Buffer): void { + this.buffer = this.buffer ? Buffer.concat([this.buffer, chunk]) : chunk; + } + + readMessage(): JSONRPCMessage | null { + if (!this.buffer) return null; + + const index = this.buffer.indexOf('\n'); + if (index === -1) { + return null; + } + + const line = this.buffer.toString('utf8', 0, index); + this.buffer = this.buffer.subarray(index + 1); + return deserializeMessage(line); + } + + clear(): void { + this.buffer = undefined; + } +} + +function serializeMessage(message: JSONRPCMessage): string { + return JSON.stringify(message) + '\n'; +} + +export function deserializeMessage(line: string): JSONRPCMessage { + return JSONRPCMessageSchema.parse(JSON.parse(line)); +} diff --git a/packages/ai/src/tool/mcp/mcp-transport.ts b/packages/mcp/src/tool/mcp-transport.ts similarity index 97% rename from packages/ai/src/tool/mcp/mcp-transport.ts rename to packages/mcp/src/tool/mcp-transport.ts index 3b67a548916f..b9a8fb76b019 100644 --- a/packages/ai/src/tool/mcp/mcp-transport.ts +++ b/packages/mcp/src/tool/mcp-transport.ts @@ -1,4 +1,4 @@ -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'; 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/ai/src/tool/mcp/oauth-types.ts b/packages/mcp/src/tool/oauth-types.ts similarity index 100% rename from packages/ai/src/tool/mcp/oauth-types.ts rename to packages/mcp/src/tool/oauth-types.ts diff --git a/packages/ai/src/tool/mcp/oauth.test.ts b/packages/mcp/src/tool/oauth.test.ts similarity index 99% rename from packages/ai/src/tool/mcp/oauth.test.ts rename to packages/mcp/src/tool/oauth.test.ts index 1ab62b93699d..f44b52fa7e22 100644 --- a/packages/ai/src/tool/mcp/oauth.test.ts +++ b/packages/mcp/src/tool/oauth.test.ts @@ -13,7 +13,7 @@ import { auth, } from './oauth'; import { AuthorizationServerMetadata } from './oauth-types'; -import { ServerError } from '../../error/oauth-error'; +import { ServerError } from '../error/oauth-error'; import { LATEST_PROTOCOL_VERSION } from './types'; // Mock the pkce-challenge module diff --git a/packages/ai/src/tool/mcp/oauth.ts b/packages/mcp/src/tool/oauth.ts similarity index 99% rename from packages/ai/src/tool/mcp/oauth.ts rename to packages/mcp/src/tool/oauth.ts index cbe1c90e9508..3f8e997920e3 100644 --- a/packages/ai/src/tool/mcp/oauth.ts +++ b/packages/mcp/src/tool/oauth.ts @@ -20,11 +20,11 @@ import { InvalidClientError, InvalidGrantError, UnauthorizedClientError, -} from '../../error/oauth-error'; +} from '../error/oauth-error'; import { resourceUrlFromServerUrl, checkResourceAllowed, -} from '../../util/oauth-util'; +} from '../util/oauth-util'; import { LATEST_PROTOCOL_VERSION } from './types'; import { FetchFunction } from '@ai-sdk/provider-utils'; 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/ai/src/util/oauth-util.ts b/packages/mcp/src/util/oauth-util.ts similarity index 100% rename from packages/ai/src/util/oauth-util.ts rename to packages/mcp/src/util/oauth-util.ts diff --git a/packages/ai/src/util/oauth-util.test.ts b/packages/mcp/src/util/oauth.util.test.ts similarity index 100% rename from packages/ai/src/util/oauth-util.test.ts rename to packages/mcp/src/util/oauth.util.test.ts 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..3f92041b987c --- /dev/null +++ b/packages/mcp/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + }, +]); 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 5da4cd722812..e13924d17a01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,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 @@ -824,6 +827,9 @@ importers: '@ai-sdk/groq': specifier: workspace:* version: link:../../packages/groq + '@ai-sdk/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@ai-sdk/mistral': specifier: workspace:* version: link:../../packages/mistral @@ -2228,6 +2234,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 + 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 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 + 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': 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" }, From a4e3a3ecb20671b40987bc19368d14f808840eba Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 9 Oct 2025 16:51:46 -0400 Subject: [PATCH 30/61] pretty --- examples/next-openai/app/mcp/chat/route.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/next-openai/app/mcp/chat/route.ts b/examples/next-openai/app/mcp/chat/route.ts index 5334b12deebf..dfb5c570a4ce 100644 --- a/examples/next-openai/app/mcp/chat/route.ts +++ b/examples/next-openai/app/mcp/chat/route.ts @@ -1,10 +1,6 @@ import { openai } from '@ai-sdk/openai'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { - convertToModelMessages, - stepCountIs, - streamText, -} from 'ai'; +import { convertToModelMessages, stepCountIs, streamText } from 'ai'; import { experimental_createMCPClient } from '@ai-sdk/mcp'; export async function POST(req: Request) { From d58e6f1fe63a4650500375707987ab306c317243 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 9 Oct 2025 16:56:36 -0400 Subject: [PATCH 31/61] rename --- examples/mcp/src/{sse-with-auth => mcp-with-auth}/client.ts | 0 examples/mcp/src/{sse-with-auth => mcp-with-auth}/server.ts | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename examples/mcp/src/{sse-with-auth => mcp-with-auth}/client.ts (100%) rename examples/mcp/src/{sse-with-auth => mcp-with-auth}/server.ts (100%) diff --git a/examples/mcp/src/sse-with-auth/client.ts b/examples/mcp/src/mcp-with-auth/client.ts similarity index 100% rename from examples/mcp/src/sse-with-auth/client.ts rename to examples/mcp/src/mcp-with-auth/client.ts diff --git a/examples/mcp/src/sse-with-auth/server.ts b/examples/mcp/src/mcp-with-auth/server.ts similarity index 100% rename from examples/mcp/src/sse-with-auth/server.ts rename to examples/mcp/src/mcp-with-auth/server.ts From ff986f4d69776b8771a309a4507340a59b1eb886 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 9 Oct 2025 17:22:16 -0400 Subject: [PATCH 32/61] changeset updated --- .changeset/fluffy-poems-live.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/fluffy-poems-live.md b/.changeset/fluffy-poems-live.md index b397ff8c21f0..82126c0c8cd0 100644 --- a/.changeset/fluffy-poems-live.md +++ b/.changeset/fluffy-poems-live.md @@ -1,5 +1,6 @@ --- 'ai': patch +'@ai-sdk/mcp': major --- -fix(ai): refactor mcp +feat(ai): add OAuth for MCP clients + refactor to new package From 23bd8c33c53ff706d121bae6b87c84bcc930e2f2 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 11:38:24 -0400 Subject: [PATCH 33/61] remove pkce dependency --- packages/ai/package.json | 3 +-- pnpm-lock.yaml | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index 4b6ddeef296d..abfff6547d50 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -57,8 +57,7 @@ "@ai-sdk/gateway": "workspace:*", "@ai-sdk/provider": "workspace:*", "@ai-sdk/provider-utils": "workspace:*", - "@opentelemetry/api": "1.9.0", - "pkce-challenge": "^5.0.0" + "@opentelemetry/api": "1.9.0" }, "devDependencies": { "@ai-sdk/test-server": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e458aa06e4fc..4e9e03b7d137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1429,9 +1429,6 @@ importers: '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 - pkce-challenge: - specifier: ^5.0.0 - version: 5.0.0 devDependencies: '@ai-sdk/test-server': specifier: workspace:* From 52f313bb08f16d4ba52edfd4b68f787521a41132 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 11:48:45 -0400 Subject: [PATCH 34/61] moving pkce to prod dep --- packages/mcp/package.json | 4 ++-- pnpm-lock.yaml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ff22d0cf6700..cff7ca5f1ee1 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -34,7 +34,8 @@ }, "dependencies": { "@ai-sdk/provider": "workspace:*", - "@ai-sdk/provider-utils": "workspace:*" + "@ai-sdk/provider-utils": "workspace:*", + "pkce-challenge": "^5.0.0" }, "devDependencies": { "@ai-sdk/test-server": "workspace:*", @@ -42,7 +43,6 @@ "@vercel/ai-tsconfig": "workspace:*", "tsup": "^8", "typescript": "5.8.3", - "pkce-challenge": "^5.0.0", "zod": "3.25.76" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e9e03b7d137..a30451ec453b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2257,6 +2257,9 @@ importers: '@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:* @@ -2267,9 +2270,6 @@ importers: '@vercel/ai-tsconfig': specifier: workspace:* version: link:../../tools/tsconfig - pkce-challenge: - specifier: ^5.0.0 - version: 5.0.0 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) From f5cc078967f7c4a5636065d681ea65dbb7ececf6 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 12:10:48 -0400 Subject: [PATCH 35/61] added mcp dependency to `ai` --- packages/ai/package.json | 8 ++------ packages/mcp/package.json | 6 ++++++ pnpm-lock.yaml | 3 +++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index abfff6547d50..5a941f4750ac 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -46,17 +46,13 @@ "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" - } + "./mcp-stdio": "@ai-sdk/mcp/mcp-stdio" }, "dependencies": { "@ai-sdk/gateway": "workspace:*", "@ai-sdk/provider": "workspace:*", "@ai-sdk/provider-utils": "workspace:*", + "@ai-sdk/mcp": "workspace:*", "@opentelemetry/api": "1.9.0" }, "devDependencies": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index cff7ca5f1ee1..1cbaafaa6938 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -30,6 +30,12 @@ "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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a30451ec453b..0f7d8d788290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1420,6 +1420,9 @@ importers: '@ai-sdk/gateway': specifier: workspace:* version: link:../gateway + '@ai-sdk/mcp': + specifier: workspace:* + version: link:../mcp '@ai-sdk/provider': specifier: workspace:* version: link:../provider From 22a4fe743bdadf924d67e0337b07ae9e99817688 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 12:28:45 -0400 Subject: [PATCH 36/61] legacy imports still work --- examples/mcp/package.json | 4 ++-- examples/mcp/src/mcp-with-auth/client.ts | 5 ++--- packages/ai/src/index.ts | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/mcp/package.json b/examples/mcp/package.json index 0d77b23fd542..5c5532915ad2 100644 --- a/examples/mcp/package.json +++ b/examples/mcp/package.json @@ -5,8 +5,8 @@ "scripts": { "sse:server": "tsx src/sse/server.ts", "sse:client": "tsx src/sse/client.ts", - "sse-auth:server": "tsx src/sse-with-auth/server.ts", - "sse-auth:client": "tsx src/sse-with-auth/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", diff --git a/examples/mcp/src/mcp-with-auth/client.ts b/examples/mcp/src/mcp-with-auth/client.ts index d01bfd8bf784..4cab0d538342 100644 --- a/examples/mcp/src/mcp-with-auth/client.ts +++ b/examples/mcp/src/mcp-with-auth/client.ts @@ -1,13 +1,12 @@ import { openai } from '@ai-sdk/openai'; -import { generateText, stepCountIs } from 'ai'; +import { generateText, stepCountIs, experimental_createMCPClient, auth } from 'ai'; import 'dotenv/config'; -import { experimental_createMCPClient, auth } from '@ai-sdk/mcp'; import type { OAuthClientProvider, OAuthClientInformation, OAuthClientMetadata, OAuthTokens, -} from '@ai-sdk/mcp'; +} from 'ai'; import { createServer } from 'node:http'; import { exec } from 'node:child_process'; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index ff831f5c2f95..e68e55a88231 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -40,6 +40,7 @@ export * from './types'; export * from './ui'; export * from './ui-message-stream'; export * from './util'; +export * from '@ai-sdk/mcp' // telemetry types: export type { TelemetrySettings } from './telemetry/telemetry-settings'; From 5ee1cb2a4e3172d8162dfc95dd5fd616d80598ee Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 12:54:37 -0400 Subject: [PATCH 37/61] pretty --- examples/mcp/src/mcp-with-auth/client.ts | 7 ++++++- packages/ai/src/index.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/mcp/src/mcp-with-auth/client.ts b/examples/mcp/src/mcp-with-auth/client.ts index 4cab0d538342..67d1de27a59b 100644 --- a/examples/mcp/src/mcp-with-auth/client.ts +++ b/examples/mcp/src/mcp-with-auth/client.ts @@ -1,5 +1,10 @@ import { openai } from '@ai-sdk/openai'; -import { generateText, stepCountIs, experimental_createMCPClient, auth } from 'ai'; +import { + generateText, + stepCountIs, + experimental_createMCPClient, + auth, +} from 'ai'; import 'dotenv/config'; import type { OAuthClientProvider, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index e68e55a88231..f91c817ffe93 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -40,7 +40,7 @@ export * from './types'; export * from './ui'; export * from './ui-message-stream'; export * from './util'; -export * from '@ai-sdk/mcp' +export * from '@ai-sdk/mcp'; // telemetry types: export type { TelemetrySettings } from './telemetry/telemetry-settings'; From 65a1367733fa075e7de1327f4b3ec8ef81220f4c Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 13:30:05 -0400 Subject: [PATCH 38/61] build fix --- packages/ai/tsconfig.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index 7dedfdb70326..bb6217385c49 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -30,6 +30,9 @@ }, { "path": "../test-server" + }, + { + "path": "../mcp" } ] } From 60d359b500afc4c3efd6b172e97635bfb5f8bbca Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 13:54:24 -0400 Subject: [PATCH 39/61] deprecated fucntion ref --- examples/mcp/src/mcp-with-auth/client.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/examples/mcp/src/mcp-with-auth/client.ts b/examples/mcp/src/mcp-with-auth/client.ts index 67d1de27a59b..33ae7bfba960 100644 --- a/examples/mcp/src/mcp-with-auth/client.ts +++ b/examples/mcp/src/mcp-with-auth/client.ts @@ -1,17 +1,26 @@ import { openai } from '@ai-sdk/openai'; -import { - generateText, - stepCountIs, - experimental_createMCPClient, - auth, +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'; +} from '@ai-sdk/mcp'; import { createServer } from 'node:http'; import { exec } from 'node:child_process'; From ea65a785b4aad61a76eda4da3b26f73ba12ea76c Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 17:21:35 -0400 Subject: [PATCH 40/61] documentation added --- content/cookbook/01-next/73-mcp-tools.mdx | 51 +++++++++++++------ content/cookbook/05-node/54-mcp-tools.mdx | 51 +++++++++++++------ content/docs/03-ai-sdk-core/16-mcp-tools.mdx | 39 +++++++++++--- .../01-ai-sdk-core/23-create-mcp-client.mdx | 11 +++- 4 files changed, 110 insertions(+), 42 deletions(-) diff --git a/content/cookbook/01-next/73-mcp-tools.mdx b/content/cookbook/01-next/73-mcp-tools.mdx index f6ea163630f8..e70c622a63eb 100644 --- a/content/cookbook/01-next/73-mcp-tools.mdx +++ b/content/cookbook/01-next/73-mcp-tools.mdx @@ -12,7 +12,7 @@ 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: @@ -20,16 +20,17 @@ To use the `StreamableHTTPClientTransport`, you will need to install the officia import { experimental_createMCPClient, streamText } from 'ai'; import { Experimental_StdioMCPTransport } from 'ai/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..8cb618ead9fd 100644 --- a/content/cookbook/05-node/54-mcp-tools.mdx +++ b/content/cookbook/05-node/54-mcp-tools.mdx @@ -8,7 +8,7 @@ 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. @@ -16,17 +16,18 @@ Use the official Model Context Protocol Typescript SDK to create the connection import { experimental_createMCPClient, generateText, stepCountIs } from 'ai'; import { Experimental_StdioMCPTransport } from 'ai/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 +36,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..127e03493449 100644 --- a/content/docs/03-ai-sdk-core/16-mcp-tools.mdx +++ b/content/docs/03-ai-sdk-core/16-mcp-tools.mdx @@ -18,13 +18,32 @@ 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'; + +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'; @@ -40,7 +59,7 @@ 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'; @@ -50,10 +69,11 @@ const mcpClient = await createMCPClient({ 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, }, }); ``` @@ -87,8 +107,11 @@ 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..f0614657caf1 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 @@ -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.', + }, ], }, ], From 10b886e85c28bfad6278bd8aa30285f1d6d4d2ed Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 17:27:28 -0400 Subject: [PATCH 41/61] pretty --- content/cookbook/01-next/73-mcp-tools.mdx | 4 ++-- content/cookbook/05-node/54-mcp-tools.mdx | 2 +- content/docs/03-ai-sdk-core/16-mcp-tools.mdx | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/content/cookbook/01-next/73-mcp-tools.mdx b/content/cookbook/01-next/73-mcp-tools.mdx index e70c622a63eb..8f43d4ee2176 100644 --- a/content/cookbook/01-next/73-mcp-tools.mdx +++ b/content/cookbook/01-next/73-mcp-tools.mdx @@ -47,7 +47,7 @@ export async function POST(req: Request) { // optional: configure headers // headers: { Authorization: 'Bearer my-api-key' }, - + // optional: provide an OAuth client provider for automatic authorization // authProvider: myOAuthClientProvider, }, @@ -61,7 +61,7 @@ export async function POST(req: Request) { // optional: configure headers // headers: { Authorization: 'Bearer my-api-key' }, - + // optional: provide an OAuth client provider for automatic authorization // authProvider: myOAuthClientProvider, }, diff --git a/content/cookbook/05-node/54-mcp-tools.mdx b/content/cookbook/05-node/54-mcp-tools.mdx index 8cb618ead9fd..f1e11db8c34d 100644 --- a/content/cookbook/05-node/54-mcp-tools.mdx +++ b/content/cookbook/05-node/54-mcp-tools.mdx @@ -58,7 +58,7 @@ try { // optional: configure headers // headers: { Authorization: 'Bearer my-api-key' }, - + // optional: provide an OAuth client provider for automatic authorization // authProvider: myOAuthClientProvider, }, 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 127e03493449..665421412951 100644 --- a/content/docs/03-ai-sdk-core/16-mcp-tools.mdx +++ b/content/docs/03-ai-sdk-core/16-mcp-tools.mdx @@ -110,8 +110,9 @@ You can also bring your own transport by implementing the `MCPTransport` interfa 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`. +Authorization via OAuth is supported when using the AI SDK MCP HTTP or SSE +transports by providing an `authProvider`. + ### Closing the MCP Client From 4f1adaf42ce76355a03758ee8cda4eb42c3ddf7c Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 17:38:25 -0400 Subject: [PATCH 42/61] agent fix --- packages/mcp/src/error/oauth-error.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/error/oauth-error.ts b/packages/mcp/src/error/oauth-error.ts index 7f34a6f61bd0..c855096b55db 100644 --- a/packages/mcp/src/error/oauth-error.ts +++ b/packages/mcp/src/error/oauth-error.ts @@ -30,10 +30,6 @@ export class ServerError extends MCPClientOAuthError { static errorCode = 'server_error'; } -export const OAUTH_ERRORS = { - [ServerError.errorCode]: ServerError, -}; - export class InvalidClientError extends MCPClientOAuthError { static errorCode = 'invalid_client'; } @@ -45,3 +41,10 @@ export class InvalidGrantError extends MCPClientOAuthError { 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, +}; From a9fcd88d6fddf011a9f760bf8883b924e80f4ad1 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Fri, 10 Oct 2025 18:04:20 -0400 Subject: [PATCH 43/61] agent fix --- packages/mcp/tsup.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts index 3f92041b987c..99634b2f52bb 100644 --- a/packages/mcp/tsup.config.ts +++ b/packages/mcp/tsup.config.ts @@ -7,4 +7,11 @@ export default defineConfig([ dts: true, sourcemap: true, }, + { + entry: ['src/tool/mcp-stdio/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/mcp-stdio', + }, ]); From 0e5ff9706f934bead92c24a0c439725deacebd1b Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 14 Oct 2025 15:19:27 -0400 Subject: [PATCH 44/61] docs fixes --- content/cookbook/01-next/73-mcp-tools.mdx | 4 ++-- content/cookbook/05-node/54-mcp-tools.mdx | 4 ++-- content/docs/03-ai-sdk-core/16-mcp-tools.mdx | 10 +++++----- .../01-ai-sdk-core/23-create-mcp-client.mdx | 6 +++--- packages/mcp/src/tool/mcp-http-transport.ts | 1 - 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/content/cookbook/01-next/73-mcp-tools.mdx b/content/cookbook/01-next/73-mcp-tools.mdx index 8f43d4ee2176..694d6b87f46d 100644 --- a/content/cookbook/01-next/73-mcp-tools.mdx +++ b/content/cookbook/01-next/73-mcp-tools.mdx @@ -17,8 +17,8 @@ If you prefer to use the official transports (optional), install the official Ty ```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'; // Optional: Official transports if you prefer them // import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; diff --git a/content/cookbook/05-node/54-mcp-tools.mdx b/content/cookbook/05-node/54-mcp-tools.mdx index f1e11db8c34d..e572b253bc75 100644 --- a/content/cookbook/05-node/54-mcp-tools.mdx +++ b/content/cookbook/05-node/54-mcp-tools.mdx @@ -13,8 +13,8 @@ If you prefer to use the official transports (optional), install the official Mo ```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'; // Optional: Official transports if you prefer them // import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; 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 665421412951..e3095ab41672 100644 --- a/content/docs/03-ai-sdk-core/16-mcp-tools.mdx +++ b/content/docs/03-ai-sdk-core/16-mcp-tools.mdx @@ -27,7 +27,7 @@ Create an MCP client using one of the following transport options: 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: { @@ -46,7 +46,7 @@ const mcpClient = await createMCPClient({ Alternatively, you can use `StreamableHTTPClientTransport` from MCP's official TypeScript SDK: ```typescript -import { experimental_createMCPClient as createMCPClient } from 'ai'; +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'); @@ -62,7 +62,7 @@ const mcpClient = await createMCPClient({ 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: { @@ -87,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({ 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 f0614657caf1..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 @@ -167,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/packages/mcp/src/tool/mcp-http-transport.ts b/packages/mcp/src/tool/mcp-http-transport.ts index c3522cc01eb4..f1255702c2f8 100644 --- a/packages/mcp/src/tool/mcp-http-transport.ts +++ b/packages/mcp/src/tool/mcp-http-transport.ts @@ -150,7 +150,6 @@ export class HttpMCPTransport implements MCPTransport { }); if (result !== 'AUTHORIZED') { const error = new UnauthorizedError(); - this.onerror?.(error); throw error; } } catch (error) { From 1ddbeae68a6c5d537a1eb653599dabd5c82197fb Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 14 Oct 2025 15:22:28 -0400 Subject: [PATCH 45/61] bug fix --- packages/mcp/src/tool/oauth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/tool/oauth.ts b/packages/mcp/src/tool/oauth.ts index 3f8e997920e3..2e401b3b2281 100644 --- a/packages/mcp/src/tool/oauth.ts +++ b/packages/mcp/src/tool/oauth.ts @@ -994,8 +994,8 @@ async function authInternal( return 'AUTHORIZED'; } catch (error) { if ( - !(error instanceof MCPClientOAuthError) || - error instanceof ServerError + !(error instanceof MCPClientOAuthError) && + !(error instanceof ServerError) ) { } else { throw error; From b9336bd48e6a7a0e092dfa11c3a8bc027d00d06b Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 14 Oct 2025 15:23:51 -0400 Subject: [PATCH 46/61] pretty --- content/cookbook/05-node/54-mcp-tools.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/content/cookbook/05-node/54-mcp-tools.mdx b/content/cookbook/05-node/54-mcp-tools.mdx index e572b253bc75..9f8663dbfa84 100644 --- a/content/cookbook/05-node/54-mcp-tools.mdx +++ b/content/cookbook/05-node/54-mcp-tools.mdx @@ -13,7 +13,11 @@ If you prefer to use the official transports (optional), install the official Mo ```ts -import { experimental_createMCPClient, generateText, stepCountIs } from '@ai-sdk/mcp'; +import { + experimental_createMCPClient, + generateText, + stepCountIs, +} from '@ai-sdk/mcp'; import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; import { openai } from '@ai-sdk/openai'; // Optional: Official transports if you prefer them From c70031091a02ed8d0ad50e4de9529f118dc2b7fd Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 14 Oct 2025 16:12:02 -0400 Subject: [PATCH 47/61] updated example --- examples/mcp/src/mcp-with-auth/client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/mcp/src/mcp-with-auth/client.ts b/examples/mcp/src/mcp-with-auth/client.ts index 33ae7bfba960..ae3f2168cd82 100644 --- a/examples/mcp/src/mcp-with-auth/client.ts +++ b/examples/mcp/src/mcp-with-auth/client.ts @@ -70,7 +70,6 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post', - scope: 'read', }; } async clientInformation(): Promise { @@ -183,7 +182,7 @@ function waitForAuthorizationCode(port: number): Promise { async function main() { const authProvider = new InMemoryOAuthClientProvider(); - const serverUrl = 'https://mcp.jam.dev/mcp'; + const serverUrl = 'https://mcp.vercel.com/'; await authorizeWithPkceOnce(authProvider, serverUrl, () => waitForAuthorizationCode(Number(8090)), From 492032f95eaf5b32d374b63cec4cd9cacdf00c3e Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 14 Oct 2025 16:14:05 -0400 Subject: [PATCH 48/61] updated zapier example for mcp --- examples/next-openai/app/api/mcp-zapier/route.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/next-openai/app/api/mcp-zapier/route.ts b/examples/next-openai/app/api/mcp-zapier/route.ts index 1827c3aa51ce..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,5 @@ import { openai } from '@ai-sdk/openai'; -import { stepCountIs, streamText } from 'ai'; +import { convertToModelMessages, stepCountIs, streamText } from 'ai'; import { experimental_createMCPClient } from '@ai-sdk/mcp'; export const maxDuration = 30; @@ -9,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', }, }); @@ -19,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(); From 7a2e305e61b4fbf35abd4cb813ce58b10c05b8e8 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Tue, 14 Oct 2025 17:43:50 -0400 Subject: [PATCH 49/61] removing mcp export --- packages/ai/package.json | 4 +--- packages/ai/src/index.ts | 1 - pnpm-lock.yaml | 3 --- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/ai/package.json b/packages/ai/package.json index 5e21694d8c78..e5952eec7144 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -46,14 +46,12 @@ "import": "./dist/test/index.mjs", "module": "./dist/test/index.mjs", "require": "./dist/test/index.js" - }, - "./mcp-stdio": "@ai-sdk/mcp/mcp-stdio" + } }, "dependencies": { "@ai-sdk/gateway": "workspace:*", "@ai-sdk/provider": "workspace:*", "@ai-sdk/provider-utils": "workspace:*", - "@ai-sdk/mcp": "workspace:*", "@opentelemetry/api": "1.9.0" }, "devDependencies": { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index f91c817ffe93..ff831f5c2f95 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -40,7 +40,6 @@ export * from './types'; export * from './ui'; export * from './ui-message-stream'; export * from './util'; -export * from '@ai-sdk/mcp'; // telemetry types: export type { TelemetrySettings } from './telemetry/telemetry-settings'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfa6be9d60c7..b07f4abde343 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1384,9 +1384,6 @@ importers: '@ai-sdk/gateway': specifier: workspace:* version: link:../gateway - '@ai-sdk/mcp': - specifier: workspace:* - version: link:../mcp '@ai-sdk/provider': specifier: workspace:* version: link:../provider From f31b0f62e476fe66229747e1eb7b4582ba7fb37a Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 15 Oct 2025 12:27:43 -0400 Subject: [PATCH 50/61] working e2e exmaple --- .../app/api/mcp-with-auth/route.ts | 249 ++++++++++++++++++ .../next-openai/app/mcp-with-auth/page.tsx | 52 ++++ 2 files changed, 301 insertions(+) create mode 100644 examples/next-openai/app/api/mcp-with-auth/route.ts create mode 100644 examples/next-openai/app/mcp-with-auth/page.tsx 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..cd23299bc3f6 --- /dev/null +++ b/examples/next-openai/app/api/mcp-with-auth/route.ts @@ -0,0 +1,249 @@ +import { openai } from '@ai-sdk/openai'; +import { convertToModelMessages, stepCountIs, streamText } from 'ai'; +import { + experimental_createMCPClient, + auth, + type OAuthClientInformation, + type OAuthClientMetadata, + type OAuthTokens, +} from '@ai-sdk/mcp'; +import { createServer } from 'node:http'; +import { exec } from 'node:child_process'; + +// 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 { + 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.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`); + }); + }); +} + +async function authorizeWithPkceOnce( + authProvider: InMemoryOAuthClientProvider, + 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, + }); + } +} + +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 authProvider = new InMemoryOAuthClientProvider( + serverUrl, + callbackPort, + ); + + // Perform auth if needed (will open browser window) + await authorizeWithPkceOnce(authProvider, serverUrl, () => + waitForAuthorizationCode(callbackPort), + ); + + const mcpClient = await experimental_createMCPClient({ + transport: { type: 'http', url: serverUrl, authProvider }, + }); + + 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), + onStepFinish: async ({ toolResults }) => { + if (toolResults.length > 0) { + console.log('Tool execution results:'); + toolResults.forEach(result => { + console.log( + ` - ${result.toolName}:`, + JSON.stringify(result, null, 2), + ); + }); + } + }, + onFinish: async () => { + await mcpClient.close(); + }, + }); + + return result.toUIMessageStreamResponse(); + } catch (error) { + console.error('MCP with auth error:', error); + return Response.json({ error: 'Unexpected error' }, { status: 500 }); + } +} + 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..cc6f25b26505 --- /dev/null +++ b/examples/next-openai/app/mcp-with-auth/page.tsx @@ -0,0 +1,52 @@ +'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' }), + }); + + 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 })} /> +
+ ); +} From 260801780ea743a428e57aff72e84e42fdc2badc Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 15 Oct 2025 12:45:20 -0400 Subject: [PATCH 51/61] pretty + fx --- examples/next-openai/app/api/mcp-with-auth/route.ts | 1 - packages/mcp/src/tool/mcp-http-transport.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/examples/next-openai/app/api/mcp-with-auth/route.ts b/examples/next-openai/app/api/mcp-with-auth/route.ts index cd23299bc3f6..f6463a75b4ab 100644 --- a/examples/next-openai/app/api/mcp-with-auth/route.ts +++ b/examples/next-openai/app/api/mcp-with-auth/route.ts @@ -246,4 +246,3 @@ export async function POST(req: Request) { return Response.json({ error: 'Unexpected error' }, { status: 500 }); } } - diff --git a/packages/mcp/src/tool/mcp-http-transport.ts b/packages/mcp/src/tool/mcp-http-transport.ts index f1255702c2f8..827bb69e9954 100644 --- a/packages/mcp/src/tool/mcp-http-transport.ts +++ b/packages/mcp/src/tool/mcp-http-transport.ts @@ -154,9 +154,6 @@ export class HttpMCPTransport implements MCPTransport { } } catch (error) { this.onerror?.(error); - if (error instanceof UnauthorizedError) { - throw error; - } return; } return attempt(true); From a71defa26f02362f085c855a98dbec4df06fb8e2 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 15 Oct 2025 14:15:41 -0400 Subject: [PATCH 52/61] agent fix --- packages/mcp/src/tool/oauth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/tool/oauth.ts b/packages/mcp/src/tool/oauth.ts index 2e401b3b2281..32cebf13350b 100644 --- a/packages/mcp/src/tool/oauth.ts +++ b/packages/mcp/src/tool/oauth.ts @@ -994,8 +994,8 @@ async function authInternal( return 'AUTHORIZED'; } catch (error) { if ( - !(error instanceof MCPClientOAuthError) && - !(error instanceof ServerError) + error instanceof MCPClientOAuthError || + error instanceof ServerError ) { } else { throw error; From a89bc67a567968190f92a6e38b5c7d390c7401eb Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 15 Oct 2025 14:21:15 -0400 Subject: [PATCH 53/61] authFlow fix --- packages/mcp/src/tool/oauth.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/tool/oauth.ts b/packages/mcp/src/tool/oauth.ts index 32cebf13350b..8409708ac687 100644 --- a/packages/mcp/src/tool/oauth.ts +++ b/packages/mcp/src/tool/oauth.ts @@ -994,10 +994,13 @@ async function authInternal( return 'AUTHORIZED'; } catch (error) { if ( - error instanceof MCPClientOAuthError || + // 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; } } From e61089a0e76b2e5713d5a129066b7eceba3ef930 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 16 Oct 2025 16:26:32 -0400 Subject: [PATCH 54/61] throw error on using wrong transport --- packages/mcp/src/tool/mcp-http-transport.ts | 20 +++++++++++++------- packages/mcp/src/tool/mcp-sse-transport.ts | 8 +++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/mcp/src/tool/mcp-http-transport.ts b/packages/mcp/src/tool/mcp-http-transport.ts index 827bb69e9954..5ddd574dea80 100644 --- a/packages/mcp/src/tool/mcp-http-transport.ts +++ b/packages/mcp/src/tool/mcp-http-transport.ts @@ -154,7 +154,7 @@ export class HttpMCPTransport implements MCPTransport { } } catch (error) { this.onerror?.(error); - return; + throw error; } return attempt(true); } @@ -171,11 +171,18 @@ export class HttpMCPTransport implements MCPTransport { 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: `MCP HTTP Transport Error: POSTing to endpoint (HTTP ${response.status}): ${text}`, + message: errorMessage, }); this.onerror?.(error); - return; + throw error; } const contentType = response.headers.get('content-type') || ''; @@ -195,7 +202,7 @@ export class HttpMCPTransport implements MCPTransport { 'MCP HTTP Transport Error: text/event-stream response without body', }); this.onerror?.(error); - return; + throw error; } const stream = response.body @@ -239,11 +246,10 @@ export class HttpMCPTransport implements MCPTransport { message: `MCP HTTP Transport Error: Unexpected content type: ${contentType}`, }); this.onerror?.(error); + throw error; } catch (error) { this.onerror?.(error); - if (error instanceof UnauthorizedError) { - throw error; - } + throw error; } }; diff --git a/packages/mcp/src/tool/mcp-sse-transport.ts b/packages/mcp/src/tool/mcp-sse-transport.ts index 01bbfe1f93b3..931b93042b2d 100644 --- a/packages/mcp/src/tool/mcp-sse-transport.ts +++ b/packages/mcp/src/tool/mcp-sse-transport.ts @@ -106,8 +106,14 @@ export class SseMCPTransport implements MCPTransport { } 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); From 2c7bd9da7e8f7f74407cff2dfb07289af6a7eda8 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 16 Oct 2025 17:10:31 -0400 Subject: [PATCH 55/61] prettier + test fix --- .../mcp/src/tool/mcp-http-transport.test.ts | 55 ++++++++++++++----- packages/mcp/src/tool/mcp-http-transport.ts | 7 ++- packages/mcp/src/tool/mcp-sse-transport.ts | 7 ++- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/mcp/src/tool/mcp-http-transport.test.ts b/packages/mcp/src/tool/mcp-http-transport.test.ts index 462cc0b262a2..35fb076446d5 100644 --- a/packages/mcp/src/tool/mcp-http-transport.test.ts +++ b/packages/mcp/src/tool/mcp-http-transport.test.ts @@ -56,10 +56,22 @@ describe('HttpMCPTransport', () => { transport = new HttpMCPTransport({ url: 'http://localhost:4000/stream' }); const controller = new TestResponseController(); - server.urls['http://localhost:4000/stream'].response = { - type: 'controlled-stream', - controller, - headers: { 'content-type': 'text/event-stream' }, + // 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(); @@ -194,12 +206,15 @@ describe('HttpMCPTransport', () => { transport.onerror = e => resolve(e); }); - await transport.send({ - jsonrpc: '2.0' as const, - method: 'test', - id: 3, - params: {}, - }); + 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'); @@ -241,10 +256,22 @@ describe('HttpMCPTransport', () => { headers: customHeaders as unknown as Record, }); - server.urls['http://localhost:4000/mcp'].response = { - type: 'controlled-stream', - controller, - headers: { 'content-type': 'text/event-stream' }, + // 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(); diff --git a/packages/mcp/src/tool/mcp-http-transport.ts b/packages/mcp/src/tool/mcp-http-transport.ts index 5ddd574dea80..7599d487decf 100644 --- a/packages/mcp/src/tool/mcp-http-transport.ts +++ b/packages/mcp/src/tool/mcp-http-transport.ts @@ -172,12 +172,13 @@ export class HttpMCPTransport implements MCPTransport { 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'; + errorMessage += + '. This server does not support HTTP transport. Try using `sse` transport instead'; } - + const error = new MCPClientError({ message: errorMessage, }); diff --git a/packages/mcp/src/tool/mcp-sse-transport.ts b/packages/mcp/src/tool/mcp-sse-transport.ts index 931b93042b2d..fb437572186c 100644 --- a/packages/mcp/src/tool/mcp-sse-transport.ts +++ b/packages/mcp/src/tool/mcp-sse-transport.ts @@ -107,11 +107,12 @@ export class SseMCPTransport implements MCPTransport { 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'; + errorMessage += + '. This server does not support SSE transport. Try using `http` transport instead'; } - + const error = new MCPClientError({ message: errorMessage, }); From f516db5afa9f9d9a905f73d9121185c96e4496df Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Thu, 16 Oct 2025 17:17:23 -0400 Subject: [PATCH 56/61] fx --- packages/ai/tsconfig.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index bb6217385c49..7dedfdb70326 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -30,9 +30,6 @@ }, { "path": "../test-server" - }, - { - "path": "../mcp" } ] } From 8f21666278a50754938c5f6c2653e3a15c4ae8a5 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 22 Oct 2025 11:36:06 -0700 Subject: [PATCH 57/61] updated example to open auth window on client side --- .../app/api/mcp-with-auth/route.ts | 141 ++++++++++-------- .../next-openai/app/mcp-with-auth/page.tsx | 10 ++ 2 files changed, 87 insertions(+), 64 deletions(-) diff --git a/examples/next-openai/app/api/mcp-with-auth/route.ts b/examples/next-openai/app/api/mcp-with-auth/route.ts index f6463a75b4ab..e8b8cdf3eba8 100644 --- a/examples/next-openai/app/api/mcp-with-auth/route.ts +++ b/examples/next-openai/app/api/mcp-with-auth/route.ts @@ -1,5 +1,11 @@ import { openai } from '@ai-sdk/openai'; -import { convertToModelMessages, stepCountIs, streamText } from 'ai'; +import { + convertToModelMessages, + stepCountIs, + streamText, + createUIMessageStream, + createUIMessageStreamResponse, +} from 'ai'; import { experimental_createMCPClient, auth, @@ -8,7 +14,26 @@ import { type OAuthTokens, } from '@ai-sdk/mcp'; import { createServer } from 'node:http'; -import { exec } from 'node:child_process'; + +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< @@ -45,20 +70,7 @@ class InMemoryOAuthClientProvider { } 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(), - ); - } - }); + setPendingAuthorizationUrl(authorizationUrl.toString()); } async saveCodeVerifier(codeVerifier: string): Promise { @@ -180,21 +192,6 @@ function waitForAuthorizationCode(port: number): Promise { }); } -async function authorizeWithPkceOnce( - authProvider: InMemoryOAuthClientProvider, - 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, - }); - } -} - export async function POST(req: Request) { const body = await req.json(); const messages = body.messages; @@ -202,45 +199,61 @@ export async function POST(req: Request) { const callbackPort = 8090; try { - const authProvider = new InMemoryOAuthClientProvider( - serverUrl, - callbackPort, - ); - - // Perform auth if needed (will open browser window) - await authorizeWithPkceOnce(authProvider, serverUrl, () => - waitForAuthorizationCode(callbackPort), - ); - - const mcpClient = await experimental_createMCPClient({ - transport: { type: 'http', url: serverUrl, authProvider }, - }); + const stream = createUIMessageStream({ + originalMessages: messages, + execute: async ({ writer }) => { + const authProvider = new InMemoryOAuthClientProvider( + serverUrl, + callbackPort, + ); - 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), - onStepFinish: async ({ toolResults }) => { - if (toolResults.length > 0) { - console.log('Tool execution results:'); - toolResults.forEach(result => { - console.log( - ` - ${result.toolName}:`, - JSON.stringify(result, null, 2), - ); + // 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, }); } - }, - onFinish: async () => { - await mcpClient.close(); + + 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 result.toUIMessageStreamResponse(); + 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/mcp-with-auth/page.tsx b/examples/next-openai/app/mcp-with-auth/page.tsx index cc6f25b26505..67a9a18d3d4b 100644 --- a/examples/next-openai/app/mcp-with-auth/page.tsx +++ b/examples/next-openai/app/mcp-with-auth/page.tsx @@ -7,6 +7,16 @@ 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 ( From a0250c3cae83cfde7fe597f9ea11a16a9d9483a2 Mon Sep 17 00:00:00 2001 From: Aayush Kapoor Date: Wed, 22 Oct 2025 11:42:41 -0700 Subject: [PATCH 58/61] pretty --- examples/next-openai/app/api/mcp-with-auth/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/next-openai/app/api/mcp-with-auth/route.ts b/examples/next-openai/app/api/mcp-with-auth/route.ts index e8b8cdf3eba8..cf12018fbbd0 100644 --- a/examples/next-openai/app/api/mcp-with-auth/route.ts +++ b/examples/next-openai/app/api/mcp-with-auth/route.ts @@ -222,7 +222,8 @@ export async function POST(req: Request) { }); } - const authorizationCode = await waitForAuthorizationCode(callbackPort); + const authorizationCode = + await waitForAuthorizationCode(callbackPort); await auth(authProvider, { serverUrl: new URL(serverUrl), authorizationCode, @@ -240,7 +241,8 @@ export async function POST(req: Request) { model: openai('gpt-4o-mini'), tools, stopWhen: stepCountIs(10), - system: 'You are a helpful assistant with access to protected tools.', + system: + 'You are a helpful assistant with access to protected tools.', messages: convertToModelMessages(messages), }); From f07c9585d001baa696ebb079df0b6261bbaad6bf Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:19:54 -0700 Subject: [PATCH 59/61] docs(changeset): Add OAuth for MCP clients and refactor import path This change adds OAuth support for MCP clients and refactors the import path for Experimental_StdioMCPTransport. --- .changeset/fluffy-poems-live.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.changeset/fluffy-poems-live.md b/.changeset/fluffy-poems-live.md index 82126c0c8cd0..7edca925b10c 100644 --- a/.changeset/fluffy-poems-live.md +++ b/.changeset/fluffy-poems-live.md @@ -4,3 +4,15 @@ --- feat(ai): add OAuth for MCP clients + refactor to new package + +This change replaces + +```ts +import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio'; +``` + +with + +```ts +import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; +``` From 2258ca0b19f15670eb2e02c3ff263db727f52751 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:22:12 -0700 Subject: [PATCH 60/61] Refactor import paths for MCP client and transport Updated import paths for MCP client and transport. --- .changeset/fluffy-poems-live.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/fluffy-poems-live.md b/.changeset/fluffy-poems-live.md index 7edca925b10c..fb9c9df41f97 100644 --- a/.changeset/fluffy-poems-live.md +++ b/.changeset/fluffy-poems-live.md @@ -8,11 +8,13 @@ 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'; ``` From e65e0b0b3ee6fa150671485270676c077228e9dc Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Fri, 24 Oct 2025 09:29:52 -0700 Subject: [PATCH 61/61] remove remaining artifacts of mcp from `ai` package --- packages/ai/mcp-stdio.d.ts | 1 - packages/ai/package.json | 1 - packages/ai/tsconfig.json | 1 - 3 files changed, 3 deletions(-) delete mode 100644 packages/ai/mcp-stdio.d.ts 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 c97f3b7665f9..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": { 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": [