SEP-1036: URL Elicitation (#1105)

This commit is contained in:
Nate Barbettini 2025-11-18 02:53:15 -08:00 • committed by GitHub
commit 4debc740a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3532 additions and 226 deletions

View file

@ -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

View file

@ -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<unknown>(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<unknown>(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);
});
});

View file

@ -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;
const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined;
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
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
}
}
}

View file

@ -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.

View file

@ -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<void> {
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<void> {
// 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 <name> [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<void> {
await new Promise<void>(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 <name> [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<void> {
while (true) {
// Wait until we have elicitations to process
await new Promise<void>(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<void> {
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<ElicitResult> {
// 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<ElicitResult>((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<ElicitResult> {
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<ElicitResult['action']> {
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<string>(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<void>((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<string> {
return new Promise<string>((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(`
<html>
<body>
<h1>Authorization Successful!</h1>
<p>This simulates successful authorization of the MCP client, which now has an access token for the MCP server.</p>
<p>This window will close automatically in 10 seconds.</p>
<script>setTimeout(() => window.close(), 10000);</script>
</body>
</html>
`);
resolve(code);
setTimeout(() => server.close(), 15000);
} else if (error) {
console.log(`❌ Authorization error: ${error}`);
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: ${error}</p>
</body>
</html>
`);
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<void> {
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<void> {
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<void> {
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<void> {
if (client) {
await disconnect();
}
await connect();
}
async function listTools(): Promise<void> {
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<string, unknown>): Promise<void> {
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 <uri>' 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<void> {
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<void> {
console.log('Calling payment-confirm tool...');
await callTool('payment-confirm', { cartId: 'cart_123' });
}
async function callThirdPartyAuthTool(): Promise<void> {
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);
});

View file

@ -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

View file

@ -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;
}
}

View file

@ -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 <name> [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<void> {
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<void> {
},
{
capabilities: {
elicitation: {}
elicitation: {
form: {}
}
}
}
);
@ -229,7 +233,10 @@ async function connect(url?: string): Promise<void> {
// 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<void> {
}
async function callCollectInfoTool(infoType: string): Promise<void> {
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 });
}

View file

@ -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');
}

View file

@ -0,0 +1,480 @@
// Run with: npx tsx src/examples/server/elicitationFormExample.ts
//
// 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.
// 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';
import express, { type Request, type Response } from 'express';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { isInitializeRequest } from '../../types.js';
// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults
// The validator supports format validation (email, date, etc.) if ajv-formats is installed
const mcpServer = new McpServer(
{
name: 'form-elicitation-example-server',
version: '1.0.0'
},
{
capabilities: {}
}
);
/**
* Example 1: Simple user registration tool
* Collects username, email, and password from the user
*/
mcpServer.registerTool(
'register_user',
{
description: 'Register a new user account by collecting their information',
inputSchema: {}
},
async () => {
try {
// Request user information through form elicitation
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Please provide your registration information:',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
description: 'Your desired username (3-20 characters)',
minLength: 3,
maxLength: 20
},
email: {
type: 'string',
title: 'Email',
description: 'Your email address',
format: 'email'
},
password: {
type: 'string',
title: 'Password',
description: 'Your password (min 8 characters)',
minLength: 8
},
newsletter: {
type: 'boolean',
title: 'Newsletter',
description: 'Subscribe to newsletter?',
default: false
}
},
required: ['username', 'email', 'password']
}
});
// Handle the different possible actions
if (result.action === 'accept' && result.content) {
const { username, email, newsletter } = result.content as {
username: string;
email: string;
password: string;
newsletter?: boolean;
};
return {
content: [
{
type: 'text',
text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [
{
type: 'text',
text: 'Registration cancelled by user.'
}
]
};
} else {
return {
content: [
{
type: 'text',
text: 'Registration was cancelled.'
}
]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Registration failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
/**
* Example 2: Multi-step workflow with multiple form elicitation requests
* Demonstrates how to collect information in multiple steps
*/
mcpServer.registerTool(
'create_event',
{
description: 'Create a calendar event by collecting event details',
inputSchema: {}
},
async () => {
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',
properties: {
title: {
type: 'string',
title: 'Event Title',
description: 'Name of the event',
minLength: 1
},
description: {
type: 'string',
title: 'Description',
description: 'Event description (optional)'
}
},
required: ['title']
}
});
if (basicInfo.action !== 'accept' || !basicInfo.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Step 2: Collect date and time
const dateTime = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Step 2: Enter date and time',
requestedSchema: {
type: 'object',
properties: {
date: {
type: 'string',
title: 'Date',
description: 'Event date',
format: 'date'
},
startTime: {
type: 'string',
title: 'Start Time',
description: 'Event start time (HH:MM)'
},
duration: {
type: 'integer',
title: 'Duration',
description: 'Duration in minutes',
minimum: 15,
maximum: 480
}
},
required: ['date', 'startTime', 'duration']
}
});
if (dateTime.action !== 'accept' || !dateTime.content) {
return {
content: [{ type: 'text', text: 'Event creation cancelled.' }]
};
}
// Combine all collected information
const event = {
...basicInfo.content,
...dateTime.content
};
return {
content: [
{
type: 'text',
text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
/**
* Example 3: Collecting address information
* Demonstrates validation with patterns and optional fields
*/
mcpServer.registerTool(
'update_shipping_address',
{
description: 'Update shipping address with validation',
inputSchema: {}
},
async () => {
try {
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: 'Please provide your shipping address:',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Full Name',
description: 'Recipient name',
minLength: 1
},
street: {
type: 'string',
title: 'Street Address',
minLength: 1
},
city: {
type: 'string',
title: 'City',
minLength: 1
},
state: {
type: 'string',
title: 'State/Province',
minLength: 2,
maxLength: 2
},
zipCode: {
type: 'string',
title: 'ZIP/Postal Code',
description: '5-digit ZIP code'
},
phone: {
type: 'string',
title: 'Phone Number (optional)',
description: 'Contact phone number'
}
},
required: ['name', 'street', 'city', 'state', 'zipCode']
}
});
if (result.action === 'accept' && result.content) {
return {
content: [
{
type: 'text',
text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}`
}
]
};
} else if (result.action === 'decline') {
return {
content: [{ type: 'text', text: 'Address update cancelled by user.' }]
};
} else {
return {
content: [{ type: 'text', text: 'Address update was cancelled.' }]
};
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Address update failed: ${error instanceof Error ? error.message : String(error)}`
}
],
isError: true
};
}
}
);
async function main() {
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
const app = express();
app.use(express.json());
// Allow CORS for all domains, expose the Mcp-Session-Id header
app.use(
cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id']
})
);
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
// MCP POST endpoint
const mcpPostHandler = async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
console.log(`Received MCP request for session: ${sessionId}`);
}
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
// Reuse existing transport for this session
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
// New initialization request - create new transport
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: sessionId => {
// Store the transport by session ID when session is initialized
console.log(`Session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
}
});
// 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];
}
};
// Connect the transport to the MCP server BEFORE handling the request
await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} 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
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
});
}
}
};
app.post('/mcp', mcpPostHandler);
// Handle GET requests for SSE streams
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;
}
console.log(`Establishing SSE stream for session ${sessionId}`);
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', mcpGetHandler);
// Handle DELETE requests for session termination
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');
}
}
};
app.delete('/mcp', mcpDeleteHandler);
// Start listening
app.listen(PORT, error => {
if (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
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');
console.log(' - update_shipping_address: Collect and validate address');
console.log('\nConnect your MCP client to this server using the HTTP transport.');
});
// 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];
} catch (error) {
console.error(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log('Server shutdown complete');
process.exit(0);
});
}
main().catch(error => {
console.error('Server error:', error);
process.exit(1);
});

