diff --git a/.changeset/dispatcher-extraction.md b/.changeset/dispatcher-extraction.md new file mode 100644 index 0000000000..e121016e1c --- /dev/null +++ b/.changeset/dispatcher-extraction.md @@ -0,0 +1,4 @@ +--- +'@modelcontextprotocol/core': major +--- +Extract Dispatcher from Protocol. Protocol composes `protected readonly dispatcher`; setRequestHandler/_onrequest delegate. The protected `_wrapHandler` override hook is replaced by `dispatcher.use(middleware)`. diff --git a/.changeset/sep-2663-tasks-removal.md b/.changeset/sep-2663-tasks-removal.md new file mode 100644 index 0000000000..51ece994a4 --- /dev/null +++ b/.changeset/sep-2663-tasks-removal.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': major +'@modelcontextprotocol/server': major +'@modelcontextprotocol/client': major +--- +SEP-2663: remove 2025-11 experimental tasks (TaskManager, experimental.tasks.* accessors). Tasks are now Extensions Track. diff --git a/.changeset/spec-types-142b3c3c.md b/.changeset/spec-types-142b3c3c.md new file mode 100644 index 0000000000..e90be3c19d --- /dev/null +++ b/.changeset/spec-types-142b3c3c.md @@ -0,0 +1,4 @@ +--- +'@modelcontextprotocol/core': major +--- +Regenerate spec.types.ts and sync schemas to spec @142b3c3c. Pre-2026 wire schemas (Initialize/Ping/SetLevel/Subscribe/Unsubscribe) moved to legacyWireSchemas.ts. diff --git a/.changeset/wraphandler-hook.md b/.changeset/wraphandler-hook.md deleted file mode 100644 index 935f576588..0000000000 --- a/.changeset/wraphandler-hook.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@modelcontextprotocol/core': patch -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -refactor: subclasses override `_wrapHandler` hook instead of redeclaring `setRequestHandler`. diff --git a/CLAUDE.md b/CLAUDE.md index cbbf950273..ac88c3fe29 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 @@ -163,10 +161,11 @@ When a request arrives from the remote side: 1. **Transport** receives message, calls `transport.onmessage()` 2. **`Protocol.connect()`** routes to `_onrequest()`, `_onresponse()`, or `_onnotification()` 3. **`Protocol._onrequest()`**: - - Looks up handler in `_requestHandlers` map (keyed by method name) + - Checks `dispatcher.canHandle(method)`; sends a `MethodNotFound` error and returns early if no handler (or fallback) is registered - Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc. - Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info) - - Invokes handler, sends JSON-RPC response back via transport + - Calls `dispatcher.dispatch()` which looks up the handler (keyed by method name), runs the middleware chain, invokes the handler, and wraps the result as a JSON-RPC response + - Sends the response back via transport 4. **Handler** was registered via `setRequestHandler('method', handler)` ### Handler Registration @@ -201,7 +200,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..ee19c1cea2 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,24 @@ 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: - -| 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. +`TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) and `TaskCreationParams` are also removed; they will return with the SEP-2663 server-directed plugin. ## 13. Client Behavioral Changes diff --git a/docs/migration.md b/docs/migration.md index cd3da6dcda..1c2311a619 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -485,7 +485,7 @@ const result = await client.callTool({ name: 'my-tool', arguments: {} }, Compati const result = await client.callTool({ name: 'my-tool', arguments: {} }); ``` -The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise`. +The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise`. For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method. @@ -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):** @@ -611,17 +609,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ```typescript server.setRequestHandler('tools/call', async (request, ctx) => { const headers = ctx.http?.req?.headers; // standard Web Request object - const taskStore = ctx.task?.store; await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); return { content: [{ type: 'text', text: 'result' }] }; }); ``` -Context fields are organized into 4 groups: +Context fields are organized into 3 groups: - **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` - **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE` -- **`task?`** — task lifecycle: `id`, `store`, `requestedTtl` +- **`sessionId?`** — transport session identifier (top-level) `BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection. @@ -853,46 +850,24 @@ 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 } -}); - -// Handler context had number | null | undefined -server.setRequestHandler('tools/call', async (request, ctx) => { - const ttl: number | null | undefined = ctx.task?.requestedTtl; -}); -``` - -**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; -}); -``` +**Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`) and `TaskCreationParams`. They will return as part of the SEP-2663 server-directed plugin in a follow-up. -> **Note:** These task APIs are marked `@experimental` and may change without notice. +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`. ## 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/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 9704ed8a5b..57853821ec 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -490,7 +490,7 @@ async function errorHandling_timeout(client: Client) { //#region errorHandling_timeout try { const result = await client.callTool( - { name: 'slow-task', arguments: {} }, + { name: 'slow-operation', arguments: {} }, { timeout: 120_000 } // 2 minutes instead of the default 60 seconds ); console.log(result.content); @@ -530,7 +530,7 @@ async function resumptionToken_basic(client: Client) { const result = await client.request( { method: 'tools/call', - params: { name: 'long-running-task', arguments: {} } + params: { name: 'long-running-operation', arguments: {} } }, { resumptionToken: lastToken, diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts index c75aea9483..cc0fe97fb0 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'; @@ -209,7 +209,7 @@ class InteractiveOAuthClient { console.log('Commands:'); console.log(' list - List available tools'); console.log(' call [args] - Call a tool'); - console.log(' stream [args] - Call a tool with streaming (shows task status)'); + console.log(' stream [args] - (disabled pending SEP-2663 tasksPlugin)'); console.log(' quit - Exit the client'); console.log(); @@ -232,7 +232,7 @@ class InteractiveOAuthClient { } else if (command.startsWith('stream ')) { await this.handleStreamTool(command); } else { - console.log("❌ Unknown command. Try 'list', 'call ', 'stream ', or 'quit'"); + console.log("❌ Unknown command. Try 'list', 'call ', or 'quit'"); } } catch (error) { if (error instanceof Error && error.message === 'SIGINT') { @@ -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/client/src/ssePollingClient.ts b/examples/client/src/ssePollingClient.ts index 4887471f99..2d1115e72a 100644 --- a/examples/client/src/ssePollingClient.ts +++ b/examples/client/src/ssePollingClient.ts @@ -65,16 +65,16 @@ async function main(): Promise { console.log('[Client] Connected successfully'); console.log(''); - // Call the long-task tool - console.log('[Client] Calling long-task tool...'); - console.log('[Client] Server will disconnect mid-task to demonstrate polling'); + // Call the long-operation tool + console.log('[Client] Calling long-operation tool...'); + console.log('[Client] Server will disconnect mid-operation to demonstrate polling'); console.log(''); const result = await client.request( { method: 'tools/call', params: { - name: 'long-task', + name: 'long-operation', arguments: {} } }, 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..84b5d320b6 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -4,12 +4,9 @@ import type { CallToolRequest, ClientCapabilities, ClientContext, - ClientNotification, - ClientRequest, CompleteRequest, GetPromptRequest, Implementation, - JSONRPCRequest, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -21,32 +18,27 @@ import type { ListToolsRequest, LoggingLevel, MessageExtraInfo, + Middleware, NotificationMethod, ProtocolOptions, ReadResourceRequest, RequestMethod, RequestOptions, - 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 +57,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 +131,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 +212,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,21 +223,12 @@ 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; - } + this.dispatcher.use(this._validationMiddleware); // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -299,22 +269,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. * @@ -330,121 +284,86 @@ export class Client extends Protocol { /** * Enforces client-side validation for `elicitation/create` and `sampling/createMessage` - * regardless of how the handler was registered. + * regardless of how the handler was registered. Installed as a {@linkcode Dispatcher} + * middleware so it applies to both the legacy `_onrequest` path and the 2026-06 + * dispatch path. */ - protected override _wrapHandler( - method: string, - handler: (request: JSONRPCRequest, ctx: ClientContext) => Promise - ): (request: JSONRPCRequest, ctx: ClientContext) => Promise { - if (method === 'elicitation/create') { - return async (request, ctx) => { - const validatedRequest = parseSchema(ElicitRequestSchema, request); - if (!validatedRequest.success) { - // Type guard: if success is false, error is guaranteed to exist - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); - } + private readonly _validationMiddleware: Middleware = async (request, _ctx, next) => { + if (request.method === 'elicitation/create') { + const validatedRequest = parseSchema(ElicitRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); + } - const { params } = validatedRequest.data; - params.mode = params.mode ?? 'form'; - const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); + const { params } = validatedRequest.data; + params.mode = params.mode ?? 'form'; + const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); - if (params.mode === 'form' && !supportsFormMode) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); - } + if (params.mode === 'form' && !supportsFormMode) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); + } - if (params.mode === 'url' && !supportsUrlMode) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); - } + if (params.mode === 'url' && !supportsUrlMode) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); + } - 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; - } + const result = await next(); - // 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 - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); - } + const validationResult = parseSchema(ElicitResultSchema, result); + if (!validationResult.success) { + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); + } - const validatedResult = validationResult.data; - const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; - - if ( - params.mode === 'form' && - validatedResult.action === 'accept' && - validatedResult.content && - requestedSchema && - this._capabilities.elicitation?.form?.applyDefaults - ) { - try { - applyElicitationDefaults(requestedSchema, validatedResult.content); - } catch { - // gracefully ignore errors in default application - } + const validatedResult = validationResult.data; + const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; + + if ( + params.mode === 'form' && + validatedResult.action === 'accept' && + validatedResult.content && + requestedSchema && + this._capabilities.elicitation?.form?.applyDefaults + ) { + try { + applyElicitationDefaults(requestedSchema, validatedResult.content); + } catch { + // gracefully ignore errors in default application } + } - return validatedResult; - }; + return validatedResult; } - if (method === 'sampling/createMessage') { - return async (request, ctx) => { - const validatedRequest = parseSchema(CreateMessageRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); - } - - const { params } = validatedRequest.data; + if (request.method === 'sampling/createMessage') { + const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); + } - const result = await handler(request, ctx); + const { params } = validatedRequest.data; - // 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; - } + const result = await next(); - // 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); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); - } + const hasTools = params.tools || params.toolChoice; + const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const validationResult = parseSchema(resultSchema, result); + if (!validationResult.success) { + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); + } - return validationResult.data; - }; + return validationResult.data; } - return handler; - } + return next(); + }; protected assertCapability(capability: keyof ServerCapabilities, method: string): void { if (!this._serverCapabilities?.[capability]) { @@ -571,7 +490,7 @@ export class Client extends Protocol { } protected assertCapabilityForMethod(method: RequestMethod | string): void { - switch (method as ClientRequest['method']) { + switch (method as RequestMethod) { case 'logging/setLevel': { if (!this._serverCapabilities?.logging) { throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); @@ -634,7 +553,7 @@ export class Client extends Protocol { } protected assertNotificationCapability(method: NotificationMethod | string): void { - switch (method as ClientNotification['method']) { + switch (method as NotificationMethod) { case 'notifications/roots/list_changed': { if (!this._capabilities.roots?.listChanged) { throw new SdkError( @@ -701,14 +620,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 +739,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 +769,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 +809,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 +822,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..ef30915ba6 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -38,6 +38,9 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils.js'; +// Dispatcher types (handler registry; consumed by Protocol) +export type { RequestHandlerSchemas } from '../../shared/dispatcher.js'; + // Protocol types (NOT the Protocol class itself or mergeCapabilities) export type { BaseContext, @@ -45,26 +48,11 @@ export type { NotificationOptions, ProgressCallback, ProtocolOptions, - RequestHandlerSchemas, RequestOptions, ServerContext } 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 +80,6 @@ export { LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, PARSE_ERROR, - RELATED_TASK_META_KEY, SUPPORTED_PROTOCOL_VERSIONS } from '../../types/constants.js'; @@ -114,29 +101,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..ba787aefa1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,12 +2,10 @@ export * from './auth/errors.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; +export * from './shared/dispatcher.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 +14,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/dispatcher.ts b/packages/core/src/shared/dispatcher.ts new file mode 100644 index 0000000000..72a8a8f76b --- /dev/null +++ b/packages/core/src/shared/dispatcher.ts @@ -0,0 +1,202 @@ +import { ProtocolErrorCode } from '../types/enums.js'; +import { ProtocolError } from '../types/errors.js'; +import type { + JSONRPCErrorResponse, + JSONRPCRequest, + JSONRPCResponse, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap +} from '../types/index.js'; +import { getRequestSchema, JSONRPC_VERSION } from '../types/index.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import { validateStandardSchema } from '../util/standardSchema.js'; + +/** + * A request handler stored in {@linkcode Dispatcher}. Receives the raw JSON-RPC + * request and a caller-supplied context, returns a `Result` (the success + * payload). Throw {@linkcode ProtocolError} to surface a structured error. + */ +export type Handler = (request: JSONRPCRequest, ctx: C) => Promise; + +/** + * Onion-style middleware around handler invocation. Receives `next` to call + * the remaining chain (and ultimately the handler). May short-circuit by + * returning a `Result` without calling `next`, or transform the result/error. + * + * Installed via {@linkcode Dispatcher.use}; runs for every request that + * routes through {@linkcode Dispatcher.dispatch}. + */ +export type Middleware = (request: JSONRPCRequest, ctx: C, next: () => Promise) => Promise; + +/** + * Schema bundle accepted by `setRequestHandler`'s 3-arg form. + * + * `params` is required and validates the inbound `request.params`. `result` is optional; + * when supplied it types the handler's return value (no runtime validation is performed + * on the result). + */ +export interface RequestHandlerSchemas< + P extends StandardSchemaV1 = StandardSchemaV1, + R extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined +> { + params: P; + result?: R; +} + +/** Infers the handler's return type from a `RequestHandlerSchemas.result` schema (or `Result` when absent). */ +export type InferHandlerResult = R extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : Result; + +/** + * Method-keyed request handler registry plus invocation. Both the legacy + * connect/_onrequest path and the 2026-06 stateless dispatch path route + * through {@linkcode dispatch}. + * + * `dispatch()` looks up the handler, runs the middleware chain, wraps the + * result/error into a JSON-RPC response. It writes no instance state and is + * safe to call concurrently. + */ +export class Dispatcher { + private readonly _handlers = new Map>(); + private readonly _middleware: Middleware[] = []; + + /** Called when no specific handler matches. Not wrapped by middleware. */ + fallbackHandler?: Handler; + + /** + * Appends a middleware. Middlewares run in registration order, with the + * registered handler as the innermost call. + */ + use(middleware: Middleware): void { + this._middleware.push(middleware); + } + + /** + * Registers a handler to invoke when this dispatcher receives a request with the given method. + * + * Note that this will replace any previous request handler for the same method. + * + * For spec methods, pass `(method, handler)`; the request is parsed with the spec + * schema and the handler receives the typed `Request`. For custom (non-spec) + * methods, pass `(method, schemas, handler)`; `params` are validated against + * `schemas.params` and the handler receives the parsed params object directly. + * Supplying `schemas.result` types the handler's return value. + */ + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise + ): void; + setRequestHandler

( + method: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise> + ): void; + setRequestHandler( + method: string, + schemasOrHandler: RequestHandlerSchemas | ((request: unknown, ctx: ContextT) => Result | Promise), + maybeHandler?: (params: unknown, ctx: ContextT) => Result | Promise + ): void { + let stored: Handler; + + if (typeof schemasOrHandler === 'function') { + const schema = getRequestSchema(method); + if (!schema) { + throw new TypeError( + `'${method}' is not a spec request method; pass schemas as the second argument to setRequestHandler().` + ); + } + stored = (request, ctx) => Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + } else if (maybeHandler) { + stored = async (request, ctx) => { + const userParams = { ...request.params }; + delete userParams._meta; + const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); + } + return maybeHandler(parsed.data, ctx); + }; + } else { + throw new TypeError('setRequestHandler: handler is required'); + } + + this._handlers.set(method, stored); + } + + /** + * Removes the request handler for the given method. + */ + removeRequestHandler(method: RequestMethod | string): void { + this._handlers.delete(method); + } + + /** + * Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed. + */ + assertCanSetRequestHandler(method: RequestMethod | string): void { + if (this._handlers.has(method)) { + throw new Error(`A request handler for ${method} already exists, which would be overridden`); + } + } + + /** + * Returns true if {@linkcode dispatch} would route this method to a handler + * (registered or fallback) rather than returning MethodNotFound. + */ + canHandle(method: string): boolean { + return this._handlers.has(method) || this.fallbackHandler !== undefined; + } + + /** + * Dispatches one JSON-RPC request through the middleware chain to its + * handler and wraps the outcome as a JSON-RPC response. + * + * Thrown errors are surfaced with their `code` (if a safe integer), + * `message`, and `data` properties. This matches the behavior of + * `Protocol._onrequest` prior to extraction. + */ + async dispatch(request: JSONRPCRequest, ctx: ContextT): Promise { + const id = request.id; + const handler = this._handlers.get(request.method); + let chain: () => Promise; + if (handler !== undefined) { + chain = () => handler(request, ctx); + for (let i = this._middleware.length - 1; i >= 0; i--) { + // Loop bounds guarantee a defined element (noUncheckedIndexedAccess). + const mw = this._middleware[i] as Middleware; + const next = chain; + chain = () => mw(request, ctx, next); + } + } else if (this.fallbackHandler === undefined) { + return errorResponse(id, ProtocolErrorCode.MethodNotFound, 'Method not found'); + } else { + // Preserve pre-extraction behavior: fallback bypasses middleware. + const fb = this.fallbackHandler; + chain = () => fb(request, ctx); + } + try { + return okResponse(id, await chain()); + } catch (error) { + const e = error as { code?: unknown; message?: string; data?: unknown }; + return errorResponse( + id, + Number.isSafeInteger(e.code) ? (e.code as number) : ProtocolErrorCode.InternalError, + e.message ?? 'Internal error', + e.data + ); + } + } +} + +/** Builds a JSON-RPC success response. */ +export function okResponse(id: JSONRPCRequest['id'], result: Result): JSONRPCResponse { + return { jsonrpc: JSONRPC_VERSION, id, result }; +} + +/** Builds a JSON-RPC error response. */ +export function errorResponse(id: JSONRPCRequest['id'], code: number, message: string, data?: unknown): JSONRPCErrorResponse { + return { jsonrpc: JSONRPC_VERSION, id, error: { code, message, ...(data === undefined ? {} : { data }) } }; +} diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 361bd6fc7c..77a2222a14 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,12 +28,10 @@ import type { RequestTypeMap, Result, ResultTypeMap, - ServerCapabilities, - TaskCreationParams + ServerCapabilities } from '../types/index.js'; import { getNotificationSchema, - getRequestSchema, getResultSchema, isJSONRPCErrorResponse, isJSONRPCNotification, @@ -46,8 +43,8 @@ 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 { Handler, InferHandlerResult, RequestHandlerSchemas } from './dispatcher.js'; +import { Dispatcher, errorResponse } from './dispatcher.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -61,7 +58,7 @@ export type ProgressCallback = (progress: Progress) => void; export type ProtocolOptions = { /** * Protocol versions supported. First version is preferred (sent by client, - * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. + * used as fallback by server). Passed to transport during `connect()`. * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ @@ -82,16 +79,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 +92,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 +120,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 +130,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 +176,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 +202,6 @@ export type BaseContext = { */ authInfo?: AuthInfo; }; - - /** - * Task context, available when task storage is configured. - */ - task?: TaskContext; }; /** @@ -311,7 +276,8 @@ type TimeoutInfo = { export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; - private _requestHandlers: Map Promise> = new Map(); + /** The handler registry. Both `_onrequest` and `_dispatchStateless` route through it. */ + protected readonly dispatcher = new Dispatcher(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -319,8 +285,6 @@ export abstract class Protocol { private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); - private _taskManager: TaskManager; - protected _supportedProtocolVersions: string[]; /** @@ -340,7 +304,12 @@ export abstract class Protocol { /** * A handler to invoke for any request types that do not have their own handler installed. */ - fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; + get fallbackRequestHandler(): Handler | undefined { + return this.dispatcher.fallbackHandler; + } + set fallbackRequestHandler(handler: Handler | undefined) { + this.dispatcher.fallbackHandler = handler; + } /** * A handler to invoke for any notification types that do not have their own handler installed. @@ -350,10 +319,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); }); @@ -362,46 +327,16 @@ export abstract class Protocol { this._onprogress(notification); }); - this.setRequestHandler( + // Register directly on the dispatcher (not via setRequestHandler) so + // the abstract assertRequestHandlerCapability is not called before + // subclass fields are initialised. + this.dispatcher.setRequestHandler( 'ping', // Automatic pong by default. _request => ({}) as Result ); } - /** - * 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 +441,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()) { @@ -538,7 +472,7 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(notification: JSONRPCNotification): void { + protected _onnotification(notification: JSONRPCNotification): void { const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; // Ignore notifications not being subscribed to. @@ -553,52 +487,21 @@ export abstract class Protocol { } private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; - // 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); - - if (handler === undefined) { - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - 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}`))); + if (!this.dispatcher.canHandle(request.method)) { + capturedTransport + ?.send(errorResponse(request.id, ProtocolErrorCode.MethodNotFound, 'Method not found')) + .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))); return; } + 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 }); + const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); @@ -613,7 +516,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,61 +530,18 @@ 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(); + this.dispatcher + .dispatch(request, ctx) + .then(async response => { + if (abortController.signal.aborted) { + return; } + await capturedTransport?.send(response); }) - .then(() => handler(request, ctx)) - .then( - async result => { - if (abortController.signal.aborted) { - // Request was cancelled - return; - } - - const response: JSONRPCResponse = { - result, - 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); - } - }, - async error => { - if (abortController.signal.aborted) { - // Request was cancelled - return; - } - - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError, - message: error.message ?? 'Internal error', - ...(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); - } - } - ) .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) .finally(() => { if (this._requestHandlerAbortControllers.get(request.id) === abortController) { @@ -722,11 +582,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 +590,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 +632,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 +666,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 +773,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 +802,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 +831,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); } /** @@ -1080,63 +877,23 @@ export abstract class Protocol { maybeHandler?: (params: unknown, ctx: ContextT) => Result | Promise ): void { this.assertRequestHandlerCapability(method); - - let stored: (request: JSONRPCRequest, ctx: ContextT) => Promise; - - if (typeof schemasOrHandler === 'function') { - const schema = getRequestSchema(method); - if (!schema) { - throw new TypeError( - `'${method}' is not a spec request method; pass schemas as the second argument to setRequestHandler().` - ); - } - stored = (request, ctx) => Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); - } else if (maybeHandler) { - stored = async (request, ctx) => { - const userParams = { ...request.params }; - delete userParams._meta; - const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); - if (!parsed.success) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); - } - return maybeHandler(parsed.data, ctx); - }; - } else { - throw new TypeError('setRequestHandler: handler is required'); - } - - this._requestHandlers.set(method, this._wrapHandler(method, stored)); - } - - /** - * Hook for subclasses to wrap a registered request handler with role-specific - * validation or behavior (e.g. `Server` validates `tools/call` results, `Client` - * validates `elicitation/create` mode and result). Runs for both the 2-arg and - * 3-arg registration paths. The default implementation is identity. - * - * Subclasses overriding this hook avoid redeclaring `setRequestHandler`'s overload set. - */ - protected _wrapHandler( - _method: string, - handler: (request: JSONRPCRequest, ctx: ContextT) => Promise - ): (request: JSONRPCRequest, ctx: ContextT) => Promise { - return handler; + // Unsound only at the impl signature; the public overloads are sound, and + // Dispatcher.setRequestHandler has the identical impl signature. + this.dispatcher.setRequestHandler(method, schemasOrHandler as never, maybeHandler as never); } /** * Removes the request handler for the given method. */ removeRequestHandler(method: RequestMethod | string): void { - this._requestHandlers.delete(method); + this.dispatcher.removeRequestHandler(method); } /** * Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed. */ assertCanSetRequestHandler(method: RequestMethod | string): void { - if (this._requestHandlers.has(method)) { - throw new Error(`A request handler for ${method} already exists, which would be overridden`); - } + this.dispatcher.assertCanSetRequestHandler(method); } /** @@ -1197,23 +954,6 @@ export abstract class Protocol { } } -/** - * Schema bundle accepted by {@linkcode Protocol.setRequestHandler | setRequestHandler}'s 3-arg form. - * - * `params` is required and validates the inbound `request.params`. `result` is optional; - * when supplied it types the handler's return value (no runtime validation is performed - * on the result). - */ -export interface RequestHandlerSchemas< - P extends StandardSchemaV1 = StandardSchemaV1, - R extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined -> { - params: P; - result?: R; -} - -type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; - function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } 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..f1930dfb0b 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -1,29 +1,25 @@ +import type { InitializedNotification, InitializeRequest } from './legacyWireSchemas.js'; +import { InitializedNotificationSchema, InitializeRequestSchema } from './legacyWireSchemas.js'; import { CallToolResultSchema, - InitializedNotificationSchema, - InitializeRequestSchema, JSONRPCErrorResponseSchema, JSONRPCMessageSchema, JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, - JSONRPCResultResponseSchema, - TaskAugmentedRequestParamsSchema + JSONRPCResultResponseSchema } from './schemas.js'; import type { CallToolResult, CompleteRequest, CompleteRequestPrompt, CompleteRequestResourceTemplate, - InitializedNotification, - InitializeRequest, JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, - JSONRPCResultResponse, - TaskAugmentedRequestParams + JSONRPCResultResponse } from './types.js'; /** @@ -81,15 +77,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/index.ts b/packages/core/src/types/index.ts index c150aea737..18c6feb5e4 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -4,6 +4,9 @@ export * from './constants.js'; export * from './enums.js'; export * from './errors.js'; export * from './guards.js'; +/* eslint-disable import/export -- legacy type names overlap with types.ts re-exports; TS resolves deterministically. */ +export * from './legacyWireSchemas.js'; export * from './schemas.js'; export * from './specTypeSchema.js'; export * from './types.js'; +/* eslint-enable import/export */ diff --git a/packages/core/src/types/legacyWireSchemas.ts b/packages/core/src/types/legacyWireSchemas.ts new file mode 100644 index 0000000000..700aa57a0e --- /dev/null +++ b/packages/core/src/types/legacyWireSchemas.ts @@ -0,0 +1,268 @@ +/** + * Pre-2026 wire types and schemas. + * + * These types represent JSON-RPC methods and notifications that were part of + * MCP up to and including 2025-11-25 but are removed from the 2026-06 draft + * spec. They are NOT in the current `spec.types.ts` (regenerated from the + * spec repo) and are SDK-maintained here so `LegacyServer` / `LegacyClient` + * can continue to speak the pre-2026 wire protocol. + * + * Deleted when pre-2026 protocol support is dropped. + */ +import * as z from 'zod/v4'; + +import { + ClientCapabilitiesSchema, + ImplementationSchema, + LoggingLevelSchema, + NotificationSchema, + ProgressTokenSchema, + registerLegacySchemas, + ServerCapabilitiesSchema +} from './schemas.js'; +import type { + ClientCapabilities, + Implementation, + JSONRPCNotification, + JSONRPCRequest, + LoggingLevel, + MetaObject, + NotificationParams, + ProgressToken, + ServerCapabilities +} from './spec.types.js'; + +/* ────────────────────────────────────────────────────────────────────────── */ +/* Legacy base shapes */ +/* */ +/* The 2026-06 spec's `RequestParams._meta` is required and carries */ +/* namespaced `io.modelcontextprotocol/*` keys. Pre-2026 requests have an */ +/* optional `_meta` with only `progressToken`. These local base types let */ +/* the legacy interfaces below avoid the strict 2026 shape. */ +/* ────────────────────────────────────────────────────────────────────────── */ + +/** Pre-2026 `_meta` shape: only `progressToken`, no namespaced keys. */ +export interface LegacyRequestMetaObject extends MetaObject { + progressToken?: ProgressToken; +} + +/** Pre-2026 request params: `_meta` is optional. */ +export interface LegacyRequestParams { + _meta?: LegacyRequestMetaObject; + [key: string]: unknown; +} + +/** Pre-2026 result: no `resultType`. */ +export interface LegacyResult { + _meta?: MetaObject; + [key: string]: unknown; +} + +/* Zod base shapes for legacy schemas. Kept separate from `schemas.ts` so the + * 2026 file is spec-only at the source level. The shapes here are the pre-2026 + * wire format (no namespaced `_meta` keys, no `resultType`). */ + +const LegacyRequestMetaSchema = z.looseObject({ + progressToken: ProgressTokenSchema.optional() +}); + +const LegacyBaseRequestParamsSchema = z.object({ + _meta: LegacyRequestMetaSchema.optional() +}); + +const LegacyRequestSchema = z.object({ + method: z.string(), + params: LegacyBaseRequestParamsSchema.loose().optional() +}); + +const LegacyResultSchema = z.looseObject({ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/* ────────────────────────────────────────────────────────────────────────── */ +/* `initialize` / `notifications/initialized` */ +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface InitializeRequestParams extends LegacyRequestParams { + /** + * The latest version of the Model Context Protocol that the client + * supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; +} + +export interface InitializeRequest extends JSONRPCRequest { + method: 'initialize'; + params: InitializeRequestParams; +} + +export interface InitializeResult extends LegacyResult { + /** + * The version of the Model Context Protocol that the server wants to use. + * This may not match the version that the client requested. If the client + * cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + /** + * Instructions describing how to use the server and its features. + */ + instructions?: string; +} + +export interface InitializedNotification extends JSONRPCNotification { + method: 'notifications/initialized'; + params?: NotificationParams; +} + +export const InitializeRequestParamsSchema = LegacyBaseRequestParamsSchema.extend({ + protocolVersion: z.string(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema +}); + +export const InitializeRequestSchema = LegacyRequestSchema.extend({ + method: z.literal('initialize'), + params: InitializeRequestParamsSchema +}); + +export const InitializeResultSchema = LegacyResultSchema.extend({ + protocolVersion: z.string(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + instructions: z.string().optional() +}); + +export const InitializedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/initialized') +}); + +/* ────────────────────────────────────────────────────────────────────────── */ +/* `ping` */ +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface PingRequest extends JSONRPCRequest { + method: 'ping'; + params?: LegacyRequestParams; +} + +export const PingRequestSchema = LegacyRequestSchema.extend({ + method: z.literal('ping') +}); + +/* ────────────────────────────────────────────────────────────────────────── */ +/* `logging/setLevel` */ +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface SetLevelRequestParams extends LegacyRequestParams { + /** + * The level of logging that the client wants to receive from the server. + */ + level: LoggingLevel; +} + +export interface SetLevelRequest extends JSONRPCRequest { + method: 'logging/setLevel'; + params: SetLevelRequestParams; +} + +export const SetLevelRequestParamsSchema = LegacyBaseRequestParamsSchema.extend({ + level: LoggingLevelSchema +}); + +export const SetLevelRequestSchema = LegacyRequestSchema.extend({ + method: z.literal('logging/setLevel'), + params: SetLevelRequestParamsSchema +}); + +/* ────────────────────────────────────────────────────────────────────────── */ +/* `resources/subscribe` / `resources/unsubscribe` */ +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface SubscribeRequestParams extends LegacyRequestParams { + /** The URI of the resource to subscribe to. */ + uri: string; +} + +export interface SubscribeRequest extends JSONRPCRequest { + method: 'resources/subscribe'; + params: SubscribeRequestParams; +} + +export type UnsubscribeRequestParams = SubscribeRequestParams; + +export interface UnsubscribeRequest extends JSONRPCRequest { + method: 'resources/unsubscribe'; + params: UnsubscribeRequestParams; +} + +export const SubscribeRequestParamsSchema = LegacyBaseRequestParamsSchema.extend({ + uri: z.string() +}); + +export const SubscribeRequestSchema = LegacyRequestSchema.extend({ + method: z.literal('resources/subscribe'), + params: SubscribeRequestParamsSchema +}); + +export const UnsubscribeRequestParamsSchema = SubscribeRequestParamsSchema; + +export const UnsubscribeRequestSchema = LegacyRequestSchema.extend({ + method: z.literal('resources/unsubscribe'), + params: UnsubscribeRequestParamsSchema +}); + +/* ────────────────────────────────────────────────────────────────────────── */ +/* `notifications/roots/list_changed` (client → server) */ +/* ────────────────────────────────────────────────────────────────────────── */ + +export interface RootsListChangedNotification extends JSONRPCNotification { + method: 'notifications/roots/list_changed'; + params?: NotificationParams; +} + +export const RootsListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/roots/list_changed') +}); + +/* ────────────────────────────────────────────────────────────────────────── */ +/* Maps for legacy method/result/notification dispatch */ +/* ────────────────────────────────────────────────────────────────────────── */ + +/** Legacy request methods that are not in the 2026-06 `RequestTypeMap`. */ +export const legacyRequestSchemas = { + initialize: InitializeRequestSchema, + ping: PingRequestSchema, + 'logging/setLevel': SetLevelRequestSchema, + 'resources/subscribe': SubscribeRequestSchema, + 'resources/unsubscribe': UnsubscribeRequestSchema +} as const; + +/** Legacy result schemas keyed by request method. */ +export const legacyResultSchemas = { + initialize: InitializeResultSchema, + ping: LegacyResultSchema, + 'logging/setLevel': LegacyResultSchema, + 'resources/subscribe': LegacyResultSchema, + 'resources/unsubscribe': LegacyResultSchema +} as const; + +/** Legacy notification methods that are not in the 2026-06 `NotificationTypeMap`. */ +export const legacyNotificationSchemas = { + 'notifications/initialized': InitializedNotificationSchema, + 'notifications/roots/list_changed': RootsListChangedNotificationSchema +} as const; + +export type LegacyRequestMethod = keyof typeof legacyRequestSchemas; +export type LegacyNotificationMethod = keyof typeof legacyNotificationSchemas; + +// Merge into the runtime lookup maps so `getRequestSchema('initialize')` etc. +// continue to work. Runs at module load (this file is barrel-imported). +registerLegacySchemas({ + requests: legacyRequestSchemas, + results: legacyResultSchemas, + notifications: legacyNotificationSchemas +}); diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index a243c1b829..7a01b4e445 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, @@ -28,43 +28,30 @@ 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`. + * Request `_meta` shape. + * + * The 2026-06 spec marks the namespaced `io.modelcontextprotocol/*` keys as + * required. They are intentionally NOT enumerated in this zod schema: + * + * 1. Adding `ClientCapabilitiesSchema`/`ImplementationSchema` here (even lazy) + * pushes every request-type inference past TS2589's depth budget. + * 2. The same base schema must validate pre-2026 (no namespaced keys) and 2026 + * (keys present) wire shapes. + * 3. Strict validation of the namespaced keys is owned by the stateless + * dispatch layer (added later in the v2-stateless stack). + * + * `looseObject` passes through unknown keys, so 2026 `_meta` shapes parse fine. */ -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() }); +/** Alias mirroring `spec.types.ts` naming. */ +export const RequestMetaObjectSchema = RequestMetaSchema; + /** * Common params for any request. */ @@ -76,18 +63,16 @@ export const BaseRequestParamsSchema = z.object({ }); /** - * Common params for any task-augmented request. + * Request params carrying responses to a prior `input_required` result. + * + * Declared ahead of the MRTR block (which defines `InputResponsesSchema`) so + * `ReadResourceRequestParamsSchema` / `GetPromptRequestParamsSchema` / + * `CallToolRequestParamsSchema` can extend it; `z.lazy` defers the forward + * reference until parse time. */ -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 InputResponseRequestParamsSchema = BaseRequestParamsSchema.extend({ + inputResponses: z.lazy(() => InputResponsesSchema).optional(), + requestState: z.string().optional() }); export const RequestSchema = z.object({ @@ -95,12 +80,15 @@ export const RequestSchema = z.object({ params: BaseRequestParamsSchema.loose().optional() }); +/** Generic `MetaObject` (open record). */ +export const MetaObjectSchema = z.record(z.string(), z.unknown()); + export const NotificationsParamsSchema = z.object({ /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on `_meta` usage. */ - _meta: RequestMetaSchema.optional() + _meta: MetaObjectSchema.optional() }); export const NotificationSchema = z.object({ @@ -108,12 +96,20 @@ export const NotificationSchema = z.object({ params: NotificationsParamsSchema.loose().optional() }); +export const ResultTypeSchema = z.enum(['complete', 'input_required']); + export const ResultSchema = z.looseObject({ /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on `_meta` usage. */ - _meta: RequestMetaSchema.optional() + _meta: MetaObjectSchema.optional(), + /** + * Indicates the type of the result. Spec marks this required for 2026-06 + * servers; kept optional here so the schema also accepts pre-2026 results + * (which omit it). Clients MUST treat absent as `"complete"`. + */ + resultType: ResultTypeSchema.optional() }); /** @@ -212,7 +208,7 @@ export const CancelledNotificationParamsSchema = NotificationsParamsSchema.exten * * This notification indicates that the result will be unused, so any associated processing SHOULD cease. * - * A client MUST NOT attempt to cancel its {@linkcode InitializeRequest | initialize} request. + * A client MUST NOT attempt to cancel its `initialize` (pre-2026) or `server/discover` request. */ export const CancelledNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/cancelled'), @@ -331,72 +327,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. */ @@ -436,32 +366,12 @@ export const ClientCapabilitiesSchema = z.object({ listChanged: z.boolean().optional() }) .optional(), - /** - * Present if the client supports task creation. - */ - tasks: ClientTasksCapabilitySchema.optional(), /** * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). */ extensions: z.record(z.string(), JSONObjectSchema).optional() }); -export const InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ - /** - * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. - */ - protocolVersion: z.string(), - capabilities: ClientCapabilitiesSchema, - clientInfo: ImplementationSchema -}); -/** - * This request is sent from the client to the server when it first connects, asking it to begin initialization. - */ -export const InitializeRequestSchema = RequestSchema.extend({ - method: z.literal('initialize'), - params: InitializeRequestParamsSchema -}); - /** * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. */ @@ -516,49 +426,39 @@ export const ServerCapabilitiesSchema = z.object({ listChanged: z.boolean().optional() }) .optional(), - /** - * Present if the server supports task creation. - */ - tasks: ServerTasksCapabilitySchema.optional(), /** * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). */ extensions: z.record(z.string(), JSONObjectSchema).optional() }); +/* Discover (2026-06 negotiation) */ + /** - * After receiving an initialize request from the client, the server sends this response. + * Sent by the client to discover the server's supported protocol versions + * and capabilities. Replaces the pre-2026 `initialize` handshake. */ -export const InitializeResultSchema = ResultSchema.extend({ - /** - * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. - */ - protocolVersion: z.string(), +export const DiscoverRequestSchema = RequestSchema.extend({ + method: z.literal('server/discover'), + params: BaseRequestParamsSchema.optional() +}); + +export const DiscoverResultSchema = ResultSchema.extend({ + supportedVersions: z.array(z.string()), capabilities: ServerCapabilitiesSchema, serverInfo: ImplementationSchema, - /** - * Instructions describing how to use the server and its features. - * - * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. - */ instructions: z.string().optional() }); -/** - * This notification is sent from the client to the server after initialization has finished. - */ -export const InitializedNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/initialized'), - params: NotificationsParamsSchema.optional() +/* Error data shapes */ + +export const UnsupportedProtocolVersionErrorDataSchema = z.object({ + supported: z.array(z.string()), + requested: z.string() }); -/* Ping */ -/** - * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. - */ -export const PingRequestSchema = RequestSchema.extend({ - method: z.literal('ping'), - params: BaseRequestParamsSchema.optional() +export const MissingRequiredClientCapabilityErrorDataSchema = z.object({ + requiredCapabilities: ClientCapabilitiesSchema }); /* Progress notifications */ @@ -608,128 +508,28 @@ export const PaginatedRequestSchema = RequestSchema.extend({ params: PaginatedRequestParamsSchema.optional() }); -export const PaginatedResultSchema = ResultSchema.extend({ - /** - * An opaque token representing the pagination position after the last returned result. - * If present, there may be more results available. - */ - 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(); +/* Caching (SEP-2243) */ -/** - * A request to list tasks. - */ -export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: z.literal('tasks/list') -}); +export const CacheScopeSchema = z.enum(['public', 'private']); /** - * The response to a {@linkcode ListTasksRequest | tasks/list} request. + * Result fields for cache hints. Spec marks `ttlMs`/`cacheScope` required; + * kept optional here so the schema accepts servers that have not yet + * implemented SEP-2243. */ -export const ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: z.array(TaskSchema) +export const CacheableResultSchema = ResultSchema.extend({ + ttlMs: z.number().int().nonnegative().optional(), + cacheScope: CacheScopeSchema.optional() }); -/** - * A request to cancel a specific task. - */ -export const CancelTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/cancel'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) +export const PaginatedResultSchema = CacheableResultSchema.extend({ + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor: CursorSchema.optional() }); -/** - * 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. @@ -925,7 +725,7 @@ export const ResourceRequestParamsSchema = BaseRequestParamsSchema.extend({ /** * Parameters for a {@linkcode ReadResourceRequest | resources/read} request. */ -export const ReadResourceRequestParamsSchema = ResourceRequestParamsSchema; +export const ReadResourceRequestParamsSchema = InputResponseRequestParamsSchema.extend(ResourceRequestParamsSchema.shape); /** * Sent from the client to the server, to read a specific resource URI. @@ -938,7 +738,7 @@ export const ReadResourceRequestSchema = RequestSchema.extend({ /** * The server's response to a {@linkcode ReadResourceRequest | resources/read} request from the client. */ -export const ReadResourceResultSchema = ResultSchema.extend({ +export const ReadResourceResultSchema = CacheableResultSchema.extend({ contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) }); @@ -950,24 +750,6 @@ export const ResourceListChangedNotificationSchema = NotificationSchema.extend({ params: NotificationsParamsSchema.optional() }); -export const SubscribeRequestParamsSchema = ResourceRequestParamsSchema; -/** - * Sent from the client to request `resources/updated` notifications from the server whenever a particular resource changes. - */ -export const SubscribeRequestSchema = RequestSchema.extend({ - method: z.literal('resources/subscribe'), - params: SubscribeRequestParamsSchema -}); - -export const UnsubscribeRequestParamsSchema = ResourceRequestParamsSchema; -/** - * Sent from the client to request cancellation of {@linkcode ResourceUpdatedNotification | resources/updated} notifications from the server. This should follow a previous {@linkcode SubscribeRequest | resources/subscribe} request. - */ -export const UnsubscribeRequestSchema = RequestSchema.extend({ - method: z.literal('resources/unsubscribe'), - params: UnsubscribeRequestParamsSchema -}); - /** * Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification. */ @@ -979,13 +761,40 @@ export const ResourceUpdatedNotificationParamsSchema = NotificationsParamsSchema }); /** - * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a {@linkcode SubscribeRequest | resources/subscribe} request. + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. */ export const ResourceUpdatedNotificationSchema = NotificationSchema.extend({ method: z.literal('notifications/resources/updated'), params: ResourceUpdatedNotificationParamsSchema }); +/* Subscriptions (2026-06) */ + +export const SubscriptionFilterSchema = z.object({ + toolsListChanged: z.boolean().optional(), + promptsListChanged: z.boolean().optional(), + resourcesListChanged: z.boolean().optional(), + resourceSubscriptions: z.array(z.string()).optional() +}); + +export const SubscriptionsListenRequestParamsSchema = BaseRequestParamsSchema.extend({ + notifications: SubscriptionFilterSchema +}); + +export const SubscriptionsListenRequestSchema = RequestSchema.extend({ + method: z.literal('subscriptions/listen'), + params: SubscriptionsListenRequestParamsSchema +}); + +export const SubscriptionsAcknowledgedNotificationParamsSchema = NotificationsParamsSchema.extend({ + notifications: SubscriptionFilterSchema +}); + +export const SubscriptionsAcknowledgedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/subscriptions/acknowledged'), + params: SubscriptionsAcknowledgedNotificationParamsSchema +}); + /* Prompts */ /** * Describes an argument that a prompt can accept. @@ -1043,7 +852,7 @@ export const ListPromptsResultSchema = PaginatedResultSchema.extend({ /** * Parameters for a {@linkcode GetPromptRequest | prompts/get} request. */ -export const GetPromptRequestParamsSchema = BaseRequestParamsSchema.extend({ +export const GetPromptRequestParamsSchema = InputResponseRequestParamsSchema.extend({ /** * The name of the prompt or prompt template. */ @@ -1282,21 +1091,6 @@ export const ToolAnnotationsSchema = z.object({ openWorldHint: z.boolean().optional() }); -/** - * 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: z.enum(['required', 'optional', 'forbidden']).optional() -}); - /** * Definition for a tool the client can call. */ @@ -1314,20 +1108,16 @@ export const ToolSchema = z.object({ inputSchema: z .object({ type: z.literal('object'), - properties: z.record(z.string(), JSONValueSchema).optional(), - required: z.array(z.string()).optional() + $schema: z.string().optional() }) .catchall(z.unknown()), /** * An optional JSON Schema 2020-12 object defining the structure of the tool's output * returned in the `structuredContent` field of a `CallToolResult`. - * Must have `type: 'object'` at the root level per MCP spec. */ outputSchema: z .object({ - type: z.literal('object'), - properties: z.record(z.string(), JSONValueSchema).optional(), - required: z.array(z.string()).optional() + $schema: z.string().optional() }) .catchall(z.unknown()) .optional(), @@ -1335,10 +1125,6 @@ export const ToolSchema = z.object({ * Optional additional tool information. */ annotations: ToolAnnotationsSchema.optional(), - /** - * Execution-related properties for this tool. - */ - execution: ToolExecutionSchema.optional(), /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) @@ -1378,7 +1164,7 @@ export const CallToolResultSchema = ResultSchema.extend({ * * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. */ - structuredContent: z.record(z.string(), z.unknown()).optional(), + structuredContent: z.unknown().optional(), /** * Whether the tool call ended in an error. @@ -1409,7 +1195,7 @@ export const CompatibilityCallToolResultSchema = CallToolResultSchema.or( /** * Parameters for a `tools/call` request. */ -export const CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const CallToolRequestParamsSchema = InputResponseRequestParamsSchema.extend({ /** * The name of the tool to call. */ @@ -1467,23 +1253,6 @@ export const ListChangedOptionsBaseSchema = z.object({ */ export const LoggingLevelSchema = z.enum(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']); -/** - * Parameters for a `logging/setLevel` request. - */ -export const SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ - /** - * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as `notifications/logging/message`. - */ - level: LoggingLevelSchema -}); -/** - * A request from the client to the server, to enable or adjust logging. - */ -export const SetLevelRequestSchema = RequestSchema.extend({ - method: z.literal('logging/setLevel'), - params: SetLevelRequestParamsSchema -}); - /** * Parameters for a `notifications/message` notification. */ @@ -1563,7 +1332,7 @@ export const ToolResultContentSchema = z.object({ type: z.literal('tool_result'), toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), content: z.array(ContentBlockSchema).default([]), - structuredContent: z.object({}).loose().optional(), + structuredContent: z.unknown().optional(), isError: z.boolean().optional(), /** @@ -1607,7 +1376,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 +1615,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 +1642,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. */ @@ -2067,65 +1836,59 @@ export const ListRootsResultSchema = ResultSchema.extend({ roots: z.array(RootSchema) }); +/* MRTR (input_required) */ + /** - * A notification from the client to the server, informing it that the list of roots has changed. + * Union of request shapes a server may include in `inputRequests`. + * These are the same shapes as the (now-removed) standalone server→client RPCs. */ -export const RootsListChangedNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/roots/list_changed'), - params: NotificationsParamsSchema.optional() +export const InputRequestSchema = z.union([CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); + +export const InputRequestsSchema = z.record(z.string(), InputRequestSchema); + +export const InputResponseSchema = z.union([CreateMessageResultWithToolsSchema, ElicitResultSchema, ListRootsResultSchema]); + +export const InputResponsesSchema = z.record(z.string(), InputResponseSchema); + +/** + * Result indicating the server requires additional input before completing. + * At least one of `inputRequests` or `requestState` MUST be present. + */ +export const InputRequiredResultSchema = ResultSchema.extend({ + resultType: z.literal('input_required'), + inputRequests: InputRequestsSchema.optional(), + requestState: z.string().optional() }); -/* Client messages */ +/* Client messages (2026 spec methods only — legacy methods are merged in `types.ts` from `legacyWireSchemas.ts`) */ export const ClientRequestSchema = z.union([ - PingRequestSchema, - InitializeRequestSchema, + DiscoverRequestSchema, CompleteRequestSchema, - SetLevelRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, - SubscribeRequestSchema, - UnsubscribeRequestSchema, CallToolRequestSchema, ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema + SubscriptionsListenRequestSchema ]); -export const ClientNotificationSchema = z.union([ - CancelledNotificationSchema, - ProgressNotificationSchema, - InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema -]); +export const ClientNotificationSchema = z.union([CancelledNotificationSchema, ProgressNotificationSchema]); export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListRootsResultSchema ]); -/* Server messages */ -export const ServerRequestSchema = z.union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); +/* Server messages. Under 2026 stateless these three are not sent as RPCs; + * they appear as InputRequest payloads inside InputRequiredResult. The + * schemas are still needed for legacy server→client RPCs and for MRTR + * payload validation. */ +export const ServerRequestSchema = z.union([CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); export const ServerNotificationSchema = z.union([ CancelledNotificationSchema, @@ -2135,13 +1898,14 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, - ElicitationCompleteNotificationSchema + ElicitationCompleteNotificationSchema, + SubscriptionsAcknowledgedNotificationSchema ]); export const ServerResultSchema = z.union([ EmptyResultSchema, - InitializeResultSchema, + DiscoverResultSchema, + InputRequiredResultSchema, CompleteResultSchema, GetPromptResultSchema, ListPromptsResultSchema, @@ -2149,34 +1913,26 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); -/* Runtime schema lookup — result schemas by method */ +/* Runtime schema lookup — result schemas by method. + * 2026 spec methods are seeded here; pre-2026 methods are merged in by + * `legacyWireSchemas.ts` at module load via `registerLegacySchemas()`. */ const resultSchemas: Record = { - ping: EmptyResultSchema, - initialize: InitializeResultSchema, + 'server/discover': DiscoverResultSchema, 'completion/complete': CompleteResultSchema, - 'logging/setLevel': EmptyResultSchema, 'prompts/get': GetPromptResultSchema, 'prompts/list': ListPromptsResultSchema, 'resources/list': ListResourcesResultSchema, 'resources/templates/list': ListResourceTemplatesResultSchema, '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 + 'subscriptions/listen': EmptyResultSchema, + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema }; /** @@ -2191,9 +1947,6 @@ export function getResultSchema(method: string): z.ZodType | undefined { } /* Runtime schema lookup — request schemas by method */ -type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; -type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; - function buildSchemaMap(schemas: readonly T[]): Record { const map: Record = {}; for (const schema of schemas) { @@ -2204,14 +1957,30 @@ function buildSchemaMap(sche } const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< - RequestMethod, - RequestSchemaType + string, + z.core.$ZodType >; const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< - NotificationMethod, - NotificationSchemaType + string, + z.core.$ZodType >; +/** + * Called by `legacyWireSchemas.ts` at module load to merge pre-2026 method + * schemas into the runtime lookup maps. Keeps `schemas.ts` 2026-only at the + * source level while preserving legacy `getRequestSchema('initialize')` etc. + * @internal + */ +export function registerLegacySchemas(legacy: { + requests: Record; + results: Record; + notifications: Record; +}): void { + Object.assign(requestSchemas, legacy.requests); + Object.assign(resultSchemas, legacy.results); + Object.assign(notificationSchemas, legacy.notifications); +} + /** * Gets the Zod schema for a given request method. * Returns `undefined` for non-spec methods. diff --git a/packages/core/src/types/spec.types.ts b/packages/core/src/types/spec.types.ts index a03f21f134..389e279e6b 100644 --- a/packages/core/src/types/spec.types.ts +++ b/packages/core/src/types/spec.types.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 5c25208be86db5033f644a4e0d005e08f699ef3d + * Last updated from commit: 142b3c3cffcd10012e3dc1b07db5818877e64f9b * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types @@ -71,6 +71,38 @@ export interface RequestMetaObject extends MetaObject { * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotification | 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?: ProgressToken; + /** + * The MCP Protocol Version being used for this request. Required. + * + * For the HTTP transport, this value MUST match the `MCP-Protocol-Version` + * header; otherwise the server MUST return a `400 Bad Request`. If the + * server does not support the requested version, it MUST return an + * {@link UnsupportedProtocolVersionError}. + */ + 'io.modelcontextprotocol/protocolVersion': string; + /** + * Identifies the client software making the request. Required. + * + * The {@link Implementation} schema requires `name` and `version`; other + * fields are optional. + */ + 'io.modelcontextprotocol/clientInfo': Implementation; + /** + * The client's capabilities for this specific request. Required. + * + * Capabilities are declared per-request rather than once at initialization; + * an empty object means the client supports no optional capabilities. + * Servers MUST NOT infer capabilities from prior requests. + */ + 'io.modelcontextprotocol/clientCapabilities': ClientCapabilities; + /** + * The desired log level for this request. Optional. + * + * If absent, the server MUST NOT send any {@link LoggingMessageNotification | notifications/message} + * notifications for this request. The client opts in to log messages by + * explicitly setting a level. Replaces the former `logging/setLevel` RPC. + */ + 'io.modelcontextprotocol/logLevel'?: LoggingLevel; } /** @@ -87,30 +119,13 @@ export type ProgressToken = string | number; */ export type Cursor = string; -/** - * Common params for any task-augmented request. - * - * @internal - */ -export interface TaskAugmentedRequestParams extends RequestParams { - /** - * If specified, the caller is requesting task-augmented execution for this request. - * The request will return a {@link CreateTaskResult} immediately, and the actual result can be - * retrieved later via {@link GetTaskPayloadRequest | tasks/result}. - * - * Task augmentation is subject to capability negotiation - receivers MUST declare support - * for task augmentation of specific request types in their capabilities. - */ - task?: TaskMetadata; -} - /** * Common params for any request. * * @category Common Types */ export interface RequestParams { - _meta?: RequestMetaObject; + _meta: RequestMetaObject; } /** @internal */ @@ -138,6 +153,16 @@ export interface Notification { params?: { [key: string]: any }; } +/** + * Indicates the type of a {@link Result} object, allowing the client to + * determine how to parse the response. + * + * complete - the request completed successfully and the result contains the final content. + * input_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request. + * @category Common Types + */ +export type ResultType = 'complete' | 'input_required'; + /** * Common result fields. * @@ -145,6 +170,16 @@ export interface Notification { */ export interface Result { _meta?: MetaObject; + /** + * Indicates the type of the result, which allows the client to determine + * how to parse the result object. + * + * Servers implementing this protocol version MUST include this field. + * For backward compatibility, when a client receives a result from a + * server implementing an earlier protocol version (which does not include + * `resultType`), the client MUST treat the absent field as `"complete"`. + */ + resultType: ResultType; [key: string]: unknown; } @@ -281,7 +316,6 @@ export interface MethodNotFoundError extends Error { * - **Prompts**: Unknown prompt name or missing required arguments * - **Pagination**: Invalid or expired cursor values * - **Logging**: Invalid log level - * - **Tasks**: Invalid or nonexistent task ID, invalid cursor, or attempting to cancel a task already in a terminal status * - **Elicitation**: Server requests an elicitation mode not declared in client capabilities * - **Sampling**: Missing tool result or tool results mixed with other content * @@ -319,24 +353,60 @@ export interface InternalError extends Error { code: typeof INTERNAL_ERROR; } -// Implementation-specific JSON-RPC error codes [-32000, -32099] -/** @internal */ -export const URL_ELICITATION_REQUIRED = -32042; +/** + * Error code returned when a server requires a client capability that was + * not declared in the request's `clientCapabilities`. + * + * @category Errors + */ +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; /** - * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * Returned when the request's protocol version is unknown to the server or + * unsupported (e.g., a known experimental or draft version the server has + * chosen not to implement). For HTTP, the response status code MUST be + * `400 Bad Request`. * - * @example Authorization required - * {@includeCode ./examples/URLElicitationRequiredError/authorization-required.json} + * @example Unsupported protocol version + * {@includeCode ./examples/UnsupportedProtocolVersionError/unsupported-version.json} * - * @internal + * @category Errors */ -export interface URLElicitationRequiredError extends Omit { +export interface UnsupportedProtocolVersionError extends Omit { error: Error & { - code: typeof URL_ELICITATION_REQUIRED; + code: typeof INVALID_PARAMS; data: { - elicitations: ElicitRequestURLParams[]; - [key: string]: unknown; + /** + * Protocol versions the server supports. The client should choose a + * mutually supported version from this list and retry. + */ + supported: string[]; + /** + * The protocol version that was requested by the client. + */ + requested: string; + }; + }; +} + +/** + * Returned when processing a request requires a capability the client did not + * declare in `clientCapabilities`. For HTTP, the response status code MUST be + * `400 Bad Request`. + * + * @example Missing elicitation capability + * {@includeCode ./examples/MissingRequiredClientCapabilityError/missing-elicitation-capability.json} + * + * @category Errors + */ +export interface MissingRequiredClientCapabilityError extends Omit { + error: Error & { + code: typeof MISSING_REQUIRED_CLIENT_CAPABILITY; + data: { + /** + * The capabilities the server requires from the client to process this request. + */ + requiredCapabilities: ClientCapabilities; }; }; } @@ -349,6 +419,79 @@ export interface URLElicitationRequiredError extends Omit = 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; @@ -215,41 +225,29 @@ export type BaseMetadata = Infer; export type Annotations = Infer; export type Role = Infer; -/* Initialization */ +/* Capabilities & implementation */ export type Implementation = Infer; export type ClientCapabilities = Infer; -export type InitializeRequestParams = Infer; -export type InitializeRequest = Infer; export type ServerCapabilities = Infer; -export type InitializeResult = Infer; -export type InitializedNotification = Infer; -/* Ping */ -export type PingRequest = Infer; +/* Discover (2026-06 negotiation) */ +export type DiscoverRequest = Infer; +export type DiscoverResult = Infer; +export type UnsupportedProtocolVersionErrorData = Infer; +export type MissingRequiredClientCapabilityErrorData = Infer; + +/* Result type discriminator */ +export type ResultType = Infer; + +/* Caching (SEP-2243) */ +export type CacheScope = Infer; +export type CacheableResult = Infer; /* Progress notifications */ 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; @@ -271,13 +269,16 @@ export type ReadResourceRequestParams = Infer; export type ReadResourceResult = Infer; export type ResourceListChangedNotification = Infer; -export type SubscribeRequestParams = Infer; -export type SubscribeRequest = Infer; -export type UnsubscribeRequestParams = Infer; -export type UnsubscribeRequest = Infer; export type ResourceUpdatedNotificationParams = Infer; export type ResourceUpdatedNotification = Infer; +/* Subscriptions (2026-06) */ +export type SubscriptionFilter = Infer; +export type SubscriptionsListenRequestParams = Infer; +export type SubscriptionsListenRequest = Infer; +export type SubscriptionsAcknowledgedNotificationParams = Infer; +export type SubscriptionsAcknowledgedNotification = Infer; + /* Prompts */ export type PromptArgument = Infer; export type Prompt = Infer; @@ -299,7 +300,6 @@ export type PromptListChangedNotification = Infer; -export type ToolExecution = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; @@ -311,8 +311,6 @@ export type ToolListChangedNotification = Infer; -export type SetLevelRequestParams = Infer; -export type SetLevelRequest = Infer; export type LoggingMessageNotificationParams = Infer; export type LoggingMessageNotification = Infer; @@ -360,7 +358,14 @@ export type CompleteResult = Infer; export type Root = Infer; export type ListRootsRequest = Infer; export type ListRootsResult = Infer; -export type RootsListChangedNotification = Infer; + +/* MRTR (input_required) */ +export type InputRequest = Infer; +export type InputRequests = Infer; +export type InputResponse = Infer; +export type InputResponses = Infer; +export type InputRequiredResult = Infer; +export type InputResponseRequestParams = Infer; /* Client messages */ export type ClientRequest = Infer; @@ -372,35 +377,46 @@ export type ServerRequest = Infer; export type ServerNotification = Infer; export type ServerResult = Infer; -/* Protocol type maps */ +/* Protocol type maps — merge 2026 spec methods with pre-2026 (legacy) methods so + * `setRequestHandler('initialize', ...)` etc. continue to typecheck via the + * spec-method-keyed overload while LegacyServer/LegacyClient exist. + */ type MethodToTypeMap = { [T in U as T extends { method: infer M extends string } ? M : never]: T; }; -export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; -export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; -export type RequestTypeMap = MethodToTypeMap; -export type NotificationTypeMap = MethodToTypeMap; +export type RequestMethod = ClientRequest['method'] | ServerRequest['method'] | legacy.LegacyRequestMethod; +export type NotificationMethod = ClientNotification['method'] | ServerNotification['method'] | legacy.LegacyNotificationMethod; +export type RequestTypeMap = MethodToTypeMap & { + initialize: legacy.InitializeRequest; + ping: legacy.PingRequest; + 'logging/setLevel': legacy.SetLevelRequest; + 'resources/subscribe': legacy.SubscribeRequest; + 'resources/unsubscribe': legacy.UnsubscribeRequest; +}; +export type NotificationTypeMap = MethodToTypeMap & { + 'notifications/initialized': legacy.InitializedNotification; + 'notifications/roots/list_changed': legacy.RootsListChangedNotification; +}; export type ResultTypeMap = { - ping: EmptyResult; - initialize: InitializeResult; + 'server/discover': DiscoverResult; 'completion/complete': CompleteResult; - 'logging/setLevel': EmptyResult; 'prompts/get': GetPromptResult; 'prompts/list': ListPromptsResult; 'resources/list': ListResourcesResult; 'resources/templates/list': ListResourceTemplatesResult; '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; + 'subscriptions/listen': EmptyResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; + // Pre-2026 methods (LegacyServer/LegacyClient): + initialize: legacy.InitializeResult; + ping: EmptyResult; + 'logging/setLevel': EmptyResult; + 'resources/subscribe': EmptyResult; + 'resources/unsubscribe': EmptyResult; }; /** 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..ca98667197 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]> { @@ -81,20 +79,20 @@ describe('Protocol custom-method support', () => { expect(() => p.setRequestHandler('acme/unknown' as never, () => ({}) as never)).toThrow(TypeError); }); - it('routes both 2-arg and 3-arg registration through _wrapHandler', () => { + it('runs Dispatcher middleware for both 2-arg and 3-arg registration', async () => { + const [a, b] = await pair(); const seen: string[] = []; - class SpyProtocol extends TestProtocol { - protected override _wrapHandler( - method: string, - handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise - ): (request: JSONRPCRequest, ctx: BaseContext) => Promise { - seen.push(method); - return handler; + // dispatcher is protected; reach it via prototype access for test purposes. + (b as unknown as { dispatcher: { use: (mw: unknown) => void } }).dispatcher.use( + async (request: JSONRPCRequest, _ctx: BaseContext, next: () => Promise) => { + seen.push(request.method); + return next(); } - } - const p = new SpyProtocol(); - p.setRequestHandler('tools/list', () => ({ tools: [] })); - p.setRequestHandler('acme/custom', { params: z.object({}) }, () => ({})); + ); + b.setRequestHandler('tools/list', () => ({ tools: [] })); + b.setRequestHandler('acme/custom', { params: z.object({}) }, () => ({})); + await a.request({ method: 'tools/list' }); + await a.request({ method: 'acme/custom' }, z.unknown()); expect(seen).toContain('tools/list'); expect(seen).toContain('acme/custom'); }); diff --git a/packages/core/test/shared/dispatcher.test.ts b/packages/core/test/shared/dispatcher.test.ts new file mode 100644 index 0000000000..73bfacdf15 --- /dev/null +++ b/packages/core/test/shared/dispatcher.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; + +import { Dispatcher, errorResponse, type Middleware, okResponse } from '../../src/shared/dispatcher.js'; +import { ProtocolErrorCode } from '../../src/types/enums.js'; +import { ProtocolError } from '../../src/types/errors.js'; +import type { JSONRPCRequest, Result } from '../../src/types/index.js'; + +type Ctx = { tag: string }; + +function req(method: string, id = 1): JSONRPCRequest { + return { jsonrpc: '2.0', id, method }; +} + +// Test helper: register a raw handler directly (bypasses schema-wrap). +// Dispatcher's public registration is setRequestHandler (schema-wrapped); these +// unit tests target dispatch/middleware mechanics, not the schema layer. +function setRaw(d: Dispatcher, method: string, handler: (r: JSONRPCRequest, ctx: C) => Promise): void { + (d as unknown as { _handlers: Map })._handlers.set(method, handler); +} + +describe('Dispatcher', () => { + it('dispatches to a registered handler and wraps the result', async () => { + const d = new Dispatcher(); + setRaw(d, 'foo', async (r, ctx) => ({ value: `${ctx.tag}:${r.method}` })); + const res = await d.dispatch(req('foo'), { tag: 't' }); + expect(res).toEqual(okResponse(1, { value: 't:foo' })); + }); + + it('returns MethodNotFound when no handler matches', async () => { + const d = new Dispatcher(); + const res = await d.dispatch(req('nope'), { tag: 't' }); + expect(res).toEqual(errorResponse(1, ProtocolErrorCode.MethodNotFound, 'Method not found')); + }); + + it('falls back to fallbackHandler when set', async () => { + const d = new Dispatcher(); + d.fallbackHandler = async r => ({ fallback: r.method }); + const res = await d.dispatch(req('nope'), { tag: 't' }); + expect(res).toEqual(okResponse(1, { fallback: 'nope' })); + }); + + it('assertCanSetRequestHandler reflects registration only (not fallback)', () => { + const d = new Dispatcher(); + d.fallbackHandler = async () => ({}); + expect(() => d.assertCanSetRequestHandler('foo')).not.toThrow(); + setRaw(d, 'foo', async () => ({})); + expect(() => d.assertCanSetRequestHandler('foo')).toThrow(); + d.removeRequestHandler('foo'); + expect(() => d.assertCanSetRequestHandler('foo')).not.toThrow(); + }); + + it('fallbackHandler bypasses middleware (preserves Protocol._onrequest behavior)', async () => { + const d = new Dispatcher(); + let mwRan = false; + d.use(async (_r, _c, next) => { + mwRan = true; + return next(); + }); + d.fallbackHandler = async r => ({ fallback: r.method }); + const res = await d.dispatch(req('nope'), { tag: 't' }); + expect(res).toEqual(okResponse(1, { fallback: 'nope' })); + expect(mwRan).toBe(false); + }); + + it('surfaces ProtocolError code/message/data', async () => { + const d = new Dispatcher(); + setRaw(d, 'foo', async () => { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad', { detail: 1 }); + }); + const res = await d.dispatch(req('foo'), { tag: 't' }); + expect(res).toEqual(errorResponse(1, ProtocolErrorCode.InvalidParams, 'bad', { detail: 1 })); + }); + + it('preserves thrown error message and numeric code (matches Protocol._onrequest behavior)', async () => { + const d = new Dispatcher(); + setRaw(d, 'foo', async () => { + throw new Error('handler error message'); + }); + const res = await d.dispatch(req('foo'), { tag: 't' }); + expect(res).toEqual(errorResponse(1, ProtocolErrorCode.InternalError, 'handler error message')); + + setRaw(d, 'coded', async () => { + throw Object.assign(new Error('coded message'), { code: -31999, data: { x: 1 } }); + }); + const res2 = await d.dispatch(req('coded'), { tag: 't' }); + expect(res2).toEqual(errorResponse(1, -31999, 'coded message', { x: 1 })); + }); + + it('runs middleware in registration order around the handler', async () => { + const d = new Dispatcher(); + const order: string[] = []; + const mk = + (name: string): Middleware => + async (_r, _c, next) => { + order.push(`${name}:pre`); + const result = await next(); + order.push(`${name}:post`); + return result; + }; + d.use(mk('a')); + d.use(mk('b')); + setRaw(d, 'foo', async () => { + order.push('handler'); + return {}; + }); + await d.dispatch(req('foo'), { tag: 't' }); + expect(order).toEqual(['a:pre', 'b:pre', 'handler', 'b:post', 'a:post']); + }); + + it('lets middleware short-circuit without calling next', async () => { + const d = new Dispatcher(); + let handlerRan = false; + d.use(async () => ({ short: true })); + setRaw(d, 'foo', async () => { + handlerRan = true; + return {}; + }); + const res = await d.dispatch(req('foo'), { tag: 't' }); + expect(res).toEqual(okResponse(1, { short: true })); + expect(handlerRan).toBe(false); + }); + + it('lets middleware transform a thrown error into a result', async () => { + const d = new Dispatcher(); + d.use(async (_r, _c, next) => { + try { + return await next(); + } catch { + return { recovered: true }; + } + }); + setRaw(d, 'foo', async () => { + throw new Error('boom'); + }); + const res = await d.dispatch(req('foo'), { tag: 't' }); + expect(res).toEqual(okResponse(1, { recovered: true })); + }); + + it('does not run middleware when no handler matches', async () => { + const d = new Dispatcher(); + let ran = false; + d.use(async (_r, _c, next) => { + ran = true; + return next(); + }); + await d.dispatch(req('nope'), { tag: 't' }); + expect(ran).toBe(false); + }); + + it('supports concurrent dispatch on a shared instance', async () => { + const d = new Dispatcher(); + setRaw(d, 'foo', async (_r, ctx) => { + await new Promise(r => setTimeout(r, 5)); + return { tag: ctx.tag }; + }); + const results = await Promise.all([ + d.dispatch(req('foo', 1), { tag: 'a' }), + d.dispatch(req('foo', 2), { tag: 'b' }), + d.dispatch(req('foo', 3), { tag: 'c' }) + ]); + expect(results.map(r => 'result' in r && (r.result as Result & { tag: string }).tag)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376a..764dcc401e 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. @@ -469,6 +355,33 @@ describe('protocol tests', () => { }); }); + describe('notifications/cancelled behavior', () => { + test('should abort request handler when notifications/cancelled is received', async () => { + await protocol.connect(transport); + + let wasAborted = false; + protocol.setRequestHandler('ping', async (_request, ctx) => { + await new Promise(resolve => setTimeout(resolve, 100)); + wasAborted = ctx.mcpReq.signal.aborted; + return {}; + }); + + const requestId = 123; + transport.onmessage?.({ jsonrpc: '2.0', id: requestId, method: 'ping', params: {} }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId, reason: 'User cancelled' } + }); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(wasAborted).toBe(true); + }); + }); + describe('progress notification timeout behavior', () => { beforeEach(() => { vi.useFakeTimers(); @@ -887,97 +800,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 +890,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 deleted file mode 100644 index 6a6e33fb09..0000000000 --- a/packages/core/test/shared/wrapHandler.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { Protocol } from '../../src/shared/protocol.js'; -import type { BaseContext, JSONRPCRequest, Result } from '../../src/exports/public/index.js'; - -class TestProtocol extends Protocol { - protected buildContext(ctx: BaseContext): BaseContext { - return ctx; - } - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} -} - -describe('Protocol._wrapHandler', () => { - it('routes setRequestHandler registration through _wrapHandler', () => { - const seen: string[] = []; - class SpyProtocol extends TestProtocol { - protected override _wrapHandler( - method: string, - handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise - ): (request: JSONRPCRequest, ctx: BaseContext) => Promise { - seen.push(method); - return handler; - } - } - const p = new SpyProtocol(); - seen.length = 0; - p.setRequestHandler('tools/list', () => ({ tools: [] })); - p.setRequestHandler('resources/list', () => ({ resources: [] })); - expect(seen).toEqual(['tools/list', 'resources/list']); - }); -}); diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts index d26a4cd701..671341877d 100644 --- a/packages/core/test/spec.types.test.ts +++ b/packages/core/test/spec.types.test.ts @@ -1,9 +1,14 @@ /** * This contains: * - Static type checks to verify the Spec's types are compatible with the SDK's types - * (mutually assignable — no type-level workarounds should be needed) * - Runtime checks to verify each Spec type has a static check * (note: a few don't have SDK types, see MISSING_SDK_TYPES below) + * + * Compatibility direction: the 2026-06 spec marks several fields REQUIRED that the + * SDK's zod schemas keep OPTIONAL for backward compatibility (`_meta` namespaced + * keys, `resultType`, `ttlMs`/`cacheScope`). The `Relax` helper below makes + * those fields optional in the spec type so mutual assignability still holds for + * everything ELSE. Any drift outside those known-permissive keys is a real bug. */ import fs from 'node:fs'; import path from 'node:path'; @@ -19,747 +24,708 @@ type WithJSONRPC = T & { jsonrpc: '2.0' }; // Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. type WithJSONRPCRequest = T & { jsonrpc: '2.0'; id: SDKTypes.RequestId }; +// The SDK deliberately keeps these spec-required fields optional for BC: +// `_meta` (and the required `io.modelcontextprotocol/*` keys within it), +// `resultType`, `ttlMs`/`cacheScope`. +// `Relax` makes those optional in the spec type so mutual assignability +// holds for everything ELSE; drift outside these keys is a real bug. +type PermissiveKey = '_meta' | 'resultType' | 'ttlMs' | 'cacheScope' | `io.modelcontextprotocol/${string}`; + +type Prim = string | number | boolean | bigint | symbol | null | undefined; +type Relax = T extends Prim + ? T + : T extends ReadonlyArray + ? Array> + : T extends object + ? { -readonly [K in keyof T as K extends PermissiveKey ? never : K]: Relax } & { + -readonly [K in keyof T as K extends PermissiveKey ? K : never]+?: Relax; + } + : T; + // The spec defines typed *ResultResponse interfaces (e.g. InitializeResultResponse) that pair a // JSONRPCResultResponse envelope with a specific result type. The SDK doesn't export these because // nothing in the SDK needs the combined type — Protocol._onresponse() unwraps the envelope and // validates the inner result separately. We define this locally to verify the composition still // type-checks against the spec without polluting the SDK's public API. type TypedResultResponse = SDKTypes.JSONRPCResultResponse & { result: R }; +// `tools/call`, `prompts/get`, `resources/read` may return `input_required` per spec. +type WithInputRequired = R | SDKTypes.InputRequiredResult; const sdkTypeChecks = { - RequestParams: (sdk: SDKTypes.RequestParams, spec: SpecTypes.RequestParams) => { - sdk = spec; - spec = sdk; - }, - NotificationParams: (sdk: SDKTypes.NotificationParams, spec: SpecTypes.NotificationParams) => { - sdk = spec; - spec = sdk; - }, - CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { - sdk = spec; - spec = sdk; - }, - InitializeRequestParams: (sdk: SDKTypes.InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { + RequestParams: (sdk: SDKTypes.RequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - ProgressNotificationParams: (sdk: SDKTypes.ProgressNotificationParams, spec: SpecTypes.ProgressNotificationParams) => { + NotificationParams: (sdk: SDKTypes.NotificationParams, spec: Relax) => { sdk = spec; spec = sdk; }, - ResourceRequestParams: (sdk: SDKTypes.ResourceRequestParams, spec: SpecTypes.ResourceRequestParams) => { + CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: Relax) => { sdk = spec; spec = sdk; }, - ReadResourceRequestParams: (sdk: SDKTypes.ReadResourceRequestParams, spec: SpecTypes.ReadResourceRequestParams) => { + ProgressNotificationParams: (sdk: SDKTypes.ProgressNotificationParams, spec: Relax) => { sdk = spec; spec = sdk; }, - SubscribeRequestParams: (sdk: SDKTypes.SubscribeRequestParams, spec: SpecTypes.SubscribeRequestParams) => { + ResourceRequestParams: (sdk: SDKTypes.ResourceRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - UnsubscribeRequestParams: (sdk: SDKTypes.UnsubscribeRequestParams, spec: SpecTypes.UnsubscribeRequestParams) => { + ReadResourceRequestParams: (sdk: SDKTypes.ReadResourceRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, ResourceUpdatedNotificationParams: ( sdk: SDKTypes.ResourceUpdatedNotificationParams, - spec: SpecTypes.ResourceUpdatedNotificationParams + spec: Relax ) => { sdk = spec; spec = sdk; }, - GetPromptRequestParams: (sdk: SDKTypes.GetPromptRequestParams, spec: SpecTypes.GetPromptRequestParams) => { + GetPromptRequestParams: (sdk: SDKTypes.GetPromptRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - CallToolRequestParams: (sdk: SDKTypes.CallToolRequestParams, spec: SpecTypes.CallToolRequestParams) => { - sdk = spec; - spec = sdk; - }, - SetLevelRequestParams: (sdk: SDKTypes.SetLevelRequestParams, spec: SpecTypes.SetLevelRequestParams) => { + CallToolRequestParams: (sdk: SDKTypes.CallToolRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, LoggingMessageNotificationParams: ( sdk: SDKTypes.LoggingMessageNotificationParams, - spec: SpecTypes.LoggingMessageNotificationParams + spec: Relax ) => { sdk = spec; spec = sdk; }, - CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { + CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - ElicitRequestFormParams: (sdk: SDKTypes.ElicitRequestFormParams, spec: SpecTypes.ElicitRequestFormParams) => { + ElicitRequestFormParams: (sdk: SDKTypes.ElicitRequestFormParams, spec: Relax) => { sdk = spec; spec = sdk; }, - ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: Relax) => { sdk = spec; spec = sdk; }, ElicitationCompleteNotification: ( sdk: WithJSONRPC, - spec: SpecTypes.ElicitationCompleteNotification + spec: Relax ) => { sdk = spec; spec = sdk; }, - PaginatedRequestParams: (sdk: SDKTypes.PaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { + PaginatedRequestParams: (sdk: SDKTypes.PaginatedRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { + CancelledNotification: (sdk: WithJSONRPC, spec: Relax) => { sdk = spec; spec = sdk; }, - BaseMetadata: (sdk: SDKTypes.BaseMetadata, spec: SpecTypes.BaseMetadata) => { + BaseMetadata: (sdk: SDKTypes.BaseMetadata, spec: Relax) => { sdk = spec; spec = sdk; }, - Implementation: (sdk: SDKTypes.Implementation, spec: SpecTypes.Implementation) => { + Implementation: (sdk: SDKTypes.Implementation, spec: Relax) => { sdk = spec; spec = sdk; }, - ProgressNotification: (sdk: WithJSONRPC, spec: SpecTypes.ProgressNotification) => { + ProgressNotification: (sdk: WithJSONRPC, spec: Relax) => { sdk = spec; spec = sdk; }, - SubscribeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SubscribeRequest) => { + PaginatedRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - UnsubscribeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.UnsubscribeRequest) => { + PaginatedResult: (sdk: SDKTypes.PaginatedResult, spec: Relax) => { sdk = spec; spec = sdk; }, - PaginatedRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.PaginatedRequest) => { + ListRootsRequest: (sdk: WithJSONRPCRequest, spec: Relax>) => { sdk = spec; spec = sdk; }, - PaginatedResult: (sdk: SDKTypes.PaginatedResult, spec: SpecTypes.PaginatedResult) => { + ListRootsResult: (sdk: SDKTypes.ListRootsResult, spec: Relax) => { sdk = spec; spec = sdk; }, - ListRootsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListRootsRequest) => { + Root: (sdk: SDKTypes.Root, spec: Relax) => { sdk = spec; spec = sdk; }, - ListRootsResult: (sdk: SDKTypes.ListRootsResult, spec: SpecTypes.ListRootsResult) => { + ElicitRequest: (sdk: WithJSONRPCRequest, spec: Relax>) => { sdk = spec; spec = sdk; }, - Root: (sdk: SDKTypes.Root, spec: SpecTypes.Root) => { + ElicitResult: (sdk: SDKTypes.ElicitResult, spec: Relax) => { sdk = spec; spec = sdk; }, - ElicitRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ElicitRequest) => { + CompleteRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - ElicitResult: (sdk: SDKTypes.ElicitResult, spec: SpecTypes.ElicitResult) => { + CompleteResult: (sdk: SDKTypes.CompleteResult, spec: Relax) => { sdk = spec; spec = sdk; }, - CompleteRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CompleteRequest) => { + ProgressToken: (sdk: SDKTypes.ProgressToken, spec: Relax) => { sdk = spec; spec = sdk; }, - CompleteResult: (sdk: SDKTypes.CompleteResult, spec: SpecTypes.CompleteResult) => { + Cursor: (sdk: SDKTypes.Cursor, spec: Relax) => { sdk = spec; spec = sdk; }, - ProgressToken: (sdk: SDKTypes.ProgressToken, spec: SpecTypes.ProgressToken) => { + Request: (sdk: SDKTypes.Request, spec: Relax) => { sdk = spec; spec = sdk; }, - Cursor: (sdk: SDKTypes.Cursor, spec: SpecTypes.Cursor) => { + Result: (sdk: SDKTypes.Result, spec: Relax) => { sdk = spec; spec = sdk; }, - Request: (sdk: SDKTypes.Request, spec: SpecTypes.Request) => { + RequestId: (sdk: SDKTypes.RequestId, spec: Relax) => { sdk = spec; spec = sdk; }, - Result: (sdk: SDKTypes.Result, spec: SpecTypes.Result) => { + JSONRPCRequest: (sdk: SDKTypes.JSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - RequestId: (sdk: SDKTypes.RequestId, spec: SpecTypes.RequestId) => { + JSONRPCNotification: (sdk: SDKTypes.JSONRPCNotification, spec: Relax) => { sdk = spec; spec = sdk; }, - JSONRPCRequest: (sdk: SDKTypes.JSONRPCRequest, spec: SpecTypes.JSONRPCRequest) => { + JSONRPCResponse: (sdk: SDKTypes.JSONRPCResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - JSONRPCNotification: (sdk: SDKTypes.JSONRPCNotification, spec: SpecTypes.JSONRPCNotification) => { + EmptyResult: (sdk: SDKTypes.EmptyResult, spec: Relax) => { sdk = spec; spec = sdk; }, - JSONRPCResponse: (sdk: SDKTypes.JSONRPCResponse, spec: SpecTypes.JSONRPCResponse) => { + Notification: (sdk: SDKTypes.Notification, spec: Relax) => { sdk = spec; spec = sdk; }, - EmptyResult: (sdk: SDKTypes.EmptyResult, spec: SpecTypes.EmptyResult) => { + ClientResult: (sdk: SDKTypes.ClientResult, spec: Relax) => { sdk = spec; spec = sdk; }, - Notification: (sdk: SDKTypes.Notification, spec: SpecTypes.Notification) => { + ClientNotification: (sdk: WithJSONRPC, spec: Relax) => { sdk = spec; spec = sdk; }, - ClientResult: (sdk: SDKTypes.ClientResult, spec: SpecTypes.ClientResult) => { + ServerResult: (sdk: SDKTypes.ServerResult, spec: Relax) => { sdk = spec; spec = sdk; }, - ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + ResourceTemplateReference: (sdk: SDKTypes.ResourceTemplateReference, spec: Relax) => { sdk = spec; spec = sdk; }, - ServerResult: (sdk: SDKTypes.ServerResult, spec: SpecTypes.ServerResult) => { + PromptReference: (sdk: SDKTypes.PromptReference, spec: Relax) => { sdk = spec; spec = sdk; }, - ResourceTemplateReference: (sdk: SDKTypes.ResourceTemplateReference, spec: SpecTypes.ResourceTemplateReference) => { + ToolAnnotations: (sdk: SDKTypes.ToolAnnotations, spec: Relax) => { sdk = spec; spec = sdk; }, - PromptReference: (sdk: SDKTypes.PromptReference, spec: SpecTypes.PromptReference) => { + Tool: (sdk: SDKTypes.Tool, spec: Relax) => { sdk = spec; spec = sdk; }, - ToolAnnotations: (sdk: SDKTypes.ToolAnnotations, spec: SpecTypes.ToolAnnotations) => { + ListToolsRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - Tool: (sdk: SDKTypes.Tool, spec: SpecTypes.Tool) => { + ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: Relax) => { sdk = spec; spec = sdk; }, - ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { + CallToolResult: (sdk: SDKTypes.CallToolResult, spec: Relax) => { sdk = spec; spec = sdk; }, - ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: SpecTypes.ListToolsResult) => { + CallToolRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { - sdk = spec; - spec = sdk; - }, - CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { - sdk = spec; - spec = sdk; - }, - ToolListChangedNotification: (sdk: WithJSONRPC, spec: SpecTypes.ToolListChangedNotification) => { + ToolListChangedNotification: ( + sdk: WithJSONRPC, + spec: Relax + ) => { sdk = spec; spec = sdk; }, ResourceListChangedNotification: ( sdk: WithJSONRPC, - spec: SpecTypes.ResourceListChangedNotification + spec: Relax ) => { sdk = spec; spec = sdk; }, PromptListChangedNotification: ( sdk: WithJSONRPC, - spec: SpecTypes.PromptListChangedNotification + spec: Relax ) => { sdk = spec; spec = sdk; }, - RootsListChangedNotification: ( - sdk: WithJSONRPC, - spec: SpecTypes.RootsListChangedNotification + ResourceUpdatedNotification: ( + sdk: WithJSONRPC, + spec: Relax ) => { sdk = spec; spec = sdk; }, - ResourceUpdatedNotification: (sdk: WithJSONRPC, spec: SpecTypes.ResourceUpdatedNotification) => { - sdk = spec; - spec = sdk; - }, - SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { - sdk = spec; - spec = sdk; - }, - CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { - sdk = spec; - spec = sdk; - }, - SetLevelRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SetLevelRequest) => { + SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: Relax) => { sdk = spec; spec = sdk; }, - PingRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.PingRequest) => { + CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: Relax) => { sdk = spec; spec = sdk; }, - InitializedNotification: (sdk: WithJSONRPC, spec: SpecTypes.InitializedNotification) => { + ListResourcesRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - ListResourcesRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListResourcesRequest) => { - sdk = spec; - spec = sdk; - }, - ListResourcesResult: (sdk: SDKTypes.ListResourcesResult, spec: SpecTypes.ListResourcesResult) => { + ListResourcesResult: (sdk: SDKTypes.ListResourcesResult, spec: Relax) => { sdk = spec; spec = sdk; }, ListResourceTemplatesRequest: ( sdk: WithJSONRPCRequest, - spec: SpecTypes.ListResourceTemplatesRequest + spec: Relax ) => { sdk = spec; spec = sdk; }, - ListResourceTemplatesResult: (sdk: SDKTypes.ListResourceTemplatesResult, spec: SpecTypes.ListResourceTemplatesResult) => { - sdk = spec; - spec = sdk; - }, - ReadResourceRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ReadResourceRequest) => { - sdk = spec; - spec = sdk; - }, - ReadResourceResult: (sdk: SDKTypes.ReadResourceResult, spec: SpecTypes.ReadResourceResult) => { - sdk = spec; - spec = sdk; - }, - ResourceContents: (sdk: SDKTypes.ResourceContents, spec: SpecTypes.ResourceContents) => { + ListResourceTemplatesResult: (sdk: SDKTypes.ListResourceTemplatesResult, spec: Relax) => { sdk = spec; spec = sdk; }, - TextResourceContents: (sdk: SDKTypes.TextResourceContents, spec: SpecTypes.TextResourceContents) => { + ReadResourceRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - BlobResourceContents: (sdk: SDKTypes.BlobResourceContents, spec: SpecTypes.BlobResourceContents) => { + ReadResourceResult: (sdk: SDKTypes.ReadResourceResult, spec: Relax) => { sdk = spec; spec = sdk; }, - Resource: (sdk: SDKTypes.Resource, spec: SpecTypes.Resource) => { + ResourceContents: (sdk: SDKTypes.ResourceContents, spec: Relax) => { sdk = spec; spec = sdk; }, - ResourceTemplate: (sdk: SDKTypes.ResourceTemplateType, spec: SpecTypes.ResourceTemplate) => { + TextResourceContents: (sdk: SDKTypes.TextResourceContents, spec: Relax) => { sdk = spec; spec = sdk; }, - PromptArgument: (sdk: SDKTypes.PromptArgument, spec: SpecTypes.PromptArgument) => { + BlobResourceContents: (sdk: SDKTypes.BlobResourceContents, spec: Relax) => { sdk = spec; spec = sdk; }, - Prompt: (sdk: SDKTypes.Prompt, spec: SpecTypes.Prompt) => { + Resource: (sdk: SDKTypes.Resource, spec: Relax) => { sdk = spec; spec = sdk; }, - ListPromptsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListPromptsRequest) => { + ResourceTemplate: (sdk: SDKTypes.ResourceTemplateType, spec: Relax) => { sdk = spec; spec = sdk; }, - ListPromptsResult: (sdk: SDKTypes.ListPromptsResult, spec: SpecTypes.ListPromptsResult) => { + PromptArgument: (sdk: SDKTypes.PromptArgument, spec: Relax) => { sdk = spec; spec = sdk; }, - GetPromptRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetPromptRequest) => { + Prompt: (sdk: SDKTypes.Prompt, spec: Relax) => { sdk = spec; spec = sdk; }, - TextContent: (sdk: SDKTypes.TextContent, spec: SpecTypes.TextContent) => { + ListPromptsRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - ImageContent: (sdk: SDKTypes.ImageContent, spec: SpecTypes.ImageContent) => { + ListPromptsResult: (sdk: SDKTypes.ListPromptsResult, spec: Relax) => { sdk = spec; spec = sdk; }, - AudioContent: (sdk: SDKTypes.AudioContent, spec: SpecTypes.AudioContent) => { + GetPromptRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - EmbeddedResource: (sdk: SDKTypes.EmbeddedResource, spec: SpecTypes.EmbeddedResource) => { + TextContent: (sdk: SDKTypes.TextContent, spec: Relax) => { sdk = spec; spec = sdk; }, - ResourceLink: (sdk: SDKTypes.ResourceLink, spec: SpecTypes.ResourceLink) => { + ImageContent: (sdk: SDKTypes.ImageContent, spec: Relax) => { sdk = spec; spec = sdk; }, - ContentBlock: (sdk: SDKTypes.ContentBlock, spec: SpecTypes.ContentBlock) => { + AudioContent: (sdk: SDKTypes.AudioContent, spec: Relax) => { sdk = spec; spec = sdk; }, - PromptMessage: (sdk: SDKTypes.PromptMessage, spec: SpecTypes.PromptMessage) => { + EmbeddedResource: (sdk: SDKTypes.EmbeddedResource, spec: Relax) => { sdk = spec; spec = sdk; }, - GetPromptResult: (sdk: SDKTypes.GetPromptResult, spec: SpecTypes.GetPromptResult) => { + ResourceLink: (sdk: SDKTypes.ResourceLink, spec: Relax) => { sdk = spec; spec = sdk; }, - BooleanSchema: (sdk: SDKTypes.BooleanSchema, spec: SpecTypes.BooleanSchema) => { + ContentBlock: (sdk: SDKTypes.ContentBlock, spec: Relax) => { sdk = spec; spec = sdk; }, - StringSchema: (sdk: SDKTypes.StringSchema, spec: SpecTypes.StringSchema) => { + PromptMessage: (sdk: SDKTypes.PromptMessage, spec: Relax) => { sdk = spec; spec = sdk; }, - NumberSchema: (sdk: SDKTypes.NumberSchema, spec: SpecTypes.NumberSchema) => { + GetPromptResult: (sdk: SDKTypes.GetPromptResult, spec: Relax) => { sdk = spec; spec = sdk; }, - EnumSchema: (sdk: SDKTypes.EnumSchema, spec: SpecTypes.EnumSchema) => { + BooleanSchema: (sdk: SDKTypes.BooleanSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - UntitledSingleSelectEnumSchema: (sdk: SDKTypes.UntitledSingleSelectEnumSchema, spec: SpecTypes.UntitledSingleSelectEnumSchema) => { + StringSchema: (sdk: SDKTypes.StringSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - TitledSingleSelectEnumSchema: (sdk: SDKTypes.TitledSingleSelectEnumSchema, spec: SpecTypes.TitledSingleSelectEnumSchema) => { + NumberSchema: (sdk: SDKTypes.NumberSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - SingleSelectEnumSchema: (sdk: SDKTypes.SingleSelectEnumSchema, spec: SpecTypes.SingleSelectEnumSchema) => { + EnumSchema: (sdk: SDKTypes.EnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - UntitledMultiSelectEnumSchema: (sdk: SDKTypes.UntitledMultiSelectEnumSchema, spec: SpecTypes.UntitledMultiSelectEnumSchema) => { - sdk = spec; - spec = sdk; - }, - TitledMultiSelectEnumSchema: (sdk: SDKTypes.TitledMultiSelectEnumSchema, spec: SpecTypes.TitledMultiSelectEnumSchema) => { - sdk = spec; - spec = sdk; - }, - MultiSelectEnumSchema: (sdk: SDKTypes.MultiSelectEnumSchema, spec: SpecTypes.MultiSelectEnumSchema) => { - sdk = spec; - spec = sdk; - }, - LegacyTitledEnumSchema: (sdk: SDKTypes.LegacyTitledEnumSchema, spec: SpecTypes.LegacyTitledEnumSchema) => { - sdk = spec; - spec = sdk; - }, - PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: SpecTypes.PrimitiveSchemaDefinition) => { - sdk = spec; - spec = sdk; - }, - JSONRPCErrorResponse: (sdk: SDKTypes.JSONRPCErrorResponse, spec: SpecTypes.JSONRPCErrorResponse) => { - sdk = spec; - spec = sdk; - }, - JSONRPCResultResponse: (sdk: SDKTypes.JSONRPCResultResponse, spec: SpecTypes.JSONRPCResultResponse) => { - sdk = spec; - spec = sdk; - }, - JSONRPCMessage: (sdk: SDKTypes.JSONRPCMessage, spec: SpecTypes.JSONRPCMessage) => { - sdk = spec; - spec = sdk; - }, - CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { - sdk = spec; - spec = sdk; - }, - InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { - sdk = spec; - spec = sdk; - }, - InitializeResult: (sdk: SDKTypes.InitializeResult, spec: SpecTypes.InitializeResult) => { - sdk = spec; - spec = sdk; - }, - ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { - sdk = spec; - spec = sdk; - }, - ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { + UntitledSingleSelectEnumSchema: ( + sdk: SDKTypes.UntitledSingleSelectEnumSchema, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + TitledSingleSelectEnumSchema: (sdk: SDKTypes.TitledSingleSelectEnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { + SingleSelectEnumSchema: (sdk: SDKTypes.SingleSelectEnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - LoggingMessageNotification: (sdk: WithJSONRPC, spec: SpecTypes.LoggingMessageNotification) => { + UntitledMultiSelectEnumSchema: (sdk: SDKTypes.UntitledMultiSelectEnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + TitledMultiSelectEnumSchema: (sdk: SDKTypes.TitledMultiSelectEnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - LoggingLevel: (sdk: SDKTypes.LoggingLevel, spec: SpecTypes.LoggingLevel) => { + MultiSelectEnumSchema: (sdk: SDKTypes.MultiSelectEnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - Icon: (sdk: SDKTypes.Icon, spec: SpecTypes.Icon) => { + LegacyTitledEnumSchema: (sdk: SDKTypes.LegacyTitledEnumSchema, spec: Relax) => { sdk = spec; spec = sdk; }, - Icons: (sdk: SDKTypes.Icons, spec: SpecTypes.Icons) => { + PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: Relax) => { sdk = spec; spec = sdk; }, - ModelHint: (sdk: SDKTypes.ModelHint, spec: SpecTypes.ModelHint) => { + JSONRPCErrorResponse: (sdk: SDKTypes.JSONRPCErrorResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - ModelPreferences: (sdk: SDKTypes.ModelPreferences, spec: SpecTypes.ModelPreferences) => { + JSONRPCResultResponse: (sdk: SDKTypes.JSONRPCResultResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - ToolChoice: (sdk: SDKTypes.ToolChoice, spec: SpecTypes.ToolChoice) => { + JSONRPCMessage: (sdk: SDKTypes.JSONRPCMessage, spec: Relax) => { sdk = spec; spec = sdk; }, - ToolUseContent: (sdk: SDKTypes.ToolUseContent, spec: SpecTypes.ToolUseContent) => { + CreateMessageRequest: ( + sdk: WithJSONRPCRequest, + spec: Relax> + ) => { sdk = spec; spec = sdk; }, - ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => { + ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: Relax) => { sdk = spec; spec = sdk; }, - SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { + ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: Relax) => { sdk = spec; spec = sdk; }, - Annotations: (sdk: SDKTypes.Annotations, spec: SpecTypes.Annotations) => { + ClientRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - Role: (sdk: SDKTypes.Role, spec: SpecTypes.Role) => { + LoggingMessageNotification: ( + sdk: WithJSONRPC, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - TaskAugmentedRequestParams: (sdk: SDKTypes.TaskAugmentedRequestParams, spec: SpecTypes.TaskAugmentedRequestParams) => { + ServerNotification: (sdk: WithJSONRPC, spec: Relax) => { sdk = spec; spec = sdk; }, - ToolExecution: (sdk: SDKTypes.ToolExecution, spec: SpecTypes.ToolExecution) => { + LoggingLevel: (sdk: SDKTypes.LoggingLevel, spec: Relax) => { sdk = spec; spec = sdk; }, - TaskStatus: (sdk: SDKTypes.TaskStatus, spec: SpecTypes.TaskStatus) => { + Icon: (sdk: SDKTypes.Icon, spec: Relax) => { sdk = spec; spec = sdk; }, - TaskMetadata: (sdk: SDKTypes.TaskMetadata, spec: SpecTypes.TaskMetadata) => { + Icons: (sdk: SDKTypes.Icons, spec: Relax) => { sdk = spec; spec = sdk; }, - RelatedTaskMetadata: (sdk: SDKTypes.RelatedTaskMetadata, spec: SpecTypes.RelatedTaskMetadata) => { + ModelHint: (sdk: SDKTypes.ModelHint, spec: Relax) => { sdk = spec; spec = sdk; }, - Task: (sdk: SDKTypes.Task, spec: SpecTypes.Task) => { + ModelPreferences: (sdk: SDKTypes.ModelPreferences, spec: Relax) => { sdk = spec; spec = sdk; }, - CreateTaskResult: (sdk: SDKTypes.CreateTaskResult, spec: SpecTypes.CreateTaskResult) => { + ToolChoice: (sdk: SDKTypes.ToolChoice, spec: Relax) => { sdk = spec; spec = sdk; }, - GetTaskResult: (sdk: SDKTypes.GetTaskResult, spec: SpecTypes.GetTaskResult) => { + ToolUseContent: (sdk: SDKTypes.ToolUseContent, spec: Relax) => { sdk = spec; spec = sdk; }, - GetTaskPayloadRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskPayloadRequest) => { + ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: Relax) => { sdk = spec; spec = sdk; }, - ListTasksRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListTasksRequest) => { + SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: Relax) => { sdk = spec; spec = sdk; }, - ListTasksResult: (sdk: SDKTypes.ListTasksResult, spec: SpecTypes.ListTasksResult) => { + Annotations: (sdk: SDKTypes.Annotations, spec: Relax) => { sdk = spec; spec = sdk; }, - CancelTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CancelTaskRequest) => { + Role: (sdk: SDKTypes.Role, spec: Relax) => { sdk = spec; spec = sdk; }, - CancelTaskResult: (sdk: SDKTypes.CancelTaskResult, spec: SpecTypes.CancelTaskResult) => { + + /* JSON primitives */ + JSONValue: (sdk: SDKTypes.JSONValue, spec: Relax) => { sdk = spec; spec = sdk; }, - GetTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskRequest) => { + JSONObject: (sdk: SDKTypes.JSONObject, spec: Relax) => { sdk = spec; spec = sdk; }, - GetTaskPayloadResult: (sdk: SDKTypes.GetTaskPayloadResult, spec: SpecTypes.GetTaskPayloadResult) => { + JSONArray: (sdk: SDKTypes.JSONArray, spec: Relax) => { sdk = spec; spec = sdk; }, - TaskStatusNotificationParams: (sdk: SDKTypes.TaskStatusNotificationParams, spec: SpecTypes.TaskStatusNotificationParams) => { + + /* Meta types */ + MetaObject: (sdk: SDKTypes.MetaObject, spec: Relax) => { sdk = spec; spec = sdk; }, - TaskStatusNotification: (sdk: WithJSONRPC, spec: SpecTypes.TaskStatusNotification) => { + RequestMetaObject: (sdk: SDKTypes.RequestMetaObject, spec: Relax) => { sdk = spec; spec = sdk; }, - /* JSON primitives */ - JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { + /* Error types */ + ParseError: (sdk: SDKTypes.ParseError, spec: Relax) => { sdk = spec; spec = sdk; }, - JSONObject: (sdk: SDKTypes.JSONObject, spec: SpecTypes.JSONObject) => { + InvalidRequestError: (sdk: SDKTypes.InvalidRequestError, spec: Relax) => { sdk = spec; spec = sdk; }, - JSONArray: (sdk: SDKTypes.JSONArray, spec: SpecTypes.JSONArray) => { + MethodNotFoundError: (sdk: SDKTypes.MethodNotFoundError, spec: Relax) => { sdk = spec; spec = sdk; }, - - /* Meta types */ - MetaObject: (sdk: SDKTypes.MetaObject, spec: SpecTypes.MetaObject) => { + InvalidParamsError: (sdk: SDKTypes.InvalidParamsError, spec: Relax) => { sdk = spec; spec = sdk; }, - RequestMetaObject: (sdk: SDKTypes.RequestMetaObject, spec: SpecTypes.RequestMetaObject) => { + InternalError: (sdk: SDKTypes.InternalError, spec: Relax) => { sdk = spec; spec = sdk; }, - /* Error types */ - ParseError: (sdk: SDKTypes.ParseError, spec: SpecTypes.ParseError) => { + /* ResultResponse types — see TypedResultResponse comment above */ + ListResourcesResultResponse: ( + sdk: TypedResultResponse, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - InvalidRequestError: (sdk: SDKTypes.InvalidRequestError, spec: SpecTypes.InvalidRequestError) => { + ListResourceTemplatesResultResponse: ( + sdk: TypedResultResponse, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - MethodNotFoundError: (sdk: SDKTypes.MethodNotFoundError, spec: SpecTypes.MethodNotFoundError) => { + ReadResourceResultResponse: ( + sdk: TypedResultResponse>, + spec: Relax + ) => { + // @ts-expect-error Relax<> drops the index signature from the InputRequiredResult union arm; inner ReadResourceResult/InputRequiredResult are checked separately above. sdk = spec; spec = sdk; }, - InvalidParamsError: (sdk: SDKTypes.InvalidParamsError, spec: SpecTypes.InvalidParamsError) => { + ListPromptsResultResponse: (sdk: TypedResultResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - InternalError: (sdk: SDKTypes.InternalError, spec: SpecTypes.InternalError) => { + GetPromptResultResponse: ( + sdk: TypedResultResponse>, + spec: Relax + ) => { + // @ts-expect-error see ReadResourceResultResponse note above sdk = spec; spec = sdk; }, - - /* ResultResponse types — see TypedResultResponse comment above */ - InitializeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.InitializeResultResponse) => { + ListToolsResultResponse: (sdk: TypedResultResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - PingResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.PingResultResponse) => { + CallToolResultResponse: ( + sdk: TypedResultResponse>, + spec: Relax + ) => { + // @ts-expect-error see ReadResourceResultResponse note above sdk = spec; spec = sdk; }, - ListResourcesResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListResourcesResultResponse) => { + CompleteResultResponse: (sdk: TypedResultResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - ListResourceTemplatesResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.ListResourceTemplatesResultResponse - ) => { + DiscoverResultResponse: (sdk: TypedResultResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - ReadResourceResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ReadResourceResultResponse) => { + /* 2026-06 additions */ + ResultType: (sdk: SDKTypes.ResultType, spec: SpecTypes.ResultType) => { sdk = spec; spec = sdk; }, - SubscribeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SubscribeResultResponse) => { + DiscoverRequest: (sdk: WithJSONRPCRequest, spec: Relax) => { sdk = spec; spec = sdk; }, - UnsubscribeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.UnsubscribeResultResponse) => { + DiscoverResult: (sdk: SDKTypes.DiscoverResult, spec: Relax) => { sdk = spec; spec = sdk; }, - ListPromptsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListPromptsResultResponse) => { + CacheableResult: (sdk: SDKTypes.CacheableResult, spec: Relax) => { sdk = spec; spec = sdk; }, - GetPromptResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetPromptResultResponse) => { + InputRequest: (sdk: WithJSONRPCRequest, spec: Relax>) => { sdk = spec; spec = sdk; }, - ListToolsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListToolsResultResponse) => { + InputResponse: (sdk: SDKTypes.InputResponse, spec: Relax) => { sdk = spec; spec = sdk; }, - CallToolResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CallToolResultResponse) => { + InputRequests: (sdk: SDKTypes.InputRequests, spec: Relax) => { sdk = spec; spec = sdk; }, - CreateTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CreateTaskResultResponse) => { + InputResponses: (sdk: SDKTypes.InputResponses, spec: Relax) => { sdk = spec; spec = sdk; }, - GetTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetTaskResultResponse) => { - sdk = spec; + InputRequiredResult: (sdk: SDKTypes.InputRequiredResult, spec: Relax) => { + // Relax<> makes `resultType` optional but SDK keeps it as the literal discriminator; one-direction. + void sdk; spec = sdk; }, - GetTaskPayloadResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.GetTaskPayloadResultResponse - ) => { + InputResponseRequestParams: (sdk: SDKTypes.InputResponseRequestParams, spec: Relax) => { sdk = spec; spec = sdk; }, - CancelTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CancelTaskResultResponse) => { + SubscriptionFilter: (sdk: SDKTypes.SubscriptionFilter, spec: Relax) => { sdk = spec; spec = sdk; }, - ListTasksResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListTasksResultResponse) => { + SubscriptionsListenRequestParams: ( + sdk: SDKTypes.SubscriptionsListenRequestParams, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - SetLevelResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SetLevelResultResponse) => { + SubscriptionsListenRequest: ( + sdk: WithJSONRPCRequest, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - CreateMessageResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.CreateMessageResultResponse + SubscriptionsAcknowledgedNotificationParams: ( + sdk: SDKTypes.SubscriptionsAcknowledgedNotificationParams, + spec: Relax ) => { sdk = spec; spec = sdk; }, - CompleteResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CompleteResultResponse) => { + SubscriptionsAcknowledgedNotification: ( + sdk: WithJSONRPC, + spec: Relax + ) => { sdk = spec; spec = sdk; }, - ListRootsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListRootsResultResponse) => { - sdk = spec; - spec = sdk; + UnsupportedProtocolVersionError: ( + sdk: SDKTypes.UnsupportedProtocolVersionErrorData, + spec: SpecTypes.UnsupportedProtocolVersionError + ) => { + sdk = spec.error.data; + void spec; }, - ElicitResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ElicitResultResponse) => { - sdk = spec; - spec = sdk; + MissingRequiredClientCapabilityError: ( + sdk: SDKTypes.MissingRequiredClientCapabilityErrorData, + spec: SpecTypes.MissingRequiredClientCapabilityError + ) => { + sdk = spec.error.data; + void spec; } }; @@ -783,8 +749,8 @@ type KnownKeys = keyof { type AssertExactKeys< A, B, - Extra extends PropertyKey = Exclude, KnownKeys>, - Missing extends PropertyKey = Exclude, KnownKeys> + Extra extends PropertyKey = Exclude, KnownKeys | PermissiveKey>, + Missing extends PropertyKey = Exclude, KnownKeys | PermissiveKey> > = [Extra, Missing] extends [never, never] ? true : { _brand: 'KeyMismatch'; extra: Extra; missing: Missing }; /** Constraint: T must resolve to `true`. */ @@ -801,7 +767,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) -- @@ -809,18 +775,14 @@ type Assert = T; type _K_RequestParams = Assert>; type _K_NotificationParams = Assert>; type _K_CancelledNotificationParams = Assert>; -type _K_InitializeRequestParams = Assert>; type _K_ProgressNotificationParams = Assert>; type _K_ResourceRequestParams = Assert>; type _K_ReadResourceRequestParams = Assert>; -type _K_SubscribeRequestParams = Assert>; -type _K_UnsubscribeRequestParams = Assert>; type _K_ResourceUpdatedNotificationParams = Assert< AssertExactKeys >; type _K_GetPromptRequestParams = Assert>; type _K_CallToolRequestParams = Assert>; -type _K_SetLevelRequestParams = Assert>; type _K_LoggingMessageNotificationParams = Assert< AssertExactKeys >; @@ -883,7 +845,6 @@ type _K_TitledMultiSelectEnumSchema = Assert>; type _K_JSONRPCErrorResponse = Assert>; type _K_JSONRPCResultResponse = Assert>; -type _K_InitializeResult = Assert>; type _K_ClientCapabilities = Assert>; type _K_ServerCapabilities = Assert>; type _K_SamplingMessage = Assert>; @@ -895,22 +856,8 @@ 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>; @@ -936,32 +883,25 @@ type _K_ResourceListChangedNotification = Assert< type _K_PromptListChangedNotification = Assert< AssertExactKeys, SpecTypes.PromptListChangedNotification> >; -type _K_RootsListChangedNotification = Assert< - AssertExactKeys, SpecTypes.RootsListChangedNotification> ->; type _K_ResourceUpdatedNotification = Assert< AssertExactKeys, SpecTypes.ResourceUpdatedNotification> >; 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 // wrap with WithJSONRPCRequest<> to add the missing fields before comparing keys. -type _K_SubscribeRequest = Assert, SpecTypes.SubscribeRequest>>; -type _K_UnsubscribeRequest = Assert, SpecTypes.UnsubscribeRequest>>; type _K_PaginatedRequest = Assert, SpecTypes.PaginatedRequest>>; -type _K_ListRootsRequest = Assert, SpecTypes.ListRootsRequest>>; -type _K_ElicitRequest = Assert, SpecTypes.ElicitRequest>>; +type _K_ListRootsRequest = Assert< + AssertExactKeys, WithJSONRPCRequest> +>; +type _K_ElicitRequest = Assert, WithJSONRPCRequest>>; type _K_CompleteRequest = Assert, SpecTypes.CompleteRequest>>; type _K_ListToolsRequest = Assert, SpecTypes.ListToolsRequest>>; type _K_CallToolRequest = Assert, SpecTypes.CallToolRequest>>; -type _K_SetLevelRequest = Assert, SpecTypes.SetLevelRequest>>; -type _K_PingRequest = Assert, SpecTypes.PingRequest>>; type _K_ListResourcesRequest = Assert, SpecTypes.ListResourcesRequest>>; type _K_ListResourceTemplatesRequest = Assert< AssertExactKeys, SpecTypes.ListResourceTemplatesRequest> @@ -969,24 +909,15 @@ type _K_ListResourceTemplatesRequest = Assert< type _K_ReadResourceRequest = Assert, SpecTypes.ReadResourceRequest>>; type _K_ListPromptsRequest = Assert, SpecTypes.ListPromptsRequest>>; type _K_GetPromptRequest = Assert, SpecTypes.GetPromptRequest>>; -type _K_CreateMessageRequest = Assert, SpecTypes.CreateMessageRequest>>; -type _K_InitializeRequest = Assert, SpecTypes.InitializeRequest>>; -type _K_GetTaskPayloadRequest = Assert< - AssertExactKeys, SpecTypes.GetTaskPayloadRequest> +type _K_CreateMessageRequest = Assert< + AssertExactKeys, WithJSONRPCRequest> >; -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 // with a specific result. We compare TypedResultResponse against the // spec's combined type. -type _K_InitializeResultResponse = Assert< - AssertExactKeys, SpecTypes.InitializeResultResponse> ->; -type _K_PingResultResponse = Assert, SpecTypes.PingResultResponse>>; type _K_ListResourcesResultResponse = Assert< AssertExactKeys, SpecTypes.ListResourcesResultResponse> >; @@ -996,32 +927,13 @@ type _K_ListResourceTemplatesResultResponse = Assert< type _K_ReadResourceResultResponse = Assert< AssertExactKeys, SpecTypes.ReadResourceResultResponse> >; -type _K_SubscribeResultResponse = Assert, SpecTypes.SubscribeResultResponse>>; -type _K_UnsubscribeResultResponse = Assert, SpecTypes.UnsubscribeResultResponse>>; type _K_ListPromptsResultResponse = Assert< AssertExactKeys, SpecTypes.ListPromptsResultResponse> >; 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> ->; type _K_CompleteResultResponse = Assert, SpecTypes.CompleteResultResponse>>; -type _K_ListRootsResultResponse = Assert, SpecTypes.ListRootsResultResponse>>; -type _K_ElicitResultResponse = Assert, SpecTypes.ElicitResultResponse>>; // -- Name mismatches (2) -- // SDK exports these under different names than the spec. @@ -1032,7 +944,7 @@ type _K_ResourceTemplate = Assert` index-signature interaction is resolved. + 'DiscoverRequest', + 'DiscoverResult', + 'DiscoverResultResponse', + 'CacheableResult', + 'InputRequiredResult', + 'InputResponseRequestParams', + 'SubscriptionFilter', + 'SubscriptionsListenRequestParams', + 'SubscriptionsListenRequest', + 'SubscriptionsAcknowledgedNotificationParams', + 'SubscriptionsAcknowledgedNotification' ]; // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) @@ -1086,7 +1020,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(176); + expect(specTypes).toHaveLength(150); }); it('should have up to date list of missing sdk types', () => { diff --git a/packages/core/test/types.capabilities.test.ts b/packages/core/test/types.capabilities.test.ts index 1f66184525..193487e329 100644 --- a/packages/core/test/types.capabilities.test.ts +++ b/packages/core/test/types.capabilities.test.ts @@ -1,4 +1,5 @@ -import { ClientCapabilitiesSchema, InitializeRequestParamsSchema } from '../src/types/index.js'; +import { ClientCapabilitiesSchema } from '../src/types/index.js'; +import { InitializeRequestParamsSchema } from '../src/types/legacyWireSchemas.js'; describe('ClientCapabilitiesSchema backwards compatibility', () => { describe('ElicitationCapabilitySchema preprocessing', () => { diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index 9383f7d5ec..aca029d8a5 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -478,7 +478,7 @@ describe('Types', () => { expect(result.success).toBe(false); }); - test('should still require type: object at root for outputSchema', () => { + test('outputSchema accepts any JSON Schema (spec @142b3c3c dropped the type:object constraint)', () => { const tool = { name: 'test', inputSchema: { type: 'object' }, @@ -487,7 +487,7 @@ describe('Types', () => { } }; const result = ToolSchema.safeParse(tool); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); test('should accept simple minimal schema (backward compatibility)', () => { 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..b3fb54813e 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, @@ -23,7 +20,6 @@ import type { StandardSchemaWithJSON, Tool, ToolAnnotations, - ToolExecution, Transport, Variables } from '@modelcontextprotocol/core'; @@ -41,8 +37,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 +66,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. * @@ -147,7 +124,6 @@ export class McpServer { ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, - execution: tool.execution, _meta: tool._meta }; @@ -160,7 +136,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 +146,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 +208,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 +235,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() { @@ -773,7 +677,6 @@ export class McpServer { inputSchema: StandardSchemaWithJSON | undefined, outputSchema: StandardSchemaWithJSON | undefined, annotations: ToolAnnotations | undefined, - execution: ToolExecution | undefined, _meta: Record | undefined, handler: AnyToolHandler ): RegisteredTool { @@ -789,7 +692,6 @@ export class McpServer { inputSchema, outputSchema, annotations, - execution, _meta, handler: handler, executor: createToolExecutor(inputSchema, handler), @@ -914,7 +816,6 @@ export class McpServer { normalizeRawShapeSchema(inputSchema), normalizeRawShapeSchema(outputSchema), annotations, - { taskSupport: 'forbidden' }, _meta, cb as ToolCallback ); @@ -1148,14 +1049,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; @@ -1163,7 +1064,6 @@ export type RegisteredTool = { inputSchema?: StandardSchemaWithJSON; outputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; - execution?: ToolExecution; _meta?: Record; handler: AnyToolHandler; /** @hidden */ @@ -1194,23 +1094,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 +1183,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..55d8a44b17 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -12,37 +12,31 @@ import type { Implementation, InitializeRequest, InitializeResult, - JSONRPCRequest, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, + Middleware, NotificationMethod, NotificationOptions, ProtocolOptions, RequestMethod, RequestOptions, ResourceUpdatedNotification, - 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 +50,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 +85,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,21 +98,12 @@ 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.dispatcher.use(Server._callToolResultMiddleware); this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); @@ -174,22 +148,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(); @@ -220,51 +178,26 @@ export class Server extends Protocol { /** * Enforces server-side validation for `tools/call` results regardless of how the - * handler was registered. + * handler was registered. Installed as a {@linkcode Dispatcher} middleware so + * it applies to both the legacy `_onrequest` path and the 2026-06 dispatch path. */ - protected override _wrapHandler( - method: string, - handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise - ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { - if (method !== 'tools/call') { - return handler; + private static readonly _callToolResultMiddleware: Middleware = async (request, _ctx, next) => { + if (request.method !== 'tools/call') { + return next(); } - return async (request, ctx) => { - const validatedRequest = parseSchema(CallToolRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - 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 = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); - } - - return validationResult.data; - }; - } + const validatedRequest = parseSchema(CallToolRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); + } + const result = await next(); + const validationResult = parseSchema(CallToolResultSchema, result); + if (!validationResult.success) { + const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); + } + return validationResult.data; + }; protected assertCapabilityForMethod(method: RequestMethod | string): void { switch (method) { @@ -410,14 +343,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); - }); -});