SEP-1036: URL Elicitation (#1105)
This commit is contained in:
parent
df56e35322
commit
4debc740a5
22 changed files with 3532 additions and 226 deletions
85
README.md
85
README.md
|
|
@ -1175,7 +1175,7 @@ await server.connect(transport);
|
|||
|
||||
### Eliciting User Input
|
||||
|
||||
MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation:
|
||||
MCP servers can request non-sensitive information from users through the form elicitation capability. This is useful for interactive workflows where the server needs user input or confirmation:
|
||||
|
||||
```typescript
|
||||
// Server-side: Restaurant booking tool that asks for alternatives
|
||||
|
|
@ -1208,6 +1208,7 @@ server.registerTool(
|
|||
if (!available) {
|
||||
// Ask user if they want to try alternative dates
|
||||
const result = await server.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
|
|
@ -1274,7 +1275,7 @@ server.registerTool(
|
|||
);
|
||||
```
|
||||
|
||||
Client-side: Handle elicitation requests
|
||||
On the client side, handle form elicitation requests:
|
||||
|
||||
```typescript
|
||||
// This is a placeholder - implement based on your UI framework
|
||||
|
|
@ -1299,7 +1300,85 @@ client.setRequestHandler(ElicitRequestSchema, async request => {
|
|||
});
|
||||
```
|
||||
|
||||
**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization.
|
||||
When calling `server.elicitInput`, prefer to explicitly set `mode: 'form'` for new code. Omitting the mode continues to work for backwards compatibility and defaults to form elicitation.
|
||||
|
||||
Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization:
|
||||
|
||||
```typescript
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {
|
||||
form: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Note**: Form elicitation **must** only be used to gather non-sensitive information. For sensitive information such as API keys or secrets, use URL elicitation instead.
|
||||
|
||||
### Eliciting URL Actions
|
||||
|
||||
MCP servers can prompt the user to perform a URL-based action through URL elicitation. This is useful for securely gathering sensitive information such as API keys or secrets, or for redirecting users to secure web-based flows.
|
||||
|
||||
```typescript
|
||||
// Server-side: Prompt the user to navigate to a URL
|
||||
const result = await server.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please enter your API key',
|
||||
elicitationId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
url: 'http://localhost:3000/api-key'
|
||||
});
|
||||
|
||||
// Alternative, return an error from within a tool:
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'This tool requires a payment confirmation. Open the link to confirm payment!',
|
||||
url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`,
|
||||
elicitationId: '550e8400-e29b-41d4-a716-446655440000'
|
||||
}
|
||||
]);
|
||||
```
|
||||
|
||||
On the client side, handle URL elicitation requests:
|
||||
|
||||
```typescript
|
||||
client.setRequestHandler(ElicitRequestSchema, async request => {
|
||||
if (request.params.mode !== 'url') {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
|
||||
}
|
||||
|
||||
// At a minimum, implement a UI that:
|
||||
// - Display the full URL and server reason to prevent phishing
|
||||
// - Explicitly ask the user for consent, with clear decline/cancel options
|
||||
// - Open the URL in the system (not embedded) browser
|
||||
// Optionally, listen for a `nofifications/elicitation/complete` message from the server
|
||||
});
|
||||
```
|
||||
|
||||
Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization:
|
||||
|
||||
```typescript
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'example-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {
|
||||
url: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Writing MCP Clients
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
786
src/examples/client/elicitationUrlExample.ts
Normal file
786
src/examples/client/elicitationUrlExample.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
65
src/examples/client/simpleOAuthClientProvider.ts
Normal file
65
src/examples/client/simpleOAuthClientProvider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
480
src/examples/server/elicitationFormExample.ts
Normal file
480
src/examples/server/elicitationFormExample.ts
Normal 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);
|
||||
});
|
||||
770
src/examples/server/elicitationUrlExample.ts
Normal file
770
src/examples/server/elicitationUrlExample.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue