[SEP-2663] refactor!: remove 2025-11 experimental tasks (#2128)

This commit is contained in:
Felix Weinberger 2026-06-02 13:04:18 +01:00 • committed by GitHub
commit c8d7401b46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 423 additions and 18280 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: {}
}
},

View file

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

View file

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

View file

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

View file

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

View file

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