SEP-1036: URL Elicitation (#1105)
This commit is contained in:
parent
df56e35322
commit
4debc740a5
9 changed files with 3532 additions and 226 deletions
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue