SEP-1686: Tasks (#1041)

Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Luca Chang 2025-11-27 12:11:25 -08:00 • committed by GitHub
commit b9538a222a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 16166 additions and 246 deletions

201
README.md
View file

@ -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)
@ -1387,6 +1388,206 @@ const client = new Client(
);
```
### Task-Based Execution
> **⚠️ Experimental API**: Task-based execution is an experimental feature and may change without notice. Access these APIs via the `.experimental.tasks` namespace.
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/experimental';
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(taskParams, requestId, request, sessionId?): Promise<Task> {
// Generate unique taskId and lastUpdatedAt/createdAt timestamps
// Store task in your database, using the session ID as a proxy to restrict unauthorized access
// Return final Task object
}
async getTask(taskId): Promise<Task | null> {
// Retrieve task from your database
}
async updateTaskStatus(taskId, status, statusMessage?): Promise<void> {
// Update task status in your database
}
async storeTaskResult(taskId, result): Promise<void> {
// Store task result in your database
}
async getTaskResult(taskId): Promise<Result> {
// Retrieve task result from your database
}
async listTasks(cursor?, sessionId?): Promise<{ tasks: Task[]; nextCursor?: string }> {
// List tasks with pagination support
}
}
const taskStore = new MyTaskStore();
const server = new Server(
{
name: 'task-enabled-server',
version: '1.0.0'
},
{
capabilities: {
tools: {},
// Declare capabilities
tasks: {
list: {},
cancel: {},
requests: {
tools: {
// Declares support for tasks on tools/call
call: {}
}
}
}
},
taskStore // Enable task support
}
);
// Register a tool that supports tasks using the experimental API
server.experimental.tasks.registerToolTask(
'my-echo-tool',
{
title: 'My Echo Tool',
description: 'A simple task-based echo tool.',
inputSchema: {
message: z.string().describe('Message to send')
}
},
{
async createTask({ message }, { taskStore, taskRequestedTtl, requestId }) {
// Create the task
const task = await taskStore.createTask({
ttl: taskRequestedTtl
});
// Simulate out-of-band work
(async () => {
await new Promise(resolve => setTimeout(resolve, 5000));
await taskStore.storeTaskResult(task.taskId, 'completed', {
content: [
{
type: 'text',
text: message
}
]
});
})();
// Return CreateTaskResult with the created task
return { task };
},
async getTask(_args, { taskId, taskStore }) {
// Retrieve the task
return await taskStore.getTask(taskId);
},
async getTaskResult(_args, { taskId, taskStore }) {
// Retrieve the result of the task
const result = await taskStore.getTaskResult(taskId);
return result as CallToolResult;
}
}
);
```
**Note**: See `src/examples/shared/inMemoryTaskStore.ts` in the SDK source for a reference task store implementation suitable for development and testing.
#### Client-Side: Using Task-Based Execution
Clients use `experimental.tasks.callToolStream()` to initiate task-augmented tool calls. The returned `AsyncGenerator` abstracts automatic polling and status updates:
```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 ...
// Call the tool with task metadata using the experimental streaming API
const stream = client.experimental.tasks.callToolStream(
{
name: 'my-echo-tool',
arguments: { message: 'Hello, world!' }
},
CallToolResultSchema
);
// Iterate the stream and handle stream events
let taskId = '';
for await (const message of stream) {
switch (message.type) {
case 'taskCreated':
console.log('Task created successfully with ID:', message.task.taskId);
taskId = message.task.taskId;
break;
case 'taskStatus':
console.log(` ${message.task.status}${message.task.statusMessage ?? ''}`);
break;
case 'result':
console.log('Task completed! Tool result:');
message.result.content.forEach(item => {
if (item.type === 'text') {
console.log(` ${item.text}`);
}
});
break;
case 'error':
throw message.error;
}
}
// Optional: 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);
}
```
The `experimental.tasks.callToolStream()` method also works with non-task tools, making it a drop-in replacement for `callTool()` in applications that support it. When used to invoke a tool that doesn't support tasks, the `taskCreated` and `taskStatus` events will not be emitted.
#### Task Status Lifecycle
Tasks transition through the following states:
- **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
The `ttl` parameter suggests how long the server will manage the task for. If the task duration exceeds this, the server may delete the task prematurely. The client's suggested value may be overridden by the server, and the final TTL will be provided in `Task.ttl` in
`taskCreated` and `taskStatus` events.
### Writing MCP Clients
The SDK provides a high-level client interface:

View file

@ -43,6 +43,14 @@
"import": "./dist/esm/validation/cfworker-provider.js",
"require": "./dist/cjs/validation/cfworker-provider.js"
},
"./experimental": {
"import": "./dist/esm/experimental/index.js",
"require": "./dist/cjs/experimental/index.js"
},
"./experimental/tasks": {
"import": "./dist/esm/experimental/tasks/index.js",
"require": "./dist/cjs/experimental/tasks/index.js"
},
"./*": {
"import": "./dist/esm/*",
"require": "./dist/cjs/*"

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
import type { Transport } from '../shared/transport.js';
import {
type CallToolRequest,
CallToolResultSchema,
@ -38,7 +39,10 @@ import {
type Tool,
type UnsubscribeRequest,
ElicitResultSchema,
ElicitRequestSchema
ElicitRequestSchema,
CreateTaskResultSchema,
CreateMessageRequestSchema,
CreateMessageResultSchema
} from '../types.js';
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
@ -52,6 +56,8 @@ import {
type ZodV4Internal
} from '../server/zod-compat.js';
import type { RequestHandlerExtra } from '../shared/protocol.js';
import { ExperimentalClientTasks } from '../experimental/tasks/client.js';
import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.js';
/**
* Elicitation default application helper. Applies defaults to the data based on the schema.
@ -195,6 +201,9 @@ export class Client<
private _instructions?: string;
private _jsonSchemaValidator: jsonSchemaValidator;
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
private _cachedKnownTaskTools: Set<string> = new Set();
private _cachedRequiredTaskTools: Set<string> = new Set();
private _experimental?: { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> };
/**
* Initializes this client with the given name and version information.
@ -208,6 +217,22 @@ export class Client<
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator();
}
/**
* Access experimental features.
*
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
get experimental(): { tasks: ExperimentalClientTasks<RequestT, NotificationT, ResultT> } {
if (!this._experimental) {
this._experimental = {
tasks: new ExperimentalClientTasks(this)
};
}
return this._experimental;
}
/**
* Registers new capabilities. This can only be called before connecting to a transport.
*
@ -280,6 +305,20 @@ export class Client<
const result = await Promise.resolve(handler(request, extra));
// When task creation is requested, validate and return CreateTaskResult
if (params.task) {
const taskValidationResult = safeParse(CreateTaskResultSchema, result);
if (!taskValidationResult.success) {
const errorMessage =
taskValidationResult.error instanceof Error
? taskValidationResult.error.message
: String(taskValidationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
}
return taskValidationResult.data;
}
// For non-task requests, validate against ElicitResultSchema
const validationResult = safeParse(ElicitResultSchema, result);
if (!validationResult.success) {
// Type guard: if success is false, error is guaranteed to exist
@ -308,7 +347,51 @@ export class Client<
return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler);
}
// Non-elicitation handlers use default behavior
if (method === 'sampling/createMessage') {
const wrappedHandler = async (
request: SchemaOutput<T>,
extra: RequestHandlerExtra<ClientRequest | RequestT, ClientNotification | NotificationT>
): Promise<ClientResult | ResultT> => {
const validatedRequest = safeParse(CreateMessageRequestSchema, request);
if (!validatedRequest.success) {
const errorMessage =
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`);
}
const { params } = validatedRequest.data;
const result = await Promise.resolve(handler(request, extra));
// When task creation is requested, validate and return CreateTaskResult
if (params.task) {
const taskValidationResult = safeParse(CreateTaskResultSchema, result);
if (!taskValidationResult.success) {
const errorMessage =
taskValidationResult.error instanceof Error
? taskValidationResult.error.message
: String(taskValidationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
}
return taskValidationResult.data;
}
// For non-task requests, validate against CreateMessageResultSchema
const validationResult = safeParse(CreateMessageResultSchema, result);
if (!validationResult.success) {
const errorMessage =
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
throw new McpError(ErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`);
}
return validationResult.data;
};
// Install the wrapped handler
return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler);
}
// Other handlers use default behavior
return super.setRequestHandler(requestSchema, handler);
}
@ -463,6 +546,12 @@ export class Client<
}
protected assertRequestHandlerCapability(method: string): void {
// Task handlers are registered in Protocol constructor before _capabilities is initialized
// Skip capability check for task methods during initialization
if (!this._capabilities) {
return;
}
switch (method) {
case 'sampling/createMessage':
if (!this._capabilities.sampling) {
@ -482,12 +571,35 @@ export class Client<
}
break;
case 'tasks/get':
case 'tasks/list':
case 'tasks/result':
case 'tasks/cancel':
if (!this._capabilities.tasks) {
throw new Error(`Client does not support tasks capability (required for ${method})`);
}
break;
case 'ping':
// No specific capability required for ping
break;
}
}
protected assertTaskCapability(method: string): void {
assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server');
}
protected assertTaskHandlerCapability(method: string): void {
// Task handlers are registered in Protocol constructor before _capabilities is initialized
// Skip capability check for task methods during initialization
if (!this._capabilities) {
return;
}
assertClientRequestTaskCapability(this._capabilities.tasks?.requests, method, 'Client');
}
async ping(options?: RequestOptions) {
return this.request({ method: 'ping' }, EmptyResultSchema, options);
}
@ -528,11 +640,24 @@ export class Client<
return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options);
}
/**
* Calls a tool and waits for the result. Automatically validates structured output if the tool has an outputSchema.
*
* For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead.
*/
async callTool(
params: CallToolRequest['params'],
resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema,
options?: RequestOptions
) {
// Guard: required-task tools need experimental API
if (this.isToolTaskRequired(params.name)) {
throw new McpError(
ErrorCode.InvalidRequest,
`Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.`
);
}
const result = await this.request({ method: 'tools/call', params }, resultSchema, options);
// Check if the tool has an outputSchema
@ -573,12 +698,30 @@ export class Client<
return result;
}
private isToolTask(toolName: string): boolean {
if (!this._serverCapabilities?.tasks?.requests?.tools?.call) {
return false;
}
return this._cachedKnownTaskTools.has(toolName);
}
/**
* Check if a tool requires task-based execution.
* Unlike isToolTask which includes 'optional' tools, this only checks for 'required'.
*/
private isToolTaskRequired(toolName: string): boolean {
return this._cachedRequiredTaskTools.has(toolName);
}
/**
* Cache validators for tool output schemas.
* Called after listTools() to pre-compile validators for better performance.
*/
private cacheToolOutputSchemas(tools: Tool[]): void {
private cacheToolMetadata(tools: Tool[]): void {
this._cachedToolOutputValidators.clear();
this._cachedKnownTaskTools.clear();
this._cachedRequiredTaskTools.clear();
for (const tool of tools) {
// If the tool has an outputSchema, create and cache the validator
@ -586,6 +729,15 @@ export class Client<
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
this._cachedToolOutputValidators.set(tool.name, toolValidator);
}
// If the tool supports task-based execution, cache that information
const taskSupport = tool.execution?.taskSupport;
if (taskSupport === 'required' || taskSupport === 'optional') {
this._cachedKnownTaskTools.add(tool.name);
}
if (taskSupport === 'required') {
this._cachedRequiredTaskTools.add(tool.name);
}
}
}
@ -600,7 +752,7 @@ export class Client<
const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options);
// Cache the tools and their output schemas for future validation
this.cacheToolOutputSchemas(result.tools);
this.cacheToolMetadata(result.tools);
return result;
}

