diff --git a/README.md b/README.md index 92f56786f..548fd2871 100644 --- a/README.md +++ b/README.md @@ -1175,7 +1175,7 @@ await server.connect(transport); ### Eliciting User Input -MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation: +MCP servers can request non-sensitive information from users through the form elicitation capability. This is useful for interactive workflows where the server needs user input or confirmation: ```typescript // Server-side: Restaurant booking tool that asks for alternatives @@ -1208,6 +1208,7 @@ server.registerTool( if (!available) { // Ask user if they want to try alternative dates const result = await server.server.elicitInput({ + mode: 'form', message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, requestedSchema: { type: 'object', @@ -1274,7 +1275,7 @@ server.registerTool( ); ``` -Client-side: Handle elicitation requests +On the client side, handle form elicitation requests: ```typescript // This is a placeholder - implement based on your UI framework @@ -1299,7 +1300,85 @@ client.setRequestHandler(ElicitRequestSchema, async request => { }); ``` -**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization. +When calling `server.elicitInput`, prefer to explicitly set `mode: 'form'` for new code. Omitting the mode continues to work for backwards compatibility and defaults to form elicitation. + +Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization: + +```typescript +const client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } +); +``` + +**Note**: Form elicitation **must** only be used to gather non-sensitive information. For sensitive information such as API keys or secrets, use URL elicitation instead. + +### Eliciting URL Actions + +MCP servers can prompt the user to perform a URL-based action through URL elicitation. This is useful for securely gathering sensitive information such as API keys or secrets, or for redirecting users to secure web-based flows. + +```typescript +// Server-side: Prompt the user to navigate to a URL +const result = await server.server.elicitInput({ + mode: 'url', + message: 'Please enter your API key', + elicitationId: '550e8400-e29b-41d4-a716-446655440000', + url: 'http://localhost:3000/api-key' +}); + +// Alternative, return an error from within a tool: +throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'This tool requires a payment confirmation. Open the link to confirm payment!', + url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, + elicitationId: '550e8400-e29b-41d4-a716-446655440000' + } +]); +``` + +On the client side, handle URL elicitation requests: + +```typescript +client.setRequestHandler(ElicitRequestSchema, async request => { + if (request.params.mode !== 'url') { + throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); + } + + // At a minimum, implement a UI that: + // - Display the full URL and server reason to prevent phishing + // - Explicitly ask the user for consent, with clear decline/cancel options + // - Open the URL in the system (not embedded) browser + // Optionally, listen for a `nofifications/elicitation/complete` message from the server +}); +``` + +Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization: + +```typescript +const client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } +); +``` ### Writing MCP Clients diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 70508ebaa..8534842ee 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { Client } from './index.js'; +import { Client, getSupportedElicitationModes } from './index.js'; import { z } from 'zod'; import { RequestSchema, @@ -596,6 +596,316 @@ test('should allow setRequestHandler for declared elicitation capability', () => }).toThrow('Client does not support sampling capability'); }); +test('should accept form-mode elicitation request when client advertises empty elicitation object (back-compat)', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Set up client handler for form-mode elicitation + client.setRequestHandler(ElicitRequestSchema, request => { + expect(request.params.mode).toBe('form'); + return { + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server should be able to send form-mode elicitation request + // This works because getSupportedElicitationModes defaults to form mode + // when neither form nor url are explicitly declared + const result = await server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + username: 'test-user', + confirmed: true + }); +}); + +test('should reject form-mode elicitation when client only supports URL mode', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const handler = vi.fn().mockResolvedValue({ + action: 'cancel' + }); + client.setRequestHandler(ElicitRequestSchema, handler); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + let resolveResponse: ((message: unknown) => void) | undefined; + const responsePromise = new Promise(resolve => { + resolveResponse = resolve; + }); + + serverTransport.onmessage = async message => { + if ('method' in message) { + if (message.method === 'initialize') { + if (!('id' in message) || message.id === undefined) { + throw new Error('Expected initialize request to include an id'); + } + const messageId = message.id; + await serverTransport.send({ + jsonrpc: '2.0', + id: messageId, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + } + }); + } else if (message.method === 'notifications/initialized') { + // ignore + } + } else { + resolveResponse?.(message); + } + }; + + await client.connect(clientTransport); + + // Server shouldn't send this, because the client capabilities + // only advertised URL mode. Test that it's rejected by the client: + const requestId = 1; + await serverTransport.send({ + jsonrpc: '2.0', + id: requestId, + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } + } + }); + + const response = (await responsePromise) as { id: number; error: { code: number; message: string } }; + + expect(response.id).toBe(requestId); + expect(response.error.code).toBe(ErrorCode.InvalidParams); + expect(response.error.message).toContain('Client does not support form-mode elicitation requests'); + expect(handler).not.toHaveBeenCalled(); + + await client.close(); +}); + +test('should reject URL-mode elicitation when client only supports form mode', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const handler = vi.fn().mockResolvedValue({ + action: 'cancel' + }); + client.setRequestHandler(ElicitRequestSchema, handler); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + let resolveResponse: ((message: unknown) => void) | undefined; + const responsePromise = new Promise(resolve => { + resolveResponse = resolve; + }); + + serverTransport.onmessage = async message => { + if ('method' in message) { + if (message.method === 'initialize') { + if (!('id' in message) || message.id === undefined) { + throw new Error('Expected initialize request to include an id'); + } + const messageId = message.id; + await serverTransport.send({ + jsonrpc: '2.0', + id: messageId, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + } + }); + } else if (message.method === 'notifications/initialized') { + // ignore + } + } else { + resolveResponse?.(message); + } + }; + + await client.connect(clientTransport); + + // Server shouldn't send this, because the client capabilities + // only advertised form mode. Test that it's rejected by the client: + const requestId = 2; + await serverTransport.send({ + jsonrpc: '2.0', + id: requestId, + method: 'elicitation/create', + params: { + mode: 'url', + message: 'Open the authorization page', + elicitationId: 'elicitation-123', + url: 'https://example.com/authorize' + } + }); + + const response = (await responsePromise) as { id: number; error: { code: number; message: string } }; + + expect(response.id).toBe(requestId); + expect(response.error.code).toBe(ErrorCode.InvalidParams); + expect(response.error.message).toContain('Client does not support URL-mode elicitation requests'); + expect(handler).not.toHaveBeenCalled(); + + await client.close(); +}); + +test('should apply defaults for form-mode elicitation when applyDefaults is enabled', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, request => { + expect(request.params.mode).toBe('form'); + return { + action: 'accept', + content: {} + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Please confirm your preferences', + requestedSchema: { + type: 'object', + properties: { + confirmed: { + type: 'boolean', + default: true + } + } + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + confirmed: true + }); + + await client.close(); +}); + /*** * Test: Type Checking * Test that custom request/notification/result schemas can be used with the Client class. @@ -1236,3 +1546,41 @@ describe('outputSchema validation', () => { ); }); }); + +describe('getSupportedElicitationModes', () => { + test('should support nothing when capabilities are undefined', () => { + const result = getSupportedElicitationModes(undefined); + expect(result.supportsFormMode).toBe(false); + expect(result.supportsUrlMode).toBe(false); + }); + + test('should default to form mode when capabilities are an empty object', () => { + const result = getSupportedElicitationModes({}); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(false); + }); + + test('should support form mode when form is explicitly declared', () => { + const result = getSupportedElicitationModes({ form: {} }); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(false); + }); + + test('should support url mode when url is explicitly declared', () => { + const result = getSupportedElicitationModes({ url: {} }); + expect(result.supportsFormMode).toBe(false); + expect(result.supportsUrlMode).toBe(true); + }); + + test('should support both modes when both are explicitly declared', () => { + const result = getSupportedElicitationModes({ form: {}, url: {} }); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(true); + }); + + test('should support form mode when form declares applyDefaults', () => { + const result = getSupportedElicitationModes({ form: { applyDefaults: true } }); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(false); + }); +}); diff --git a/src/client/index.ts b/src/client/index.ts index 5770f9d7f..f2864982a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -85,6 +85,34 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn } } +/** + * Determines which elicitation modes are supported based on declared client capabilities. + * + * According to the spec: + * - An empty elicitation capability object defaults to form mode support (backwards compatibility) + * - URL mode is only supported if explicitly declared + * + * @param capabilities - The client's elicitation capabilities + * @returns An object indicating which modes are supported + */ +export function getSupportedElicitationModes(capabilities: ClientCapabilities['elicitation']): { + supportsFormMode: boolean; + supportsUrlMode: boolean; +} { + if (!capabilities) { + return { supportsFormMode: false, supportsUrlMode: false }; + } + + const hasFormCapability = capabilities.form !== undefined; + const hasUrlCapability = capabilities.url !== undefined; + + // If neither form nor url are explicitly declared, form mode is supported (backwards compatibility) + const supportsFormMode = hasFormCapability || (!hasFormCapability && !hasUrlCapability); + const supportsUrlMode = hasUrlCapability; + + return { supportsFormMode, supportsUrlMode }; +} + export type ClientOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this client. @@ -210,6 +238,17 @@ export class Client< throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${validatedRequest.error.message}`); } + const { params } = validatedRequest.data; + const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); + + if (params.mode === 'form' && !supportsFormMode) { + throw new McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); + } + + if (params.mode === 'url' && !supportsUrlMode) { + throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); + } + const result = await Promise.resolve(handler(request, extra)); const validationResult = ElicitResultSchema.safeParse(result); @@ -218,17 +257,15 @@ export class Client< } const validatedResult = validationResult.data; - - if ( - this._capabilities.elicitation?.applyDefaults && - validatedResult.action === 'accept' && - validatedResult.content && - validatedRequest.data.params.requestedSchema - ) { - try { - applyElicitationDefaults(validatedRequest.data.params.requestedSchema, validatedResult.content); - } catch { - // gracefully ignore errors in default application + const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; + + if (params.mode === 'form' && validatedResult.action === 'accept' && validatedResult.content && requestedSchema) { + if (this._capabilities.elicitation?.form?.applyDefaults) { + try { + applyElicitationDefaults(requestedSchema, validatedResult.content); + } catch { + // gracefully ignore errors in default application + } } } diff --git a/src/examples/README.md b/src/examples/README.md index 1c30b8dde..3a8e3a211 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -7,11 +7,14 @@ This directory contains example implementations of MCP clients and servers using - [Client Implementations](#client-implementations) - [Streamable HTTP Client](#streamable-http-client) - [Backwards Compatible Client](#backwards-compatible-client) + - [URL Elicitation Example Client](#url-elicitation-example-client) - [Server Implementations](#server-implementations) - [Single Node Deployment](#single-node-deployment) - [Streamable HTTP Transport](#streamable-http-transport) - [Deprecated SSE Transport](#deprecated-sse-transport) - [Backwards Compatible Server](#streamable-http-backwards-compatible-server-with-sse) + - [Form Elicitation Example](#form-elicitation-example) + - [URL Elicitation Example](#url-elicitation-example) - [Multi-Node Deployment](#multi-node-deployment) - [Backwards Compatibility](#testing-streamable-http-backwards-compatibility-with-sse) @@ -51,6 +54,19 @@ A client that implements backwards compatibility according to the [MCP specifica npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts ``` +### URL Elicitation Example Client + +A client that demonstrates how to use URL elicitation to securely collect _sensitive_ user input or perform secure third-party flows. + +```bash +# First, run the server: +npx tsx src/examples/server/elicitationUrlExample.ts + +# Then, run the client: +npx tsx src/examples/client/elicitationUrlExample.ts + +``` + ## Server Implementations ### Single Node Deployment @@ -105,6 +121,32 @@ A server that demonstrates server notifications using Streamable HTTP. npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts ``` +##### Form Elicitation Example + +A server that demonstrates using form elicitation to collect _non-sensitive_ user input. + +```bash +npx tsx src/examples/server/elicitationFormExample.ts +``` + +##### URL Elicitation Example + +A comprehensive example demonstrating URL mode elicitation in a server protected by MCP authorization. This example shows: + +- SSE-driven URL elicitation of an API Key on session initialization: obtain sensitive user input at session init +- Tools that require direct user interaction via URL elicitation (for payment confirmation and for third-party OAuth tokens) +- Completion notifications for URL elicitation + +To run this example: + +```bash +# Start the server +npx tsx src/examples/server/elicitationUrlExample.ts + +# In a separate terminal, start the client +npx tsx src/examples/client/elicitationUrlExample.ts +``` + #### Deprecated SSE Transport A server that implements the deprecated HTTP+SSE transport (protocol version 2024-11-05). This example only used for testing backwards compatibility for clients. diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts new file mode 100644 index 000000000..b3a962b64 --- /dev/null +++ b/src/examples/client/elicitationUrlExample.ts @@ -0,0 +1,786 @@ +// Run with: npx tsx src/examples/client/elicitationUrlExample.ts +// +// This example demonstrates how to use URL elicitation to securely +// collect user input in a remote (HTTP) server. +// URL elicitation allows servers to prompt the end-user to open a URL in their browser +// to collect sensitive information. + +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + ElicitRequestSchema, + ElicitRequest, + ElicitResult, + ResourceLink, + ElicitRequestURLParams, + McpError, + ErrorCode, + UrlElicitationRequiredError, + ElicitationCompleteNotificationSchema +} from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; +import { OAuthClientMetadata } from '../../shared/auth.js'; +import { exec } from 'node:child_process'; +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; +import { UnauthorizedError } from '../../client/auth.js'; +import { createServer } from 'node:http'; + +// Set up OAuth (required for this example) +const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001) +const OAUTH_CALLBACK_URL = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`; +let oauthProvider: InMemoryOAuthClientProvider | undefined = undefined; + +console.log('Getting OAuth token...'); +const clientMetadata: OAuthClientMetadata = { + client_name: 'Elicitation MCP Client', + redirect_uris: [OAUTH_CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'mcp:tools' +}; +oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { + console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); + openBrowser(redirectUrl.toString()); +}); + +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); +let abortCommand = new AbortController(); + +// Global client and transport for interactive commands +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; +let sessionId: string | undefined = undefined; + +// Elicitation queue management +interface QueuedElicitation { + request: ElicitRequest; + resolve: (result: ElicitResult) => void; + reject: (error: Error) => void; +} + +let isProcessingCommand = false; +let isProcessingElicitations = false; +const elicitationQueue: QueuedElicitation[] = []; +let elicitationQueueSignal: (() => void) | null = null; +let elicitationsCompleteSignal: (() => void) | null = null; + +// Map to track pending URL elicitations waiting for completion notifications +const pendingURLElicitations = new Map< + string, + { + resolve: () => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } +>(); + +async function main(): Promise { + console.log('MCP Interactive Client'); + console.log('====================='); + + // Connect to server immediately with default settings + await connect(); + + // Start the elicitation loop in the background + elicitationLoop().catch(error => { + console.error('Unexpected error in elicitation loop:', error); + process.exit(1); + }); + + // Short delay allowing the server to send any SSE elicitations on connection + await new Promise(resolve => setTimeout(resolve, 200)); + + // Wait until we are done processing any initial elicitations + await waitForElicitationsToComplete(); + + // Print help and start the command loop + printHelp(); + await commandLoop(); +} + +async function waitForElicitationsToComplete(): Promise { + // Wait until the queue is empty and nothing is being processed + while (elicitationQueue.length > 0 || isProcessingElicitations) { + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' terminate-session - Terminate the current session'); + console.log(' reconnect - Reconnect to the server'); + console.log(' list-tools - List available tools'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' payment-confirm - Test URL elicitation via error response with payment-confirm tool'); + console.log(' third-party-auth - Test tool that requires third-party OAuth credentials'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} + +async function commandLoop(): Promise { + await new Promise(resolve => { + if (!isProcessingElicitations) { + resolve(); + } else { + elicitationsCompleteSignal = resolve; + } + }); + + readline.question('\n> ', { signal: abortCommand.signal }, async input => { + isProcessingCommand = true; + + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); + + try { + switch (command) { + case 'connect': + await connect(args[1]); + break; + + case 'disconnect': + await disconnect(); + break; + + case 'terminate-session': + await terminateSession(); + break; + + case 'reconnect': + await reconnect(); + break; + + case 'list-tools': + await listTools(); + break; + + case 'call-tool': + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + + case 'payment-confirm': + await callPaymentConfirmTool(); + break; + + case 'third-party-auth': + await callThirdPartyAuthTool(); + break; + + case 'help': + printHelp(); + break; + + case 'quit': + case 'exit': + await cleanup(); + return; + + default: + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } catch (error) { + console.error(`Error executing command: ${error}`); + } finally { + isProcessingCommand = false; + } + + // Process another command after we've processed the this one + await commandLoop(); + }); +} + +async function elicitationLoop(): Promise { + while (true) { + // Wait until we have elicitations to process + await new Promise(resolve => { + if (elicitationQueue.length > 0) { + resolve(); + } else { + elicitationQueueSignal = resolve; + } + }); + + isProcessingElicitations = true; + abortCommand.abort(); // Abort the command loop if it's running + + // Process all queued elicitations + while (elicitationQueue.length > 0) { + const queued = elicitationQueue.shift()!; + console.log(`📤 Processing queued elicitation (${elicitationQueue.length} remaining)`); + + try { + const result = await handleElicitationRequest(queued.request); + queued.resolve(result); + } catch (error) { + queued.reject(error instanceof Error ? error : new Error(String(error))); + } + } + + console.log('✅ All queued elicitations processed. Resuming command loop...\n'); + isProcessingElicitations = false; + + // Reset the abort controller for the next command loop + abortCommand = new AbortController(); + + // Resume the command loop + if (elicitationsCompleteSignal) { + elicitationsCompleteSignal(); + elicitationsCompleteSignal = null; + } + } +} + +async function openBrowser(url: string): Promise { + const command = `open "${url}"`; + + exec(command, error => { + if (error) { + console.error(`Failed to open browser: ${error.message}`); + console.log(`Please manually open: ${url}`); + } + }); +} + +/** + * Enqueues an elicitation request and returns the result. + * + * This function is used so that our CLI (which can only handle one input request at a time) + * can handle elicitation requests and the command loop. + * + * @param request - The elicitation request to be handled + * @returns The elicitation result + */ +async function elicitationRequestHandler(request: ElicitRequest): Promise { + // If we are processing a command, handle this elicitation immediately + if (isProcessingCommand) { + console.log('📋 Processing elicitation immediately (during command execution)'); + return await handleElicitationRequest(request); + } + + // Otherwise, queue the request to be handled by the elicitation loop + console.log(`📥 Queueing elicitation request (queue size will be: ${elicitationQueue.length + 1})`); + + return new Promise((resolve, reject) => { + elicitationQueue.push({ + request, + resolve, + reject + }); + + // Signal the elicitation loop that there's work to do + if (elicitationQueueSignal) { + elicitationQueueSignal(); + elicitationQueueSignal = null; + } + }); +} + +/** + * Handles an elicitation request. + * + * This function is used to handle the elicitation request and return the result. + * + * @param request - The elicitation request to be handled + * @returns The elicitation result + */ +async function handleElicitationRequest(request: ElicitRequest): Promise { + const mode = request.params.mode; + console.log('\n🔔 Elicitation Request Received:'); + console.log(`Mode: ${mode}`); + + if (mode === 'url') { + return { + action: await handleURLElicitation(request.params as ElicitRequestURLParams) + }; + } else { + // Should not happen because the client declares its capabilities to the server, + // but being defensive is a good practice: + throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${mode}`); + } +} + +/** + * Handles a URL elicitation by opening the URL in the browser. + * + * Note: This is a shared code for both request handlers and error handlers. + * As a result of sharing schema, there is no big forking of logic for the client. + * + * @param params - The URL elicitation request parameters + * @returns The action to take (accept, cancel, or decline) + */ +async function handleURLElicitation(params: ElicitRequestURLParams): Promise { + const url = params.url; + const elicitationId = params.elicitationId; + const message = params.message; + console.log(`🆔 Elicitation ID: ${elicitationId}`); // Print for illustration + + // Parse URL to show domain for security + let domain = 'unknown domain'; + try { + const parsedUrl = new URL(url); + domain = parsedUrl.hostname; + } catch { + console.error('Invalid URL provided by server'); + return 'decline'; + } + + // Example security warning to help prevent phishing attacks + console.log('\n⚠️ \x1b[33mSECURITY WARNING\x1b[0m ⚠️'); + console.log('\x1b[33mThe server is requesting you to open an external URL.\x1b[0m'); + console.log('\x1b[33mOnly proceed if you trust this server and understand why it needs this.\x1b[0m\n'); + console.log(`🌐 Target domain: \x1b[36m${domain}\x1b[0m`); + console.log(`🔗 Full URL: \x1b[36m${url}\x1b[0m`); + console.log(`\nℹ️ Server's reason:\n\n\x1b[36m${message}\x1b[0m\n`); + + // 1. Ask for user consent to open the URL + const consent = await new Promise(resolve => { + readline.question('\nDo you want to open this URL in your browser? (y/n): ', input => { + resolve(input.trim().toLowerCase()); + }); + }); + + // 2. If user did not consent, return appropriate result + if (consent === 'no' || consent === 'n') { + console.log('❌ URL navigation declined.'); + return 'decline'; + } else if (consent !== 'yes' && consent !== 'y') { + console.log('🚫 Invalid response. Cancelling elicitation.'); + return 'cancel'; + } + + // 3. Wait for completion notification in the background + const completionPromise = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + pendingURLElicitations.delete(elicitationId); + console.log(`\x1b[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\x1b[0m`); + reject(new Error('Elicitation completion timeout')); + }, + 5 * 60 * 1000 + ); // 5 minute timeout + + pendingURLElicitations.set(elicitationId, { + resolve: () => { + clearTimeout(timeout); + resolve(); + }, + reject, + timeout + }); + }); + + completionPromise.catch(error => { + console.error('Background completion wait failed:', error); + }); + + // 4. Open the URL in the browser + console.log(`\n🚀 Opening browser to: ${url}`); + await openBrowser(url); + + console.log('\n⏳ Waiting for you to complete the interaction in your browser...'); + console.log(' The server will send a notification once you complete the action.'); + + // 5. Acknowledge the user accepted the elicitation + return 'accept'; +} + +/** + * Example OAuth callback handler - in production, use a more robust approach + * for handling callbacks and storing tokens + */ +/** + * Starts a temporary HTTP server to receive the OAuth callback + */ +async function waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // Ignore favicon requests + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + console.log(`📥 Received callback: ${req.url}`); + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`✅ Authorization code received: ${code?.substring(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

