Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
- [Improving Network Efficiency with Notification Debouncing](#improving-network-efficiency-with-notification-debouncing)
- [Low-Level Server](#low-level-server)
- [Eliciting User Input](#eliciting-user-input)
- [Task-Based Execution](#task-based-execution)
- [Writing MCP Clients](#writing-mcp-clients)
- [Proxy Authorization Requests Upstream](#proxy-authorization-requests-upstream)
- [Backwards Compatibility](#backwards-compatibility)
Expand Down Expand Up @@ -1301,6 +1302,169 @@ client.setRequestHandler(ElicitRequestSchema, async request => {

**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization.

### Task-Based Execution

Task-based execution enables "call-now, fetch-later" patterns for long-running operations. This is useful for tools that take significant time to complete, where clients may want to disconnect and check on progress or retrieve results later.

Common use cases include:

- Long-running data processing or analysis
- Code migration or refactoring operations
- Complex computational tasks
- Operations that require periodic status updates

#### Server-Side: Implementing Task Support

To enable task-based execution, configure your server with a `TaskStore` implementation. The SDK doesn't provide a built-in TaskStore—you'll need to implement one backed by your database of choice:

```typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { TaskStore } from '@modelcontextprotocol/sdk/shared/task.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';

// Implement TaskStore backed by your database (e.g., PostgreSQL, Redis, etc.)
class MyTaskStore implements TaskStore {
async createTask(metadata, requestId, request) {
// Store task in your database
}

async getTask(taskId) {
// Retrieve task from your database
}

async updateTaskStatus(taskId, status, errorMessage?) {
// Update task status in your database
}

async storeTaskResult(taskId, result) {
// Store task result in your database
}

async getTaskResult(taskId) {
// Retrieve task result from your database
}
}

const taskStore = new MyTaskStore();

const server = new Server(
{
name: 'task-enabled-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
},
taskStore // Enable task support
}
);

// Set up a long-running tool handler as usual
server.setRequestHandler(CallToolRequestSchema, async request => {
if (request.params.name === 'analyze-data') {
// Simulate long-running analysis
await new Promise(resolve => setTimeout(resolve, 30000));

return {
content: [
{
type: 'text',
text: 'Analysis complete!'
}
]
};
}
throw new Error('Unknown tool');
});

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'analyze-data',
description: 'Perform data analysis (long-running)',
inputSchema: {
type: 'object',
properties: {
dataset: { type: 'string' }
}
}
}
]
}));
```

**Note**: See `src/examples/shared/inMemoryTaskStore.ts` in the SDK source for a reference implementation suitable for development and testing.

#### Client-Side: Using Task-Based Execution

Clients use `beginCallTool()` to initiate task-based operations. The returned `PendingRequest` object provides automatic polling and status tracking:

```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';

const client = new Client({
name: 'task-client',
version: '1.0.0'
});

// ... connect to server ...

// Initiate a task-based tool call
const taskId = 'analysis-task-123';
const pendingRequest = client.beginCallTool(
{
name: 'analyze-data',
arguments: { dataset: 'user-data.csv' }
},
CallToolResultSchema,
{
task: {
taskId,
keepAlive: 300000 // Keep results for 5 minutes after completion
}
}
);

// Option 1: Wait for completion with status callbacks
const result = await pendingRequest.result({
onTaskCreated: () => {
console.log('Task created successfully');
},
onTaskStatus: task => {
console.log(`Task status: ${task.status}`);
// Status can be: 'submitted', 'working', 'input_required', 'completed', 'failed', or 'cancelled'
}
});
console.log('Task completed:', result);

// Option 2: Fire and forget - disconnect and reconnect later
// (useful when you don't want to wait for long-running tasks)
// Later, after disconnecting and reconnecting to the server:
const taskStatus = await client.getTask({ taskId });
console.log('Task status:', taskStatus.status);

if (taskStatus.status === 'completed') {
const taskResult = await client.getTaskResult({ taskId }, CallToolResultSchema);
console.log('Retrieved result after reconnect:', taskResult);
}
```

#### Task Status Lifecycle

Tasks transition through the following states:

- **submitted**: Task has been created and queued
- **working**: Task is actively being processed
- **input_required**: Task is waiting for additional input (e.g., from elicitation)
- **completed**: Task finished successfully
- **failed**: Task encountered an error
- **cancelled**: Task was cancelled by the client
- **unknown**: Task status could not be determined (terminal state, rarely occurs)

The `keepAlive` parameter determines how long the server retains task results after completion. This allows clients to retrieve results even after disconnecting and reconnecting.

### Writing MCP Clients

The SDK provides a high-level client interface:
Expand Down
32 changes: 23 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"client": "tsx src/cli.ts client"
},
"dependencies": {
"@lukeed/uuid": "^2.0.1",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
Expand Down
25 changes: 25 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
import type { Transport } from '../shared/transport.js';
import { PendingRequest } from '../shared/request.js';
import { v4 as uuidv4 } from '@lukeed/uuid';
import {
type CallToolRequest,
CallToolResultSchema,
Expand Down Expand Up @@ -357,6 +359,29 @@ export class Client<
return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options);
}

/**
* Begins a tool call and returns a PendingRequest for granular control over task-based execution.
*
* This is useful when you want to create a task for a long-running tool call and poll for results later.
*/
beginCallTool(
params: CallToolRequest['params'],
resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
options?: RequestOptions
): PendingRequest<ClientRequest | RequestT, ClientNotification | NotificationT, ClientResult | ResultT> {
// Automatically add task metadata if not provided
const optionsWithTask = {
...options,
task: options?.task ?? { taskId: uuidv4() }
};
return this.beginRequest({ method: 'tools/call', params }, resultSchema, optionsWithTask);
}

/**
* Calls a tool and waits for the result. Automatically validates structured output if the tool has an outputSchema.
*
* For task-based execution with granular control, use beginCallTool() instead.
*/
async callTool(
params: CallToolRequest['params'],
resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
Expand Down
83 changes: 82 additions & 1 deletion src/examples/client/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
ElicitRequestSchema,
ResourceLink,
ReadResourceRequest,
ReadResourceResultSchema
ReadResourceResultSchema,
RELATED_TASK_META_KEY
} from '../../types.js';
import { getDisplayName } from '../../shared/metadataUtils.js';
import { Ajv } from 'ajv';
Expand Down Expand Up @@ -58,6 +59,7 @@ 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 elicitation with collect-user-info tool (contact/preferences/feedback)');
Expand Down Expand Up @@ -141,6 +143,23 @@ function commandLoop(): void {
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;
Expand Down Expand Up @@ -231,6 +250,7 @@ async function connect(url?: string): Promise<void> {
client.setRequestHandler(ElicitRequestSchema, async request => {
console.log('\n🔔 Elicitation Request Received:');
console.log(`Message: ${request.params.message}`);
console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`);
console.log('Requested Schema:');
console.log(JSON.stringify(request.params.requestedSchema, null, 2));

Expand Down Expand Up @@ -777,6 +797,67 @@ 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
const taskId = `task-${Date.now()}`;
console.log(`Task ID: ${taskId}`);
console.log('This will return immediately while processing continues in the background...');

try {
// Begin the tool call with task metadata
const pendingRequest = client.beginCallTool(
{
name,
arguments: args
},
CallToolResultSchema,
{
task: {
taskId,
keepAlive: 60000 // Keep results for 60 seconds
}
}
);

console.log('Waiting for task completion...');

let lastStatus = '';
await pendingRequest.result({
onTaskCreated: () => {
console.log('Task created successfully');
},
onTaskStatus: task => {
if (lastStatus !== task.status) {
console.log(` ${task.status}${task.error ? ` - ${task.error}` : ''}`);
}
lastStatus = task.status;
}
});

console.log('Task completed! Fetching result...');

// Get the actual result
const result = await client.getTaskResult({ taskId }, CallToolResultSchema);

console.log('Tool result:');
result.content.forEach(item => {
if (item.type === 'text') {
console.log(` ${item.text}`);
}
});
} catch (error) {
console.log(`Error with task-based execution: ${error}`);
}
}

async function cleanup(): Promise<void> {
if (client && transport) {
try {
Expand Down
Loading