View file

@ -200,6 +200,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(' quit - Exit the client');
console.log();
@ -219,8 +220,10 @@ class InteractiveOAuthClient {
await this.listTools();
} else if (command.startsWith('call ')) {
await this.handleCallTool(command);
} else if (command.startsWith('stream ')) {
await this.handleStreamTool(command);
} else {
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'");
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', 'stream <tool_name>', or 'quit'");
}
} catch (error) {
if (error instanceof Error && error.message === 'SIGINT') {
@ -321,6 +324,90 @@ class InteractiveOAuthClient {
}
}
private async handleStreamTool(command: string): Promise<void> {
const parts = command.split(/\s+/);
const toolName = parts[1];
if (!toolName) {
console.log('❌ Please specify a tool name');
return;
}
// Parse arguments (simple JSON-like format)
let toolArgs: Record<string, unknown> = {};
if (parts.length > 2) {
const argsString = parts.slice(2).join(' ');
try {
toolArgs = JSON.parse(argsString);
} catch {
console.log('❌ Invalid arguments format (expected JSON)');
return;
}
}
await this.streamTool(toolName, toolArgs);
}
private async streamTool(toolName: string, toolArgs: Record<string, unknown>): Promise<void> {
if (!this.client) {
console.log('❌ Not connected to server');
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
},
CallToolResultSchema,
{
task: {
taskId: `task-${Date.now()}`,
ttl: 60000
}
}
);
// 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!');
message.result.content.forEach(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);
}
}
close(): void {
this.rl.close();
if (this.client) {

View file

@ -18,6 +18,7 @@ import {
ResourceLink,
ReadResourceRequest,
ReadResourceResultSchema,
RELATED_TASK_META_KEY,
ErrorCode,
McpError
} from '../../types.js';
@ -60,6 +61,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 form elicitation with collect-user-info tool (contact/preferences/feedback)');
@ -143,6 +145,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;
@ -238,6 +257,7 @@ async function connect(url?: string): Promise<void> {
}
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('Requested Schema:');
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
@ -784,6 +804,66 @@ 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
},
CallToolResultSchema,
{
task: {
ttl: 60000 // 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:');
message.result.content.forEach(item => {
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

@ -7,6 +7,7 @@ import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
import {
CallToolResult,
ElicitResultSchema,
GetPromptResult,
isInitializeRequest,
PrimitiveSchemaDefinition,
@ -14,6 +15,7 @@ import {
ResourceLink
} from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../experimental/tasks/stores/in-memory.js';
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
import { OAuthMetadata } from '../../shared/auth.js';
import { checkResourceAllowed } from '../../shared/auth-utils.js';
@ -24,6 +26,9 @@ import cors from 'cors';
const useOAuth = process.argv.includes('--oauth');
const strictOAuth = process.argv.includes('--oauth-strict');
// Create shared task store for demonstration
const taskStore = new InMemoryTaskStore();
// Create an MCP server with implementation details
const getServer = () => {
const server = new McpServer(
@ -33,7 +38,11 @@ const getServer = () => {
icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }],
websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk'
},
{ capabilities: { logging: {} } }
{
capabilities: { logging: {}, tasks: { requests: { tools: { call: {} } } } },
taskStore, // Enable task support
taskMessageQueue: new InMemoryTaskMessageQueue()
}
);
// Register a simple tool that returns a greeting
@ -119,7 +128,7 @@ const getServer = () => {
{
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect')
},
async ({ infoType }): Promise<CallToolResult> => {
async ({ infoType }, extra): Promise<CallToolResult> => {
let message: string;
let requestedSchema: {
type: 'object';
@ -214,12 +223,18 @@ const getServer = () => {
}
try {
// Use the underlying server instance to elicit input from the client
const result = await server.server.elicitInput({
mode: 'form',
message,
requestedSchema
});
// Use sendRequest through the extra parameter to elicit input
const result = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
mode: 'form',
message,
requestedSchema
}
},
ElicitResultSchema
);
if (result.action === 'accept') {
return {
@ -440,6 +455,52 @@ 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: {
duration: z.number().describe('Duration in milliseconds').default(5000)
}
},
{
async createTask({ duration }, { taskStore, taskRequestedTtl }) {
// Create the task
const task = await taskStore.createTask({
ttl: taskRequestedTtl
});
// Simulate out-of-band work
(async () => {
await new Promise(resolve => setTimeout(resolve, duration));
await taskStore.storeTaskResult(task.taskId, 'completed', {
content: [
{
type: 'text',
text: `Completed ${duration}ms delay`
}
]
});
})();
// Return CreateTaskResult with the created task
return {
task
};
},
async getTask(_args, { taskId, taskStore }) {
return await taskStore.getTask(taskId);
},
async getTaskResult(_args, { taskId, taskStore }) {
const result = await taskStore.getTaskResult(taskId);
return result as CallToolResult;
}
}
);
return server;
};

13
src/experimental/index.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Experimental MCP SDK features.
* WARNING: These APIs are experimental and may change without notice.
*
* Import experimental features from this module:
* ```typescript
* import { TaskStore, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental';
* ```
*
* @experimental
*/
export * from './tasks/index.js';

View file

@ -0,0 +1,264 @@
/**
* Experimental client task features for MCP SDK.
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
import type { Client } from '../../client/index.js';
import type { RequestOptions } from '../../shared/protocol.js';
import type { ResponseMessage } from '../../shared/responseMessage.js';
import type { AnyObjectSchema, SchemaOutput } from '../../server/zod-compat.js';
import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '../../types.js';
import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '../../types.js';
import type { GetTaskResult, ListTasksResult, CancelTaskResult } from './types.js';
/**
* Internal interface for accessing Client's private methods.
* @internal
*/
interface ClientInternal<RequestT extends Request> {
requestStream<T extends AnyObjectSchema>(
request: ClientRequest | RequestT,
resultSchema: T,
options?: RequestOptions
): AsyncGenerator<ResponseMessage<SchemaOutput<T>>, void, void>;
isToolTask(toolName: string): boolean;
getToolOutputValidator(toolName: string): ((data: unknown) => { valid: boolean; errorMessage?: string }) | undefined;
}
/**
* Experimental task features for MCP clients.
*
* Access via `client.experimental.tasks`:
* ```typescript
* const stream = client.experimental.tasks.callToolStream({ name: 'tool', arguments: {} });
* const task = await client.experimental.tasks.getTask(taskId);
* ```
*
* @experimental
*/
export class ExperimentalClientTasks<
RequestT extends Request = Request,
NotificationT extends Notification = Notification,
ResultT extends Result = Result
> {
constructor(private readonly _client: Client<RequestT, NotificationT, ResultT>) {}
/**
* Calls a tool and returns an AsyncGenerator that yields response messages.
* The generator is guaranteed to end with either a 'result' or 'error' message.
*
* This method provides streaming access to tool execution, allowing you to
* observe intermediate task status updates for long-running tool calls.
* Automatically validates structured output if the tool has an outputSchema.
*
* @example
* ```typescript
* const stream = client.experimental.tasks.callToolStream({ name: 'myTool', arguments: {} });
* for await (const message of stream) {
* switch (message.type) {
* case 'taskCreated':
* console.log('Tool execution started:', message.task.taskId);
* break;
* case 'taskStatus':
* console.log('Tool status:', message.task.status);
* break;
* case 'result':
* console.log('Tool result:', message.result);
* break;
* case 'error':
* console.error('Tool error:', message.error);
* break;
* }
* }
* ```
*
* @param params - Tool call parameters (name and arguments)
* @param resultSchema - Zod schema for validating the result (defaults to CallToolResultSchema)
* @param options - Optional request options (timeout, signal, task creation params, etc.)
* @returns AsyncGenerator that yields ResponseMessage objects
*
* @experimental
*/
async *callToolStream<T extends typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema>(
params: CallToolRequest['params'],
resultSchema: T = CallToolResultSchema as T,
options?: RequestOptions
): AsyncGenerator<ResponseMessage<SchemaOutput<T>>, void, void> {
// Access Client's internal methods
const clientInternal = this._client as unknown as ClientInternal<RequestT>;
// Add task creation parameters if server supports it and not explicitly provided
const optionsWithTask = {
...options,
// We check if the tool is known to be a task during auto-configuration, but assume
// the caller knows what they're doing if they pass this explicitly
task: options?.task ?? (clientInternal.isToolTask(params.name) ? {} : undefined)
};
const stream = clientInternal.requestStream({ method: 'tools/call', params }, resultSchema, optionsWithTask);
// Get the validator for this tool (if it has an output schema)
const validator = clientInternal.getToolOutputValidator(params.name);
// Iterate through the stream and validate the final result if needed
for await (const message of stream) {
// If this is a result message and the tool has an output schema, validate it
if (message.type === 'result' && validator) {
const result = message.result;
// If tool has outputSchema, it MUST return structuredContent (unless it's an error)
if (!result.structuredContent && !result.isError) {
yield {
type: 'error',
error: new McpError(
ErrorCode.InvalidRequest,
`Tool ${params.name} has an output schema but did not return structured content`
)
};
return;
}
// Only validate structured content if present (not when there's an error)
if (result.structuredContent) {
try {
// Validate the structured content against the schema
const validationResult = validator(result.structuredContent);
if (!validationResult.valid) {
yield {
type: 'error',
error: new McpError(
ErrorCode.InvalidParams,
`Structured content does not match the tool's output schema: ${validationResult.errorMessage}`
)
};
return;
}
} catch (error) {
if (error instanceof McpError) {
yield { type: 'error', error };
return;
}
yield {
type: 'error',
error: new McpError(
ErrorCode.InvalidParams,
`Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`
)
};
return;
}
}
}
// Yield the message (either validated result or any other message type)
yield message;
}
}
/**
* Gets the current status of a task.
*
* @param taskId - The task identifier
* @param options - Optional request options
* @returns The task status
*
* @experimental
*/
async getTask(taskId: string, options?: RequestOptions): Promise<GetTaskResult> {
// Delegate to the client's underlying Protocol method
type ClientWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise<GetTaskResult> };
return (this._client as unknown as ClientWithGetTask).getTask({ taskId }, options);
}
/**
* Retrieves the result of a completed task.
*
* @param taskId - The task identifier
* @param resultSchema - Zod schema for validating the result
* @param options - Optional request options
* @returns The task result
*
* @experimental
*/
async getTaskResult<T extends AnyObjectSchema>(taskId: string, resultSchema?: T, options?: RequestOptions): Promise<SchemaOutput<T>> {
// Delegate to the client's underlying Protocol method
return (
this._client as unknown as {
getTaskResult: <U extends AnyObjectSchema>(
params: { taskId: string },
resultSchema?: U,
options?: RequestOptions
) => Promise<SchemaOutput<U>>;
}
).getTaskResult({ taskId }, resultSchema, options);
}
/**
* Lists tasks with optional pagination.
*
* @param cursor - Optional pagination cursor
* @param options - Optional request options
* @returns List of tasks with optional next cursor
*
* @experimental
*/
async listTasks(cursor?: string, options?: RequestOptions): Promise<ListTasksResult> {
// Delegate to the client's underlying Protocol method
return (
this._client as unknown as {
listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise<ListTasksResult>;
}
).listTasks(cursor ? { cursor } : undefined, options);
}
/**
* Cancels a running task.
*
* @param taskId - The task identifier
* @param options - Optional request options
*
* @experimental
*/
async cancelTask(taskId: string, options?: RequestOptions): Promise<CancelTaskResult> {
// Delegate to the client's underlying Protocol method
return (
this._client as unknown as {
cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise<CancelTaskResult>;
}
).cancelTask({ taskId }, options);
}
/**
* Sends a request and returns an AsyncGenerator that yields response messages.
* The generator is guaranteed to end with either a 'result' or 'error' message.
*
* This method provides streaming access to request processing, allowing you to
* observe intermediate task status updates for task-augmented requests.
*
* @param request - The request to send
* @param resultSchema - Zod schema for validating the result
* @param options - Optional request options (timeout, signal, task creation params, etc.)
* @returns AsyncGenerator that yields ResponseMessage objects
*
* @experimental
*/
requestStream<T extends AnyObjectSchema>(
request: ClientRequest | RequestT,
resultSchema: T,
options?: RequestOptions
): AsyncGenerator<ResponseMessage<SchemaOutput<T>>, void, void> {
// Delegate to the client's underlying Protocol method
type ClientWithRequestStream = {
requestStream<U extends AnyObjectSchema>(
request: ClientRequest | RequestT,
resultSchema: U,
options?: RequestOptions
): AsyncGenerator<ResponseMessage<SchemaOutput<U>>, void, void>;
};
return (this._client as unknown as ClientWithRequestStream).requestStream(request, resultSchema, options);
}
}

View file

@ -0,0 +1,88 @@
/**
* Experimental task capability assertion helpers.
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
/**
* Type representing the task requests capability structure.
* This is derived from ClientTasksCapability.requests and ServerTasksCapability.requests.
*/
interface TaskRequestsCapability {
tools?: { call?: object };
sampling?: { createMessage?: object };
elicitation?: { create?: object };
}
/**
* Asserts that task creation is supported for tools/call.
* Used by Client.assertTaskCapability and Server.assertTaskHandlerCapability.
*
* @param requests - The task requests capability object
* @param method - The method being checked
* @param entityName - 'Server' or 'Client' for error messages
* @throws Error if the capability is not supported
*
* @experimental
*/
export function assertToolsCallTaskCapability(
requests: TaskRequestsCapability | undefined,
method: string,
entityName: 'Server' | 'Client'
): void {
if (!requests) {
throw new Error(`${entityName} does not support task creation (required for ${method})`);
}
switch (method) {
case 'tools/call':
if (!requests.tools?.call) {
throw new Error(`${entityName} does not support task creation for tools/call (required for ${method})`);
}
break;
default:
// Method doesn't support tasks, which is fine - no error
break;
}
}
/**
* Asserts that task creation is supported for sampling/createMessage or elicitation/create.
* Used by Server.assertTaskCapability and Client.assertTaskHandlerCapability.
*
* @param requests - The task requests capability object
* @param method - The method being checked
* @param entityName - 'Server' or 'Client' for error messages
* @throws Error if the capability is not supported
*
* @experimental
*/
export function assertClientRequestTaskCapability(
requests: TaskRequestsCapability | undefined,
method: string,
entityName: 'Server' | 'Client'
): void {
if (!requests) {
throw new Error(`${entityName} does not support task creation (required for ${method})`);
}
switch (method) {
case 'sampling/createMessage':
if (!requests.sampling?.createMessage) {
throw new Error(`${entityName} does not support task creation for sampling/createMessage (required for ${method})`);
}
break;
case 'elicitation/create':
if (!requests.elicitation?.create) {
throw new Error(`${entityName} does not support task creation for elicitation/create (required for ${method})`);
}
break;
default:
// Method doesn't support tasks, which is fine - no error
break;
}
}

View file

@ -0,0 +1,34 @@
/**
* Experimental task features for MCP SDK.
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
// Re-export spec types for convenience
export * from './types.js';
// SDK implementation interfaces
export * from './interfaces.js';
// Assertion helpers
export * from './helpers.js';
// Wrapper classes
export * from './client.js';
export * from './server.js';
export * from './mcp-server.js';
// Store implementations
export * from './stores/in-memory.js';
// Re-export response message types for task streaming
export type {
ResponseMessage,
TaskStatusMessage,
TaskCreatedMessage,
ResultMessage,
ErrorMessage,
BaseResponseMessage
} from '../../shared/responseMessage.js';
export { takeResult, toArrayAsync } from '../../shared/responseMessage.js';

View file

@ -0,0 +1,289 @@
/**
* Experimental task interfaces for MCP SDK.
* WARNING: These APIs are experimental and may change without notice.
*/
import {
Task,
Request,
RequestId,
Result,
JSONRPCRequest,
JSONRPCNotification,
JSONRPCResponse,
JSONRPCError,
ServerRequest,
ServerNotification,
CallToolResult,
GetTaskResult,
ToolExecution
} from '../../types.js';
import { CreateTaskResult } from './types.js';
import type { RequestHandlerExtra, RequestTaskStore } from '../../shared/protocol.js';
import type { ZodRawShapeCompat, AnySchema, ShapeOutput } from '../../server/zod-compat.js';
// ============================================================================
// Task Handler Types (for registerToolTask)
// ============================================================================
/**
* Extended handler extra with task store for task creation.
* @experimental
*/
export interface CreateTaskRequestHandlerExtra extends RequestHandlerExtra<ServerRequest, ServerNotification> {
taskStore: RequestTaskStore;
}
/**
* Extended handler extra with task ID and store for task operations.
* @experimental
*/
export interface TaskRequestHandlerExtra extends RequestHandlerExtra<ServerRequest, ServerNotification> {
taskId: string;
taskStore: RequestTaskStore;
}
/**
* Base callback type for tool handlers.
* @experimental
*/
export type BaseToolCallback<
SendResultT extends Result,
ExtraT extends RequestHandlerExtra<ServerRequest, ServerNotification>,
Args extends undefined | ZodRawShapeCompat | AnySchema = undefined
> = Args extends ZodRawShapeCompat
? (args: ShapeOutput<Args>, extra: ExtraT) => SendResultT | Promise<SendResultT>
: Args extends AnySchema
? (args: unknown, extra: ExtraT) => SendResultT | Promise<SendResultT>
: (extra: ExtraT) => SendResultT | Promise<SendResultT>;
/**
* Handler for creating a task.
* @experimental
*/
export type CreateTaskRequestHandler<
SendResultT extends Result,
Args extends undefined | ZodRawShapeCompat | AnySchema = undefined
> = BaseToolCallback<SendResultT, CreateTaskRequestHandlerExtra, Args>;
/**
* Handler for task operations (get, getResult).
* @experimental
*/
export type TaskRequestHandler<
SendResultT extends Result,
Args extends undefined | ZodRawShapeCompat | AnySchema = undefined
> = BaseToolCallback<SendResultT, TaskRequestHandlerExtra, Args>;
/**
* Interface for task-based tool handlers.
* @experimental
*/
export interface ToolTaskHandler<Args extends undefined | ZodRawShapeCompat | AnySchema = undefined> {
createTask: CreateTaskRequestHandler<CreateTaskResult, Args>;
getTask: TaskRequestHandler<GetTaskResult, Args>;
getTaskResult: TaskRequestHandler<CallToolResult, Args>;
}
/**
* Task-specific execution configuration.
* taskSupport cannot be 'forbidden' for task-based tools.
* @experimental
*/
export type TaskToolExecution<TaskSupport = ToolExecution['taskSupport']> = Omit<ToolExecution, 'taskSupport'> & {
taskSupport: TaskSupport extends 'forbidden' | undefined ? never : TaskSupport;
};
/**
* Represents a message queued for side-channel delivery via tasks/result.
*
* This is a serializable data structure that can be stored in external systems.
* All fields are JSON-serializable.
*/
export type QueuedMessage = QueuedRequest | QueuedNotification | QueuedResponse | QueuedError;
export interface BaseQueuedMessage {
/** Type of message */
type: string;
/** When the message was queued (milliseconds since epoch) */
timestamp: number;
}
export interface QueuedRequest extends BaseQueuedMessage {
type: 'request';
/** The actual JSONRPC request */
message: JSONRPCRequest;
}
export interface QueuedNotification extends BaseQueuedMessage {
type: 'notification';
/** The actual JSONRPC notification */
message: JSONRPCNotification;
}
export interface QueuedResponse extends BaseQueuedMessage {
type: 'response';
/** The actual JSONRPC response */
message: JSONRPCResponse;
}
export interface QueuedError extends BaseQueuedMessage {
type: 'error';
/** The actual JSONRPC error */
message: JSONRPCError;
}
/**
* Interface for managing per-task FIFO message queues.
*
* Similar to TaskStore, this allows pluggable queue implementations
* (in-memory, Redis, other distributed queues, etc.).
*
* Each method accepts taskId and optional sessionId parameters to enable
* a single queue instance to manage messages for multiple tasks, with
* isolation based on task ID and session ID.
*
* All methods are async to support external storage implementations.
* All data in QueuedMessage must be JSON-serializable.
*
* @experimental
*/
export interface TaskMessageQueue {
/**
* Adds a message to the end of the queue for a specific task.
* Atomically checks queue size and throws if maxSize would be exceeded.
* @param taskId The task identifier
* @param message The message to enqueue
* @param sessionId Optional session ID for binding the operation to a specific session
* @param maxSize Optional maximum queue size - if specified and queue is full, throws an error
* @throws Error if maxSize is specified and would be exceeded
*/
enqueue(taskId: string, message: QueuedMessage, sessionId?: string, maxSize?: number): Promise<void>;
/**
* Removes and returns the first message from the queue for a specific task.
* @param taskId The task identifier
* @param sessionId Optional session ID for binding the query to a specific session
* @returns The first message, or undefined if the queue is empty
*/
dequeue(taskId: string, sessionId?: string): Promise<QueuedMessage | undefined>;
/**
* Removes and returns all messages from the queue for a specific task.
* Used when tasks are cancelled or failed to clean up pending messages.
* @param taskId The task identifier
* @param sessionId Optional session ID for binding the query to a specific session
* @returns Array of all messages that were in the queue
*/
dequeueAll(taskId: string, sessionId?: string): Promise<QueuedMessage[]>;
}
/**
* Task creation options.
* @experimental
*/
export interface CreateTaskOptions {
/**
* Time in milliseconds to keep task results available after completion.
* If null, the task has unlimited lifetime until manually cleaned up.
*/
ttl?: number | null;
/**
* Time in milliseconds to wait between task status requests.
*/
pollInterval?: number;
/**
* Additional context to pass to the task store.
*/
context?: Record<string, unknown>;
}
/**
* Interface for storing and retrieving task state and results.
*
* Similar to Transport, this allows pluggable task storage implementations
* (in-memory, database, distributed cache, etc.).
*
* @experimental
*/
export interface TaskStore {
/**
* Creates a new task with the given creation parameters and original request.
* The implementation must generate a unique taskId and createdAt timestamp.
*
* TTL Management:
* - The implementation receives the TTL suggested by the requestor via taskParams.ttl
* - The implementation MAY override the requested TTL (e.g., to enforce limits)
* - The actual TTL used MUST be returned in the Task object
* - Null TTL indicates unlimited task lifetime (no automatic cleanup)
* - Cleanup SHOULD occur automatically after TTL expires, regardless of task status
*
* @param taskParams - The task creation parameters from the request (ttl, pollInterval)
* @param requestId - The JSON-RPC request ID
* @param request - The original request that triggered task creation
* @param sessionId - Optional session ID for binding the task to a specific session
* @returns The created task object
*/
createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, sessionId?: string): Promise<Task>;
/**
* Gets the current status of a task.
*
* @param taskId - The task identifier
* @param sessionId - Optional session ID for binding the query to a specific session
* @returns The task object, or null if it does not exist
*/
getTask(taskId: string, sessionId?: string): Promise<Task | null>;
/**
* Stores the result of a task and sets its final status.
*
* @param taskId - The task identifier
* @param status - The final status: 'completed' for success, 'failed' for errors
* @param result - The result to store
* @param sessionId - Optional session ID for binding the operation to a specific session
*/
storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void>;
/**
* Retrieves the stored result of a task.
*
* @param taskId - The task identifier
* @param sessionId - Optional session ID for binding the query to a specific session
* @returns The stored result
*/
getTaskResult(taskId: string, sessionId?: string): Promise<Result>;
/**
* Updates a task's status (e.g., to 'cancelled', 'failed', 'completed').
*
* @param taskId - The task identifier
* @param status - The new status
* @param statusMessage - Optional diagnostic message for failed tasks or other status information
* @param sessionId - Optional session ID for binding the operation to a specific session
*/
updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise<void>;
/**
* Lists tasks, optionally starting from a pagination cursor.
*
* @param cursor - Optional cursor for pagination
* @param sessionId - Optional session ID for binding the query to a specific session
* @returns An object containing the tasks array and an optional nextCursor
*/
listTasks(cursor?: string, sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }>;
}
/**
* Checks if a task status represents a terminal state.
* Terminal states are those where the task has finished and will not change.
*
* @param status - The task status to check
* @returns True if the status is terminal (completed, failed, or cancelled)
* @experimental
*/
export function isTerminal(status: Task['status']): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
}

View file

@ -0,0 +1,142 @@
/**
* Experimental McpServer task features for MCP SDK.
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js';
import type { ZodRawShapeCompat, AnySchema } from '../../server/zod-compat.js';
import type { ToolAnnotations, ToolExecution } from '../../types.js';
import type { ToolTaskHandler, TaskToolExecution } from './interfaces.js';
/**
* Internal interface for accessing McpServer's private _createRegisteredTool method.
* @internal
*/
interface McpServerInternal {
_createRegisteredTool(
name: string,
title: string | undefined,
description: string | undefined,
inputSchema: ZodRawShapeCompat | AnySchema | undefined,
outputSchema: ZodRawShapeCompat | AnySchema | undefined,
annotations: ToolAnnotations | undefined,
execution: ToolExecution | undefined,
_meta: Record<string, unknown> | undefined,
handler: AnyToolHandler<ZodRawShapeCompat | undefined>
): RegisteredTool;
}
/**
* Experimental task features for McpServer.
*
* Access via `server.experimental.tasks`:
* ```typescript
* server.experimental.tasks.registerToolTask('long-running', config, handler);
* ```
*
* @experimental
*/
export class ExperimentalMcpServerTasks {
constructor(private readonly _mcpServer: McpServer) {}
/**
* Registers a task-based tool with a config object and handler.
*
* Task-based tools support long-running operations that can be polled for status
* and results. The handler must implement `createTask`, `getTask`, and `getTaskResult`
* methods.
*
* @example
* ```typescript
* server.experimental.tasks.registerToolTask('long-computation', {
* description: 'Performs a long computation',
* inputSchema: { input: z.string() },
* execution: { taskSupport: 'required' }
* }, {
* createTask: async (args, extra) => {
* const task = await extra.taskStore.createTask({ ttl: 300000 });
* startBackgroundWork(task.taskId, args);
* return { task };
* },
* getTask: async (args, extra) => {
* return extra.taskStore.getTask(extra.taskId);
* },
* getTaskResult: async (args, extra) => {
* return extra.taskStore.getTaskResult(extra.taskId);
* }
* });
* ```
*
* @param name - The tool name
* @param config - Tool configuration (description, schemas, etc.)
* @param handler - Task handler with createTask, getTask, getTaskResult methods
* @returns RegisteredTool for managing the tool's lifecycle
*
* @experimental
*/
registerToolTask<OutputArgs extends undefined | ZodRawShapeCompat | AnySchema>(
name: string,
config: {
title?: string;
description?: string;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
execution?: TaskToolExecution;
_meta?: Record<string, unknown>;
},
handler: ToolTaskHandler<undefined>
): RegisteredTool;
registerToolTask<InputArgs extends ZodRawShapeCompat | AnySchema, OutputArgs extends undefined | ZodRawShapeCompat | AnySchema>(
name: string,
config: {
title?: string;
description?: string;
inputSchema: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
execution?: TaskToolExecution;
_meta?: Record<string, unknown>;
},
handler: ToolTaskHandler<InputArgs>
): RegisteredTool;
registerToolTask<
InputArgs extends undefined | ZodRawShapeCompat | AnySchema,
OutputArgs extends undefined | ZodRawShapeCompat | AnySchema
>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
execution?: TaskToolExecution;
_meta?: Record<string, unknown>;
},
handler: ToolTaskHandler<InputArgs>
): RegisteredTool {
// Validate that taskSupport is not 'forbidden' for task-based tools
const execution: ToolExecution = { taskSupport: 'required', ...config.execution };
if (execution.taskSupport === 'forbidden') {
throw new Error(`Cannot register task-based tool '${name}' with taskSupport 'forbidden'. Use registerTool() instead.`);
}
// Access McpServer's internal _createRegisteredTool method
const mcpServerInternal = this._mcpServer as unknown as McpServerInternal;
return mcpServerInternal._createRegisteredTool(
name,
config.title,
config.description,
config.inputSchema,
config.outputSchema,
config.annotations,
execution,
config._meta,
handler as AnyToolHandler<ZodRawShapeCompat | undefined>
);
}
}

View file

@ -0,0 +1,131 @@
/**
* Experimental server task features for MCP SDK.
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
import type { Server } from '../../server/index.js';
import type { RequestOptions } from '../../shared/protocol.js';
import type { ResponseMessage } from '../../shared/responseMessage.js';
import type { AnySchema, SchemaOutput } from '../../server/zod-compat.js';
import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '../../types.js';
/**
* Experimental task features for low-level MCP servers.
*
* Access via `server.experimental.tasks`:
* ```typescript
* const stream = server.experimental.tasks.requestStream(request, schema, options);
* ```
*
* For high-level server usage with task-based tools, use `McpServer.experimental.tasks` instead.
*
* @experimental
*/
export class ExperimentalServerTasks<
RequestT extends Request = Request,
NotificationT extends Notification = Notification,
ResultT extends Result = Result
> {
constructor(private readonly _server: Server<RequestT, NotificationT, ResultT>) {}
/**
* Sends a request and returns an AsyncGenerator that yields response messages.
* The generator is guaranteed to end with either a 'result' or 'error' message.
*
* This method provides streaming access to request processing, allowing you to
* observe intermediate task status updates for task-augmented requests.
*
* @param request - The request to send
* @param resultSchema - Zod schema for validating the result
* @param options - Optional request options (timeout, signal, task creation params, etc.)
* @returns AsyncGenerator that yields ResponseMessage objects
*
* @experimental
*/
requestStream<T extends AnySchema>(
request: ServerRequest | RequestT,
resultSchema: T,
options?: RequestOptions
): AsyncGenerator<ResponseMessage<SchemaOutput<T>>, void, void> {
// Delegate to the server's underlying Protocol method
type ServerWithRequestStream = {
requestStream<U extends AnySchema>(
request: ServerRequest | RequestT,
resultSchema: U,
options?: RequestOptions
): AsyncGenerator<ResponseMessage<SchemaOutput<U>>, void, void>;
};
return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options);
}
/**
* Gets the current status of a task.
*
* @param taskId - The task identifier
* @param options - Optional request options
* @returns The task status
*
* @experimental
*/
async getTask(taskId: string, options?: RequestOptions): Promise<GetTaskResult> {
type ServerWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise<GetTaskResult> };
return (this._server as unknown as ServerWithGetTask).getTask({ taskId }, options);
}
/**
* Retrieves the result of a completed task.
*
* @param taskId - The task identifier
* @param resultSchema - Zod schema for validating the result
* @param options - Optional request options
* @returns The task result
*
* @experimental
*/
async getTaskResult<T extends AnySchema>(taskId: string, resultSchema?: T, options?: RequestOptions): Promise<SchemaOutput<T>> {
return (
this._server as unknown as {
getTaskResult: <U extends AnySchema>(
params: { taskId: string },
resultSchema?: U,
options?: RequestOptions
) => Promise<SchemaOutput<U>>;
}
).getTaskResult({ taskId }, resultSchema, options);
}
/**
* Lists tasks with optional pagination.
*
* @param cursor - Optional pagination cursor
* @param options - Optional request options
* @returns List of tasks with optional next cursor
*
* @experimental
*/
async listTasks(cursor?: string, options?: RequestOptions): Promise<ListTasksResult> {
return (
this._server as unknown as {
listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise<ListTasksResult>;
}
).listTasks(cursor ? { cursor } : undefined, options);
}
/**
* Cancels a running task.
*
* @param taskId - The task identifier
* @param options - Optional request options
*
* @experimental
*/
async cancelTask(taskId: string, options?: RequestOptions): Promise<CancelTaskResult> {
return (
this._server as unknown as {
cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise<CancelTaskResult>;
}
).cancelTask({ taskId }, options);
}
}

View file

@ -0,0 +1,936 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from './in-memory.js';
import { TaskCreationParams, Request } from '../../../types.js';
import { QueuedMessage } from '../interfaces.js';
describe('InMemoryTaskStore', () => {
let store: InMemoryTaskStore;
beforeEach(() => {
store = new InMemoryTaskStore();
});
afterEach(() => {
store.cleanup();
});
describe('createTask', () => {
it('should create a new task with working status', async () => {
const taskParams: TaskCreationParams = {
ttl: 60000
};
const request: Request = {
method: 'tools/call',
params: { name: 'test-tool' }
};
const task = await store.createTask(taskParams, 123, request);
expect(task).toBeDefined();
expect(task.taskId).toBeDefined();
expect(typeof task.taskId).toBe('string');
expect(task.taskId.length).toBeGreaterThan(0);
expect(task.status).toBe('working');
expect(task.ttl).toBe(60000);
expect(task.pollInterval).toBeDefined();
expect(task.createdAt).toBeDefined();
expect(new Date(task.createdAt).getTime()).toBeGreaterThan(0);
});
it('should create task without ttl', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const task = await store.createTask(taskParams, 456, request);
expect(task).toBeDefined();
expect(task.ttl).toBeNull();
});
it('should generate unique taskIds', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const task1 = await store.createTask(taskParams, 789, request);
const task2 = await store.createTask(taskParams, 790, request);
expect(task1.taskId).not.toBe(task2.taskId);
});
});
describe('getTask', () => {
it('should return null for non-existent task', async () => {
const task = await store.getTask('non-existent');
expect(task).toBeNull();
});
it('should return task state', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const createdTask = await store.createTask(taskParams, 111, request);
await store.updateTaskStatus(createdTask.taskId, 'working');
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
expect(task?.status).toBe('working');
});
});
describe('updateTaskStatus', () => {
let taskId: string;
beforeEach(async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 222, {
method: 'tools/call',
params: {}
});
taskId = createdTask.taskId;
});
it('should keep task status as working', async () => {
const task = await store.getTask(taskId);
expect(task?.status).toBe('working');
});
it('should update task status to input_required', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const task = await store.getTask(taskId);
expect(task?.status).toBe('input_required');
});
it('should update task status to completed', async () => {
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should update task status to failed with error', async () => {
await store.updateTaskStatus(taskId, 'failed', 'Something went wrong');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
expect(task?.statusMessage).toBe('Something went wrong');
});
it('should update task status to cancelled', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should throw if task not found', async () => {
await expect(store.updateTaskStatus('non-existent', 'working')).rejects.toThrow('Task with ID non-existent not found');
});
describe('status lifecycle validation', () => {
it('should allow transition from working to input_required', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const task = await store.getTask(taskId);
expect(task?.status).toBe('input_required');
});
it('should allow transition from working to completed', async () => {
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should allow transition from working to failed', async () => {
await store.updateTaskStatus(taskId, 'failed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
});
it('should allow transition from working to cancelled', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should allow transition from input_required to working', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'working');
const task = await store.getTask(taskId);
expect(task?.status).toBe('working');
});
it('should allow transition from input_required to completed', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should allow transition from input_required to failed', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'failed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
});
it('should allow transition from input_required to cancelled', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should reject transition from completed to any other status', async () => {
await store.updateTaskStatus(taskId, 'completed');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
});
it('should reject transition from failed to any other status', async () => {
await store.updateTaskStatus(taskId, 'failed');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
});
it('should reject transition from cancelled to any other status', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
});
});
});
describe('storeTaskResult', () => {
let taskId: string;
beforeEach(async () => {
const taskParams: TaskCreationParams = {
ttl: 60000
};
const createdTask = await store.createTask(taskParams, 333, {
method: 'tools/call',
params: {}
});
taskId = createdTask.taskId;
});
it('should store task result and set status to completed', async () => {
const result = {
content: [{ type: 'text' as const, text: 'Success!' }]
};
await store.storeTaskResult(taskId, 'completed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
const storedResult = await store.getTaskResult(taskId);
expect(storedResult).toEqual(result);
});
it('should throw if task not found', async () => {
await expect(store.storeTaskResult('non-existent', 'completed', {})).rejects.toThrow('Task with ID non-existent not found');
});
it('should reject storing result for task already in completed status', async () => {
// First complete the task
const firstResult = {
content: [{ type: 'text' as const, text: 'First result' }]
};
await store.storeTaskResult(taskId, 'completed', firstResult);
// Try to store result again (should fail)
const secondResult = {
content: [{ type: 'text' as const, text: 'Second result' }]
};
await expect(store.storeTaskResult(taskId, 'completed', secondResult)).rejects.toThrow('Cannot store result for task');
});
it('should store result with failed status', async () => {
const result = {
content: [{ type: 'text' as const, text: 'Error details' }],
isError: true
};
await store.storeTaskResult(taskId, 'failed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
const storedResult = await store.getTaskResult(taskId);
expect(storedResult).toEqual(result);
});
it('should reject storing result for task already in failed status', async () => {
// First fail the task
const firstResult = {
content: [{ type: 'text' as const, text: 'First error' }],
isError: true
};
await store.storeTaskResult(taskId, 'failed', firstResult);
// Try to store result again (should fail)
const secondResult = {
content: [{ type: 'text' as const, text: 'Second error' }],
isError: true
};
await expect(store.storeTaskResult(taskId, 'failed', secondResult)).rejects.toThrow('Cannot store result for task');
});
it('should reject storing result for cancelled task', async () => {
// Mark task as cancelled
await store.updateTaskStatus(taskId, 'cancelled');
// Try to store result (should fail)
const result = {
content: [{ type: 'text' as const, text: 'Cancellation result' }]
};
await expect(store.storeTaskResult(taskId, 'completed', result)).rejects.toThrow('Cannot store result for task');
});
it('should allow storing result from input_required status', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const result = {
content: [{ type: 'text' as const, text: 'Success!' }]
};
await store.storeTaskResult(taskId, 'completed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
});
describe('getTaskResult', () => {
it('should throw if task not found', async () => {
await expect(store.getTaskResult('non-existent')).rejects.toThrow('Task with ID non-existent not found');
});
it('should throw if task has no result stored', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 444, {
method: 'tools/call',
params: {}
});
await expect(store.getTaskResult(createdTask.taskId)).rejects.toThrow(`Task ${createdTask.taskId} has no result stored`);
});
it('should return stored result', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 555, {
method: 'tools/call',
params: {}
});
const result = {
content: [{ type: 'text' as const, text: 'Result data' }]
};
await store.storeTaskResult(createdTask.taskId, 'completed', result);
const retrieved = await store.getTaskResult(createdTask.taskId);
expect(retrieved).toEqual(result);
});
});
describe('ttl cleanup', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should cleanup task after ttl duration', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 666, {
method: 'tools/call',
params: {}
});
// Task should exist initially
let task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
// Fast-forward past ttl
vi.advanceTimersByTime(1001);
// Task should be cleaned up
task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
});
it('should reset cleanup timer when result is stored', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 777, {
method: 'tools/call',
params: {}
});
// Fast-forward 500ms
vi.advanceTimersByTime(500);
// Store result (should reset timer)
await store.storeTaskResult(createdTask.taskId, 'completed', {
content: [{ type: 'text' as const, text: 'Done' }]
});
// Fast-forward another 500ms (total 1000ms since creation, but timer was reset)
vi.advanceTimersByTime(500);
// Task should still exist
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
// Fast-forward remaining time
vi.advanceTimersByTime(501);
// Now task should be cleaned up
const cleanedTask = await store.getTask(createdTask.taskId);
expect(cleanedTask).toBeNull();
});
it('should not cleanup tasks without ttl', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 888, {
method: 'tools/call',
params: {}
});
// Fast-forward a long time
vi.advanceTimersByTime(100000);
// Task should still exist
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
});
it('should start cleanup timer when task reaches terminal state', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 999, {
method: 'tools/call',
params: {}
});
// Task in non-terminal state, fast-forward
vi.advanceTimersByTime(1001);
// Task should be cleaned up
let task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
// Create another task
const taskParams2: TaskCreationParams = {
ttl: 2000
};
const createdTask2 = await store.createTask(taskParams2, 1000, {
method: 'tools/call',
params: {}
});
// Update to terminal state
await store.updateTaskStatus(createdTask2.taskId, 'completed');
// Fast-forward past original ttl
vi.advanceTimersByTime(2001);
// Task should be cleaned up
task = await store.getTask(createdTask2.taskId);
expect(task).toBeNull();
});
it('should return actual TTL in task response', async () => {
// Test that the TaskStore returns the actual TTL it will use
// This implementation uses the requested TTL as-is, but implementations
// MAY override it (e.g., enforce maximum TTL limits)
const requestedTtl = 5000;
const taskParams: TaskCreationParams = {
ttl: requestedTtl
};
const createdTask = await store.createTask(taskParams, 1111, {
method: 'tools/call',
params: {}
});
// The returned task should include the actual TTL that will be used
expect(createdTask.ttl).toBe(requestedTtl);
// Verify the task is cleaned up after the actual TTL
vi.advanceTimersByTime(requestedTtl + 1);
const task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
});
it('should support null TTL for unlimited lifetime', async () => {
// Test that null TTL means unlimited lifetime
const taskParams: TaskCreationParams = {
ttl: null
};
const createdTask = await store.createTask(taskParams, 2222, {
method: 'tools/call',
params: {}
});
// The returned task should have null TTL
expect(createdTask.ttl).toBeNull();
// Task should not be cleaned up even after a long time
vi.advanceTimersByTime(100000);
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
expect(task?.taskId).toBe(createdTask.taskId);
});
it('should cleanup tasks regardless of status', async () => {
// Test that TTL cleanup happens regardless of task status
const taskParams: TaskCreationParams = {
ttl: 1000
};
// Create tasks in different statuses
const workingTask = await store.createTask(taskParams, 3333, {
method: 'tools/call',
params: {}
});
const completedTask = await store.createTask(taskParams, 4444, {
method: 'tools/call',
params: {}
});
await store.storeTaskResult(completedTask.taskId, 'completed', {
content: [{ type: 'text' as const, text: 'Done' }]
});
const failedTask = await store.createTask(taskParams, 5555, {
method: 'tools/call',
params: {}
});
await store.storeTaskResult(failedTask.taskId, 'failed', {
content: [{ type: 'text' as const, text: 'Error' }]
});
// Fast-forward past TTL
vi.advanceTimersByTime(1001);
// All tasks should be cleaned up regardless of status
expect(await store.getTask(workingTask.taskId)).toBeNull();
expect(await store.getTask(completedTask.taskId)).toBeNull();
expect(await store.getTask(failedTask.taskId)).toBeNull();
});
});
describe('getAllTasks', () => {
it('should return all tasks', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 2, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 3, {
method: 'tools/call',
params: {}
});
const tasks = store.getAllTasks();
expect(tasks).toHaveLength(3);
// Verify all tasks have unique IDs
const taskIds = tasks.map(t => t.taskId);
expect(new Set(taskIds).size).toBe(3);
});
it('should return empty array when no tasks', () => {
const tasks = store.getAllTasks();
expect(tasks).toEqual([]);
});
});
describe('listTasks', () => {
it('should return empty list when no tasks', async () => {
const result = await store.listTasks();
expect(result.tasks).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it('should return all tasks when less than page size', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 2, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 3, {
method: 'tools/call',
params: {}
});
const result = await store.listTasks();
expect(result.tasks).toHaveLength(3);
expect(result.nextCursor).toBeUndefined();
});
it('should paginate when more than page size', async () => {
// Create 15 tasks (page size is 10)
for (let i = 1; i <= 15; i++) {
await store.createTask({}, i, {
method: 'tools/call',
params: {}
});
}
// Get first page
const page1 = await store.listTasks();
expect(page1.tasks).toHaveLength(10);
expect(page1.nextCursor).toBeDefined();
// Get second page using cursor
const page2 = await store.listTasks(page1.nextCursor);
expect(page2.tasks).toHaveLength(5);
expect(page2.nextCursor).toBeUndefined();
});
it('should throw error for invalid cursor', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await expect(store.listTasks('non-existent-cursor')).rejects.toThrow('Invalid cursor: non-existent-cursor');
});
it('should continue from cursor correctly', async () => {
// Create 5 tasks
for (let i = 1; i <= 5; i++) {
await store.createTask({}, i, {
method: 'tools/call',
params: {}
});
}
// Get first 3 tasks
const allTaskIds = Array.from(store.getAllTasks().map(t => t.taskId));
const result = await store.listTasks(allTaskIds[2]);
// Should get tasks after the third task
expect(result.tasks).toHaveLength(2);
});
});
describe('cleanup', () => {
it('should clear all timers and tasks', async () => {
await store.createTask({ ttl: 1000 }, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({ ttl: 2000 }, 2, {
method: 'tools/call',
params: {}
});
expect(store.getAllTasks()).toHaveLength(2);
store.cleanup();
expect(store.getAllTasks()).toHaveLength(0);
});
});
});
describe('InMemoryTaskMessageQueue', () => {
let queue: InMemoryTaskMessageQueue;
beforeEach(() => {
queue = new InMemoryTaskMessageQueue();
});
describe('enqueue and dequeue', () => {
it('should enqueue and dequeue request messages', async () => {
const requestMessage: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'test-tool', arguments: {} }
},
timestamp: Date.now()
};
await queue.enqueue('task-1', requestMessage);
const dequeued = await queue.dequeue('task-1');
expect(dequeued).toEqual(requestMessage);
});
it('should enqueue and dequeue notification messages', async () => {
const notificationMessage: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progress: 50, total: 100 }
},
timestamp: Date.now()
};
await queue.enqueue('task-2', notificationMessage);
const dequeued = await queue.dequeue('task-2');
expect(dequeued).toEqual(notificationMessage);
});
it('should enqueue and dequeue response messages', async () => {
const responseMessage: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 42,
result: { content: [{ type: 'text', text: 'Success' }] }
},
timestamp: Date.now()
};
await queue.enqueue('task-3', responseMessage);
const dequeued = await queue.dequeue('task-3');
expect(dequeued).toEqual(responseMessage);
});
it('should return undefined when dequeuing from empty queue', async () => {
const dequeued = await queue.dequeue('task-empty');
expect(dequeued).toBeUndefined();
});
it('should maintain FIFO order for mixed message types', async () => {
const request: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {}
},
timestamp: 1000
};
const notification: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: {}
},
timestamp: 2000
};
const response: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: 3000
};
await queue.enqueue('task-fifo', request);
await queue.enqueue('task-fifo', notification);
await queue.enqueue('task-fifo', response);
expect(await queue.dequeue('task-fifo')).toEqual(request);
expect(await queue.dequeue('task-fifo')).toEqual(notification);
expect(await queue.dequeue('task-fifo')).toEqual(response);
expect(await queue.dequeue('task-fifo')).toBeUndefined();
});
});
describe('dequeueAll', () => {
it('should dequeue all messages including responses', async () => {
const request: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {}
},
timestamp: 1000
};
const response: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: 2000
};
const notification: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: {}
},
timestamp: 3000
};
await queue.enqueue('task-all', request);
await queue.enqueue('task-all', response);
await queue.enqueue('task-all', notification);
const all = await queue.dequeueAll('task-all');
expect(all).toHaveLength(3);
expect(all[0]).toEqual(request);
expect(all[1]).toEqual(response);
expect(all[2]).toEqual(notification);
});
it('should return empty array for non-existent task', async () => {
const all = await queue.dequeueAll('non-existent');
expect(all).toEqual([]);
});
it('should clear the queue after dequeueAll', async () => {
const message: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {}
},
timestamp: Date.now()
};
await queue.enqueue('task-clear', message);
await queue.dequeueAll('task-clear');
const dequeued = await queue.dequeue('task-clear');
expect(dequeued).toBeUndefined();
});
});
describe('queue size limits', () => {
it('should throw when maxSize is exceeded', async () => {
const message: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {}
},
timestamp: Date.now()
};
await queue.enqueue('task-limit', message, undefined, 2);
await queue.enqueue('task-limit', message, undefined, 2);
await expect(queue.enqueue('task-limit', message, undefined, 2)).rejects.toThrow('Task message queue overflow');
});
it('should allow enqueue when under maxSize', async () => {
const message: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: Date.now()
};
await expect(queue.enqueue('task-ok', message, undefined, 5)).resolves.toBeUndefined();
});
});
describe('task isolation', () => {
it('should isolate messages between different tasks', async () => {
const message1: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test1',
params: {}
},
timestamp: 1000
};
const message2: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 2,
result: {}
},
timestamp: 2000
};
await queue.enqueue('task-a', message1);
await queue.enqueue('task-b', message2);
expect(await queue.dequeue('task-a')).toEqual(message1);
expect(await queue.dequeue('task-b')).toEqual(message2);
expect(await queue.dequeue('task-a')).toBeUndefined();
expect(await queue.dequeue('task-b')).toBeUndefined();
});
});
describe('response message error handling', () => {
it('should handle response messages with errors', async () => {
const errorResponse: QueuedMessage = {
type: 'error',
message: {
jsonrpc: '2.0',
id: 1,
error: {
code: -32600,
message: 'Invalid Request'
}
},
timestamp: Date.now()
};
await queue.enqueue('task-error', errorResponse);
const dequeued = await queue.dequeue('task-error');
expect(dequeued).toEqual(errorResponse);
expect(dequeued?.type).toBe('error');
});
});
});

