[SEP-2663] refactor!: remove 2025-11 experimental tasks (#2128)
This commit is contained in:
parent
600ba75f34
commit
c8d7401b46
11 changed files with 423 additions and 18280 deletions
|
|
@ -24,18 +24,17 @@ Most clients expect a server to be running. Start one from [`../server/README.md
|
|||
|
||||
## Example index
|
||||
|
||||
| Scenario | Description | File |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) |
|
||||
| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) |
|
||||
| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) |
|
||||
| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) |
|
||||
| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) |
|
||||
| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) |
|
||||
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
|
||||
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) |
|
||||
| Scenario | Description | File |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) |
|
||||
| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) |
|
||||
| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) |
|
||||
| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) |
|
||||
| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) |
|
||||
| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) |
|
||||
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
|
||||
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
|
||||
## URL elicitation example (server + client)
|
||||
|
||||
|
|
|
|||
|
|
@ -490,7 +490,7 @@ async function errorHandling_timeout(client: Client) {
|
|||
//#region errorHandling_timeout
|
||||
try {
|
||||
const result = await client.callTool(
|
||||
{ name: 'slow-task', arguments: {} },
|
||||
{ name: 'slow-operation', arguments: {} },
|
||||
{ timeout: 120_000 } // 2 minutes instead of the default 60 seconds
|
||||
);
|
||||
console.log(result.content);
|
||||
|
|
@ -530,7 +530,7 @@ async function resumptionToken_basic(client: Client) {
|
|||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'long-running-task', arguments: {} }
|
||||
params: { name: 'long-running-operation', arguments: {} }
|
||||
},
|
||||
{
|
||||
resumptionToken: lastToken,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createServer } from 'node:http';
|
|||
import { createInterface } from 'node:readline';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import type { ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
import open from 'open';
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ class InteractiveOAuthClient {
|
|||
console.log('Commands:');
|
||||
console.log(' list - List available tools');
|
||||
console.log(' call <tool_name> [args] - Call a tool');
|
||||
console.log(' stream <tool_name> [args] - Call a tool with streaming (shows task status)');
|
||||
console.log(' stream <tool_name> [args] - (disabled; returns when the SEP-2663 tasks extension lands)');
|
||||
console.log(' quit - Exit the client');
|
||||
console.log();
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ class InteractiveOAuthClient {
|
|||
} else if (command.startsWith('stream ')) {
|
||||
await this.handleStreamTool(command);
|
||||
} else {
|
||||
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', 'stream <tool_name>', or 'quit'");
|
||||
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'SIGINT') {
|
||||
|
|
@ -358,62 +358,11 @@ class InteractiveOAuthClient {
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
console.log(`\n🔧 Streaming tool '${toolName}'...`);
|
||||
|
||||
const stream = this.client.experimental.tasks.callToolStream(
|
||||
{
|
||||
name: toolName,
|
||||
arguments: toolArgs
|
||||
},
|
||||
{
|
||||
task: {
|
||||
taskId: `task-${Date.now()}`,
|
||||
ttl: 60_000
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Iterate through all messages yielded by the generator
|
||||
for await (const message of stream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log(`✓ Task created: ${message.task.taskId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'taskStatus': {
|
||||
console.log(`⟳ Status: ${message.task.status}`);
|
||||
if (message.task.statusMessage) {
|
||||
console.log(` ${message.task.statusMessage}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
console.log('✓ Completed!');
|
||||
const toolResult = message.result as CallToolResult;
|
||||
for (const content of toolResult.content) {
|
||||
if (content.type === 'text') {
|
||||
console.log(content.text);
|
||||
} else {
|
||||
console.log(content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
console.log('✗ Error:');
|
||||
console.log(` ${message.error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to stream tool '${toolName}':`, error);
|
||||
}
|
||||
// The streaming-tool demo (callToolStream) was removed with the 2025-11
|
||||
// experimental tasks (SEP-2663); it returns when the tasks extension lands.
|
||||
void toolName;
|
||||
void toolArgs;
|
||||
console.log('Streaming tool demo removed with the 2025-11 experimental tasks (SEP-2663); returns when the tasks extension lands.');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type {
|
||||
CallToolResult,
|
||||
GetPromptRequest,
|
||||
ListPromptsRequest,
|
||||
ListResourcesRequest,
|
||||
|
|
@ -9,15 +8,7 @@ import type {
|
|||
ReadResourceRequest,
|
||||
ResourceLink
|
||||
} from '@modelcontextprotocol/client';
|
||||
import {
|
||||
Client,
|
||||
getDisplayName,
|
||||
InMemoryTaskStore,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
RELATED_TASK_META_KEY,
|
||||
StreamableHTTPClientTransport
|
||||
} from '@modelcontextprotocol/client';
|
||||
import { Client, getDisplayName, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { Ajv } from 'ajv';
|
||||
|
||||
// Create readline interface for user input
|
||||
|
|
@ -56,11 +47,9 @@ function printHelp(): void {
|
|||
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(' call-tool-task <name> [args] - Call a tool with task-based execution (example: call-tool-task delay {"duration":3000})');
|
||||
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 form elicitation with collect-user-info tool (contact/preferences/feedback)');
|
||||
console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation');
|
||||
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');
|
||||
|
|
@ -135,12 +124,6 @@ function commandLoop(): void {
|
|||
await callCollectInfoTool(args[1] || 'contact');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'collect-info-task': {
|
||||
await callCollectInfoWithTask(args[1] || 'contact');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'start-notifications': {
|
||||
const interval = args[1] ? Number.parseInt(args[1], 10) : 2000;
|
||||
const count = args[2] ? Number.parseInt(args[2], 10) : 10;
|
||||
|
|
@ -154,25 +137,6 @@ function commandLoop(): void {
|
|||
await runNotificationsToolWithResumability(interval, count);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'call-tool-task': {
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: call-tool-task <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 callToolTask(toolName, toolArgs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-prompts': {
|
||||
await listPrompts();
|
||||
break;
|
||||
|
|
@ -250,10 +214,7 @@ async function connect(url?: string): Promise<void> {
|
|||
console.log(`Connecting to ${serverUrl}...`);
|
||||
|
||||
try {
|
||||
// Create task store for client-side task support
|
||||
const clientTaskStore = new InMemoryTaskStore();
|
||||
|
||||
// Create a new client with form elicitation capability and task support
|
||||
// Create a new client with form elicitation capability
|
||||
client = new Client(
|
||||
{
|
||||
name: 'example-client',
|
||||
|
|
@ -263,14 +224,6 @@ async function connect(url?: string): Promise<void> {
|
|||
capabilities: {
|
||||
elicitation: {
|
||||
form: {}
|
||||
},
|
||||
tasks: {
|
||||
taskStore: clientTaskStore,
|
||||
requests: {
|
||||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -279,33 +232,16 @@ async function connect(url?: string): Promise<void> {
|
|||
console.error('\u001B[31mClient error:', error, '\u001B[0m');
|
||||
};
|
||||
|
||||
// Set up elicitation request handler with proper validation and task support
|
||||
client.setRequestHandler('elicitation/create', async (request, extra) => {
|
||||
// Set up elicitation request handler with proper validation
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
if (request.params.mode !== 'form') {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
|
||||
}
|
||||
console.log('\n🔔 Elicitation (form) Request Received:');
|
||||
console.log(`Message: ${request.params.message}`);
|
||||
console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`);
|
||||
console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`);
|
||||
console.log('Requested Schema:');
|
||||
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
|
||||
|
||||
// Helper to return result, optionally creating a task if requested
|
||||
const returnResult = async (result: {
|
||||
action: 'accept' | 'decline' | 'cancel';
|
||||
content?: Record<string, string | number | boolean | string[]>;
|
||||
}) => {
|
||||
if (request.params.task && extra.task?.store) {
|
||||
// Create a task and store the result
|
||||
const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl });
|
||||
await extra.task.store.storeTaskResult(task.taskId, 'completed', result);
|
||||
console.log(`📋 Created client-side task: ${task.taskId}`);
|
||||
return { task };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const schema = request.params.requestedSchema;
|
||||
const properties = schema.properties;
|
||||
const required = schema.required || [];
|
||||
|
|
@ -439,7 +375,7 @@ async function connect(url?: string): Promise<void> {
|
|||
}
|
||||
|
||||
if (inputCancelled) {
|
||||
return returnResult({ action: 'cancel' });
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
// If we didn't complete all fields due to an error, try again
|
||||
|
|
@ -452,7 +388,7 @@ async function connect(url?: string): Promise<void> {
|
|||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -471,7 +407,7 @@ async function connect(url?: string): Promise<void> {
|
|||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,14 +424,14 @@ async function connect(url?: string): Promise<void> {
|
|||
switch (confirmAnswer) {
|
||||
case 'yes':
|
||||
case 'y': {
|
||||
return returnResult({
|
||||
return {
|
||||
action: 'accept',
|
||||
content
|
||||
});
|
||||
};
|
||||
}
|
||||
case 'cancel':
|
||||
case 'c': {
|
||||
return returnResult({ action: 'cancel' });
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
case 'no':
|
||||
case 'n': {
|
||||
|
|
@ -503,7 +439,7 @@ async function connect(url?: string): Promise<void> {
|
|||
console.log('Please re-enter the information...');
|
||||
continue;
|
||||
} else {
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
@ -513,7 +449,7 @@ async function connect(url?: string): Promise<void> {
|
|||
}
|
||||
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
|
|
@ -716,12 +652,6 @@ async function callCollectInfoTool(infoType: string): Promise<void> {
|
|||
await callTool('collect-user-info', { infoType });
|
||||
}
|
||||
|
||||
async function callCollectInfoWithTask(infoType: string): Promise<void> {
|
||||
console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`);
|
||||
console.log('This will create a task on the server, which will elicit input and create a task on the client.\n');
|
||||
await callToolTask('collect-user-info-task', { infoType });
|
||||
}
|
||||
|
||||
async function startNotifications(interval: number, count: number): Promise<void> {
|
||||
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
await callTool('start-notification-stream', { interval, count });
|
||||
|
|
@ -880,70 +810,6 @@ async function readResource(uri: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function callToolTask(name: string, args: Record<string, unknown>): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Calling tool '${name}' with task-based execution...`);
|
||||
console.log('Arguments:', args);
|
||||
|
||||
// Use task-based execution - call now, fetch later
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
console.log('This will return immediately while processing continues in the background...');
|
||||
|
||||
try {
|
||||
// Call the tool with task metadata using streaming API
|
||||
const stream = client.experimental.tasks.callToolStream(
|
||||
{
|
||||
name,
|
||||
arguments: args
|
||||
},
|
||||
{
|
||||
task: {
|
||||
ttl: 60_000 // Keep results for 60 seconds
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Waiting for task completion...');
|
||||
|
||||
let lastStatus = '';
|
||||
for await (const message of stream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log('Task created successfully with ID:', message.task.taskId);
|
||||
break;
|
||||
}
|
||||
case 'taskStatus': {
|
||||
if (lastStatus !== message.task.status) {
|
||||
console.log(` ${message.task.status}${message.task.statusMessage ? ` - ${message.task.statusMessage}` : ''}`);
|
||||
}
|
||||
lastStatus = message.task.status;
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
console.log('Task completed!');
|
||||
console.log('Tool result:');
|
||||
const toolResult = message.result as CallToolResult;
|
||||
for (const item of toolResult.content) {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
throw message.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error with task-based execution: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
/**
|
||||
* Simple interactive task client demonstrating elicitation and sampling responses.
|
||||
*
|
||||
* This client connects to simpleTaskInteractive.ts server and demonstrates:
|
||||
* - Handling elicitation requests (y/n confirmation)
|
||||
* - Handling sampling requests (returns a hardcoded haiku)
|
||||
* - Using task-based tool execution with streaming
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type { CallToolResult, CreateMessageRequest, CreateMessageResult, TextContent } from '@modelcontextprotocol/client';
|
||||
import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
function question(prompt: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
readline.question(prompt, answer => {
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTextContent(result: { content: Array<{ type: string; text?: string }> }): string {
|
||||
const textContent = result.content.find((c): c is TextContent => c.type === 'text');
|
||||
return textContent?.text ?? '(no text)';
|
||||
}
|
||||
|
||||
async function elicitationCallback(params: {
|
||||
mode?: string;
|
||||
message: string;
|
||||
requestedSchema?: object;
|
||||
}): Promise<{ action: 'accept' | 'cancel' | 'decline'; content?: Record<string, string | number | boolean | string[]> }> {
|
||||
console.log(`\n[Elicitation] Server asks: ${params.message}`);
|
||||
|
||||
// Simple terminal prompt for y/n
|
||||
const response = await question('Your response (y/n): ');
|
||||
const confirmed = ['y', 'yes', 'true', '1'].includes(response.toLowerCase());
|
||||
|
||||
console.log(`[Elicitation] Responding with: confirm=${confirmed}`);
|
||||
return { action: 'accept', content: { confirm: confirmed } };
|
||||
}
|
||||
|
||||
async function samplingCallback(params: CreateMessageRequest['params']): Promise<CreateMessageResult> {
|
||||
// Get the prompt from the first message
|
||||
let prompt = 'unknown';
|
||||
if (params.messages && params.messages.length > 0) {
|
||||
const firstMessage = params.messages[0]!;
|
||||
const content = firstMessage.content;
|
||||
if (typeof content === 'object' && !Array.isArray(content) && content.type === 'text' && 'text' in content) {
|
||||
prompt = content.text;
|
||||
} else if (Array.isArray(content)) {
|
||||
const textPart = content.find(c => c.type === 'text' && 'text' in c);
|
||||
if (textPart && 'text' in textPart) {
|
||||
prompt = textPart.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[Sampling] Server requests LLM completion for: ${prompt}`);
|
||||
|
||||
// Return a hardcoded haiku (in real use, call your LLM here)
|
||||
const haiku = `Cherry blossoms fall
|
||||
Softly on the quiet pond
|
||||
Spring whispers goodbye`;
|
||||
|
||||
console.log('[Sampling] Responding with haiku');
|
||||
return {
|
||||
model: 'mock-haiku-model',
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: haiku }
|
||||
};
|
||||
}
|
||||
|
||||
async function run(url: string): Promise<void> {
|
||||
console.log('Simple Task Interactive Client');
|
||||
console.log('==============================');
|
||||
console.log(`Connecting to ${url}...`);
|
||||
|
||||
// Create client with elicitation and sampling capabilities
|
||||
const client = new Client(
|
||||
{ name: 'simple-task-interactive-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: { form: {} },
|
||||
sampling: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set up elicitation request handler
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
if (request.params.mode && request.params.mode !== 'form') {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
|
||||
}
|
||||
return elicitationCallback(request.params);
|
||||
});
|
||||
|
||||
// Set up sampling request handler
|
||||
client.setRequestHandler('sampling/createMessage', async request => {
|
||||
return samplingCallback(request.params) as unknown as ReturnType<typeof samplingCallback>;
|
||||
});
|
||||
|
||||
// Connect to server
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
await client.connect(transport);
|
||||
console.log('Connected!\n');
|
||||
|
||||
// List tools
|
||||
const toolsResult = await client.listTools();
|
||||
console.log(`Available tools: ${toolsResult.tools.map(t => t.name).join(', ')}`);
|
||||
|
||||
// Demo 1: Elicitation (confirm_delete)
|
||||
console.log('\n--- Demo 1: Elicitation ---');
|
||||
console.log('Calling confirm_delete tool...');
|
||||
|
||||
const confirmStream = client.experimental.tasks.callToolStream(
|
||||
{ name: 'confirm_delete', arguments: { filename: 'important.txt' } },
|
||||
{ task: { ttl: 60_000 } }
|
||||
);
|
||||
|
||||
for await (const message of confirmStream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log(`Task created: ${message.task.taskId}`);
|
||||
break;
|
||||
}
|
||||
case 'taskStatus': {
|
||||
console.log(`Task status: ${message.task.status}`);
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
const toolResult = message.result as CallToolResult;
|
||||
console.log(`Result: ${getTextContent(toolResult)}`);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
console.error(`Error: ${message.error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Demo 2: Sampling (write_haiku)
|
||||
console.log('\n--- Demo 2: Sampling ---');
|
||||
console.log('Calling write_haiku tool...');
|
||||
|
||||
const haikuStream = client.experimental.tasks.callToolStream(
|
||||
{ name: 'write_haiku', arguments: { topic: 'autumn leaves' } },
|
||||
{ task: { ttl: 60_000 } }
|
||||
);
|
||||
|
||||
for await (const message of haikuStream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log(`Task created: ${message.task.taskId}`);
|
||||
break;
|
||||
}
|
||||
case 'taskStatus': {
|
||||
console.log(`Task status: ${message.task.status}`);
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
const toolResult = message.result as CallToolResult;
|
||||
console.log(`Result:\n${getTextContent(toolResult)}`);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
console.error(`Error: ${message.error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
console.log('\nDemo complete. Closing connection...');
|
||||
await transport.close();
|
||||
readline.close();
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
let url = 'http://localhost:8000/mcp';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--url' && args[i + 1]) {
|
||||
url = args[i + 1]!;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the client
|
||||
try {
|
||||
await run(url);
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -65,16 +65,16 @@ async function main(): Promise<void> {
|
|||
console.log('[Client] Connected successfully');
|
||||
console.log('');
|
||||
|
||||
// Call the long-task tool
|
||||
console.log('[Client] Calling long-task tool...');
|
||||
console.log('[Client] Server will disconnect mid-task to demonstrate polling');
|
||||
// Call the long-operation tool
|
||||
console.log('[Client] Calling long-operation tool...');
|
||||
console.log('[Client] Server will disconnect mid-operation to demonstrate polling');
|
||||
console.log('');
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'long-task',
|
||||
name: 'long-operation',
|
||||
arguments: {}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
|
||||
| Scenario | Description | File |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) |
|
||||
| Resource-Server-only auth | Minimal OAuth RS using `mcpAuthMetadataRouter` + `requireBearerAuth` from `@modelcontextprotocol/express` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) |
|
||||
| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) |
|
||||
|
|
@ -35,8 +35,7 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) |
|
||||
| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) |
|
||||
| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) |
|
||||
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
|
||||
| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) |
|
||||
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
|
||||
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
# Simple Task Interactive Example
|
||||
|
||||
This example demonstrates the MCP Tasks message queue pattern with interactive server-to-client requests (elicitation and sampling).
|
||||
|
||||
## Overview
|
||||
|
||||
The example consists of two components:
|
||||
|
||||
1. **Server** (`simpleTaskInteractive.ts`) - Exposes two task-based tools that require client interaction:
|
||||
- `confirm_delete` - Uses elicitation to ask the user for confirmation before "deleting" a file
|
||||
- `write_haiku` - Uses sampling to request an LLM to generate a haiku on a topic
|
||||
|
||||
2. **Client** (`simpleTaskInteractiveClient.ts`) - Connects to the server and handles:
|
||||
- Elicitation requests with simple y/n terminal prompts
|
||||
- Sampling requests with a mock haiku generator
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Task-Based Execution
|
||||
|
||||
Both tools use `execution.taskSupport: 'required'`, meaning they follow the "call-now, fetch-later" pattern:
|
||||
|
||||
1. Client calls tool with `task: { ttl: 60000 }` parameter
|
||||
2. Server creates a task and returns `CreateTaskResult` immediately
|
||||
3. Client polls via `tasks/result` to get the final result
|
||||
4. Server sends elicitation/sampling requests through the task message queue
|
||||
5. Client handles requests and returns responses
|
||||
6. Server completes the task with the final result
|
||||
|
||||
### Message Queue Pattern
|
||||
|
||||
When a tool needs to interact with the client (elicitation or sampling), it:
|
||||
|
||||
1. Updates task status to `input_required`
|
||||
2. Enqueues the request in the task message queue
|
||||
3. Waits for the response via a Resolver
|
||||
4. Updates task status back to `working`
|
||||
5. Continues processing
|
||||
|
||||
The `TaskResultHandler` dequeues messages when the client calls `tasks/result` and routes responses back to waiting Resolvers.
|
||||
|
||||
## Running the Example
|
||||
|
||||
### Start the Server
|
||||
|
||||
```bash
|
||||
# From anywhere in the SDK
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts
|
||||
|
||||
# Or with a custom port
|
||||
PORT=9000 pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts
|
||||
```
|
||||
|
||||
Or, from within the `examples/server` package:
|
||||
|
||||
```bash
|
||||
cd examples/server
|
||||
pnpm tsx src/simpleTaskInteractive.ts
|
||||
|
||||
# Or with a custom port
|
||||
PORT=9000 pnpm tsx src/simpleTaskInteractive.ts
|
||||
```
|
||||
|
||||
The server will start on http://localhost:8000/mcp (or your custom port).
|
||||
|
||||
### Run the Client
|
||||
|
||||
```bash
|
||||
# From anywhere in the SDK
|
||||
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts
|
||||
|
||||
# Or connect to a different server
|
||||
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp
|
||||
```
|
||||
|
||||
Or, from within the `examples/client` package:
|
||||
|
||||
```bash
|
||||
cd examples/client
|
||||
pnpm tsx src/simpleTaskInteractiveClient.ts
|
||||
|
||||
# Or connect to a different server
|
||||
pnpm tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
### Server Output
|
||||
|
||||
```
|
||||
Starting server on http://localhost:8000/mcp
|
||||
|
||||
Available tools:
|
||||
- confirm_delete: Demonstrates elicitation (asks user y/n)
|
||||
- write_haiku: Demonstrates sampling (requests LLM completion)
|
||||
|
||||
[Server] confirm_delete called, task created: task-abc123
|
||||
[Server] confirm_delete: asking about 'important.txt'
|
||||
[Server] Sending elicitation request to client...
|
||||
[Server] tasks/result called for task task-abc123
|
||||
[Server] Delivering queued request message for task task-abc123
|
||||
[Server] Received elicitation response: action=accept, content={"confirm":true}
|
||||
[Server] Completing task with result: Deleted 'important.txt'
|
||||
|
||||
[Server] write_haiku called, task created: task-def456
|
||||
[Server] write_haiku: topic 'autumn leaves'
|
||||
[Server] Sending sampling request to client...
|
||||
[Server] tasks/result called for task task-def456
|
||||
[Server] Delivering queued request message for task task-def456
|
||||
[Server] Received sampling response: Cherry blossoms fall...
|
||||
[Server] Completing task with haiku
|
||||
```
|
||||
|
||||
### Client Output
|
||||
|
||||
```
|
||||
Simple Task Interactive Client
|
||||
==============================
|
||||
Connecting to http://localhost:8000/mcp...
|
||||
Connected!
|
||||
|
||||
Available tools: confirm_delete, write_haiku
|
||||
|
||||
--- Demo 1: Elicitation ---
|
||||
Calling confirm_delete tool...
|
||||
Task created: task-abc123
|
||||
Task status: working
|
||||
|
||||
[Elicitation] Server asks: Are you sure you want to delete 'important.txt'?
|
||||
Your response (y/n): y
|
||||
[Elicitation] Responding with: confirm=true
|
||||
Task status: input_required
|
||||
Task status: completed
|
||||
Result: Deleted 'important.txt'
|
||||
|
||||
--- Demo 2: Sampling ---
|
||||
Calling write_haiku tool...
|
||||
Task created: task-def456
|
||||
Task status: working
|
||||
|
||||
[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves
|
||||
[Sampling] Responding with haiku
|
||||
Task status: input_required
|
||||
Task status: completed
|
||||
Result:
|
||||
Haiku:
|
||||
Cherry blossoms fall
|
||||
Softly on the quiet pond
|
||||
Spring whispers goodbye
|
||||
|
||||
Demo complete. Closing connection...
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Server Components
|
||||
|
||||
- **Resolver**: Promise-like class for passing results between async operations
|
||||
- **TaskMessageQueueWithResolvers**: Extended message queue that tracks pending requests with their Resolvers
|
||||
- **TaskStoreWithNotifications**: Extended task store with notification support for status changes
|
||||
- **TaskResultHandler**: Handles `tasks/result` requests by dequeuing messages and routing responses
|
||||
- **TaskSession**: Wraps the server to enqueue requests during task execution
|
||||
|
||||
### Client Capabilities
|
||||
|
||||
The client declares these capabilities during initialization:
|
||||
|
||||
```typescript
|
||||
capabilities: {
|
||||
elicitation: { form: {} },
|
||||
sampling: {}
|
||||
}
|
||||
```
|
||||
|
||||
This tells the server that the client can handle both form-based elicitation and sampling requests.
|
||||
|
||||
## Related Files
|
||||
|
||||
- `packages/core/src/experimental/tasks/interfaces.ts` - Core task interfaces (TaskStore, TaskMessageQueue)
|
||||
- `packages/core/src/experimental/tasks/stores/in-memory.ts` - In-memory task store implementation
|
||||
- `packages/core/src/types/types.ts` - Task-related types (Task, CreateTaskResult, GetTaskRequestSchema, etc.)
|
||||
|
|
@ -5,13 +5,12 @@ import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBeare
|
|||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
PrimitiveSchemaDefinition,
|
||||
ReadResourceResult,
|
||||
ResourceLink
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { InMemoryTaskMessageQueue, InMemoryTaskStore, isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
|
@ -22,9 +21,6 @@ import { InMemoryEventStore } from './inMemoryEventStore.js';
|
|||
const useOAuth = process.argv.includes('--oauth');
|
||||
const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled');
|
||||
|
||||
// Create shared task store for demonstration
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const getServer = () => {
|
||||
const server = new McpServer(
|
||||
|
|
@ -36,12 +32,7 @@ const getServer = () => {
|
|||
},
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tasks: {
|
||||
requests: { tools: { call: {} } },
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
logging: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -439,160 +430,6 @@ const getServer = () => {
|
|||
}
|
||||
);
|
||||
|
||||
// Register a long-running tool that demonstrates task execution
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
server.experimental.tasks.registerToolTask(
|
||||
'delay',
|
||||
{
|
||||
title: 'Delay',
|
||||
description: 'A simple tool that delays for a specified duration, useful for testing task execution',
|
||||
inputSchema: z.object({
|
||||
duration: z.number().describe('Duration in milliseconds').default(5000)
|
||||
})
|
||||
},
|
||||
{
|
||||
async createTask({ duration }, ctx) {
|
||||
// Create the task
|
||||
const task = await ctx.task.store.createTask({
|
||||
ttl: ctx.task.requestedTtl
|
||||
});
|
||||
|
||||
// Simulate out-of-band work
|
||||
(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, duration));
|
||||
await ctx.task.store.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Completed ${duration}ms delay`
|
||||
}
|
||||
]
|
||||
});
|
||||
})();
|
||||
|
||||
// Return CreateTaskResult with the created task
|
||||
return {
|
||||
task
|
||||
};
|
||||
},
|
||||
async getTask(_args, ctx) {
|
||||
return await ctx.task.store.getTask(ctx.task.id);
|
||||
},
|
||||
async getTaskResult(_args, ctx) {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that demonstrates bidirectional task support:
|
||||
// Server creates a task, then elicits input from client using elicitInputStream
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
server.experimental.tasks.registerToolTask(
|
||||
'collect-user-info-task',
|
||||
{
|
||||
title: 'Collect Info with Task',
|
||||
description: 'Collects user info via elicitation with task support using elicitInputStream',
|
||||
inputSchema: z.object({
|
||||
infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact')
|
||||
})
|
||||
},
|
||||
{
|
||||
async createTask({ infoType }, ctx) {
|
||||
// Create the server-side task
|
||||
const task = await ctx.task.store.createTask({
|
||||
ttl: ctx.task.requestedTtl
|
||||
});
|
||||
|
||||
// Perform async work that makes a nested elicitation request using elicitInputStream
|
||||
(async () => {
|
||||
try {
|
||||
const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences';
|
||||
|
||||
// Define schemas with proper typing for PrimitiveSchemaDefinition
|
||||
const contactSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required: string[];
|
||||
} = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: 'Full Name', description: 'Your full name' },
|
||||
email: { type: 'string', title: 'Email', description: 'Your email address' }
|
||||
},
|
||||
required: ['name', 'email']
|
||||
};
|
||||
|
||||
const preferencesSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required: string[];
|
||||
} = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] },
|
||||
notifications: { type: 'boolean', title: 'Enable Notifications', default: true }
|
||||
},
|
||||
required: ['theme']
|
||||
};
|
||||
|
||||
const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema;
|
||||
|
||||
// Use elicitInputStream to elicit input from client
|
||||
// This demonstrates the streaming elicitation API
|
||||
// Access via server.server to get the underlying Server instance
|
||||
const stream = server.server.experimental.tasks.elicitInputStream({
|
||||
mode: 'form',
|
||||
message,
|
||||
requestedSchema
|
||||
});
|
||||
|
||||
let elicitResult: ElicitResult | undefined;
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'result') {
|
||||
elicitResult = msg.result as ElicitResult;
|
||||
} else if (msg.type === 'error') {
|
||||
throw msg.error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!elicitResult) {
|
||||
throw new Error('No result received from elicitation');
|
||||
}
|
||||
|
||||
let resultText: string;
|
||||
if (elicitResult.action === 'accept') {
|
||||
resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`;
|
||||
} else if (elicitResult.action === 'decline') {
|
||||
resultText = `User declined to provide ${infoType} information`;
|
||||
} else {
|
||||
resultText = 'User cancelled the request';
|
||||
}
|
||||
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text', text: resultText }]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in collect-user-info-task:', error);
|
||||
await taskStore.storeTaskResult(task.taskId, 'failed', {
|
||||
content: [{ type: 'text', text: `Error: ${error}` }],
|
||||
isError: true
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return { task };
|
||||
},
|
||||
async getTask(_args, ctx) {
|
||||
return await ctx.task.store.getTask(ctx.task.id);
|
||||
},
|
||||
async getTaskResult(_args, ctx) {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,758 +0,0 @@
|
|||
/**
|
||||
* Simple interactive task server demonstrating elicitation and sampling.
|
||||
*
|
||||
* This server demonstrates the task message queue pattern from the MCP Tasks spec:
|
||||
* - confirm_delete: Uses elicitation to ask the user for confirmation
|
||||
* - write_haiku: Uses sampling to request an LLM to generate content
|
||||
*
|
||||
* Both tools use the "call-now, fetch-later" pattern where the initial call
|
||||
* creates a task, and the result is fetched via tasks/result endpoint.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
CreateTaskOptions,
|
||||
CreateTaskResult,
|
||||
ElicitRequestFormParams,
|
||||
ElicitResult,
|
||||
GetTaskPayloadResult,
|
||||
GetTaskResult,
|
||||
JSONRPCRequest,
|
||||
PrimitiveSchemaDefinition,
|
||||
QueuedMessage,
|
||||
QueuedRequest,
|
||||
RequestId,
|
||||
Result,
|
||||
SamplingMessage,
|
||||
Task,
|
||||
TaskMessageQueue,
|
||||
TextContent,
|
||||
Tool
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { InMemoryTaskStore, isTerminal, RELATED_TASK_META_KEY, Server } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// Resolver - Promise-like for passing results between async operations
|
||||
// ============================================================================
|
||||
|
||||
class Resolver<T> {
|
||||
private _resolve!: (value: T) => void;
|
||||
private _reject!: (error: Error) => void;
|
||||
private _promise: Promise<T>;
|
||||
private _done = false;
|
||||
|
||||
constructor() {
|
||||
this._promise = new Promise<T>((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
setResult(value: T): void {
|
||||
if (this._done) return;
|
||||
this._done = true;
|
||||
this._resolve(value);
|
||||
}
|
||||
|
||||
setException(error: Error): void {
|
||||
if (this._done) return;
|
||||
this._done = true;
|
||||
this._reject(error);
|
||||
}
|
||||
|
||||
wait(): Promise<T> {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
done(): boolean {
|
||||
return this._done;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extended message queue with resolver support and wait functionality
|
||||
// ============================================================================
|
||||
|
||||
interface QueuedRequestWithResolver extends QueuedRequest {
|
||||
resolver?: Resolver<Record<string, unknown>>;
|
||||
originalRequestId?: RequestId;
|
||||
}
|
||||
|
||||
type QueuedMessageWithResolver = QueuedRequestWithResolver | QueuedMessage;
|
||||
|
||||
class TaskMessageQueueWithResolvers implements TaskMessageQueue {
|
||||
private queues = new Map<string, QueuedMessageWithResolver[]>();
|
||||
private waitResolvers = new Map<string, (() => void)[]>();
|
||||
|
||||
private getQueue(taskId: string): QueuedMessageWithResolver[] {
|
||||
let queue = this.queues.get(taskId);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.queues.set(taskId, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
async enqueue(taskId: string, message: QueuedMessage, _sessionId?: string, maxSize?: number): Promise<void> {
|
||||
const queue = this.getQueue(taskId);
|
||||
if (maxSize !== undefined && queue.length >= maxSize) {
|
||||
throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`);
|
||||
}
|
||||
queue.push(message);
|
||||
// Notify any waiters
|
||||
this.notifyWaiters(taskId);
|
||||
}
|
||||
|
||||
async enqueueWithResolver(
|
||||
taskId: string,
|
||||
message: JSONRPCRequest,
|
||||
resolver: Resolver<Record<string, unknown>>,
|
||||
originalRequestId: RequestId
|
||||
): Promise<void> {
|
||||
const queue = this.getQueue(taskId);
|
||||
const queuedMessage: QueuedRequestWithResolver = {
|
||||
type: 'request',
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
resolver,
|
||||
originalRequestId
|
||||
};
|
||||
queue.push(queuedMessage);
|
||||
this.notifyWaiters(taskId);
|
||||
}
|
||||
|
||||
async dequeue(taskId: string, _sessionId?: string): Promise<QueuedMessageWithResolver | undefined> {
|
||||
const queue = this.getQueue(taskId);
|
||||
return queue.shift();
|
||||
}
|
||||
|
||||
async dequeueAll(taskId: string, _sessionId?: string): Promise<QueuedMessageWithResolver[]> {
|
||||
const queue = this.queues.get(taskId) ?? [];
|
||||
this.queues.delete(taskId);
|
||||
return queue;
|
||||
}
|
||||
|
||||
async waitForMessage(taskId: string): Promise<void> {
|
||||
// Check if there are already messages
|
||||
const queue = this.getQueue(taskId);
|
||||
if (queue.length > 0) return;
|
||||
|
||||
// Wait for a message to be added
|
||||
return new Promise<void>(resolve => {
|
||||
let waiters = this.waitResolvers.get(taskId);
|
||||
if (!waiters) {
|
||||
waiters = [];
|
||||
this.waitResolvers.set(taskId, waiters);
|
||||
}
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private notifyWaiters(taskId: string): void {
|
||||
const waiters = this.waitResolvers.get(taskId);
|
||||
if (waiters) {
|
||||
this.waitResolvers.delete(taskId);
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.queues.clear();
|
||||
this.waitResolvers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extended task store with wait functionality
|
||||
// ============================================================================
|
||||
|
||||
class TaskStoreWithNotifications extends InMemoryTaskStore {
|
||||
private updateResolvers = new Map<string, (() => void)[]>();
|
||||
|
||||
override async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise<void> {
|
||||
await super.updateTaskStatus(taskId, status, statusMessage, sessionId);
|
||||
this.notifyUpdate(taskId);
|
||||
}
|
||||
|
||||
override async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void> {
|
||||
await super.storeTaskResult(taskId, status, result, sessionId);
|
||||
this.notifyUpdate(taskId);
|
||||
}
|
||||
|
||||
async waitForUpdate(taskId: string): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
let waiters = this.updateResolvers.get(taskId);
|
||||
if (!waiters) {
|
||||
waiters = [];
|
||||
this.updateResolvers.set(taskId, waiters);
|
||||
}
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private notifyUpdate(taskId: string): void {
|
||||
const waiters = this.updateResolvers.get(taskId);
|
||||
if (waiters) {
|
||||
this.updateResolvers.delete(taskId);
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Result Handler - delivers queued messages and routes responses
|
||||
// ============================================================================
|
||||
|
||||
class TaskResultHandler {
|
||||
private pendingRequests = new Map<RequestId, Resolver<Record<string, unknown>>>();
|
||||
|
||||
constructor(
|
||||
private store: TaskStoreWithNotifications,
|
||||
private queue: TaskMessageQueueWithResolvers
|
||||
) {}
|
||||
|
||||
async handle(taskId: string, server: Server, _sessionId: string): Promise<Result> {
|
||||
while (true) {
|
||||
// Get fresh task state
|
||||
const task = await this.store.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
// Dequeue and send all pending messages
|
||||
await this.deliverQueuedMessages(taskId, server, _sessionId);
|
||||
|
||||
// If task is terminal, return result
|
||||
if (isTerminal(task.status)) {
|
||||
const result = await this.store.getTaskResult(taskId);
|
||||
// Add related-task metadata per spec
|
||||
return {
|
||||
...result,
|
||||
_meta: {
|
||||
...result._meta,
|
||||
[RELATED_TASK_META_KEY]: { taskId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for task update or new message
|
||||
await this.waitForUpdate(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverQueuedMessages(taskId: string, server: Server, _sessionId: string): Promise<void> {
|
||||
while (true) {
|
||||
const message = await this.queue.dequeue(taskId);
|
||||
if (!message) break;
|
||||
|
||||
console.log(`[Server] Delivering queued ${message.type} message for task ${taskId}`);
|
||||
|
||||
if (message.type === 'request') {
|
||||
const reqMessage = message as QueuedRequestWithResolver;
|
||||
// Send the request via the server
|
||||
// Store the resolver so we can route the response back
|
||||
if (reqMessage.resolver && reqMessage.originalRequestId) {
|
||||
this.pendingRequests.set(reqMessage.originalRequestId, reqMessage.resolver);
|
||||
}
|
||||
|
||||
// Send the message - for elicitation/sampling, we use the server's methods
|
||||
// But since we're in tasks/result context, we need to send via transport
|
||||
// This is simplified - in production you'd use proper message routing
|
||||
try {
|
||||
const request = reqMessage.message;
|
||||
let response: ElicitResult | CreateMessageResult;
|
||||
|
||||
if (request.method === 'elicitation/create') {
|
||||
// Send elicitation request to client
|
||||
const params = request.params as ElicitRequestFormParams;
|
||||
response = await server.elicitInput(params);
|
||||
} else if (request.method === 'sampling/createMessage') {
|
||||
// Send sampling request to client
|
||||
const params = request.params as CreateMessageRequest['params'];
|
||||
response = await server.createMessage(params);
|
||||
} else {
|
||||
throw new Error(`Unknown request method: ${request.method}`);
|
||||
}
|
||||
|
||||
// Route response back to resolver
|
||||
if (reqMessage.resolver) {
|
||||
reqMessage.resolver.setResult(response as unknown as Record<string, unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
if (reqMessage.resolver) {
|
||||
reqMessage.resolver.setException(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// For notifications, we'd send them too but this example focuses on requests
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForUpdate(taskId: string): Promise<void> {
|
||||
// Race between store update and queue message
|
||||
await Promise.race([this.store.waitForUpdate(taskId), this.queue.waitForMessage(taskId)]);
|
||||
}
|
||||
|
||||
routeResponse(requestId: RequestId, response: Record<string, unknown>): boolean {
|
||||
const resolver = this.pendingRequests.get(requestId);
|
||||
if (resolver && !resolver.done()) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
resolver.setResult(response);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
routeError(requestId: RequestId, error: Error): boolean {
|
||||
const resolver = this.pendingRequests.get(requestId);
|
||||
if (resolver && !resolver.done()) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
resolver.setException(error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Session - wraps server to enqueue requests during task execution
|
||||
// ============================================================================
|
||||
|
||||
class TaskSession {
|
||||
private requestCounter = 0;
|
||||
|
||||
constructor(
|
||||
private server: Server,
|
||||
private taskId: string,
|
||||
private store: TaskStoreWithNotifications,
|
||||
private queue: TaskMessageQueueWithResolvers
|
||||
) {}
|
||||
|
||||
private nextRequestId(): string {
|
||||
return `task-${this.taskId}-${++this.requestCounter}`;
|
||||
}
|
||||
|
||||
async elicit(
|
||||
message: string,
|
||||
requestedSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required?: string[];
|
||||
}
|
||||
): Promise<{ action: string; content?: Record<string, unknown> }> {
|
||||
// Update task status to input_required
|
||||
await this.store.updateTaskStatus(this.taskId, 'input_required');
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
|
||||
// Build the elicitation request with related-task metadata
|
||||
const params: ElicitRequestFormParams = {
|
||||
message,
|
||||
requestedSchema,
|
||||
mode: 'form',
|
||||
_meta: {
|
||||
[RELATED_TASK_META_KEY]: { taskId: this.taskId }
|
||||
}
|
||||
};
|
||||
|
||||
const jsonrpcRequest: JSONRPCRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'elicitation/create',
|
||||
params
|
||||
};
|
||||
|
||||
// Create resolver to wait for response
|
||||
const resolver = new Resolver<Record<string, unknown>>();
|
||||
|
||||
// Enqueue the request
|
||||
await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId);
|
||||
|
||||
try {
|
||||
// Wait for response
|
||||
const response = await resolver.wait();
|
||||
|
||||
// Update status back to working
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
|
||||
return response as { action: string; content?: Record<string, unknown> };
|
||||
} catch (error) {
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createMessage(
|
||||
messages: SamplingMessage[],
|
||||
maxTokens: number
|
||||
): Promise<{ role: string; content: TextContent | { type: string } }> {
|
||||
// Update task status to input_required
|
||||
await this.store.updateTaskStatus(this.taskId, 'input_required');
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
|
||||
// Build the sampling request with related-task metadata
|
||||
const params = {
|
||||
messages,
|
||||
maxTokens,
|
||||
_meta: {
|
||||
[RELATED_TASK_META_KEY]: { taskId: this.taskId }
|
||||
}
|
||||
};
|
||||
|
||||
const jsonrpcRequest: JSONRPCRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'sampling/createMessage',
|
||||
params
|
||||
};
|
||||
|
||||
// Create resolver to wait for response
|
||||
const resolver = new Resolver<Record<string, unknown>>();
|
||||
|
||||
// Enqueue the request
|
||||
await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId);
|
||||
|
||||
try {
|
||||
// Wait for response
|
||||
const response = await resolver.wait();
|
||||
|
||||
// Update status back to working
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
|
||||
return response as { role: string; content: TextContent | { type: string } };
|
||||
} catch (error) {
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Server Setup
|
||||
// ============================================================================
|
||||
|
||||
const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8000;
|
||||
|
||||
// Create shared stores
|
||||
const taskStore = new TaskStoreWithNotifications();
|
||||
const messageQueue = new TaskMessageQueueWithResolvers();
|
||||
const taskResultHandler = new TaskResultHandler(taskStore, messageQueue);
|
||||
|
||||
// Track active task executions
|
||||
const activeTaskExecutions = new Map<
|
||||
string,
|
||||
{
|
||||
promise: Promise<void>;
|
||||
server: Server;
|
||||
sessionId: string;
|
||||
}
|
||||
>();
|
||||
|
||||
// Create the server
|
||||
const createServer = (): Server => {
|
||||
const server = new Server(
|
||||
{ name: 'simple-task-interactive', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: { call: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register tools
|
||||
server.setRequestHandler('tools/list', async (): Promise<{ tools: Tool[] }> => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'confirm_delete',
|
||||
description: 'Asks for confirmation before deleting (demonstrates elicitation)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filename: { type: 'string' }
|
||||
}
|
||||
},
|
||||
execution: { taskSupport: 'required' }
|
||||
},
|
||||
{
|
||||
name: 'write_haiku',
|
||||
description: 'Asks LLM to write a haiku (demonstrates sampling)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: { type: 'string' }
|
||||
}
|
||||
},
|
||||
execution: { taskSupport: 'required' }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
server.setRequestHandler('tools/call', async (request, ctx): Promise<CallToolResult | CreateTaskResult> => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const taskParams = (request.params._meta?.task || request.params.task) as { ttl?: number; pollInterval?: number } | undefined;
|
||||
|
||||
// Validate task mode - these tools require tasks
|
||||
if (!taskParams) {
|
||||
throw new Error(`Tool ${name} requires task mode`);
|
||||
}
|
||||
|
||||
// Create task
|
||||
const taskOptions: CreateTaskOptions = {
|
||||
ttl: taskParams.ttl,
|
||||
pollInterval: taskParams.pollInterval ?? 1000
|
||||
};
|
||||
|
||||
const task = await taskStore.createTask(taskOptions, ctx.mcpReq.id, request, ctx.sessionId);
|
||||
|
||||
console.log(`\n[Server] ${name} called, task created: ${task.taskId}`);
|
||||
|
||||
// Start background task execution
|
||||
const taskExecution = (async () => {
|
||||
try {
|
||||
const taskSession = new TaskSession(server, task.taskId, taskStore, messageQueue);
|
||||
|
||||
if (name === 'confirm_delete') {
|
||||
const filename = args?.filename ?? 'unknown.txt';
|
||||
console.log(`[Server] confirm_delete: asking about '${filename}'`);
|
||||
|
||||
console.log('[Server] Sending elicitation request to client...');
|
||||
const result = await taskSession.elicit(`Are you sure you want to delete '${filename}'?`, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
confirm: { type: 'boolean' }
|
||||
},
|
||||
required: ['confirm']
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Server] Received elicitation response: action=${result.action}, content=${JSON.stringify(result.content)}`
|
||||
);
|
||||
|
||||
let text: string;
|
||||
if (result.action === 'accept' && result.content) {
|
||||
const confirmed = result.content.confirm;
|
||||
text = confirmed ? `Deleted '${filename}'` : 'Deletion cancelled';
|
||||
} else {
|
||||
text = 'Deletion cancelled';
|
||||
}
|
||||
|
||||
console.log(`[Server] Completing task with result: ${text}`);
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text', text }]
|
||||
});
|
||||
} else if (name === 'write_haiku') {
|
||||
const topic = args?.topic ?? 'nature';
|
||||
console.log(`[Server] write_haiku: topic '${topic}'`);
|
||||
|
||||
console.log('[Server] Sending sampling request to client...');
|
||||
const result = await taskSession.createMessage(
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'text', text: `Write a haiku about ${topic}` }
|
||||
}
|
||||
],
|
||||
50
|
||||
);
|
||||
|
||||
let haiku = 'No response';
|
||||
if (result.content && 'text' in result.content) {
|
||||
haiku = (result.content as TextContent).text;
|
||||
}
|
||||
|
||||
console.log(`[Server] Received sampling response: ${haiku.slice(0, 50)}...`);
|
||||
console.log('[Server] Completing task with haiku');
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text', text: `Haiku:\n${haiku}` }]
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Server] Task ${task.taskId} failed:`, error);
|
||||
await taskStore.storeTaskResult(task.taskId, 'failed', {
|
||||
content: [{ type: 'text', text: `Error: ${error}` }],
|
||||
isError: true
|
||||
});
|
||||
} finally {
|
||||
activeTaskExecutions.delete(task.taskId);
|
||||
}
|
||||
})();
|
||||
|
||||
activeTaskExecutions.set(task.taskId, {
|
||||
promise: taskExecution,
|
||||
server,
|
||||
sessionId: ctx.sessionId ?? ''
|
||||
});
|
||||
|
||||
return { task };
|
||||
});
|
||||
|
||||
// Handle tasks/get
|
||||
server.setRequestHandler('tasks/get', async (request): Promise<GetTaskResult> => {
|
||||
const { taskId } = request.params;
|
||||
const task = await taskStore.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
return task;
|
||||
});
|
||||
|
||||
// Handle tasks/result
|
||||
server.setRequestHandler('tasks/result', async (request, ctx): Promise<GetTaskPayloadResult> => {
|
||||
const { taskId } = request.params;
|
||||
console.log(`[Server] tasks/result called for task ${taskId}`);
|
||||
return taskResultHandler.handle(taskId, server, ctx.sessionId ?? '');
|
||||
});
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Express App Setup
|
||||
// ============================================================================
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
// Map to store transports by session ID
|
||||
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
|
||||
|
||||
// Helper to check if request is initialize
|
||||
const isInitializeRequest = (body: unknown): boolean => {
|
||||
return typeof body === 'object' && body !== null && 'method' in body && (body as { method: string }).method === 'initialize';
|
||||
};
|
||||
|
||||
// MCP POST endpoint
|
||||
app.post('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
try {
|
||||
let transport: NodeStreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && transports[sessionId]) {
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: sid => {
|
||||
console.log(`Session initialized: ${sid}`);
|
||||
transports[sid] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
const server = createServer();
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
} else if (sessionId) {
|
||||
res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_001, message: 'Session not found' },
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Bad Request: Session ID required' },
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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: -32_603, message: 'Internal server error' },
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle GET requests for SSE streams
|
||||
app.get('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId) {
|
||||
res.status(400).send('Missing session ID');
|
||||
return;
|
||||
}
|
||||
if (!transports[sessionId]) {
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Handle DELETE requests for session termination
|
||||
app.delete('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId) {
|
||||
res.status(400).send('Missing session ID');
|
||||
return;
|
||||
}
|
||||
if (!transports[sessionId]) {
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Session termination request: ${sessionId}`);
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Starting server on http://localhost:${PORT}/mcp`);
|
||||
console.log('\nAvailable tools:');
|
||||
console.log(' - confirm_delete: Demonstrates elicitation (asks user y/n)');
|
||||
console.log(' - write_haiku: Demonstrates sampling (requests LLM completion)');
|
||||
});
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nShutting down server...');
|
||||
for (const sessionId of Object.keys(transports)) {
|
||||
try {
|
||||
await transports[sessionId]!.close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
taskStore.cleanup();
|
||||
messageQueue.cleanup();
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -37,14 +37,14 @@ const getServer = () => {
|
|||
|
||||
// Register a long-running tool that demonstrates server-initiated disconnect
|
||||
server.registerTool(
|
||||
'long-task',
|
||||
'long-operation',
|
||||
{
|
||||
description: 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.'
|
||||
description: 'A long-running operation that sends progress updates. Server will disconnect mid-stream to demonstrate polling.'
|
||||
},
|
||||
async (ctx): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
console.log(`[${ctx.sessionId}] Starting long-task...`);
|
||||
console.log(`[${ctx.sessionId}] Starting long-operation...`);
|
||||
|
||||
// Send first progress notification
|
||||
await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...');
|
||||
|
|
@ -70,13 +70,13 @@ const getServer = () => {
|
|||
await sleep(500);
|
||||
await ctx.mcpReq.log('info', 'Progress: 100% - Complete!');
|
||||
|
||||
console.log(`[${ctx.sessionId}] Task complete`);
|
||||
console.log(`[${ctx.sessionId}] Operation complete`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Long task completed successfully!'
|
||||
text: 'Long operation completed successfully!'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -131,5 +131,5 @@ app.listen(PORT, () => {
|
|||
console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)');
|
||||
console.log('- eventStore: InMemoryEventStore (events are persisted for replay)');
|
||||
console.log('');
|
||||
console.log('Try calling the "long-task" tool to see server-initiated disconnect in action.');
|
||||
console.log('Try calling the "long-operation" tool to see server-initiated disconnect in action.');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue