diff --git a/CLAUDE.md b/CLAUDE.md index cbbf950273..d5a188676a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,9 +104,7 @@ The repo also ships “middleware” packages under `packages/middleware/` (e.g. ### Experimental Features -Located in `packages/*/src/experimental/`: - -- **Tasks**: Long-running task support with polling/resumption (`packages/core/src/experimental/tasks/`) +Located in `packages/*/src/experimental/`. Currently empty. ### Zod Schemas @@ -201,7 +199,6 @@ The `ctx` parameter in handlers provides a structured context: - `notify(notification)`: Send related notification back - `http?`: HTTP transport info (undefined for stdio) - `authInfo?`: Validated auth token info -- `task?`: Task context (`{ id?, store, requestedTtl? }`) when task storage is configured **`ServerContext`** extends `BaseContext.mcpReq` and `BaseContext.http?` via type intersection: diff --git a/docs/client.md b/docs/client.md index 0946eeec97..0c852f4e11 100644 --- a/docs/client.md +++ b/docs/client.md @@ -544,7 +544,7 @@ All requests have a 60-second default timeout. Pass a custom `timeout` in the op ```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout" try { const result = await client.callTool( - { name: 'slow-task', arguments: {} }, + { name: 'slow-operation', arguments: {} }, { timeout: 120_000 } // 2 minutes instead of the default 60 seconds ); console.log(result.content); @@ -581,7 +581,7 @@ let lastToken: string | undefined; const result = await client.request( { method: 'tools/call', - params: { name: 'long-running-task', arguments: {} } + params: { name: 'long-running-operation', arguments: {} } }, { resumptionToken: lastToken, @@ -596,18 +596,6 @@ console.log(result); For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts). -## Tasks (experimental) - -> [!WARNING] -> The tasks API is experimental and may change without notice. - -Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see [Tasks](https://modelcontextprotocol.io/specification/latest/basic/utilities/tasks) in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks: - -- Call {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#callToolStream | client.experimental.tasks.callToolStream(...)} to start a tool call that may create a task and emit status updates over time. -- Call {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#getTask | client.experimental.tasks.getTask(...)} and {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#getTaskResult | getTaskResult(...)} to check status and fetch results after reconnecting. - -For a full runnable example, see [`simpleTaskInteractiveClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTaskInteractiveClient.ts). - ## See also - [`examples/client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Full runnable client examples diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index dbe6a4e9f7..a4e1f3cb66 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -420,9 +420,7 @@ Request/notification params remain fully typed. Remove unused schema imports aft | `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) | | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) | -| `extra.taskStore` | `ctx.task?.store` | -| `extra.taskId` | `ctx.task?.id` | -| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` | +| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed; see §12_ | `ServerContext` convenience methods (new in v2, no v1 equivalent): @@ -473,24 +471,26 @@ If a `*Schema` constant was used for **runtime validation** (not just as a `requ `isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. -## 12. Experimental: `TaskCreationParams.ttl` no longer accepts `null` +## 12. Experimental tasks interception removed -`TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide. +The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). No mechanical migration; remove usages. -| v1 | v2 | -| ---------------------- | ---------------------------------- | -| `task: { ttl: null }` | `task: {}` (omit ttl) | -| `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | +| Removed | Notes | +| --- | --- | +| `ProtocolOptions.tasks` | drop the option | +| `protocol.taskManager` | gone | +| `RequestOptions.task` / `.relatedTask`, `NotificationOptions.relatedTask` | drop the option | +| `BaseContext.task` (`ctx.task?.*`) | gone | +| `assertTaskCapability` / `assertTaskHandlerCapability` overrides | delete the override | +| `*.experimental.tasks.*` accessors, `Experimental{Client,Server,McpServer}Tasks` | removed | +| `requestStream` / `callToolStream` / `createMessageStream` / `elicitInputStream` | removed; no streaming variant | +| `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler` | removed | +| `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution` | removed | +| `ResponseMessage`, `TaskStatusMessage`, `TaskCreatedMessage`, `ResultMessage`, `takeResult`, `toArrayAsync` | removed | -Type changes in handler context: +`TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) are unchanged. -| Type | v1 | v2 | -| ------------------------------------------- | ----------------------------- | --------------------- | -| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | -| `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | -| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | - -> These task APIs are `@experimental` and may change without notice. +`TaskCreationParams.ttl` also no longer accepts `null` (`number | undefined` only); omit `ttl` to let the server decide. ## 13. Client Behavioral Changes diff --git a/docs/migration.md b/docs/migration.md index cd3da6dcda..e989041254 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -591,9 +591,7 @@ The `RequestHandlerExtra` type has been replaced with a structured context type | `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | | `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | | `extra.sessionId` | `ctx.sessionId` | -| `extra.taskStore` | `ctx.task?.store` | -| `extra.taskId` | `ctx.task?.id` | -| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` | +| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed — see "Experimental tasks interception removed" below_ | **Before (v1):** @@ -853,46 +851,28 @@ try { } ``` -### Experimental: `TaskCreationParams.ttl` no longer accepts `null` +### Experimental tasks interception removed -The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let -the server decide the lifetime. +The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement: -This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. +- `ProtocolOptions.tasks` (the `{ taskStore, taskMessageQueue }` constructor option) +- `protocol.taskManager` getter, `Protocol#_bindTaskManager` +- `RequestOptions.task` / `RequestOptions.relatedTask`, `NotificationOptions.relatedTask` +- `BaseContext.task` (`ctx.task?.store` / `ctx.task?.id` / `ctx.task?.requestedTtl`) +- abstract `assertTaskCapability` / `assertTaskHandlerCapability` +- `client.experimental.tasks.*` / `server.experimental.tasks.*` / `mcpServer.experimental.tasks.*` accessors and the `Experimental{Client,Server,McpServer}Tasks` classes +- streaming methods (`requestStream`, `callToolStream`, `createMessageStream`, `elicitInputStream`) and the `ResponseMessage` types they yielded +- `mcpServer.experimental.tasks.registerToolTask(...)`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler` +- `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `Queued*` message types, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution` +- `examples/{client,server}/src/simpleTaskInteractive*.ts` -**Before (v1):** - -```typescript -// Requesting unlimited lifetime by passing null -const result = await client.callTool({ - name: 'long-task', - arguments: {}, - task: { ttl: null } -}); +**Unchanged:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`). It will be consumed by the SEP-2663 server-directed plugin in a follow-up. -// Handler context had number | null | undefined -server.setRequestHandler('tools/call', async (request, ctx) => { - const ttl: number | null | undefined = ctx.task?.requestedTtl; -}); -``` +There is no migration path for the removed surface; it was always `@experimental`. Under SEP-2663, tasks reattach via a `DispatchMiddleware` (`mcp.use(tasksPlugin({ store }))`) and handlers read task context from `ctx.ext.task` instead of `ctx.task`. -**After (v2):** - -```typescript -// Omit ttl to let the server decide (server may return null for unlimited) -const result = await client.callTool({ - name: 'long-task', - arguments: {}, - task: {} -}); - -// Handler context is now number | undefined -server.setRequestHandler('tools/call', async (request, ctx) => { - const ttl: number | undefined = ctx.task?.requestedTtl; -}); -``` +#### `TaskCreationParams.ttl` no longer accepts `null` -> **Note:** These task APIs are marked `@experimental` and may change without notice. +`TaskCreationParams.ttl` (the storage-layer creation parameter) is now `number | undefined`; `null` is no longer accepted. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in creation requests. Omit `ttl` to let the store decide. This is a storage-interface change and is independent of the Protocol-level removals above. ## Enhancements diff --git a/docs/server.md b/docs/server.md index 3b173af4e0..b16c24fc4d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -495,19 +495,6 @@ server.registerTool( ); ``` -## Tasks (experimental) - -> [!WARNING] -> The tasks API is experimental and may change without notice. - -Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see [Tasks](https://modelcontextprotocol.io/specification/latest/basic/utilities/tasks) in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks: - -- Provide a {@linkcode @modelcontextprotocol/server!index.TaskStore | TaskStore} implementation that persists task metadata and results (see {@linkcode @modelcontextprotocol/server!index.InMemoryTaskStore | InMemoryTaskStore} for reference). -- Enable the `tasks` capability when constructing the server. -- Register tools with {@linkcode @modelcontextprotocol/server!experimental/tasks/mcpServer.ExperimentalMcpServerTasks#registerToolTask | server.experimental.tasks.registerToolTask(...)}. - -For a full runnable example, see [`simpleTaskInteractive.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleTaskInteractive.ts). - ## Shutdown For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: diff --git a/examples/client/README.md b/examples/client/README.md index 12a2b0d68b..46f7c82c9c 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -24,18 +24,17 @@ Most clients expect a server to be running. Start one from [`../server/README.md ## Example index -| Scenario | Description | File | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | -| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | -| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | -| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | -| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | -| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | -| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | -| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | +| Scenario | Description | File | +| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | +| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | +| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | +| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | +| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | +| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | +| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | +| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts index c75aea9483..f87abb7e3e 100644 --- a/examples/client/src/simpleOAuthClient.ts +++ b/examples/client/src/simpleOAuthClient.ts @@ -4,7 +4,7 @@ import { createServer } from 'node:http'; import { createInterface } from 'node:readline'; import { URL } from 'node:url'; -import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client'; +import type { ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client'; import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; import open from 'open'; @@ -358,62 +358,12 @@ class InteractiveOAuthClient { return; } - try { - // Using the experimental tasks API - WARNING: may change without notice - console.log(`\n🔧 Streaming tool '${toolName}'...`); - - const stream = this.client.experimental.tasks.callToolStream( - { - name: toolName, - arguments: toolArgs - }, - { - task: { - taskId: `task-${Date.now()}`, - ttl: 60_000 - } - } - ); - - // Iterate through all messages yielded by the generator - for await (const message of stream) { - switch (message.type) { - case 'taskCreated': { - console.log(`✓ Task created: ${message.task.taskId}`); - break; - } - - case 'taskStatus': { - console.log(`⟳ Status: ${message.task.status}`); - if (message.task.statusMessage) { - console.log(` ${message.task.statusMessage}`); - } - break; - } - - case 'result': { - console.log('✓ Completed!'); - const toolResult = message.result as CallToolResult; - for (const content of toolResult.content) { - if (content.type === 'text') { - console.log(content.text); - } else { - console.log(content); - } - } - break; - } - - case 'error': { - console.log('✗ Error:'); - console.log(` ${message.error.message}`); - break; - } - } - } - } catch (error) { - console.error(`❌ Failed to stream tool '${toolName}':`, error); - } + // TODO(F3): re-enable streaming-tool demo via tasksPlugin (SEP-2663). + // The 2025-11 callToolStream API is removed by R0; this command is disabled + // until the F3 rewrite. + void toolName; + void toolArgs; + console.log('Streaming tool demo disabled pending tasksPlugin (SEP-2663). See TODO(F3).'); } close(): void { diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/client/src/simpleStreamableHttp.ts index f22d16ba4b..6c8be12610 100644 --- a/examples/client/src/simpleStreamableHttp.ts +++ b/examples/client/src/simpleStreamableHttp.ts @@ -1,7 +1,6 @@ import { createInterface } from 'node:readline'; import type { - CallToolResult, GetPromptRequest, ListPromptsRequest, ListResourcesRequest, @@ -9,15 +8,7 @@ import type { ReadResourceRequest, ResourceLink } from '@modelcontextprotocol/client'; -import { - Client, - getDisplayName, - InMemoryTaskStore, - ProtocolError, - ProtocolErrorCode, - RELATED_TASK_META_KEY, - StreamableHTTPClientTransport -} from '@modelcontextprotocol/client'; +import { Client, getDisplayName, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { Ajv } from 'ajv'; // Create readline interface for user input @@ -56,11 +47,9 @@ function printHelp(): void { console.log(' reconnect - Reconnect to the server'); console.log(' list-tools - List available tools'); console.log(' call-tool [args] - Call a tool with optional JSON arguments'); - console.log(' call-tool-task [args] - Call a tool with task-based execution (example: call-tool-task delay {"duration":3000})'); console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)'); - console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); @@ -135,12 +124,6 @@ function commandLoop(): void { await callCollectInfoTool(args[1] || 'contact'); break; } - - case 'collect-info-task': { - await callCollectInfoWithTask(args[1] || 'contact'); - break; - } - case 'start-notifications': { const interval = args[1] ? Number.parseInt(args[1], 10) : 2000; const count = args[2] ? Number.parseInt(args[2], 10) : 10; @@ -154,25 +137,6 @@ function commandLoop(): void { await runNotificationsToolWithResumability(interval, count); break; } - - case 'call-tool-task': { - if (args.length < 2) { - console.log('Usage: call-tool-task [args]'); - } else { - const toolName = args[1]!; - let toolArgs = {}; - if (args.length > 2) { - try { - toolArgs = JSON.parse(args.slice(2).join(' ')); - } catch { - console.log('Invalid JSON arguments. Using empty args.'); - } - } - await callToolTask(toolName, toolArgs); - } - break; - } - case 'list-prompts': { await listPrompts(); break; @@ -250,10 +214,7 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create task store for client-side task support - const clientTaskStore = new InMemoryTaskStore(); - - // Create a new client with form elicitation capability and task support + // Create a new client with form elicitation capability client = new Client( { name: 'example-client', @@ -263,14 +224,6 @@ async function connect(url?: string): Promise { capabilities: { elicitation: { form: {} - }, - tasks: { - taskStore: clientTaskStore, - requests: { - elicitation: { - create: {} - } - } } } } @@ -279,33 +232,16 @@ async function connect(url?: string): Promise { console.error('\u001B[31mClient error:', error, '\u001B[0m'); }; - // Set up elicitation request handler with proper validation and task support - client.setRequestHandler('elicitation/create', async (request, extra) => { + // Set up elicitation request handler with proper validation + client.setRequestHandler('elicitation/create', async request => { if (request.params.mode !== 'form') { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); } console.log('\n🔔 Elicitation (form) Request Received:'); console.log(`Message: ${request.params.message}`); - console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`); - console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`); console.log('Requested Schema:'); console.log(JSON.stringify(request.params.requestedSchema, null, 2)); - // Helper to return result, optionally creating a task if requested - const returnResult = async (result: { - action: 'accept' | 'decline' | 'cancel'; - content?: Record; - }) => { - if (request.params.task && extra.task?.store) { - // Create a task and store the result - const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl }); - await extra.task.store.storeTaskResult(task.taskId, 'completed', result); - console.log(`📋 Created client-side task: ${task.taskId}`); - return { task }; - } - return result; - }; - const schema = request.params.requestedSchema; const properties = schema.properties; const required = schema.required || []; @@ -439,7 +375,7 @@ async function connect(url?: string): Promise { } if (inputCancelled) { - return returnResult({ action: 'cancel' }); + return { action: 'cancel' }; } // If we didn't complete all fields due to an error, try again @@ -452,7 +388,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return returnResult({ action: 'decline' }); + return { action: 'decline' }; } } @@ -471,7 +407,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return returnResult({ action: 'decline' }); + return { action: 'decline' }; } } @@ -488,14 +424,14 @@ async function connect(url?: string): Promise { switch (confirmAnswer) { case 'yes': case 'y': { - return returnResult({ + return { action: 'accept', content - }); + }; } case 'cancel': case 'c': { - return returnResult({ action: 'cancel' }); + return { action: 'cancel' }; } case 'no': case 'n': { @@ -503,7 +439,7 @@ async function connect(url?: string): Promise { console.log('Please re-enter the information...'); continue; } else { - return returnResult({ action: 'decline' }); + return { action: 'decline' }; } break; @@ -513,7 +449,7 @@ async function connect(url?: string): Promise { } console.log('Maximum attempts reached. Declining request.'); - return returnResult({ action: 'decline' }); + return { action: 'decline' }; }); transport = new StreamableHTTPClientTransport(new URL(serverUrl), { @@ -716,12 +652,6 @@ async function callCollectInfoTool(infoType: string): Promise { await callTool('collect-user-info', { infoType }); } -async function callCollectInfoWithTask(infoType: string): Promise { - console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`); - console.log('This will create a task on the server, which will elicit input and create a task on the client.\n'); - await callToolTask('collect-user-info-task', { infoType }); -} - async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); @@ -880,70 +810,6 @@ async function readResource(uri: string): Promise { } } -async function callToolTask(name: string, args: Record): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - console.log(`Calling tool '${name}' with task-based execution...`); - console.log('Arguments:', args); - - // Use task-based execution - call now, fetch later - // Using the experimental tasks API - WARNING: may change without notice - console.log('This will return immediately while processing continues in the background...'); - - try { - // Call the tool with task metadata using streaming API - const stream = client.experimental.tasks.callToolStream( - { - name, - arguments: args - }, - { - task: { - ttl: 60_000 // Keep results for 60 seconds - } - } - ); - - console.log('Waiting for task completion...'); - - let lastStatus = ''; - for await (const message of stream) { - switch (message.type) { - case 'taskCreated': { - console.log('Task created successfully with ID:', message.task.taskId); - break; - } - case 'taskStatus': { - if (lastStatus !== message.task.status) { - console.log(` ${message.task.status}${message.task.statusMessage ? ` - ${message.task.statusMessage}` : ''}`); - } - lastStatus = message.task.status; - break; - } - case 'result': { - console.log('Task completed!'); - console.log('Tool result:'); - const toolResult = message.result as CallToolResult; - for (const item of toolResult.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } - } - break; - } - case 'error': { - throw message.error; - } - } - } - } catch (error) { - console.log(`Error with task-based execution: ${error}`); - } -} - async function cleanup(): Promise { if (client && transport) { try { diff --git a/examples/client/src/simpleTaskInteractiveClient.ts b/examples/client/src/simpleTaskInteractiveClient.ts deleted file mode 100644 index 0a35faba24..0000000000 --- a/examples/client/src/simpleTaskInteractiveClient.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Simple interactive task client demonstrating elicitation and sampling responses. - * - * This client connects to simpleTaskInteractive.ts server and demonstrates: - * - Handling elicitation requests (y/n confirmation) - * - Handling sampling requests (returns a hardcoded haiku) - * - Using task-based tool execution with streaming - */ - -import { createInterface } from 'node:readline'; - -import type { CallToolResult, CreateMessageRequest, CreateMessageResult, TextContent } from '@modelcontextprotocol/client'; -import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -// Create readline interface for user input -const readline = createInterface({ - input: process.stdin, - output: process.stdout -}); - -function question(prompt: string): Promise { - return new Promise(resolve => { - readline.question(prompt, answer => { - resolve(answer.trim()); - }); - }); -} - -function getTextContent(result: { content: Array<{ type: string; text?: string }> }): string { - const textContent = result.content.find((c): c is TextContent => c.type === 'text'); - return textContent?.text ?? '(no text)'; -} - -async function elicitationCallback(params: { - mode?: string; - message: string; - requestedSchema?: object; -}): Promise<{ action: 'accept' | 'cancel' | 'decline'; content?: Record }> { - console.log(`\n[Elicitation] Server asks: ${params.message}`); - - // Simple terminal prompt for y/n - const response = await question('Your response (y/n): '); - const confirmed = ['y', 'yes', 'true', '1'].includes(response.toLowerCase()); - - console.log(`[Elicitation] Responding with: confirm=${confirmed}`); - return { action: 'accept', content: { confirm: confirmed } }; -} - -async function samplingCallback(params: CreateMessageRequest['params']): Promise { - // Get the prompt from the first message - let prompt = 'unknown'; - if (params.messages && params.messages.length > 0) { - const firstMessage = params.messages[0]!; - const content = firstMessage.content; - if (typeof content === 'object' && !Array.isArray(content) && content.type === 'text' && 'text' in content) { - prompt = content.text; - } else if (Array.isArray(content)) { - const textPart = content.find(c => c.type === 'text' && 'text' in c); - if (textPart && 'text' in textPart) { - prompt = textPart.text; - } - } - } - - console.log(`\n[Sampling] Server requests LLM completion for: ${prompt}`); - - // Return a hardcoded haiku (in real use, call your LLM here) - const haiku = `Cherry blossoms fall -Softly on the quiet pond -Spring whispers goodbye`; - - console.log('[Sampling] Responding with haiku'); - return { - model: 'mock-haiku-model', - role: 'assistant', - content: { type: 'text', text: haiku } - }; -} - -async function run(url: string): Promise { - console.log('Simple Task Interactive Client'); - console.log('=============================='); - console.log(`Connecting to ${url}...`); - - // Create client with elicitation and sampling capabilities - const client = new Client( - { name: 'simple-task-interactive-client', version: '1.0.0' }, - { - capabilities: { - elicitation: { form: {} }, - sampling: {} - } - } - ); - - // Set up elicitation request handler - client.setRequestHandler('elicitation/create', async request => { - if (request.params.mode && request.params.mode !== 'form') { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); - } - return elicitationCallback(request.params); - }); - - // Set up sampling request handler - client.setRequestHandler('sampling/createMessage', async request => { - return samplingCallback(request.params) as unknown as ReturnType; - }); - - // Connect to server - const transport = new StreamableHTTPClientTransport(new URL(url)); - await client.connect(transport); - console.log('Connected!\n'); - - // List tools - const toolsResult = await client.listTools(); - console.log(`Available tools: ${toolsResult.tools.map(t => t.name).join(', ')}`); - - // Demo 1: Elicitation (confirm_delete) - console.log('\n--- Demo 1: Elicitation ---'); - console.log('Calling confirm_delete tool...'); - - const confirmStream = client.experimental.tasks.callToolStream( - { name: 'confirm_delete', arguments: { filename: 'important.txt' } }, - { task: { ttl: 60_000 } } - ); - - for await (const message of confirmStream) { - switch (message.type) { - case 'taskCreated': { - console.log(`Task created: ${message.task.taskId}`); - break; - } - case 'taskStatus': { - console.log(`Task status: ${message.task.status}`); - break; - } - case 'result': { - const toolResult = message.result as CallToolResult; - console.log(`Result: ${getTextContent(toolResult)}`); - break; - } - case 'error': { - console.error(`Error: ${message.error}`); - break; - } - } - } - - // Demo 2: Sampling (write_haiku) - console.log('\n--- Demo 2: Sampling ---'); - console.log('Calling write_haiku tool...'); - - const haikuStream = client.experimental.tasks.callToolStream( - { name: 'write_haiku', arguments: { topic: 'autumn leaves' } }, - { task: { ttl: 60_000 } } - ); - - for await (const message of haikuStream) { - switch (message.type) { - case 'taskCreated': { - console.log(`Task created: ${message.task.taskId}`); - break; - } - case 'taskStatus': { - console.log(`Task status: ${message.task.status}`); - break; - } - case 'result': { - const toolResult = message.result as CallToolResult; - console.log(`Result:\n${getTextContent(toolResult)}`); - break; - } - case 'error': { - console.error(`Error: ${message.error}`); - break; - } - } - } - - // Cleanup - console.log('\nDemo complete. Closing connection...'); - await transport.close(); - readline.close(); -} - -// Parse command line arguments -const args = process.argv.slice(2); -let url = 'http://localhost:8000/mcp'; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--url' && args[i + 1]) { - url = args[i + 1]!; - i++; - } -} - -// Run the client -try { - await run(url); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/README.md b/examples/server/README.md index 0f684bec7e..0d78215473 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -25,20 +25,19 @@ pnpm tsx src/simpleStreamableHttp.ts ## Example index -| Scenario | Description | File | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | -| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | -| Resource-Server-only auth | Minimal OAuth RS using SDK's `mcpAuthMetadataRouter` + `requireBearerAuth` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) | -| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | -| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | -| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | -| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | -| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | -| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | -| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | -| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | -| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| Scenario | Description | File | +| ----------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | +| Resource-Server-only auth | Minimal OAuth RS using SDK's `mcpAuthMetadataRouter` + `requireBearerAuth` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) | +| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | +| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | +| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | +| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | +| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | +| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | +| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/README-simpleTaskInteractive.md b/examples/server/src/README-simpleTaskInteractive.md deleted file mode 100644 index 5e9793d1a0..0000000000 --- a/examples/server/src/README-simpleTaskInteractive.md +++ /dev/null @@ -1,181 +0,0 @@ -# Simple Task Interactive Example - -This example demonstrates the MCP Tasks message queue pattern with interactive server-to-client requests (elicitation and sampling). - -## Overview - -The example consists of two components: - -1. **Server** (`simpleTaskInteractive.ts`) - Exposes two task-based tools that require client interaction: - - `confirm_delete` - Uses elicitation to ask the user for confirmation before "deleting" a file - - `write_haiku` - Uses sampling to request an LLM to generate a haiku on a topic - -2. **Client** (`simpleTaskInteractiveClient.ts`) - Connects to the server and handles: - - Elicitation requests with simple y/n terminal prompts - - Sampling requests with a mock haiku generator - -## Key Concepts - -### Task-Based Execution - -Both tools use `execution.taskSupport: 'required'`, meaning they follow the "call-now, fetch-later" pattern: - -1. Client calls tool with `task: { ttl: 60000 }` parameter -2. Server creates a task and returns `CreateTaskResult` immediately -3. Client polls via `tasks/result` to get the final result -4. Server sends elicitation/sampling requests through the task message queue -5. Client handles requests and returns responses -6. Server completes the task with the final result - -### Message Queue Pattern - -When a tool needs to interact with the client (elicitation or sampling), it: - -1. Updates task status to `input_required` -2. Enqueues the request in the task message queue -3. Waits for the response via a Resolver -4. Updates task status back to `working` -5. Continues processing - -The `TaskResultHandler` dequeues messages when the client calls `tasks/result` and routes responses back to waiting Resolvers. - -## Running the Example - -### Start the Server - -```bash -# From anywhere in the SDK -pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts - -# Or with a custom port -PORT=9000 pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts -``` - -Or, from within the `examples/server` package: - -```bash -cd examples/server -pnpm tsx src/simpleTaskInteractive.ts - -# Or with a custom port -PORT=9000 pnpm tsx src/simpleTaskInteractive.ts -``` - -The server will start on http://localhost:8000/mcp (or your custom port). - -### Run the Client - -```bash -# From anywhere in the SDK -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts - -# Or connect to a different server -pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp -``` - -Or, from within the `examples/client` package: - -```bash -cd examples/client -pnpm tsx src/simpleTaskInteractiveClient.ts - -# Or connect to a different server -pnpm tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp -``` - -## Expected Output - -### Server Output - -``` -Starting server on http://localhost:8000/mcp - -Available tools: - - confirm_delete: Demonstrates elicitation (asks user y/n) - - write_haiku: Demonstrates sampling (requests LLM completion) - -[Server] confirm_delete called, task created: task-abc123 -[Server] confirm_delete: asking about 'important.txt' -[Server] Sending elicitation request to client... -[Server] tasks/result called for task task-abc123 -[Server] Delivering queued request message for task task-abc123 -[Server] Received elicitation response: action=accept, content={"confirm":true} -[Server] Completing task with result: Deleted 'important.txt' - -[Server] write_haiku called, task created: task-def456 -[Server] write_haiku: topic 'autumn leaves' -[Server] Sending sampling request to client... -[Server] tasks/result called for task task-def456 -[Server] Delivering queued request message for task task-def456 -[Server] Received sampling response: Cherry blossoms fall... -[Server] Completing task with haiku -``` - -### Client Output - -``` -Simple Task Interactive Client -============================== -Connecting to http://localhost:8000/mcp... -Connected! - -Available tools: confirm_delete, write_haiku - ---- Demo 1: Elicitation --- -Calling confirm_delete tool... -Task created: task-abc123 -Task status: working - -[Elicitation] Server asks: Are you sure you want to delete 'important.txt'? -Your response (y/n): y -[Elicitation] Responding with: confirm=true -Task status: input_required -Task status: completed -Result: Deleted 'important.txt' - ---- Demo 2: Sampling --- -Calling write_haiku tool... -Task created: task-def456 -Task status: working - -[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves -[Sampling] Responding with haiku -Task status: input_required -Task status: completed -Result: -Haiku: -Cherry blossoms fall -Softly on the quiet pond -Spring whispers goodbye - -Demo complete. Closing connection... -``` - -## Implementation Details - -### Server Components - -- **Resolver**: Promise-like class for passing results between async operations -- **TaskMessageQueueWithResolvers**: Extended message queue that tracks pending requests with their Resolvers -- **TaskStoreWithNotifications**: Extended task store with notification support for status changes -- **TaskResultHandler**: Handles `tasks/result` requests by dequeuing messages and routing responses -- **TaskSession**: Wraps the server to enqueue requests during task execution - -### Client Capabilities - -The client declares these capabilities during initialization: - -```typescript -capabilities: { - elicitation: { form: {} }, - sampling: {} -} -``` - -This tells the server that the client can handle both form-based elicitation and sampling requests. - -## Related Files - -- `packages/core/src/experimental/tasks/interfaces.ts` - Core task interfaces (TaskStore, TaskMessageQueue) -- `packages/core/src/experimental/tasks/stores/in-memory.ts` - In-memory task store implementation -- `packages/core/src/types/types.ts` - Task-related types (Task, CreateTaskResult, GetTaskRequestSchema, etc.) diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts index 6da0841ec1..1f0998cca9 100644 --- a/examples/server/src/simpleStreamableHttp.ts +++ b/examples/server/src/simpleStreamableHttp.ts @@ -5,13 +5,12 @@ import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBeare import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, - ElicitResult, GetPromptResult, PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink } from '@modelcontextprotocol/server'; -import { InMemoryTaskMessageQueue, InMemoryTaskStore, isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; @@ -22,9 +21,6 @@ import { InMemoryEventStore } from './inMemoryEventStore.js'; const useOAuth = process.argv.includes('--oauth'); const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled'); -// Create shared task store for demonstration -const taskStore = new InMemoryTaskStore(); - // Create an MCP server with implementation details const getServer = () => { const server = new McpServer( @@ -36,12 +32,7 @@ const getServer = () => { }, { capabilities: { - logging: {}, - tasks: { - requests: { tools: { call: {} } }, - taskStore, - taskMessageQueue: new InMemoryTaskMessageQueue() - } + logging: {} } } ); @@ -439,160 +430,6 @@ const getServer = () => { } ); - // Register a long-running tool that demonstrates task execution - // Using the experimental tasks API - WARNING: may change without notice - server.experimental.tasks.registerToolTask( - 'delay', - { - title: 'Delay', - description: 'A simple tool that delays for a specified duration, useful for testing task execution', - inputSchema: z.object({ - duration: z.number().describe('Duration in milliseconds').default(5000) - }) - }, - { - async createTask({ duration }, ctx) { - // Create the task - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - // Simulate out-of-band work - (async () => { - await new Promise(resolve => setTimeout(resolve, duration)); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [ - { - type: 'text', - text: `Completed ${duration}ms delay` - } - ] - }); - })(); - - // Return CreateTaskResult with the created task - return { - task - }; - }, - async getTask(_args, ctx) { - return await ctx.task.store.getTask(ctx.task.id); - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - - // Register a tool that demonstrates bidirectional task support: - // Server creates a task, then elicits input from client using elicitInputStream - // Using the experimental tasks API - WARNING: may change without notice - server.experimental.tasks.registerToolTask( - 'collect-user-info-task', - { - title: 'Collect Info with Task', - description: 'Collects user info via elicitation with task support using elicitInputStream', - inputSchema: z.object({ - infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact') - }) - }, - { - async createTask({ infoType }, ctx) { - // Create the server-side task - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - // Perform async work that makes a nested elicitation request using elicitInputStream - (async () => { - try { - const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences'; - - // Define schemas with proper typing for PrimitiveSchemaDefinition - const contactSchema: { - type: 'object'; - properties: Record; - required: string[]; - } = { - type: 'object', - properties: { - name: { type: 'string', title: 'Full Name', description: 'Your full name' }, - email: { type: 'string', title: 'Email', description: 'Your email address' } - }, - required: ['name', 'email'] - }; - - const preferencesSchema: { - type: 'object'; - properties: Record; - required: string[]; - } = { - type: 'object', - properties: { - theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, - notifications: { type: 'boolean', title: 'Enable Notifications', default: true } - }, - required: ['theme'] - }; - - const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema; - - // Use elicitInputStream to elicit input from client - // This demonstrates the streaming elicitation API - // Access via server.server to get the underlying Server instance - const stream = server.server.experimental.tasks.elicitInputStream({ - mode: 'form', - message, - requestedSchema - }); - - let elicitResult: ElicitResult | undefined; - for await (const msg of stream) { - if (msg.type === 'result') { - elicitResult = msg.result as ElicitResult; - } else if (msg.type === 'error') { - throw msg.error; - } - } - - if (!elicitResult) { - throw new Error('No result received from elicitation'); - } - - let resultText: string; - if (elicitResult.action === 'accept') { - resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`; - } else if (elicitResult.action === 'decline') { - resultText = `User declined to provide ${infoType} information`; - } else { - resultText = 'User cancelled the request'; - } - - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: resultText }] - }); - } catch (error) { - console.error('Error in collect-user-info-task:', error); - await taskStore.storeTaskResult(task.taskId, 'failed', { - content: [{ type: 'text', text: `Error: ${error}` }], - isError: true - }); - } - })(); - - return { task }; - }, - async getTask(_args, ctx) { - return await ctx.task.store.getTask(ctx.task.id); - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - return server; }; diff --git a/examples/server/src/simpleTaskInteractive.ts b/examples/server/src/simpleTaskInteractive.ts deleted file mode 100644 index fc0d7280c8..0000000000 --- a/examples/server/src/simpleTaskInteractive.ts +++ /dev/null @@ -1,758 +0,0 @@ -/** - * Simple interactive task server demonstrating elicitation and sampling. - * - * This server demonstrates the task message queue pattern from the MCP Tasks spec: - * - confirm_delete: Uses elicitation to ask the user for confirmation - * - write_haiku: Uses sampling to request an LLM to generate content - * - * Both tools use the "call-now, fetch-later" pattern where the initial call - * creates a task, and the result is fetched via tasks/result endpoint. - */ - -import { randomUUID } from 'node:crypto'; - -import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { - CallToolResult, - CreateMessageRequest, - CreateMessageResult, - CreateTaskOptions, - CreateTaskResult, - ElicitRequestFormParams, - ElicitResult, - GetTaskPayloadResult, - GetTaskResult, - JSONRPCRequest, - PrimitiveSchemaDefinition, - QueuedMessage, - QueuedRequest, - RequestId, - Result, - SamplingMessage, - Task, - TaskMessageQueue, - TextContent, - Tool -} from '@modelcontextprotocol/server'; -import { InMemoryTaskStore, isTerminal, RELATED_TASK_META_KEY, Server } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; - -// ============================================================================ -// Resolver - Promise-like for passing results between async operations -// ============================================================================ - -class Resolver { - private _resolve!: (value: T) => void; - private _reject!: (error: Error) => void; - private _promise: Promise; - private _done = false; - - constructor() { - this._promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - }); - } - - setResult(value: T): void { - if (this._done) return; - this._done = true; - this._resolve(value); - } - - setException(error: Error): void { - if (this._done) return; - this._done = true; - this._reject(error); - } - - wait(): Promise { - return this._promise; - } - - done(): boolean { - return this._done; - } -} - -// ============================================================================ -// Extended message queue with resolver support and wait functionality -// ============================================================================ - -interface QueuedRequestWithResolver extends QueuedRequest { - resolver?: Resolver>; - originalRequestId?: RequestId; -} - -type QueuedMessageWithResolver = QueuedRequestWithResolver | QueuedMessage; - -class TaskMessageQueueWithResolvers implements TaskMessageQueue { - private queues = new Map(); - private waitResolvers = new Map void)[]>(); - - private getQueue(taskId: string): QueuedMessageWithResolver[] { - let queue = this.queues.get(taskId); - if (!queue) { - queue = []; - this.queues.set(taskId, queue); - } - return queue; - } - - async enqueue(taskId: string, message: QueuedMessage, _sessionId?: string, maxSize?: number): Promise { - const queue = this.getQueue(taskId); - if (maxSize !== undefined && queue.length >= maxSize) { - throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`); - } - queue.push(message); - // Notify any waiters - this.notifyWaiters(taskId); - } - - async enqueueWithResolver( - taskId: string, - message: JSONRPCRequest, - resolver: Resolver>, - originalRequestId: RequestId - ): Promise { - const queue = this.getQueue(taskId); - const queuedMessage: QueuedRequestWithResolver = { - type: 'request', - message, - timestamp: Date.now(), - resolver, - originalRequestId - }; - queue.push(queuedMessage); - this.notifyWaiters(taskId); - } - - async dequeue(taskId: string, _sessionId?: string): Promise { - const queue = this.getQueue(taskId); - return queue.shift(); - } - - async dequeueAll(taskId: string, _sessionId?: string): Promise { - const queue = this.queues.get(taskId) ?? []; - this.queues.delete(taskId); - return queue; - } - - async waitForMessage(taskId: string): Promise { - // Check if there are already messages - const queue = this.getQueue(taskId); - if (queue.length > 0) return; - - // Wait for a message to be added - return new Promise(resolve => { - let waiters = this.waitResolvers.get(taskId); - if (!waiters) { - waiters = []; - this.waitResolvers.set(taskId, waiters); - } - waiters.push(resolve); - }); - } - - private notifyWaiters(taskId: string): void { - const waiters = this.waitResolvers.get(taskId); - if (waiters) { - this.waitResolvers.delete(taskId); - for (const resolve of waiters) { - resolve(); - } - } - } - - cleanup(): void { - this.queues.clear(); - this.waitResolvers.clear(); - } -} - -// ============================================================================ -// Extended task store with wait functionality -// ============================================================================ - -class TaskStoreWithNotifications extends InMemoryTaskStore { - private updateResolvers = new Map void)[]>(); - - override async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { - await super.updateTaskStatus(taskId, status, statusMessage, sessionId); - this.notifyUpdate(taskId); - } - - override async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise { - await super.storeTaskResult(taskId, status, result, sessionId); - this.notifyUpdate(taskId); - } - - async waitForUpdate(taskId: string): Promise { - return new Promise(resolve => { - let waiters = this.updateResolvers.get(taskId); - if (!waiters) { - waiters = []; - this.updateResolvers.set(taskId, waiters); - } - waiters.push(resolve); - }); - } - - private notifyUpdate(taskId: string): void { - const waiters = this.updateResolvers.get(taskId); - if (waiters) { - this.updateResolvers.delete(taskId); - for (const resolve of waiters) { - resolve(); - } - } - } -} - -// ============================================================================ -// Task Result Handler - delivers queued messages and routes responses -// ============================================================================ - -class TaskResultHandler { - private pendingRequests = new Map>>(); - - constructor( - private store: TaskStoreWithNotifications, - private queue: TaskMessageQueueWithResolvers - ) {} - - async handle(taskId: string, server: Server, _sessionId: string): Promise { - while (true) { - // Get fresh task state - const task = await this.store.getTask(taskId); - if (!task) { - throw new Error(`Task not found: ${taskId}`); - } - - // Dequeue and send all pending messages - await this.deliverQueuedMessages(taskId, server, _sessionId); - - // If task is terminal, return result - if (isTerminal(task.status)) { - const result = await this.store.getTaskResult(taskId); - // Add related-task metadata per spec - return { - ...result, - _meta: { - ...result._meta, - [RELATED_TASK_META_KEY]: { taskId } - } - }; - } - - // Wait for task update or new message - await this.waitForUpdate(taskId); - } - } - - private async deliverQueuedMessages(taskId: string, server: Server, _sessionId: string): Promise { - while (true) { - const message = await this.queue.dequeue(taskId); - if (!message) break; - - console.log(`[Server] Delivering queued ${message.type} message for task ${taskId}`); - - if (message.type === 'request') { - const reqMessage = message as QueuedRequestWithResolver; - // Send the request via the server - // Store the resolver so we can route the response back - if (reqMessage.resolver && reqMessage.originalRequestId) { - this.pendingRequests.set(reqMessage.originalRequestId, reqMessage.resolver); - } - - // Send the message - for elicitation/sampling, we use the server's methods - // But since we're in tasks/result context, we need to send via transport - // This is simplified - in production you'd use proper message routing - try { - const request = reqMessage.message; - let response: ElicitResult | CreateMessageResult; - - if (request.method === 'elicitation/create') { - // Send elicitation request to client - const params = request.params as ElicitRequestFormParams; - response = await server.elicitInput(params); - } else if (request.method === 'sampling/createMessage') { - // Send sampling request to client - const params = request.params as CreateMessageRequest['params']; - response = await server.createMessage(params); - } else { - throw new Error(`Unknown request method: ${request.method}`); - } - - // Route response back to resolver - if (reqMessage.resolver) { - reqMessage.resolver.setResult(response as unknown as Record); - } - } catch (error) { - if (reqMessage.resolver) { - reqMessage.resolver.setException(error instanceof Error ? error : new Error(String(error))); - } - } - } - // For notifications, we'd send them too but this example focuses on requests - } - } - - private async waitForUpdate(taskId: string): Promise { - // Race between store update and queue message - await Promise.race([this.store.waitForUpdate(taskId), this.queue.waitForMessage(taskId)]); - } - - routeResponse(requestId: RequestId, response: Record): boolean { - const resolver = this.pendingRequests.get(requestId); - if (resolver && !resolver.done()) { - this.pendingRequests.delete(requestId); - resolver.setResult(response); - return true; - } - return false; - } - - routeError(requestId: RequestId, error: Error): boolean { - const resolver = this.pendingRequests.get(requestId); - if (resolver && !resolver.done()) { - this.pendingRequests.delete(requestId); - resolver.setException(error); - return true; - } - return false; - } -} - -// ============================================================================ -// Task Session - wraps server to enqueue requests during task execution -// ============================================================================ - -class TaskSession { - private requestCounter = 0; - - constructor( - private server: Server, - private taskId: string, - private store: TaskStoreWithNotifications, - private queue: TaskMessageQueueWithResolvers - ) {} - - private nextRequestId(): string { - return `task-${this.taskId}-${++this.requestCounter}`; - } - - async elicit( - message: string, - requestedSchema: { - type: 'object'; - properties: Record; - required?: string[]; - } - ): Promise<{ action: string; content?: Record }> { - // Update task status to input_required - await this.store.updateTaskStatus(this.taskId, 'input_required'); - - const requestId = this.nextRequestId(); - - // Build the elicitation request with related-task metadata - const params: ElicitRequestFormParams = { - message, - requestedSchema, - mode: 'form', - _meta: { - [RELATED_TASK_META_KEY]: { taskId: this.taskId } - } - }; - - const jsonrpcRequest: JSONRPCRequest = { - jsonrpc: '2.0', - id: requestId, - method: 'elicitation/create', - params - }; - - // Create resolver to wait for response - const resolver = new Resolver>(); - - // Enqueue the request - await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId); - - try { - // Wait for response - const response = await resolver.wait(); - - // Update status back to working - await this.store.updateTaskStatus(this.taskId, 'working'); - - return response as { action: string; content?: Record }; - } catch (error) { - await this.store.updateTaskStatus(this.taskId, 'working'); - throw error; - } - } - - async createMessage( - messages: SamplingMessage[], - maxTokens: number - ): Promise<{ role: string; content: TextContent | { type: string } }> { - // Update task status to input_required - await this.store.updateTaskStatus(this.taskId, 'input_required'); - - const requestId = this.nextRequestId(); - - // Build the sampling request with related-task metadata - const params = { - messages, - maxTokens, - _meta: { - [RELATED_TASK_META_KEY]: { taskId: this.taskId } - } - }; - - const jsonrpcRequest: JSONRPCRequest = { - jsonrpc: '2.0', - id: requestId, - method: 'sampling/createMessage', - params - }; - - // Create resolver to wait for response - const resolver = new Resolver>(); - - // Enqueue the request - await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId); - - try { - // Wait for response - const response = await resolver.wait(); - - // Update status back to working - await this.store.updateTaskStatus(this.taskId, 'working'); - - return response as { role: string; content: TextContent | { type: string } }; - } catch (error) { - await this.store.updateTaskStatus(this.taskId, 'working'); - throw error; - } - } -} - -// ============================================================================ -// Server Setup -// ============================================================================ - -const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8000; - -// Create shared stores -const taskStore = new TaskStoreWithNotifications(); -const messageQueue = new TaskMessageQueueWithResolvers(); -const taskResultHandler = new TaskResultHandler(taskStore, messageQueue); - -// Track active task executions -const activeTaskExecutions = new Map< - string, - { - promise: Promise; - server: Server; - sessionId: string; - } ->(); - -// Create the server -const createServer = (): Server => { - const server = new Server( - { name: 'simple-task-interactive', version: '1.0.0' }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { call: {} } - } - } - } - } - ); - - // Register tools - server.setRequestHandler('tools/list', async (): Promise<{ tools: Tool[] }> => { - return { - tools: [ - { - name: 'confirm_delete', - description: 'Asks for confirmation before deleting (demonstrates elicitation)', - inputSchema: { - type: 'object', - properties: { - filename: { type: 'string' } - } - }, - execution: { taskSupport: 'required' } - }, - { - name: 'write_haiku', - description: 'Asks LLM to write a haiku (demonstrates sampling)', - inputSchema: { - type: 'object', - properties: { - topic: { type: 'string' } - } - }, - execution: { taskSupport: 'required' } - } - ] - }; - }); - - // Handle tool calls - server.setRequestHandler('tools/call', async (request, ctx): Promise => { - const { name, arguments: args } = request.params; - const taskParams = (request.params._meta?.task || request.params.task) as { ttl?: number; pollInterval?: number } | undefined; - - // Validate task mode - these tools require tasks - if (!taskParams) { - throw new Error(`Tool ${name} requires task mode`); - } - - // Create task - const taskOptions: CreateTaskOptions = { - ttl: taskParams.ttl, - pollInterval: taskParams.pollInterval ?? 1000 - }; - - const task = await taskStore.createTask(taskOptions, ctx.mcpReq.id, request, ctx.sessionId); - - console.log(`\n[Server] ${name} called, task created: ${task.taskId}`); - - // Start background task execution - const taskExecution = (async () => { - try { - const taskSession = new TaskSession(server, task.taskId, taskStore, messageQueue); - - if (name === 'confirm_delete') { - const filename = args?.filename ?? 'unknown.txt'; - console.log(`[Server] confirm_delete: asking about '${filename}'`); - - console.log('[Server] Sending elicitation request to client...'); - const result = await taskSession.elicit(`Are you sure you want to delete '${filename}'?`, { - type: 'object', - properties: { - confirm: { type: 'boolean' } - }, - required: ['confirm'] - }); - - console.log( - `[Server] Received elicitation response: action=${result.action}, content=${JSON.stringify(result.content)}` - ); - - let text: string; - if (result.action === 'accept' && result.content) { - const confirmed = result.content.confirm; - text = confirmed ? `Deleted '${filename}'` : 'Deletion cancelled'; - } else { - text = 'Deletion cancelled'; - } - - console.log(`[Server] Completing task with result: ${text}`); - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text }] - }); - } else if (name === 'write_haiku') { - const topic = args?.topic ?? 'nature'; - console.log(`[Server] write_haiku: topic '${topic}'`); - - console.log('[Server] Sending sampling request to client...'); - const result = await taskSession.createMessage( - [ - { - role: 'user', - content: { type: 'text', text: `Write a haiku about ${topic}` } - } - ], - 50 - ); - - let haiku = 'No response'; - if (result.content && 'text' in result.content) { - haiku = (result.content as TextContent).text; - } - - console.log(`[Server] Received sampling response: ${haiku.slice(0, 50)}...`); - console.log('[Server] Completing task with haiku'); - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: `Haiku:\n${haiku}` }] - }); - } - } catch (error) { - console.error(`[Server] Task ${task.taskId} failed:`, error); - await taskStore.storeTaskResult(task.taskId, 'failed', { - content: [{ type: 'text', text: `Error: ${error}` }], - isError: true - }); - } finally { - activeTaskExecutions.delete(task.taskId); - } - })(); - - activeTaskExecutions.set(task.taskId, { - promise: taskExecution, - server, - sessionId: ctx.sessionId ?? '' - }); - - return { task }; - }); - - // Handle tasks/get - server.setRequestHandler('tasks/get', async (request): Promise => { - const { taskId } = request.params; - const task = await taskStore.getTask(taskId); - if (!task) { - throw new Error(`Task ${taskId} not found`); - } - return task; - }); - - // Handle tasks/result - server.setRequestHandler('tasks/result', async (request, ctx): Promise => { - const { taskId } = request.params; - console.log(`[Server] tasks/result called for task ${taskId}`); - return taskResultHandler.handle(taskId, server, ctx.sessionId ?? ''); - }); - - return server; -}; - -// ============================================================================ -// Express App Setup -// ============================================================================ - -const app = createMcpExpressApp(); - -// Map to store transports by session ID -const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; - -// Helper to check if request is initialize -const isInitializeRequest = (body: unknown): boolean => { - return typeof body === 'object' && body !== null && 'method' in body && (body as { method: string }).method === 'initialize'; -}; - -// MCP POST endpoint -app.post('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - try { - let transport: NodeStreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - transport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: sid => { - console.log(`Session initialized: ${sid}`); - transports[sid] = transport; - } - }); - - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}`); - delete transports[sid]; - } - }; - - const server = createServer(); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; - } else if (sessionId) { - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32_001, message: 'Session not found' }, - id: null - }); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32_000, message: 'Bad Request: Session ID required' }, - id: null - }); - return; - } - - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { code: -32_603, message: 'Internal server error' }, - id: null - }); - } - } -}); - -// Handle GET requests for SSE streams -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}); - -// Handle DELETE requests for session termination -app.delete('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId) { - res.status(400).send('Missing session ID'); - return; - } - if (!transports[sessionId]) { - res.status(404).send('Session not found'); - return; - } - - console.log(`Session termination request: ${sessionId}`); - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}); - -// Start server -app.listen(PORT, () => { - console.log(`Starting server on http://localhost:${PORT}/mcp`); - console.log('\nAvailable tools:'); - console.log(' - confirm_delete: Demonstrates elicitation (asks user y/n)'); - console.log(' - write_haiku: Demonstrates sampling (requests LLM completion)'); -}); - -// Handle shutdown -process.on('SIGINT', async () => { - console.log('\nShutting down server...'); - for (const sessionId of Object.keys(transports)) { - try { - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing session ${sessionId}:`, error); - } - } - taskStore.cleanup(); - messageQueue.cleanup(); - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/ssePollingExample.ts b/examples/server/src/ssePollingExample.ts index 7c318d70d9..2675a038ed 100644 --- a/examples/server/src/ssePollingExample.ts +++ b/examples/server/src/ssePollingExample.ts @@ -37,14 +37,14 @@ const getServer = () => { // Register a long-running tool that demonstrates server-initiated disconnect server.registerTool( - 'long-task', + 'long-operation', { - description: 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.' + description: 'A long-running operation that sends progress updates. Server will disconnect mid-stream to demonstrate polling.' }, async (ctx): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - console.log(`[${ctx.sessionId}] Starting long-task...`); + console.log(`[${ctx.sessionId}] Starting long-operation...`); // Send first progress notification await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...'); @@ -70,13 +70,13 @@ const getServer = () => { await sleep(500); await ctx.mcpReq.log('info', 'Progress: 100% - Complete!'); - console.log(`[${ctx.sessionId}] Task complete`); + console.log(`[${ctx.sessionId}] Operation complete`); return { content: [ { type: 'text', - text: 'Long task completed successfully!' + text: 'Long operation completed successfully!' } ] }; @@ -131,5 +131,5 @@ app.listen(PORT, () => { console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)'); console.log('- eventStore: InMemoryEventStore (events are persisted for replay)'); console.log(''); - console.log('Try calling the "long-task" tool to see server-initiated disconnect in action.'); + console.log('Try calling the "long-operation" tool to see server-initiated disconnect in action.'); }); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5fa2e14d94..f9894c6f14 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -29,24 +29,19 @@ import type { Result, ServerCapabilities, SubscribeRequest, - TaskManagerOptions, Tool, Transport, UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - assertClientRequestTaskCapability, - assertToolsCallTaskCapability, CallToolResultSchema, CompleteResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, - extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, LATEST_PROTOCOL_VERSION, @@ -65,8 +60,6 @@ import { SdkErrorCode } from '@modelcontextprotocol/core'; -import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; - /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -141,19 +134,11 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e return { supportsFormMode, supportsUrlMode }; } -/** - * Extended tasks capability that includes runtime configuration (store, messageQueue). - * The runtime-only fields are stripped before advertising capabilities to servers. - */ -export type ClientTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; - export type ClientOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this client. */ - capabilities?: Omit & { - tasks?: ClientTasksCapabilityWithRuntime; - }; + capabilities?: ClientCapabilities; /** * JSON Schema validator for tool output validation. @@ -230,9 +215,6 @@ export class Client extends Protocol { private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); - private _cachedKnownTaskTools: Set = new Set(); - private _cachedRequiredTaskTools: Set = new Set(); - private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; @@ -244,22 +226,11 @@ export class Client extends Protocol { private _clientInfo: Implementation, options?: ClientOptions ) { - super({ - ...options, - tasks: extractTaskManagerOptions(options?.capabilities?.tasks) - }); + super(options); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; - // Strip runtime-only fields from advertised capabilities - if (options?.capabilities?.tasks) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = - options.capabilities.tasks; - this._capabilities.tasks = wireCapabilities; - } - // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { this._pendingListChangedConfig = options.listChanged; @@ -299,22 +270,6 @@ export class Client extends Protocol { } } - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalClientTasks } { - 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. * @@ -360,20 +315,6 @@ export class Client extends Protocol { const result = await handler(request, ctx); - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against ElicitResultSchema const validationResult = parseSchema(ElicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist @@ -416,20 +357,6 @@ export class Client extends Protocol { const result = await handler(request, ctx); - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against appropriate schema based on tools presence const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; const validationResult = parseSchema(resultSchema, result); @@ -701,14 +628,6 @@ export class Client extends Protocol { } } - protected assertTaskCapability(method: string): void { - assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server'); - } - - protected assertTaskHandlerCapability(method: string): void { - assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client'); - } - async ping(options?: RequestOptions) { return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); } @@ -828,8 +747,6 @@ export class Client extends Protocol { * a problem), and thrown {@linkcode ProtocolError} for protocol-level failures or {@linkcode SdkError} for * SDK-level issues (timeouts, missing capabilities). * - * For task-based execution with streaming behavior, use {@linkcode ExperimentalClientTasks.callToolStream | client.experimental.tasks.callToolStream()} instead. - * * @example Basic usage * ```ts source="./client.examples.ts#Client_callTool_basic" * const result = await client.callTool({ @@ -860,14 +777,6 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - // Guard: required-task tools need experimental API - if (this.isToolTaskRequired(params.name)) { - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` - ); - } - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); // Check if the tool has an outputSchema @@ -908,30 +817,12 @@ export class Client extends Protocol { 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 {@linkcode 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 {@linkcode listTools | listTools()} to pre-compile validators for better performance. */ 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 @@ -939,15 +830,6 @@ export class Client extends Protocol { 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); - } } } diff --git a/packages/client/src/client/streamableHttp.examples.ts b/packages/client/src/client/streamableHttp.examples.ts index 74023fa51f..5a67ed576f 100644 --- a/packages/client/src/client/streamableHttp.examples.ts +++ b/packages/client/src/client/streamableHttp.examples.ts @@ -18,7 +18,7 @@ declare const platformBackgroundTask: { }; /** - * Example: Using a platform background-task API to schedule reconnections. + * Example: Using a platform background-scheduler API to schedule reconnections. */ function ReconnectionScheduler_basicUsage() { //#region ReconnectionScheduler_basicUsage diff --git a/packages/client/src/experimental/index.ts b/packages/client/src/experimental/index.ts deleted file mode 100644 index 926369f994..0000000000 --- a/packages/client/src/experimental/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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/client.js'; diff --git a/packages/client/src/experimental/tasks/client.examples.ts b/packages/client/src/experimental/tasks/client.examples.ts deleted file mode 100644 index 5652062758..0000000000 --- a/packages/client/src/experimental/tasks/client.examples.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Type-checked examples for `client.ts`. - * - * These examples are synced into JSDoc comments via the sync-snippets script. - * Each function's region markers define the code snippet that appears in the docs. - * - * @module - */ - -import type { RequestOptions } from '@modelcontextprotocol/core'; - -import type { Client } from '../../client/client.js'; - -/** - * Example: Using callToolStream to execute a tool with task lifecycle events. - */ -async function ExperimentalClientTasks_callToolStream(client: Client) { - //#region ExperimentalClientTasks_callToolStream - 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; - } - } - } - //#endregion ExperimentalClientTasks_callToolStream -} - -/** - * Example: Using requestStream to consume task lifecycle events for any request type. - */ -async function ExperimentalClientTasks_requestStream(client: Client, options: RequestOptions) { - //#region ExperimentalClientTasks_requestStream - const stream = client.experimental.tasks.requestStream({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, options); - for await (const message of stream) { - switch (message.type) { - case 'taskCreated': { - console.log('Task created:', message.task.taskId); - break; - } - case 'taskStatus': { - console.log('Task status:', message.task.status); - break; - } - case 'result': { - console.log('Final result:', message.result); - break; - } - case 'error': { - console.error('Error:', message.error); - break; - } - } - } - //#endregion ExperimentalClientTasks_requestStream -} diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts deleted file mode 100644 index 75ba873c97..0000000000 --- a/packages/client/src/experimental/tasks/client.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Experimental client task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import type { - AnyObjectSchema, - CallToolRequest, - CallToolResult, - CancelTaskResult, - CreateTaskResult, - GetTaskPayloadResult, - GetTaskResult, - ListTasksResult, - Request, - RequestMethod, - RequestOptions, - ResponseMessage, - ResultTypeMap -} from '@modelcontextprotocol/core'; -import { - CallToolResultSchema, - getResultSchema, - GetTaskPayloadResultSchema, - ProtocolError, - ProtocolErrorCode -} from '@modelcontextprotocol/core'; - -import type { Client } from '../../client/client.js'; - -/** - * Internal interface for accessing {@linkcode Client}'s private methods. - * @internal - */ -interface ClientInternal { - 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 { - constructor(private readonly _client: Client) {} - - private get _module() { - return this._client.taskManager; - } - - /** - * 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 - * ```ts source="./client.examples.ts#ExperimentalClientTasks_callToolStream" - * 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 options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects - * - * @experimental - */ - async *callToolStream( - params: CallToolRequest['params'], - options?: RequestOptions - ): AsyncGenerator, void, void> { - // Access Client's internal methods - const clientInternal = this._client as unknown as ClientInternal; - - // 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 = this._module.requestStream({ method: 'tools/call', params }, CallToolResultSchema, 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 - // Only validate CallToolResult (has 'content'), not CreateTaskResult (has 'task') - if (message.type === 'result' && validator && 'content' in message.result) { - const result = message.result as CallToolResult; - - // If tool has outputSchema, it MUST return structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { - yield { - type: 'error', - error: new ProtocolError( - ProtocolErrorCode.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 ProtocolError( - ProtocolErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` - ) - }; - return; - } - } catch (error) { - if (error instanceof ProtocolError) { - yield { type: 'error', error }; - return; - } - yield { - type: 'error', - error: new ProtocolError( - ProtocolErrorCode.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 { - return this._module.getTask({ taskId }, options); - } - - /** - * Retrieves the result of a completed task. - * - * @param taskId - The task identifier - * @param options - Optional request options - * @returns The task result. The payload structure matches the result type of the - * original request (e.g., a `tools/call` task returns a `CallToolResult`). - * - * @experimental - */ - async getTaskResult(taskId: string, options?: RequestOptions): Promise { - return this._module.getTaskResult({ taskId }, GetTaskPayloadResultSchema, 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 { - return this._module.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 { - return this._module.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. - * - * @example - * ```ts source="./client.examples.ts#ExperimentalClientTasks_requestStream" - * const stream = client.experimental.tasks.requestStream({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, options); - * for await (const message of stream) { - * switch (message.type) { - * case 'taskCreated': { - * console.log('Task created:', message.task.taskId); - * break; - * } - * case 'taskStatus': { - * console.log('Task status:', message.task.status); - * break; - * } - * case 'result': { - * console.log('Final result:', message.result); - * break; - * } - * case 'error': { - * console.error('Error:', message.error); - * break; - * } - * } - * } - * ``` - * - * @param request - The request to send - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects - * - * @experimental - */ - requestStream( - request: { method: M; params?: Record }, - options?: RequestOptions - ): AsyncGenerator, void, void> { - const resultSchema = getResultSchema(request.method) as unknown as AnyObjectSchema; - return this._module.requestStream(request as Request, resultSchema, options) as AsyncGenerator< - ResponseMessage, - void, - void - >; - } -} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 06ca1141b2..8a08e8fd79 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -71,9 +71,6 @@ export type { } from './client/streamableHttp.js'; export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; -// experimental exports -export { ExperimentalClientTasks } from './experimental/tasks/client.js'; - // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; diff --git a/packages/core/src/experimental/index.ts b/packages/core/src/experimental/index.ts deleted file mode 100644 index ea39eb79f6..0000000000 --- a/packages/core/src/experimental/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './tasks/helpers.js'; -export * from './tasks/interfaces.js'; -export * from './tasks/stores/inMemory.js'; diff --git a/packages/core/src/experimental/tasks/helpers.ts b/packages/core/src/experimental/tasks/helpers.ts deleted file mode 100644 index 7a13fffbd3..0000000000 --- a/packages/core/src/experimental/tasks/helpers.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Experimental task capability assertion helpers. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; - -/** - * 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 to implement the `assertTaskCapability` or `assertTaskHandlerCapability` abstract methods on Protocol. - * - * @param requests - The task requests capability object - * @param method - The method being checked - * @param entityName - `'Server'` or `'Client'` for error messages - * @throws {@linkcode SdkError} with {@linkcode SdkErrorCode.CapabilityNotSupported} if the capability is not supported - * - * @experimental - */ -export function assertToolsCallTaskCapability( - requests: TaskRequestsCapability | undefined, - method: string, - entityName: 'Server' | 'Client' -): void { - if (!requests) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `${entityName} does not support task creation (required for ${method})`); - } - - switch (method) { - case 'tools/call': { - if (!requests.tools?.call) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `${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 to implement the `assertTaskCapability` or `assertTaskHandlerCapability` abstract methods on Protocol. - * - * @param requests - The task requests capability object - * @param method - The method being checked - * @param entityName - `'Server'` or `'Client'` for error messages - * @throws {@linkcode SdkError} with {@linkcode SdkErrorCode.CapabilityNotSupported} if the capability is not supported - * - * @experimental - */ -export function assertClientRequestTaskCapability( - requests: TaskRequestsCapability | undefined, - method: string, - entityName: 'Server' | 'Client' -): void { - if (!requests) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, `${entityName} does not support task creation (required for ${method})`); - } - - switch (method) { - case 'sampling/createMessage': { - if (!requests.sampling?.createMessage) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `${entityName} does not support task creation for sampling/createMessage (required for ${method})` - ); - } - break; - } - - case 'elicitation/create': { - if (!requests.elicitation?.create) { - throw new SdkError( - SdkErrorCode.CapabilityNotSupported, - `${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; - } - } -} diff --git a/packages/core/src/experimental/tasks/interfaces.ts b/packages/core/src/experimental/tasks/interfaces.ts deleted file mode 100644 index d980f304ca..0000000000 --- a/packages/core/src/experimental/tasks/interfaces.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Experimental task interfaces for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - */ - -import type { ServerContext } from '../../shared/protocol.js'; -import type { RequestTaskStore } from '../../shared/taskManager.js'; -import type { - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResultResponse, - Request, - RequestId, - Result, - Task, - ToolExecution -} from '../../types/index.js'; - -// ============================================================================ -// Task Handler Types (for registerToolTask) -// ============================================================================ - -/** - * Server context with guaranteed task store for task creation. - * @experimental - */ -export type CreateTaskServerContext = ServerContext & { - task: { store: RequestTaskStore; requestedTtl?: number }; -}; - -/** - * Server context with guaranteed task ID and store for task operations. - * @experimental - */ -export type TaskServerContext = ServerContext & { - task: { id: string; store: RequestTaskStore; requestedTtl?: number }; -}; - -/** - * Task-specific execution configuration. - * `taskSupport` cannot be `'forbidden'` for task-based tools. - * @experimental - */ -export type TaskToolExecution = Omit & { - 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: JSONRPCResultResponse; -} - -export interface QueuedError extends BaseQueuedMessage { - type: 'error'; - /** The actual JSONRPC error */ - message: JSONRPCErrorResponse; -} - -/** - * Interface for managing per-task FIFO message queues. - * - * Similar to {@linkcode 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 {@linkcode QueuedMessage} must be JSON-serializable. - * - * @see {@linkcode InMemoryTaskMessageQueue} for a reference implementation - * @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; - - /** - * 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; - - /** - * 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; -} - -/** - * Task creation options. - * @experimental - */ -export interface CreateTaskOptions { - /** - * Duration in milliseconds to retain task from creation. - * 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; -} - -/** - * Interface for storing and retrieving task state and results. - * - * Similar to {@linkcode Transport}, this allows pluggable task storage implementations - * (in-memory, database, distributed cache, etc.). - * - * @see {@linkcode InMemoryTaskStore} for a reference implementation - * @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 {@linkcode 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 {@linkcode Task} object - */ - createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, sessionId?: string): Promise; - - /** - * 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 {@linkcode Task} object, or `null` if it does not exist - */ - getTask(taskId: string, sessionId?: string): Promise; - - /** - * 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; - - /** - * 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; - - /** - * 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; - - /** - * 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'; -} diff --git a/packages/core/src/experimental/tasks/stores/inMemory.ts b/packages/core/src/experimental/tasks/stores/inMemory.ts deleted file mode 100644 index fbd7e39f53..0000000000 --- a/packages/core/src/experimental/tasks/stores/inMemory.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * In-memory implementations of {@linkcode TaskStore} and {@linkcode TaskMessageQueue}. - * @experimental - */ - -import type { Request, RequestId, Result, Task } from '../../../types/index.js'; -import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../interfaces.js'; -import { isTerminal } from '../interfaces.js'; - -interface StoredTask { - task: Task; - request: Request; - requestId: RequestId; - sessionId?: string; - result?: Result; -} - -/** - * In-memory {@linkcode TaskStore} implementation for development and testing. - * For production, use a database or distributed cache. - * @experimental - */ -export class InMemoryTaskStore implements TaskStore { - private tasks = new Map(); - private cleanupTimers = new Map>(); - - /** - * Generates a unique task ID using Web Crypto API. - */ - private generateTaskId(): string { - return crypto.randomUUID().replaceAll('-', ''); - } - - /** {@inheritDoc TaskStore.createTask} */ - async createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, sessionId?: string): Promise { - // 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, - sessionId - }); - - // 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; - } - - /** - * Retrieves a stored task, enforcing session ownership when a sessionId is provided. - * Returns undefined if the task does not exist or belongs to a different session. - */ - private getStoredTask(taskId: string, sessionId?: string): StoredTask | undefined { - const stored = this.tasks.get(taskId); - if (!stored) { - return undefined; - } - // Enforce session isolation: if a sessionId is provided and the task - // was created with a sessionId, they must match. - if (sessionId !== undefined && stored.sessionId !== undefined && stored.sessionId !== sessionId) { - return undefined; - } - return stored; - } - - async getTask(taskId: string, sessionId?: string): Promise { - const stored = this.getStoredTask(taskId, sessionId); - return stored ? { ...stored.task } : null; - } - - /** {@inheritDoc TaskStore.storeTaskResult} */ - async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise { - const stored = this.getStoredTask(taskId, sessionId); - 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); - } - } - - /** {@inheritDoc TaskStore.getTaskResult} */ - async getTaskResult(taskId: string, sessionId?: string): Promise { - const stored = this.getStoredTask(taskId, sessionId); - 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; - } - - /** {@inheritDoc TaskStore.updateTaskStatus} */ - async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { - const stored = this.getStoredTask(taskId, sessionId); - 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); - } - } - - /** {@inheritDoc TaskStore.listTasks} */ - async listTasks(cursor?: string, sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { - const PAGE_SIZE = 10; - - // Filter tasks by session ownership before pagination - const filteredTaskIds = [...this.tasks.entries()] - .filter(([, stored]) => { - if (sessionId === undefined || stored.sessionId === undefined) { - return true; - } - return stored.sessionId === sessionId; - }) - .map(([taskId]) => taskId); - - let startIndex = 0; - if (cursor) { - const cursorIndex = filteredTaskIds.indexOf(cursor); - if (cursorIndex === -1) { - // Invalid cursor - throw error - throw new Error(`Invalid cursor: ${cursor}`); - } else { - startIndex = cursorIndex + 1; - } - } - - const pageTaskIds = filteredTaskIds.slice(startIndex, startIndex + PAGE_SIZE); - const tasks = pageTaskIds.map(taskId => { - const stored = this.tasks.get(taskId)!; - return { ...stored.task }; - }); - - const nextCursor = startIndex + PAGE_SIZE < filteredTaskIds.length ? pageTaskIds.at(-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 [...this.tasks.values()].map(stored => ({ ...stored.task })); - } -} - -/** - * In-memory {@linkcode TaskMessageQueue} implementation for development and testing. - * For production, use Redis or another distributed queue. - * @experimental - */ -export class InMemoryTaskMessageQueue implements TaskMessageQueue { - private queues = new Map(); - - /** - * 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 { - 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 { - 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 { - const key = this.getQueueKey(taskId, sessionId); - const queue = this.queues.get(key) ?? []; - this.queues.delete(key); - return queue; - } -} diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index e305f32a44..28c36538e0 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -51,20 +51,6 @@ export type { } from '../../shared/protocol.js'; export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; -// Task manager types (NOT TaskManager class itself — internal) -export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; - -// Response message types -export type { - BaseResponseMessage, - ErrorMessage, - ResponseMessage, - ResultMessage, - TaskCreatedMessage, - TaskStatusMessage -} from '../../shared/responseMessage.js'; -export { takeResult, toArrayAsync } from '../../shared/responseMessage.js'; - // stdio message framing utilities (for custom transport authors) export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; @@ -92,7 +78,6 @@ export { LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, PARSE_ERROR, - RELATED_TASK_META_KEY, SUPPORTED_PROTOCOL_VERSIONS } from '../../types/constants.js'; @@ -114,29 +99,9 @@ export { isJSONRPCRequest, isJSONRPCResponse, isJSONRPCResultResponse, - isTaskAugmentedRequestParams, parseJSONRPCMessage } from '../../types/guards.js'; -// Experimental task types and classes -export { assertClientRequestTaskCapability, assertToolsCallTaskCapability } from '../../experimental/tasks/helpers.js'; -export type { - BaseQueuedMessage, - CreateTaskOptions, - CreateTaskServerContext, - QueuedError, - QueuedMessage, - QueuedNotification, - QueuedRequest, - QueuedResponse, - TaskMessageQueue, - TaskServerContext, - TaskStore, - TaskToolExecution -} from '../../experimental/tasks/interfaces.js'; -export { isTerminal } from '../../experimental/tasks/interfaces.js'; -export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; - // Validator types and classes export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js'; export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8bcc9c9591..0c34b64915 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,10 +4,7 @@ export * from './shared/auth.js'; export * from './shared/authUtils.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; -export * from './shared/responseMessage.js'; export * from './shared/stdio.js'; -export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; -export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; @@ -16,9 +13,6 @@ export * from './util/inMemory.js'; export * from './util/schema.js'; export * from './util/standardSchema.js'; export * from './util/zodCompat.js'; - -// experimental exports -export * from './experimental/index.js'; export * from './validators/ajvProvider.js'; // cfWorkerProvider is intentionally NOT re-exported here: it statically imports // `@cfworker/json-schema` (an optional peer), and bundling it into the main barrel diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 361bd6fc7c..ed78cc68d0 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -21,7 +21,6 @@ import type { NotificationTypeMap, Progress, ProgressNotification, - RelatedTaskMetadata, Request, RequestId, RequestMeta, @@ -29,8 +28,7 @@ import type { RequestTypeMap, Result, ResultTypeMap, - ServerCapabilities, - TaskCreationParams + ServerCapabilities } from '../types/index.js'; import { getNotificationSchema, @@ -46,8 +44,6 @@ import { } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; -import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; -import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -82,16 +78,6 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; - - /** - * Runtime configuration for task management. - * If provided, creates a TaskManager with the given options; otherwise a NullTaskManager is used. - * - * Capability assertions are wired automatically from the protocol's - * `assertTaskCapability()` and `assertTaskHandlerCapability()` methods, - * so they should NOT be included here. - */ - tasks?: TaskManagerOptions; }; /** @@ -105,8 +91,6 @@ export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000; export type RequestOptions = { /** * If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked. - * - * For task-augmented requests: progress notifications continue after {@linkcode CreateTaskResult} is returned and stop automatically when the task reaches a terminal status. */ onprogress?: ProgressCallback; @@ -135,16 +119,6 @@ export type RequestOptions = { * If not specified, there is no maximum total timeout. */ maxTotalTimeout?: number; - - /** - * If provided, augments the request with task creation parameters to enable call-now, fetch-later execution patterns. - */ - task?: TaskCreationParams; - - /** - * If provided, associates this request with a related task. - */ - relatedTask?: RelatedTaskMetadata; } & TransportSendOptions; /** @@ -155,11 +129,6 @@ export type NotificationOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing notification with. */ relatedRequestId?: RequestId; - - /** - * If provided, associates this notification with a related task. - */ - relatedTask?: RelatedTaskMetadata; }; /** @@ -206,12 +175,12 @@ export type BaseContext = { send: { ( request: { method: M; params?: Record }, - options?: TaskRequestOptions + options?: RequestOptions ): Promise; ( request: Request, resultSchema: T, - options?: TaskRequestOptions + options?: RequestOptions ): Promise>; }; @@ -232,11 +201,6 @@ export type BaseContext = { */ authInfo?: AuthInfo; }; - - /** - * Task context, available when task storage is configured. - */ - task?: TaskContext; }; /** @@ -319,8 +283,6 @@ export abstract class Protocol { private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); - private _taskManager: TaskManager; - protected _supportedProtocolVersions: string[]; /** @@ -350,10 +312,6 @@ export abstract class Protocol { constructor(private _options?: ProtocolOptions) { this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - // Create TaskManager from protocol options - this._taskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); - this._bindTaskManager(); - this.setNotificationHandler('notifications/cancelled', notification => { this._oncancel(notification); }); @@ -369,39 +327,6 @@ export abstract class Protocol { ); } - /** - * Access the TaskManager for task orchestration. - * Always available; returns a NullTaskManager when no task store is configured. - */ - get taskManager(): TaskManager { - return this._taskManager; - } - - private _bindTaskManager(): void { - const taskManager = this._taskManager; - const host: TaskManagerHost = { - request: (request, resultSchema, options) => this._requestWithSchema(request, resultSchema, options), - notification: (notification, options) => this.notification(notification, options), - reportError: error => this._onerror(error), - removeProgressHandler: token => this._progressHandlers.delete(token), - registerHandler: (method, handler) => { - const schema = getRequestSchema(method as RequestMethod); - this._requestHandlers.set(method, (request, ctx) => { - // Validate request params via Zod (strips jsonrpc/id, so we pass original to handler) - schema.parse(request); - return handler(request, ctx); - }); - }, - sendOnResponseStream: async (message, relatedRequestId) => { - await this._transport?.send(message, { relatedRequestId }); - }, - enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, - assertTaskCapability: method => this.assertTaskCapability(method), - assertTaskHandlerCapability: method => this.assertTaskHandlerCapability(method) - }; - taskManager.bind(host); - } - /** * Builds the context object for request handlers. Subclasses must override * to return the appropriate context type (e.g., ServerContext adds HTTP request info). @@ -506,7 +431,6 @@ export abstract class Protocol { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); - this._taskManager.onClose(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) { @@ -558,23 +482,10 @@ export abstract class Protocol { // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; - // Delegate context extraction to module (if registered) - const inboundCtx = { - sessionId: capturedTransport?.sessionId, - sendNotification: (notification: Notification, options?: NotificationOptions) => - this.notification(notification, { ...options, relatedRequestId: request.id }), - sendRequest: (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }) - }; - - // Delegate to TaskManager for task context, wrapped send/notify, and response routing - const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); - const sendNotification = taskResult.sendNotification; - const sendRequest = taskResult.sendRequest; - const taskContext = taskResult.taskContext; - const routeResponse = taskResult.routeResponse; - const validators: Array<() => void> = []; - if (taskResult.validateInbound) validators.push(taskResult.validateInbound); + const sendNotification = (notification: Notification, options?: NotificationOptions) => + this.notification(notification, { ...options, relatedRequestId: request.id }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }); if (handler === undefined) { const errorResponse: JSONRPCErrorResponse = { @@ -585,17 +496,7 @@ export abstract class Protocol { message: 'Method not found' } }; - - // Queue or send the error response based on whether this is a task-related request - routeResponse(errorResponse) - .then(routed => { - if (!routed) { - capturedTransport - ?.send(errorResponse) - .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); - } - }) - .catch(error => this._onerror(new Error(`Failed to enqueue error response: ${error}`))); + capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); return; } @@ -613,7 +514,7 @@ export abstract class Protocol { // literals can't carry overload signatures, so the inferred single-signature type isn't assignable to // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. - send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | TaskRequestOptions, maybeOptions?: TaskRequestOptions) => { + send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => { if (isStandardSchema(schemaOrOptions)) { return sendRequest(r, schemaOrOptions, maybeOptions); } @@ -627,18 +528,12 @@ export abstract class Protocol { }) as BaseContext['mcpReq']['send'], notify: sendNotification }, - http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, - task: taskContext + http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined }; const ctx = this.buildContext(baseCtx, extra); // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => { - for (const validate of validators) { - validate(); - } - }) .then(() => handler(request, ctx)) .then( async result => { @@ -652,12 +547,7 @@ export abstract class Protocol { jsonrpc: '2.0', id: request.id }; - - // Queue or send the response based on whether this is a task-related request - const routed = await routeResponse(response); - if (!routed) { - await capturedTransport?.send(response); - } + await capturedTransport?.send(response); }, async error => { if (abortController.signal.aborted) { @@ -674,12 +564,7 @@ export abstract class Protocol { ...(error['data'] !== undefined && { data: error['data'] }) } }; - - // Queue or send the error response based on whether this is a task-related request - const routed = await routeResponse(errorResponse); - if (!routed) { - await capturedTransport?.send(errorResponse); - } + await capturedTransport?.send(errorResponse); } ) .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) @@ -722,11 +607,6 @@ export abstract class Protocol { private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); - // Delegate to TaskManager for task-related response handling - const taskResult = this._taskManager.processInboundResponse(response, messageId); - if (taskResult.consumed) return; - const preserveProgress = taskResult.preserveProgress; - const handler = this._responseHandlers.get(messageId); if (handler === undefined) { this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); @@ -735,11 +615,7 @@ export abstract class Protocol { this._responseHandlers.delete(messageId); this._cleanupTimeout(messageId); - - // Keep progress handler alive for CreateTaskResult responses - if (!preserveProgress) { - this._progressHandlers.delete(messageId); - } + this._progressHandlers.delete(messageId); if (isJSONRPCResultResponse(response)) { handler(response); @@ -781,22 +657,6 @@ export abstract class Protocol { */ protected abstract assertRequestHandlerCapability(method: string): void; - /** - * A method to check if the remote side supports task creation for the given method. - * - * Called when sending a task-augmented outbound request (only when enforceStrictCapabilities is true). - * This should be implemented by subclasses. - */ - protected abstract assertTaskCapability(method: string): void; - - /** - * A method to check if this side supports handling task creation for the given method. - * - * Called when receiving a task-augmented inbound request. - * This should be implemented by subclasses. - */ - protected abstract assertTaskHandlerCapability(method: string): void; - /** * Sends a request and waits for a response. * @@ -831,7 +691,7 @@ export abstract class Protocol { * Sends a request and waits for a response, using the provided schema for validation. * * This is the internal implementation used by SDK methods that need to specify - * a particular result schema (e.g., for compatibility or task-specific schemas). + * a particular result schema (e.g., for compatibility schemas). */ protected _requestWithSchema( request: Request, @@ -938,44 +798,15 @@ export abstract class Protocol { this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - // Delegate task augmentation and routing to module (if registered) - const responseHandler = (response: JSONRPCResultResponse | Error) => { - const handler = this._responseHandlers.get(messageId); - if (handler) { - handler(response); - } else { - this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); - } - }; - - let outboundQueued = false; - try { - const taskResult = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, error => { - this._progressHandlers.delete(messageId); - reject(error); - }); - if (taskResult.queued) { - outboundQueued = true; - } - } catch (error) { + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { this._progressHandlers.delete(messageId); reject(error); - return; - } - - if (!outboundQueued) { - // No related task or no module - send through transport normally - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); - } + }); }).finally(() => { // Per-request cleanup that must run on every exit path. Consolidated // here so new exit paths added to the promise body can't forget it. // _progressHandlers is NOT cleaned up here: _onresponse deletes it - // conditionally (preserveProgress for task flows), and error paths - // above delete it inline since no task exists in those cases. + // on resolution, and error paths above delete it inline. if (onAbort) { options?.signal?.removeEventListener('abort', onAbort); } @@ -996,21 +827,12 @@ export abstract class Protocol { this.assertNotificationCapability(notification.method); - // Delegate task-related notification routing and JSONRPC building to TaskManager - const taskResult = await this._taskManager.processOutboundNotification(notification, options); - const queued = taskResult.queued; - const jsonrpcNotification = taskResult.queued ? undefined : taskResult.jsonrpcNotification; - - if (queued) { - // Don't send through transport - queued messages are delivered via tasks/result only - return; - } + const jsonrpcNotification: JSONRPCNotification = { jsonrpc: '2.0', ...notification }; const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; // A notification can only be debounced if it's in the list AND it's "simple" - // (i.e., has no parameters and no related request ID or related task that could be lost). - const canDebounce = - debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId && !options?.relatedTask; + // (i.e., has no parameters and no related request ID that could be lost). + const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId; if (canDebounce) { // If a notification of this type is already scheduled, do nothing. @@ -1034,14 +856,14 @@ export abstract class Protocol { // Send the notification, but don't await it here to avoid blocking. // Handle potential errors with a .catch(). - this._transport?.send(jsonrpcNotification!, options).catch(error => this._onerror(error)); + this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error)); }); // Return immediately. return; } - await this._transport.send(jsonrpcNotification!, options); + await this._transport.send(jsonrpcNotification, options); } /** diff --git a/packages/core/src/shared/responseMessage.ts b/packages/core/src/shared/responseMessage.ts deleted file mode 100644 index 25922a355f..0000000000 --- a/packages/core/src/shared/responseMessage.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Result, Task } from '../types/index.js'; - -/** - * Base message type for the response stream. - */ -export interface BaseResponseMessage { - type: string; -} - -/** - * Task status update message. - * - * Yielded on each poll iteration while the task is active (e.g. while - * `working`). May be emitted multiple times with the same status. - */ -export interface TaskStatusMessage extends BaseResponseMessage { - type: 'taskStatus'; - task: Task; -} - -/** - * Task created message. - * - * Yielded once when the server creates a new task for a long-running operation. - * This is always the first message for task-augmented requests. - */ -export interface TaskCreatedMessage extends BaseResponseMessage { - type: 'taskCreated'; - task: Task; -} - -/** - * Final result message. - * - * Yielded once when the operation completes successfully. Terminal — no further - * messages will follow. - */ -export interface ResultMessage extends BaseResponseMessage { - type: 'result'; - result: T; -} - -/** - * Error message. - * - * Yielded once if the operation fails. Terminal — no further messages will follow. - */ -export interface ErrorMessage extends BaseResponseMessage { - type: 'error'; - error: Error; -} - -/** - * Union of all message types yielded by task-aware streaming APIs such as - * {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#callToolStream | callToolStream()}, - * {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#requestStream | ExperimentalClientTasks.requestStream()}, and - * {@linkcode @modelcontextprotocol/server!experimental/tasks/server.ExperimentalServerTasks#requestStream | ExperimentalServerTasks.requestStream()}. - * - * A typical sequence is: - * 1. `taskCreated` — task is registered (once) - * 2. `taskStatus` — zero or more progress updates - * 3. `result` **or** `error` — terminal message (once) - * - * Progress notifications are handled through the existing {@linkcode index.RequestOptions | onprogress} callback. - * Side-channeled messages (server requests/notifications) are handled through registered handlers. - */ -export type ResponseMessage = TaskStatusMessage | TaskCreatedMessage | ResultMessage | ErrorMessage; - -export type AsyncGeneratorValue = T extends AsyncGenerator ? U : never; - -/** - * Collects all values from an async generator into an array. - */ -export async function toArrayAsync>(it: T): Promise[]> { - const arr: AsyncGeneratorValue[] = []; - for await (const o of it) { - arr.push(o as AsyncGeneratorValue); - } - - return arr; -} - -/** - * Consumes a {@linkcode ResponseMessage} stream and returns the final result, - * discarding intermediate `taskCreated` and `taskStatus` messages. Throws - * if an `error` message is received or the stream ends without a result. - */ -export async function takeResult>>(it: U): Promise { - for await (const o of it) { - if (o.type === 'result') { - return o.result; - } else if (o.type === 'error') { - throw o.error; - } - } - - throw new Error('No result in stream.'); -} diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts deleted file mode 100644 index 257dbec827..0000000000 --- a/packages/core/src/shared/taskManager.ts +++ /dev/null @@ -1,915 +0,0 @@ -import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js'; -import { isTerminal } from '../experimental/tasks/interfaces.js'; -import type { - GetTaskPayloadRequest, - GetTaskRequest, - GetTaskResult, - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - JSONRPCResultResponse, - Notification, - Request, - RequestId, - Result, - Task, - TaskCreationParams, - TaskStatusNotification -} from '../types/index.js'; -import { - CancelTaskResultSchema, - CreateTaskResultSchema, - GetTaskResultSchema, - isJSONRPCErrorResponse, - isJSONRPCRequest, - isJSONRPCResultResponse, - isTaskAugmentedRequestParams, - ListTasksResultSchema, - ProtocolError, - ProtocolErrorCode, - RELATED_TASK_META_KEY, - TaskStatusNotificationSchema -} from '../types/index.js'; -import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import type { StandardSchemaV1 } from '../util/standardSchema.js'; -import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js'; -import type { ResponseMessage } from './responseMessage.js'; - -/** - * Host interface for TaskManager to call back into Protocol. @internal - */ -export interface TaskManagerHost { - request( - request: Request, - resultSchema: T, - options?: RequestOptions - ): Promise>; - notification(notification: Notification, options?: NotificationOptions): Promise; - reportError(error: Error): void; - removeProgressHandler(token: number): void; - registerHandler(method: string, handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise): void; - sendOnResponseStream(message: JSONRPCNotification | JSONRPCRequest, relatedRequestId: RequestId): Promise; - enforceStrictCapabilities: boolean; - assertTaskCapability(method: string): void; - assertTaskHandlerCapability(method: string): void; -} - -/** - * Context provided to TaskManager when processing an inbound request. - * @internal - */ -export interface InboundContext { - sessionId?: string; - sendNotification: (notification: Notification, options?: NotificationOptions) => Promise; - sendRequest: ( - request: Request, - resultSchema: U, - options?: RequestOptions - ) => Promise>; -} - -/** - * Result returned by TaskManager after processing an inbound request. - * @internal - */ -export interface InboundResult { - taskContext?: BaseContext['task']; - sendNotification: (notification: Notification) => Promise; - sendRequest: ( - request: Request, - resultSchema: U, - options?: Omit - ) => Promise>; - routeResponse: (message: JSONRPCResponse | JSONRPCErrorResponse) => Promise; - hasTaskCreationParams: boolean; - /** - * Optional validation to run inside the async handler chain (before the request handler). - * Throwing here produces a proper JSON-RPC error response, matching the behavior of - * capability checks on main. - */ - validateInbound?: () => void; -} - -/** - * Options that can be given per request. - */ -// relatedTask is excluded as the SDK controls if this is sent according to if the source is a task. -export type TaskRequestOptions = Omit; - -/** - * Request-scoped TaskStore interface. - */ -export interface RequestTaskStore { - /** - * Creates a new task with the given creation parameters. - * The implementation generates a unique taskId and createdAt timestamp. - * - * @param taskParams - The task creation parameters from the request - * @returns The created task object - */ - createTask(taskParams: CreateTaskOptions): Promise; - - /** - * Gets the current status of a task. - * - * @param taskId - The task identifier - * @returns The task object - * @throws If the task does not exist - */ - getTask(taskId: string): Promise; - - /** - * 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 - */ - storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result): Promise; - - /** - * Retrieves the stored result of a task. - * - * @param taskId - The task identifier - * @returns The stored result - */ - getTaskResult(taskId: string): Promise; - - /** - * 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 - */ - updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string): Promise; - - /** - * Lists tasks, optionally starting from a pagination cursor. - * - * @param cursor - Optional cursor for pagination - * @returns An object containing the tasks array and an optional nextCursor - */ - listTasks(cursor?: string): Promise<{ tasks: Task[]; nextCursor?: string }>; -} - -/** - * Task context provided to request handlers when task storage is configured. - */ -export type TaskContext = { - id?: string; - store: RequestTaskStore; - requestedTtl?: number; -}; - -export type TaskManagerOptions = { - /** - * Task storage implementation. Required for handling incoming task requests (server-side). - * Not required for sending task requests (client-side outbound API). - */ - taskStore?: TaskStore; - /** - * Optional task message queue implementation for managing server-initiated messages - * that will be delivered through the tasks/result response stream. - */ - taskMessageQueue?: TaskMessageQueue; - /** - * Default polling interval (in milliseconds) for task status checks when no pollInterval - * is provided by the server. Defaults to 1000ms if not specified. - */ - defaultTaskPollInterval?: number; - /** - * Maximum number of messages that can be queued per task for side-channel delivery. - * If undefined, the queue size is unbounded. - */ - maxTaskQueueSize?: number; -}; - -/** - * Extracts {@linkcode TaskManagerOptions} from a capability object that mixes in runtime fields. - * Returns `undefined` when no task capability is configured. - */ -export function extractTaskManagerOptions(tasksCapability: TaskManagerOptions | undefined): TaskManagerOptions | undefined { - if (!tasksCapability) return undefined; - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize } = tasksCapability; - return { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize }; -} - -/** - * Manages task orchestration: state, message queuing, and polling. - * Capability checking is delegated to the Protocol host. - * @internal - */ -export class TaskManager { - private _taskStore?: TaskStore; - private _taskMessageQueue?: TaskMessageQueue; - private _taskProgressTokens: Map = new Map(); - private _requestResolvers: Map void> = new Map(); - private _options: TaskManagerOptions; - private _host?: TaskManagerHost; - - constructor(options: TaskManagerOptions) { - this._options = options; - this._taskStore = options.taskStore; - this._taskMessageQueue = options.taskMessageQueue; - } - - bind(host: TaskManagerHost): void { - this._host = host; - - if (this._taskStore) { - host.registerHandler('tasks/get', async (request, ctx) => { - const params = request.params as { taskId: string }; - const task = await this.handleGetTask(params.taskId, ctx.sessionId); - // Per spec: tasks/get responses SHALL NOT include related-task metadata - // as the taskId parameter is the source of truth - return { - ...task - } as Result; - }); - - host.registerHandler('tasks/result', async (request, ctx) => { - const params = request.params as { taskId: string }; - return await this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => { - // Send the message on the response stream by passing the relatedRequestId - // This tells the transport to write the message to the tasks/result response stream - await host.sendOnResponseStream(message, ctx.mcpReq.id); - }); - }); - - host.registerHandler('tasks/list', async (request, ctx) => { - const params = request.params as { cursor?: string } | undefined; - return (await this.handleListTasks(params?.cursor, ctx.sessionId)) as Result; - }); - - host.registerHandler('tasks/cancel', async (request, ctx) => { - const params = request.params as { taskId: string }; - return await this.handleCancelTask(params.taskId, ctx.sessionId); - }); - } - } - - protected get _requireHost(): TaskManagerHost { - if (!this._host) { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskManager is not bound to a Protocol host — call bind() first'); - } - return this._host; - } - - get taskStore(): TaskStore | undefined { - return this._taskStore; - } - - private get _requireTaskStore(): TaskStore { - if (!this._taskStore) { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskStore is not configured'); - } - return this._taskStore; - } - - get taskMessageQueue(): TaskMessageQueue | undefined { - return this._taskMessageQueue; - } - - // -- Public API (client-facing) -- - async *requestStream( - request: Request, - resultSchema: T, - options?: RequestOptions - ): AsyncGenerator>, void, void> { - const host = this._requireHost; - const { task } = options ?? {}; - - if (!task) { - try { - // TODO: SchemaOutput (Zod) and StandardSchemaV1.InferOutput (host.request's return) - // resolve to the same type for Zod schemas, but TS can't unify them generically. - // Removing this cast requires aligning ResponseMessage with StandardSchema. - const result = (await host.request(request, resultSchema, options)) as SchemaOutput; - yield { type: 'result', result }; - } catch (error) { - yield { - type: 'error', - error: error instanceof Error ? error : new Error(String(error)) - }; - } - return; - } - - let taskId: string | undefined; - try { - const createResult = await host.request(request, CreateTaskResultSchema, options); - - if (createResult.task) { - taskId = createResult.task.taskId; - yield { type: 'taskCreated', task: createResult.task }; - } else { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'Task creation did not return a task'); - } - - while (true) { - const task = await this.getTask({ taskId }, options); - yield { type: 'taskStatus', task }; - - if (isTerminal(task.status)) { - switch (task.status) { - case 'completed': - case 'failed': { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: 'result', result }; - break; - } - case 'cancelled': { - yield { - type: 'error', - error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} was cancelled`) - }; - break; - } - } - return; - } - - if (task.status === 'input_required') { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: 'result', result }; - return; - } - - const pollInterval = task.pollInterval ?? this._options.defaultTaskPollInterval ?? 1000; - await new Promise(resolve => setTimeout(resolve, pollInterval)); - options?.signal?.throwIfAborted(); - } - } catch (error) { - yield { - type: 'error', - error: error instanceof Error ? error : new Error(String(error)) - }; - } - } - - async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise { - return this._requireHost.request({ method: 'tasks/get', params }, GetTaskResultSchema, options); - } - - async getTaskResult( - params: GetTaskPayloadRequest['params'], - resultSchema: T, - options?: RequestOptions - ): Promise> { - // TODO: same SchemaOutput vs StandardSchemaV1.InferOutput mismatch as requestStream above. - return this._requireHost.request({ method: 'tasks/result', params }, resultSchema, options) as Promise>; - } - - async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise> { - return this._requireHost.request({ method: 'tasks/list', params }, ListTasksResultSchema, options); - } - - async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise> { - return this._requireHost.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); - } - - // -- Handler bodies (delegated from Protocol's registered handlers) -- - - private async handleGetTask(taskId: string, sessionId?: string): Promise { - const task = await this._requireTaskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); - } - return task; - } - - private async handleGetTaskPayload( - taskId: string, - sessionId: string | undefined, - signal: AbortSignal, - sendOnResponseStream: (message: JSONRPCNotification | JSONRPCRequest) => Promise - ): Promise { - const handleTaskResult = async (): Promise => { - if (this._taskMessageQueue) { - let queuedMessage: QueuedMessage | undefined; - while ((queuedMessage = await this._taskMessageQueue.dequeue(taskId, sessionId))) { - if (queuedMessage.type === 'response' || queuedMessage.type === 'error') { - const message = queuedMessage.message; - const requestId = message.id; - const resolver = this._requestResolvers.get(requestId as RequestId); - - if (resolver) { - this._requestResolvers.delete(requestId as RequestId); - if (queuedMessage.type === 'response') { - resolver(message as JSONRPCResultResponse); - } else { - const errorMessage = message as JSONRPCErrorResponse; - resolver(new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data)); - } - } else { - const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error'; - this._host?.reportError(new Error(`${messageType} handler missing for request ${requestId}`)); - } - continue; - } - - await sendOnResponseStream(queuedMessage.message as JSONRPCNotification | JSONRPCRequest); - } - } - - const task = await this._requireTaskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); - } - - if (!isTerminal(task.status)) { - await this._waitForTaskUpdate(task.pollInterval, signal); - return await handleTaskResult(); - } - - const result = await this._requireTaskStore.getTaskResult(taskId, sessionId); - await this._clearTaskQueue(taskId); - - return { - ...result, - _meta: { - ...result._meta, - [RELATED_TASK_META_KEY]: { taskId } - } - }; - }; - - return await handleTaskResult(); - } - - private async handleListTasks( - cursor: string | undefined, - sessionId?: string - ): Promise<{ tasks: Task[]; nextCursor?: string; _meta: Record }> { - try { - const { tasks, nextCursor } = await this._requireTaskStore.listTasks(cursor, sessionId); - return { tasks, nextCursor, _meta: {} }; - } catch (error) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Failed to list tasks: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async handleCancelTask(taskId: string, sessionId?: string): Promise { - try { - const task = await this._requireTaskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); - } - - if (isTerminal(task.status)) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); - } - - await this._requireTaskStore.updateTaskStatus(taskId, 'cancelled', 'Client cancelled task execution.', sessionId); - await this._clearTaskQueue(taskId); - - const cancelledTask = await this._requireTaskStore.getTask(taskId, sessionId); - if (!cancelledTask) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found after cancellation: ${taskId}`); - } - - return { _meta: {}, ...cancelledTask }; - } catch (error) { - if (error instanceof ProtocolError) throw error; - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Failed to cancel task: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - // -- Internal delegation methods -- - - private prepareOutboundRequest( - jsonrpcRequest: JSONRPCRequest, - options: RequestOptions | undefined, - messageId: number, - responseHandler: (response: JSONRPCResultResponse | Error) => void, - onError: (error: unknown) => void - ): boolean { - const { task, relatedTask } = options ?? {}; - - if (task) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - task: task - }; - } - - if (relatedTask) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - _meta: { - ...jsonrpcRequest.params?._meta, - [RELATED_TASK_META_KEY]: relatedTask - } - }; - } - - const relatedTaskId = relatedTask?.taskId; - if (relatedTaskId) { - this._requestResolvers.set(messageId, responseHandler); - - this._enqueueTaskMessage(relatedTaskId, { - type: 'request', - message: jsonrpcRequest, - timestamp: Date.now() - }).catch(error => { - onError(error); - }); - - return true; - } - - return false; - } - - private extractInboundTaskContext( - request: JSONRPCRequest, - sessionId?: string - ): { - relatedTaskId?: string; - taskCreationParams?: TaskCreationParams; - taskContext?: TaskContext; - } { - const relatedTaskId = (request.params?._meta as Record | undefined)?.[RELATED_TASK_META_KEY]?.taskId; - const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined; - - // Provide task context whenever a task store is configured, - // not just for task-related requests — tools need ctx.task.store - let taskContext: TaskContext | undefined; - if (this._taskStore) { - const store = this.createRequestTaskStore(request, sessionId); - taskContext = { - id: relatedTaskId, - store, - requestedTtl: taskCreationParams?.ttl - }; - } - - if (!relatedTaskId && !taskCreationParams && !taskContext) { - return {}; - } - - return { - relatedTaskId, - taskCreationParams, - taskContext - }; - } - - private wrapSendNotification( - relatedTaskId: string, - originalSendNotification: (notification: Notification, options?: NotificationOptions) => Promise - ): (notification: Notification) => Promise { - return async (notification: Notification) => { - const notificationOptions: NotificationOptions = { relatedTask: { taskId: relatedTaskId } }; - await originalSendNotification(notification, notificationOptions); - }; - } - - private wrapSendRequest( - relatedTaskId: string, - taskStore: RequestTaskStore | undefined, - originalSendRequest: ( - request: Request, - resultSchema: V, - options?: RequestOptions - ) => Promise> - ): ( - request: Request, - resultSchema: V, - options?: TaskRequestOptions - ) => Promise> { - return async (request: Request, resultSchema: V, options?: TaskRequestOptions) => { - const requestOptions: RequestOptions = { ...options }; - if (relatedTaskId && !requestOptions.relatedTask) { - requestOptions.relatedTask = { taskId: relatedTaskId }; - } - - const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; - if (effectiveTaskId && taskStore) { - await taskStore.updateTaskStatus(effectiveTaskId, 'input_required'); - } - - return await originalSendRequest(request, resultSchema, requestOptions); - }; - } - - private handleResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { - const messageId = Number(response.id); - const resolver = this._requestResolvers.get(messageId); - if (resolver) { - this._requestResolvers.delete(messageId); - if (isJSONRPCResultResponse(response)) { - resolver(response); - } else { - resolver(new ProtocolError(response.error.code, response.error.message, response.error.data)); - } - return true; - } - return false; - } - - private shouldPreserveProgressHandler(response: JSONRPCResponse | JSONRPCErrorResponse, messageId: number): boolean { - if (isJSONRPCResultResponse(response) && response.result && typeof response.result === 'object') { - const result = response.result as Record; - if (result.task && typeof result.task === 'object') { - const task = result.task as Record; - if (typeof task.taskId === 'string') { - this._taskProgressTokens.set(task.taskId, messageId); - return true; - } - } - } - return false; - } - - private async routeNotification(notification: Notification, options?: NotificationOptions): Promise { - const relatedTaskId = options?.relatedTask?.taskId; - if (!relatedTaskId) return false; - - const jsonrpcNotification: JSONRPCNotification = { - ...notification, - jsonrpc: '2.0', - params: { - ...notification.params, - _meta: { - ...notification.params?._meta, - [RELATED_TASK_META_KEY]: options!.relatedTask - } - } - }; - - await this._enqueueTaskMessage(relatedTaskId, { - type: 'notification', - message: jsonrpcNotification, - timestamp: Date.now() - }); - - return true; - } - - private async routeResponse( - relatedTaskId: string | undefined, - message: JSONRPCResponse | JSONRPCErrorResponse, - sessionId?: string - ): Promise { - if (!relatedTaskId || !this._taskMessageQueue) return false; - - await (isJSONRPCErrorResponse(message) - ? this._enqueueTaskMessage(relatedTaskId, { type: 'error', message, timestamp: Date.now() }, sessionId) - : this._enqueueTaskMessage( - relatedTaskId, - { type: 'response', message: message as JSONRPCResultResponse, timestamp: Date.now() }, - sessionId - )); - return true; - } - - private createRequestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore { - const taskStore = this._requireTaskStore; - const host = this._host; - - return { - createTask: async taskParams => { - if (!request) throw new Error('No request provided'); - return await taskStore.createTask(taskParams, request.id, { method: request.method, params: request.params }, sessionId); - }, - getTask: async taskId => { - const task = await taskStore.getTask(taskId, sessionId); - if (!task) throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); - return task; - }, - storeTaskResult: async (taskId, status, result) => { - await taskStore.storeTaskResult(taskId, status, result, sessionId); - const task = await taskStore.getTask(taskId, sessionId); - if (task) { - const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ - method: 'notifications/tasks/status', - params: task - }); - await host?.notification(notification as Notification); - if (isTerminal(task.status)) { - this._cleanupTaskProgressHandler(taskId); - } - } - }, - getTaskResult: taskId => taskStore.getTaskResult(taskId, sessionId), - updateTaskStatus: async (taskId, status, statusMessage) => { - const task = await taskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); - } - if (isTerminal(task.status)) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.` - ); - } - await taskStore.updateTaskStatus(taskId, status, statusMessage, sessionId); - const updatedTask = await taskStore.getTask(taskId, sessionId); - if (updatedTask) { - const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ - method: 'notifications/tasks/status', - params: updatedTask - }); - await host?.notification(notification as Notification); - if (isTerminal(updatedTask.status)) { - this._cleanupTaskProgressHandler(taskId); - } - } - }, - listTasks: cursor => taskStore.listTasks(cursor, sessionId) - }; - } - - // -- Lifecycle methods (called by Protocol directly) -- - - processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { - const taskInfo = this.extractInboundTaskContext(request, ctx.sessionId); - const relatedTaskId = taskInfo?.relatedTaskId; - - const sendNotification = relatedTaskId - ? this.wrapSendNotification(relatedTaskId, ctx.sendNotification) - : (notification: Notification) => ctx.sendNotification(notification); - - const sendRequest = relatedTaskId - ? this.wrapSendRequest(relatedTaskId, taskInfo?.taskContext?.store, ctx.sendRequest) - : taskInfo?.taskContext - ? this.wrapSendRequest('', taskInfo.taskContext.store, ctx.sendRequest) - : ctx.sendRequest; - - const hasTaskCreationParams = !!taskInfo?.taskCreationParams; - - return { - taskContext: taskInfo?.taskContext, - sendNotification, - sendRequest, - routeResponse: async (message: JSONRPCResponse | JSONRPCErrorResponse) => { - if (relatedTaskId) { - return this.routeResponse(relatedTaskId, message, ctx.sessionId); - } - return false; - }, - hasTaskCreationParams, - // Deferred validation: runs inside the async handler chain so errors - // produce proper JSON-RPC error responses (matching main's behavior). - validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined - }; - } - - processOutboundRequest( - jsonrpcRequest: JSONRPCRequest, - options: RequestOptions | undefined, - messageId: number, - responseHandler: (response: JSONRPCResultResponse | Error) => void, - onError: (error: unknown) => void - ): { queued: boolean } { - // Check task capability when sending a task-augmented request (matches main's enforceStrictCapabilities gate) - if (this._requireHost.enforceStrictCapabilities && options?.task) { - this._requireHost.assertTaskCapability(jsonrpcRequest.method); - } - - const queued = this.prepareOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, onError); - return { queued }; - } - - processInboundResponse( - response: JSONRPCResponse | JSONRPCErrorResponse, - messageId: number - ): { consumed: boolean; preserveProgress: boolean } { - const consumed = this.handleResponse(response); - if (consumed) { - return { consumed: true, preserveProgress: false }; - } - const preserveProgress = this.shouldPreserveProgressHandler(response, messageId); - return { consumed: false, preserveProgress }; - } - - async processOutboundNotification( - notification: Notification, - options?: NotificationOptions - ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> { - // Try queuing first - const queued = await this.routeNotification(notification, options); - if (queued) return { queued: true }; - - // Build JSONRPC notification with optional relatedTask metadata - let jsonrpcNotification: JSONRPCNotification = { ...notification, jsonrpc: '2.0' }; - if (options?.relatedTask) { - jsonrpcNotification = { - ...jsonrpcNotification, - params: { - ...jsonrpcNotification.params, - _meta: { - ...jsonrpcNotification.params?._meta, - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - } - return { queued: false, jsonrpcNotification }; - } - - onClose(): void { - this._taskProgressTokens.clear(); - this._requestResolvers.clear(); - } - - // -- Private helpers -- - - private async _enqueueTaskMessage(taskId: string, message: QueuedMessage, sessionId?: string): Promise { - if (!this._taskStore || !this._taskMessageQueue) { - throw new Error('Cannot enqueue task message: taskStore and taskMessageQueue are not configured'); - } - await this._taskMessageQueue.enqueue(taskId, message, sessionId, this._options.maxTaskQueueSize); - } - - private async _clearTaskQueue(taskId: string, sessionId?: string): Promise { - if (this._taskMessageQueue) { - const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); - for (const message of messages) { - if (message.type === 'request' && isJSONRPCRequest(message.message)) { - const requestId = message.message.id as RequestId; - const resolver = this._requestResolvers.get(requestId); - if (resolver) { - resolver(new ProtocolError(ProtocolErrorCode.InternalError, 'Task cancelled or completed')); - this._requestResolvers.delete(requestId); - } else { - this._host?.reportError(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); - } - } - } - } - } - - private async _waitForTaskUpdate(pollInterval: number | undefined, signal: AbortSignal): Promise { - const interval = pollInterval ?? this._options.defaultTaskPollInterval ?? 1000; - - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled')); - return; - } - const timeoutId = setTimeout(resolve, interval); - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeoutId); - reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled')); - }, - { once: true } - ); - }); - } - - private _cleanupTaskProgressHandler(taskId: string): void { - const progressToken = this._taskProgressTokens.get(taskId); - if (progressToken !== undefined) { - this._host?.removeProgressHandler(progressToken); - this._taskProgressTokens.delete(taskId); - } - } -} - -/** - * No-op TaskManager used when tasks capability is not configured. - * Provides passthrough implementations for the hot paths, avoiding - * unnecessary task extraction logic on every request. - */ -export class NullTaskManager extends TaskManager { - constructor() { - super({}); - } - - override processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { - const hasTaskCreationParams = isTaskAugmentedRequestParams(request.params) && !!request.params.task; - return { - taskContext: undefined, - sendNotification: (notification: Notification) => ctx.sendNotification(notification), - sendRequest: ctx.sendRequest, - routeResponse: async () => false, - hasTaskCreationParams, - validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined - }; - } - - // processOutboundRequest is inherited - it handles task/relatedTask augmentation - // and only queues if relatedTask is set (which won't happen without a task store) - - // processInboundResponse is inherited - it checks _requestResolvers (empty for NullTaskManager) - // and _taskProgressTokens (empty for NullTaskManager) - - override async processOutboundNotification( - notification: Notification, - _options?: NotificationOptions - ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> { - return { queued: false, jsonrpcNotification: { ...notification, jsonrpc: '2.0' } }; - } -} diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 878d5111cf..1766f0c8e5 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,8 +2,6 @@ export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; -export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; - /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index f385b91b42..c8185320a9 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -7,8 +7,7 @@ import { JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, - JSONRPCResultResponseSchema, - TaskAugmentedRequestParamsSchema + JSONRPCResultResponseSchema } from './schemas.js'; import type { CallToolResult, @@ -22,8 +21,7 @@ import type { JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, - JSONRPCResultResponse, - TaskAugmentedRequestParams + JSONRPCResultResponse } from './types.js'; /** @@ -81,15 +79,6 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { return CallToolResultSchema.safeParse(value).success; }; -/** - * Checks if a value is a valid {@linkcode TaskAugmentedRequestParams}. - * @param value - The value to check. - * - * @returns True if the value is a valid {@linkcode TaskAugmentedRequestParams}, false otherwise. - */ -export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => - TaskAugmentedRequestParamsSchema.safeParse(value).success; - export const isInitializeRequest = (value: unknown): value is InitializeRequest => InitializeRequestSchema.safeParse(value).success; export const isInitializedNotification = (value: unknown): value is InitializedNotification => diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index a243c1b829..e53be3dd11 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,6 +1,6 @@ import * as z from 'zod/v4'; -import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; +import { JSONRPC_VERSION } from './constants.js'; import type { JSONArray, JSONObject, @@ -27,42 +27,11 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); -/** - * Task creation parameters, used to ask that the server create a task to represent a request. - */ -export const TaskCreationParamsSchema = z.looseObject({ - /** - * Requested duration in milliseconds to retain task from creation. - */ - ttl: z.number().optional(), - - /** - * Time in milliseconds to wait between task status requests. - */ - pollInterval: z.number().optional() -}); - -export const TaskMetadataSchema = z.object({ - ttl: z.number().optional() -}); - -/** - * Metadata for associating messages with a task. - * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. - */ -export const RelatedTaskMetadataSchema = z.object({ - taskId: z.string() -}); - export const RequestMetaSchema = z.looseObject({ /** * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. */ - progressToken: ProgressTokenSchema.optional(), - /** - * If specified, this request is related to the provided task. - */ - [RELATED_TASK_META_KEY]: RelatedTaskMetadataSchema.optional() + progressToken: ProgressTokenSchema.optional() }); /** @@ -75,21 +44,6 @@ export const BaseRequestParamsSchema = z.object({ _meta: RequestMetaSchema.optional() }); -/** - * Common params for any task-augmented request. - */ -export const TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ - /** - * If specified, the caller is requesting task-augmented execution for this request. - * The request will return a `CreateTaskResult` immediately, and the actual result can be - * retrieved later via `tasks/result`. - * - * Task augmentation is subject to capability negotiation - receivers MUST declare support - * for task augmentation of specific request types in their capabilities. - */ - task: TaskMetadataSchema.optional() -}); - export const RequestSchema = z.object({ method: z.string(), params: BaseRequestParamsSchema.loose().optional() @@ -331,72 +285,6 @@ const ElicitationCapabilitySchema = z.preprocess( ) ); -/** - * Task capabilities for clients, indicating which request types support task creation. - */ -export const ClientTasksCapabilitySchema = z.looseObject({ - /** - * Present if the client supports listing tasks. - */ - list: JSONObjectSchema.optional(), - /** - * Present if the client supports cancelling tasks. - */ - cancel: JSONObjectSchema.optional(), - /** - * Capabilities for task creation on specific request types. - */ - requests: z - .looseObject({ - /** - * Task support for sampling requests. - */ - sampling: z - .looseObject({ - createMessage: JSONObjectSchema.optional() - }) - .optional(), - /** - * Task support for elicitation requests. - */ - elicitation: z - .looseObject({ - create: JSONObjectSchema.optional() - }) - .optional() - }) - .optional() -}); - -/** - * Task capabilities for servers, indicating which request types support task creation. - */ -export const ServerTasksCapabilitySchema = z.looseObject({ - /** - * Present if the server supports listing tasks. - */ - list: JSONObjectSchema.optional(), - /** - * Present if the server supports cancelling tasks. - */ - cancel: JSONObjectSchema.optional(), - /** - * Capabilities for task creation on specific request types. - */ - requests: z - .looseObject({ - /** - * Task support for tool requests. - */ - tools: z - .looseObject({ - call: JSONObjectSchema.optional() - }) - .optional() - }) - .optional() -}); - /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. */ @@ -435,14 +323,9 @@ export const ClientCapabilitiesSchema = z.object({ */ listChanged: z.boolean().optional() }) - .optional(), - /** - * Present if the client supports task creation. - */ - tasks: ClientTasksCapabilitySchema.optional(), - /** + .optional() /** * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). - */ + */, extensions: z.record(z.string(), JSONObjectSchema).optional() }); @@ -515,14 +398,9 @@ export const ServerCapabilitiesSchema = z.object({ */ listChanged: z.boolean().optional() }) - .optional(), - /** - * Present if the server supports task creation. - */ - tasks: ServerTasksCapabilitySchema.optional(), - /** + .optional() /** * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). - */ + */, extensions: z.record(z.string(), JSONObjectSchema).optional() }); @@ -616,120 +494,6 @@ export const PaginatedResultSchema = ResultSchema.extend({ nextCursor: CursorSchema.optional() }); -/** - * The status of a task. - * */ -export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); - -/* Tasks */ -/** - * A pollable state object associated with a request. - */ -export const TaskSchema = z.object({ - taskId: z.string(), - status: TaskStatusSchema, - /** - * Time in milliseconds to keep task results available after completion. - * If `null`, the task has unlimited lifetime until manually cleaned up. - */ - ttl: z.union([z.number(), z.null()]), - /** - * ISO 8601 timestamp when the task was created. - */ - createdAt: z.string(), - /** - * ISO 8601 timestamp when the task was last updated. - */ - lastUpdatedAt: z.string(), - pollInterval: z.optional(z.number()), - /** - * Optional diagnostic message for failed tasks or other status information. - */ - statusMessage: z.optional(z.string()) -}); - -/** - * Result returned when a task is created, containing the task data wrapped in a `task` field. - */ -export const CreateTaskResultSchema = ResultSchema.extend({ - task: TaskSchema -}); - -/** - * Parameters for task status notification. - */ -export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); - -/** - * A notification sent when a task's status changes. - */ -export const TaskStatusNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/tasks/status'), - params: TaskStatusNotificationParamsSchema -}); - -/** - * A request to get the state of a specific task. - */ -export const GetTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/get'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode GetTaskRequest | tasks/get} request. - */ -export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); - -/** - * A request to get the result of a specific task. - */ -export const GetTaskPayloadRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/result'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a `tasks/result` request. - * The structure matches the result type of the original request. - * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. - * - */ -export const GetTaskPayloadResultSchema = ResultSchema.loose(); - -/** - * A request to list tasks. - */ -export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: z.literal('tasks/list') -}); - -/** - * The response to a {@linkcode ListTasksRequest | tasks/list} request. - */ -export const ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: z.array(TaskSchema) -}); - -/** - * A request to cancel a specific task. - */ -export const CancelTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/cancel'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. - */ -export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); - /* Resources */ /** * The contents of a specific resource or sub-resource. @@ -1286,14 +1050,7 @@ export const ToolAnnotationsSchema = z.object({ * Execution-related properties for a tool. */ export const ToolExecutionSchema = z.object({ - /** - * Indicates the tool's preference for task-augmented execution. - * - `"required"`: Clients MUST invoke the tool as a task - * - `"optional"`: Clients MAY invoke the tool as a task or normal request - * - `"forbidden"`: Clients MUST NOT attempt to invoke the tool as a task - * - * If not present, defaults to `"forbidden"`. - */ + // taskSupport field removed in P0.2 alongside spec.types.ts regen (kept here only while spec.types.ts still declares it). taskSupport: z.enum(['required', 'optional', 'forbidden']).optional() }); @@ -1409,7 +1166,7 @@ export const CompatibilityCallToolResultSchema = CallToolResultSchema.or( /** * Parameters for a `tools/call` request. */ -export const CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const CallToolRequestParamsSchema = BaseRequestParamsSchema.extend({ /** * The name of the tool to call. */ @@ -1607,7 +1364,7 @@ export const SamplingMessageSchema = z.object({ /** * Parameters for a `sampling/createMessage` request. */ -export const CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const CreateMessageRequestParamsSchema = BaseRequestParamsSchema.extend({ messages: z.array(SamplingMessageSchema), /** * The server's preferences for which model to select. The client MAY modify or omit this request. @@ -1846,7 +1603,7 @@ export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, Boolea /** * Parameters for an `elicitation/create` request for form-based elicitation. */ -export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const ElicitRequestFormParamsSchema = BaseRequestParamsSchema.extend({ /** * The elicitation mode. * @@ -1873,7 +1630,7 @@ export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.ex /** * Parameters for an {@linkcode ElicitRequest | elicitation/create} request for URL-based elicitation. */ -export const ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const ElicitRequestURLParamsSchema = BaseRequestParamsSchema.extend({ /** * The elicitation mode. */ @@ -2089,19 +1846,14 @@ export const ClientRequestSchema = z.union([ SubscribeRequestSchema, UnsubscribeRequestSchema, CallToolRequestSchema, - ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema + ListToolsRequestSchema ]); export const ClientNotificationSchema = z.union([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema + RootsListChangedNotificationSchema ]); export const ClientResultSchema = z.union([ @@ -2109,23 +1861,11 @@ export const ClientResultSchema = z.union([ CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListRootsResultSchema ]); /* Server messages */ -export const ServerRequestSchema = z.union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); +export const ServerRequestSchema = z.union([PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); export const ServerNotificationSchema = z.union([ CancelledNotificationSchema, @@ -2135,7 +1875,6 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2149,10 +1888,7 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); /* Runtime schema lookup — result schemas by method */ @@ -2168,15 +1904,11 @@ const resultSchemas: Record = { 'resources/read': ReadResourceResultSchema, 'resources/subscribe': EmptyResultSchema, 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': z.union([CallToolResultSchema, CreateTaskResultSchema]), + 'tools/call': CallToolResultSchema, 'tools/list': ListToolsResultSchema, - 'sampling/createMessage': z.union([CreateMessageResultWithToolsSchema, CreateTaskResultSchema]), - 'elicitation/create': z.union([ElicitResultSchema, CreateTaskResultSchema]), - 'roots/list': ListRootsResultSchema, - 'tasks/get': GetTaskResultSchema, - 'tasks/result': ResultSchema, - 'tasks/list': ListTasksResultSchema, - 'tasks/cancel': CancelTaskResultSchema + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema }; /** diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index 477d61a55a..a43c4617a4 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -21,7 +21,7 @@ import * as schemas from './schemas.js'; * * This intentionally excludes internal helper schemas exported from `schemas.ts` that have no * matching public type (e.g. `ListChangedOptionsBaseSchema`, `BaseRequestParamsSchema`, - * `NotificationsParamsSchema`, `ClientTasksCapabilitySchema`, `ServerTasksCapabilitySchema`). + * `NotificationsParamsSchema`). * Keeping the list explicit means new public spec types must be added here deliberately, and * internals never leak into `SpecTypeName`. * @@ -41,8 +41,6 @@ const SPEC_SCHEMA_KEYS = [ 'CallToolResultSchema', 'CancelledNotificationSchema', 'CancelledNotificationParamsSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'ClientCapabilitiesSchema', 'ClientNotificationSchema', 'ClientRequestSchema', @@ -56,7 +54,6 @@ const SPEC_SCHEMA_KEYS = [ 'CreateMessageRequestParamsSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'ElicitationCompleteNotificationSchema', 'ElicitationCompleteNotificationParamsSchema', @@ -71,10 +68,6 @@ const SPEC_SCHEMA_KEYS = [ 'GetPromptRequestSchema', 'GetPromptRequestParamsSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -101,8 +94,6 @@ const SPEC_SCHEMA_KEYS = [ 'ListResourceTemplatesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -130,7 +121,6 @@ const SPEC_SCHEMA_KEYS = [ 'ReadResourceRequestSchema', 'ReadResourceRequestParamsSchema', 'ReadResourceResultSchema', - 'RelatedTaskMetadataSchema', 'RequestSchema', 'RequestIdSchema', 'RequestMetaSchema', @@ -160,13 +150,6 @@ const SPEC_SCHEMA_KEYS = [ 'StringSchemaSchema', 'SubscribeRequestSchema', 'SubscribeRequestParamsSchema', - 'TaskSchema', - 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', - 'TaskMetadataSchema', - 'TaskStatusSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusNotificationParamsSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index a92deec8e1..9a12c95c7f 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -17,8 +17,6 @@ import type { CallToolResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, @@ -32,7 +30,6 @@ import type { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, CursorSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, @@ -47,10 +44,6 @@ import type { GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, - GetTaskPayloadRequestSchema, - GetTaskPayloadResultSchema, - GetTaskRequestSchema, - GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, @@ -74,8 +67,6 @@ import type { ListResourceTemplatesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, - ListTasksRequestSchema, - ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, @@ -104,7 +95,6 @@ import type { ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, - RelatedTaskMetadataSchema, RequestIdSchema, RequestMetaSchema, RequestSchema, @@ -134,13 +124,6 @@ import type { StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, - TaskAugmentedRequestParamsSchema, - TaskCreationParamsSchema, - TaskMetadataSchema, - TaskSchema, - TaskStatusNotificationParamsSchema, - TaskStatusNotificationSchema, - TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, @@ -187,7 +170,6 @@ type Infer = Flatten>; export type ProgressToken = Infer; export type Cursor = Infer; export type Request = Infer; -export type TaskAugmentedRequestParams = Infer; export type RequestMeta = Infer; export type Notification = Infer; export type Result = Infer; @@ -232,24 +214,6 @@ export type Progress = Infer; export type ProgressNotificationParams = Infer; export type ProgressNotification = Infer; -/* Tasks */ -export type Task = Infer; -export type TaskStatus = Infer; -export type TaskCreationParams = Infer; -export type TaskMetadata = Infer; -export type RelatedTaskMetadata = Infer; -export type CreateTaskResult = Infer; -export type TaskStatusNotificationParams = Infer; -export type TaskStatusNotification = Infer; -export type GetTaskRequest = Infer; -export type GetTaskResult = Infer; -export type GetTaskPayloadRequest = Infer; -export type ListTasksRequest = Infer; -export type ListTasksResult = Infer; -export type CancelTaskRequest = Infer; -export type CancelTaskResult = Infer; -export type GetTaskPayloadResult = Infer; - /* Pagination */ export type PaginatedRequestParams = Infer; export type PaginatedRequest = Infer; @@ -392,15 +356,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + 'tools/call': CallToolResult; 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; - 'elicitation/create': ElicitResult | CreateTaskResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; }; /** diff --git a/packages/core/test/experimental/inMemory.test.ts b/packages/core/test/experimental/inMemory.test.ts deleted file mode 100644 index 7639cad9f4..0000000000 --- a/packages/core/test/experimental/inMemory.test.ts +++ /dev/null @@ -1,1035 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { QueuedMessage } from '../../src/experimental/tasks/interfaces.js'; -import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../src/experimental/tasks/stores/inMemory.js'; -import type { Request, TaskCreationParams } from '../../src/types/index.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: 60_000 - }; - 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(60_000); - 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: 60_000 - }; - 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).toStrictEqual(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).toStrictEqual(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).toStrictEqual(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(100_000); - - // 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 omitted TTL for unlimited lifetime', async () => { - // Test that omitting TTL means unlimited lifetime (server returns null) - // Per spec: clients omit ttl to let server decide, server returns null for unlimited - const taskParams: TaskCreationParams = {}; - const createdTask = await store.createTask(taskParams, 2222, { - method: 'tools/call', - params: {} - }); - - // The returned task should have null TTL (unlimited) - expect(createdTask.ttl).toBeNull(); - - // Task should not be cleaned up even after a long time - vi.advanceTimersByTime(100_000); - 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).toStrictEqual([]); - }); - }); - - describe('listTasks', () => { - it('should return empty list when no tasks', async () => { - const result = await store.listTasks(); - expect(result.tasks).toStrictEqual([]); - 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 = 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('session isolation', () => { - const baseRequest: Request = { method: 'tools/call', params: { name: 'demo' } }; - - it('should not allow session-b to list tasks created by session-a', async () => { - await store.createTask({}, 1, baseRequest, 'session-a'); - await store.createTask({}, 2, baseRequest, 'session-a'); - - const result = await store.listTasks(undefined, 'session-b'); - expect(result.tasks).toHaveLength(0); - }); - - it('should not allow session-b to read a task created by session-a', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - const result = await store.getTask(task.taskId, 'session-b'); - expect(result).toBeNull(); - }); - - it('should not allow session-b to update a task created by session-a', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - await expect(store.updateTaskStatus(task.taskId, 'cancelled', undefined, 'session-b')).rejects.toThrow('not found'); - }); - - it('should not allow session-b to store a result on session-a task', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - await expect(store.storeTaskResult(task.taskId, 'completed', { content: [] }, 'session-b')).rejects.toThrow('not found'); - }); - - it('should not allow session-b to get the result of session-a task', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - await store.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'secret' }] }, 'session-a'); - - await expect(store.getTaskResult(task.taskId, 'session-b')).rejects.toThrow('not found'); - }); - - it('should allow the owning session to access its own tasks', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - const retrieved = await store.getTask(task.taskId, 'session-a'); - expect(retrieved).toBeDefined(); - expect(retrieved?.taskId).toBe(task.taskId); - }); - - it('should list only tasks belonging to the requesting session', async () => { - await store.createTask({}, 1, baseRequest, 'session-a'); - await store.createTask({}, 2, baseRequest, 'session-b'); - await store.createTask({}, 3, baseRequest, 'session-a'); - - const resultA = await store.listTasks(undefined, 'session-a'); - expect(resultA.tasks).toHaveLength(2); - - const resultB = await store.listTasks(undefined, 'session-b'); - expect(resultB.tasks).toHaveLength(1); - }); - - it('should allow access when no sessionId is provided (backward compatibility)', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - // No sessionId on read = no filtering - const retrieved = await store.getTask(task.taskId); - expect(retrieved).toBeDefined(); - }); - - it('should allow access when task was created without sessionId', async () => { - const task = await store.createTask({}, 1, baseRequest); - - // Any sessionId on read should still see the task - const retrieved = await store.getTask(task.taskId, 'session-b'); - expect(retrieved).toBeDefined(); - }); - - it('should paginate correctly within a session', async () => { - // Create 15 tasks for session-a, 5 for session-b - for (let i = 1; i <= 15; i++) { - await store.createTask({}, i, baseRequest, 'session-a'); - } - for (let i = 16; i <= 20; i++) { - await store.createTask({}, i, baseRequest, 'session-b'); - } - - // First page for session-a should have 10 - const page1 = await store.listTasks(undefined, 'session-a'); - expect(page1.tasks).toHaveLength(10); - expect(page1.nextCursor).toBeDefined(); - - // Second page for session-a should have 5 - const page2 = await store.listTasks(page1.nextCursor, 'session-a'); - expect(page2.tasks).toHaveLength(5); - expect(page2.nextCursor).toBeUndefined(); - - // session-b should only see its 5 - const resultB = await store.listTasks(undefined, 'session-b'); - expect(resultB.tasks).toHaveLength(5); - expect(resultB.nextCursor).toBeUndefined(); - }); - }); - - 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).toStrictEqual(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).toStrictEqual(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).toStrictEqual(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')).toStrictEqual(request); - expect(await queue.dequeue('task-fifo')).toStrictEqual(notification); - expect(await queue.dequeue('task-fifo')).toStrictEqual(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]).toStrictEqual(request); - expect(all[1]).toStrictEqual(response); - expect(all[2]).toStrictEqual(notification); - }); - - it('should return empty array for non-existent task', async () => { - const all = await queue.dequeueAll('non-existent'); - expect(all).toStrictEqual([]); - }); - - 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')).toStrictEqual(message1); - expect(await queue.dequeue('task-b')).toStrictEqual(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: -32_600, - message: 'Invalid Request' - } - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-error', errorResponse); - const dequeued = await queue.dequeue('task-error'); - - expect(dequeued).toStrictEqual(errorResponse); - expect(dequeued?.type).toBe('error'); - }); - }); -}); diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 47e02c9bca..ffee5b9a7d 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -14,8 +14,6 @@ class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} } async function pair(): Promise<[TestProtocol, TestProtocol]> { diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376a..8e23d81086 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -3,20 +3,8 @@ import { vi } from 'vitest'; import * as z from 'zod/v4'; import type { ZodType } from 'zod/v4'; -import type { - QueuedMessage, - QueuedNotification, - QueuedRequest, - TaskMessageQueue, - TaskStore -} from '../../src/experimental/tasks/interfaces.js'; -import { InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/inMemory.js'; import type { BaseContext } from '../../src/shared/protocol.js'; import { mergeCapabilities, Protocol } from '../../src/shared/protocol.js'; -import type { ErrorMessage, ResponseMessage } from '../../src/shared/responseMessage.js'; -import { toArrayAsync } from '../../src/shared/responseMessage.js'; -import type { TaskManagerOptions } from '../../src/shared/taskManager.js'; -import { NullTaskManager, TaskManager } from '../../src/shared/taskManager.js'; import type { Transport, TransportSendOptions } from '../../src/shared/transport.js'; import type { ClientCapabilities, @@ -30,11 +18,9 @@ import type { Request, RequestId, Result, - ServerCapabilities, - Task, - TaskCreationParams + ServerCapabilities } from '../../src/types/index.js'; -import { ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/index.js'; +import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing @@ -42,29 +28,18 @@ class TestProtocolImpl extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} protected buildContext(ctx: BaseContext): BaseContext { return ctx; } } -function createTestProtocol(taskOptions?: TaskManagerOptions): TestProtocolImpl { - return new TestProtocolImpl(taskOptions ? { tasks: taskOptions } : undefined); +function createTestProtocol(): TestProtocolImpl { + return new TestProtocolImpl(); } // Type helper for accessing private/protected Protocol properties in tests interface TestProtocolInternals { _responseHandlers: Map void>; - _taskManager: { - _taskMessageQueue?: TaskMessageQueue; - _requestResolvers: Map void>; - _taskProgressTokens: Map; - _clearTaskQueue: (taskId: string, sessionId?: string) => Promise; - listTasks: (params?: { cursor?: string }) => Promise<{ tasks: Task[]; nextCursor?: string }>; - cancelTask: (params: { taskId: string }) => Promise; - requestStream: (request: Request, schema: ZodType, options?: unknown) => AsyncGenerator>; - }; } // Mock Transport class @@ -80,95 +55,6 @@ class MockTransport implements Transport { async send(_message: JSONRPCMessage, _options?: TransportSendOptions): Promise {} } -function createMockTaskStore(options?: { - onStatus?: (status: Task['status']) => void; - onList?: () => void; -}): TaskStore & { [K in keyof TaskStore]: MockInstance } { - const tasks: Record = {}; - return { - createTask: vi.fn((taskParams: TaskCreationParams, _1: RequestId, _2: Request) => { - // Generate a unique task ID - const taskId = `test-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const createdAt = new Date().toISOString(); - const task = (tasks[taskId] = { - taskId, - status: 'working', - ttl: taskParams.ttl ?? null, - createdAt, - lastUpdatedAt: createdAt, - pollInterval: taskParams.pollInterval ?? 1000 - }); - options?.onStatus?.('working'); - return Promise.resolve(task); - }), - getTask: vi.fn((taskId: string) => { - return Promise.resolve(tasks[taskId] ?? null); - }), - updateTaskStatus: vi.fn((taskId, status, statusMessage) => { - const task = tasks[taskId]; - if (task) { - task.status = status; - task.statusMessage = statusMessage; - options?.onStatus?.(task.status); - } - return Promise.resolve(); - }), - storeTaskResult: vi.fn((taskId: string, status: 'completed' | 'failed', result: Result) => { - const task = tasks[taskId]; - if (task) { - task.status = status; - task.result = result; - options?.onStatus?.(status); - } - return Promise.resolve(); - }), - getTaskResult: vi.fn((taskId: string) => { - const task = tasks[taskId]; - if (task?.result) { - return Promise.resolve(task.result); - } - throw new Error('Task result not found'); - }), - listTasks: vi.fn(() => { - const result = { - tasks: Object.values(tasks) - }; - options?.onList?.(); - return Promise.resolve(result); - }) - }; -} - -function createLatch() { - let latch = false; - const waitForLatch = async () => { - while (!latch) { - await new Promise(resolve => setTimeout(resolve, 0)); - } - }; - - return { - releaseLatch: () => { - latch = true; - }, - waitForLatch - }; -} - -function assertErrorResponse(o: ResponseMessage): asserts o is ErrorMessage { - expect(o.type).toBe('error'); -} - -function assertQueuedNotification(o?: QueuedMessage): asserts o is QueuedNotification { - expect(o).toBeDefined(); - expect(o?.type).toBe('notification'); -} - -function assertQueuedRequest(o?: QueuedMessage): asserts o is QueuedRequest { - expect(o).toBeDefined(); - expect(o?.type).toBe('request'); -} - /** * Helper to call the protected _requestWithSchema method from tests that * use custom method names not present in RequestMethod. @@ -887,97 +773,7 @@ describe('protocol tests', () => { }); }); -describe('InMemoryTaskMessageQueue', () => { - let queue: TaskMessageQueue; - const taskId = 'test-task-id'; - - beforeEach(() => { - queue = new InMemoryTaskMessageQueue(); - }); - - describe('enqueue/dequeue maintains FIFO order', () => { - it('should maintain FIFO order for multiple messages', async () => { - const msg1 = { - type: 'notification' as const, - message: { jsonrpc: '2.0' as const, method: 'test1' }, - timestamp: 1 - }; - const msg2 = { - type: 'request' as const, - message: { jsonrpc: '2.0' as const, id: 1, method: 'test2' }, - timestamp: 2 - }; - const msg3 = { - type: 'notification' as const, - message: { jsonrpc: '2.0' as const, method: 'test3' }, - timestamp: 3 - }; - - await queue.enqueue(taskId, msg1); - await queue.enqueue(taskId, msg2); - await queue.enqueue(taskId, msg3); - - expect(await queue.dequeue(taskId)).toEqual(msg1); - expect(await queue.dequeue(taskId)).toEqual(msg2); - expect(await queue.dequeue(taskId)).toEqual(msg3); - }); - - it('should return undefined when dequeuing from empty queue', async () => { - expect(await queue.dequeue(taskId)).toBeUndefined(); - }); - }); - - describe('dequeueAll operation', () => { - it('should return all messages in FIFO order', async () => { - const msg1 = { - type: 'notification' as const, - message: { jsonrpc: '2.0' as const, method: 'test1' }, - timestamp: 1 - }; - const msg2 = { - type: 'request' as const, - message: { jsonrpc: '2.0' as const, id: 1, method: 'test2' }, - timestamp: 2 - }; - const msg3 = { - type: 'notification' as const, - message: { jsonrpc: '2.0' as const, method: 'test3' }, - timestamp: 3 - }; - - await queue.enqueue(taskId, msg1); - await queue.enqueue(taskId, msg2); - await queue.enqueue(taskId, msg3); - - const allMessages = await queue.dequeueAll(taskId); - - expect(allMessages).toEqual([msg1, msg2, msg3]); - }); - - it('should return empty array for empty queue', async () => { - const allMessages = await queue.dequeueAll(taskId); - expect(allMessages).toEqual([]); - }); - - it('should clear queue after dequeueAll', async () => { - await queue.enqueue(taskId, { - type: 'notification' as const, - message: { jsonrpc: '2.0' as const, method: 'test1' }, - timestamp: 1 - }); - await queue.enqueue(taskId, { - type: 'notification' as const, - message: { jsonrpc: '2.0' as const, method: 'test2' }, - timestamp: 2 - }); - - await queue.dequeueAll(taskId); - - expect(await queue.dequeue(taskId)).toBeUndefined(); - }); - }); -}); - +// (2025-11 experimental test suites removed under SEP-2663; see git history.) describe('mergeCapabilities', () => { it('should merge client capabilities', () => { const base: ClientCapabilities = { @@ -1067,4614 +863,3 @@ describe('mergeCapabilities', () => { expect(merged).toEqual({}); }); }); - -describe('Task-based execution', () => { - let protocol: Protocol; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = createTestProtocol({ taskStore: createMockTaskStore(), taskMessageQueue: new InMemoryTaskMessageQueue() }); - }); - - describe('request with task metadata', () => { - it('should include task parameters at top level', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000, - pollInterval: 1000 - } - }).catch(() => { - // May not complete, ignore error - }); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tools/call', - params: { - name: 'test-tool', - task: { - ttl: 30000, - pollInterval: 1000 - } - } - }), - expect.any(Object) - ); - }); - - it('should preserve existing _meta and add task parameters at top level', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { - name: 'test-tool', - _meta: { - customField: 'customValue' - } - } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 60000 - } - }).catch(() => { - // May not complete, ignore error - }); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - name: 'test-tool', - _meta: { - customField: 'customValue' - }, - task: { - ttl: 60000 - } - } - }), - expect.any(Object) - ); - }); - - it('should return Promise for task-augmented request', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - const resultPromise = testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000 - } - }); - - expect(resultPromise).toBeDefined(); - expect(resultPromise).toBeInstanceOf(Promise); - }); - }); - - describe('relatedTask metadata', () => { - it('should inject relatedTask metadata into _meta field', async () => { - await protocol.connect(transport); - - const request = { - method: 'notifications/message', - params: { data: 'test' } - }; - - const resultSchema = z.object({}); - - // Start the request (don't await completion, just let it send) - void testRequest(protocol, request, resultSchema, { - relatedTask: { - taskId: 'parent-task-123' - } - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be queued - await new Promise(resolve => setTimeout(resolve, 10)); - - // Requests with relatedTask should be queued, not sent via transport - // This prevents duplicate delivery for bidirectional transports - expect(sendSpy).not.toHaveBeenCalled(); - - // Verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - }); - - it('should work with notification method', async () => { - await protocol.connect(transport); - - await protocol.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { - taskId: 'parent-task-456' - } - } - ); - - // Notifications with relatedTask should be queued, not sent via transport - // This prevents duplicate delivery for bidirectional transports - expect(sendSpy).not.toHaveBeenCalled(); - - // Verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('parent-task-456'); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: 'parent-task-456' }); - }); - }); - - describe('task metadata combination', () => { - it('should combine task, relatedTask, and progress metadata', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - // Start the request (don't await completion, just let it send) - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 60000, - pollInterval: 1000 - }, - relatedTask: { - taskId: 'parent-task' - }, - onprogress: vi.fn() - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be queued - await new Promise(resolve => setTimeout(resolve, 10)); - - // Requests with relatedTask should be queued, not sent via transport - // This prevents duplicate delivery for bidirectional transports - expect(sendSpy).not.toHaveBeenCalled(); - - // Verify the message was queued with all metadata combined - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('parent-task'); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.params).toMatchObject({ - name: 'test-tool', - task: { - ttl: 60000, - pollInterval: 1000 - }, - _meta: { - [RELATED_TASK_META_KEY]: { - taskId: 'parent-task' - }, - progressToken: expect.any(Number) - } - }); - }); - }); - - describe('task status transitions', () => { - it('should not auto-update task status when a task-augmented request completes', async () => { - const mockTaskStore = createMockTaskStore(); - const localProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const localTransport = new MockTransport(); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'done' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { - name: 'test-tool', - arguments: {}, - task: { ttl: 60000, pollInterval: 1000 } - } - }); - - // Allow the request to be processed - await new Promise(resolve => setTimeout(resolve, 20)); - - // The protocol layer must not call updateTaskStatus — that is solely the tool implementor's responsibility - expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); - }); - - it('should handle requests with task creation parameters in top-level task field', async () => { - // This test documents that task creation parameters are now in the top-level task field - // rather than in _meta, and that task management is handled by tool implementors - const mockTaskStore = createMockTaskStore(); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - protocol.setRequestHandler('tools/call', async request => { - // Tool implementor can access task creation parameters from request.params.task - expect(request.params.task).toEqual({ - ttl: 60000, - pollInterval: 1000 - }); - return { content: [{ type: 'text', text: 'success' }] }; - }); - - transport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'test', - arguments: {}, - task: { - ttl: 60000, - pollInterval: 1000 - } - } - }); - - // Wait for the request to be processed - await new Promise(resolve => setTimeout(resolve, 10)); - }); - }); - - describe('assertTaskHandlerCapability', () => { - it('should invoke assertTaskHandlerCapability when an inbound task-augmented request arrives', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - const spy = vi.spyOn(localProtocol, 'assertTaskHandlerCapability' as never); - const localTransport = new MockTransport(); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'my-tool', - arguments: {}, - task: { ttl: 30000, pollInterval: 500 } - } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith('tools/call'); - }); - - it('should not invoke assertTaskHandlerCapability for non-task-augmented requests', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - const spy = vi.spyOn(localProtocol, 'assertTaskHandlerCapability' as never); - const localTransport = new MockTransport(); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'my-tool', arguments: {} } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should succeed with default no-op assertTaskHandlerCapability', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - const localTransport = new MockTransport(); - const localSendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'my-tool', - arguments: {}, - task: { ttl: 30000, pollInterval: 500 } - } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - // The response should be a success, not an error - expect(localSendSpy).toHaveBeenCalledOnce(); - const response = localSendSpy.mock.calls[0]![0] as { error?: unknown }; - expect(response.error).toBeUndefined(); - }); - - it('should send a JSON-RPC error response when assertTaskHandlerCapability throws', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(localProtocol as any, 'assertTaskHandlerCapability').mockImplementation(() => { - throw new Error('Task handler capability not declared'); - }); - const localTransport = new MockTransport(); - const sendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'my-tool', - arguments: {}, - task: { ttl: 30000, pollInterval: 500 } - } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - // Verify the error was sent back as a JSON-RPC error response (matching main's behavior) - expect(sendSpy).toHaveBeenCalledOnce(); - const response = sendSpy.mock.calls[0]![0] as { error?: { message?: string } }; - expect(response.error).toBeDefined(); - expect(response.error!.message).toBe('Task handler capability not declared'); - }); - }); - - describe('pollInterval fallback in _waitForTaskUpdate', () => { - it('should fall back to defaultTaskPollInterval when task has no pollInterval', async () => { - const mockTaskStore = createMockTaskStore(); - - const task = await mockTaskStore.createTask({ pollInterval: undefined as unknown as number }, 1, { - method: 'test/method', - params: {} - }); - // Override pollInterval to be undefined on the stored task - const storedTask = await mockTaskStore.getTask(task.taskId); - if (storedTask) { - storedTask.pollInterval = undefined as unknown as number; - } - - const localProtocol = createTestProtocol({ - taskStore: mockTaskStore, - defaultTaskPollInterval: 100 - }); - const localTransport = new MockTransport(); - const sendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - // Send tasks/result request — task is non-terminal so it will poll - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Use a macrotask to complete the task AFTER the handler has entered polling - setTimeout(() => { - mockTaskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'done' }] }); - }, 10); - - // At 50ms the 100ms poll hasn't fired yet - await new Promise(resolve => setTimeout(resolve, 50)); - expect(sendSpy).not.toHaveBeenCalled(); - - // At 200ms the poll should have fired and found the completed task - await new Promise(resolve => setTimeout(resolve, 150)); - expect(sendSpy).toHaveBeenCalled(); - }); - - it('should fall back to 1000ms when both pollInterval and defaultTaskPollInterval are absent', async () => { - const mockTaskStore = createMockTaskStore(); - - const task = await mockTaskStore.createTask({ pollInterval: undefined as unknown as number }, 1, { - method: 'test/method', - params: {} - }); - const storedTask = await mockTaskStore.getTask(task.taskId); - if (storedTask) { - storedTask.pollInterval = undefined as unknown as number; - } - - // No defaultTaskPollInterval — should fall back to 1000ms - const localProtocol = createTestProtocol({ - taskStore: mockTaskStore - }); - const localTransport = new MockTransport(); - const sendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Complete the task via macrotask so the handler enters polling first - setTimeout(() => { - mockTaskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'done' }] }); - }, 10); - - // At 500ms the 1000ms poll hasn't fired yet - await new Promise(resolve => setTimeout(resolve, 500)); - expect(sendSpy).not.toHaveBeenCalled(); - - // At 1100ms the poll should have fired - await new Promise(resolve => setTimeout(resolve, 600)); - expect(sendSpy).toHaveBeenCalled(); - }); - }); - - describe('listTasks', () => { - it('should handle tasks/list requests and return tasks from TaskStore', async () => { - const listedTasks = createLatch(); - const mockTaskStore = createMockTaskStore({ - onList: () => listedTasks.releaseLatch() - }); - const task1 = await mockTaskStore.createTask( - { - pollInterval: 500 - }, - 1, - { - method: 'test/method', - params: {} - } - ); - // Manually set status to completed for this test - await mockTaskStore.updateTaskStatus(task1.taskId, 'completed'); - - const task2 = await mockTaskStore.createTask( - { - ttl: 60000, - pollInterval: 1000 - }, - 2, - { - method: 'test/method', - params: {} - } - ); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request - transport.onmessage?.({ - jsonrpc: '2.0', - id: 3, - method: 'tasks/list', - params: {} - }); - - await listedTasks.waitForLatch(); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(3); - expect(sentMessage.result.tasks).toEqual([ - { - taskId: task1.taskId, - status: 'completed', - ttl: null, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - pollInterval: 500 - }, - { - taskId: task2.taskId, - status: 'working', - ttl: 60000, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - pollInterval: 1000 - } - ]); - expect(sentMessage.result._meta).toEqual({}); - }); - - it('should handle tasks/list requests with cursor for pagination', async () => { - const listedTasks = createLatch(); - const mockTaskStore = createMockTaskStore({ - onList: () => listedTasks.releaseLatch() - }); - const task3 = await mockTaskStore.createTask( - { - pollInterval: 500 - }, - 1, - { - method: 'test/method', - params: {} - } - ); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request with cursor - transport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/list', - params: { - cursor: 'task-2' - } - }); - - await listedTasks.waitForLatch(); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith('task-2', undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(2); - expect(sentMessage.result.tasks).toEqual([ - { - taskId: task3.taskId, - status: 'working', - ttl: null, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - pollInterval: 500 - } - ]); - expect(sentMessage.result.nextCursor).toBeUndefined(); - expect(sentMessage.result._meta).toEqual({}); - }); - - it('should handle tasks/list requests with empty results', async () => { - const listedTasks = createLatch(); - const mockTaskStore = createMockTaskStore({ - onList: () => listedTasks.releaseLatch() - }); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request - transport.onmessage?.({ - jsonrpc: '2.0', - id: 3, - method: 'tasks/list', - params: {} - }); - - await listedTasks.waitForLatch(); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(3); - expect(sentMessage.result.tasks).toEqual([]); - expect(sentMessage.result.nextCursor).toBeUndefined(); - expect(sentMessage.result._meta).toEqual({}); - }); - - it('should return error for invalid cursor', async () => { - const mockTaskStore = createMockTaskStore(); - mockTaskStore.listTasks.mockRejectedValue(new Error('Invalid cursor: bad-cursor')); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request with invalid cursor - transport.onmessage?.({ - jsonrpc: '2.0', - id: 4, - method: 'tasks/list', - params: { - cursor: 'bad-cursor' - } - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith('bad-cursor', undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(4); - expect(sentMessage.error).toBeDefined(); - expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code - expect(sentMessage.error.message).toContain('Failed to list tasks'); - expect(sentMessage.error.message).toContain('Invalid cursor'); - }); - - it('should call listTasks method from client side', async () => { - await protocol.connect(transport); - - const listTasksPromise = (protocol as unknown as TestProtocolInternals)._taskManager.listTasks(); - - // Simulate server response - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sendSpy.mock.calls[0]![0].id, - result: { - tasks: [ - { - taskId: 'task-1', - status: 'completed', - ttl: null, - createdAt: '2024-01-01T00:00:00Z', - lastUpdatedAt: '2024-01-01T00:00:00Z', - pollInterval: 500 - } - ], - nextCursor: undefined, - _meta: {} - } - }); - }, 10); - - const result = await listTasksPromise; - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tasks/list', - params: undefined - }), - expect.any(Object) - ); - expect(result.tasks).toHaveLength(1); - expect(result.tasks[0]?.taskId).toBe('task-1'); - }); - - it('should call listTasks with cursor from client side', async () => { - await protocol.connect(transport); - - const listTasksPromise = (protocol as unknown as TestProtocolInternals)._taskManager.listTasks({ cursor: 'task-10' }); - - // Simulate server response - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sendSpy.mock.calls[0]![0].id, - result: { - tasks: [ - { - taskId: 'task-11', - status: 'working', - ttl: 30000, - createdAt: '2024-01-01T00:00:00Z', - lastUpdatedAt: '2024-01-01T00:00:00Z', - pollInterval: 1000 - } - ], - nextCursor: 'task-11', - _meta: {} - } - }); - }, 10); - - const result = await listTasksPromise; - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tasks/list', - params: { - cursor: 'task-10' - } - }), - expect.any(Object) - ); - expect(result.tasks).toHaveLength(1); - expect(result.tasks[0]?.taskId).toBe('task-11'); - expect(result.nextCursor).toBe('task-11'); - }); - }); - - describe('cancelTask', () => { - it('should handle tasks/cancel requests and update task status to cancelled', async () => { - const taskDeleted = createLatch(); - const mockTaskStore = createMockTaskStore(); - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - mockTaskStore.getTask.mockResolvedValue(task); - mockTaskStore.updateTaskStatus.mockImplementation(async (taskId: string, status: string) => { - if (taskId === task.taskId && status === 'cancelled') { - taskDeleted.releaseLatch(); - return; - } - throw new Error('Task not found'); - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 5, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - - await taskDeleted.waitForLatch(); - - expect(mockTaskStore.getTask).toHaveBeenCalledWith(task.taskId, undefined); - expect(mockTaskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCResultResponse; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(5); - expect(sentMessage.result._meta).toBeDefined(); - }); - - it('should return error with code -32602 when task does not exist', async () => { - const taskDeleted = createLatch(); - const mockTaskStore = createMockTaskStore(); - - mockTaskStore.getTask.mockResolvedValue(null); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 6, - method: 'tasks/cancel', - params: { - taskId: 'non-existent' - } - }); - - // Wait a bit for the async handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - taskDeleted.releaseLatch(); - - expect(mockTaskStore.getTask).toHaveBeenCalledWith('non-existent', undefined); - const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCErrorResponse; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(6); - expect(sentMessage.error).toBeDefined(); - expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code - expect(sentMessage.error.message).toContain('Task not found'); - }); - - it('should return error with code -32602 when trying to cancel a task in terminal status', async () => { - const mockTaskStore = createMockTaskStore(); - const completedTask = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - // Set task to completed status - await mockTaskStore.updateTaskStatus(completedTask.taskId, 'completed'); - completedTask.status = 'completed'; - - // Reset the mock so we can check it's not called during cancellation - mockTaskStore.updateTaskStatus.mockClear(); - mockTaskStore.getTask.mockResolvedValue(completedTask); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 7, - method: 'tasks/cancel', - params: { - taskId: completedTask.taskId - } - }); - - // Wait a bit for the async handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockTaskStore.getTask).toHaveBeenCalledWith(completedTask.taskId, undefined); - expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); - const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCErrorResponse; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(7); - expect(sentMessage.error).toBeDefined(); - expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code - expect(sentMessage.error.message).toContain('Cannot cancel task in terminal status'); - }); - - it('should call cancelTask method from client side', async () => { - await protocol.connect(transport); - - const deleteTaskPromise = (protocol as unknown as TestProtocolInternals)._taskManager.cancelTask({ taskId: 'task-to-delete' }); - - // Simulate server response - per MCP spec, CancelTaskResult is Result & Task - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sendSpy.mock.calls[0]![0].id, - result: { - _meta: {}, - taskId: 'task-to-delete', - status: 'cancelled', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString() - } - }); - }, 0); - - const result = await deleteTaskPromise; - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tasks/cancel', - params: { - taskId: 'task-to-delete' - } - }), - expect.any(Object) - ); - expect(result._meta).toBeDefined(); - expect(result.taskId).toBe('task-to-delete'); - expect(result.status).toBe('cancelled'); - }); - }); - - describe('task status notifications', () => { - it('should call getTask after updateTaskStatus to enable notification sending', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - - await serverProtocol.connect(serverTransport); - - // Simulate cancelling the task - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify that updateTaskStatus was called - expect(mockTaskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - - // Verify that getTask was called after updateTaskStatus - // This is done by the RequestTaskStore wrapper to get the updated task for the notification - const getTaskCalls = mockTaskStore.getTask.mock.calls; - const lastGetTaskCall = getTaskCalls[getTaskCalls.length - 1]; - expect(lastGetTaskCall?.[0]).toBe(task.taskId); - }); - }); - - describe('task metadata handling', () => { - it('should NOT include related-task metadata in tasks/get response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Request task status - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/get', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response does NOT include related-task metadata - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - taskId: task.taskId, - status: 'working' - }) - }) - ); - - // Verify _meta is not present or doesn't contain RELATED_TASK_META_KEY - const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; - expect(response.result?._meta?.[RELATED_TASK_META_KEY]).toBeUndefined(); - }); - - it('should NOT include related-task metadata in tasks/list response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Request task list - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/list', - params: {} - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response does NOT include related-task metadata - const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; - expect(response.result?._meta).toEqual({}); - }); - - it('should NOT include related-task metadata in tasks/cancel response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Cancel the task - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response does NOT include related-task metadata - const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; - expect(response.result?._meta).toEqual({}); - }); - - it('should include related-task metadata in tasks/result response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task and complete it - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const testResult = { - content: [{ type: 'text', text: 'test result' }] - }; - - await mockTaskStore.storeTaskResult(task.taskId, 'completed', testResult); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Request task result - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/result', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response DOES include related-task metadata - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - content: testResult.content, - _meta: expect.objectContaining({ - [RELATED_TASK_META_KEY]: { - taskId: task.taskId - } - }) - }) - }) - ); - }); - - it('should propagate related-task metadata to handler sendRequest and sendNotification', async () => { - const mockTaskStore = createMockTaskStore(); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Set up a handler that uses sendRequest and sendNotification - serverProtocol.setRequestHandler('tools/call', async (_request, ctx) => { - // Send a notification using the ctx.mcpReq.notify - await ctx.mcpReq.notify({ - method: 'notifications/message', - params: { level: 'info', data: 'test' } - }); - - return { - content: [{ type: 'text', text: 'done' }] - }; - }); - - // Send a request with related-task metadata - let handlerPromise: Promise | undefined; - const originalOnMessage = serverTransport.onmessage; - - serverTransport.onmessage = message => { - handlerPromise = Promise.resolve(originalOnMessage?.(message)); - return handlerPromise; - }; - - serverTransport.onmessage({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'test-tool', - _meta: { - [RELATED_TASK_META_KEY]: { - taskId: 'parent-task-123' - } - } - } - }); - - // Wait for handler to complete - if (handlerPromise) { - await handlerPromise; - } - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify the notification was QUEUED (not sent via transport) - // Messages with relatedTask metadata should be queued for delivery via tasks/result - // to prevent duplicate delivery for bidirectional transports - const queue = (serverProtocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('parent-task-123'); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ - taskId: 'parent-task-123' - }); - - // Verify the notification was NOT sent via transport (should be queued instead) - const notificationCalls = sendSpy.mock.calls.filter(call => 'method' in call[0] && call[0].method === 'notifications/message'); - expect(notificationCalls).toHaveLength(0); - }); - }); -}); - -describe('Request Cancellation vs Task Cancellation', () => { - let protocol: Protocol; - let transport: MockTransport; - let taskStore: TaskStore; - - beforeEach(() => { - transport = new MockTransport(); - taskStore = createMockTaskStore(); - protocol = createTestProtocol({ taskStore }); - }); - - describe('notifications/cancelled behavior', () => { - test('should abort request handler when notifications/cancelled is received', async () => { - await protocol.connect(transport); - - // Set up a request handler that checks if it was aborted - let wasAborted = false; - protocol.setRequestHandler('ping', async (_request, ctx) => { - // Simulate a long-running operation - await new Promise(resolve => setTimeout(resolve, 100)); - wasAborted = ctx.mcpReq.signal.aborted; - return {}; - }); - - // Simulate an incoming request - const requestId = 123; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: requestId, - method: 'ping', - params: {} - }); - } - - // Wait a bit for the handler to start - await new Promise(resolve => setTimeout(resolve, 10)); - - // Send cancellation notification - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: requestId, - reason: 'User cancelled' - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 150)); - - // Verify the request was aborted - expect(wasAborted).toBe(true); - }); - - test('should NOT automatically cancel associated tasks when notifications/cancelled is received', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Send cancellation notification for the request - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: 'req-1', - reason: 'User cancelled' - } - }); - } - - // Wait a bit - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify the task status was NOT changed to cancelled - const updatedTask = await taskStore.getTask(task.taskId); - expect(updatedTask?.status).toBe('working'); - expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'cancelled', expect.any(String)); - }); - }); - - describe('tasks/cancel behavior', () => { - test('should cancel task independently of request cancellation', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Cancel the task using tasks/cancel - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify the task was cancelled - expect(taskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - }); - - test('should reject cancellation of terminal tasks', async () => { - await protocol.connect(transport); - const sendSpy = vi.spyOn(transport, 'send'); - - // Create a task and mark it as completed - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - await taskStore.updateTaskStatus(task.taskId, 'completed'); - - // Try to cancel the completed task - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify an error was sent - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: 999, - error: expect.objectContaining({ - code: ProtocolErrorCode.InvalidParams, - message: expect.stringContaining('Cannot cancel task in terminal status') - }) - }) - ); - }); - - test('should return error when task not found', async () => { - await protocol.connect(transport); - const sendSpy = vi.spyOn(transport, 'send'); - - // Try to cancel a non-existent task - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: 'non-existent-task' - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify an error was sent - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: 999, - error: expect.objectContaining({ - code: ProtocolErrorCode.InvalidParams, - message: expect.stringContaining('Task not found') - }) - }) - ); - }); - }); - - describe('separation of concerns', () => { - test('should allow request cancellation without affecting task', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Cancel the request (not the task) - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: 'req-1', - reason: 'User cancelled request' - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify task is still working - const updatedTask = await taskStore.getTask(task.taskId); - expect(updatedTask?.status).toBe('working'); - }); - - test('should allow task cancellation without affecting request', async () => { - await protocol.connect(transport); - - // Set up a request handler - let requestCompleted = false; - protocol.setRequestHandler('ping', async () => { - await new Promise(resolve => setTimeout(resolve, 50)); - requestCompleted = true; - return {}; - }); - - // Create a task (simulating a long-running tools/call) - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'tools/call', - params: { name: 'long-running-tool', arguments: {} } - }); - - // Start an unrelated ping request - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 123, - method: 'ping', - params: {} - }); - } - - // Cancel the task (not the request) - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - } - - // Wait for request to complete - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify request completed normally - expect(requestCompleted).toBe(true); - - // Verify task was cancelled - expect(taskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - }); - }); -}); - -describe('Progress notification support for tasks', () => { - let protocol: Protocol; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = createTestProtocol({ taskStore: createMockTaskStore() }); - }); - - it('should maintain progress token association after CreateTaskResult is returned', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - // Start a task-augmented request with progress callback - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be sent - await new Promise(resolve => setTimeout(resolve, 10)); - - // Get the message ID from the sent request - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - expect(progressToken).toBe(messageId); - - // Simulate CreateTaskResult response - const taskId = 'test-task-123'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - // Wait for response to be processed - await Promise.resolve(); - await Promise.resolve(); - - // Send a progress notification - should still work after CreateTaskResult - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100 - } - }); - } - - // Wait for notification to be processed - await Promise.resolve(); - - // Verify progress callback was invoked - expect(progressCallback).toHaveBeenCalledWith({ - progress: 50, - total: 100 - }); - }); - - it('should stop progress notifications when task reaches terminal status (completed)', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - // Set up a request handler that will complete the task - protocol.setRequestHandler('tools/call', async (_request, ctx) => { - if (ctx.task?.store) { - const task = await ctx.task.store.createTask({ ttl: 60000 }); - - // Simulate async work then complete the task - const taskStore = ctx.task.store; - setTimeout(async () => { - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: 'Done' }] - }); - }, 50); - - return { task }; - } - return { content: [] }; - }); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - // Start a task-augmented request with progress callback - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be sent - await new Promise(resolve => setTimeout(resolve, 10)); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Create a task in the mock store first so it exists when we try to get it later - const createdTask = await taskStore.createTask({ ttl: 60000 }, messageId, request); - const taskId = createdTask.taskId; - - // Simulate CreateTaskResult response - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: createdTask - } - }); - } - - await Promise.resolve(); - await Promise.resolve(); - - // Progress notification should work while task is working - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100 - } - }); - } - - await Promise.resolve(); - - expect(progressCallback).toHaveBeenCalledTimes(1); - - // Verify the task-progress association was created - const taskProgressTokens = (protocol as unknown as TestProtocolInternals)._taskManager._taskProgressTokens as Map; - expect(taskProgressTokens.has(taskId)).toBe(true); - expect(taskProgressTokens.get(taskId)).toBe(progressToken); - - // Simulate task completion by triggering an inbound request whose handler - // calls storeTaskResult through the task context (the public RequestTaskStore API). - // This is equivalent to how a real server handler would complete a task. - protocol.setRequestHandler('ping', async (_request, ctx) => { - if (ctx.task?.store) { - await ctx.task.store.storeTaskResult(taskId, 'completed', { content: [] }); - } - return {}; - }); - if (transport.onmessage) { - transport.onmessage({ jsonrpc: '2.0', id: 999, method: 'ping', params: {} }); - } - - // Wait for all async operations including notification sending to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the association was cleaned up - expect(taskProgressTokens.has(taskId)).toBe(false); - - // Try to send progress notification after task completion - should be ignored - progressCallback.mockClear(); - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 100, - total: 100 - } - }); - } - - await Promise.resolve(); - - // Progress callback should NOT be invoked after task completion - expect(progressCallback).not.toHaveBeenCalled(); - }); - - it('should stop progress notifications when task reaches terminal status (failed)', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Simulate CreateTaskResult response - const taskId = 'test-task-456'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Simulate task failure via storeTaskResult - await taskStore.storeTaskResult(taskId, 'failed', { - content: [], - isError: true - }); - - // Manually trigger the status notification - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/tasks/status', - params: { - taskId, - status: 'failed', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - statusMessage: 'Task failed' - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Try to send progress notification after task failure - should be ignored - progressCallback.mockClear(); - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 75, - total: 100 - } - }); - } - - expect(progressCallback).not.toHaveBeenCalled(); - }); - - it('should stop progress notifications when task is cancelled', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Simulate CreateTaskResult response - const taskId = 'test-task-789'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Simulate task cancellation via updateTaskStatus - await taskStore.updateTaskStatus(taskId, 'cancelled', 'User cancelled'); - - // Manually trigger the status notification - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/tasks/status', - params: { - taskId, - status: 'cancelled', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - statusMessage: 'User cancelled' - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Try to send progress notification after cancellation - should be ignored - progressCallback.mockClear(); - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 25, - total: 100 - } - }); - } - - expect(progressCallback).not.toHaveBeenCalled(); - }); - - it('should use the same progressToken throughout task lifetime', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Simulate CreateTaskResult response - const taskId = 'test-task-consistency'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - await Promise.resolve(); - await Promise.resolve(); - - // Send multiple progress notifications with the same token - const progressUpdates = [ - { progress: 25, total: 100 }, - { progress: 50, total: 100 }, - { progress: 75, total: 100 } - ]; - - for (const update of progressUpdates) { - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, // Same token for all notifications - ...update - } - }); - } - await Promise.resolve(); - } - - // Verify all progress notifications were received with the same token - expect(progressCallback).toHaveBeenCalledTimes(3); - expect(progressCallback).toHaveBeenNthCalledWith(1, { progress: 25, total: 100 }); - expect(progressCallback).toHaveBeenNthCalledWith(2, { progress: 50, total: 100 }); - expect(progressCallback).toHaveBeenNthCalledWith(3, { progress: 75, total: 100 }); - }); - - it('should maintain progressToken throughout task lifetime', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'long-running-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - const onProgressMock = vi.fn(); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 60000 - }, - onprogress: onProgressMock - }); - - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.params._meta.progressToken).toBeDefined(); - }); - - it('should support progress notifications with task-augmented requests', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - const onProgressMock = vi.fn(); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000 - }, - onprogress: onProgressMock - }); - - const sentMessage = sendSpy.mock.calls[0]![0]; - const progressToken = sentMessage.params._meta.progressToken; - - // Simulate progress notification - transport.onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100, - message: 'Processing...' - } - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(onProgressMock).toHaveBeenCalledWith({ - progress: 50, - total: 100, - message: 'Processing...' - }); - }); - - it('should continue progress notifications after CreateTaskResult', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - const onProgressMock = vi.fn(); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000 - }, - onprogress: onProgressMock - }); - - const sentMessage = sendSpy.mock.calls[0]![0]; - const progressToken = sentMessage.params._meta.progressToken; - - // Simulate CreateTaskResult response - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sentMessage.id, - result: { - task: { - taskId: 'task-123', - status: 'working', - ttl: 30000, - createdAt: new Date().toISOString() - } - } - }); - }, 5); - - // Progress notifications should still work - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 75, - total: 100 - } - }); - }, 10); - - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(onProgressMock).toHaveBeenCalledWith({ - progress: 75, - total: 100 - }); - }); -}); - -describe('Capability negotiation for tasks', () => { - it('should use empty objects for capability fields', () => { - const serverCapabilities = { - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - call: {} - } - } - } - }; - - expect(serverCapabilities.tasks.list).toEqual({}); - expect(serverCapabilities.tasks.cancel).toEqual({}); - expect(serverCapabilities.tasks.requests.tools.call).toEqual({}); - }); - - it('should include list and cancel in server capabilities', () => { - const serverCapabilities = { - tasks: { - list: {}, - cancel: {} - } - }; - - expect('list' in serverCapabilities.tasks).toBe(true); - expect('cancel' in serverCapabilities.tasks).toBe(true); - }); - - it('should include list and cancel in client capabilities', () => { - const clientCapabilities = { - tasks: { - list: {}, - cancel: {} - } - }; - - expect('list' in clientCapabilities.tasks).toBe(true); - expect('cancel' in clientCapabilities.tasks).toBe(true); - }); -}); - -describe('Message interception for task-related notifications', () => { - it('should queue notifications with io.modelcontextprotocol/related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task first - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a notification with related task metadata - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { taskId: task.taskId } - } - ); - - // Access the private queue to verify the message was queued - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(task.taskId); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: task.taskId }); - }); - - it('should not queue notifications without related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Send a notification without related task metadata - await server.notification({ - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }); - - // Verify message was not queued (notification without metadata goes through transport) - // We can't directly check the queue, but we know it wasn't queued because - // notifications without relatedTask metadata are sent via transport, not queued - }); - - // Test removed: _taskResultWaiters was removed in favor of polling-based task updates - // The functionality is still tested through integration tests that verify message queuing works - - it('should propagate queue overflow errors without failing the task', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue(), maxTaskQueueSize: 100 }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Fill the queue to max capacity (100 messages) - for (let i = 0; i < 100; i++) { - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: `message ${i}` } - }, - { - relatedTask: { taskId: task.taskId } - } - ); - } - - // Try to add one more message - should throw an error - await expect( - server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'overflow message' } - }, - { - relatedTask: { taskId: task.taskId } - } - ) - ).rejects.toThrow('overflow'); - - // Verify the task was NOT automatically failed by the Protocol - // (implementations can choose to fail tasks on overflow if they want) - expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'failed', expect.anything(), expect.anything()); - }); - - it('should extract task ID correctly from metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - const taskId = 'custom-task-id-123'; - - // Send a notification with custom task ID - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { taskId } - } - ); - - // Verify the message was queued under the correct task ID - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - }); - - it('should preserve message order when queuing multiple notifications', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send multiple notifications - for (let i = 0; i < 5; i++) { - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: `message ${i}` } - }, - { - relatedTask: { taskId: task.taskId } - } - ); - } - - // Verify messages are in FIFO order - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - for (let i = 0; i < 5; i++) { - const queuedMessage = await queue!.dequeue(task.taskId); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.params!.data).toBe(`message ${i}`); - } - }); -}); - -describe('Message interception for task-related requests', () => { - it('should queue requests with io.modelcontextprotocol/related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task first - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata (don't await - we're testing queuing) - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Access the private queue to verify the message was queued - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(task.taskId); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.method).toBe('ping'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: task.taskId }); - - // Verify resolver is stored in _requestResolvers map (not in the message) - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - const resolvers = (server as unknown as TestProtocolInternals)._taskManager._requestResolvers; - expect(resolvers.has(requestId)).toBe(true); - - // Clean up - send a response to prevent hanging promise - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: {} - }); - - await requestPromise; - }); - - it('should not queue requests without related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Send a request without related task metadata - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}) - ); - - // Verify queue exists (but we don't track size in the new API) - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Clean up - send a response - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: {} - }); - - await requestPromise; - }); - - // Test removed: _taskResultWaiters was removed in favor of polling-based task updates - // The functionality is still tested through integration tests that verify message queuing works - - it('should store request resolver for response routing', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Verify the resolver was stored - const resolvers = (server as unknown as TestProtocolInternals)._taskManager._requestResolvers; - expect(resolvers.size).toBe(1); - - // Get the request ID from the queue - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue(task.taskId); - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - - expect(resolvers.has(requestId)).toBe(true); - - // Send a response to trigger resolver - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: {} - }); - - await requestPromise; - - // Verify resolver was cleaned up after response - expect(resolvers.has(requestId)).toBe(false); - }); - - it('should route responses to side-channeled requests', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const queue = new InMemoryTaskMessageQueue(); - const server = createTestProtocol({ taskStore, taskMessageQueue: queue }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({ message: z.string() }), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Get the request ID from the queue - const queuedMessage = await queue.dequeue(task.taskId); - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - - // Enqueue a response message to the queue (simulating client sending response back) - await queue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: requestId, - result: { message: 'pong' } - }, - timestamp: Date.now() - }); - - // Simulate a client calling tasks/result which will process the response - // This is done by creating a mock request handler that will trigger the GetTaskPayloadRequest handler - const mockRequestId = 999; - transport.onmessage?.({ - jsonrpc: '2.0', - id: mockRequestId, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Wait for the response to be processed - await new Promise(resolve => setTimeout(resolve, 50)); - - // Mark task as completed - await taskStore.updateTaskStatus(task.taskId, 'completed'); - await taskStore.storeTaskResult(task.taskId, 'completed', { _meta: {} }); - - // Verify the response was routed correctly - const result = await requestPromise; - expect(result).toEqual({ message: 'pong' }); - }); - - it('should log error when resolver is missing for side-channeled request', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - const errors: Error[] = []; - server.onerror = (error: Error) => { - errors.push(error); - }; - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata - void testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({ message: z.string() }), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Get the request ID from the queue - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue(task.taskId); - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - - // Manually delete the resolver to simulate missing resolver - (server as unknown as TestProtocolInternals)._taskManager._requestResolvers.delete(requestId); - - // Enqueue a response message - this should trigger the error logging when processed - await queue!.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: requestId, - result: { message: 'pong' } - }, - timestamp: Date.now() - }); - - // Simulate a client calling tasks/result which will process the response - const mockRequestId = 888; - transport.onmessage?.({ - jsonrpc: '2.0', - id: mockRequestId, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Wait for the response to be processed - await new Promise(resolve => setTimeout(resolve, 50)); - - // Mark task as completed - await taskStore.updateTaskStatus(task.taskId, 'completed'); - await taskStore.storeTaskResult(task.taskId, 'completed', { _meta: {} }); - - // Wait a bit more for error to be logged - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify error was logged - expect(errors.length).toBeGreaterThanOrEqual(1); - expect(errors.some(e => e.message.includes('Response handler missing for request'))).toBe(true); - }); - - it('should propagate queue overflow errors for requests without failing the task', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue(), maxTaskQueueSize: 100 }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Fill the queue to max capacity (100 messages) - const promises: Promise[] = []; - for (let i = 0; i < 100; i++) { - const promise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ).catch(() => { - // Requests will remain pending until task completes or fails - }); - promises.push(promise); - } - - // Try to add one more request - should throw an error - await expect( - testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ) - ).rejects.toThrow('overflow'); - - // Verify the task was NOT automatically failed by the Protocol - // (implementations can choose to fail tasks on overflow if they want) - expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'failed', expect.anything(), expect.anything()); - }); -}); - -describe('Message Interception', () => { - let protocol: Protocol; - let transport: MockTransport; - let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; - - beforeEach(() => { - transport = new MockTransport(); - mockTaskStore = createMockTaskStore(); - protocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - }); - - describe('messages with relatedTask metadata are queued', () => { - it('should queue notifications with relatedTask metadata', async () => { - await protocol.connect(transport); - - // Send a notification with relatedTask metadata - await protocol.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { - taskId: 'task-123' - } - } - ); - - // Access the private _taskMessageQueue to verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('task-123'); - assertQueuedNotification(queuedMessage); - expect(queuedMessage!.message.method).toBe('notifications/message'); - }); - - it('should queue requests with relatedTask metadata', async () => { - await protocol.connect(transport); - - const mockSchema = z.object({ result: z.string() }); - - // Send a request with relatedTask metadata - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { - relatedTask: { - taskId: 'task-456' - } - } - ); - - // Access the private _taskMessageQueue to verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('task-456'); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.method).toBe('test/request'); - - // Verify resolver is stored in _requestResolvers map (not in the message) - const requestId = queuedMessage.message.id as RequestId; - const resolvers = (protocol as unknown as TestProtocolInternals)._taskManager._requestResolvers; - expect(resolvers.has(requestId)).toBe(true); - - // Clean up the pending request - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: { result: 'success' } - }); - await requestPromise; - }); - }); - - describe('server queues responses/errors for task-related requests', () => { - it('should queue response when handling a request with relatedTask metadata', async () => { - await protocol.connect(transport); - - // Set up a request handler that returns a result - protocol.setRequestHandler('ping', async () => { - return {}; - }); - - // Simulate an incoming request with relatedTask metadata - const requestId = 456; - const taskId = 'task-response-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'ping', - params: { - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - } - }); - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the response was queued instead of sent directly - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage!.type).toBe('response'); - if (queuedMessage!.type === 'response') { - expect(queuedMessage!.message.id).toBe(requestId); - expect(queuedMessage!.message.result).toEqual({}); - } - }); - - it('should queue error when handling a request with relatedTask metadata that throws', async () => { - await protocol.connect(transport); - - // Set up a request handler that throws an error - protocol.setRequestHandler('ping', async () => { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'Test error message'); - }); - - // Simulate an incoming request with relatedTask metadata - const requestId = 789; - const taskId = 'task-error-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'ping', - params: { - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - } - }); - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the error was queued instead of sent directly - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage!.type).toBe('error'); - if (queuedMessage!.type === 'error') { - expect(queuedMessage!.message.id).toBe(requestId); - expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.InternalError); - expect(queuedMessage!.message.error.message).toContain('Test error message'); - } - }); - - it('should queue MethodNotFound error for unknown method with relatedTask metadata', async () => { - await protocol.connect(transport); - - // Simulate an incoming request for unknown method with relatedTask metadata - const requestId = 101; - const taskId = 'task-not-found-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'unknown/method', - params: { - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - } - }); - - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the error was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage!.type).toBe('error'); - if (queuedMessage!.type === 'error') { - expect(queuedMessage!.message.id).toBe(requestId); - expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.MethodNotFound); - } - }); - - it('should send response normally when request has no relatedTask metadata', async () => { - await protocol.connect(transport); - const sendSpy = vi.spyOn(transport, 'send'); - - // Set up a request handler - protocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'done' }] }; - }); - - // Simulate an incoming request WITHOUT relatedTask metadata - const requestId = 202; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'tools/call', - params: { name: 'test-tool' } - }); - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the response was sent through transport, not queued - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: requestId, - result: { content: [{ type: 'text', text: 'done' }] } - }) - ); - }); - }); - - describe('messages without metadata bypass the queue', () => { - it('should not queue notifications without relatedTask metadata', async () => { - await protocol.connect(transport); - - // Send a notification without relatedTask metadata - await protocol.notification({ - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }); - - // Access the private _taskMessageQueue to verify no messages were queued - // Since we can't check if queues exist without messages, we verify that - // attempting to dequeue returns undefined (no messages queued) - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - }); - - it('should not queue requests without relatedTask metadata', async () => { - await protocol.connect(transport); - - const mockSchema = z.object({ result: z.string() }); - const sendSpy = vi.spyOn(transport, 'send'); - - // Send a request without relatedTask metadata - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema - ); - - // Access the private _taskMessageQueue to verify no messages were queued - // Since we can't check if queues exist without messages, we verify that - // attempting to dequeue returns undefined (no messages queued) - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Clean up the pending request - const requestId = (sendSpy.mock.calls[0]![0] as JSONRPCResultResponse).id; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: { result: 'success' } - }); - await requestPromise; - }); - }); - - describe('task ID extraction from metadata', () => { - it('should extract correct task ID from relatedTask metadata for notifications', async () => { - await protocol.connect(transport); - - const taskId = 'extracted-task-789'; - - // Send a notification with relatedTask metadata - await protocol.notification( - { - method: 'notifications/message', - params: { data: 'test' } - }, - { - relatedTask: { - taskId: taskId - } - } - ); - - // Verify the message was queued under the correct task ID - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify a message was queued for this task - const queuedMessage = await queue!.dequeue(taskId); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - }); - - it('should extract correct task ID from relatedTask metadata for requests', async () => { - await protocol.connect(transport); - - const taskId = 'extracted-task-999'; - const mockSchema = z.object({ result: z.string() }); - - // Send a request with relatedTask metadata - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { - relatedTask: { - taskId: taskId - } - } - ); - - // Verify the message was queued under the correct task ID - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Clean up the pending request - const queuedMessage = await queue!.dequeue(taskId); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.method).toBe('test/request'); - transport.onmessage?.({ - jsonrpc: '2.0', - id: queuedMessage.message.id, - result: { result: 'success' } - }); - await requestPromise; - }); - - it('should handle multiple messages for different task IDs', async () => { - await protocol.connect(transport); - - // Send messages for different tasks - await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'task-A' } }); - await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'task-B' } }); - await protocol.notification({ method: 'test3', params: {} }, { relatedTask: { taskId: 'task-A' } }); - - // Verify messages are queued under correct task IDs - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify two messages for task-A - const msg1A = await queue!.dequeue('task-A'); - const msg2A = await queue!.dequeue('task-A'); - const msg3A = await queue!.dequeue('task-A'); // Should be undefined - expect(msg1A).toBeDefined(); - expect(msg2A).toBeDefined(); - expect(msg3A).toBeUndefined(); - - // Verify one message for task-B - const msg1B = await queue!.dequeue('task-B'); - const msg2B = await queue!.dequeue('task-B'); // Should be undefined - expect(msg1B).toBeDefined(); - expect(msg2B).toBeUndefined(); - }); - }); - - describe('queue creation on first message', () => { - it('should queue messages for a task', async () => { - await protocol.connect(transport); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Send first message for a task - await protocol.notification({ method: 'test', params: {} }, { relatedTask: { taskId: 'new-task' } }); - - // Verify message was queued - const msg = await queue!.dequeue('new-task'); - assertQueuedNotification(msg); - expect(msg.message.method).toBe('test'); - }); - - it('should queue multiple messages for the same task', async () => { - await protocol.connect(transport); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Send first message - await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'reuse-task' } }); - - // Send second message - await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'reuse-task' } }); - - // Verify both messages were queued in order - const msg1 = await queue!.dequeue('reuse-task'); - const msg2 = await queue!.dequeue('reuse-task'); - assertQueuedNotification(msg1); - expect(msg1.message.method).toBe('test1'); - assertQueuedNotification(msg2); - expect(msg2.message.method).toBe('test2'); - }); - - it('should queue messages for different tasks separately', async () => { - await protocol.connect(transport); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Send messages for different tasks - await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'task-1' } }); - await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'task-2' } }); - - // Verify messages are queued separately - const msg1 = await queue!.dequeue('task-1'); - const msg2 = await queue!.dequeue('task-2'); - assertQueuedNotification(msg1); - expect(msg1?.message.method).toBe('test1'); - assertQueuedNotification(msg2); - expect(msg2?.message.method).toBe('test2'); - }); - }); - - describe('metadata preservation in queued messages', () => { - it('should preserve relatedTask metadata in queued notification', async () => { - await protocol.connect(transport); - - const relatedTask = { taskId: 'task-meta-123' }; - - await protocol.notification( - { - method: 'test/notification', - params: { data: 'test' } - }, - { relatedTask } - ); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue('task-meta-123'); - - // Verify the metadata is preserved in the queued message - expect(queuedMessage).toBeDefined(); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.params!._meta).toBeDefined(); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual(relatedTask); - }); - - it('should preserve relatedTask metadata in queued request', async () => { - await protocol.connect(transport); - - const relatedTask = { taskId: 'task-meta-456' }; - const mockSchema = z.object({ result: z.string() }); - - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { relatedTask } - ); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue('task-meta-456'); - - // Verify the metadata is preserved in the queued message - expect(queuedMessage).toBeDefined(); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.params!._meta).toBeDefined(); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual(relatedTask); - - // Clean up - transport.onmessage?.({ - jsonrpc: '2.0', - id: (queuedMessage!.message as JSONRPCRequest).id, - result: { result: 'success' } - }); - await requestPromise; - }); - - it('should preserve existing _meta fields when adding relatedTask', async () => { - await protocol.connect(transport); - - await protocol.notification( - { - method: 'test/notification', - params: { - data: 'test', - _meta: { - customField: 'customValue', - anotherField: 123 - } - } - }, - { - relatedTask: { taskId: 'task-preserve-meta' } - } - ); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue('task-preserve-meta'); - - // Verify both existing and new metadata are preserved - expect(queuedMessage).toBeDefined(); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.params!._meta!.customField).toBe('customValue'); - expect(queuedMessage.message.params!._meta!.anotherField).toBe(123); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ - taskId: 'task-preserve-meta' - }); - }); - }); -}); - -describe('Queue lifecycle management', () => { - let protocol: Protocol; - let transport: MockTransport; - let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; - - beforeEach(() => { - transport = new MockTransport(); - mockTaskStore = createMockTaskStore(); - protocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - }); - - describe('queue cleanup on task completion', () => { - it('should clear queue when task reaches completed status', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue some messages for the task - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - await protocol.notification({ method: 'test/notification', params: { data: 'test2' } }, { relatedTask: { taskId } }); - - // Verify messages are queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify messages can be dequeued - const msg1 = await queue!.dequeue(taskId); - const msg2 = await queue!.dequeue(taskId); - expect(msg1).toBeDefined(); - expect(msg2).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // After cleanup, no more messages should be available - const msg3 = await queue!.dequeue(taskId); - expect(msg3).toBeUndefined(); - }); - - it('should clear queue after delivering messages on tasks/result for completed task', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a message - await protocol.notification({ method: 'test/notification', params: { data: 'test' } }, { relatedTask: { taskId } }); - - // Mark task as completed - const completedTask = { ...task, status: 'completed' as const }; - mockTaskStore.getTask.mockResolvedValue(completedTask); - mockTaskStore.getTaskResult.mockResolvedValue({ content: [{ type: 'text', text: 'done' }] }); - - // Simulate tasks/result request - const resultPromise = new Promise(resolve => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: 100, - method: 'tasks/result', - params: { taskId } - }); - setTimeout(resolve, 50); - }); - - await resultPromise; - - // Verify queue is cleared after delivery (no messages available) - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - }); - - describe('queue cleanup on task cancellation', () => { - it('should clear queue when task is cancelled', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue some messages - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - - // Verify message is queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const msg1 = await queue!.dequeue(taskId); - expect(msg1).toBeDefined(); - - // Re-queue the message for cancellation test - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - - // Mock task as non-terminal - mockTaskStore.getTask.mockResolvedValue(task); - - // Cancel the task - transport.onmessage?.({ - jsonrpc: '2.0', - id: 200, - method: 'tasks/cancel', - params: { taskId } - }); - - // Wait for cancellation to process - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify queue is cleared (no messages available) - const msg2 = await queue!.dequeue(taskId); - expect(msg2).toBeUndefined(); - }); - - it('should reject pending request resolvers when task is cancelled', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a request (catch rejection to avoid unhandled promise rejection) - const requestPromise = testRequest( - protocol, - { method: 'test/request', params: { data: 'test' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Verify request is queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Mock task as non-terminal - mockTaskStore.getTask.mockResolvedValue(task); - - // Cancel the task - transport.onmessage?.({ - jsonrpc: '2.0', - id: 201, - method: 'tasks/cancel', - params: { taskId } - }); - - // Wait for cancellation to process - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the request promise is rejected - const result = (await requestPromise) as Error; - expect(result).toBeInstanceOf(ProtocolError); - expect(result.message).toContain('Task cancelled or completed'); - - // Verify queue is cleared (no messages available) - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - }); - - describe('queue cleanup on task failure', () => { - it('should clear queue when task reaches failed status', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue some messages - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - await protocol.notification({ method: 'test/notification', params: { data: 'test2' } }, { relatedTask: { taskId } }); - - // Verify messages are queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify messages can be dequeued - const msg1 = await queue!.dequeue(taskId); - const msg2 = await queue!.dequeue(taskId); - expect(msg1).toBeDefined(); - expect(msg2).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // After cleanup, no more messages should be available - const msg3 = await queue!.dequeue(taskId); - expect(msg3).toBeUndefined(); - }); - - it('should reject pending request resolvers when task fails', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a request (catch the rejection to avoid unhandled promise rejection) - const requestPromise = testRequest( - protocol, - { method: 'test/request', params: { data: 'test' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Verify request is queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // Verify the request promise is rejected - const result = (await requestPromise) as Error; - expect(result).toBeInstanceOf(ProtocolError); - expect(result.message).toContain('Task cancelled or completed'); - - // Verify queue is cleared (no messages available) - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - }); - - describe('resolver rejection on cleanup', () => { - it('should reject all pending request resolvers when queue is cleared', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue multiple requests (catch rejections to avoid unhandled promise rejections) - const request1Promise = testRequest( - protocol, - { method: 'test/request1', params: { data: 'test1' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - const request2Promise = testRequest( - protocol, - { method: 'test/request2', params: { data: 'test2' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - const request3Promise = testRequest( - protocol, - { method: 'test/request3', params: { data: 'test3' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Verify requests are queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // Verify all request promises are rejected - const result1 = (await request1Promise) as Error; - const result2 = (await request2Promise) as Error; - const result3 = (await request3Promise) as Error; - - expect(result1).toBeInstanceOf(ProtocolError); - expect(result1.message).toContain('Task cancelled or completed'); - expect(result2).toBeInstanceOf(ProtocolError); - expect(result2.message).toContain('Task cancelled or completed'); - expect(result3).toBeInstanceOf(ProtocolError); - expect(result3.message).toContain('Task cancelled or completed'); - - // Verify queue is cleared (no messages available) - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - - it('should clean up resolver mappings when rejecting requests', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a request (catch rejection to avoid unhandled promise rejection) - const requestPromise = testRequest( - protocol, - { method: 'test/request', params: { data: 'test' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Get the request ID that was sent - const requestResolvers = (protocol as unknown as TestProtocolInternals)._taskManager._requestResolvers; - const initialResolverCount = requestResolvers.size; - expect(initialResolverCount).toBeGreaterThan(0); - - // Complete the task (triggers cleanup) - const completedTask = { ...task, status: 'completed' as const }; - mockTaskStore.getTask.mockResolvedValue(completedTask); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // Verify request promise is rejected - const result = (await requestPromise) as Error; - expect(result).toBeInstanceOf(ProtocolError); - expect(result.message).toContain('Task cancelled or completed'); - - // Verify resolver mapping is cleaned up - // The resolver should be removed from the map - expect(requestResolvers.size).toBeLessThan(initialResolverCount); - }); - }); -}); - -describe('requestStream() method', () => { - const CallToolResultSchema = z.object({ - content: z.array(z.object({ type: z.string(), text: z.string() })), - _meta: z.object({}).optional() - }); - - test('should yield result immediately for non-task requests', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - // Start the request stream - const streamPromise = (async () => { - const messages = []; - const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ); - for await (const message of stream) { - messages.push(message); - } - return messages; - })(); - - // Simulate server response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { - content: [{ type: 'text', text: 'test result' }], - _meta: {} - } - }); - - const messages = await streamPromise; - - // Should yield exactly one result message - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('result'); - expect(messages[0]).toHaveProperty('result'); - }); - - test('should yield error message on request failure', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - // Start the request stream - const streamPromise = (async () => { - const messages = []; - const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ); - for await (const message of stream) { - messages.push(message); - } - return messages; - })(); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Test error' - } - }); - - const messages = await streamPromise; - - // Should yield exactly one error message - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('error'); - expect(messages[0]).toHaveProperty('error'); - if (messages[0]?.type === 'error') { - expect(messages[0]?.error?.message).toContain('Test error'); - } - }); - - test('should handle cancellation via AbortSignal', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const abortController = new AbortController(); - - // Abort immediately before starting the stream - abortController.abort('User cancelled'); - - // Start the request stream with already-aborted signal - const messages = []; - const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema, - { - signal: abortController.signal - } - ); - for await (const message of stream) { - messages.push(message); - } - - // Should yield error message about cancellation - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('error'); - if (messages[0]?.type === 'error') { - expect(messages[0]?.error?.message).toContain('cancelled'); - } - }); - - describe('Error responses', () => { - test('should yield error as terminal message for server error response', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Server error' - } - }); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - expect(lastMessage.error.message).toContain('Server error'); - }); - - test('should yield error as terminal message for timeout', async () => { - vi.useFakeTimers(); - try { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema, - { - timeout: 100 - } - ) - ); - - // Advance time to trigger timeout - await vi.advanceTimersByTimeAsync(101); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - expect(lastMessage.error).toBeInstanceOf(SdkError); - expect((lastMessage.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); - } finally { - vi.useRealTimers(); - } - }); - - test('should yield error as terminal message for cancellation', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const abortController = new AbortController(); - abortController.abort('User cancelled'); - - // Collect messages - const messages = await toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema, - { - signal: abortController.signal - } - ) - ); - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - expect(lastMessage.error.message).toContain('cancelled'); - }); - - test('should not yield any messages after error message', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Test error' - } - }); - - // Collect messages - const messages = await messagesPromise; - - // Verify only one message (the error) was yielded - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('error'); - - // Try to send another message (should be ignored) - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { - content: [{ type: 'text', text: 'should not appear' }] - } - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify no additional messages were yielded - expect(messages).toHaveLength(1); - }); - - test('should yield error as terminal message for task failure', async () => { - const transport = new MockTransport(); - const mockTaskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore: mockTaskStore }); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate task creation response - await new Promise(resolve => setTimeout(resolve, 10)); - const taskId = 'test-task-123'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { - _meta: { - task: { - taskId, - status: 'working', - createdAt: new Date().toISOString(), - pollInterval: 100 - } - } - } - }); - - // Wait for task creation to be processed - await new Promise(resolve => setTimeout(resolve, 20)); - - // Update task to failed status - const failedTask = { - taskId, - status: 'failed' as const, - createdAt: new Date().toISOString(), - pollInterval: 100, - ttl: null, - statusMessage: 'Task failed' - }; - mockTaskStore.getTask.mockResolvedValue(failedTask); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - }); - - test('should yield error as terminal message for network error', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - // Override send to simulate network error - transport.send = vi.fn().mockRejectedValue(new Error('Network error')); - - const messages = await toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - }); - - test('should ensure error is always the final message', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Test error' - } - }); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is the last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - expect(lastMessage?.type).toBe('error'); - - // Verify all messages before the last are not terminal - for (let i = 0; i < messages.length - 1; i++) { - expect(messages[i]?.type).not.toBe('error'); - expect(messages[i]?.type).not.toBe('result'); - } - }); - }); -}); - -describe('Error handling for missing resolvers', () => { - let protocol: Protocol; - let transport: MockTransport; - let taskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; - let taskMessageQueue: TaskMessageQueue; - let errorHandler: MockInstance; - - beforeEach(() => { - taskStore = createMockTaskStore(); - taskMessageQueue = new InMemoryTaskMessageQueue(); - errorHandler = vi.fn(); - - protocol = createTestProtocol({ taskStore, taskMessageQueue, defaultTaskPollInterval: 100 }); - - // @ts-expect-error deliberately overriding error handler with mock - protocol.onerror = errorHandler; - transport = new MockTransport(); - }); - - describe('Response routing with missing resolvers', () => { - it('should log error for unknown request ID without throwing', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue a response message without a corresponding resolver - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 999, // Non-existent request ID - result: { content: [] } - }, - timestamp: Date.now() - }); - - // Set up the GetTaskPayloadRequest handler to process the message - const testProtocol = protocol as unknown as TestProtocolInternals; - - // Simulate dequeuing and processing the response - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage?.type).toBe('response'); - - // Manually trigger the response handling logic - if (queuedMessage && queuedMessage.type === 'response') { - const responseMessage = queuedMessage.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - - if (!resolver) { - // This simulates what happens in the actual handler - protocol.onerror?.(new Error(`Response handler missing for request ${requestId}`)); - } - } - - // Verify error was logged - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Response handler missing for request 999') - }) - ); - }); - - it('should continue processing after missing resolver error', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue a response with missing resolver, then a valid notification - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 999, - result: { content: [] } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: { progress: 50, total: 100 } - }, - timestamp: Date.now() - }); - - // Process first message (response with missing resolver) - const msg1 = await taskMessageQueue.dequeue(task.taskId); - expect(msg1?.type).toBe('response'); - - // Process second message (should work fine) - const msg2 = await taskMessageQueue.dequeue(task.taskId); - expect(msg2?.type).toBe('notification'); - expect(msg2?.message).toMatchObject({ - method: 'notifications/progress' - }); - }); - }); - - describe('Task cancellation with missing resolvers', () => { - it('should log error when resolver is missing during cleanup', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue a request without storing a resolver - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - // Clear the task queue (simulating cancellation) - const testProtocol = protocol as unknown as TestProtocolInternals; - await testProtocol._taskManager._clearTaskQueue(task.taskId); - - // Verify error was logged for missing resolver - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Resolver missing for request 42') - }) - ); - }); - - it('should handle cleanup gracefully when resolver exists', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - const requestId = 42; - const resolverMock = vi.fn(); - - // Store a resolver - const testProtocol = protocol as unknown as TestProtocolInternals; - testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); - - // Enqueue a request - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: requestId, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - // Clear the task queue - await testProtocol._taskManager._clearTaskQueue(task.taskId); - - // Verify resolver was called with cancellation error - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - - // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InternalError); - expect(calledError.message).toContain('Task cancelled or completed'); - - // Verify resolver was removed - expect(testProtocol._taskManager._requestResolvers.has(requestId)).toBe(false); - }); - - it('should handle mixed messages during cleanup', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - const testProtocol = protocol as unknown as TestProtocolInternals; - - // Enqueue multiple messages: request with resolver, request without, notification - const requestId1 = 42; - const resolverMock = vi.fn(); - testProtocol._taskManager._requestResolvers.set(requestId1, resolverMock); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: requestId1, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: 43, // No resolver for this one - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: { progress: 50, total: 100 } - }, - timestamp: Date.now() - }); - - // Clear the task queue - await testProtocol._taskManager._clearTaskQueue(task.taskId); - - // Verify resolver was called for first request - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - - // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InternalError); - expect(calledError.message).toContain('Task cancelled or completed'); - - // Verify error was logged for second request - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Resolver missing for request 43') - }) - ); - - // Verify queue is empty - const remaining = await taskMessageQueue.dequeue(task.taskId); - expect(remaining).toBeUndefined(); - }); - }); - - describe('Side-channeled request error handling', () => { - it('should log error when response handler is missing for side-channeled request', async () => { - await protocol.connect(transport); - - const testProtocol = protocol as unknown as TestProtocolInternals; - const messageId = 123; - - // Create a response resolver without a corresponding response handler - const responseResolver = (response: JSONRPCResultResponse | Error) => { - const handler = testProtocol._responseHandlers.get(messageId); - if (handler) { - handler(response); - } else { - protocol.onerror?.(new Error(`Response handler missing for side-channeled request ${messageId}`)); - } - }; - - // Simulate the resolver being called without a handler - const mockResponse: JSONRPCResultResponse = { - jsonrpc: '2.0', - id: messageId, - result: { content: [] } - }; - - responseResolver(mockResponse); - - // Verify error was logged - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Response handler missing for side-channeled request 123') - }) - ); - }); - }); - - describe('Error handling does not throw exceptions', () => { - it('should not throw when processing response with missing resolver', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 999, - result: { content: [] } - }, - timestamp: Date.now() - }); - - // This should not throw - const processMessage = async () => { - const msg = await taskMessageQueue.dequeue(task.taskId); - if (msg && msg.type === 'response') { - const testProtocol = protocol as unknown as TestProtocolInternals; - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (!resolver) { - protocol.onerror?.(new Error(`Response handler missing for request ${requestId}`)); - } - } - }; - - await expect(processMessage()).resolves.not.toThrow(); - }); - - it('should not throw during task cleanup with missing resolvers', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - const testProtocol = protocol as unknown as TestProtocolInternals; - - // This should not throw - await expect(testProtocol._taskManager._clearTaskQueue(task.taskId)).resolves.not.toThrow(); - }); - }); - - describe('Error message routing', () => { - it('should route error messages to resolvers correctly', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const requestId = 42; - const resolverMock = vi.fn(); - - // Store a resolver - const testProtocol = protocol as unknown as TestProtocolInternals; - testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); - - // Enqueue an error message - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: requestId, - error: { - code: ProtocolErrorCode.InvalidRequest, - message: 'Invalid request parameters' - } - }, - timestamp: Date.now() - }); - - // Simulate dequeuing and processing the error - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage?.type).toBe('error'); - - // Manually trigger the error handling logic - if (queuedMessage && queuedMessage.type === 'error') { - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const reqId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(reqId); - - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(reqId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - - // Verify resolver was called with ProtocolError - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InvalidRequest); - expect(calledError.message).toContain('Invalid request parameters'); - - // Verify resolver was removed from map - expect(testProtocol._taskManager._requestResolvers.has(requestId)).toBe(false); - }); - - it('should log error for unknown request ID in error messages', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue an error message without a corresponding resolver - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 999, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Something went wrong' - } - }, - timestamp: Date.now() - }); - - // Simulate dequeuing and processing the error - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage?.type).toBe('error'); - - // Manually trigger the error handling logic - if (queuedMessage && queuedMessage.type === 'error') { - const testProtocol = protocol as unknown as TestProtocolInternals; - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - - if (!resolver) { - protocol.onerror?.(new Error(`Error handler missing for request ${requestId}`)); - } - } - - // Verify error was logged - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Error handler missing for request 999') - }) - ); - }); - - it('should handle error messages with data field', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const requestId = 42; - const resolverMock = vi.fn(); - - // Store a resolver - const testProtocol = protocol as unknown as TestProtocolInternals; - testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); - - // Enqueue an error message with data field - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: requestId, - error: { - code: ProtocolErrorCode.InvalidParams, - message: 'Validation failed', - data: { field: 'userName', reason: 'required' } - } - }, - timestamp: Date.now() - }); - - // Simulate dequeuing and processing the error - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - - if (queuedMessage && queuedMessage.type === 'error') { - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const reqId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(reqId); - - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(reqId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - - // Verify resolver was called with ProtocolError including data - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InvalidParams); - expect(calledError.message).toContain('Validation failed'); - expect(calledError.data).toEqual({ field: 'userName', reason: 'required' }); - }); - - it('should not throw when processing error with missing resolver', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 999, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Error occurred' - } - }, - timestamp: Date.now() - }); - - // This should not throw - const processMessage = async () => { - const msg = await taskMessageQueue.dequeue(task.taskId); - if (msg && msg.type === 'error') { - const testProtocol = protocol as unknown as TestProtocolInternals; - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (!resolver) { - protocol.onerror?.(new Error(`Error handler missing for request ${requestId}`)); - } - } - }; - - await expect(processMessage()).resolves.not.toThrow(); - }); - }); - - describe('Response and error message routing integration', () => { - it('should handle mixed response and error messages in queue', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const testProtocol = protocol as unknown as TestProtocolInternals; - - // Set up resolvers for multiple requests - const resolver1 = vi.fn(); - const resolver2 = vi.fn(); - const resolver3 = vi.fn(); - - testProtocol._taskManager._requestResolvers.set(1, resolver1); - testProtocol._taskManager._requestResolvers.set(2, resolver2); - testProtocol._taskManager._requestResolvers.set(3, resolver3); - - // Enqueue mixed messages: response, error, response - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 1, - result: { content: [{ type: 'text', text: 'Success' }] } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 2, - error: { - code: ProtocolErrorCode.InvalidRequest, - message: 'Request failed' - } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 3, - result: { content: [{ type: 'text', text: 'Another success' }] } - }, - timestamp: Date.now() - }); - - // Process all messages - let msg; - while ((msg = await taskMessageQueue.dequeue(task.taskId))) { - if (msg.type === 'response') { - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - resolver(responseMessage); - } - } else if (msg.type === 'error') { - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - } - - // Verify all resolvers were called correctly - expect(resolver1).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); - expect(resolver2).toHaveBeenCalledWith(expect.any(ProtocolError)); - expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); - - // Verify error has correct properties - const error = resolver2.mock.calls[0]![0]; - expect(error.code).toBe(ProtocolErrorCode.InvalidRequest); - expect(error.message).toContain('Request failed'); - - // Verify all resolvers were removed - expect(testProtocol._taskManager._requestResolvers.size).toBe(0); - }); - - it('should maintain FIFO order when processing responses and errors', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const testProtocol = protocol as unknown as TestProtocolInternals; - - const callOrder: number[] = []; - const resolver1 = vi.fn(() => callOrder.push(1)); - const resolver2 = vi.fn(() => callOrder.push(2)); - const resolver3 = vi.fn(() => callOrder.push(3)); - - testProtocol._taskManager._requestResolvers.set(1, resolver1); - testProtocol._taskManager._requestResolvers.set(2, resolver2); - testProtocol._taskManager._requestResolvers.set(3, resolver3); - - // Enqueue in specific order - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { jsonrpc: '2.0', id: 1, result: {} }, - timestamp: 1000 - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 2, - error: { code: -32600, message: 'Error' } - }, - timestamp: 2000 - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { jsonrpc: '2.0', id: 3, result: {} }, - timestamp: 3000 - }); - - // Process all messages - let msg; - while ((msg = await taskMessageQueue.dequeue(task.taskId))) { - if (msg.type === 'response') { - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - resolver(responseMessage); - } - } else if (msg.type === 'error') { - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - } - - // Verify FIFO order was maintained - expect(callOrder).toEqual([1, 2, 3]); - }); - }); -}); - -describe('Protocol without task configuration', () => { - let protocol: TestProtocolImpl; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = createTestProtocol(); // empty TaskManager options - }); - - test('request/response flow works normally without task config', async () => { - await protocol.connect(transport); - const mockSchema = z.object({ result: z.string() }); - - const requestPromise = testRequest(protocol, { method: 'example', params: {} }, mockSchema, { timeout: 5000 }); - - // Simulate response - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { result: 'hello' } - }); - - const result = await requestPromise; - expect(result).toEqual({ result: 'hello' }); - }); - - test('notifications are sent with proper JSONRPC wrapping without task config', async () => { - await protocol.connect(transport); - - await protocol.notification({ method: 'notifications/cancelled', params: { requestId: '1', reason: 'test' } }); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { requestId: '1', reason: 'test' } - }), - undefined - ); - }); - - test('onClose does not error without task config', async () => { - await protocol.connect(transport); - await expect(protocol.close()).resolves.not.toThrow(); - }); - - test('inbound requests dispatch to handlers without task config', async () => { - const handler = vi.fn().mockResolvedValue({ content: 'ok' }); - protocol.setRequestHandler('ping', handler); - - await protocol.connect(transport); - transport.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 1 }); - - // Wait for async handler - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(handler).toHaveBeenCalled(); - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: 1, - result: { content: 'ok' } - }) - ); - }); -}); - -describe('TaskManager lifecycle via Protocol', () => { - let protocol: TestProtocolImpl; - let transport: MockTransport; - - beforeEach(() => { - transport = new MockTransport(); - protocol = new TestProtocolImpl(); - }); - - test('bind() is called during Protocol construction', () => { - const bindSpy = vi.spyOn(TaskManager.prototype, 'bind'); - const p = new TestProtocolImpl({ tasks: {} }); - expect(bindSpy).toHaveBeenCalled(); - expect(p.taskManager).toBeInstanceOf(TaskManager); - bindSpy.mockRestore(); - }); - - test('NullTaskManager is created when no tasks config is provided', () => { - const p = new TestProtocolImpl(); - expect(p.taskManager).toBeInstanceOf(NullTaskManager); - }); - - test('onClose() is called when transport closes', async () => { - const p = createTestProtocol({}); - const onCloseSpy = vi.spyOn(p.taskManager, 'onClose'); - - await p.connect(transport); - await p.close(); - - expect(onCloseSpy).toHaveBeenCalled(); - }); -}); - -describe('TaskManager always present (NullTaskManager pattern)', () => { - test('taskManager accessor always returns a TaskManager', () => { - const mockTaskModule = { getTask: vi.fn() }; - const mockClient = { taskManager: mockTaskModule } as any; - expect(mockClient.taskManager).toBe(mockTaskModule); - }); -}); diff --git a/packages/core/test/shared/protocolTransportHandling.test.ts b/packages/core/test/shared/protocolTransportHandling.test.ts index 4e9c33e67d..23e3dad76b 100644 --- a/packages/core/test/shared/protocolTransportHandling.test.ts +++ b/packages/core/test/shared/protocolTransportHandling.test.ts @@ -38,8 +38,6 @@ describe('Protocol transport handling bug', () => { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} protected buildContext(ctx: BaseContext): BaseContext { return ctx; } diff --git a/packages/core/test/shared/wrapHandler.test.ts b/packages/core/test/shared/wrapHandler.test.ts index 6a6e33fb09..452b58194f 100644 --- a/packages/core/test/shared/wrapHandler.test.ts +++ b/packages/core/test/shared/wrapHandler.test.ts @@ -10,8 +10,6 @@ class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} } describe('Protocol._wrapHandler', () => { diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts index d26a4cd701..60cc57da84 100644 --- a/packages/core/test/spec.types.test.ts +++ b/packages/core/test/spec.types.test.ts @@ -225,6 +225,7 @@ const sdkTypeChecks = { spec = sdk; }, ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + // @ts-expect-error SEP-2663: spec union includes task variants the SDK does not implement sdk = spec; spec = sdk; }, @@ -493,10 +494,12 @@ const sdkTypeChecks = { spec = sdk; }, ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + // @ts-expect-error SEP-2663: spec union includes task variants the SDK does not implement sdk = spec; spec = sdk; }, ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { + // @ts-expect-error SEP-2663: spec union includes task variants the SDK does not implement sdk = spec; spec = sdk; }, @@ -505,6 +508,7 @@ const sdkTypeChecks = { spec = sdk; }, ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + // @ts-expect-error SEP-2663: spec union includes task variants the SDK does not implement sdk = spec; spec = sdk; }, @@ -552,74 +556,10 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - TaskAugmentedRequestParams: (sdk: SDKTypes.TaskAugmentedRequestParams, spec: SpecTypes.TaskAugmentedRequestParams) => { - sdk = spec; - spec = sdk; - }, ToolExecution: (sdk: SDKTypes.ToolExecution, spec: SpecTypes.ToolExecution) => { sdk = spec; spec = sdk; }, - TaskStatus: (sdk: SDKTypes.TaskStatus, spec: SpecTypes.TaskStatus) => { - sdk = spec; - spec = sdk; - }, - TaskMetadata: (sdk: SDKTypes.TaskMetadata, spec: SpecTypes.TaskMetadata) => { - sdk = spec; - spec = sdk; - }, - RelatedTaskMetadata: (sdk: SDKTypes.RelatedTaskMetadata, spec: SpecTypes.RelatedTaskMetadata) => { - sdk = spec; - spec = sdk; - }, - Task: (sdk: SDKTypes.Task, spec: SpecTypes.Task) => { - sdk = spec; - spec = sdk; - }, - CreateTaskResult: (sdk: SDKTypes.CreateTaskResult, spec: SpecTypes.CreateTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskResult: (sdk: SDKTypes.GetTaskResult, spec: SpecTypes.GetTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskPayloadRequest) => { - sdk = spec; - spec = sdk; - }, - ListTasksRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListTasksRequest) => { - sdk = spec; - spec = sdk; - }, - ListTasksResult: (sdk: SDKTypes.ListTasksResult, spec: SpecTypes.ListTasksResult) => { - sdk = spec; - spec = sdk; - }, - CancelTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CancelTaskRequest) => { - sdk = spec; - spec = sdk; - }, - CancelTaskResult: (sdk: SDKTypes.CancelTaskResult, spec: SpecTypes.CancelTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskRequest) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadResult: (sdk: SDKTypes.GetTaskPayloadResult, spec: SpecTypes.GetTaskPayloadResult) => { - sdk = spec; - spec = sdk; - }, - TaskStatusNotificationParams: (sdk: SDKTypes.TaskStatusNotificationParams, spec: SpecTypes.TaskStatusNotificationParams) => { - sdk = spec; - spec = sdk; - }, - TaskStatusNotification: (sdk: WithJSONRPC, spec: SpecTypes.TaskStatusNotification) => { - sdk = spec; - spec = sdk; - }, /* JSON primitives */ JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { @@ -715,29 +655,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CreateTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - GetTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.GetTaskPayloadResultResponse - ) => { - sdk = spec; - spec = sdk; - }, - CancelTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CancelTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - ListTasksResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListTasksResultResponse) => { - sdk = spec; - spec = sdk; - }, SetLevelResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SetLevelResultResponse) => { sdk = spec; spec = sdk; @@ -801,7 +718,7 @@ type Assert = T; * * Primitive type aliases — no object keys to compare (8): * JSONValue, JSONArray, Role, LoggingLevel, ProgressToken, RequestId, - * Cursor, TaskStatus + * Cursor */ // -- Simple types (96) -- @@ -819,14 +736,18 @@ type _K_ResourceUpdatedNotificationParams = Assert< AssertExactKeys >; type _K_GetPromptRequestParams = Assert>; +// @ts-expect-error SEP-2663: spec has optional `task` field; SDK removes task augmentation type _K_CallToolRequestParams = Assert>; type _K_SetLevelRequestParams = Assert>; type _K_LoggingMessageNotificationParams = Assert< AssertExactKeys >; +// @ts-expect-error SEP-2663: spec has optional `task` field; SDK removes task augmentation type _K_CreateMessageRequestParams = Assert>; type _K_CompleteRequestParams = Assert>; +// @ts-expect-error SEP-2663: spec has optional `task` field; SDK removes task augmentation type _K_ElicitRequestFormParams = Assert>; +// @ts-expect-error SEP-2663: spec has optional `task` field; SDK removes task augmentation type _K_ElicitRequestURLParams = Assert>; type _K_PaginatedRequestParams = Assert>; type _K_BaseMetadata = Assert>; @@ -884,7 +805,9 @@ type _K_LegacyTitledEnumSchema = Assert>; type _K_JSONRPCResultResponse = Assert>; type _K_InitializeResult = Assert>; +// @ts-expect-error SEP-2663: spec has `tasks` capability; SDK removes it type _K_ClientCapabilities = Assert>; +// @ts-expect-error SEP-2663: spec has `tasks` capability; SDK removes it type _K_ServerCapabilities = Assert>; type _K_SamplingMessage = Assert>; type _K_Icon = Assert>; @@ -895,22 +818,9 @@ type _K_ToolChoice = Assert>; type _K_ToolResultContent = Assert>; type _K_Annotations = Assert>; -type _K_TaskAugmentedRequestParams = Assert>; type _K_ToolExecution = Assert>; -type _K_TaskMetadata = Assert>; -type _K_RelatedTaskMetadata = Assert>; -type _K_Task = Assert>; -type _K_CreateTaskResult = Assert>; -type _K_GetTaskResult = Assert>; -type _K_ListTasksResult = Assert>; -type _K_CancelTaskResult = Assert>; -type _K_GetTaskPayloadResult = Assert>; -type _K_TaskStatusNotificationParams = Assert< - AssertExactKeys ->; type _K_JSONObject = Assert>; type _K_MetaObject = Assert>; -// @ts-expect-error Genuine mismatch: SDK RequestMetaObject has extra 'io.modelcontextprotocol/related-task' not in spec type _K_RequestMetaObject = Assert>; type _K_ParseError = Assert>; type _K_InvalidRequestError = Assert>; @@ -946,7 +856,6 @@ type _K_LoggingMessageNotification = Assert< AssertExactKeys, SpecTypes.LoggingMessageNotification> >; type _K_InitializedNotification = Assert, SpecTypes.InitializedNotification>>; -type _K_TaskStatusNotification = Assert, SpecTypes.TaskStatusNotification>>; // -- WithJSONRPCRequest-wrapped request types (21) -- // SDK request types do not include `jsonrpc` or `id` — the spec types do. We @@ -971,12 +880,6 @@ type _K_ListPromptsRequest = Assert, SpecTypes.GetPromptRequest>>; type _K_CreateMessageRequest = Assert, SpecTypes.CreateMessageRequest>>; type _K_InitializeRequest = Assert, SpecTypes.InitializeRequest>>; -type _K_GetTaskPayloadRequest = Assert< - AssertExactKeys, SpecTypes.GetTaskPayloadRequest> ->; -type _K_ListTasksRequest = Assert, SpecTypes.ListTasksRequest>>; -type _K_CancelTaskRequest = Assert, SpecTypes.CancelTaskRequest>>; -type _K_GetTaskRequest = Assert, SpecTypes.GetTaskRequest>>; // -- TypedResultResponse-wrapped types (21) -- // The spec defines typed *ResultResponse interfaces that pair JSONRPCResultResponse @@ -1004,17 +907,6 @@ type _K_ListPromptsResultResponse = Assert< type _K_GetPromptResultResponse = Assert, SpecTypes.GetPromptResultResponse>>; type _K_ListToolsResultResponse = Assert, SpecTypes.ListToolsResultResponse>>; type _K_CallToolResultResponse = Assert, SpecTypes.CallToolResultResponse>>; -type _K_CreateTaskResultResponse = Assert< - AssertExactKeys, SpecTypes.CreateTaskResultResponse> ->; -type _K_GetTaskResultResponse = Assert, SpecTypes.GetTaskResultResponse>>; -type _K_GetTaskPayloadResultResponse = Assert< - AssertExactKeys, SpecTypes.GetTaskPayloadResultResponse> ->; -type _K_CancelTaskResultResponse = Assert< - AssertExactKeys, SpecTypes.CancelTaskResultResponse> ->; -type _K_ListTasksResultResponse = Assert, SpecTypes.ListTasksResultResponse>>; type _K_SetLevelResultResponse = Assert, SpecTypes.SetLevelResultResponse>>; type _K_CreateMessageResultResponse = Assert< AssertExactKeys, SpecTypes.CreateMessageResultResponse> @@ -1055,8 +947,7 @@ const KEY_PARITY_EXCLUDED = [ 'LoggingLevel', 'ProgressToken', 'RequestId', - 'Cursor', - 'TaskStatus' + 'Cursor' ]; // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) @@ -1066,7 +957,32 @@ const SDK_TYPES_FILE = path.resolve(__dirname, '../src/types/types.ts'); const MISSING_SDK_TYPES = [ // These are inlined in the SDK: 'Error', // The inner error object of a JSONRPCError - 'URLElicitationRequiredError' // In the SDK, but with a custom definition + 'URLElicitationRequiredError', // In the SDK, but with a custom definition + // SEP-2663: 2025-11 experimental tasks removed from the SDK; spec still defines them. + 'Task', + 'TaskStatus', + 'TaskMetadata', + 'TaskCreationParams', + 'TaskAugmentedRequestParams', + 'RelatedTaskMetadata', + 'CreateTaskResult', + 'CreateTaskResultResponse', + 'GetTaskRequest', + 'GetTaskResult', + 'GetTaskResultResponse', + 'GetTaskPayloadRequest', + 'GetTaskPayloadResult', + 'GetTaskPayloadResultResponse', + 'ListTasksRequest', + 'ListTasksResult', + 'ListTasksResultResponse', + 'CancelTaskRequest', + 'CancelTaskResult', + 'CancelTaskResultResponse', + 'TaskStatusNotification', + 'TaskStatusNotificationParams', + 'ClientTasksCapability', + 'ServerTasksCapability' ]; function extractExportedTypes(source: string): string[] { diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts index 198e104f9f..e43513e81b 100644 --- a/packages/core/test/types/specTypeSchema.test.ts +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -154,9 +154,7 @@ describe('SPEC_SCHEMA_KEYS allowlist', () => { const INTERNAL_HELPER_SCHEMAS: readonly string[] = [ 'ListChangedOptionsBaseSchema', 'BaseRequestParamsSchema', - 'NotificationsParamsSchema', - 'ClientTasksCapabilitySchema', - 'ServerTasksCapabilitySchema' + 'NotificationsParamsSchema' ]; it('covers every public protocol schema in schemas.ts (drift guard)', () => { diff --git a/packages/server/src/experimental/index.ts b/packages/server/src/experimental/index.ts deleted file mode 100644 index 55dd44ed08..0000000000 --- a/packages/server/src/experimental/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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'; diff --git a/packages/server/src/experimental/tasks/index.ts b/packages/server/src/experimental/tasks/index.ts deleted file mode 100644 index 6917fe61af..0000000000 --- a/packages/server/src/experimental/tasks/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Experimental task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -export * from './interfaces.js'; -export * from './mcpServer.js'; -export * from './server.js'; diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts deleted file mode 100644 index 2aef91a8c0..0000000000 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Experimental task interfaces for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - */ - -import type { - CallToolResult, - CreateTaskResult, - CreateTaskServerContext, - GetTaskResult, - Result, - StandardSchemaWithJSON, - TaskServerContext -} from '@modelcontextprotocol/core'; - -import type { BaseToolCallback } from '../../server/mcp.js'; - -// ============================================================================ -// Task Handler Types (for registerToolTask) -// ============================================================================ - -/** - * Handler for creating a task. - * @experimental - */ -export type CreateTaskRequestHandler< - SendResultT extends Result, - Args extends StandardSchemaWithJSON | undefined = undefined -> = BaseToolCallback; - -/** - * Handler for task operations (`get`, `getResult`). - * @experimental - */ -export type TaskRequestHandler = BaseToolCallback< - SendResultT, - TaskServerContext, - Args ->; - -/** - * Interface for task-based tool handlers. - * - * Task-based tools split a long-running operation into three phases: - * `createTask`, `getTask`, and `getTaskResult`. - * - * @see {@linkcode @modelcontextprotocol/server!experimental/tasks/mcpServer.ExperimentalMcpServerTasks#registerToolTask | registerToolTask} for registration. - * @experimental - */ -export interface ToolTaskHandler { - /** - * Called on the initial `tools/call` request. - * - * Creates a task via `ctx.task.store.createTask(...)`, starts any - * background work, and returns the task object. - */ - createTask: CreateTaskRequestHandler; - /** - * Handler for `tasks/get` requests. - */ - getTask: TaskRequestHandler; - /** - * Handler for `tasks/result` requests. - */ - getTaskResult: TaskRequestHandler; -} diff --git a/packages/server/src/experimental/tasks/mcpServer.ts b/packages/server/src/experimental/tasks/mcpServer.ts deleted file mode 100644 index b7c28c40d3..0000000000 --- a/packages/server/src/experimental/tasks/mcpServer.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Experimental {@linkcode McpServer} task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import type { StandardSchemaWithJSON, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; - -import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; -import type { ToolTaskHandler } from './interfaces.js'; - -/** - * Internal interface for accessing {@linkcode McpServer}'s private _createRegisteredTool method. - * @internal - */ -interface McpServerInternal { - _createRegisteredTool( - name: string, - title: string | undefined, - description: string | undefined, - inputSchema: StandardSchemaWithJSON | undefined, - outputSchema: StandardSchemaWithJSON | undefined, - annotations: ToolAnnotations | undefined, - execution: ToolExecution | undefined, - _meta: Record | undefined, - handler: AnyToolHandler - ): RegisteredTool; -} - -/** - * Experimental task features for {@linkcode 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 {@linkcode ToolTaskHandler.createTask | createTask}, {@linkcode ToolTaskHandler.getTask | getTask}, and {@linkcode ToolTaskHandler.getTaskResult | getTaskResult} - * methods. - * - * @example - * ```typescript - * server.experimental.tasks.registerToolTask('long-computation', { - * description: 'Performs a long computation', - * inputSchema: z.object({ input: z.string() }), - * execution: { taskSupport: 'required' } - * }, { - * createTask: async (args, ctx) => { - * const task = await ctx.task.store.createTask({ ttl: 300000 }); - * startBackgroundWork(task.taskId, args); - * return { task }; - * }, - * getTask: async (args, ctx) => { - * return ctx.task.store.getTask(ctx.task.id); - * }, - * getTaskResult: async (args, ctx) => { - * return ctx.task.store.getTaskResult(ctx.task.id); - * } - * }); - * ``` - * - * @param name - The tool name - * @param config - Tool configuration (description, schemas, etc.) - * @param handler - Task handler with {@linkcode ToolTaskHandler.createTask | createTask}, {@linkcode ToolTaskHandler.getTask | getTask}, {@linkcode ToolTaskHandler.getTaskResult | getTaskResult} methods - * @returns {@linkcode server/mcp.RegisteredTool | RegisteredTool} for managing the tool's lifecycle - * - * @experimental - */ - registerToolTask( - name: string, - config: { - title?: string; - description?: string; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - execution?: TaskToolExecution; - _meta?: Record; - }, - handler: ToolTaskHandler - ): RegisteredTool; - - registerToolTask( - name: string, - config: { - title?: string; - description?: string; - inputSchema: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - execution?: TaskToolExecution; - _meta?: Record; - }, - handler: ToolTaskHandler - ): RegisteredTool; - - registerToolTask( - name: string, - config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - execution?: TaskToolExecution; - _meta?: Record; - }, - handler: ToolTaskHandler - ): 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 - ); - } -} diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts deleted file mode 100644 index 2e7b205fd6..0000000000 --- a/packages/server/src/experimental/tasks/server.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Experimental server task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import type { - AnyObjectSchema, - CancelTaskResult, - CreateMessageRequestParams, - CreateMessageResult, - ElicitRequestFormParams, - ElicitRequestURLParams, - ElicitResult, - GetTaskPayloadResult, - GetTaskResult, - ListTasksResult, - Request, - RequestMethod, - RequestOptions, - ResponseMessage, - ResultTypeMap -} from '@modelcontextprotocol/core'; -import { getResultSchema, GetTaskPayloadResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; - -import type { Server } from '../../server/server.js'; - -/** - * Experimental task features for low-level MCP servers. - * - * Access via `server.experimental.tasks`: - * ```typescript - * const stream = server.experimental.tasks.requestStream(request, options); - * ``` - * - * For high-level server usage with task-based tools, use {@linkcode index.McpServer | McpServer}.experimental.tasks instead. - * - * @experimental - */ -export class ExperimentalServerTasks { - constructor(private readonly _server: Server) {} - - private get _module() { - return this._server.taskManager; - } - - /** - * 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 (method name determines the result schema) - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects - * - * @experimental - */ - requestStream( - request: { method: M; params?: Record }, - options?: RequestOptions - ): AsyncGenerator, void, void> { - const resultSchema = getResultSchema(request.method) as unknown as AnyObjectSchema; - return this._module.requestStream(request as Request, resultSchema, options) as AsyncGenerator< - ResponseMessage, - void, - void - >; - } - - /** - * Sends a sampling request and returns an AsyncGenerator that yields response messages. - * The generator is guaranteed to end with either a 'result' or 'error' message. - * - * For task-augmented requests, yields 'taskCreated' and 'taskStatus' messages - * before the final result. - * - * @example - * ```typescript - * const stream = server.experimental.tasks.createMessageStream({ - * messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], - * maxTokens: 100 - * }, { - * onprogress: (progress) => { - * // Handle streaming tokens via progress notifications - * console.log('Progress:', progress.message); - * } - * }); - * - * for await (const message of stream) { - * switch (message.type) { - * case 'taskCreated': - * console.log('Task created:', message.task.taskId); - * break; - * case 'taskStatus': - * console.log('Task status:', message.task.status); - * break; - * case 'result': - * console.log('Final result:', message.result); - * break; - * case 'error': - * console.error('Error:', message.error); - * break; - * } - * } - * ``` - * - * @param params - The sampling request parameters - * @param options - Optional request options (timeout, signal, task creation params, onprogress, etc.) - * @returns AsyncGenerator that yields ResponseMessage objects - * - * @experimental - */ - createMessageStream( - params: CreateMessageRequestParams, - options?: RequestOptions - ): AsyncGenerator, void, void> { - // Access client capabilities via the server - const clientCapabilities = this._server.getClientCapabilities(); - - // Capability check - only required when tools/toolChoice are provided - if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); - } - - // Message structure validation - always validate tool_use/tool_result pairs. - // These may appear even without tools/toolChoice in the current request when - // a previous sampling request returned tool_use and this is a follow-up with results. - if (params.messages.length > 0) { - const lastMessage = params.messages.at(-1)!; - const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; - const hasToolResults = lastContent.some(c => c.type === 'tool_result'); - - const previousMessage = params.messages.length > 1 ? params.messages.at(-2) : undefined; - const previousContent = previousMessage - ? Array.isArray(previousMessage.content) - ? previousMessage.content - : [previousMessage.content] - : []; - const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); - - if (hasToolResults) { - if (lastContent.some(c => c.type !== 'tool_result')) { - throw new Error('The last message must contain only tool_result content if any is present'); - } - if (!hasPreviousToolUse) { - throw new Error('tool_result blocks are not matching any tool_use from the previous message'); - } - } - if (hasPreviousToolUse) { - const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => c.id)); - const toolResultIds = new Set(lastContent.filter(c => c.type === 'tool_result').map(c => c.toolUseId)); - if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { - throw new Error('ids of tool_result blocks and tool_use blocks from previous message do not match'); - } - } - } - - return this.requestStream( - { - method: 'sampling/createMessage', - params - }, - options - ) as AsyncGenerator, void, void>; - } - - /** - * Sends an elicitation request and returns an AsyncGenerator that yields response messages. - * The generator is guaranteed to end with either a 'result' or 'error' message. - * - * For task-augmented requests (especially URL-based elicitation), yields 'taskCreated' - * and 'taskStatus' messages before the final result. - * - * @example - * ```typescript - * const stream = server.experimental.tasks.elicitInputStream({ - * mode: 'url', - * message: 'Please authenticate', - * elicitationId: 'auth-123', - * url: 'https://example.com/auth' - * }, { - * task: { ttl: 300000 } // Task-augmented for long-running auth flow - * }); - * - * for await (const message of stream) { - * switch (message.type) { - * case 'taskCreated': - * console.log('Task created:', message.task.taskId); - * break; - * case 'taskStatus': - * console.log('Task status:', message.task.status); - * break; - * case 'result': - * console.log('User action:', message.result.action); - * break; - * case 'error': - * console.error('Error:', message.error); - * break; - * } - * } - * ``` - * - * @param params - The elicitation request parameters - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields ResponseMessage objects - * - * @experimental - */ - elicitInputStream( - params: ElicitRequestFormParams | ElicitRequestURLParams, - options?: RequestOptions - ): AsyncGenerator, void, void> { - // Access client capabilities via the server - const clientCapabilities = this._server.getClientCapabilities(); - const mode = params.mode ?? 'form'; - - // Capability check based on mode - switch (mode) { - case 'url': { - if (!clientCapabilities?.elicitation?.url) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); - } - break; - } - case 'form': { - if (!clientCapabilities?.elicitation?.form) { - throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); - } - break; - } - } - - // Normalize params to ensure mode is set - const normalizedParams = mode === 'form' && params.mode !== 'form' ? { ...params, mode: 'form' } : params; - return this.requestStream( - { - method: 'elicitation/create', - params: normalizedParams - }, - options - ) as AsyncGenerator, void, void>; - } - - /** - * 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 { - return this._module.getTask({ taskId }, options); - } - - /** - * Retrieves the result of a completed task. - * - * @param taskId - The task identifier - * @param options - Optional request options - * @returns The task result. The payload structure matches the result type of the - * original request (e.g., a `tools/call` task returns a `CallToolResult`). - * - * @experimental - */ - async getTaskResult(taskId: string, options?: RequestOptions): Promise { - return this._module.getTaskResult({ taskId }, GetTaskPayloadResultSchema, 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 { - return this._module.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 { - return this._module.cancelTask({ taskId }, options); - } -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 95566bbb4d..c33d394c8b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -40,11 +40,6 @@ export type { } from './server/streamableHttp.js'; export { WebStandardStreamableHTTPServerTransport } from './server/streamableHttp.js'; -// experimental exports -export type { CreateTaskRequestHandler, TaskRequestHandler, ToolTaskHandler } from './experimental/tasks/interfaces.js'; -export { ExperimentalMcpServerTasks } from './experimental/tasks/mcpServer.js'; -export { ExperimentalServerTasks } from './experimental/tasks/server.js'; - // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema.js'; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index fb45fd5db6..40ec8bb1eb 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,12 +1,9 @@ import type { BaseMetadata, - CallToolRequest, CallToolResult, CompleteRequestPrompt, CompleteRequestResourceTemplate, CompleteResult, - CreateTaskResult, - CreateTaskServerContext, GetPromptResult, Implementation, ListPromptsResult, @@ -41,8 +38,6 @@ import { } from '@modelcontextprotocol/core'; import type * as z from 'zod/v4'; -import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; -import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; import { getCompleter, isCompletable } from './completable.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; @@ -72,28 +67,11 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; - private _experimental?: { tasks: ExperimentalMcpServerTasks }; constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); } - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalMcpServerTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalMcpServerTasks(this) - }; - } - return this._experimental; - } - /** * Attaches to the given transport, starts it, and starts listening for messages. * @@ -160,7 +138,7 @@ export class McpServer { }) ); - this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { + this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { const tool = this._registeredTools[request.params.name]; if (!tool) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); @@ -170,41 +148,8 @@ export class McpServer { } try { - const isTaskRequest = !!request.params.task; - const taskSupport = tool.execution?.taskSupport; - const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); - - // Validate task hint configuration - if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { - throw new ProtocolError( - ProtocolErrorCode.InternalError, - `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask` - ); - } - - // Handle taskSupport 'required' without task augmentation - if (taskSupport === 'required' && !isTaskRequest) { - throw new ProtocolError( - ProtocolErrorCode.MethodNotFound, - `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')` - ); - } - - // Handle taskSupport 'optional' without task augmentation - automatic polling - if (taskSupport === 'optional' && !isTaskRequest && isTaskHandler) { - return await this.handleAutomaticTaskPolling(tool, request, ctx); - } - - // Normal execution path const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); const result = await this.executeToolHandler(tool, args, ctx); - - // Return CreateTaskResult immediately for task requests - if (isTaskRequest) { - return result; - } - - // Validate output schema for non-task requests await this.validateToolOutput(tool, result, request.params.name); return result; } catch (error) { @@ -265,16 +210,11 @@ export class McpServer { /** * Validates tool output against the tool's output schema. */ - private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult, toolName: string): Promise { if (!tool.outputSchema) { return; } - // Only validate CallToolResult, not CreateTaskResult - if (!('content' in result)) { - return; - } - if (result.isError) { return; } @@ -297,47 +237,13 @@ export class McpServer { } /** - * Executes a tool handler (either regular or task-based). + * Executes a tool handler. */ - private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { + private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { // Executor encapsulates handler invocation with proper types return tool.executor(args, ctx); } - /** - * Handles automatic task polling for tools with `taskSupport` `'optional'`. - */ - private async handleAutomaticTaskPolling( - tool: RegisteredTool, - request: RequestT, - ctx: ServerContext - ): Promise { - if (!ctx.task?.store) { - throw new Error('No task store provided for task-capable tool.'); - } - - // Validate input and create task using the executor - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const createTaskResult = (await tool.executor(args, ctx)) as CreateTaskResult; - - // Poll until completion - const taskId = createTaskResult.task.taskId; - let task = createTaskResult.task; - const pollInterval = task.pollInterval ?? 5000; - - while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { - await new Promise(resolve => setTimeout(resolve, pollInterval)); - const updatedTask = await ctx.task.store.getTask(taskId); - if (!updatedTask) { - throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`); - } - task = updatedTask; - } - - // Return the final result - return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult; - } - private _completionHandlerInitialized = false; private setCompletionRequestHandler() { @@ -914,7 +820,7 @@ export class McpServer { normalizeRawShapeSchema(inputSchema), normalizeRawShapeSchema(outputSchema), annotations, - { taskSupport: 'forbidden' }, + undefined, _meta, cb as ToolCallback ); @@ -1148,14 +1054,14 @@ export type ToolCallback; /** - * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). + * Tool handler callback type. */ -export type AnyToolHandler = ToolCallback | ToolTaskHandler; +export type AnyToolHandler = ToolCallback; /** * Internal executor type that encapsulates handler invocation with proper types. */ -type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; export type RegisteredTool = { title?: string; @@ -1194,23 +1100,6 @@ function createToolExecutor( inputSchema: StandardSchemaWithJSON | undefined, handler: AnyToolHandler ): ToolExecutor { - const isTaskHandler = 'createTask' in handler; - - if (isTaskHandler) { - const taskHandler = handler as TaskHandlerInternal; - return async (args, ctx) => { - if (!ctx.task?.store) { - throw new Error('No task store provided.'); - } - const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; - if (inputSchema) { - return taskHandler.createTask(args, taskCtx); - } - // When no inputSchema, call with just ctx (the handler expects (ctx) signature) - return (taskHandler.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); - }; - } - if (inputSchema) { const callback = handler as ToolCallbackInternal; return async (args, ctx) => callback(args, ctx); @@ -1300,10 +1189,6 @@ type PromptHandler = (args: Record | undefined, ctx: ServerCont type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; -type TaskHandlerInternal = { - createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; -}; - export type RegisteredPrompt = { title?: string; description?: string; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f6a34f02da..89e1de1817 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -28,21 +28,16 @@ import type { Result, ServerCapabilities, ServerContext, - TaskManagerOptions, ToolResultContent, ToolUseContent } from '@modelcontextprotocol/core'; import { - assertClientRequestTaskCapability, - assertToolsCallTaskCapability, CallToolRequestSchema, CallToolResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, ElicitResultSchema, EmptyResultSchema, - extractTaskManagerOptions, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -56,21 +51,11 @@ import { } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; - -/** - * Extended tasks capability that includes runtime configuration (store, messageQueue). - * The runtime-only fields are stripped before advertising capabilities to clients. - */ -export type ServerTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; - export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. */ - capabilities?: Omit & { - tasks?: ServerTasksCapabilityWithRuntime; - }; + capabilities?: ServerCapabilities; /** * Optional instructions describing how to use the server and its features. @@ -101,7 +86,6 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _experimental?: { tasks: ExperimentalServerTasks }; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -115,22 +99,11 @@ export class Server extends Protocol { private _serverInfo: Implementation, options?: ServerOptions ) { - super({ - ...options, - tasks: extractTaskManagerOptions(options?.capabilities?.tasks) - }); + super(options); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - // Strip runtime-only fields from advertised capabilities - if (options?.capabilities?.tasks) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = - options.capabilities.tasks; - this._capabilities.tasks = wireCapabilities; - } - this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); @@ -174,22 +147,6 @@ export class Server extends Protocol { }; } - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalServerTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalServerTasks(this) - }; - } - return this._experimental; - } - // Map log levels by session id private _loggingLevels = new Map(); @@ -237,24 +194,8 @@ export class Server extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); } - const { params } = validatedRequest.data; - const result = await handler(request, ctx); - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against CallToolResultSchema const validationResult = parseSchema(CallToolResultSchema, result); if (!validationResult.success) { const errorMessage = @@ -410,14 +351,6 @@ export class Server extends Protocol { } } - protected assertTaskCapability(method: string): void { - assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); - } - - protected assertTaskHandlerCapability(method: string): void { - assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); - } - private async _oninitialize(request: InitializeRequest): Promise { const requestedVersion = request.params.protocolVersion; diff --git a/test/helpers/src/helpers/tasks.ts b/test/helpers/src/helpers/tasks.ts deleted file mode 100644 index 4db3231a67..0000000000 --- a/test/helpers/src/helpers/tasks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Task } from '@modelcontextprotocol/core'; - -/** - * Polls the provided getTask function until the task reaches the desired status or times out. - */ -export async function waitForTaskStatus( - getTask: (taskId: string) => Promise, - taskId: string, - desiredStatus: Task['status'], - { - intervalMs = 100, - timeoutMs = 10_000 - }: { - intervalMs?: number; - timeoutMs?: number; - } = {} -): Promise { - const start = Date.now(); - - // eslint-disable-next-line no-constant-condition - while (true) { - const task = await getTask(taskId); - if (task && task.status === desiredStatus) { - return task; - } - - if (Date.now() - start > timeoutMs) { - throw new Error(`Timed out waiting for task ${taskId} to reach status ${desiredStatus}`); - } - - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } -} diff --git a/test/helpers/src/index.ts b/test/helpers/src/index.ts index 1ecfa8e24a..1fd7ce2b9b 100644 --- a/test/helpers/src/index.ts +++ b/test/helpers/src/index.ts @@ -1,3 +1,2 @@ export * from './helpers/http.js'; export * from './helpers/oauth.js'; -export * from './helpers/tasks.js'; diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 52d151bddb..3c433da975 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1,8 +1,6 @@ import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/client'; import type { Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - ElicitResultSchema, InMemoryTransport, LATEST_PROTOCOL_VERSION, ProtocolErrorCode, @@ -10,8 +8,7 @@ import { SdkErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; -import { InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/server'; -import * as z from 'zod/v4'; +import { McpServer, Server } from '@modelcontextprotocol/server'; /*** * Test: Initialize with Matching Protocol Version @@ -2280,1812 +2277,21 @@ describe('outputSchema validation', () => { }); }); -describe('Task-based execution', () => { - describe('Client calling server', () => { - let serverTaskStore: InMemoryTaskStore; - - beforeEach(() => { - serverTaskStore = new InMemoryTaskStore(); - }); - - afterEach(() => { - serverTaskStore?.cleanup(); - }); - - test('should create task on server via tool call', async () => { - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Tool executed successfully!' }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { capabilities: { tasks: { requests: { tools: { call: {} } } } } } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Client creates task on server via tool call - await client.callTool( - { name: 'test-tool', arguments: {} }, - { - task: { - ttl: 60_000 - } - } - ); - - // Verify task was created successfully by listing tasks - const taskList = await client.experimental.tasks.listTasks(); - expect(taskList.tasks.length).toBeGreaterThan(0); - const task = taskList.tasks[0]!; - expect(task.status).toBe('completed'); - }); - - test('should query task status from server using getTask', async () => { - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Success!' }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { capabilities: { tasks: { requests: { tools: { call: {} } } } } } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create a task - await client.callTool( - { name: 'test-tool', arguments: {} }, - { - task: { ttl: 60_000 } - } - ); - - // Query task status by listing tasks and getting the first one - const taskList = await client.experimental.tasks.listTasks(); - expect(taskList.tasks.length).toBeGreaterThan(0); - const task = taskList.tasks[0]!; - expect(task).toBeDefined(); - expect(task.taskId).toBeDefined(); - expect(task.status).toBe('completed'); - }); - - test('should query task result from server using getTaskResult', async () => { - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {}, - list: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Result data!' }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { capabilities: { tasks: { requests: { tools: { call: {} } } } } } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create a task using callToolStream to capture the task ID - let taskId: string | undefined; - const stream = client.experimental.tasks.callToolStream( - { name: 'test-tool', arguments: {} }, - { - task: { ttl: 60_000 } - } - ); - - for await (const message of stream) { - if (message.type === 'taskCreated') { - taskId = message.task.taskId; - } - } - - expect(taskId).toBeDefined(); - - // Query task result using the captured task ID - const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); - expect(result.content).toEqual([{ type: 'text', text: 'Result data!' }]); - }); - - test('should query task list from server using listTasks', async () => { - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Success!' }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { capabilities: { tasks: { requests: { tools: { call: {} } } } } } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create multiple tasks - const createdTaskIds: string[] = []; - - for (let i = 0; i < 2; i++) { - await client.callTool( - { name: 'test-tool', arguments: {} }, - { - task: { ttl: 60_000 } - } - ); - - // Get the task ID from the task list - const taskList = await client.experimental.tasks.listTasks(); - const newTask = taskList.tasks.find(t => !createdTaskIds.includes(t.taskId)); - if (newTask) { - createdTaskIds.push(newTask.taskId); - } - } - - // Query task list - const taskList = await client.experimental.tasks.listTasks(); - expect(taskList.tasks.length).toBeGreaterThanOrEqual(2); - for (const taskId of createdTaskIds) { - expect(taskList.tasks).toContainEqual( - expect.objectContaining({ - taskId, - status: 'completed' - }) - ); - } - }); - }); - - describe('Server calling client', () => { - let clientTaskStore: InMemoryTaskStore; - - beforeEach(() => { - clientTaskStore = new InMemoryTaskStore(); - }); - - afterEach(() => { - clientTaskStore?.cleanup(); - }); - - test('should create task on client via server elicitation', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Server creates task on client via elicitation - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Please provide your username', - requestedSchema: { - type: 'object', - properties: { - username: { type: 'string' } - }, - required: ['username'] - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - // Verify task was created - const task = await server.experimental.tasks.getTask(taskId); - expect(task.status).toBe('completed'); - }); - - test('should query task status from client using getTask', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create a task on client and wait for CreateTaskResult - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Please provide info', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - // Query task status - const task = await server.experimental.tasks.getTask(taskId); - expect(task).toBeDefined(); - expect(task.taskId).toBe(taskId); - expect(task.status).toBe('completed'); - }); - - test('should query task result from client using getTaskResult', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'result-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create a task on client and wait for CreateTaskResult - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Please provide info', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - // Query task result using getTaskResult - const taskResult = await server.experimental.tasks.getTaskResult(taskId, ElicitResultSchema); - expect(taskResult.action).toBe('accept'); - expect(taskResult.content).toEqual({ username: 'result-user' }); - }); - - test('should query task list from client using listTasks', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create multiple tasks on client - const createdTaskIds: string[] = []; - for (let i = 0; i < 2; i++) { - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Please provide info', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure and capture taskId - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - createdTaskIds.push(createTaskResult.task.taskId); - } - - // Query task list - const taskList = await server.experimental.tasks.listTasks(); - expect(taskList.tasks.length).toBeGreaterThanOrEqual(2); - for (const taskId of createdTaskIds) { - expect(taskList.tasks).toContainEqual( - expect.objectContaining({ - taskId, - status: 'completed' - }) - ); - } - }); - }); - - test('should list tasks from server with pagination', async () => { - const serverTaskStore = new InMemoryTaskStore(); - - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({ - id: z.string() - }) - }, - { - async createTask({ id }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - const result = { - content: [{ type: 'text', text: `Result for ${id || 'unknown'}` }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create multiple tasks - const createdTaskIds: string[] = []; - - for (let i = 0; i < 3; i++) { - await client.callTool( - { name: 'test-tool', arguments: { id: `task-${i + 1}` } }, - { - task: { ttl: 60_000 } - } - ); - - // Get the task ID from the task list - const taskList = await client.experimental.tasks.listTasks(); - const newTask = taskList.tasks.find(t => !createdTaskIds.includes(t.taskId)); - if (newTask) { - createdTaskIds.push(newTask.taskId); - } - } - - // List all tasks without cursor - const firstPage = await client.experimental.tasks.listTasks(); - expect(firstPage.tasks.length).toBeGreaterThan(0); - expect(firstPage.tasks.map(t => t.taskId)).toEqual(expect.arrayContaining(createdTaskIds)); - - // If there's a cursor, test pagination - if (firstPage.nextCursor) { - const secondPage = await client.experimental.tasks.listTasks(firstPage.nextCursor); - expect(secondPage.tasks).toBeDefined(); - } - - serverTaskStore.cleanup(); - }); - - describe('Error scenarios', () => { - let serverTaskStore: InMemoryTaskStore; - let clientTaskStore: InMemoryTaskStore; - - beforeEach(() => { - serverTaskStore = new InMemoryTaskStore(); - clientTaskStore = new InMemoryTaskStore(); - }); - - afterEach(() => { - serverTaskStore?.cleanup(); - clientTaskStore?.cleanup(); - }); - - test('should throw error when querying non-existent task from server', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Try to get a task that doesn't exist - await expect(client.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); - }); - - test('should throw error when querying result of non-existent task from server', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Try to get result of a task that doesn't exist - await expect(client.experimental.tasks.getTaskResult('non-existent-task', CallToolResultSchema)).rejects.toThrow(); - }); - - test('should throw error when server queries non-existent task from client', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async () => ({ - action: 'accept', - content: { username: 'test' } - })); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Try to query a task that doesn't exist on client - await expect(server.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); - }); - }); -}); - -test('should respect server task capabilities', async () => { - const serverTaskStore = new InMemoryTaskStore(); - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore: serverTaskStore - } - } - } - ); - - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Success!' }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - enforceStrictCapabilities: true, - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Server supports task creation for tools/call - expect(client.getServerCapabilities()).toEqual({ - tools: { - listChanged: true - }, - tasks: { - requests: { - tools: { - call: {} - } - } - } - }); - - // These should work because server supports tasks - await expect( - client.callTool( - { name: 'test-tool', arguments: {} }, - { - task: { ttl: 60_000 } - } - ) - ).resolves.not.toThrow(); - await expect(client.experimental.tasks.listTasks()).resolves.not.toThrow(); - - // tools/list doesn't support task creation, but it shouldn't throw - it should just ignore the task metadata - await expect( - client.request({ - method: 'tools/list', - params: {} - }) - ).resolves.not.toThrow(); - - serverTaskStore.cleanup(); -}); - -/** - * Test: requestStream() method - */ -test('should expose requestStream() method for streaming responses', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/call', async () => { - return { - content: [{ type: 'text', text: 'Tool result' }] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // First verify that regular request() works - const regularResult = await client.callTool({ name: 'test-tool', arguments: {} }); - expect(regularResult.content).toEqual([{ type: 'text', text: 'Tool result' }]); - - // Test requestStream with non-task request (should yield only result) - const stream = client.experimental.tasks.requestStream({ - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - // Should have received only a result message (no task messages) - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('result'); - if (messages[0]!.type === 'result') { - expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Tool result' }]); - } - - await client.close(); - await server.close(); -}); - -/** - * Test: callToolStream() method - */ -test('should expose callToolStream() method for streaming tool calls', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/call', async () => { - return { - content: [{ type: 'text', text: 'Tool result' }] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Test callToolStream - const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - // Should have received messages ending with result - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('result'); - if (messages[0]!.type === 'result') { - expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Tool result' }]); - } - - await client.close(); - await server.close(); -}); - -/** - * Test: callToolStream() with output schema validation - */ -test('should validate structured output in callToolStream()', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => { - return { - tools: [ - { - name: 'structured-tool', - description: 'A tool with output schema', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - value: { type: 'number' } - }, - required: ['value'] - } - } - ] - }; - }); - - server.setRequestHandler('tools/call', async () => { - return { - content: [{ type: 'text', text: 'Result' }], - structuredContent: { value: 42 } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the output schema - await client.listTools(); - - // Test callToolStream with valid structured output - const stream = client.experimental.tasks.callToolStream({ name: 'structured-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - // Should have received result with validated structured content - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('result'); - if (messages[0]!.type === 'result') { - expect(messages[0]!.result.structuredContent).toEqual({ value: 42 }); - } - - await client.close(); - await server.close(); -}); - -test('callToolStream() should yield error when structuredContent does not match schema', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - }, - required: ['result', 'count'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler('tools/call', async () => { - // Return invalid structured content (count is string instead of number) - return { - structuredContent: { result: 'success', count: 'not a number' } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('error'); - if (messages[0]!.type === 'error') { - expect(messages[0]!.error.message).toMatch(/Structured content does not match the tool's output schema/); - } - - await client.close(); - await server.close(); -}); - -test('callToolStream() should yield error when tool with outputSchema returns no structuredContent', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' } - }, - required: ['result'] - } - } - ] - })); - - server.setRequestHandler('tools/call', async () => { - return { - content: [{ type: 'text', text: 'This should be structured content' }] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - await client.listTools(); - - const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('error'); - if (messages[0]!.type === 'error') { - expect(messages[0]!.error.message).toMatch(/Tool test-tool has an output schema but did not return structured content/); - } - - await client.close(); - await server.close(); -}); - -test('callToolStream() should handle tools without outputSchema normally', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - } - } - ] - })); - - server.setRequestHandler('tools/call', async () => { - return { - content: [{ type: 'text', text: 'Normal response' }] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - await client.listTools(); - - const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('result'); - if (messages[0]!.type === 'result') { - expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Normal response' }]); - } - - await client.close(); - await server.close(); -}); - -test('callToolStream() should handle complex JSON schema validation', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'complex-tool', - description: 'A tool with complex schema', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - name: { type: 'string', minLength: 3 }, - age: { type: 'integer', minimum: 0, maximum: 120 }, - active: { type: 'boolean' }, - tags: { - type: 'array', - items: { type: 'string' }, - minItems: 1 - }, - metadata: { - type: 'object', - properties: { - created: { type: 'string' } - }, - required: ['created'] - } - }, - required: ['name', 'age', 'active', 'tags', 'metadata'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler('tools/call', async () => { - return { - structuredContent: { - name: 'John Doe', - age: 30, - active: true, - tags: ['user', 'admin'], - metadata: { - created: '2023-01-01T00:00:00Z' - } - } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - await client.listTools(); - - const stream = client.experimental.tasks.callToolStream({ name: 'complex-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('result'); - if (messages[0]!.type === 'result') { - expect(messages[0]!.result.structuredContent).toBeDefined(); - const structuredContent = messages[0]!.result.structuredContent as { name: string; age: number }; - expect(structuredContent.name).toBe('John Doe'); - expect(structuredContent.age).toBe(30); - } - - await client.close(); - await server.close(); -}); - -test('callToolStream() should yield error with additional properties when not allowed', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'strict-tool', - description: 'A tool with strict schema', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - name: { type: 'string' } - }, - required: ['name'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler('tools/call', async () => { - return { - structuredContent: { - name: 'John', - extraField: 'not allowed' - } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - await client.listTools(); - - const stream = client.experimental.tasks.callToolStream({ name: 'strict-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('error'); - if (messages[0]!.type === 'error') { - expect(messages[0]!.error.message).toMatch(/Structured content does not match the tool's output schema/); - } - - await client.close(); - await server.close(); -}); - -test('callToolStream() should not validate structuredContent when isError is true', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' } - }, - required: ['result'] - } - } - ] - })); - - server.setRequestHandler('tools/call', async () => { - // Return isError with content (no structuredContent) - should NOT trigger validation error - return { - isError: true, - content: [{ type: 'text', text: 'Something went wrong' }] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: { requests: { tools: { call: {} } } } } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - await client.listTools(); - - const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); - - const messages = []; - for await (const message of stream) { - messages.push(message); - } - - // Should have received result (not error), with isError flag set - expect(messages.length).toBe(1); - expect(messages[0]!.type).toBe('result'); - if (messages[0]!.type === 'result') { - expect(messages[0]!.result.isError).toBe(true); - expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Something went wrong' }]); - } - - await client.close(); - await server.close(); -}); +// The 2025-11 task suites that lived here are removed under SEP-2663: +// +// `Task-based execution` (Client calling server / Server calling client / Error scenarios): +// Replacement coverage lands with the SEP-2663 tasks implementation; nothing in this +// commit re-covers it. The server-to-client half (server polls client's tasks/*) is the +// pattern SEP-2663 removes entirely; that direction becomes MRTR, not tasks. +// +// `should respect server task capabilities`: +// Removed. Tasks is an extension under SEP-2663, not core protocol; there is no +// client-side `assertCapabilityForMethod` case for `tasks/*`. +// +// `requestStream()` / `callToolStream()` (9 tests): +// Removed. These tested incremental result streaming. SEP-2663's server-directed model +// returns a CreateTaskResult pointer (not a stream). Use `callTool()` and inspect for +// `{resultType: 'task'}`, then poll with `pollTask()`. The methods are removed. describe('getSupportedElicitationModes', () => { test('should support nothing when capabilities are undefined', () => { diff --git a/test/integration/test/experimental/tasks/task.test.ts b/test/integration/test/experimental/tasks/task.test.ts deleted file mode 100644 index d2aca2cc07..0000000000 --- a/test/integration/test/experimental/tasks/task.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Task } from '@modelcontextprotocol/core'; -import { isTerminal, TaskCreationParamsSchema } from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; - -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: 60_000, - createdAt, - lastUpdatedAt: createdAt, - pollInterval: 1000 - }; - - expect(task.ttl).toBe(60_000); - 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: 30_000, - 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: 30_000, - 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(); - for (const status of statuses) { - const task: Task = { - taskId: `test-${status}`, - status, - ttl: null, - createdAt, - lastUpdatedAt: createdAt - }; - expect(task.status).toBe(status); - } - }); -}); - -describe('TaskCreationParams Schema Validation', () => { - it('should accept ttl as a number', () => { - const result = TaskCreationParamsSchema.safeParse({ ttl: 60_000 }); - expect(result.success).toBe(true); - }); - - it('should accept missing ttl (optional)', () => { - const result = TaskCreationParamsSchema.safeParse({}); - expect(result.success).toBe(true); - }); - - it('should reject null ttl (not allowed in request, only response)', () => { - const result = TaskCreationParamsSchema.safeParse({ ttl: null }); - expect(result.success).toBe(false); - }); - - it('should accept pollInterval as a number', () => { - const result = TaskCreationParamsSchema.safeParse({ pollInterval: 1000 }); - expect(result.success).toBe(true); - }); - - it('should accept both ttl and pollInterval', () => { - const result = TaskCreationParamsSchema.safeParse({ ttl: 60_000, pollInterval: 1000 }); - expect(result.success).toBe(true); - }); -}); diff --git a/test/integration/test/experimental/tasks/taskListing.test.ts b/test/integration/test/experimental/tasks/taskListing.test.ts deleted file mode 100644 index 2b21e99d51..0000000000 --- a/test/integration/test/experimental/tasks/taskListing.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js'; - -describe('Task Listing with Pagination', () => { - let client: Awaited>['client']; - let server: Awaited>['server']; - let taskStore: Awaited>['taskStore']; - - beforeEach(async () => { - const env = await createInMemoryTaskEnvironment(); - client = env.client; - server = env.server; - taskStore = env.taskStore; - }); - - 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: ProtocolError) => { - expect(error).toBeInstanceOf(ProtocolError); - expect(error.code).toBe(ProtocolErrorCode.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(); - }); -}); diff --git a/test/integration/test/helpers/mcp.ts b/test/integration/test/helpers/mcp.ts deleted file mode 100644 index 1fe0b33912..0000000000 --- a/test/integration/test/helpers/mcp.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport } from '@modelcontextprotocol/core'; -import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/server'; -import { InMemoryTaskMessageQueue, InMemoryTaskStore, Server } from '@modelcontextprotocol/server'; - -export interface InMemoryTaskEnvironment { - client: Client; - server: Server; - taskStore: InMemoryTaskStore; - clientTransport: InMemoryTransport; - serverTransport: InMemoryTransport; -} - -export async function createInMemoryTaskEnvironment(options?: { - clientCapabilities?: ClientCapabilities; - serverCapabilities?: ServerCapabilities; -}): Promise { - const taskStore = new InMemoryTaskStore(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: options?.clientCapabilities ?? { - tasks: { - list: {}, - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: options?.serverCapabilities ?? { - tasks: { - list: {}, - requests: { - tools: { - call: {} - } - }, - taskStore, - taskMessageQueue: new InMemoryTaskMessageQueue() - } - } - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - return { - client, - server, - taskStore, - clientTransport, - serverTransport - }; -} diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index 825af7ea45..7ed3d3c204 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -8,22 +8,17 @@ import type { JsonSchemaValidator, jsonSchemaValidator, LoggingMessageNotification, - ResponseMessage, - Task, Transport } from '@modelcontextprotocol/core'; import { - CallToolResultSchema, - ElicitResultSchema, InMemoryTransport, LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode, - SUPPORTED_PROTOCOL_VERSIONS, - toArrayAsync + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/server'; +import { McpServer, Server } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; import supertest from 'supertest'; import * as z from 'zod/v4'; @@ -1825,248 +1820,11 @@ describe('createMessage validation', () => { }); }); -describe('createMessageStream', () => { - test('should throw when tools are provided without sampling.tools capability', async () => { - const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); - const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); - - client.setRequestHandler('sampling/createMessage', async () => ({ - role: 'assistant', - content: { type: 'text', text: 'Response' }, - model: 'test-model' - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - expect(() => { - server.experimental.tasks.createMessageStream({ - messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], - maxTokens: 100, - tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] - }); - }).toThrow('Client does not support sampling tools capability'); - }); - - test('should throw when tool_result has no matching tool_use in previous message', async () => { - const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); - const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); - - client.setRequestHandler('sampling/createMessage', async () => ({ - role: 'assistant', - content: { type: 'text', text: 'Response' }, - model: 'test-model' - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - expect(() => { - server.experimental.tasks.createMessageStream({ - messages: [ - { role: 'user', content: { type: 'text', text: 'Hello' } }, - { - role: 'user', - content: [{ type: 'tool_result', toolUseId: 'test-id', content: [{ type: 'text', text: 'result' }] }] - } - ], - maxTokens: 100 - }); - }).toThrow('tool_result blocks are not matching any tool_use from the previous message'); - }); - - describe('with tasks', () => { - let server: Server; - let client: Client; - let clientTransport: ReturnType[0]; - let serverTransport: ReturnType[1]; - - beforeEach(async () => { - server = new Server( - { name: 'test server', version: '1.0' }, - { - capabilities: { - tasks: { - taskStore: new InMemoryTaskStore() - } - } - } - ); - - client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); - - [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - }); - - afterEach(async () => { - await server.close().catch(() => {}); - await client.close().catch(() => {}); - }); - - describe('terminal message guarantees', () => { - test('should yield exactly one terminal message for successful request', async () => { - client.setRequestHandler('sampling/createMessage', async () => ({ - role: 'assistant', - content: { type: 'text', text: 'Response' }, - model: 'test-model' - })); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const stream = server.experimental.tasks.createMessageStream({ - messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], - maxTokens: 100 - }); - - const allMessages = await toArrayAsync(stream); - - expect(allMessages.length).toBe(1); - expect(allMessages[0].type).toBe('result'); - - const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); - expect(taskMessages.length).toBe(0); - }); - - test('should yield error as terminal message when client returns error', async () => { - client.setRequestHandler('sampling/createMessage', async () => { - throw new Error('Simulated client error'); - }); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const stream = server.experimental.tasks.createMessageStream({ - messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], - maxTokens: 100 - }); - - const allMessages = await toArrayAsync(stream); - - expect(allMessages.length).toBe(1); - expect(allMessages[0].type).toBe('error'); - }); - - test('should yield exactly one terminal message with result', async () => { - client.setRequestHandler('sampling/createMessage', () => ({ - model: 'test-model', - role: 'assistant' as const, - content: { type: 'text' as const, text: 'Response' } - })); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const stream = server.experimental.tasks.createMessageStream({ - messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], - maxTokens: 100 - }); - - const messages = await toArrayAsync(stream); - const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); - - expect(terminalMessages.length).toBe(1); - - const lastMessage = messages.at(-1); - expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); - - if (lastMessage.type === 'result') { - expect((lastMessage.result as CreateMessageResult).content).toBeDefined(); - } - }); - }); - - describe('non-task request minimality', () => { - test('should yield only result message for non-task request', async () => { - client.setRequestHandler('sampling/createMessage', () => ({ - model: 'test-model', - role: 'assistant' as const, - content: { type: 'text' as const, text: 'Response' } - })); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const stream = server.experimental.tasks.createMessageStream({ - messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], - maxTokens: 100 - }); - - const messages = await toArrayAsync(stream); - - const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); - expect(taskMessages.length).toBe(0); - - const resultMessages = messages.filter(m => m.type === 'result'); - expect(resultMessages.length).toBe(1); - - expect(messages.length).toBe(1); - }); - }); - - describe('task-augmented request handling', () => { - test('should yield taskCreated and result for task-augmented request', async () => { - const clientTaskStore = new InMemoryTaskStore(); - const taskClient = new Client( - { name: 'test client', version: '1.0' }, - { - capabilities: { - sampling: {}, - tasks: { - taskStore: clientTaskStore, - requests: { - sampling: { createMessage: {} } - } - } - } - } - ); - - taskClient.setRequestHandler('sampling/createMessage', async (request, extra) => { - const result = { - model: 'test-model', - role: 'assistant' as const, - content: { type: 'text' as const, text: 'Task response' } - }; - - if (request.params.task && extra.task?.store) { - const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl }); - await extra.task.store.storeTaskResult(task.taskId, 'completed', result); - return { task }; - } - return result; - }); - - const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); - - const stream = server.experimental.tasks.createMessageStream( - { - messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }], - maxTokens: 100 - }, - { task: { ttl: 60_000 } } - ); - - const messages = await toArrayAsync(stream); - - // Should have taskCreated and result - expect(messages.length).toBeGreaterThanOrEqual(2); - - // First message should be taskCreated - expect(messages[0].type).toBe('taskCreated'); - const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; - expect(taskCreated.task.taskId).toBeDefined(); - - // Last message should be result - const lastMessage = messages.at(-1); - expect(lastMessage.type).toBe('result'); - if (lastMessage.type === 'result') { - expect((lastMessage.result as CreateMessageResult).model).toBe('test-model'); - } - - clientTaskStore.cleanup(); - await taskClient.close().catch(() => {}); - }); - }); - }); -}); +// SEP-2663 removes the client-hosted task store these tests relied on (server polls +// the client's tasks/* for async sampling). That direction becomes MRTR (S3/F4), +// not tasks. The remaining sampling.tools capability assertions are covered by +// `_createMessageVia` tests; the `createMessageStream` wrapper has no equivalent and is +// removed. describe('createMessage backwards compatibility', () => { test('createMessage without tools returns single content (backwards compat)', async () => { @@ -2359,1419 +2117,20 @@ describe('createMcpExpressApp', () => { }); }); -describe('Task-based execution', () => { - test('server with TaskStore should handle task-based tool execution', async () => { - const taskStore = new InMemoryTaskStore(); - - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - // Register a tool using registerToolTask - server.experimental.tasks.registerToolTask( - 'test-tool', - { - description: 'A test tool', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - // Simulate some async work - (async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - const result = { - content: [{ type: 'text', text: 'Tool executed successfully!' }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Use callToolStream to create a task and capture the task ID - let taskId: string | undefined; - const stream = client.experimental.tasks.callToolStream( - { name: 'test-tool', arguments: {} }, - { - task: { - ttl: 60_000 - } - } - ); - - for await (const message of stream) { - if (message.type === 'taskCreated') { - taskId = message.task.taskId; - } - } - - expect(taskId).toBeDefined(); - - // Wait for the task to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify we can retrieve the task - const task = await client.experimental.tasks.getTask(taskId!); - expect(task).toBeDefined(); - expect(task.status).toBe('completed'); - - // Verify we can retrieve the result - const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); - expect(result.content).toEqual([{ type: 'text', text: 'Tool executed successfully!' }]); - - // Cleanup - taskStore.cleanup(); - }); - - test('server without TaskStore should reject task-based requests', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - // No taskStore configured - } - ); - - server.setRequestHandler('tools/call', async request => { - if (request.params.name === 'test-tool') { - return { - content: [{ type: 'text', text: 'Success!' }] - }; - } - throw new Error('Unknown tool'); - }); - - server.setRequestHandler('tools/list', async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - } - } - ] - })); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Try to get a task when server doesn't have TaskStore - // The server will return a "Method not found" error - await expect(client.experimental.tasks.getTask('non-existent')).rejects.toThrow('Method not found'); - }); - - test('should automatically attach related-task metadata to nested requests during tool execution', async () => { - const taskStore = new InMemoryTaskStore(); - - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - // Track the elicitation request to verify related-task metadata - let capturedElicitRequest: z.infer | null = null; - - // Set up client elicitation handler - client.setRequestHandler('elicitation/create', async (request, ctx) => { - let taskId: string | undefined; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const createdTask = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - taskId = createdTask.taskId; - } - - // Capture the request to verify metadata later - capturedElicitRequest = request; - - return { - action: 'accept', - content: { - username: 'test-user' - } - }; - }); - - // Register a tool using registerToolTask that makes a nested elicitation request - server.experimental.tasks.registerToolTask( - 'collect-info', - { - description: 'Collects user info via elicitation', - inputSchema: z.object({}) - }, - { - async createTask(_args, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - // Perform async work that makes a nested request - (async () => { - // During tool execution, make a nested request to the client using ctx.mcpReq.send - const elicitResult = await ctx.mcpReq.send({ - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Please provide your username', - requestedSchema: { - type: 'object', - properties: { - username: { type: 'string' } - }, - required: ['username'] - } - } - }); - - const result = { - content: [ - { - type: 'text', - text: `Collected username: ${elicitResult.action === 'accept' && elicitResult.content ? (elicitResult.content as Record).username : 'none'}` - } - ] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Call tool WITH task creation using callToolStream to capture task ID - let taskId: string | undefined; - const stream = client.experimental.tasks.callToolStream( - { name: 'collect-info', arguments: {} }, - { - task: { - ttl: 60_000 - } - } - ); - - for await (const message of stream) { - if (message.type === 'taskCreated') { - taskId = message.task.taskId; - } - } - - expect(taskId).toBeDefined(); - - // Wait for completion - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the nested elicitation request was made (related-task metadata is no longer automatically attached) - expect(capturedElicitRequest).toBeDefined(); - - // Verify tool result was correct - const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Collected username: test-user' - } - ]); - - // Cleanup - taskStore.cleanup(); - }); - - describe('Server calling client via elicitation', () => { - let clientTaskStore: InMemoryTaskStore; - - beforeEach(() => { - clientTaskStore = new InMemoryTaskStore(); - }); - - afterEach(() => { - clientTaskStore?.cleanup(); - }); - - test('should create task on client via elicitation', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'server-test-user', confirmed: true } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Server creates task on client via elicitation - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Please provide your username', - requestedSchema: { - type: 'object', - properties: { - username: { type: 'string' }, - confirmed: { type: 'boolean' } - }, - required: ['username'] - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - // Verify task was created - const task = await server.experimental.tasks.getTask(taskId); - expect(task.status).toBe('completed'); - }); - - test('should query task from client using getTask', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { create: {} } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create task - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Provide info', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - // Query task - const task = await server.experimental.tasks.getTask(taskId); - expect(task).toBeDefined(); - expect(task.taskId).toBe(taskId); - expect(task.status).toBe('completed'); - }); - - test('should query task result from client using getTaskResult', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'result-user', confirmed: true } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { create: {} } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create task - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Provide info', - requestedSchema: { - type: 'object', - properties: { - username: { type: 'string' }, - confirmed: { type: 'boolean' } - } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - // Query result - const result = await server.experimental.tasks.getTaskResult(taskId, ElicitResultSchema); - expect(result.action).toBe('accept'); - expect(result.content).toEqual({ username: 'result-user', confirmed: true }); - }); - - test('should query task list from client using listTasks', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create multiple tasks - const createdTaskIds: string[] = []; - for (let i = 0; i < 2; i++) { - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Provide info', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure and capture taskId - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - createdTaskIds.push(createTaskResult.task.taskId); - } - - // Query task list - const taskList = await server.experimental.tasks.listTasks(); - expect(taskList.tasks.length).toBeGreaterThanOrEqual(2); - for (const taskId of createdTaskIds) { - expect(taskList.tasks).toContainEqual( - expect.objectContaining({ - taskId, - status: 'completed' - }) - ); - } - }); - }); - - test('should handle multiple concurrent task-based tool calls', async () => { - const taskStore = new InMemoryTaskStore(); - - const server = new McpServer( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - // Register a tool using registerToolTask with variable delay - server.experimental.tasks.registerToolTask( - 'async-tool', - { - description: 'An async test tool', - inputSchema: z.object({ - delay: z.number().optional().default(10), - taskNum: z.number().optional() - }) - }, - { - async createTask({ delay, taskNum }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - - // Simulate async work - (async () => { - await new Promise(resolve => setTimeout(resolve, delay)); - const result = { - content: [{ type: 'text', text: `Completed task ${taskNum || 'unknown'}` }] - }; - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create multiple tasks concurrently - const pendingRequests = Array.from({ length: 4 }, (_, index) => - client.callTool( - { name: 'async-tool', arguments: { delay: 10 + index * 5, taskNum: index + 1 } }, - { - task: { ttl: 60_000 } - } - ) - ); - - // Wait for all tasks to complete - await Promise.all(pendingRequests); - - // Wait a bit more to ensure all tasks are completed - await new Promise(resolve => setTimeout(resolve, 50)); - - // Get all task IDs from the task list - const taskList = await client.experimental.tasks.listTasks(); - expect(taskList.tasks.length).toBeGreaterThanOrEqual(4); - const taskIds = taskList.tasks.map(t => t.taskId); - - // Verify all tasks completed successfully - for (const [i, taskId] of taskIds.entries()) { - const task = await client.experimental.tasks.getTask(taskId!); - expect(task.status).toBe('completed'); - expect(task.taskId).toBe(taskId!); - - const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); - expect(result.content).toEqual([{ type: 'text', text: `Completed task ${i + 1}` }]); - } - - // Verify listTasks returns all tasks - const finalTaskList = await client.experimental.tasks.listTasks(); - for (const taskId of taskIds) { - expect(finalTaskList.tasks).toContainEqual(expect.objectContaining({ taskId })); - } - - // Cleanup - taskStore.cleanup(); - }); - - describe('Error scenarios', () => { - let taskStore: InMemoryTaskStore; - let clientTaskStore: InMemoryTaskStore; - - beforeEach(() => { - taskStore = new InMemoryTaskStore(); - clientTaskStore = new InMemoryTaskStore(); - }); - - afterEach(() => { - taskStore?.cleanup(); - clientTaskStore?.cleanup(); - }); - - test('should throw error when client queries non-existent task from server', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Try to query a task that doesn't exist - await expect(client.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); - }); - - test('should throw error when server queries non-existent task from client', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async () => ({ - action: 'accept', - content: { username: 'test' } - })); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Try to query a task that doesn't exist on client - await expect(server.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); - }); - }); -}); - -test('should respect client task capabilities', async () => { - const clientTaskStore = new InMemoryTaskStore(); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - sampling: {}, - elicitation: {}, - tasks: { - requests: { - elicitation: { - create: {} - } - }, - - taskStore: clientTaskStore - } - } - } - ); - - client.setRequestHandler('elicitation/create', async (request, ctx) => { - const result = { - action: 'accept', - content: { username: 'test-user' } - }; - - // Check if task creation is requested - if (request.params.task && ctx.task?.store) { - const task = await ctx.task.store.createTask({ - ttl: ctx.task.requestedTtl - }); - await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); - // Return CreateTaskResult when task creation is requested - return { task }; - } - - // Return ElicitResult for non-task requests - return result; - }); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - }, - enforceStrictCapabilities: true - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Client supports task creation for elicitation/create and task methods - expect(server.getClientCapabilities()).toEqual({ - sampling: {}, - elicitation: { - form: {} - }, - tasks: { - requests: { - elicitation: { - create: {} - } - } - } - }); - - // These should work because client supports tasks - const createTaskResult = await server.request( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Test', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } } - } - } - }, - { task: { ttl: 60_000 } } - ); - - // Verify CreateTaskResult structure - expect(createTaskResult.task).toBeDefined(); - expect(createTaskResult.task.taskId).toBeDefined(); - const taskId = createTaskResult.task.taskId; - - await expect(server.experimental.tasks.listTasks()).resolves.not.toThrow(); - await expect(server.experimental.tasks.getTask(taskId)).resolves.not.toThrow(); - - // This should throw because client doesn't support task creation for sampling/createMessage - await expect( - server.request( - { - method: 'sampling/createMessage', - params: { - messages: [], - maxTokens: 10 - } - }, - { task: { taskId: 'test-task-2', keepAlive: 60_000 } } - ) - ).rejects.toThrow('Client does not support task creation for sampling/createMessage'); - - clientTaskStore.cleanup(); -}); - -describe('elicitInputStream', () => { - let server: Server; - let client: Client; - let clientTransport: ReturnType[0]; - let serverTransport: ReturnType[1]; - - beforeEach(async () => { - server = new Server( - { name: 'test server', version: '1.0' }, - { - capabilities: { - tasks: { - taskStore: new InMemoryTaskStore() - } - } - } - ); - - client = new Client( - { name: 'test client', version: '1.0' }, - { - capabilities: { - elicitation: { - form: {}, - url: {} - } - } - } - ); - - [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - }); - - afterEach(async () => { - await server.close().catch(() => {}); - await client.close().catch(() => {}); - }); - - test('should throw when client does not support form elicitation', async () => { - // Create client without form elicitation capability - const noFormClient = new Client( - { name: 'test client', version: '1.0' }, - { - capabilities: { - elicitation: { - url: {} - } - } - } - ); - - const [noFormClientTransport, noFormServerTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([noFormClient.connect(noFormClientTransport), server.connect(noFormServerTransport)]); - - expect(() => { - server.experimental.tasks.elicitInputStream({ - mode: 'form', - message: 'Enter data', - requestedSchema: { type: 'object', properties: {} } - }); - }).toThrow('Client does not support form elicitation.'); - - await noFormClient.close().catch(() => {}); - }); - - test('should throw when client does not support url elicitation', async () => { - // Create client without url elicitation capability - const noUrlClient = new Client( - { name: 'test client', version: '1.0' }, - { - capabilities: { - elicitation: { - form: {} - } - } - } - ); - - const [noUrlClientTransport, noUrlServerTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([noUrlClient.connect(noUrlClientTransport), server.connect(noUrlServerTransport)]); - - expect(() => { - server.experimental.tasks.elicitInputStream({ - mode: 'url', - message: 'Open URL', - elicitationId: 'test-123', - url: 'https://example.com/auth' - }); - }).toThrow('Client does not support url elicitation.'); - - await noUrlClient.close().catch(() => {}); - }); - - test('should default to form mode when mode is not specified', async () => { - const requestStreamSpy = vi.spyOn(server.experimental.tasks, 'requestStream'); - - client.setRequestHandler('elicitation/create', () => ({ - action: 'accept', - content: { value: 'test' } - })); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Call without explicit mode - const params = { - message: 'Enter value', - requestedSchema: { - type: 'object' as const, - properties: { value: { type: 'string' as const } } - } - }; - - const stream = server.experimental.tasks.elicitInputStream( - params as Parameters[0] - ); - await toArrayAsync(stream); - - // Verify mode was normalized to 'form' - expect(requestStreamSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'elicitation/create', - params: expect.objectContaining({ mode: 'form' }) - }), - undefined - ); - }); - - test('should yield error as terminal message when client returns error', async () => { - client.setRequestHandler('elicitation/create', () => { - throw new Error('Simulated client error'); - }); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const stream = server.experimental.tasks.elicitInputStream({ - mode: 'form', - message: 'Enter data', - requestedSchema: { - type: 'object', - properties: { value: { type: 'string' } } - } - }); - - const allMessages = await toArrayAsync(stream); - - expect(allMessages.length).toBe(1); - expect(allMessages[0].type).toBe('error'); - }); - - // For any streaming elicitation request, the AsyncGenerator yields exactly one terminal - // message (either 'result' or 'error') as its final message. - describe('terminal message guarantees', () => { - test.each([ - { action: 'accept' as const, content: { data: 'test-value' } }, - { action: 'decline' as const, content: undefined }, - { action: 'cancel' as const, content: undefined } - ])('should yield exactly one terminal message for action: $action', async ({ action, content }) => { - client.setRequestHandler('elicitation/create', () => ({ - action, - content - })); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const stream = server.experimental.tasks.elicitInputStream({ - mode: 'form', - message: 'Test message', - requestedSchema: { - type: 'object', - properties: { data: { type: 'string' } } - } - }); - - const messages = await toArrayAsync(stream); - - // Count terminal messages (result or error) - const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); - - expect(terminalMessages.length).toBe(1); - - // Verify terminal message is the last message - const lastMessage = messages.at(-1); - expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); - - // Verify result content matches expected action - if (lastMessage.type === 'result') { - expect((lastMessage.result as ElicitResult).action).toBe(action); - } - }); - }); - - // For any non-task elicitation request, the generator yields exactly one 'result' message - // (or 'error' if the request fails), with no 'taskCreated' or 'taskStatus' messages. - describe('non-task request minimality', () => { - test.each([ - { action: 'accept' as const, content: { value: 'test' } }, - { action: 'decline' as const, content: undefined }, - { action: 'cancel' as const, content: undefined } - ])('should yield only result message for non-task request with action: $action', async ({ action, content }) => { - client.setRequestHandler('elicitation/create', () => ({ - action, - content - })); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Non-task request (no task option) - const stream = server.experimental.tasks.elicitInputStream({ - mode: 'form', - message: 'Non-task request', - requestedSchema: { - type: 'object', - properties: { value: { type: 'string' } } - } - }); - - const messages = await toArrayAsync(stream); - - // Verify no taskCreated or taskStatus messages - const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); - expect(taskMessages.length).toBe(0); - - // Verify exactly one result message - const resultMessages = messages.filter(m => m.type === 'result'); - expect(resultMessages.length).toBe(1); - - // Verify total message count is 1 - expect(messages.length).toBe(1); - }); - }); - - // For any task-augmented elicitation request, the generator should yield at least one - // 'taskCreated' message followed by 'taskStatus' messages before yielding the final - // result or error. - describe('task-augmented request handling', () => { - test('should yield taskCreated and result for task-augmented request', async () => { - const clientTaskStore = new InMemoryTaskStore(); - const taskClient = new Client( - { name: 'test client', version: '1.0' }, - { - capabilities: { - elicitation: { form: {} }, - tasks: { - taskStore: clientTaskStore, - requests: { - elicitation: { create: {} } - } - } - } - } - ); - - taskClient.setRequestHandler('elicitation/create', async (request, extra) => { - const result = { - action: 'accept' as const, - content: { username: 'task-user' } - }; - - if (request.params.task && extra.task?.store) { - const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl }); - await extra.task.store.storeTaskResult(task.taskId, 'completed', result); - return { task }; - } - return result; - }); - - const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); - - const stream = server.experimental.tasks.elicitInputStream( - { - mode: 'form', - message: 'Task-augmented request', - requestedSchema: { - type: 'object', - properties: { username: { type: 'string' } }, - required: ['username'] - } - }, - { task: { ttl: 60_000 } } - ); - - const messages = await toArrayAsync(stream); - - // Should have taskCreated and result - expect(messages.length).toBeGreaterThanOrEqual(2); - - // First message should be taskCreated - expect(messages[0].type).toBe('taskCreated'); - const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; - expect(taskCreated.task.taskId).toBeDefined(); - - // Last message should be result - const lastMessage = messages.at(-1); - expect(lastMessage.type).toBe('result'); - if (lastMessage.type === 'result') { - expect((lastMessage.result as ElicitResult).action).toBe('accept'); - expect((lastMessage.result as ElicitResult).content).toEqual({ username: 'task-user' }); - } - - clientTaskStore.cleanup(); - await taskClient.close().catch(() => {}); - }); - }); -}); +// SEP-2663: the `Task-based execution` suite exercised the 2025-11 client-directed +// augmentation (`params.task` from caller, server interception via TaskManager) and the +// server-to-client direction (server polls client's tasks/* via elicitation). Under the +// server-directed model the handler returns `{resultType:'task', task}`; there is no +// `params.task` and no interception. Replacement coverage lands with the SEP-2663 +// tasks implementation; nothing in this commit re-covers it. +// +// `should respect client task capabilities`: removed. Under SEP-2663 the server does not +// send `tasks/*` to the client (the client does not host tasks), so there is no +// server-side capability check for that direction. + +// SEP-2663 removes the client-hosted task store these tests relied on (server polls +// the client's tasks/* for async elicitation). That direction becomes MRTR, not tasks. +// The `elicitInputStream` wrapper has no equivalent and is removed. describe('Server registerCapabilities with logging', () => { test('registerCapabilities should register logging/setLevel handler', async () => { diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 92af09744c..8c844b11cb 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,33 +1,10 @@ import { Client } from '@modelcontextprotocol/client'; -import type { CallToolResult, Notification, TextContent } from '@modelcontextprotocol/core'; -import { - getDisplayName, - InMemoryTaskStore, - InMemoryTransport, - ProtocolErrorCode, - UriTemplate, - UrlElicitationRequiredError -} from '@modelcontextprotocol/core'; +import type { Notification, TextContent } from '@modelcontextprotocol/core'; +import { getDisplayName, InMemoryTransport, ProtocolErrorCode, UriTemplate, UrlElicitationRequiredError } from '@modelcontextprotocol/core'; import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import * as z from 'zod/v4'; -function createLatch() { - let latch = false; - const waitForLatch = async () => { - while (!latch) { - await new Promise(resolve => setTimeout(resolve, 0)); - } - }; - - return { - releaseLatch: () => { - latch = true; - }, - waitForLatch - }; -} - describe('Zod v4', () => { describe('McpServer', () => { /*** @@ -2019,146 +1996,6 @@ describe('Zod v4', () => { expect(result.tools[0]!._meta).toBeUndefined(); }); - test('should include execution field in listTools response when tool has execution settings', async () => { - const taskStore = new InMemoryTaskStore(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // Register a tool with execution.taskSupport - mcpServer.experimental.tasks.registerToolTask( - 'task-tool', - { - description: 'A tool with task support', - inputSchema: z.object({ input: z.string() }), - execution: { - taskSupport: 'required' - } - }, - { - createTask: async (_args, ctx) => { - const task = await ctx.task.store.createTask({ ttl: 60_000 }); - return { task }; - }, - getTask: async (_args, ctx) => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) throw new Error('Task not found'); - return task; - }, - getTaskResult: async (_args, ctx) => { - return (await ctx.task.store.getTaskResult(ctx.task.id)) as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0]!.name).toBe('task-tool'); - expect(result.tools[0]!.execution).toEqual({ - taskSupport: 'required' - }); - - taskStore.cleanup(); - }); - - test('should include execution field with taskSupport optional in listTools response', async () => { - const taskStore = new InMemoryTaskStore(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // Register a tool with execution.taskSupport optional - mcpServer.experimental.tasks.registerToolTask( - 'optional-task-tool', - { - description: 'A tool with optional task support', - inputSchema: z.object({ input: z.string() }), - execution: { - taskSupport: 'optional' - } - }, - { - createTask: async (_args, ctx) => { - const task = await ctx.task.store.createTask({ ttl: 60_000 }); - return { task }; - }, - getTask: async (_args, ctx) => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) throw new Error('Task not found'); - return task; - }, - getTaskResult: async (_args, ctx) => { - return (await ctx.task.store.getTaskResult(ctx.task.id)) as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0]!.name).toBe('optional-task-tool'); - expect(result.tools[0]!.execution).toEqual({ - taskSupport: 'optional' - }); - - taskStore.cleanup(); - }); - test('should validate tool names according to SEP specification', () => { // Create a new server instance for this test const testServer = new McpServer({ @@ -6445,598 +6282,10 @@ describe('Zod v4', () => { }); }); - describe('Tool-level task hints with automatic polling wrapper', () => { - test('should return error for tool with taskSupport "required" called without task augmentation', async () => { - const taskStore = new InMemoryTaskStore(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - // Register a task-based tool with taskSupport "required" - mcpServer.experimental.tasks.registerToolTask( - 'long-running-task', - { - description: 'A long running task', - inputSchema: z.object({ - input: z.string() - }), - execution: { - taskSupport: 'required' - } - }, - { - createTask: async ({ input }, ctx) => { - const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); - - // Capture taskStore for use in setTimeout - const store = ctx.task.store; - - // Simulate async work - setTimeout(async () => { - await store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text' as const, text: `Processed: ${input}` }] - }); - }, 200); - - return { task }; - }, - getTask: async (_args, ctx) => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error('Task not found'); - } - return task; - }, - getTaskResult: async (_input, ctx) => { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the tool WITHOUT task augmentation - should return error - const result = await client.callTool({ - name: 'long-running-task', - arguments: { input: 'test data' } - }); - - // Should receive error result - expect(result.isError).toBe(true); - const content = result.content as TextContent[]; - expect(content[0]!.text).toContain('requires task augmentation'); - - taskStore.cleanup(); - }); - - test('should automatically poll and return CallToolResult for tool with taskSupport "optional" called without task augmentation', async () => { - const taskStore = new InMemoryTaskStore(); - const { releaseLatch, waitForLatch } = createLatch(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - // Register a task-based tool with taskSupport "optional" - mcpServer.experimental.tasks.registerToolTask( - 'optional-task', - { - description: 'An optional task', - inputSchema: z.object({ - value: z.number() - }), - execution: { - taskSupport: 'optional' - } - }, - { - createTask: async ({ value }, ctx) => { - const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); - - // Capture taskStore for use in setTimeout - const store = ctx.task.store; - - // Simulate async work - setTimeout(async () => { - await store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text' as const, text: `Result: ${value * 2}` }] - }); - releaseLatch(); - }, 150); - - return { task }; - }, - getTask: async (_args, ctx) => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error('Task not found'); - } - return task; - }, - getTaskResult: async (_value, ctx) => { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the tool WITHOUT task augmentation - const result = await client.callTool({ - name: 'optional-task', - arguments: { value: 21 } - }); - - // Should receive CallToolResult directly, not CreateTaskResult - expect(result).toHaveProperty('content'); - expect(result.content).toEqual([{ type: 'text' as const, text: 'Result: 42' }]); - expect(result).not.toHaveProperty('task'); - - // Wait for async operations to complete - await waitForLatch(); - taskStore.cleanup(); - }); - - test('should return CreateTaskResult when tool with taskSupport "required" is called WITH task augmentation', async () => { - const taskStore = new InMemoryTaskStore(); - const { releaseLatch, waitForLatch } = createLatch(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - // Register a task-based tool with taskSupport "required" - mcpServer.experimental.tasks.registerToolTask( - 'task-tool', - { - description: 'A task tool', - inputSchema: z.object({ - data: z.string() - }), - execution: { - taskSupport: 'required' - } - }, - { - createTask: async ({ data }, ctx) => { - const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); - - // Capture taskStore for use in setTimeout - const store = ctx.task.store; - - // Simulate async work - setTimeout(async () => { - await store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text' as const, text: `Completed: ${data}` }] - }); - releaseLatch(); - }, 200); - - return { task }; - }, - getTask: async (_args, ctx) => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error('Task not found'); - } - return task; - }, - getTaskResult: async (_data, ctx) => { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the tool WITH task augmentation - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'task-tool', - arguments: { data: 'test' }, - task: { ttl: 60_000 } - } - }, - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.union([z.number(), z.null()]), - createdAt: z.string(), - pollInterval: z.number().optional() - }) - }) - ); - - // Should receive CreateTaskResult with task field - expect(result).toHaveProperty('task'); - expect(result.task).toHaveProperty('taskId'); - expect(result.task.status).toBe('working'); - - // Wait for async operations to complete - await waitForLatch(); - taskStore.cleanup(); - }); - - test('should handle task failures during automatic polling', async () => { - const taskStore = new InMemoryTaskStore(); - const { releaseLatch, waitForLatch } = createLatch(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - // Register a task-based tool that fails - mcpServer.experimental.tasks.registerToolTask( - 'failing-task', - { - description: 'A failing task', - execution: { - taskSupport: 'optional' - } - }, - { - createTask: async ctx => { - const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); - - // Capture taskStore for use in setTimeout - const store = ctx.task.store; - - // Simulate async failure - setTimeout(async () => { - await store.storeTaskResult(task.taskId, 'failed', { - content: [{ type: 'text' as const, text: 'Error occurred' }], - isError: true - }); - releaseLatch(); - }, 150); - - return { task }; - }, - getTask: async ctx => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error('Task not found'); - } - return task; - }, - getTaskResult: async ctx => { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the tool WITHOUT task augmentation - const result = await client.callTool({ - name: 'failing-task', - arguments: {} - }); - - // Should receive the error result - expect(result).toHaveProperty('content'); - expect(result.content).toEqual([{ type: 'text' as const, text: 'Error occurred' }]); - expect(result.isError).toBe(true); - - // Wait for async operations to complete - await waitForLatch(); - taskStore.cleanup(); - }); - - test('should handle task cancellation during automatic polling', async () => { - const taskStore = new InMemoryTaskStore(); - const { releaseLatch, waitForLatch } = createLatch(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - } - } - } - } - ); - - // Register a task-based tool that gets cancelled - mcpServer.experimental.tasks.registerToolTask( - 'cancelled-task', - { - description: 'A task that gets cancelled', - execution: { - taskSupport: 'optional' - } - }, - { - createTask: async ctx => { - const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); - - // Capture taskStore for use in setTimeout - const store = ctx.task.store; - - // Simulate async cancellation - setTimeout(async () => { - await store.updateTaskStatus(task.taskId, 'cancelled', 'Task was cancelled'); - releaseLatch(); - }, 150); - - return { task }; - }, - getTask: async ctx => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error('Task not found'); - } - return task; - }, - getTaskResult: async ctx => { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the tool WITHOUT task augmentation - const result = await client.callTool({ - name: 'cancelled-task', - arguments: {} - }); - - // Should receive an error since cancelled tasks don't have results - expect(result).toHaveProperty('content'); - expect(result.content).toEqual([{ type: 'text' as const, text: expect.stringContaining('has no result stored') }]); - - // Wait for async operations to complete - await waitForLatch(); - taskStore.cleanup(); - }); - - test('should raise error when registerToolTask is called with taskSupport "forbidden"', () => { - const taskStore = new InMemoryTaskStore(); - - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - tools: {}, - tasks: { - requests: { - tools: { - call: {} - } - }, - - taskStore - } - } - } - ); - - // Attempt to register a task-based tool with taskSupport "forbidden" (cast to bypass type checking) - expect(() => { - mcpServer.experimental.tasks.registerToolTask( - 'invalid-task', - { - description: 'A task with forbidden support', - inputSchema: z.object({ - input: z.string() - }), - execution: { - taskSupport: 'forbidden' as unknown as 'required' - } - }, - { - createTask: async (_args, ctx) => { - const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); - return { task }; - }, - getTask: async (_args, ctx) => { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error('Task not found'); - } - return task; - }, - getTaskResult: async (_args, ctx) => { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as CallToolResult; - } - } - ); - }).toThrow(); - - taskStore.cleanup(); - }); - }); + // SEP-2663: `taskSupport: 'required'` enforcement and the automatic-polling wrapper + // depended on the client sending `params.task`. Under the server-directed model the + // tool handler decides to return `{resultType:'task', task}`; there is no per-tool + // augmentation to enforce and no wrapper. The deleted "should include execution field" + // tests registered tools via `experimental.tasks.registerToolTask`, which is removed; + // `Tool.execution` remains a spec field but no SDK registration path currently sets it. }); diff --git a/test/integration/test/taskLifecycle.test.ts b/test/integration/test/taskLifecycle.test.ts deleted file mode 100644 index 1a540df0fd..0000000000 --- a/test/integration/test/taskLifecycle.test.ts +++ /dev/null @@ -1,1625 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { Server } from 'node:http'; -import { createServer } from 'node:http'; - -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; -import type { TaskRequestOptions } from '@modelcontextprotocol/server'; -import { - InMemoryTaskMessageQueue, - InMemoryTaskStore, - McpServer, - ProtocolError, - ProtocolErrorCode, - RELATED_TASK_META_KEY -} from '@modelcontextprotocol/server'; -import { listenOnRandomPort, waitForTaskStatus } from '@modelcontextprotocol/test-helpers'; -import * as z from 'zod/v4'; - -describe('Task Lifecycle Integration Tests', () => { - let server: Server; - let mcpServer: McpServer; - let serverTransport: NodeStreamableHTTPServerTransport; - let baseUrl: URL; - let taskStore: InMemoryTaskStore; - - beforeEach(async () => { - // Create task store - taskStore = new InMemoryTaskStore(); - - // Create MCP server with task support - mcpServer = new McpServer( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - } - }, - list: {}, - cancel: {}, - taskStore, - taskMessageQueue: new InMemoryTaskMessageQueue() - } - } - } - ); - - // Register a long-running tool using registerToolTask - mcpServer.experimental.tasks.registerToolTask( - 'long-task', - { - title: 'Long Running Task', - description: 'A tool that takes time to complete', - inputSchema: z.object({ - duration: z.number().describe('Duration in milliseconds').default(1000), - shouldFail: z.boolean().describe('Whether the task should fail').default(false) - }) - }, - { - async createTask({ duration, shouldFail }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: 60_000, - pollInterval: 100 - }); - - // Simulate async work - (async () => { - await new Promise(resolve => setTimeout(resolve, duration)); - - try { - await (shouldFail - ? ctx.task.store.storeTaskResult(task.taskId, 'failed', { - content: [{ type: 'text', text: 'Task failed as requested' }], - isError: true - }) - : ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: `Completed after ${duration}ms` }] - })); - } catch { - // Task may have been cleaned up if test ended - } - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - // Register a tool that requires input via elicitation - mcpServer.experimental.tasks.registerToolTask( - 'input-task', - { - title: 'Input Required Task', - description: 'A tool that requires user input', - inputSchema: z.object({ - userName: z.string().describe('User name').optional() - }) - }, - { - async createTask({ userName }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: 60_000, - pollInterval: 100 - }); - - // Perform async work that requires elicitation - (async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - - // If userName not provided, request it via elicitation - if (userName) { - // Complete immediately if userName was provided - try { - await ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: `Hello, ${userName}!` }] - }); - } catch { - // Task may have been cleaned up if test ended - } - } else { - const elicitationResult = await ctx.mcpReq.send( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: 'What is your name?', - requestedSchema: { - type: 'object', - properties: { - userName: { type: 'string' } - }, - required: ['userName'] - } - } - }, - { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions - ); - - // Complete with the elicited name - const name = - elicitationResult.action === 'accept' && elicitationResult.content - ? elicitationResult.content.userName - : 'Unknown'; - try { - await ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: `Hello, ${name}!` }] - }); - } catch { - // Task may have been cleaned up if test ended - } - } - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - // Create transport - serverTransport = new NodeStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID() - }); - - await mcpServer.connect(serverTransport); - - // Create HTTP server - server = createServer(async (req, res) => { - await serverTransport.handleRequest(req, res); - }); - - // Start server - baseUrl = await listenOnRandomPort(server); - }); - - afterEach(async () => { - taskStore.cleanup(); - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - server.close(); - }); - - describe('Task Creation and Completion', () => { - it('should create a task and return CreateTaskResult', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 500, - shouldFail: false - }, - task: { - ttl: 60_000 - } - } - }); - - // Verify CreateTaskResult structure - expect(createResult).toHaveProperty('task'); - expect(createResult.task).toHaveProperty('taskId'); - expect(createResult.task.status).toBe('working'); - expect(createResult.task.ttl).toBe(60_000); - expect(createResult.task.createdAt).toBeDefined(); - expect(createResult.task.pollInterval).toBe(100); - - // Verify task is stored in taskStore - const taskId = createResult.task.taskId; - const storedTask = await taskStore.getTask(taskId); - expect(storedTask).toBeDefined(); - expect(storedTask?.taskId).toBe(taskId); - expect(storedTask?.status).toBe('working'); - - // Wait for completion - const completedTask = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'completed'); - - // Verify task completed - expect(completedTask.status).toBe('completed'); - - // Verify result is stored - const result = await taskStore.getTaskResult(taskId); - expect(result).toBeDefined(); - expect(result.content).toEqual([{ type: 'text', text: 'Completed after 500ms' }]); - - await transport.close(); - }); - - it('should handle task failure correctly', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task that will fail - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 300, - shouldFail: true - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Wait for failure - const task = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'failed'); - - // Verify task failed - expect(task.status).toBe('failed'); - - // Verify error result is stored - const result = await taskStore.getTaskResult(taskId); - expect(result.content).toEqual([{ type: 'text', text: 'Task failed as requested' }]); - expect(result.isError).toBe(true); - - await transport.close(); - }); - }); - - describe('Task Cancellation', () => { - it('should cancel a working task and return the cancelled task', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: {} } - } - ); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a long-running task - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 5000 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Verify task is working - let task = await taskStore.getTask(taskId); - expect(task?.status).toBe('working'); - - // Cancel the task via client.experimental.tasks.cancelTask - per spec, returns Result & Task - const cancelResult = await client.experimental.tasks.cancelTask(taskId); - - // Verify the cancel response includes the cancelled task (per MCP spec CancelTaskResult is Result & Task) - expect(cancelResult.taskId).toBe(taskId); - expect(cancelResult.status).toBe('cancelled'); - expect(cancelResult.createdAt).toBeDefined(); - expect(cancelResult.lastUpdatedAt).toBeDefined(); - expect(cancelResult.ttl).toBeDefined(); - - // Verify task is cancelled in store as well - task = await taskStore.getTask(taskId); - expect(task?.status).toBe('cancelled'); - - await transport.close(); - }); - - it('should reject cancellation of completed task with error code -32602', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: {} } - } - ); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a quick task - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 100 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Wait for completion - const task = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'completed'); - - // Verify task is completed - expect(task.status).toBe('completed'); - - // Try to cancel via tasks/cancel request (should fail with -32602) - await expect(client.experimental.tasks.cancelTask(taskId)).rejects.toSatisfy((error: ProtocolError) => { - expect(error).toBeInstanceOf(ProtocolError); - expect(error.code).toBe(ProtocolErrorCode.InvalidParams); - expect(error.message).toContain('Cannot cancel task in terminal status'); - return true; - }); - - await transport.close(); - }); - }); - - describe('Multiple Queued Messages', () => { - it('should deliver multiple queued messages in order', async () => { - // Register a tool that sends multiple server requests during execution - mcpServer.experimental.tasks.registerToolTask( - 'multi-request-task', - { - title: 'Multi Request Task', - description: 'A tool that sends multiple server requests', - inputSchema: z.object({ - requestCount: z.number().describe('Number of requests to send').default(3) - }) - }, - { - async createTask({ requestCount }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: 60_000, - pollInterval: 100 - }); - - // Perform async work that sends multiple requests - (async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - - const responses: string[] = []; - - // Send multiple elicitation requests - for (let i = 0; i < requestCount; i++) { - const elicitationResult = await ctx.mcpReq.send( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: `Request ${i + 1} of ${requestCount}`, - requestedSchema: { - type: 'object', - properties: { - response: { type: 'string' } - }, - required: ['response'] - } - } - }, - { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions - ); - - if (elicitationResult.action === 'accept' && elicitationResult.content) { - responses.push(elicitationResult.content.response as string); - } - } - - // Complete with all responses - try { - await ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: `Received responses: ${responses.join(', ')}` }] - }); - } catch { - // Task may have been cleaned up if test ended - } - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - const receivedMessages: Array<{ method: string; message: string }> = []; - - // Set up elicitation handler on client to track message order - client.setRequestHandler('elicitation/create', async request => { - // Track the message - receivedMessages.push({ - method: request.method, - message: request.params.message - }); - - // Extract the request number from the message - const match = request.params.message.match(/Request (\d+) of (\d+)/); - const requestNum = match ? match[1] : 'unknown'; - - // Respond with the request number - return { - action: 'accept' as const, - content: { - response: `Response ${requestNum}` - } - }; - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task that will send 3 requests - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'multi-request-task', - arguments: { - requestCount: 3 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Wait for messages to be queued - await new Promise(resolve => setTimeout(resolve, 200)); - - // Call tasks/result to receive all queued messages - // This should deliver all 3 elicitation requests in order - const result = await client.request({ - method: 'tasks/result', - params: { taskId } - }); - - // Verify all messages were delivered in order - expect(receivedMessages.length).toBe(3); - expect(receivedMessages[0]!.message).toBe('Request 1 of 3'); - expect(receivedMessages[1]!.message).toBe('Request 2 of 3'); - expect(receivedMessages[2]!.message).toBe('Request 3 of 3'); - - // Verify final result includes all responses - expect(result.content).toEqual([{ type: 'text', text: 'Received responses: Response 1, Response 2, Response 3' }]); - - // Verify task is completed - const task = await client.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(task.status).toBe('completed'); - - await transport.close(); - }, 10_000); - }); - - describe('Input Required Flow', () => { - it('should handle elicitation during tool execution', async () => { - // Complete flow phases: - // 1. Client creates task - // 2. Server queues elicitation request and sets status to input_required - // 3. Client polls tasks/get, sees input_required status - // 4. Client calls tasks/result to dequeue elicitation request - // 5. Client responds to elicitation - // 6. Server receives response, completes task - // 7. Client receives final result - - const elicitClient = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - // Track elicitation request receipt - let elicitationReceived = false; - let elicitationRequestMeta: Record | undefined; - - // Set up elicitation handler on client - elicitClient.setRequestHandler('elicitation/create', async request => { - elicitationReceived = true; - elicitationRequestMeta = request.params._meta; - - return { - action: 'accept' as const, - content: { - userName: 'TestUser' - } - }; - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await elicitClient.connect(transport); - - // Phase 1: Create task - const createResult = await elicitClient.request({ - method: 'tools/call', - params: { - name: 'input-task', - arguments: {}, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - expect(createResult.task.status).toBe('working'); - - // Phase 2: Wait for server to queue elicitation and update status - const task = await waitForTaskStatus( - id => - elicitClient.request({ - method: 'tasks/get', - params: { taskId: id } - }), - taskId, - 'input_required', - { - intervalMs: createResult.task.pollInterval ?? 100 - } - ); - - // Verify we saw input_required status (not completed or failed) - expect(task.status).toBe('input_required'); - - // Phase 3: Call tasks/result to dequeue messages and get final result - // This should: - // - Deliver the queued elicitation request via SSE - // - Client handler responds - // - Server receives response, completes task - // - Return final result - const result = await elicitClient.request({ - method: 'tasks/result', - params: { taskId } - }); - - // Verify elicitation was received and processed - expect(elicitationReceived).toBe(true); - - // Verify the elicitation request had related-task metadata - expect(elicitationRequestMeta).toBeDefined(); - expect(elicitationRequestMeta?.[RELATED_TASK_META_KEY]).toEqual({ taskId }); - - // Verify final result - expect(result.content).toEqual([{ type: 'text', text: 'Hello, TestUser!' }]); - - // Verify task is now completed - const finalTask = await elicitClient.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(finalTask.status).toBe('completed'); - - await transport.close(); - }, 15_000); - }); - - describe('Task Listing and Pagination', () => { - it('should list tasks', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create multiple tasks - const taskIds: string[] = []; - for (let i = 0; i < 3; i++) { - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 1000 - }, - task: { - ttl: 60_000 - } - } - }); - taskIds.push(createResult.task.taskId); - } - - // List tasks using taskStore - const listResult = await taskStore.listTasks(); - - expect(listResult.tasks.length).toBeGreaterThanOrEqual(3); - expect(listResult.tasks.some(t => taskIds.includes(t.taskId))).toBe(true); - - await transport.close(); - }); - - it('should handle pagination with large datasets', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create 15 tasks (more than page size of 10) - for (let i = 0; i < 15; i++) { - await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 5000 - }, - task: { - ttl: 60_000 - } - } - }); - } - - // Get first page using taskStore - const page1 = await taskStore.listTasks(); - - expect(page1.tasks.length).toBe(10); - expect(page1.nextCursor).toBeDefined(); - - // Get second page - const page2 = await taskStore.listTasks(page1.nextCursor); - - expect(page2.tasks.length).toBeGreaterThanOrEqual(5); - - await transport.close(); - }); - }); - - describe('Error Handling', () => { - it('should return error code -32602 for non-existent task in tasks/get', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: {} } - } - ); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Try to get non-existent task via tasks/get request - await expect(client.experimental.tasks.getTask('non-existent-task-id')).rejects.toSatisfy((error: ProtocolError) => { - expect(error).toBeInstanceOf(ProtocolError); - expect(error.code).toBe(ProtocolErrorCode.InvalidParams); - expect(error.message).toContain('Task not found'); - return true; - }); - - await transport.close(); - }); - - it('should return error code -32602 for non-existent task in tasks/cancel', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: {} } - } - ); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Try to cancel non-existent task via tasks/cancel request - await expect(client.experimental.tasks.cancelTask('non-existent-task-id')).rejects.toSatisfy((error: ProtocolError) => { - expect(error).toBeInstanceOf(ProtocolError); - expect(error.code).toBe(ProtocolErrorCode.InvalidParams); - expect(error.message).toContain('Task not found'); - return true; - }); - - await transport.close(); - }); - - it('should return error code -32602 for non-existent task in tasks/result', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Try to get result of non-existent task via tasks/result request - await expect( - client.request({ - method: 'tasks/result', - params: { taskId: 'non-existent-task-id' } - }) - ).rejects.toSatisfy((error: ProtocolError) => { - expect(error).toBeInstanceOf(ProtocolError); - expect(error.code).toBe(ProtocolErrorCode.InvalidParams); - expect(error.message).toContain('Task not found'); - return true; - }); - - await transport.close(); - }); - }); - - describe('TTL and Cleanup', () => { - it('should respect TTL in task creation', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task with specific TTL - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 100 - }, - task: { - ttl: 5000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Verify TTL is set correctly - expect(createResult.task.ttl).toBe(60_000); // The task store uses 60000 as default - - // Task should exist - const task = await client.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(task).toBeDefined(); - expect(task.ttl).toBe(60_000); - - await transport.close(); - }); - }); - - describe('Task Cancellation with Queued Messages', () => { - it('should clear queue and deliver no messages when task is cancelled before tasks/result', async () => { - // Register a tool that queues messages but doesn't complete immediately - mcpServer.experimental.tasks.registerToolTask( - 'cancellable-task', - { - title: 'Cancellable Task', - description: 'A tool that queues messages and can be cancelled', - inputSchema: z.object({ - messageCount: z.number().describe('Number of messages to queue').default(2) - }) - }, - { - async createTask({ messageCount }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: 60_000, - pollInterval: 100 - }); - - // Perform async work that queues messages - (async () => { - try { - await new Promise(resolve => setTimeout(resolve, 100)); - - // Queue multiple elicitation requests - for (let i = 0; i < messageCount; i++) { - // Send request but don't await - let it queue - ctx.mcpReq - .send( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: `Message ${i + 1} of ${messageCount}`, - requestedSchema: { - type: 'object', - properties: { - response: { type: 'string' } - }, - required: ['response'] - } - } - }, - { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions - ) - .catch(() => { - // Ignore errors from cancelled requests - }); - } - - // Don't complete - let the task be cancelled - // Wait indefinitely (or until cancelled) - await new Promise(() => {}); - } catch { - // Ignore errors - task was cancelled - } - })().catch(() => { - // Catch any unhandled errors from the async execution - }); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - let elicitationCallCount = 0; - - // Set up elicitation handler to track if any messages are delivered - client.setRequestHandler('elicitation/create', async () => { - elicitationCallCount++; - return { - action: 'accept' as const, - content: { - response: 'Should not be called' - } - }; - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task that will queue messages - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'cancellable-task', - arguments: { - messageCount: 2 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Wait for messages to be queued - await new Promise(resolve => setTimeout(resolve, 200)); - - // Verify task is in input_required state and messages are queued - let task = await client.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(task.status).toBe('input_required'); - - // Cancel the task before calling tasks/result using the proper tasks/cancel request - // This will trigger queue cleanup via _clearTaskQueue in the handler - await client.request({ - method: 'tasks/cancel', - params: { taskId } - }); - - // Verify task is cancelled - task = await client.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(task.status).toBe('cancelled'); - - // Attempt to call tasks/result - // When a task is cancelled, the system needs to clear the message queue - // and reject any pending message delivery promises, meaning no further - // messages should be delivered for a cancelled task. - try { - await client.request({ - method: 'tasks/result', - params: { taskId } - }); - } catch { - // tasks/result might throw an error for cancelled tasks without a result - // This is acceptable behavior - } - - // Verify no elicitation messages were delivered, as the queue should be cleared immediately on cancellation - expect(elicitationCallCount).toBe(0); - - // Verify queue remains cleared on subsequent calls - try { - await client.request({ - method: 'tasks/result', - params: { taskId } - }); - } catch { - // Expected - task is cancelled - } - - // Still no messages should have been delivered - expect(elicitationCallCount).toBe(0); - - await transport.close(); - }, 10_000); - }); - - describe('Continuous Message Delivery', () => { - it('should deliver messages immediately while tasks/result is blocking', async () => { - // Register a tool that queues messages over time - mcpServer.experimental.tasks.registerToolTask( - 'streaming-task', - { - title: 'Streaming Task', - description: 'A tool that sends messages over time', - inputSchema: z.object({ - messageCount: z.number().describe('Number of messages to send').default(3), - delayBetweenMessages: z.number().describe('Delay between messages in ms').default(200) - }) - }, - { - async createTask({ messageCount, delayBetweenMessages }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: 60_000, - pollInterval: 100 - }); - - // Perform async work that sends messages over time - (async () => { - try { - // Wait a bit before starting to send messages - await new Promise(resolve => setTimeout(resolve, 100)); - - const responses: string[] = []; - - // Send messages with delays between them - for (let i = 0; i < messageCount; i++) { - const elicitationResult = await ctx.mcpReq.send( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: `Streaming message ${i + 1} of ${messageCount}`, - requestedSchema: { - type: 'object', - properties: { - response: { type: 'string' } - }, - required: ['response'] - } - } - }, - { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions - ); - - if (elicitationResult.action === 'accept' && elicitationResult.content) { - responses.push(elicitationResult.content.response as string); - } - - // Wait before sending next message (if not the last one) - if (i < messageCount - 1) { - await new Promise(resolve => setTimeout(resolve, delayBetweenMessages)); - } - } - - // Complete with all responses - try { - await ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: `Received all responses: ${responses.join(', ')}` }] - }); - } catch { - // Task may have been cleaned up if test ended - } - } catch (error) { - // Handle errors - try { - await ctx.task.store.storeTaskResult(task.taskId, 'failed', { - content: [{ type: 'text', text: `Error: ${error}` }], - isError: true - }); - } catch { - // Task may have been cleaned up if test ended - } - } - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - const receivedMessages: Array<{ message: string; timestamp: number }> = []; - let tasksResultStartTime = 0; - - // Set up elicitation handler to track when messages arrive - client.setRequestHandler('elicitation/create', async request => { - const timestamp = Date.now(); - receivedMessages.push({ - message: request.params.message, - timestamp - }); - - // Extract the message number - const match = request.params.message.match(/Streaming message (\d+) of (\d+)/); - const messageNum = match ? match[1] : 'unknown'; - - // Respond immediately - return { - action: 'accept' as const, - content: { - response: `Response ${messageNum}` - } - }; - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task that will send messages over time - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'streaming-task', - arguments: { - messageCount: 3, - delayBetweenMessages: 300 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Verify task is in working status - let task = await client.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(task.status).toBe('working'); - - // Call tasks/result immediately (before messages are queued) - // This should block and deliver messages as they arrive - tasksResultStartTime = Date.now(); - const resultPromise = client.request({ - method: 'tasks/result', - params: { taskId } - }); - - // Wait for the task to complete and get the result - const result = await resultPromise; - - // Verify all 3 messages were delivered - expect(receivedMessages.length).toBe(3); - expect(receivedMessages[0]!.message).toBe('Streaming message 1 of 3'); - expect(receivedMessages[1]!.message).toBe('Streaming message 2 of 3'); - expect(receivedMessages[2]!.message).toBe('Streaming message 3 of 3'); - - // Verify messages were delivered over time (not all at once) - // The delay between messages should be approximately 300ms - const timeBetweenFirstAndSecond = receivedMessages[1]!.timestamp - receivedMessages[0]!.timestamp; - const timeBetweenSecondAndThird = receivedMessages[2]!.timestamp - receivedMessages[1]!.timestamp; - - // Allow some tolerance for timing (messages should be at least 200ms apart) - expect(timeBetweenFirstAndSecond).toBeGreaterThan(200); - expect(timeBetweenSecondAndThird).toBeGreaterThan(200); - - // Verify messages were delivered while tasks/result was blocking - // (all messages should arrive after tasks/result was called) - for (const msg of receivedMessages) { - expect(msg.timestamp).toBeGreaterThanOrEqual(tasksResultStartTime); - } - - // Verify final result is correct - expect(result.content).toEqual([{ type: 'text', text: 'Received all responses: Response 1, Response 2, Response 3' }]); - - // Verify task is now completed - task = await client.request({ - method: 'tasks/get', - params: { taskId } - }); - expect(task.status).toBe('completed'); - - await transport.close(); - }, 15_000); // Increase timeout to 15 seconds to allow for message delays - }); - - describe('Terminal Task with Queued Messages', () => { - it('should deliver queued messages followed by final result for terminal task', async () => { - // Register a tool that completes quickly and queues messages before completion - mcpServer.experimental.tasks.registerToolTask( - 'quick-complete-task', - { - title: 'Quick Complete Task', - description: 'A tool that queues messages and completes quickly', - inputSchema: z.object({ - messageCount: z.number().describe('Number of messages to queue').default(2) - }) - }, - { - async createTask({ messageCount }, ctx) { - const task = await ctx.task.store.createTask({ - ttl: 60_000, - pollInterval: 100 - }); - - // Perform async work that queues messages and completes quickly - (async () => { - try { - // Queue messages - these will be queued before the task completes - // We await each one starting to ensure they're queued before completing - for (let i = 0; i < messageCount; i++) { - // Start the request but don't wait for response - // The request gets queued when sendRequest is called - ctx.mcpReq - .send( - { - method: 'elicitation/create', - params: { - mode: 'form', - message: `Quick message ${i + 1} of ${messageCount}`, - requestedSchema: { - type: 'object', - properties: { - response: { type: 'string' } - }, - required: ['response'] - } - } - }, - { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions - ) - .catch(() => {}); - // Small delay to ensure message is queued before next iteration - await new Promise(resolve => setTimeout(resolve, 10)); - } - - // Complete the task after all messages are queued - try { - await ctx.task.store.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: 'Task completed quickly' }] - }); - } catch { - // Task may have been cleaned up if test ended - } - } catch (error) { - // Handle errors - try { - await ctx.task.store.storeTaskResult(task.taskId, 'failed', { - content: [{ type: 'text', text: `Error: ${error}` }], - isError: true - }); - } catch { - // Task may have been cleaned up if test ended - } - } - })(); - - return { task }; - }, - async getTask(_args, ctx) { - const task = await ctx.task.store.getTask(ctx.task.id); - if (!task) { - throw new Error(`Task ${ctx.task.id} not found`); - } - return task; - }, - async getTaskResult(_args, ctx) { - const result = await ctx.task.store.getTaskResult(ctx.task.id); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - const receivedMessages: Array<{ type: string; message?: string; content?: unknown }> = []; - - // Set up elicitation handler to track message order - client.setRequestHandler('elicitation/create', async request => { - receivedMessages.push({ - type: 'elicitation', - message: request.params.message - }); - - // Extract the message number - const match = request.params.message.match(/Quick message (\d+) of (\d+)/); - const messageNum = match ? match[1] : 'unknown'; - - return { - action: 'accept' as const, - content: { - response: `Response ${messageNum}` - } - }; - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task that will complete quickly with queued messages - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'quick-complete-task', - arguments: { - messageCount: 2 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Wait for task to complete and messages to be queued - const task = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'completed'); - - // Verify task is in terminal status (completed) - expect(task.status).toBe('completed'); - - // Call tasks/result - should deliver queued messages followed by final result - const result = await client.request({ - method: 'tasks/result', - params: { taskId } - }); - - // Verify all queued messages were delivered before the final result - expect(receivedMessages.length).toBe(2); - expect(receivedMessages[0]!.message).toBe('Quick message 1 of 2'); - expect(receivedMessages[1]!.message).toBe('Quick message 2 of 2'); - - // Verify final result is correct - expect(result.content).toEqual([{ type: 'text', text: 'Task completed quickly' }]); - - // Verify queue is cleaned up - calling tasks/result again should only return the result - receivedMessages.length = 0; // Clear the array - - const result2 = await client.request({ - method: 'tasks/result', - params: { taskId } - }); - - // No messages should be delivered on second call (queue was cleaned up) - expect(receivedMessages.length).toBe(0); - expect(result2.content).toEqual([{ type: 'text', text: 'Task completed quickly' }]); - - await transport.close(); - }, 10_000); - }); - - describe('Concurrent Operations', () => { - it('should handle multiple concurrent task creations', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create multiple tasks concurrently - const promises = Array.from({ length: 5 }, () => - client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 500 - }, - task: { - ttl: 60_000 - } - } - }) - ); - - const results = await Promise.all(promises); - - // Verify all tasks were created with unique IDs - const taskIds = results.map(r => r.task.taskId); - expect(new Set(taskIds).size).toBe(5); - - // Verify all tasks are in working status - for (const result of results) { - expect(result.task.status).toBe('working'); - } - - await transport.close(); - }); - - it('should handle concurrent operations on same task', async () => { - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Create a task - const createResult = await client.request({ - method: 'tools/call', - params: { - name: 'long-task', - arguments: { - duration: 2000 - }, - task: { - ttl: 60_000 - } - } - }); - - const taskId = createResult.task.taskId; - - // Perform multiple concurrent gets - const getPromises = Array.from({ length: 5 }, () => - client.request({ - method: 'tasks/get', - params: { taskId } - }) - ); - - const tasks = await Promise.all(getPromises); - - // All should return the same task - for (const task of tasks) { - expect(task.taskId).toBe(taskId); - expect(task.status).toBe('working'); - } - - await transport.close(); - }); - }); - - describe('callToolStream with failed task', () => { - it('should yield stored result (isError: true) when task fails, not a generic ProtocolError', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { tasks: {} } - } - ); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Use callToolStream with shouldFail: true so the tool stores a failed result - const stream = client.experimental.tasks.callToolStream( - { name: 'long-task', arguments: { duration: 100, shouldFail: true } }, - { task: { ttl: 60_000 } } - ); - - // Collect all stream messages - const messages: Array<{ type: string; task?: unknown; result?: unknown; error?: unknown }> = []; - for await (const message of stream) { - messages.push(message); - } - - // First message should be taskCreated - expect(messages[0]!.type).toBe('taskCreated'); - - // Last message must be 'result' (carrying the stored isError content), - // NOT 'error' (which would mean the generic hardcoded ProtocolError was returned) - const lastMessage = messages.at(-1)!; - expect(lastMessage.type).toBe('result'); - - // The stored result should contain isError: true and the real failure content - const result = lastMessage.result as { content: Array<{ type: string; text: string }>; isError: boolean }; - expect(result.isError).toBe(true); - expect(result.content).toEqual([{ type: 'text', text: 'Task failed as requested' }]); - - await transport.close(); - }, 15_000); - }); - - describe('callToolStream with elicitation', () => { - it('should deliver elicitation via callToolStream and complete task', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {}, - tasks: {} - } - } - ); - - // Track elicitation request receipt - let elicitationReceived = false; - let elicitationMessage = ''; - - // Set up elicitation handler on client - client.setRequestHandler('elicitation/create', async request => { - elicitationReceived = true; - elicitationMessage = request.params.message; - - return { - action: 'accept' as const, - content: { - userName: 'StreamUser' - } - }; - }); - - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Use callToolStream instead of raw request() - const stream = client.experimental.tasks.callToolStream( - { name: 'input-task', arguments: {} }, - { - task: { ttl: 60_000 } - } - ); - - // Collect all stream messages - const messages: Array<{ type: string; task?: unknown; result?: unknown; error?: unknown }> = []; - for await (const message of stream) { - messages.push(message); - } - - // Verify stream yielded expected message types - expect(messages.length).toBeGreaterThanOrEqual(2); - - // First message should be taskCreated - expect(messages[0]!.type).toBe('taskCreated'); - expect(messages[0]!.task).toBeDefined(); - - // Should have a taskStatus message - const statusMessages = messages.filter(m => m.type === 'taskStatus'); - expect(statusMessages.length).toBeGreaterThanOrEqual(1); - - // Last message should be result - const lastMessage = messages.at(-1)!; - expect(lastMessage.type).toBe('result'); - expect(lastMessage.result).toBeDefined(); - - // Verify elicitation was received and processed - expect(elicitationReceived).toBe(true); - expect(elicitationMessage).toContain('What is your name?'); - - // Verify result content - const result = lastMessage.result as { content: Array<{ type: string; text: string }> }; - expect(result.content).toEqual([{ type: 'text', text: 'Hello, StreamUser!' }]); - - await transport.close(); - }, 15_000); - }); -});