View file

@ -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<CallToolResult> => {
/*
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<CallToolResult> => {
/*
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<void>;
completeResolver: () => void;
createdAt: Date;
sessionId: string;
completionNotifier?: () => Promise<void>;
}
const elicitationsMap = new Map<string, ElicitationMetadata>();
// 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<void>(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('<h1>Error</h1><p>Missing required parameters</p>');
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('<h1>Error</h1><p>Unauthorized - please reconnect to login again</p>');
return;
}
// Serve a simple HTML form
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Submit Your API Key</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
input[type="text"] { width: 100%; padding: 8px; margin: 10px 0; box-sizing: border-box; }
button { background: #007bff; color: white; padding: 10px 20px; border: none; cursor: pointer; }
button:hover { background: #0056b3; }
.user { background: #d1ecf1; padding: 8px; margin-bottom: 10px; }
.info { color: #666; font-size: 0.9em; margin-top: 20px; }
</style>
</head>
<body>
<h1>API Key Required</h1>
<div class="user">✓ Logged in as: <strong>${userSession.name}</strong></div>
<form method="POST" action="/api-key-form">
<input type="hidden" name="session" value="${mcpSessionId}" />
<input type="hidden" name="elicitation" value="${elicitationId}" />
<label>API Key:<br>
<input type="text" name="apiKey" required placeholder="Enter your API key" />
</label>
<button type="submit">Submit</button>
</form>
<div class="info">This is a demo showing how a server can securely elicit sensitive data from a user using a URL.</div>
</body>
</html>
`);
});
// 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('<h1>Error</h1><p>Missing required parameters</p>');
return;
}
// Check for user session cookie here too
const userSession = getUserSessionCookie(req.headers.cookie);
if (!userSession) {
res.status(401).send('<h1>Error</h1><p>Unauthorized - please reconnect to login again</p>');
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(`
<!DOCTYPE html>
<html>
<head>
<title>Success</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; text-align: center; }
.success { background: #d4edda; color: #155724; padding: 20px; margin: 20px 0; }
</style>
</head>
<body>
<div class="success">
<h1>Success ✓</h1>
<p>API key received.</p>
</div>
<p>You can close this window and return to your MCP client.</p>
</body>
</html>
`);
});
// 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('<h1>Error</h1><p>Missing required parameters</p>');
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('<h1>Error</h1><p>Unauthorized - please reconnect to login again</p>');
return;
}
// Serve a simple HTML form
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Confirm Payment</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; }
button { background: #28a745; color: white; padding: 12px 24px; border: none; cursor: pointer; font-size: 16px; width: 100%; margin: 10px 0; }
button:hover { background: #218838; }
button.cancel { background: #6c757d; }
button.cancel:hover { background: #5a6268; }
.user { background: #d1ecf1; padding: 8px; margin-bottom: 10px; }
.cart-info { background: #f8f9fa; padding: 12px; margin: 15px 0; border-left: 4px solid #007bff; }
.info { color: #666; font-size: 0.9em; margin-top: 20px; }
.warning { background: #fff3cd; color: #856404; padding: 12px; margin: 15px 0; border-left: 4px solid #ffc107; }
</style>
</head>
<body>
<h1>Confirm Payment</h1>
<div class="user">✓ Logged in as: <strong>${userSession.name}</strong></div>
${cartId ? `<div class="cart-info"><strong>Cart ID:</strong> ${cartId}</div>` : ''}
<div class="warning">
<strong>⚠️ Please review your order before confirming.</strong>
</div>
<form method="POST" action="/confirm-payment">
<input type="hidden" name="session" value="${mcpSessionId}" />
<input type="hidden" name="elicitation" value="${elicitationId}" />
${cartId ? `<input type="hidden" name="cartId" value="${cartId}" />` : ''}
<button type="submit" name="action" value="confirm">Confirm Payment</button>
<button type="submit" name="action" value="cancel" class="cancel">Cancel</button>
</form>
<div class="info">This is a demo showing how a server can securely get user confirmation for sensitive operations using URL-mode elicitation.</div>
</body>
</html>
`);
});
// 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('<h1>Error</h1><p>Missing required parameters</p>');
return;
}
// Check for user session cookie here too
const userSession = getUserSessionCookie(req.headers.cookie);
if (!userSession) {
res.status(401).send('<h1>Error</h1><p>Unauthorized - please reconnect to login again</p>');
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(`
<!DOCTYPE html>
<html>
<head>
<title>Payment Confirmed</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; text-align: center; }
.success { background: #d4edda; color: #155724; padding: 20px; margin: 20px 0; }
</style>
</head>
<body>
<div class="success">
<h1>Payment Confirmed ✓</h1>
<p>Your payment has been successfully processed.</p>
${cartId ? `<p><strong>Cart ID:</strong> ${cartId}</p>` : ''}
</div>
<p>You can close this window and return to your MCP client.</p>
</body>
</html>
`);
} 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(`
<!DOCTYPE html>
<html>
<head>
<title>Payment Cancelled</title>
<style>
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; padding: 20px; text-align: center; }
.info { background: #d1ecf1; color: #0c5460; padding: 20px; margin: 20px 0; }
</style>
</head>
<body>
<div class="info">
<h1>Payment Cancelled</h1>
<p>Your payment has been cancelled.</p>
</div>
<p>You can close this window and return to your MCP client.</p>
</body>
</html>
`);
} else {
res.status(400).send('<h1>Error</h1><p>Invalid action</p>');
}
});
// 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<ElicitResult>;
type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise<void>;
// 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);
});

View file

@ -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
});

View file

@ -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',

View file

@ -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,7 +306,92 @@ 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',
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 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(
@ -345,6 +432,534 @@ test('should respect client elicitation capabilities', async () => {
).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<T>(_schema: JsonSchemaType): JsonSchemaValidator<T> {
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
})

View file

@ -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<ElicitRequestFormParams, 'mode'>;
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<ElicitResult> {
const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options);
/**
* 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<ElicitResult>;
/**
* 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<ElicitResult>;
/**
* 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<ElicitResult>;
async elicitInput(
params: LegacyElicitRequestFormParams | ElicitRequestFormParams | ElicitRequestURLParams,
options?: RequestOptions
): Promise<ElicitResult> {
const mode = 'mode' in params ? params.mode : 'form';
// 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);
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;
}
}
}
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<void> {
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error('Client does not support URL elicitation (required for notifications/elicitation/complete)');
}
return () =>
this.notification(
{
method: 'notifications/elicitation/complete',
params: {
elicitationId
}
},
options
);
}
async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) {

View file

@ -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',

View file

@ -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));
}

View file

@ -250,7 +250,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
const totalElapsed = Date.now() - info.startTime;
if (info.maxTotalTimeout && totalElapsed >= 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<SendRequestT extends Request, SendNotificationT e
this._transport = undefined;
this.onclose?.();
const error = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed');
for (const handler of responseHandlers.values()) {
handler(error);
}
@ -396,7 +396,8 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
id: request.id,
error: {
code: Number.isSafeInteger(error['code']) ? error['code'] : ErrorCode.InternalError,
message: error.message ?? 'Internal error'
message: error.message ?? 'Internal error',
...(error['data'] !== undefined && { data: error['data'] })
}
});
}
@ -447,7 +448,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
if (isJSONRPCResponse(response)) {
handler(response);
} else {
const error = new McpError(response.error.code, response.error.message, response.error.data);
const error = McpError.fromError(response.error.code, response.error.message, response.error.data);
handler(error);
}
}
@ -566,7 +567,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
});
const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
const timeoutHandler = () => 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);

View file

@ -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<WithJSONRPC<SDKTypes.ElicitationCompleteNotification>>,
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', () => {

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more