Skip to content
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
16ba027
Add types for tasks
LucaButBoring Oct 22, 2025
ecef231
Implement PendingRequest and basic task API
LucaButBoring Oct 22, 2025
41f2124
Implement RelatedTask metadata sends
LucaButBoring Oct 22, 2025
a8fabb6
Implement task state management
LucaButBoring Oct 22, 2025
b3420b3
Attach related task metadata to request handler
LucaButBoring Oct 22, 2025
8e17d04
Create task before calling handler
LucaButBoring Oct 23, 2025
fcd2882
Create task example
LucaButBoring Oct 23, 2025
c73b105
Implement input_required status for tasks
LucaButBoring Oct 23, 2025
b028061
Implement unit tests for task support
LucaButBoring Oct 23, 2025
d9b72f0
Add docs for task augmentation
LucaButBoring Oct 23, 2025
5dc999f
Implement tasks/list method
LucaButBoring Oct 27, 2025
a2d65df
Merge branch 'main' of https://github.com/modelcontextprotocol/typesc…
LucaButBoring Oct 27, 2025
71a9568
Automatically execute tool calls as tasks
LucaButBoring Oct 29, 2025
30043ed
Merge branch 'main' into feat/tasks
LucaButBoring Oct 31, 2025
2167b43
Implement task API tests on both the client and server
LucaButBoring Nov 1, 2025
12d0f66
Make default task polling interval configurable
LucaButBoring Nov 1, 2025
6a28003
Merge branch 'main' into feat/tasks
LucaButBoring Nov 3, 2025
bb28ef7
Exclude relatedTask from RequestHandlerExtra
LucaButBoring Nov 3, 2025
0bf2b42
Mark tasks as cancelled only after confirming abort
LucaButBoring Nov 3, 2025
486e8ed
Store task result before attempting to respond to client
LucaButBoring Nov 3, 2025
06db603
Allow task polling before creation notification arrives
LucaButBoring Nov 3, 2025
723bc7d
Add session ID to TaskStore methods
LucaButBoring Nov 5, 2025
3789080
Implement tasks/delete
LucaButBoring Nov 5, 2025
9ae5f84
Rename pollFrequency to pollInterval
LucaButBoring Nov 5, 2025
719675a
Implement capabilities for tasks
LucaButBoring Nov 5, 2025
7a4f52b
Add taskHint for tool-level signposting
LucaButBoring Nov 5, 2025
01be32d
Only auto-add task ID if server capability is set
LucaButBoring Nov 6, 2025
0b8ced2
Correctly check peer task capabilities on receiving end
LucaButBoring Nov 6, 2025
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