View file

@ -0,0 +1,295 @@
/**
* In-memory implementations of TaskStore and TaskMessageQueue.
* WARNING: These APIs are experimental and may change without notice.
*
* @experimental
*/
import { Task, Request, RequestId, Result } from '../../../types.js';
import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js';
import { randomBytes } from 'crypto';
interface StoredTask {
task: Task;
request: Request;
requestId: RequestId;
result?: Result;
}
/**
* A simple in-memory implementation of TaskStore for demonstration purposes.
*
* This implementation stores all tasks in memory and provides automatic cleanup
* based on the ttl duration specified in the task creation parameters.
*
* Note: This is not suitable for production use as all data is lost on restart.
* For production, consider implementing TaskStore with a database or distributed cache.
*
* @experimental
*/
export class InMemoryTaskStore implements TaskStore {
private tasks = new Map<string, StoredTask>();
private cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* Generates a unique task ID.
* Uses 16 bytes of random data encoded as hex (32 characters).
*/
private generateTaskId(): string {
return randomBytes(16).toString('hex');
}
async createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, _sessionId?: string): Promise<Task> {
// Generate a unique task ID
const taskId = this.generateTaskId();
// Ensure uniqueness
if (this.tasks.has(taskId)) {
throw new Error(`Task with ID ${taskId} already exists`);
}
const actualTtl = taskParams.ttl ?? null;
// Create task with generated ID and timestamps
const createdAt = new Date().toISOString();
const task: Task = {
taskId,
status: 'working',
ttl: actualTtl,
createdAt,
lastUpdatedAt: createdAt,
pollInterval: taskParams.pollInterval ?? 1000
};
this.tasks.set(taskId, {
task,
request,
requestId
});
// Schedule cleanup if ttl is specified
// Cleanup occurs regardless of task status
if (actualTtl) {
const timer = setTimeout(() => {
this.tasks.delete(taskId);
this.cleanupTimers.delete(taskId);
}, actualTtl);
this.cleanupTimers.set(taskId, timer);
}
return task;
}
async getTask(taskId: string, _sessionId?: string): Promise<Task | null> {
const stored = this.tasks.get(taskId);
return stored ? { ...stored.task } : null;
}
async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise<void> {
const stored = this.tasks.get(taskId);
if (!stored) {
throw new Error(`Task with ID ${taskId} not found`);
}
// Don't allow storing results for tasks already in terminal state
if (isTerminal(stored.task.status)) {
throw new Error(
`Cannot store result for task ${taskId} in terminal status '${stored.task.status}'. Task results can only be stored once.`
);
}
stored.result = result;
stored.task.status = status;
stored.task.lastUpdatedAt = new Date().toISOString();
// Reset cleanup timer to start from now (if ttl is set)
if (stored.task.ttl) {
const existingTimer = this.cleanupTimers.get(taskId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
this.tasks.delete(taskId);
this.cleanupTimers.delete(taskId);
}, stored.task.ttl);
this.cleanupTimers.set(taskId, timer);
}
}
async getTaskResult(taskId: string, _sessionId?: string): Promise<Result> {
const stored = this.tasks.get(taskId);
if (!stored) {
throw new Error(`Task with ID ${taskId} not found`);
}
if (!stored.result) {
throw new Error(`Task ${taskId} has no result stored`);
}
return stored.result;
}
async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise<void> {
const stored = this.tasks.get(taskId);
if (!stored) {
throw new Error(`Task with ID ${taskId} not found`);
}
// Don't allow transitions from terminal states
if (isTerminal(stored.task.status)) {
throw new Error(
`Cannot update task ${taskId} from terminal status '${stored.task.status}' to '${status}'. Terminal states (completed, failed, cancelled) cannot transition to other states.`
);
}
stored.task.status = status;
if (statusMessage) {
stored.task.statusMessage = statusMessage;
}
stored.task.lastUpdatedAt = new Date().toISOString();
// If task is in a terminal state and has ttl, start cleanup timer
if (isTerminal(status) && stored.task.ttl) {
const existingTimer = this.cleanupTimers.get(taskId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
this.tasks.delete(taskId);
this.cleanupTimers.delete(taskId);
}, stored.task.ttl);
this.cleanupTimers.set(taskId, timer);
}
}
async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> {
const PAGE_SIZE = 10;
const allTaskIds = Array.from(this.tasks.keys());
let startIndex = 0;
if (cursor) {
const cursorIndex = allTaskIds.indexOf(cursor);
if (cursorIndex >= 0) {
startIndex = cursorIndex + 1;
} else {
// Invalid cursor - throw error
throw new Error(`Invalid cursor: ${cursor}`);
}
}
const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE);
const tasks = pageTaskIds.map(taskId => {
const stored = this.tasks.get(taskId)!;
return { ...stored.task };
});
const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds[pageTaskIds.length - 1] : undefined;
return { tasks, nextCursor };
}
/**
* Cleanup all timers (useful for testing or graceful shutdown)
*/
cleanup(): void {
for (const timer of this.cleanupTimers.values()) {
clearTimeout(timer);
}
this.cleanupTimers.clear();
this.tasks.clear();
}
/**
* Get all tasks (useful for debugging)
*/
getAllTasks(): Task[] {
return Array.from(this.tasks.values()).map(stored => ({ ...stored.task }));
}
}
/**
* A simple in-memory implementation of TaskMessageQueue for demonstration purposes.
*
* This implementation stores messages in memory, organized by task ID and optional session ID.
* Messages are stored in FIFO queues per task.
*
* Note: This is not suitable for production use in distributed systems.
* For production, consider implementing TaskMessageQueue with Redis or other distributed queues.
*
* @experimental
*/
export class InMemoryTaskMessageQueue implements TaskMessageQueue {
private queues = new Map<string, QueuedMessage[]>();
/**
* Generates a queue key from taskId.
* SessionId is intentionally ignored because taskIds are globally unique
* and tasks need to be accessible across HTTP requests/sessions.
*/
private getQueueKey(taskId: string, _sessionId?: string): string {
return taskId;
}
/**
* Gets or creates a queue for the given task and session.
*/
private getQueue(taskId: string, sessionId?: string): QueuedMessage[] {
const key = this.getQueueKey(taskId, sessionId);
let queue = this.queues.get(key);
if (!queue) {
queue = [];
this.queues.set(key, queue);
}
return queue;
}
/**
* Adds a message to the end of the queue for a specific task.
* Atomically checks queue size and throws if maxSize would be exceeded.
* @param taskId The task identifier
* @param message The message to enqueue
* @param sessionId Optional session ID for binding the operation to a specific session
* @param maxSize Optional maximum queue size - if specified and queue is full, throws an error
* @throws Error if maxSize is specified and would be exceeded
*/
async enqueue(taskId: string, message: QueuedMessage, sessionId?: string, maxSize?: number): Promise<void> {
const queue = this.getQueue(taskId, sessionId);
// Atomically check size and enqueue
if (maxSize !== undefined && queue.length >= maxSize) {
throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`);
}
queue.push(message);
}
/**
* Removes and returns the first message from the queue for a specific task.
* @param taskId The task identifier
* @param sessionId Optional session ID for binding the query to a specific session
* @returns The first message, or undefined if the queue is empty
*/
async dequeue(taskId: string, sessionId?: string): Promise<QueuedMessage | undefined> {
const queue = this.getQueue(taskId, sessionId);
return queue.shift();
}
/**
* Removes and returns all messages from the queue for a specific task.
* @param taskId The task identifier
* @param sessionId Optional session ID for binding the query to a specific session
* @returns Array of all messages that were in the queue
*/
async dequeueAll(taskId: string, sessionId?: string): Promise<QueuedMessage[]> {
const key = this.getQueueKey(taskId, sessionId);
const queue = this.queues.get(key) ?? [];
this.queues.delete(key);
return queue;
}
}

View file

@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { InMemoryTransport } from '../../inMemory.js';
import { Client } from '../../client/index.js';
import { Server } from '../../server/index.js';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from './stores/in-memory.js';
import { ErrorCode, McpError } from '../../types.js';
describe('Task Listing with Pagination', () => {
let client: Client;
let server: Server;
let taskStore: InMemoryTaskStore;
let clientTransport: InMemoryTransport;
let serverTransport: InMemoryTransport;
beforeEach(async () => {
taskStore = new InMemoryTaskStore();
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
capabilities: {
tasks: {
list: {},
requests: {
tools: {
call: {}
}
}
}
}
}
);
server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {
tasks: {
list: {},
requests: {
tools: {
call: {}
}
}
}
},
taskStore,
taskMessageQueue: new InMemoryTaskMessageQueue()
}
);
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
});
afterEach(async () => {
taskStore.cleanup();
await client.close();
await server.close();
});
it('should return empty list when no tasks exist', async () => {
const result = await client.experimental.tasks.listTasks();
expect(result.tasks).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it('should return all tasks when less than page size', async () => {
// Create 3 tasks
for (let i = 0; i < 3; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
const result = await client.experimental.tasks.listTasks();
expect(result.tasks).toHaveLength(3);
expect(result.nextCursor).toBeUndefined();
});
it('should paginate when more than page size exists', async () => {
// Create 15 tasks (page size is 10 in InMemoryTaskStore)
for (let i = 0; i < 15; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
// Get first page
const page1 = await client.experimental.tasks.listTasks();
expect(page1.tasks).toHaveLength(10);
expect(page1.nextCursor).toBeDefined();
// Get second page using cursor
const page2 = await client.experimental.tasks.listTasks(page1.nextCursor);
expect(page2.tasks).toHaveLength(5);
expect(page2.nextCursor).toBeUndefined();
});
it('should treat cursor as opaque token', async () => {
// Create 5 tasks
for (let i = 0; i < 5; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
// Get all tasks to get a valid cursor
const allTasks = taskStore.getAllTasks();
const validCursor = allTasks[2].taskId;
// Use the cursor - should work even though we don't know its internal structure
const result = await client.experimental.tasks.listTasks(validCursor);
expect(result.tasks).toHaveLength(2);
});
it('should return error code -32602 for invalid cursor', async () => {
await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: McpError) => {
expect(error).toBeInstanceOf(McpError);
expect(error.code).toBe(ErrorCode.InvalidParams);
expect(error.message).toContain('Invalid cursor');
return true;
});
});
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {
// Create a task
const task = await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
// Verify it's accessible via tasks/get
const getResult = await client.experimental.tasks.getTask(task.taskId);
expect(getResult.taskId).toBe(task.taskId);
// Verify it's also accessible via tasks/list
const listResult = await client.experimental.tasks.listTasks();
expect(listResult.tasks).toHaveLength(1);
expect(listResult.tasks[0].taskId).toBe(task.taskId);
});
it('should not include related-task metadata in list response', async () => {
// Create a task
await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
const result = await client.experimental.tasks.listTasks();
// The response should have _meta but not include related-task metadata
expect(result._meta).toBeDefined();
expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined();
});
});

View file

@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { isTerminal } from './interfaces.js';
import type { Task } from '../../types.js';
describe('Task utility functions', () => {
describe('isTerminal', () => {
it('should return true for completed status', () => {
expect(isTerminal('completed')).toBe(true);
});
it('should return true for failed status', () => {
expect(isTerminal('failed')).toBe(true);
});
it('should return true for cancelled status', () => {
expect(isTerminal('cancelled')).toBe(true);
});
it('should return false for working status', () => {
expect(isTerminal('working')).toBe(false);
});
it('should return false for input_required status', () => {
expect(isTerminal('input_required')).toBe(false);
});
});
});
describe('Task Schema Validation', () => {
it('should validate task with ttl field', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-123',
status: 'working',
ttl: 60000,
createdAt,
lastUpdatedAt: createdAt,
pollInterval: 1000
};
expect(task.ttl).toBe(60000);
expect(task.createdAt).toBeDefined();
expect(typeof task.createdAt).toBe('string');
});
it('should validate task with null ttl', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-456',
status: 'completed',
ttl: null,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.ttl).toBeNull();
});
it('should validate task with statusMessage field', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-789',
status: 'failed',
ttl: null,
createdAt,
lastUpdatedAt: createdAt,
statusMessage: 'Operation failed due to timeout'
};
expect(task.statusMessage).toBe('Operation failed due to timeout');
});
it('should validate task with createdAt in ISO 8601 format', () => {
const now = new Date();
const createdAt = now.toISOString();
const task: Task = {
taskId: 'test-iso',
status: 'working',
ttl: 30000,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(new Date(task.createdAt).getTime()).toBe(now.getTime());
});
it('should validate task with lastUpdatedAt in ISO 8601 format', () => {
const now = new Date();
const createdAt = now.toISOString();
const task: Task = {
taskId: 'test-iso',
status: 'working',
ttl: 30000,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('should validate all task statuses', () => {
const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
const createdAt = new Date().toISOString();
statuses.forEach(status => {
const task: Task = {
taskId: `test-${status}`,
status,
ttl: null,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.status).toBe(status);
});
});
});

View file

@ -0,0 +1,43 @@
/**
* Re-exports of task-related types from the MCP protocol spec.
* WARNING: These APIs are experimental and may change without notice.
*
* These types are defined in types.ts (matching the protocol spec) and
* re-exported here for convenience when working with experimental task features.
*/
// Task schemas (Zod)
export {
TaskCreationParamsSchema,
RelatedTaskMetadataSchema,
TaskSchema,
CreateTaskResultSchema,
TaskStatusNotificationParamsSchema,
TaskStatusNotificationSchema,
GetTaskRequestSchema,
GetTaskResultSchema,
GetTaskPayloadRequestSchema,
ListTasksRequestSchema,
ListTasksResultSchema,
CancelTaskRequestSchema,
CancelTaskResultSchema,
ClientTasksCapabilitySchema,
ServerTasksCapabilitySchema
} from '../../types.js';
// Task types (inferred from schemas)
export type {
Task,
TaskCreationParams,
RelatedTaskMetadata,
CreateTaskResult,
TaskStatusNotificationParams,
TaskStatusNotification,
GetTaskRequest,
GetTaskResult,
GetTaskPayloadRequest,
ListTasksRequest,
ListTasksResult,
CancelTaskRequest,
CancelTaskResult
} from '../../types.js';

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more