This simulates successful authorization of the MCP client, which now has an access token for the MCP server.

+

This window will close automatically in 10 seconds.

+ + + + `); + + resolve(code); + setTimeout(() => server.close(), 15000); + } else if (error) { + console.log(`❌ Authorization error: ${error}`); + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + console.log(`❌ No authorization code or error in callback`); + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(OAUTH_CALLBACK_PORT, () => { + console.log(`OAuth callback server started on http://localhost:${OAUTH_CALLBACK_PORT}`); + }); + }); +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + // Create a new client with elicitation capability + client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + // Only URL elicitation is supported in this demo + // (see server/elicitationExample.ts for a demo of form mode elicitation) + url: {} + } + } + } + ); + if (!transport) { + // Only create a new transport if one doesn't exist + transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + sessionId: sessionId, + authProvider: oauthProvider, + requestInit: { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + } + } + }); + } + + // Set up elicitation request handler with proper validation + client.setRequestHandler(ElicitRequestSchema, elicitationRequestHandler); + + // Set up notification handler for elicitation completion + client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { + const { elicitationId } = notification.params; + const pending = pendingURLElicitations.get(elicitationId); + if (pending) { + clearTimeout(pending.timeout); + pendingURLElicitations.delete(elicitationId); + console.log(`\x1b[32m✅ Elicitation ${elicitationId} completed!\x1b[0m`); + pending.resolve(); + } else { + // Shouldn't happen - discard it! + console.warn(`Received completion notification for unknown elicitation: ${elicitationId}`); + } + }); + + try { + console.log(`Connecting to ${serverUrl}...`); + // Connect the client + await client.connect(transport); + sessionId = transport.sessionId; + console.log('Transport created with session ID:', sessionId); + console.log('Connected to MCP server'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('OAuth required - waiting for authorization...'); + const callbackPromise = waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('🔐 Authorization code received:', authCode); + console.log('🔌 Reconnecting with authenticated transport...'); + transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + sessionId: sessionId, + authProvider: oauthProvider, + requestInit: { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + } + } + }); + await client.connect(transport); + } else { + console.error('Failed to connect:', error); + client = null; + transport = null; + return; + } + } + // Set up error handler after connection is established so we don't double log errors + client.onerror = error => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + }; +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function terminateSession(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + console.log('Terminating session with ID:', transport.sessionId); + await transport.terminateSession(); + console.log('Session terminated successfully'); + + // Check if sessionId was cleared after termination + if (!transport.sessionId) { + console.log('Session ID has been cleared'); + sessionId = undefined; + + // Also close the transport and clear client objects + await transport.close(); + console.log('Transport closed after session termination'); + client = null; + transport = null; + } else { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } + } catch (error) { + console.error('Error terminating session:', error); + } +} + +async function reconnect(): Promise { + if (client) { + await disconnect(); + } + await connect(); +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + } +} + +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name, + arguments: args + } + }; + + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + const resourceLinks: ResourceLink[] = []; + + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else if (item.type === 'resource_link') { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` 📁 Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); + } else { + console.log(` [Unknown content type]:`, item); + } + }); + + // Offer to read resource links + if (resourceLinks.length > 0) { + console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); + } + } catch (error) { + if (error instanceof UrlElicitationRequiredError) { + console.log('\n🔔 Elicitation Required Error Received:'); + console.log(`Message: ${error.message}`); + for (const e of error.elicitations) { + await handleURLElicitation(e); // For the error handler, we discard the action result because we don't respond to an error response + } + return; + } + console.log(`Error calling tool ${name}: ${error}`); + } +} + +async function cleanup(): Promise { + if (client && transport) { + try { + // First try to terminate the session gracefully + if (transport.sessionId) { + try { + console.log('Terminating session before exit...'); + await transport.terminateSession(); + console.log('Session terminated successfully'); + } catch (error) { + console.error('Error terminating session:', error); + } + } + + // Then close the transport + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + process.stdin.setRawMode(false); + readline.close(); + console.log('\nGoodbye!'); + process.exit(0); +} + +async function callPaymentConfirmTool(): Promise { + console.log('Calling payment-confirm tool...'); + await callTool('payment-confirm', { cartId: 'cart_123' }); +} + +async function callThirdPartyAuthTool(): Promise { + console.log('Calling third-party-auth tool...'); + await callTool('third-party-auth', { param1: 'test' }); +} + +// Set up raw mode for keyboard input to capture Escape key +process.stdin.setRawMode(true); +process.stdin.on('data', async data => { + // Check for Escape key (27) + if (data.length === 1 && data[0] === 27) { + console.log('\nESC key pressed. Disconnecting from server...'); + + // Abort current operation and disconnect from server + if (client && transport) { + await disconnect(); + console.log('Disconnected. Press Enter to continue.'); + } else { + console.log('Not connected to server.'); + } + + // Re-display the prompt + process.stdout.write('> '); + } +}); + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index fc296bc6a..2cb458d3b 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -6,77 +6,16 @@ import { URL } from 'node:url'; import { exec } from 'node:child_process'; import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientMetadata } from '../../shared/auth.js'; import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '../../types.js'; -import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js'; +import { UnauthorizedError } from '../../client/auth.js'; +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; const CALLBACK_PORT = 8090; // Use different port than auth server (3001) const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; -/** - * In-memory OAuth client provider for demonstration purposes - * In production, you should persist tokens securely - */ -class InMemoryOAuthClientProvider implements OAuthClientProvider { - private _clientInformation?: OAuthClientInformationMixed; - private _tokens?: OAuthTokens; - private _codeVerifier?: string; - - constructor( - private readonly _redirectUrl: string | URL, - private readonly _clientMetadata: OAuthClientMetadata, - onRedirect?: (url: URL) => void - ) { - this._onRedirect = - onRedirect || - (url => { - console.log(`Redirect to: ${url.toString()}`); - }); - } - - private _onRedirect: (url: URL) => void; - - get redirectUrl(): string | URL { - return this._redirectUrl; - } - - get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; - } - - clientInformation(): OAuthClientInformationMixed | undefined { - return this._clientInformation; - } - - saveClientInformation(clientInformation: OAuthClientInformationMixed): void { - this._clientInformation = clientInformation; - } - - tokens(): OAuthTokens | undefined { - return this._tokens; - } - - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; - } - - redirectToAuthorization(authorizationUrl: URL): void { - this._onRedirect(authorizationUrl); - } - - saveCodeVerifier(codeVerifier: string): void { - this._codeVerifier = codeVerifier; - } - - codeVerifier(): string { - if (!this._codeVerifier) { - throw new Error('No code verifier saved'); - } - return this._codeVerifier; - } -} /** * Interactive MCP client with OAuth authentication * Demonstrates the complete OAuth flow with browser-based authorization diff --git a/src/examples/client/simpleOAuthClientProvider.ts b/src/examples/client/simpleOAuthClientProvider.ts new file mode 100644 index 000000000..d33aba161 --- /dev/null +++ b/src/examples/client/simpleOAuthClientProvider.ts @@ -0,0 +1,65 @@ +import { OAuthClientProvider } from '../../client/auth.js'; +import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; + +/** + * In-memory OAuth client provider for demonstration purposes + * In production, you should persist tokens securely + */ +export class InMemoryOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationMixed; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + onRedirect?: (url: URL) => void + ) { + this._onRedirect = + onRedirect || + (url => { + console.log(`Redirect to: ${url.toString()}`); + }); + } + + private _onRedirect: (url: URL) => void; + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationMixed): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(authorizationUrl: URL): void { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 353861397..6627e0b83 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -17,7 +17,9 @@ import { ElicitRequestSchema, ResourceLink, ReadResourceRequest, - ReadResourceResultSchema + ReadResourceResultSchema, + ErrorCode, + McpError } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { Ajv } from 'ajv'; @@ -60,7 +62,7 @@ function printHelp(): void { console.log(' call-tool [args] - Call a tool with optional JSON arguments'); console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); - console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)'); + console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); @@ -211,7 +213,7 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client with elicitation capability + // Create a new client with form elicitation capability client = new Client( { name: 'example-client', @@ -219,7 +221,9 @@ async function connect(url?: string): Promise { }, { capabilities: { - elicitation: {} + elicitation: { + form: {} + } } } ); @@ -229,7 +233,10 @@ async function connect(url?: string): Promise { // Set up elicitation request handler with proper validation client.setRequestHandler(ElicitRequestSchema, async request => { - console.log('\n🔔 Elicitation Request Received:'); + if (request.params.mode !== 'form') { + throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); + } + console.log('\n🔔 Elicitation (form) Request Received:'); console.log(`Message: ${request.params.message}`); console.log('Requested Schema:'); console.log(JSON.stringify(request.params.requestedSchema, null, 2)); @@ -610,7 +617,7 @@ async function callMultiGreetTool(name: string): Promise { } async function callCollectInfoTool(infoType: string): Promise { - console.log(`Testing elicitation with collect-user-info tool (${infoType})...`); + console.log(`Testing form elicitation with collect-user-info tool (${infoType})...`); await callTool('collect-user-info', { infoType }); } diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ba1d3a468..1abc040ce 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -57,6 +57,23 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params }); + // Simulate a user login + // Set a secure HTTP-only session cookie with authorization info + if (res.cookie) { + const authCookieData = { + userId: 'demo_user', + name: 'Demo User', + timestamp: Date.now() + }; + res.cookie('demo_session', JSON.stringify(authCookieData), { + httpOnly: true, + secure: false, // In production, this should be true + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000, // 24 hours - for demo purposes + path: '/' // Available to all routes + }); + } + if (!client.redirect_uris.includes(params.redirectUri)) { throw new InvalidRequestError('Unregistered redirect_uri'); } diff --git a/src/examples/server/elicitationExample.ts b/src/examples/server/elicitationFormExample.ts similarity index 95% rename from src/examples/server/elicitationExample.ts rename to src/examples/server/elicitationFormExample.ts index 6607e8cca..2e6286b71 100644 --- a/src/examples/server/elicitationExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -1,9 +1,11 @@ -// Run with: npx tsx src/examples/server/elicitationExample.ts +// Run with: npx tsx src/examples/server/elicitationFormExample.ts // -// This example demonstrates how to use elicitation to collect structured user input +// This example demonstrates how to use form elicitation to collect structured user input // with JSON Schema validation via a local HTTP server with SSE streaming. -// Elicitation allows servers to request user input through the client interface +// Form elicitation allows servers to request *non-sensitive* user input through the client // with schema-based validation. +// Note: See also elicitationUrlExample.ts for an example of using URL elicitation +// to collect *sensitive* user input via a browser. import { randomUUID } from 'node:crypto'; import cors from 'cors'; @@ -16,7 +18,7 @@ import { isInitializeRequest } from '../../types.js'; // The validator supports format validation (email, date, etc.) if ajv-formats is installed const mcpServer = new McpServer( { - name: 'elicitation-example-server', + name: 'form-elicitation-example-server', version: '1.0.0' }, { @@ -36,8 +38,9 @@ mcpServer.registerTool( }, async () => { try { - // Request user information through elicitation + // Request user information through form elicitation const result = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Please provide your registration information:', requestedSchema: { type: 'object', @@ -123,7 +126,7 @@ mcpServer.registerTool( ); /** - * Example 2: Multi-step workflow with multiple elicitation requests + * Example 2: Multi-step workflow with multiple form elicitation requests * Demonstrates how to collect information in multiple steps */ mcpServer.registerTool( @@ -136,6 +139,7 @@ mcpServer.registerTool( try { // Step 1: Collect basic event information const basicInfo = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Step 1: Enter basic event information', requestedSchema: { type: 'object', @@ -164,6 +168,7 @@ mcpServer.registerTool( // Step 2: Collect date and time const dateTime = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Step 2: Enter date and time', requestedSchema: { type: 'object', @@ -238,6 +243,7 @@ mcpServer.registerTool( async () => { try { const result = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Please provide your shipping address:', requestedSchema: { type: 'object', @@ -441,7 +447,7 @@ async function main() { console.error('Failed to start server:', error); process.exit(1); } - console.log(`Elicitation example server is running on http://localhost:${PORT}/mcp`); + console.log(`Form elicitation example server is running on http://localhost:${PORT}/mcp`); console.log('Available tools:'); console.log(' - register_user: Collect user registration information'); console.log(' - create_event: Multi-step event creation'); diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts new file mode 100644 index 000000000..089c6f887 --- /dev/null +++ b/src/examples/server/elicitationUrlExample.ts @@ -0,0 +1,770 @@ +// Run with: npx tsx src/examples/server/elicitationUrlExample.ts +// +// This example demonstrates how to use URL elicitation to securely collect +// *sensitive* user input in a remote (HTTP) server. +// URL elicitation allows servers to prompt the end-user to open a URL in their browser +// to collect sensitive information. +// Note: See also elicitationFormExample.ts for an example of using form (not URL) elicitation +// to collect *non-sensitive* user input with a structured schema. + +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; +import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; +import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { OAuthMetadata } from '../../shared/auth.js'; +import { checkResourceAllowed } from '../../shared/auth-utils.js'; + +import cors from 'cors'; + +// Create an MCP server with implementation details +const getServer = () => { + const mcpServer = new McpServer( + { + name: 'url-elicitation-http-server', + version: '1.0.0' + }, + { + capabilities: { logging: {} } + } + ); + + mcpServer.registerTool( + 'payment-confirm', + { + description: 'A tool that confirms a payment directly with a user', + inputSchema: { + cartId: z.string().describe('The ID of the cart to confirm') + } + }, + async ({ cartId }, extra): Promise => { + /* + In a real world scenario, there would be some logic here to check if the user has the provided cartId. + For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to confirm payment) + */ + const sessionId = extra.sessionId; + if (!sessionId) { + throw new Error('Expected a Session ID'); + } + + // Create and track the elicitation + const elicitationId = generateTrackedElicitation(sessionId, elicitationId => + mcpServer.server.createElicitationCompletionNotifier(elicitationId) + ); + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'This tool requires a payment confirmation. Open the link to confirm payment!', + url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, + elicitationId + } + ]); + } + ); + + mcpServer.registerTool( + 'third-party-auth', + { + description: 'A demo tool that requires third-party OAuth credentials', + inputSchema: { + param1: z.string().describe('First parameter') + } + }, + async (_, extra): Promise => { + /* + In a real world scenario, there would be some logic here to check if we already have a valid access token for the user. + Auth info (with a subject or `sub` claim) can be typically be found in `extra.authInfo`. + If we do, we can just return the result of the tool call. + If we don't, we can throw an ElicitationRequiredError to request the user to authenticate. + For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to authenticate). + */ + const sessionId = extra.sessionId; + if (!sessionId) { + throw new Error('Expected a Session ID'); + } + + // Create and track the elicitation + const elicitationId = generateTrackedElicitation(sessionId, elicitationId => + mcpServer.server.createElicitationCompletionNotifier(elicitationId) + ); + + // Simulate OAuth callback and token exchange after 5 seconds + // In a real app, this would be called from your OAuth callback handler + setTimeout(() => { + console.log(`Simulating OAuth token received for elicitation ${elicitationId}`); + completeURLElicitation(elicitationId); + }, 5000); + + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'This tool requires access to your example.com account. Open the link to authenticate!', + url: 'https://www.example.com/oauth/authorize', + elicitationId + } + ]); + } + ); + + return mcpServer; +}; + +/** + * Elicitation Completion Tracking Utilities + **/ + +interface ElicitationMetadata { + status: 'pending' | 'complete'; + completedPromise: Promise; + completeResolver: () => void; + createdAt: Date; + sessionId: string; + completionNotifier?: () => Promise; +} + +const elicitationsMap = new Map(); + +// Clean up old elicitations after 1 hour to prevent memory leaks +const ELICITATION_TTL_MS = 60 * 60 * 1000; // 1 hour +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + +function cleanupOldElicitations() { + const now = new Date(); + for (const [id, metadata] of elicitationsMap.entries()) { + if (now.getTime() - metadata.createdAt.getTime() > ELICITATION_TTL_MS) { + elicitationsMap.delete(id); + console.log(`Cleaned up expired elicitation: ${id}`); + } + } +} + +setInterval(cleanupOldElicitations, CLEANUP_INTERVAL_MS); + +/** + * Elicitation IDs must be unique strings within the MCP session + * UUIDs are used in this example for simplicity + */ +function generateElicitationId(): string { + return randomUUID(); +} + +/** + * Helper function to create and track a new elicitation. + */ +function generateTrackedElicitation(sessionId: string, createCompletionNotifier?: ElicitationCompletionNotifierFactory): string { + const elicitationId = generateElicitationId(); + + // Create a Promise and its resolver for tracking completion + let completeResolver: () => void; + const completedPromise = new Promise(resolve => { + completeResolver = resolve; + }); + + const completionNotifier = createCompletionNotifier ? createCompletionNotifier(elicitationId) : undefined; + + // Store the elicitation in our map + elicitationsMap.set(elicitationId, { + status: 'pending', + completedPromise, + completeResolver: completeResolver!, + createdAt: new Date(), + sessionId, + completionNotifier + }); + + return elicitationId; +} + +/** + * Helper function to complete an elicitation. + */ +function completeURLElicitation(elicitationId: string) { + const elicitation = elicitationsMap.get(elicitationId); + if (!elicitation) { + console.warn(`Attempted to complete unknown elicitation: ${elicitationId}`); + return; + } + + if (elicitation.status === 'complete') { + console.warn(`Elicitation already complete: ${elicitationId}`); + return; + } + + // Update metadata + elicitation.status = 'complete'; + + // Send completion notification to the client + if (elicitation.completionNotifier) { + console.log(`Sending notifications/elicitation/complete notification for elicitation ${elicitationId}`); + + elicitation.completionNotifier().catch(error => { + console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error); + }); + } + + // Resolve the promise to unblock any waiting code + elicitation.completeResolver(); +} + +const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; + +const app = express(); +app.use(express.json()); + +// Allow CORS all domains, expose the Mcp-Session-Id header +app.use( + cors({ + origin: '*', // Allow all origins + exposedHeaders: ['Mcp-Session-Id'], + credentials: true // Allow cookies to be sent cross-origin + }) +); + +// Set up OAuth (required for this example) +let authMiddleware = null; +// Create auth middleware for MCP endpoints +const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); +const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); + +const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true }); + +const tokenVerifier = { + verifyAccessToken: async (token: string) => { + const endpoint = oauthMetadata.introspection_endpoint; + + if (!endpoint) { + throw new Error('No token verification endpoint available in metadata'); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + token: token + }).toString() + }); + + if (!response.ok) { + throw new Error(`Invalid or expired token: ${await response.text()}`); + } + + const data = await response.json(); + + if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); + } + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); + } + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp + }; + } +}; +// Add metadata routes to the main MCP server +app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'MCP Demo Server' + }) +); + +authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); + +/** + * API Key Form Handling + * + * Many servers today require an API key to operate, but there's no scalable way to do this dynamically for remote servers within MCP protocol. + * URL-mode elicitation enables the server to host a simple form and get the secret data securely from the user without involving the LLM or client. + **/ + +async function sendApiKeyElicitation( + sessionId: string, + sender: ElicitationSender, + createCompletionNotifier: ElicitationCompletionNotifierFactory +) { + if (!sessionId) { + console.error('No session ID provided'); + throw new Error('Expected a Session ID to track elicitation'); + } + + console.log('🔑 URL elicitation demo: Requesting API key from client...'); + const elicitationId = generateTrackedElicitation(sessionId, createCompletionNotifier); + try { + const result = await sender({ + mode: 'url', + message: 'Please provide your API key to authenticate with this server', + // Host the form on the same server. In a real app, you might coordinate passing these state variables differently. + url: `http://localhost:${MCP_PORT}/api-key-form?session=${sessionId}&elicitation=${elicitationId}`, + elicitationId + }); + + switch (result.action) { + case 'accept': + console.log('🔑 URL elicitation demo: Client accepted the API key elicitation (now pending form submission)'); + // Wait for the API key to be submitted via the form + // The form submission will complete the elicitation + break; + default: + console.log('🔑 URL elicitation demo: Client declined to provide an API key'); + // In a real app, this might close the connection, but for the demo, we'll continue + break; + } + } catch (error) { + console.error('Error during API key elicitation:', error); + } +} + +// API Key Form endpoint - serves a simple HTML form +app.get('/api-key-form', (req: Request, res: Response) => { + const mcpSessionId = req.query.session as string | undefined; + const elicitationId = req.query.elicitation as string | undefined; + if (!mcpSessionId || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie + // In production, this is often handled by some user auth middleware to ensure the user has a valid session + // This session is different from the MCP session. + // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + // Serve a simple HTML form + res.send(` + + + + Submit Your API Key + + + +

API Key Required

+
✓ Logged in as: ${userSession.name}
+
+ + + + +
+
This is a demo showing how a server can securely elicit sensitive data from a user using a URL.
+ + + `); +}); + +// Handle API key form submission +app.post('/api-key-form', express.urlencoded(), (req: Request, res: Response) => { + const { session: sessionId, apiKey, elicitation: elicitationId } = req.body; + if (!sessionId || !apiKey || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie here too + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + // A real app might store this API key to be used later for the user. + console.log(`🔑 Received API key \x1b[32m${apiKey}\x1b[0m for session ${sessionId}`); + + // If we have an elicitationId, complete the elicitation + completeURLElicitation(elicitationId); + + // Send a success response + res.send(` + + + + Success + + + +
+

Success ✓

+

API key received.

+
+

You can close this window and return to your MCP client.

+ + + `); +}); + +// Helper to get the user session from the demo_session cookie +function getUserSessionCookie(cookieHeader?: string): { userId: string; name: string; timestamp: number } | null { + if (!cookieHeader) return null; + + const cookies = cookieHeader.split(';'); + for (const cookie of cookies) { + const [name, value] = cookie.trim().split('='); + if (name === 'demo_session' && value) { + try { + return JSON.parse(decodeURIComponent(value)); + } catch (error) { + console.error('Failed to parse demo_session cookie:', error); + return null; + } + } + } + return null; +} + +/** + * Payment Confirmation Form Handling + * + * This demonstrates how a server can use URL-mode elicitation to get user confirmation + * for sensitive operations like payment processing. + **/ + +// Payment Confirmation Form endpoint - serves a simple HTML form +app.get('/confirm-payment', (req: Request, res: Response) => { + const mcpSessionId = req.query.session as string | undefined; + const elicitationId = req.query.elicitation as string | undefined; + const cartId = req.query.cartId as string | undefined; + if (!mcpSessionId || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie + // In production, this is often handled by some user auth middleware to ensure the user has a valid session + // This session is different from the MCP session. + // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + // Serve a simple HTML form + res.send(` + + + + Confirm Payment + + + +

Confirm Payment

+
✓ Logged in as: ${userSession.name}
+ ${cartId ? `
Cart ID: ${cartId}
` : ''} +
+ ⚠️ Please review your order before confirming. +
+
+ + + ${cartId ? `` : ''} + + +
+
This is a demo showing how a server can securely get user confirmation for sensitive operations using URL-mode elicitation.
+ + + `); +}); + +// Handle Payment Confirmation form submission +app.post('/confirm-payment', express.urlencoded(), (req: Request, res: Response) => { + const { session: sessionId, elicitation: elicitationId, cartId, action } = req.body; + if (!sessionId || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie here too + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + if (action === 'confirm') { + // A real app would process the payment here + console.log(`💳 Payment confirmed for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); + + // Complete the elicitation + completeURLElicitation(elicitationId); + + // Send a success response + res.send(` + + + + Payment Confirmed + + + +
+

Payment Confirmed ✓

+

Your payment has been successfully processed.

+ ${cartId ? `

Cart ID: ${cartId}

` : ''} +
+

You can close this window and return to your MCP client.

+ + + `); + } else if (action === 'cancel') { + console.log(`💳 Payment cancelled for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); + + // The client will still receive a notifications/elicitation/complete notification, + // which indicates that the out-of-band interaction is complete (but not necessarily successful) + completeURLElicitation(elicitationId); + + res.send(` + + + + Payment Cancelled + + + +
+

Payment Cancelled

+

Your payment has been cancelled.

+
+

You can close this window and return to your MCP client.

+ + + `); + } else { + res.status(400).send('

Error

Invalid action

'); + } +}); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +// Interface for a function that can send an elicitation request +type ElicitationSender = (params: ElicitRequestURLParams) => Promise; +type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise; + +// Track sessions that need an elicitation request to be sent +interface SessionElicitationInfo { + elicitationSender: ElicitationSender; + createCompletionNotifier: ElicitationCompletionNotifierFactory; +} +const sessionsNeedingElicitation: { [sessionId: string]: SessionElicitationInfo } = {}; + +// MCP POST endpoint +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + console.debug(`Received MCP POST for session: ${sessionId || 'unknown'}`); + + try { + let transport: StreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const server = getServer(); + // New initialization request + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + sessionsNeedingElicitation[sessionId] = { + elicitationSender: params => server.server.elicitInput(params), + createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId) + }; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + delete sessionsNeedingElicitation[sid]; + } + }; + + // Connect the transport to the MCP server BEFORE handling the request + // so responses can flow back through the same transport + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + id: null + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } +}; + +// Set up routes with auth middleware +app.post('/mcp', authMiddleware, mcpPostHandler); + +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) +const mcpGetHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + + if (sessionsNeedingElicitation[sessionId]) { + const { elicitationSender, createCompletionNotifier } = sessionsNeedingElicitation[sessionId]; + + // Send an elicitation request to the client in the background + sendApiKeyElicitation(sessionId, elicitationSender, createCompletionNotifier) + .then(() => { + // Only delete on successful send for this demo + delete sessionsNeedingElicitation[sessionId]; + console.log(`🔑 URL elicitation demo: Finished sending API key elicitation request for session ${sessionId}`); + }) + .catch(error => { + console.error('Error sending API key elicitation:', error); + // Keep in map to potentially retry on next reconnect + }); + } +}; + +// Set up GET route with conditional auth middleware +app.get('/mcp', authMiddleware, mcpGetHandler); + +// Handle DELETE requests for session termination (according to MCP spec) +const mcpDeleteHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}; + +// Set up DELETE route with auth middleware +app.delete('/mcp', authMiddleware, mcpDeleteHandler); + +app.listen(MCP_PORT, error => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + delete sessionsNeedingElicitation[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); +}); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c970bdd1..1765414fa 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -111,11 +111,11 @@ const getServer = () => { }; } ); - // Register a tool that demonstrates elicitation (user input collection) + // Register a tool that demonstrates form elicitation (user input collection with a schema) // This creates a closure that captures the server instance server.tool( 'collect-user-info', - 'A tool that collects user information through elicitation', + 'A tool that collects user information through form elicitation', { infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') }, @@ -216,6 +216,7 @@ const getServer = () => { try { // Use the underlying server instance to elicit input from the client const result = await server.server.elicitInput({ + mode: 'form', message, requestedSchema }); diff --git a/src/server/elicitation.test.ts b/src/server/elicitation.test.ts index dad56d133..ce9e55be2 100644 --- a/src/server/elicitation.test.ts +++ b/src/server/elicitation.test.ts @@ -9,7 +9,7 @@ import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; -import { ElicitRequestParams, ElicitRequestSchema } from '../types.js'; +import { ElicitRequestFormParams, ElicitRequestSchema } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js'; import { Server } from './index.js'; @@ -70,6 +70,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', @@ -93,6 +94,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'What is your age?', requestedSchema: { type: 'object', @@ -116,6 +118,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Do you agree?', requestedSchema: { type: 'object', @@ -149,7 +152,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: userData })); - const result = await server.elicitInput({ + const formRequestParams: ElicitRequestFormParams = { + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -166,7 +170,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }, required: ['name', 'email', 'age', 'street', 'city', 'zipCode'] } - }); + }; + const result = await server.elicitInput(formRequestParams); expect(result).toEqual({ action: 'accept', @@ -185,6 +190,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -209,6 +215,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -250,6 +257,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'What is your age?', requestedSchema: { type: 'object', @@ -268,19 +276,20 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { zipCode: 'ABC123' } // Doesn't match pattern })); - await expect( - server.elicitInput({ - message: 'Enter a 5-digit zip code', - requestedSchema: { - type: 'object', - properties: { - // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator - zipCode: { type: 'string', pattern: '^[0-9]{5}$' } - }, - required: ['zipCode'] - } - }) - ).rejects.toThrow(/does not match requested schema/); + const formRequestParams: ElicitRequestFormParams = { + mode: 'form', + message: 'Enter a 5-digit zip code', + requestedSchema: { + type: 'object', + properties: { + // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator + zipCode: { type: 'string', pattern: '^[0-9]{5}$' } + }, + required: ['zipCode'] + } + }; + + await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/); }); test(`${validatorName}: should allow decline action without validation`, async () => { @@ -289,6 +298,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -310,6 +320,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -340,6 +351,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); const nameResult = await server.elicitInput({ + mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', @@ -385,6 +397,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Enter your name', requestedSchema: { type: 'object', @@ -409,6 +422,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Enter your name', requestedSchema: { type: 'object', @@ -433,6 +447,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Enter your email', requestedSchema: { type: 'object', @@ -463,13 +478,15 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo { capabilities: { elicitation: { - applyDefaults: true + form: { + applyDefaults: true + } } } } ); - const testSchemaProperties: ElicitRequestParams['requestedSchema'] = { + const testSchemaProperties: ElicitRequestFormParams['requestedSchema'] = { type: 'object', properties: { subscribe: { type: 'boolean', default: true }, @@ -542,7 +559,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Client returns no values; SDK should apply defaults automatically (and validate) client.setRequestHandler(ElicitRequestSchema, request => { - expect(request.params.requestedSchema).toEqual(testSchemaProperties); + expect(request.params.mode).toEqual('form'); + expect((request.params as ElicitRequestFormParams).requestedSchema).toEqual(testSchemaProperties); return { action: 'accept', content: {} @@ -553,6 +571,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await server.elicitInput({ + mode: 'form', message: 'Provide your preferences', requestedSchema: testSchemaProperties }); @@ -582,6 +601,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'Enter your email', requestedSchema: { type: 'object', @@ -608,6 +628,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -643,6 +664,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -720,6 +742,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -759,6 +782,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -803,6 +827,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -900,6 +925,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -934,6 +960,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', diff --git a/src/server/index.test.ts b/src/server/index.test.ts index a660c3085..16a1d94bd 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -6,6 +6,7 @@ import type { Transport } from '../shared/transport.js'; import { CreateMessageRequestSchema, ElicitRequestSchema, + ElicitationCompleteNotificationSchema, ErrorCode, LATEST_PROTOCOL_VERSION, ListPromptsRequestSchema, @@ -19,6 +20,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '../types.js'; import { Server } from './index.js'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; test('should accept latest protocol version', async () => { let sendPromiseResolve: (value: unknown) => void; @@ -304,11 +306,13 @@ test('should respect client elicitation capabilities', async () => { await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - expect(server.getClientCapabilities()).toEqual({ elicitation: {} }); + // After schema parsing, empty elicitation object should have form capability injected + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); // This should work because elicitation is supported by the client await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your username', requestedSchema: { type: 'object', @@ -345,6 +349,617 @@ test('should respect client elicitation capabilities', async () => { ).rejects.toThrow(/^Client does not support/); }); +test('should use elicitInput with mode: "form" by default for backwards compatibility', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, params => ({ + action: 'accept', + content: { + username: params.params.message.includes('username') ? 'test-user' : undefined, + confirmed: true + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // After schema parsing, empty elicitation object should have form capability injected + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).rejects.toThrow(/^Client does not support/); +}); + +test('should throw when elicitInput is called without client form capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} // No form mode capability + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'cancel' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } + }) + ).rejects.toThrow('Client does not support form elicitation.'); +}); + +test('should throw when elicitInput is called without client URL capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} // No URL mode capability + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'cancel' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'url', + message: 'Open the authorization URL', + elicitationId: 'elicitation-001', + url: 'https://example.com/auth' + }) + ).rejects.toThrow('Client does not support url elicitation.'); +}); + +test('should include form mode when sending elicitation form requests', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const receivedModes: string[] = []; + client.setRequestHandler(ElicitRequestSchema, request => { + receivedModes.push(request.params.mode); + return { + action: 'accept', + content: { + confirmation: true + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Confirm action', + requestedSchema: { + type: 'object', + properties: { + confirmation: { + type: 'boolean' + } + }, + required: ['confirmation'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + confirmation: true + } + }); + + expect(receivedModes).toEqual(['form']); +}); + +test('should include url mode when sending elicitation URL requests', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const receivedModes: string[] = []; + const receivedIds: string[] = []; + client.setRequestHandler(ElicitRequestSchema, request => { + receivedModes.push(request.params.mode); + if (request.params.mode === 'url') { + receivedIds.push(request.params.elicitationId); + } + return { + action: 'decline' + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'url', + message: 'Complete verification', + elicitationId: 'elicitation-xyz', + url: 'https://example.com/verify' + }) + ).resolves.toEqual({ + action: 'decline' + }); + + expect(receivedModes).toEqual(['url']); + expect(receivedIds).toEqual(['elicitation-xyz']); +}); + +test('should reject elicitInput when client response violates requested schema', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + + // Bad response: missing required field `username` + content: {} + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + }, + required: ['username'] + } + }) + ).rejects.toThrow('Elicitation response content does not match requested schema'); +}); + +test('should wrap unexpected validator errors during elicitInput', async () => { + class ThrowingValidator implements jsonSchemaValidator { + getValidator(_schema: JsonSchemaType): JsonSchemaValidator { + throw new Error('boom - validator exploded'); + } + } + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {}, + jsonSchemaValidator: new ThrowingValidator() + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + username: 'ignored' + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Provide any data', + requestedSchema: { + type: 'object', + properties: {}, + required: [] + } + }) + ).rejects.toThrow('MCP error -32603: Error validating elicitation response: boom - validator exploded'); +}); + +test('should forward notification options when using elicitation completion notifier', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + client.setNotificationHandler(ElicitationCompleteNotificationSchema, () => {}); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const notificationSpy = vi.spyOn(server, 'notification'); + + const notifier = server.createElicitationCompletionNotifier('elicitation-789', { relatedRequestId: 42 }); + await notifier(); + + expect(notificationSpy).toHaveBeenCalledWith( + { + method: 'notifications/elicitation/complete', + params: { + elicitationId: 'elicitation-789' + } + }, + expect.objectContaining({ relatedRequestId: 42 }) + ); +}); + +test('should create notifier that emits elicitation completion notification', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const receivedIds: string[] = []; + client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { + receivedIds.push(notification.params.elicitationId); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const notifier = server.createElicitationCompletionNotifier('elicitation-123'); + await notifier(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(receivedIds).toEqual(['elicitation-123']); +}); + +test('should throw when creating notifier if client lacks URL elicitation support', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => server.createElicitationCompletionNotifier('elicitation-123')).toThrow( + 'Client does not support URL elicitation (required for notifications/elicitation/complete)' + ); +}); + +test('should apply back-compat form capability injection when client sends empty elicitation object', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify that the schema preprocessing injected form capability + const clientCapabilities = server.getClientCapabilities(); + expect(clientCapabilities).toBeDefined(); + expect(clientCapabilities?.elicitation).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toEqual({}); + expect(clientCapabilities?.elicitation?.url).toBeUndefined(); +}); + +test('should preserve form capability configuration when client enables applyDefaults', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify that the schema preprocessing preserved the form capability configuration + const clientCapabilities = server.getClientCapabilities(); + expect(clientCapabilities).toBeDefined(); + expect(clientCapabilities?.elicitation).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toEqual({ applyDefaults: true }); + expect(clientCapabilities?.elicitation?.url).toBeUndefined(); +}); + test('should validate elicitation response against requested schema', async () => { const server = new Server( { @@ -391,6 +1006,7 @@ test('should validate elicitation response against requested schema', async () = // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -467,6 +1083,7 @@ test('should reject elicitation response with invalid data', async () => { // Test with invalid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -545,6 +1162,7 @@ test('should allow elicitation reject and cancel without validation', async () = // Test reject - should not validate await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your name', requestedSchema: schema }) @@ -555,6 +1173,7 @@ test('should allow elicitation reject and cancel without validation', async () = // Test cancel - should not validate await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your name', requestedSchema: schema }) diff --git a/src/server/index.ts b/src/server/index.ts index 47b5f538f..60751cc38 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,9 +1,10 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; import { type ClientCapabilities, type CreateMessageRequest, CreateMessageResultSchema, - type ElicitRequest, + type ElicitRequestFormParams, + type ElicitRequestURLParams, type ElicitResult, ElicitResultSchema, EmptyResultSchema, @@ -34,6 +35,8 @@ import { import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; +type LegacyElicitRequestFormParams = Omit; + export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. @@ -225,6 +228,12 @@ export class Server< } break; + case 'notifications/elicitation/complete': + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error(`Client does not support URL elicitation (required for ${method})`); + } + break; + case 'notifications/cancelled': // Cancellation notifications are always allowed break; @@ -320,33 +329,100 @@ export class Server< return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } - async elicitInput(params: ElicitRequest['params'], options?: RequestOptions): Promise { - const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options); - - // Validate the response content against the requested schema if action is "accept" - if (result.action === 'accept' && result.content && params.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType); - const validationResult = validator(result.content); + /** + * Creates an elicitation request for the given parameters. + * @param params The parameters for the form elicitation request (explicit mode: 'form'). + * @param options Optional request options. + * @returns The result of the elicitation request. + */ + async elicitInput(params: ElicitRequestFormParams, options?: RequestOptions): Promise; + /** + * Creates an elicitation request for the given parameters. + * @param params The parameters for the URL elicitation request (with url and elicitationId). + * @param options Optional request options. + * @returns The result of the elicitation request. + */ + async elicitInput(params: ElicitRequestURLParams, options?: RequestOptions): Promise; + /** + * Creates an elicitation request for the given parameters. + * @param params The parameters for the form elicitation request (legacy signature without mode). + * @param options Optional request options. + * @returns The result of the elicitation request. + */ + async elicitInput(params: LegacyElicitRequestFormParams, options?: RequestOptions): Promise; + async elicitInput( + params: LegacyElicitRequestFormParams | ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions + ): Promise { + const mode = 'mode' in params ? params.mode : 'form'; + + switch (mode) { + case 'url': { + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error('Client does not support url elicitation.'); + } - if (!validationResult.valid) { - throw new McpError( - ErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` - ); + const urlParams = params as ElicitRequestURLParams; + return this.request({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + } + case 'form': { + if (!this._clientCapabilities?.elicitation?.form) { + throw new Error('Client does not support form elicitation.'); } - } catch (error) { - if (error instanceof McpError) { - throw error; + const formParams: ElicitRequestFormParams = + 'mode' in params ? (params as ElicitRequestFormParams) : { ...(params as LegacyElicitRequestFormParams), mode: 'form' }; + + const result = await this.request({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options); + + if (result.action === 'accept' && result.content && formParams.requestedSchema) { + try { + const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); + const validationResult = validator(result.content); + + if (!validationResult.valid) { + throw new McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); + } } - throw new McpError( - ErrorCode.InternalError, - `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` - ); + return result; } } + } + + /** + * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` + * notification for the specified elicitation ID. + * + * @param elicitationId The ID of the elicitation to mark as complete. + * @param options Optional notification options. Useful when the completion notification should be related to a prior request. + * @returns A function that emits the completion notification when awaited. + */ + createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise { + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error('Client does not support URL elicitation (required for notifications/elicitation/complete)'); + } - return result; + return () => + this.notification( + { + method: 'notifications/elicitation/complete', + params: { + elicitationId + } + }, + options + ); } async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index e2291481a..a6310173f 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -15,7 +15,9 @@ import { LoggingMessageNotificationSchema, type Notification, ReadResourceResultSchema, - type TextContent + type TextContent, + UrlElicitationRequiredError, + ErrorCode } from '../types.js'; import { completable } from './completable.js'; import { McpServer, ResourceTemplate } from './mcp.js'; @@ -1618,6 +1620,60 @@ describe('tool()', () => { ); }); + /*** + * Test: URL Elicitation Required Error Propagation + */ + test('should propagate UrlElicitationRequiredError to client callers', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const elicitationParams = { + mode: 'url' as const, + elicitationId: 'elicitation-123', + url: 'https://mcp.example.com/connect', + message: 'Authorization required' + }; + + mcpServer.tool('needs-authorization', async () => { + throw new UrlElicitationRequiredError([elicitationParams], 'Confirmation required'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client + .callTool({ + name: 'needs-authorization' + }) + .then(() => { + throw new Error('Expected callTool to throw UrlElicitationRequiredError'); + }) + .catch(error => { + expect(error).toBeInstanceOf(UrlElicitationRequiredError); + if (error instanceof UrlElicitationRequiredError) { + expect(error.code).toBe(ErrorCode.UrlElicitationRequired); + expect(error.elicitations).toEqual([elicitationParams]); + } + }); + }); + /*** * Test: Tool Registration with _meta field */ @@ -4098,6 +4154,7 @@ describe('elicitInput()', () => { if (!available) { // Ask user if they want to try alternative dates const result = await mcpServer.server.elicitInput({ + mode: 'form', message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, requestedSchema: { type: 'object', diff --git a/src/server/mcp.ts b/src/server/mcp.ts index bee3b76ec..3348d57e1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -178,6 +178,11 @@ export class McpServer { } } } catch (error) { + if (error instanceof McpError) { + if (error.code === ErrorCode.UrlElicitationRequired) { + throw error; // Return the error to the caller without wrapping in CallToolResult + } + } return this.createToolError(error instanceof Error ? error.message : String(error)); } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 48cad896f..5141e201c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -250,7 +250,7 @@ export abstract class Protocol= info.maxTotalTimeout) { this._timeoutInfo.delete(messageId); - throw new McpError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + throw McpError.fromError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { maxTotalTimeout: info.maxTotalTimeout, totalElapsed }); @@ -313,7 +313,7 @@ export abstract class Protocol cancel(new McpError(ErrorCode.RequestTimeout, 'Request timed out', { timeout })); + const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, 'Request timed out', { timeout })); this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 2417e6b1d..3df41bfc5 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -153,6 +153,21 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, + ElicitRequestFormParams: (sdk: SDKTypes.ElicitRequestFormParams, spec: SpecTypes.ElicitRequestFormParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + sdk = spec; + spec = sdk; + }, + ElicitationCompleteNotification: ( + sdk: RemovePassthrough>, + spec: SpecTypes.ElicitationCompleteNotification + ) => { + sdk = spec; + spec = sdk; + }, PaginatedRequestParams: (sdk: SDKTypes.PaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { sdk = spec; spec = sdk; @@ -598,6 +613,7 @@ const MISSING_SDK_TYPES = [ // These are inlined in the SDK: 'Role', 'Error', // The inner error object of a JSONRPCError + 'URLElicitationRequiredError', // In the SDK, but with a custom definition // These aren't supported by the SDK yet: // TODO: Add definitions to the SDK 'Annotations' @@ -615,7 +631,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(119); + expect(specTypes).toHaveLength(123); }); it('should have up to date list of missing sdk types', () => { diff --git a/src/spec.types.ts b/src/spec.types.ts index c58636350..307884fa0 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 11ad2a720d8e2f54881235f734121db0bda39052 + * Last updated from commit: 4528444698f76e6d0337e58d2941d5d3485d779d * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: npm run fetch:spec-types @@ -12,7 +12,7 @@ /** * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. * - * @internal + * @category JSON-RPC */ export type JSONRPCMessage = | JSONRPCRequest @@ -27,16 +27,22 @@ export const JSONRPC_VERSION = "2.0"; /** * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types */ export type ProgressToken = string | number; /** * An opaque token used to represent a cursor for pagination. + * + * @category Common Types */ export type Cursor = string; /** * Common params for any request. + * + * @internal */ export interface RequestParams { /** @@ -75,6 +81,9 @@ export interface Notification { params?: { [key: string]: any }; } +/** + * @category Common Types + */ export interface Result { /** * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. @@ -83,6 +92,9 @@ export interface Result { [key: string]: unknown; } +/** + * @category Common Types + */ export interface Error { /** * The error type that occurred. @@ -100,11 +112,15 @@ export interface Error { /** * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types */ export type RequestId = string | number; /** * A request that expects a response. + * + * @category JSON-RPC */ export interface JSONRPCRequest extends Request { jsonrpc: typeof JSONRPC_VERSION; @@ -113,6 +129,8 @@ export interface JSONRPCRequest extends Request { /** * A notification which does not expect a response. + * + * @category JSON-RPC */ export interface JSONRPCNotification extends Notification { jsonrpc: typeof JSONRPC_VERSION; @@ -120,6 +138,8 @@ export interface JSONRPCNotification extends Notification { /** * A successful (non-error) response to a request. + * + * @category JSON-RPC */ export interface JSONRPCResponse { jsonrpc: typeof JSONRPC_VERSION; @@ -128,19 +148,20 @@ export interface JSONRPCResponse { } // Standard JSON-RPC error codes -/** @internal */ export const PARSE_ERROR = -32700; -/** @internal */ export const INVALID_REQUEST = -32600; -/** @internal */ export const METHOD_NOT_FOUND = -32601; -/** @internal */ export const INVALID_PARAMS = -32602; -/** @internal */ export const INTERNAL_ERROR = -32603; +// Implementation-specific JSON-RPC error codes [-32000, -32099] +/** @internal */ +export const URL_ELICITATION_REQUIRED = -32042; + /** * A response to a request that indicates an error occurred. + * + * @category JSON-RPC */ export interface JSONRPCError { jsonrpc: typeof JSONRPC_VERSION; @@ -148,9 +169,27 @@ export interface JSONRPCError { error: Error; } +/** + * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * + * @internal + */ +export interface URLElicitationRequiredError + extends Omit { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + /* Empty result */ /** * A response that indicates success but carries no data. + * + * @category Common Types */ export type EmptyResult = Result; @@ -158,7 +197,7 @@ export type EmptyResult = Result; /** * Parameters for a `notifications/cancelled` notification. * - * @category notifications/cancelled + * @category `notifications/cancelled` */ export interface CancelledNotificationParams extends NotificationParams { /** @@ -183,7 +222,7 @@ export interface CancelledNotificationParams extends NotificationParams { * * A client MUST NOT attempt to cancel its `initialize` request. * - * @category notifications/cancelled + * @category `notifications/cancelled` */ export interface CancelledNotification extends JSONRPCNotification { method: "notifications/cancelled"; @@ -194,7 +233,7 @@ export interface CancelledNotification extends JSONRPCNotification { /** * Parameters for an `initialize` request. * - * @category initialize + * @category `initialize` */ export interface InitializeRequestParams extends RequestParams { /** @@ -208,7 +247,7 @@ export interface InitializeRequestParams extends RequestParams { /** * This request is sent from the client to the server when it first connects, asking it to begin initialization. * - * @category initialize + * @category `initialize` */ export interface InitializeRequest extends JSONRPCRequest { method: "initialize"; @@ -218,7 +257,7 @@ export interface InitializeRequest extends JSONRPCRequest { /** * After receiving an initialize request from the client, the server sends this response. * - * @category initialize + * @category `initialize` */ export interface InitializeResult extends Result { /** @@ -239,7 +278,7 @@ export interface InitializeResult extends Result { /** * This notification is sent from the client to the server after initialization has finished. * - * @category notifications/initialized + * @category `notifications/initialized` */ export interface InitializedNotification extends JSONRPCNotification { method: "notifications/initialized"; @@ -248,6 +287,8 @@ export interface InitializedNotification extends JSONRPCNotification { /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `initialize` */ export interface ClientCapabilities { /** @@ -270,11 +311,13 @@ export interface ClientCapabilities { /** * Present if the client supports elicitation from the server. */ - elicitation?: object; + elicitation?: { form?: object; url?: object }; } /** * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `initialize` */ export interface ServerCapabilities { /** @@ -324,6 +367,8 @@ export interface ServerCapabilities { /** * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types */ export interface Icon { /** @@ -407,7 +452,9 @@ export interface BaseMetadata { } /** - * Describes the MCP implementation + * Describes the MCP implementation. + * + * @category `initialize` */ export interface Implementation extends BaseMetadata, Icons { version: string; @@ -424,7 +471,7 @@ export interface Implementation extends BaseMetadata, Icons { /** * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. * - * @category ping + * @category `ping` */ export interface PingRequest extends JSONRPCRequest { method: "ping"; @@ -436,7 +483,7 @@ export interface PingRequest extends JSONRPCRequest { /** * Parameters for a `notifications/progress` notification. * - * @category notifications/progress + * @category `notifications/progress` */ export interface ProgressNotificationParams extends NotificationParams { /** @@ -464,7 +511,7 @@ export interface ProgressNotificationParams extends NotificationParams { /** * An out-of-band notification used to inform the receiver of a progress update for a long-running request. * - * @category notifications/progress + * @category `notifications/progress` */ export interface ProgressNotification extends JSONRPCNotification { method: "notifications/progress"; @@ -474,6 +521,8 @@ export interface ProgressNotification extends JSONRPCNotification { /* Pagination */ /** * Common parameters for paginated requests. + * + * @internal */ export interface PaginatedRequestParams extends RequestParams { /** @@ -501,7 +550,7 @@ export interface PaginatedResult extends Result { /** * Sent from the client to request a list of resources the server has. * - * @category resources/list + * @category `resources/list` */ export interface ListResourcesRequest extends PaginatedRequest { method: "resources/list"; @@ -510,7 +559,7 @@ export interface ListResourcesRequest extends PaginatedRequest { /** * The server's response to a resources/list request from the client. * - * @category resources/list + * @category `resources/list` */ export interface ListResourcesResult extends PaginatedResult { resources: Resource[]; @@ -519,7 +568,7 @@ export interface ListResourcesResult extends PaginatedResult { /** * Sent from the client to request a list of resource templates the server has. * - * @category resources/templates/list + * @category `resources/templates/list` */ export interface ListResourceTemplatesRequest extends PaginatedRequest { method: "resources/templates/list"; @@ -528,7 +577,7 @@ export interface ListResourceTemplatesRequest extends PaginatedRequest { /** * The server's response to a resources/templates/list request from the client. * - * @category resources/templates/list + * @category `resources/templates/list` */ export interface ListResourceTemplatesResult extends PaginatedResult { resourceTemplates: ResourceTemplate[]; @@ -551,7 +600,7 @@ export interface ResourceRequestParams extends RequestParams { /** * Parameters for a `resources/read` request. * - * @category resources/read + * @category `resources/read` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ReadResourceRequestParams extends ResourceRequestParams {} @@ -559,7 +608,7 @@ export interface ReadResourceRequestParams extends ResourceRequestParams {} /** * Sent from the client to the server, to read a specific resource URI. * - * @category resources/read + * @category `resources/read` */ export interface ReadResourceRequest extends JSONRPCRequest { method: "resources/read"; @@ -569,7 +618,7 @@ export interface ReadResourceRequest extends JSONRPCRequest { /** * The server's response to a resources/read request from the client. * - * @category resources/read + * @category `resources/read` */ export interface ReadResourceResult extends Result { contents: (TextResourceContents | BlobResourceContents)[]; @@ -578,7 +627,7 @@ export interface ReadResourceResult extends Result { /** * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. * - * @category notifications/resources/list_changed + * @category `notifications/resources/list_changed` */ export interface ResourceListChangedNotification extends JSONRPCNotification { method: "notifications/resources/list_changed"; @@ -588,7 +637,7 @@ export interface ResourceListChangedNotification extends JSONRPCNotification { /** * Parameters for a `resources/subscribe` request. * - * @category resources/subscribe + * @category `resources/subscribe` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface SubscribeRequestParams extends ResourceRequestParams {} @@ -596,7 +645,7 @@ export interface SubscribeRequestParams extends ResourceRequestParams {} /** * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. * - * @category resources/subscribe + * @category `resources/subscribe` */ export interface SubscribeRequest extends JSONRPCRequest { method: "resources/subscribe"; @@ -606,7 +655,7 @@ export interface SubscribeRequest extends JSONRPCRequest { /** * Parameters for a `resources/unsubscribe` request. * - * @category resources/unsubscribe + * @category `resources/unsubscribe` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UnsubscribeRequestParams extends ResourceRequestParams {} @@ -614,7 +663,7 @@ export interface UnsubscribeRequestParams extends ResourceRequestParams {} /** * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. * - * @category resources/unsubscribe + * @category `resources/unsubscribe` */ export interface UnsubscribeRequest extends JSONRPCRequest { method: "resources/unsubscribe"; @@ -624,7 +673,7 @@ export interface UnsubscribeRequest extends JSONRPCRequest { /** * Parameters for a `notifications/resources/updated` notification. * - * @category notifications/resources/updated + * @category `notifications/resources/updated` */ export interface ResourceUpdatedNotificationParams extends NotificationParams { /** @@ -638,7 +687,7 @@ export interface ResourceUpdatedNotificationParams extends NotificationParams { /** * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. * - * @category notifications/resources/updated + * @category `notifications/resources/updated` */ export interface ResourceUpdatedNotification extends JSONRPCNotification { method: "notifications/resources/updated"; @@ -647,6 +696,8 @@ export interface ResourceUpdatedNotification extends JSONRPCNotification { /** * A known resource that the server is capable of reading. + * + * @category `resources/list` */ export interface Resource extends BaseMetadata, Icons { /** @@ -688,6 +739,8 @@ export interface Resource extends BaseMetadata, Icons { /** * A template description for resources available on the server. + * + * @category `resources/templates/list` */ export interface ResourceTemplate extends BaseMetadata, Icons { /** @@ -722,6 +775,8 @@ export interface ResourceTemplate extends BaseMetadata, Icons { /** * The contents of a specific resource or sub-resource. + * + * @internal */ export interface ResourceContents { /** @@ -741,6 +796,9 @@ export interface ResourceContents { _meta?: { [key: string]: unknown }; } +/** + * @category Content + */ export interface TextResourceContents extends ResourceContents { /** * The text of the item. This must only be set if the item can actually be represented as text (not binary data). @@ -748,6 +806,9 @@ export interface TextResourceContents extends ResourceContents { text: string; } +/** + * @category Content + */ export interface BlobResourceContents extends ResourceContents { /** * A base64-encoded string representing the binary data of the item. @@ -761,7 +822,7 @@ export interface BlobResourceContents extends ResourceContents { /** * Sent from the client to request a list of prompts and prompt templates the server has. * - * @category prompts/list + * @category `prompts/list` */ export interface ListPromptsRequest extends PaginatedRequest { method: "prompts/list"; @@ -770,7 +831,7 @@ export interface ListPromptsRequest extends PaginatedRequest { /** * The server's response to a prompts/list request from the client. * - * @category prompts/list + * @category `prompts/list` */ export interface ListPromptsResult extends PaginatedResult { prompts: Prompt[]; @@ -779,7 +840,7 @@ export interface ListPromptsResult extends PaginatedResult { /** * Parameters for a `prompts/get` request. * - * @category prompts/get + * @category `prompts/get` */ export interface GetPromptRequestParams extends RequestParams { /** @@ -795,7 +856,7 @@ export interface GetPromptRequestParams extends RequestParams { /** * Used by the client to get a prompt provided by the server. * - * @category prompts/get + * @category `prompts/get` */ export interface GetPromptRequest extends JSONRPCRequest { method: "prompts/get"; @@ -805,7 +866,7 @@ export interface GetPromptRequest extends JSONRPCRequest { /** * The server's response to a prompts/get request from the client. * - * @category prompts/get + * @category `prompts/get` */ export interface GetPromptResult extends Result { /** @@ -817,6 +878,8 @@ export interface GetPromptResult extends Result { /** * A prompt or prompt template that the server offers. + * + * @category `prompts/list` */ export interface Prompt extends BaseMetadata, Icons { /** @@ -837,6 +900,8 @@ export interface Prompt extends BaseMetadata, Icons { /** * Describes an argument that a prompt can accept. + * + * @category `prompts/list` */ export interface PromptArgument extends BaseMetadata { /** @@ -851,6 +916,8 @@ export interface PromptArgument extends BaseMetadata { /** * The sender or recipient of messages and data in a conversation. + * + * @category Common Types */ export type Role = "user" | "assistant"; @@ -859,6 +926,8 @@ export type Role = "user" | "assistant"; * * This is similar to `SamplingMessage`, but also supports the embedding of * resources from the MCP server. + * + * @category `prompts/get` */ export interface PromptMessage { role: Role; @@ -869,6 +938,8 @@ export interface PromptMessage { * A resource that the server is capable of reading, included in a prompt or tool call result. * * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + * + * @category Content */ export interface ResourceLink extends Resource { type: "resource_link"; @@ -879,6 +950,8 @@ export interface ResourceLink extends Resource { * * It is up to the client how best to render embedded resources for the benefit * of the LLM and/or the user. + * + * @category Content */ export interface EmbeddedResource { type: "resource"; @@ -897,7 +970,7 @@ export interface EmbeddedResource { /** * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. * - * @category notifications/prompts/list_changed + * @category `notifications/prompts/list_changed` */ export interface PromptListChangedNotification extends JSONRPCNotification { method: "notifications/prompts/list_changed"; @@ -908,7 +981,7 @@ export interface PromptListChangedNotification extends JSONRPCNotification { /** * Sent from the client to request a list of tools the server has. * - * @category tools/list + * @category `tools/list` */ export interface ListToolsRequest extends PaginatedRequest { method: "tools/list"; @@ -917,7 +990,7 @@ export interface ListToolsRequest extends PaginatedRequest { /** * The server's response to a tools/list request from the client. * - * @category tools/list + * @category `tools/list` */ export interface ListToolsResult extends PaginatedResult { tools: Tool[]; @@ -926,7 +999,7 @@ export interface ListToolsResult extends PaginatedResult { /** * The server's response to a tool call. * - * @category tools/call + * @category `tools/call` */ export interface CallToolResult extends Result { /** @@ -959,7 +1032,7 @@ export interface CallToolResult extends Result { /** * Parameters for a `tools/call` request. * - * @category tools/call + * @category `tools/call` */ export interface CallToolRequestParams extends RequestParams { /** @@ -975,7 +1048,7 @@ export interface CallToolRequestParams extends RequestParams { /** * Used by the client to invoke a tool provided by the server. * - * @category tools/call + * @category `tools/call` */ export interface CallToolRequest extends JSONRPCRequest { method: "tools/call"; @@ -985,7 +1058,7 @@ export interface CallToolRequest extends JSONRPCRequest { /** * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. * - * @category notifications/tools/list_changed + * @category `notifications/tools/list_changed` */ export interface ToolListChangedNotification extends JSONRPCNotification { method: "notifications/tools/list_changed"; @@ -1001,6 +1074,8 @@ export interface ToolListChangedNotification extends JSONRPCNotification { * * Clients should never make tool use decisions based on ToolAnnotations * received from untrusted servers. + * + * @category `tools/list` */ export interface ToolAnnotations { /** @@ -1048,6 +1123,8 @@ export interface ToolAnnotations { /** * Definition for a tool the client can call. + * + * @category `tools/list` */ export interface Tool extends BaseMetadata, Icons { /** @@ -1094,7 +1171,7 @@ export interface Tool extends BaseMetadata, Icons { /** * Parameters for a `logging/setLevel` request. * - * @category logging/setLevel + * @category `logging/setLevel` */ export interface SetLevelRequestParams extends RequestParams { /** @@ -1106,7 +1183,7 @@ export interface SetLevelRequestParams extends RequestParams { /** * A request from the client to the server, to enable or adjust logging. * - * @category logging/setLevel + * @category `logging/setLevel` */ export interface SetLevelRequest extends JSONRPCRequest { method: "logging/setLevel"; @@ -1116,7 +1193,7 @@ export interface SetLevelRequest extends JSONRPCRequest { /** * Parameters for a `notifications/message` notification. * - * @category notifications/message + * @category `notifications/message` */ export interface LoggingMessageNotificationParams extends NotificationParams { /** @@ -1136,7 +1213,7 @@ export interface LoggingMessageNotificationParams extends NotificationParams { /** * JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. * - * @category notifications/message + * @category `notifications/message` */ export interface LoggingMessageNotification extends JSONRPCNotification { method: "notifications/message"; @@ -1148,6 +1225,8 @@ export interface LoggingMessageNotification extends JSONRPCNotification { * * These map to syslog message severities, as specified in RFC-5424: * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @category Common Types */ export type LoggingLevel = | "debug" @@ -1163,7 +1242,7 @@ export type LoggingLevel = /** * Parameters for a `sampling/createMessage` request. * - * @category sampling/createMessage + * @category `sampling/createMessage` */ export interface CreateMessageRequestParams extends RequestParams { messages: SamplingMessage[]; @@ -1199,7 +1278,7 @@ export interface CreateMessageRequestParams extends RequestParams { /** * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. * - * @category sampling/createMessage + * @category `sampling/createMessage` */ export interface CreateMessageRequest extends JSONRPCRequest { method: "sampling/createMessage"; @@ -1209,7 +1288,7 @@ export interface CreateMessageRequest extends JSONRPCRequest { /** * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. * - * @category sampling/createMessage + * @category `sampling/createMessage` */ export interface CreateMessageResult extends Result, SamplingMessage { /** @@ -1224,6 +1303,8 @@ export interface CreateMessageResult extends Result, SamplingMessage { /** * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` */ export interface SamplingMessage { role: Role; @@ -1232,6 +1313,8 @@ export interface SamplingMessage { /** * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types */ export interface Annotations { /** @@ -1265,6 +1348,9 @@ export interface Annotations { lastModified?: string; } +/** + * @category Content + */ export type ContentBlock = | TextContent | ImageContent @@ -1274,6 +1360,8 @@ export type ContentBlock = /** * Text provided to or from an LLM. + * + * @category Content */ export interface TextContent { type: "text"; @@ -1296,6 +1384,8 @@ export interface TextContent { /** * An image provided to or from an LLM. + * + * @category Content */ export interface ImageContent { type: "image"; @@ -1325,6 +1415,8 @@ export interface ImageContent { /** * Audio provided to or from an LLM. + * + * @category Content */ export interface AudioContent { type: "audio"; @@ -1364,6 +1456,8 @@ export interface AudioContent { * These preferences are always advisory. The client MAY ignore them. It is also * up to the client to decide how to interpret these preferences and how to * balance them against other considerations. + * + * @category `sampling/createMessage` */ export interface ModelPreferences { /** @@ -1416,6 +1510,8 @@ export interface ModelPreferences { * * Keys not declared here are currently left unspecified by the spec and are up * to the client to interpret. + * + * @category `sampling/createMessage` */ export interface ModelHint { /** @@ -1436,7 +1532,7 @@ export interface ModelHint { /** * Parameters for a `completion/complete` request. * - * @category completion/complete + * @category `completion/complete` */ export interface CompleteRequestParams extends RequestParams { ref: PromptReference | ResourceTemplateReference; @@ -1468,7 +1564,7 @@ export interface CompleteRequestParams extends RequestParams { /** * A request from the client to the server, to ask for completion options. * - * @category completion/complete + * @category `completion/complete` */ export interface CompleteRequest extends JSONRPCRequest { method: "completion/complete"; @@ -1478,7 +1574,7 @@ export interface CompleteRequest extends JSONRPCRequest { /** * The server's response to a completion/complete request * - * @category completion/complete + * @category `completion/complete` */ export interface CompleteResult extends Result { completion: { @@ -1499,6 +1595,8 @@ export interface CompleteResult extends Result { /** * A reference to a resource or resource template definition. + * + * @category `completion/complete` */ export interface ResourceTemplateReference { type: "ref/resource"; @@ -1512,6 +1610,8 @@ export interface ResourceTemplateReference { /** * Identifies a prompt. + * + * @category `completion/complete` */ export interface PromptReference extends BaseMetadata { type: "ref/prompt"; @@ -1527,7 +1627,7 @@ export interface PromptReference extends BaseMetadata { * This request is typically used when the server needs to understand the file system * structure or access specific locations that the client has permission to read from. * - * @category roots/list + * @category `roots/list` */ export interface ListRootsRequest extends JSONRPCRequest { method: "roots/list"; @@ -1539,7 +1639,7 @@ export interface ListRootsRequest extends JSONRPCRequest { * This result contains an array of Root objects, each representing a root directory * or file that the server can operate on. * - * @category roots/list + * @category `roots/list` */ export interface ListRootsResult extends Result { roots: Root[]; @@ -1547,6 +1647,8 @@ export interface ListRootsResult extends Result { /** * Represents a root directory or file that the server can operate on. + * + * @category `roots/list` */ export interface Root { /** @@ -1575,7 +1677,7 @@ export interface Root { * This notification should be sent whenever the client adds, removes, or modifies any root. * The server should then request an updated list of roots using the ListRootsRequest. * - * @category notifications/roots/list_changed + * @category `notifications/roots/list_changed` */ export interface RootsListChangedNotification extends JSONRPCNotification { method: "notifications/roots/list_changed"; @@ -1583,15 +1685,21 @@ export interface RootsListChangedNotification extends JSONRPCNotification { } /** - * Parameters for an `elicitation/create` request. + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. * - * @category elicitation/create + * @category `elicitation/create` */ -export interface ElicitRequestParams extends RequestParams { +export interface ElicitRequestFormParams extends RequestParams { + /** + * The elicitation mode. + */ + mode: "form"; + /** - * The message to present to the user. + * The message to present to the user describing what information is being requested. */ message: string; + /** * A restricted subset of JSON Schema. * Only top-level properties are allowed, without nesting. @@ -1605,19 +1713,61 @@ export interface ElicitRequestParams extends RequestParams { }; } +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams extends RequestParams { + /** + * The elicitation mode. + */ + mode: "url"; + + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string; + + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: string; + + /** + * The URL that the user should navigate to. + * + * @format uri + */ + url: string; +} + +/** + * The parameters for a request to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export type ElicitRequestParams = + | ElicitRequestFormParams + | ElicitRequestURLParams; + /** * A request from the server to elicit additional information from the user via the client. * - * @category elicitation/create + * @category `elicitation/create` */ export interface ElicitRequest extends JSONRPCRequest { method: "elicitation/create"; params: ElicitRequestParams; } +/** /** * Restricted schema definitions that only allow primitive types * without nested objects or arrays. + * + * @category `elicitation/create` */ export type PrimitiveSchemaDefinition = | StringSchema @@ -1625,6 +1775,9 @@ export type PrimitiveSchemaDefinition = | BooleanSchema | EnumSchema; +/** + * @category `elicitation/create` + */ export interface StringSchema { type: "string"; title?: string; @@ -1635,6 +1788,9 @@ export interface StringSchema { default?: string; } +/** + * @category `elicitation/create` + */ export interface NumberSchema { type: "number" | "integer"; title?: string; @@ -1644,6 +1800,9 @@ export interface NumberSchema { default?: number; } +/** + * @category `elicitation/create` + */ export interface BooleanSchema { type: "boolean"; title?: string; @@ -1653,6 +1812,8 @@ export interface BooleanSchema { /** * Schema for single-selection enumeration without display titles for options. + * + * @category `elicitation/create` */ export interface UntitledSingleSelectEnumSchema { type: "string"; @@ -1676,6 +1837,8 @@ export interface UntitledSingleSelectEnumSchema { /** * Schema for single-selection enumeration with display titles for each option. + * + * @category `elicitation/create` */ export interface TitledSingleSelectEnumSchema { type: "string"; @@ -1706,6 +1869,9 @@ export interface TitledSingleSelectEnumSchema { default?: string; } +/** + * @category `elicitation/create` + */ // Combined single selection enumeration export type SingleSelectEnumSchema = | UntitledSingleSelectEnumSchema @@ -1713,6 +1879,8 @@ export type SingleSelectEnumSchema = /** * Schema for multiple-selection enumeration without display titles for options. + * + * @category `elicitation/create` */ export interface UntitledMultiSelectEnumSchema { type: "array"; @@ -1750,6 +1918,8 @@ export interface UntitledMultiSelectEnumSchema { /** * Schema for multiple-selection enumeration with display titles for each option. + * + * @category `elicitation/create` */ export interface TitledMultiSelectEnumSchema { type: "array"; @@ -1793,6 +1963,9 @@ export interface TitledMultiSelectEnumSchema { default?: string[]; } +/** + * @category `elicitation/create` + */ // Combined multiple selection enumeration export type MultiSelectEnumSchema = | UntitledMultiSelectEnumSchema @@ -1801,6 +1974,8 @@ export type MultiSelectEnumSchema = /** * Use TitledSingleSelectEnumSchema instead. * This interface will be removed in a future version. + * + * @category `elicitation/create` */ export interface LegacyTitledEnumSchema { type: "string"; @@ -1815,6 +1990,9 @@ export interface LegacyTitledEnumSchema { default?: string; } +/** + * @category `elicitation/create` + */ // Union type for all enum schemas export type EnumSchema = | SingleSelectEnumSchema @@ -1824,7 +2002,7 @@ export type EnumSchema = /** * The client's response to an elicitation request. * - * @category elicitation/create + * @category `elicitation/create` */ export interface ElicitResult extends Result { /** @@ -1836,12 +2014,28 @@ export interface ElicitResult extends Result { action: "accept" | "decline" | "cancel"; /** - * The submitted form data, only present when action is "accept". + * The submitted form data, only present when action is "accept" and mode was "form". * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. */ content?: { [key: string]: string | number | boolean | string[] }; } +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotification extends JSONRPCNotification { + method: "notifications/elicitation/complete"; + params: { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; + }; +} + /* Client messages */ /** @internal */ export type ClientRequest = @@ -1889,7 +2083,8 @@ export type ServerNotification = | ResourceUpdatedNotification | ResourceListChangedNotification | ToolListChangedNotification - | PromptListChangedNotification; + | PromptListChangedNotification + | ElicitationCompleteNotification; /** @internal */ export type ServerResult = diff --git a/src/types.capabilities.test.ts b/src/types.capabilities.test.ts new file mode 100644 index 000000000..67a8ceeb9 --- /dev/null +++ b/src/types.capabilities.test.ts @@ -0,0 +1,103 @@ +import { ClientCapabilitiesSchema, InitializeRequestParamsSchema } from './types.js'; + +describe('ClientCapabilitiesSchema backwards compatibility', () => { + describe('ElicitationCapabilitySchema preprocessing', () => { + it('should inject form capability when elicitation is an empty object', () => { + const capabilities = { + elicitation: {} + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should preserve form capability configuration including applyDefaults', () => { + const capabilities = { + elicitation: { + form: { + applyDefaults: true + } + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({ applyDefaults: true }); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should not inject form capability when form is explicitly declared', () => { + const capabilities = { + elicitation: { + form: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should not inject form capability when url is explicitly declared', () => { + const capabilities = { + elicitation: { + url: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.url).toBeDefined(); + expect(result.elicitation?.url).toEqual({}); + expect(result.elicitation?.form).toBeUndefined(); + }); + + it('should not inject form capability when both form and url are explicitly declared', () => { + const capabilities = { + elicitation: { + form: {}, + url: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.url).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toEqual({}); + }); + + it('should not inject form capability when elicitation is undefined', () => { + const capabilities = {}; + + const result = ClientCapabilitiesSchema.parse(capabilities); + // When elicitation is not provided, it should remain undefined + expect(result.elicitation).toBeUndefined(); + }); + + it('should work within InitializeRequestParamsSchema context', () => { + const initializeParams = { + protocolVersion: '2025-11-25', + capabilities: { + elicitation: {} + }, + clientInfo: { + name: 'test client', + version: '1.0' + } + }; + + const result = InitializeRequestParamsSchema.parse(initializeParams); + expect(result.capabilities.elicitation).toBeDefined(); + expect(result.capabilities.elicitation?.form).toBeDefined(); + expect(result.capabilities.elicitation?.form).toEqual({}); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 66cc34941..78fa81d54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,7 +137,10 @@ export enum ErrorCode { InvalidRequest = -32600, MethodNotFound = -32601, InvalidParams = -32602, - InternalError = -32603 + InternalError = -32603, + + // MCP-specific error codes + UrlElicitationRequired = -32042 } /** @@ -271,6 +274,31 @@ export const ImplementationSchema = BaseMetadataSchema.extend({ websiteUrl: z.string().optional() }).merge(IconsSchema); +const FormElicitationCapabilitySchema = z.intersection( + z.object({ + applyDefaults: z.boolean().optional() + }), + z.record(z.string(), z.unknown()) +); + +const ElicitationCapabilitySchema = z.preprocess( + value => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + if (Object.keys(value as Record).length === 0) { + return { form: {} }; + } + } + return value; + }, + z.intersection( + z.object({ + form: FormElicitationCapabilitySchema.optional(), + url: AssertObjectSchema.optional() + }), + z.record(z.string(), z.unknown()).optional() + ) +); + /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. */ @@ -286,17 +314,7 @@ export const ClientCapabilitiesSchema = z.object({ /** * Present if the client supports eliciting user input. */ - elicitation: z.intersection( - z - .object({ - /** - * Whether the client should apply defaults to the user input. - */ - applyDefaults: z.boolean().optional() - }) - .optional(), - z.record(z.string(), z.unknown()).optional() - ), + elicitation: ElicitationCapabilitySchema.optional(), /** * Present if the client supports listing roots. */ @@ -1333,11 +1351,15 @@ export const EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSel export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); /** - * Parameters for an `elicitation/create` request. + * Parameters for an `elicitation/create` request for form-based elicitation. */ -export const ElicitRequestParamsSchema = BaseRequestParamsSchema.extend({ +export const ElicitRequestFormParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The elicitation mode. + */ + mode: z.literal('form'), /** - * The message to present to the user. + * The message to present to the user describing what information is being requested. */ message: z.string(), /** @@ -1351,15 +1373,66 @@ export const ElicitRequestParamsSchema = BaseRequestParamsSchema.extend({ }) }); +/** + * Parameters for an `elicitation/create` request for URL-based elicitation. + */ +export const ElicitRequestURLParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The elicitation mode. + */ + mode: z.literal('url'), + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: z.string(), + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: z.string(), + /** + * The URL that the user should navigate to. + */ + url: z.string().url() +}); + +/** + * The parameters for a request to elicit additional information from the user via the client. + */ +export const ElicitRequestParamsSchema = z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); + /** * A request from the server to elicit user input via the client. - * The client should present the message and form fields to the user. + * The client should present the message and form fields to the user (form mode) + * or navigate to a URL (URL mode). */ export const ElicitRequestSchema = RequestSchema.extend({ method: z.literal('elicitation/create'), params: ElicitRequestParamsSchema }); +/** + * Parameters for a `notifications/elicitation/complete` notification. + * + * @category notifications/elicitation/complete + */ +export const ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the elicitation that completed. + */ + elicitationId: z.string() +}); + +/** + * A notification from the server to the client, informing it of a completion of an out-of-band elicitation request. + * + * @category notifications/elicitation/complete + */ +export const ElicitationCompleteNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/elicitation/complete'), + params: ElicitationCompleteNotificationParamsSchema +}); + /** * The client's response to an elicitation/create request from the server. */ @@ -1553,7 +1626,8 @@ export const ServerNotificationSchema = z.union([ ResourceUpdatedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema + PromptListChangedNotificationSchema, + ElicitationCompleteNotificationSchema ]); export const ServerResultSchema = z.union([ @@ -1578,6 +1652,38 @@ export class McpError extends Error { super(`MCP error ${code}: ${message}`); this.name = 'McpError'; } + + /** + * Factory method to create the appropriate error type based on the error code and data + */ + static fromError(code: number, message: string, data?: unknown): McpError { + // Check for specific error types + if (code === ErrorCode.UrlElicitationRequired && data) { + const errorData = data as { elicitations?: unknown[] }; + if (errorData.elicitations) { + return new UrlElicitationRequiredError(errorData.elicitations as ElicitRequestURLParams[], message); + } + } + + // Default to generic McpError + return new McpError(code, message, data); + } +} + +/** + * Specialized error type when a tool requires a URL mode elicitation. + * This makes it nicer for the client to handle since there is specific data to work with instead of just a code to check against. + */ +export class UrlElicitationRequiredError extends McpError { + constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) { + super(ErrorCode.UrlElicitationRequired, message, { + elicitations: elicitations + }); + } + + get elicitations(): ElicitRequestURLParams[] { + return (this.data as { elicitations: ElicitRequestURLParams[] })?.elicitations ?? []; + } } type Primitive = string | number | boolean | bigint | null | undefined; @@ -1756,7 +1862,11 @@ export type MultiSelectEnumSchema = Infer; export type PrimitiveSchemaDefinition = Infer; export type ElicitRequestParams = Infer; +export type ElicitRequestFormParams = Infer; +export type ElicitRequestURLParams = Infer; export type ElicitRequest = Infer; +export type ElicitationCompleteNotificationParams = Infer; +export type ElicitationCompleteNotification = Infer; export type ElicitResult = Infer; /* Autocomplete */