From 26ac4ba71115cf834b21c50c09b9c36638834835 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 15 May 2026 19:43:54 +0300 Subject: [PATCH 1/2] remove tasks --- .changeset/extract-task-manager.md | 10 - .../fix-failed-task-result-retrieval.md | 5 - .changeset/fix-task-session-isolation.md | 5 - CLAUDE.md | 3 - docs/migration-SKILL.md | 28 +- docs/migration.md | 51 +- .../plans/2026-06-30-remove-tasks.md | 826 +++ examples/client/src/simpleOAuthClient.ts | 95 +- examples/client/src/simpleStreamableHttp.ts | 156 +- .../client/src/simpleTaskInteractiveClient.ts | 204 - .../src/README-simpleTaskInteractive.md | 181 - examples/server/src/simpleStreamableHttp.ts | 167 +- examples/server/src/simpleTaskInteractive.ts | 758 --- packages/client/src/client/client.ts | 122 +- packages/client/src/experimental/index.ts | 13 - .../src/experimental/tasks/client.examples.ts | 70 - .../client/src/experimental/tasks/client.ts | 277 - packages/client/src/index.ts | 3 - packages/core/src/experimental/index.ts | 3 - .../core/src/experimental/tasks/helpers.ts | 104 - .../core/src/experimental/tasks/interfaces.ts | 243 - .../src/experimental/tasks/stores/inMemory.ts | 313 -- packages/core/src/exports/public/index.ts | 33 +- packages/core/src/index.ts | 5 - packages/core/src/shared/protocol.ts | 221 +- packages/core/src/shared/responseMessage.ts | 44 +- packages/core/src/shared/taskManager.ts | 915 ---- packages/core/src/types/constants.ts | 2 - packages/core/src/types/guards.ts | 15 +- packages/core/src/types/schemas.ts | 308 +- packages/core/src/types/spec.types.ts | 408 +- packages/core/src/types/specTypeSchema.ts | 20 +- packages/core/src/types/types.ts | 48 +- .../core/test/experimental/inMemory.test.ts | 1035 ---- .../core/test/shared/customMethods.test.ts | 2 - packages/core/test/shared/protocol.test.ts | 4845 +---------------- .../shared/protocolTransportHandling.test.ts | 2 - packages/core/test/shared/wrapHandler.test.ts | 2 - packages/core/test/spec.types.test.ts | 135 +- packages/server/src/experimental/index.ts | 13 - .../server/src/experimental/tasks/index.ts | 10 - .../src/experimental/tasks/interfaces.ts | 66 - .../src/experimental/tasks/mcpServer.ts | 139 - .../server/src/experimental/tasks/server.ts | 298 - packages/server/src/index.ts | 5 - packages/server/src/server/mcp.ts | 136 +- packages/server/src/server/server.ts | 71 +- test/helpers/src/helpers/tasks.ts | 33 - test/helpers/src/index.ts | 1 - test/integration/test/client/client.test.ts | 1889 +------ .../test/experimental/tasks/task.test.ts | 144 - .../experimental/tasks/taskListing.test.ts | 129 - test/integration/test/helpers/mcp.ts | 70 - test/integration/test/server.test.ts | 1670 +----- test/integration/test/server/mcp.test.ts | 762 +-- test/integration/test/taskLifecycle.test.ts | 1625 ------ 56 files changed, 943 insertions(+), 17795 deletions(-) delete mode 100644 .changeset/extract-task-manager.md delete mode 100644 .changeset/fix-failed-task-result-retrieval.md delete mode 100644 .changeset/fix-task-session-isolation.md create mode 100644 docs/superpowers/plans/2026-06-30-remove-tasks.md delete mode 100644 examples/client/src/simpleTaskInteractiveClient.ts delete mode 100644 examples/server/src/README-simpleTaskInteractive.md delete mode 100644 examples/server/src/simpleTaskInteractive.ts delete mode 100644 packages/client/src/experimental/index.ts delete mode 100644 packages/client/src/experimental/tasks/client.examples.ts delete mode 100644 packages/client/src/experimental/tasks/client.ts delete mode 100644 packages/core/src/experimental/index.ts delete mode 100644 packages/core/src/experimental/tasks/helpers.ts delete mode 100644 packages/core/src/experimental/tasks/interfaces.ts delete mode 100644 packages/core/src/experimental/tasks/stores/inMemory.ts delete mode 100644 packages/core/src/shared/taskManager.ts delete mode 100644 packages/core/test/experimental/inMemory.test.ts delete mode 100644 packages/server/src/experimental/index.ts delete mode 100644 packages/server/src/experimental/tasks/index.ts delete mode 100644 packages/server/src/experimental/tasks/interfaces.ts delete mode 100644 packages/server/src/experimental/tasks/mcpServer.ts delete mode 100644 packages/server/src/experimental/tasks/server.ts delete mode 100644 test/helpers/src/helpers/tasks.ts delete mode 100644 test/integration/test/experimental/tasks/task.test.ts delete mode 100644 test/integration/test/experimental/tasks/taskListing.test.ts delete mode 100644 test/integration/test/helpers/mcp.ts delete mode 100644 test/integration/test/taskLifecycle.test.ts diff --git a/.changeset/extract-task-manager.md b/.changeset/extract-task-manager.md deleted file mode 100644 index 6a72182837..0000000000 --- a/.changeset/extract-task-manager.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@modelcontextprotocol/core": minor -"@modelcontextprotocol/client": minor -"@modelcontextprotocol/server": minor ---- - -refactor: extract task orchestration from Protocol into TaskManager - -**Breaking changes:** -- `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` diff --git a/.changeset/fix-failed-task-result-retrieval.md b/.changeset/fix-failed-task-result-retrieval.md deleted file mode 100644 index aa4e3e3aa4..0000000000 --- a/.changeset/fix-failed-task-result-retrieval.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/core': patch ---- - -Fix `requestStream` to call `tasks/result` for failed tasks instead of yielding a hardcoded `ProtocolError`. When a task reaches the `failed` terminal status, the stream now retrieves and yields the actual stored result (matching the behavior for `completed` tasks), as required by the spec. diff --git a/.changeset/fix-task-session-isolation.md b/.changeset/fix-task-session-isolation.md deleted file mode 100644 index 7220673374..0000000000 --- a/.changeset/fix-task-session-isolation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/core': patch ---- - -Fix InMemoryTaskStore to enforce session isolation. Previously, sessionId was accepted but ignored on all TaskStore methods, allowing any session to enumerate, read, and mutate tasks created by other sessions. The store now persists sessionId at creation time and enforces ownership on all reads and writes. diff --git a/CLAUDE.md b/CLAUDE.md index cbbf950273..8e9c7296da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,8 +106,6 @@ The repo also ships “middleware” packages under `packages/middleware/` (e.g. Located in `packages/*/src/experimental/`: -- **Tasks**: Long-running task support with polling/resumption (`packages/core/src/experimental/tasks/`) - ### Zod Schemas The SDK uses `zod/v4` internally. Schema utilities live in: @@ -201,7 +199,6 @@ The `ctx` parameter in handlers provides a structured context: - `notify(notification)`: Send related notification back - `http?`: HTTP transport info (undefined for stdio) - `authInfo?`: Validated auth token info -- `task?`: Task context (`{ id?, store, requestedTtl? }`) when task storage is configured **`ServerContext`** extends `BaseContext.mcpReq` and `BaseContext.http?` via type intersection: diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 9cff719bbc..d60e09f8b0 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -420,9 +420,6 @@ 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` | `ServerContext` convenience methods (new in v2, no v1 equivalent): @@ -473,30 +470,11 @@ 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` - -`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. - -| v1 | v2 | -| ---------------------- | ---------------------------------- | -| `task: { ttl: null }` | `task: {}` (omit ttl) | -| `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | - -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. - -## 13. Client Behavioral Changes +## 12. Client Behavioral Changes `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. -## 14. Runtime-Specific JSON Schema Validators (Enhancement) +## 13. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: @@ -524,7 +502,7 @@ Access validators explicitly: - AJV (Node.js): `import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server';` - CF Worker: `import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';` -## 15. Migration Steps (apply in this order) +## 14. Migration Steps (apply in this order) 1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages 2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport` → `NodeStreamableHTTPServerTransport` diff --git a/docs/migration.md b/docs/migration.md index fecf185996..0de075eb19 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,16 +591,12 @@ 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` | **Before (v1):** ```typescript server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const headers = extra.requestInfo?.headers; - const taskStore = extra.taskStore; await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); return { content: [{ type: 'text', text: 'result' }] }; }); @@ -611,17 +607,15 @@ 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 2 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` `BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection. @@ -853,47 +847,6 @@ try { } ``` -### Experimental: `TaskCreationParams.ttl` no longer accepts `null` - -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. - -This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. - -**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; -}); -``` - -> **Note:** These task APIs are marked `@experimental` and may change without notice. - ## Enhancements ### Automatic JSON Schema validator selection by runtime diff --git a/docs/superpowers/plans/2026-06-30-remove-tasks.md b/docs/superpowers/plans/2026-06-30-remove-tasks.md new file mode 100644 index 0000000000..2e60dfe510 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-remove-tasks.md @@ -0,0 +1,826 @@ +# Remove Tasks Feature — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the entire Tasks feature (experimental task-augmented execution, TaskManager, task stores, task schemas) from the MCP TypeScript SDK — as if it never existed. + +**Architecture:** Tasks is woven into four layers: Zod schemas/types, the Protocol class (via TaskManager), Server/Client classes (experimental accessors + capability wiring), and barrel exports. We delete bottom-up: schemas/types first, then core TaskManager, then Server/Client integrations, then exports and tests. Each task produces a buildable (but possibly failing-tests) state; the final task verifies everything passes. + +**Tech Stack:** TypeScript, Zod v4, vitest, pnpm workspaces + +--- + +## File Map + +### Files/Directories to DELETE entirely + +``` +packages/core/src/experimental/tasks/ (helpers.ts, interfaces.ts, stores/inMemory.ts) +packages/core/src/experimental/index.ts (only re-exports tasks) +packages/server/src/experimental/tasks/ (index.ts, interfaces.ts, server.ts, mcpServer.ts) +packages/server/src/experimental/index.ts (only re-exports tasks) +packages/client/src/experimental/tasks/ (client.ts, client.examples.ts) +packages/client/src/experimental/index.ts (only re-exports tasks) +packages/core/src/shared/taskManager.ts (915 lines — TaskManager, NullTaskManager) +test/integration/test/experimental/tasks/ (task.test.ts, taskListing.test.ts) +test/integration/test/taskLifecycle.test.ts +packages/core/test/experimental/ (inMemory.test.ts) +test/helpers/src/helpers/tasks.ts +examples/server/src/simpleTaskInteractive.ts +examples/server/src/README-simpleTaskInteractive.md +examples/client/src/simpleTaskInteractiveClient.ts +.changeset/extract-task-manager.md +.changeset/fix-failed-task-result-retrieval.md +.changeset/fix-task-session-isolation.md +``` + +### Files to MODIFY + +``` +packages/core/src/types/schemas.ts — remove ~20 task schemas, TaskAugmentedRequestParamsSchema, ToolExecutionSchema +packages/core/src/types/spec.types.ts — remove task types, TaskAugmentedRequestParams, ToolExecution +packages/core/src/types/types.ts — remove task type aliases +packages/core/src/types/specTypeSchema.ts — remove task schema names from allowlist +packages/core/src/types/guards.ts — remove isTaskAugmentedRequestParams +packages/core/src/types/constants.ts — remove RELATED_TASK_META_KEY +packages/core/src/shared/protocol.ts — remove TaskManager integration entirely +packages/core/src/shared/responseMessage.ts — remove TaskStatusMessage, TaskCreatedMessage +packages/core/src/index.ts — remove taskManager exports, experimental re-export +packages/core/src/exports/public/index.ts — remove task exports +packages/server/src/server/server.ts — remove task capability wiring, experimental getter +packages/server/src/server/mcp.ts — remove task handling logic, experimental getter +packages/server/src/index.ts — remove experimental task exports +packages/client/src/client/client.ts — remove task capability wiring, experimental getter +packages/client/src/index.ts — remove experimental task exports +packages/core/test/shared/protocol.test.ts — remove task-related tests +test/helpers/src/index.ts — remove tasks export +examples/server/src/simpleStreamableHttp.ts — remove task store config and registerToolTask +docs/migration.md — remove task-related sections +docs/migration-SKILL.md — remove task-related sections +CLAUDE.md — remove task references +``` + +--- + +### Task 1: Delete task source directories and standalone files + +**Files:** +- Delete: `packages/core/src/experimental/tasks/` (entire directory) +- Delete: `packages/core/src/experimental/index.ts` +- Delete: `packages/server/src/experimental/tasks/` (entire directory) +- Delete: `packages/server/src/experimental/index.ts` +- Delete: `packages/client/src/experimental/tasks/` (entire directory) +- Delete: `packages/client/src/experimental/index.ts` +- Delete: `packages/core/src/shared/taskManager.ts` + +- [ ] **Step 1: Delete core experimental tasks directory and its barrel** + +```bash +rm -rf packages/core/src/experimental/tasks +rm packages/core/src/experimental/index.ts +rmdir packages/core/src/experimental # should be empty now +``` + +- [ ] **Step 2: Delete server experimental tasks directory and its barrel** + +```bash +rm -rf packages/server/src/experimental/tasks +rm packages/server/src/experimental/index.ts +rmdir packages/server/src/experimental +``` + +- [ ] **Step 3: Delete client experimental tasks directory and its barrel** + +```bash +rm -rf packages/client/src/experimental/tasks +rm packages/client/src/experimental/index.ts +rmdir packages/client/src/experimental +``` + +- [ ] **Step 4: Delete TaskManager** + +```bash +rm packages/core/src/shared/taskManager.ts +``` + +--- + +### Task 2: Remove task schemas from `schemas.ts` + +**Files:** +- Modify: `packages/core/src/types/schemas.ts` + +Remove these schemas and all their JSDoc comments. The schemas are spread across the file, so use line references below. + +- [ ] **Step 1: Remove `RELATED_TASK_META_KEY` import and task-related schemas at top of file** + +In `schemas.ts`, remove the `RELATED_TASK_META_KEY` import from the `constants.js` import line (line 3). Then remove: + +- `TaskCreationParamsSchema` (lines ~33–43) +- `TaskMetadataSchema` (lines ~45–47) +- `RelatedTaskMetadataSchema` (lines ~49–55) +- The `[RELATED_TASK_META_KEY]` field from `BaseRequestParamsSchema` (line ~65) +- `TaskAugmentedRequestParamsSchema` (lines ~79–91) — this is the schema that adds `task` to requests + +- [ ] **Step 2: Remove task capability schemas** + +Remove: +- `ClientTasksCapabilitySchema` (lines ~335–368) +- `ServerTasksCapabilitySchema` (lines ~372–410) +- The `tasks` field from `ClientCapabilitiesSchema` (line ~442) +- The `tasks` field from `ServerCapabilitiesSchema` (line ~522) + +- [ ] **Step 3: Remove task status, task, and task-related request/result schemas** + +Remove: +- `TaskStatusSchema` (line ~620) +- `TaskSchema` (lines ~628–648) +- `CreateTaskResultSchema` (lines ~652–656) +- `TaskStatusNotificationParamsSchema` (line ~659) +- `TaskStatusNotificationSchema` (lines ~664–668) +- `GetTaskRequestSchema` (lines ~672–678) +- `GetTaskResultSchema` (line ~682 — aliases TaskSchema) +- `GetTaskPayloadRequestSchema` (lines ~687–693) +- `GetTaskPayloadResultSchema` (line ~697 — aliases ResultSchema) +- `ListTasksRequestSchema` (lines ~705–709) +- `ListTasksResultSchema` (lines ~712–716) +- `CancelTaskRequestSchema` (lines ~719–725) +- `CancelTaskResultSchema` (line ~729 — aliases EmptyResultSchema) + +- [ ] **Step 4: Remove `ToolExecutionSchema` and `execution` from `ToolSchema`** + +`ToolExecutionSchema` (lines ~1288–1298) only contains `taskSupport` — remove the entire schema. + +In `ToolSchema` (line ~1341), remove the `execution: ToolExecutionSchema.optional()` field. + +- [ ] **Step 5: Change request params schemas to extend `BaseRequestParamsSchema` instead of `TaskAugmentedRequestParamsSchema`** + +Change these lines: +- `CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `CallToolRequestParamsSchema = BaseRequestParamsSchema.extend({` (line ~1412) +- `CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `CreateMessageRequestParamsSchema = BaseRequestParamsSchema.extend({` (line ~1610) +- `ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `ElicitRequestFormParamsSchema = BaseRequestParamsSchema.extend({` (line ~1849) +- `ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `ElicitRequestURLParamsSchema = BaseRequestParamsSchema.extend({` (line ~1876) + +- [ ] **Step 6: Remove task methods from result type mapping** + +At the bottom of `schemas.ts` (lines ~2176–2179), remove: +```typescript +'tasks/get': GetTaskResultSchema, +'tasks/result': ResultSchema, +'tasks/list': ListTasksResultSchema, +'tasks/cancel': CancelTaskResultSchema +``` + +--- + +### Task 3: Remove task types from `spec.types.ts`, `types.ts`, `specTypeSchema.ts`, `guards.ts`, `constants.ts` + +**Files:** +- Modify: `packages/core/src/types/spec.types.ts` +- Modify: `packages/core/src/types/types.ts` +- Modify: `packages/core/src/types/specTypeSchema.ts` +- Modify: `packages/core/src/types/guards.ts` +- Modify: `packages/core/src/types/constants.ts` + +- [ ] **Step 1: Remove task types from `spec.types.ts`** + +Remove: +- `TaskAugmentedRequestParams` interface (lines ~91–104) — change `CallToolRequestParams`, `CreateMessageRequestParams`, `ElicitRequestFormParams`, `ElicitRequestURLParams` to extend `RequestParams` instead +- `ClientCapabilities.tasks` field (lines ~546–578) +- `ServerCapabilities.tasks` field (lines ~672–694) +- `ToolExecution` interface (lines ~1695–1708) +- `Tool.execution` field (line ~1748) +- All types in the `/* Tasks */` section (lines ~1774–1965): `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `Task`, `CreateTaskResult`, `CreateTaskResultResponse`, `GetTaskRequest`, `GetTaskResult`, `GetTaskResultResponse`, `GetTaskPayloadRequest`, `GetTaskPayloadResult`, `ListTasksRequest`, `ListTasksResult`, `CancelTaskRequest`, `CancelTaskResult`, `TaskStatusNotificationParams`, `TaskStatusNotification` +- Remove task-related JSDoc references in `CancelledNotification` (lines ~366–367, ~386) +- Remove task mention from `ErrorCode` docs (line ~284) + +- [ ] **Step 2: Remove task type aliases from `types.ts`** + +Remove all task-related schema imports and type aliases: +```typescript +// Remove these imports from schemas.ts: +CancelTaskRequestSchema, CancelTaskResultSchema, CreateTaskResultSchema, +GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, GetTaskRequestSchema, +GetTaskResultSchema, ListTasksRequestSchema, ListTasksResultSchema, +RelatedTaskMetadataSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, +TaskMetadataSchema, TaskSchema, TaskStatusNotificationParamsSchema, +TaskStatusNotificationSchema, TaskStatusSchema + +// Remove these type aliases (lines ~190, ~235–260): +TaskAugmentedRequestParams, Task, TaskStatus, TaskCreationParams, TaskMetadata, +RelatedTaskMetadata, CreateTaskResult, TaskStatusNotificationParams, +TaskStatusNotification, GetTaskRequest, GetTaskResult, GetTaskPayloadRequest, +GetTaskPayloadResult, ListTasksRequest, ListTasksResult, CancelTaskRequest, CancelTaskResult +``` + +Also remove the `ToolExecution` type alias and `ToolExecutionSchema` import if present. + +- [ ] **Step 3: Remove task schema names from `specTypeSchema.ts` allowlist** + +Remove these entries from the `SPEC_SCHEMA_NAMES` array: +``` +'CancelTaskRequestSchema', 'CancelTaskResultSchema', 'CreateTaskResultSchema', +'GetTaskPayloadRequestSchema', 'GetTaskPayloadResultSchema', +'GetTaskRequestSchema', 'GetTaskResultSchema', +'ListTasksRequestSchema', 'ListTasksResultSchema', +'RelatedTaskMetadataSchema', 'TaskSchema', 'TaskAugmentedRequestParamsSchema', +'TaskCreationParamsSchema', 'TaskMetadataSchema', 'TaskStatusSchema', +'TaskStatusNotificationSchema', 'TaskStatusNotificationParamsSchema' +``` + +Also remove `'ToolExecutionSchema'` if present. + +- [ ] **Step 4: Remove `isTaskAugmentedRequestParams` from `guards.ts`** + +Remove the import of `TaskAugmentedRequestParamsSchema` and `TaskAugmentedRequestParams`. +Remove the `isTaskAugmentedRequestParams` function (lines ~85–91). + +- [ ] **Step 5: Remove `RELATED_TASK_META_KEY` from `constants.ts`** + +Remove line 5: `export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task';` + +--- + +### Task 4: Remove TaskManager integration from `protocol.ts` + +**Files:** +- Modify: `packages/core/src/shared/protocol.ts` + +This is the most complex modification. The TaskManager intercepts request/response/notification flows. + +- [ ] **Step 1: Remove task imports** + +Remove from the type imports (lines 24, 33): +- `RelatedTaskMetadata` +- `TaskCreationParams` + +Remove the taskManager imports (lines 49–50): +```typescript +import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; +import { NullTaskManager, TaskManager } from './taskManager.js'; +``` + +- [ ] **Step 2: Remove `tasks` from `ProtocolOptions`** + +Remove the `tasks?: TaskManagerOptions` field and its JSDoc from `ProtocolOptions` (lines ~87–94). + +- [ ] **Step 3: Remove task fields from `RequestOptions`** + +Remove from `RequestOptions`: +- The `task?: TaskCreationParams` field and JSDoc (lines ~140–142) +- The `relatedTask?: RelatedTaskMetadata` field and JSDoc (lines ~144–147) +- Update the `onprogress` JSDoc to remove the task-related sentence (line ~109) + +- [ ] **Step 4: Remove `relatedTask` from `NotificationOptions`** + +Remove the `relatedTask?: RelatedTaskMetadata` field and JSDoc from `NotificationOptions` (lines ~160–162). + +- [ ] **Step 5: Remove `task?` from `BaseContext`** + +Remove the `task?: TaskContext` field and its JSDoc from `BaseContext` (lines ~236–239). + +Change `TaskRequestOptions` to `RequestOptions` in `BaseContext.mcpReq.send` signatures (lines ~209, ~215). + +- [ ] **Step 6: Remove `_taskManager` field, constructor wiring, and `_bindTaskManager()`** + +In the `Protocol` class: +- Remove `private _taskManager: TaskManager;` (line ~322) +- Remove `taskManager` getter (lines ~376–378) +- In constructor: remove the TaskManager creation lines (lines ~353–355): + ```typescript + this._taskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); + this._bindTaskManager(); + ``` +- Remove entire `_bindTaskManager()` method (lines ~380–403) +- Remove `assertTaskCapability()` abstract method declaration (lines ~785–790) +- Remove `assertTaskHandlerCapability()` abstract method declaration (lines ~792–798) + +- [ ] **Step 7: Simplify `_onclose()` to remove TaskManager call** + +Remove `this._taskManager.onClose();` (line ~509). + +- [ ] **Step 8: Simplify `_onrequest()` to remove TaskManager delegation** + +In `_onrequest()` (starting at line ~555): + +Replace the TaskManager delegation block (lines ~570–631) with direct context building. The key changes: +- Remove `const taskResult = this._taskManager.processInboundRequest(...)` and all destructuring +- Use `inboundCtx.sendNotification` and `inboundCtx.sendRequest` directly in BaseContext +- Remove `taskContext` from BaseContext construction (remove `task: taskContext` at line ~631) +- Replace `routeResponse(...)` calls with direct `capturedTransport?.send(...)` — there are three places: the no-handler error path, the success path, and the error path + +The simplified `_onrequest` should: +1. Build the abort controller and base context directly +2. Call the handler +3. Send responses directly through `capturedTransport?.send()` + +- [ ] **Step 9: Simplify `_onresponse()` to remove TaskManager delegation** + +In `_onresponse()` (starting at line ~722): + +Remove: +```typescript +const taskResult = this._taskManager.processInboundResponse(response, messageId); +if (taskResult.consumed) return; +const preserveProgress = taskResult.preserveProgress; +``` + +And change `if (!preserveProgress)` to unconditionally delete progress handlers: +```typescript +this._progressHandlers.delete(messageId); +``` + +Remove the comment about "Keep progress handler alive for CreateTaskResult responses" (line ~739). + +- [ ] **Step 10: Simplify `_requestWithSchema()` to remove TaskManager delegation** + +In `_requestWithSchema()` (starting at line ~836): + +Remove the entire TaskManager outbound block (lines ~941–964): +```typescript +const responseHandler = ...; +let outboundQueued = false; +try { const taskResult = this._taskManager.processOutboundRequest(...); ... } +``` + +Replace with direct transport send (the code that's currently in the `if (!outboundQueued)` block at line ~966). + +- [ ] **Step 11: Simplify `notification()` to remove TaskManager delegation** + +In `notification()` (starting at line ~992): + +Remove the TaskManager delegation block (lines ~999–1007): +```typescript +const taskResult = await this._taskManager.processOutboundNotification(notification, options); +const queued = taskResult.queued; +const jsonrpcNotification = taskResult.queued ? undefined : taskResult.jsonrpcNotification; +if (queued) { return; } +``` + +Build the JSONRPC notification directly (it was previously done by TaskManager for the non-queued path). The simple version: +```typescript +const jsonrpcNotification: JSONRPCNotification = { + jsonrpc: '2.0', + method: notification.method, + ...(notification.params && { params: notification.params }) +}; +``` + +Also remove `!options?.relatedTask` from the debounce guard (line ~1013). + +--- + +### Task 5: Simplify `responseMessage.ts` + +**Files:** +- Modify: `packages/core/src/shared/responseMessage.ts` + +- [ ] **Step 1: Remove task message types and simplify** + +Remove: +- Import of `Task` from types +- `TaskStatusMessage` interface (lines ~16–19) +- `TaskCreatedMessage` interface (lines ~27–30) +- Task references from `ResponseMessage` union type (line ~67) — becomes: `ResultMessage | ErrorMessage` +- Task references from JSDoc comments throughout the file +- In `takeResult()`, the `taskCreated` and `taskStatus` cases are already handled by the fall-through — the function only returns on `result` and throws on `error`, so no code change needed there, but update the JSDoc to remove task mentions. + +--- + +### Task 6: Clean up Server (`server.ts` and `mcp.ts`) + +**Files:** +- Modify: `packages/server/src/server/server.ts` +- Modify: `packages/server/src/server/mcp.ts` + +- [ ] **Step 1: Clean up `server.ts` imports** + +Remove from the type imports: +- `TaskManagerOptions` (line 31) + +Remove from the value imports: +- `assertClientRequestTaskCapability` (line 36) +- `assertToolsCallTaskCapability` (line 37) +- `CreateTaskResultSchema` (line 42) +- `extractTaskManagerOptions` (line 45) + +Remove: +```typescript +import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; +``` +(line 59) + +- [ ] **Step 2: Remove `ServerTasksCapabilityWithRuntime` and simplify `ServerOptions`** + +Remove the `ServerTasksCapabilityWithRuntime` type (line 65). + +Simplify `ServerOptions.capabilities` — remove the `Omit` and `tasks?` override (lines 71–73). Just use: +```typescript +capabilities?: ServerCapabilities; +``` + +- [ ] **Step 3: Remove task wiring from Server constructor** + +- Remove `tasks: extractTaskManagerOptions(options?.capabilities?.tasks)` from super call (line 120) — just pass `...options` +- Remove the entire `if (options?.capabilities?.tasks)` block that strips runtime fields (lines 127–132) +- Remove `private _experimental?: { tasks: ExperimentalServerTasks };` (line 104) +- Remove the `get experimental()` getter (lines 184–191) + +- [ ] **Step 4: Remove task validation from `_wrapHandler()`** + +In `_wrapHandler()` for `tools/call` (lines 225–267): + +Remove the `if (params.task)` block (lines 245–255) that validates `CreateTaskResult`. The method should only validate against `CallToolResultSchema`. + +Also change the return type annotation on the handler from `Promise` to `Promise` if present. + +- [ ] **Step 5: Remove `assertTaskCapability()` and `assertTaskHandlerCapability()`** + +Remove both methods (lines 413–418). + +- [ ] **Step 6: Clean up `mcp.ts` imports** + +Remove from imports: +- `CreateTaskResult` (line 8) +- `CreateTaskServerContext` (line 9) +- `ToolExecution` (line 26) + +Remove: +```typescript +import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; +import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; +``` +(lines 44–45) + +- [ ] **Step 7: Remove `_experimental` and experimental getter from `McpServer`** + +Remove `private _experimental?: { tasks: ExperimentalMcpServerTasks };` (line 75). +Remove the `get experimental()` getter (lines 88–95). + +- [ ] **Step 8: Simplify `tools/call` handler in McpServer** + +In the `tools/call` handler (lines 163–216), remove all task logic: + +Remove: +- `const isTaskRequest = !!request.params.task;` (line 173) +- `const taskSupport = tool.execution?.taskSupport;` (line 174) +- `const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler);` (line 175) +- The taskSupport validation block (lines 178–183) +- The `taskSupport === 'required'` guard (lines 186–191) +- The `taskSupport === 'optional'` automatic polling block (lines 194–196) +- The `if (isTaskRequest) { return result; }` block (lines 203–205) + +The handler becomes just: validate input → execute → validate output → return. + +Change the handler return type from `Promise` to `Promise`. + +- [ ] **Step 9: Remove `handleAutomaticTaskPolling()` method** + +Delete the entire `handleAutomaticTaskPolling()` method (lines 310–339). + +- [ ] **Step 10: Simplify `validateToolOutput()` and `executeToolHandler()` signatures** + +In `validateToolOutput()` (line 268): Change parameter from `result: CallToolResult | CreateTaskResult` to `result: CallToolResult`. Remove the `if (!('content' in result))` guard (lines 274–276). + +In `executeToolHandler()` (line 302): Change return type from `Promise` to `Promise`. + +- [ ] **Step 11: Remove `ToolExecution` from tool registration types** + +In `mcp.ts`, find the `RegisteredTool` type and the `tool()` method overloads. Remove `execution?: ToolExecution` from any interfaces/types that carry it. If `ToolExecution` was the type for `execution`, note that `ToolExecutionSchema` is already deleted — the `execution` field on `ToolSchema` was removed in Task 2. + +Also remove `taskSupport: 'forbidden'` default from any tool registration code (around line ~917 — search for it). + +--- + +### Task 7: Clean up Client (`client.ts`) + +**Files:** +- Modify: `packages/client/src/client/client.ts` + +- [ ] **Step 1: Clean up imports** + +Remove from type imports: +- `TaskManagerOptions` (line 32) + +Remove from value imports: +- `assertClientRequestTaskCapability` (line 38) +- `assertToolsCallTaskCapability` (line 39) +- `CreateTaskResultSchema` (line 45) +- `extractTaskManagerOptions` (line 49) + +Remove: +```typescript +import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; +``` +(line 68) + +- [ ] **Step 2: Remove `ClientTasksCapabilityWithRuntime` and simplify `ClientOptions`** + +Remove the `ClientTasksCapabilityWithRuntime` type (line 148). + +Simplify `ClientOptions.capabilities` — remove the `Omit` and `tasks?` override (lines 154–156). Just use: +```typescript +capabilities?: ClientCapabilities; +``` + +- [ ] **Step 3: Remove task wiring from Client constructor** + +- Remove `tasks: extractTaskManagerOptions(options?.capabilities?.tasks)` from super call (line 249) — just pass `...options` +- Remove the entire `if (options?.capabilities?.tasks)` block that strips runtime fields (lines 256–261) + +- [ ] **Step 4: Remove task fields and experimental getter** + +Remove: +- `private _cachedKnownTaskTools: Set = new Set();` (line 233) +- `private _cachedRequiredTaskTools: Set = new Set();` (line 234) +- `private _experimental?: { tasks: ExperimentalClientTasks };` (line 235) +- The `get experimental()` getter (lines 309–316) + +- [ ] **Step 5: Remove task validation from `_wrapHandler()` for elicitation** + +In `_wrapHandler()` for `elicitation/create` (around line ~339): + +Remove the `if (params.task)` block (lines ~363–374) that validates `CreateTaskResult`. Keep only the non-task `ElicitResultSchema` validation path. + +- [ ] **Step 6: Remove task validation from `_wrapHandler()` for sampling** + +Find the `sampling/createMessage` section in `_wrapHandler()` (around line ~420). Remove the `if (params.task)` block (lines ~420–429). + +- [ ] **Step 7: Remove task guard from `callTool()`** + +In `callTool()` (line ~862), remove the task-required guard (lines ~863–869): +```typescript +if (this.isToolTaskRequired(params.name)) { + throw new ProtocolError(...); +} +``` + +Also remove the task-related JSDoc comment about `client.experimental.tasks.callToolStream()` (line ~831). + +- [ ] **Step 8: Remove task tool caching methods** + +Remove: +- `isToolTask()` method (lines ~911–917) +- `isToolTaskRequired()` method (lines ~923–925) + +In `cacheToolMetadata()` (lines ~931–952): +- Remove `this._cachedKnownTaskTools.clear();` and `this._cachedRequiredTaskTools.clear();` +- Remove the `taskSupport` caching block (lines ~943–950) + +- [ ] **Step 9: Remove `assertTaskCapability()` and `assertTaskHandlerCapability()`** + +Remove both methods (lines ~704–709). + +--- + +### Task 8: Update barrel exports + +**Files:** +- Modify: `packages/core/src/index.ts` +- Modify: `packages/core/src/exports/public/index.ts` +- Modify: `packages/server/src/index.ts` +- Modify: `packages/client/src/index.ts` + +- [ ] **Step 1: Clean up `packages/core/src/index.ts`** + +Remove: +```typescript +export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; +export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; +``` +(lines 9–10) + +Remove: +```typescript +export * from './experimental/index.js'; +``` +(line 21) + +- [ ] **Step 2: Clean up `packages/core/src/exports/public/index.ts`** + +Remove the task manager types block (lines 54–55): +```typescript +// Task manager types (NOT TaskManager class itself — internal) +export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; +``` + +Remove task response message types from the response message export block (lines 63–64): +```typescript +TaskCreatedMessage, +TaskStatusMessage +``` + +Remove the experimental task types and classes block (lines 121–138): +```typescript +// Experimental task types and classes +export { assertClientRequestTaskCapability, assertToolsCallTaskCapability } from '../../experimental/tasks/helpers.js'; +export type { ... } from '../../experimental/tasks/interfaces.js'; +export { isTerminal } from '../../experimental/tasks/interfaces.js'; +export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; +``` + +Remove `isTaskAugmentedRequestParams` from the guards export (line 117). + +Remove `RELATED_TASK_META_KEY` from the constants export (line 95). + +- [ ] **Step 3: Clean up `packages/server/src/index.ts`** + +Remove the experimental exports block (lines 43–46): +```typescript +// 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'; +``` + +- [ ] **Step 4: Clean up `packages/client/src/index.ts`** + +Remove the experimental exports block (lines 74–75): +```typescript +// experimental exports +export { ExperimentalClientTasks } from './experimental/tasks/client.js'; +``` + +--- + +### Task 9: Clean up tests and examples + +**Files:** +- Delete: `test/integration/test/experimental/tasks/` (entire directory) +- Delete: `test/integration/test/taskLifecycle.test.ts` +- Delete: `packages/core/test/experimental/` (entire directory) +- Delete: `test/helpers/src/helpers/tasks.ts` +- Delete: `examples/server/src/simpleTaskInteractive.ts` +- Delete: `examples/server/src/README-simpleTaskInteractive.md` +- Delete: `examples/client/src/simpleTaskInteractiveClient.ts` +- Modify: `test/helpers/src/index.ts` +- Modify: `packages/core/test/shared/protocol.test.ts` +- Modify: `examples/server/src/simpleStreamableHttp.ts` + +- [ ] **Step 1: Delete task-specific test files** + +```bash +rm -rf test/integration/test/experimental/tasks +rm test/integration/test/taskLifecycle.test.ts +rm -rf packages/core/test/experimental +``` + +- [ ] **Step 2: Delete task test helpers** + +```bash +rm test/helpers/src/helpers/tasks.ts +``` + +Remove the re-export from `test/helpers/src/index.ts`: +```typescript +export * from './helpers/tasks.js'; +``` + +- [ ] **Step 3: Delete task example files** + +```bash +rm examples/server/src/simpleTaskInteractive.ts +rm examples/server/src/README-simpleTaskInteractive.md +rm examples/client/src/simpleTaskInteractiveClient.ts +``` + +- [ ] **Step 4: Remove task-related tests from `protocol.test.ts`** + +In `packages/core/test/shared/protocol.test.ts`: + +Remove all task-related imports (lines ~10–19): +- `TaskMessageQueue`, `TaskStore` from experimental interfaces +- `InMemoryTaskMessageQueue` from experimental stores +- `TaskManagerOptions`, `NullTaskManager`, `TaskManager` from taskManager + +Remove `assertTaskCapability()` and `assertTaskHandlerCapability()` stubs from `TestProtocolImpl` (lines ~45–46). + +Remove `taskOptions` parameter from `createTestProtocol()` (lines ~52–53). + +Remove the `createMockTaskStore()` helper and all test blocks that use task functionality. Search for `describe` blocks containing "task" in their names and remove them entirely. + +- [ ] **Step 5: Remove task configuration from `simpleStreamableHttp.ts` example** + +In `examples/server/src/simpleStreamableHttp.ts`: + +Remove imports of `InMemoryTaskMessageQueue`, `InMemoryTaskStore` (line 14). + +Remove the task store creation (lines 25–26): +```typescript +const taskStore = new InMemoryTaskStore(); +``` + +Remove the `tasks` field from server capabilities (lines 40–43): +```typescript +tasks: { + ... + taskStore, + taskMessageQueue: new InMemoryTaskMessageQueue() +} +``` + +Remove the `registerToolTask` call and its entire implementation (lines ~442–483). + +--- + +### Task 10: Delete changesets and update documentation + +**Files:** +- Delete: `.changeset/extract-task-manager.md` +- Delete: `.changeset/fix-failed-task-result-retrieval.md` +- Delete: `.changeset/fix-task-session-isolation.md` +- Modify: `docs/migration.md` +- Modify: `docs/migration-SKILL.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Delete task-related changesets** + +```bash +rm .changeset/extract-task-manager.md +rm .changeset/fix-failed-task-result-retrieval.md +rm .changeset/fix-task-session-isolation.md +``` + +- [ ] **Step 2: Remove task references from `docs/migration.md`** + +Remove: +- The `` `CreateTaskResult` `` mention in the return type description (line ~488) +- The `extra.taskStore` → `ctx.task?.store` migration rows (lines ~594–596) +- The task code examples (lines ~603–625) +- The `task?` mention in context field descriptions (line ~624) +- The entire "Experimental: TaskCreationParams.ttl no longer accepts null" section (lines ~856–895) + +- [ ] **Step 3: Remove task references from `docs/migration-SKILL.md`** + +Remove: +- The `extra.taskStore`/`extra.taskId`/`extra.taskRequestedTtl` migration rows (lines ~423–425) +- The entire section "12. Experimental: TaskCreationParams.ttl no longer accepts null" (lines ~476–493) + +- [ ] **Step 4: Remove task references from `CLAUDE.md`** + +Remove: +- The `task?` field from `BaseContext` description +- The `task?` field from `ServerContext` description +- References to `TaskManager` and experimental tasks +- The `- **Tasks**: Long-running task support with polling/resumption` line under Experimental Features + +--- + +### Task 11: Build and test + +**Files:** None (verification only) + +- [ ] **Step 1: Build all packages** + +```bash +pnpm build:all +``` + +Expected: Clean build with no errors. + +- [ ] **Step 2: Type-check all packages** + +```bash +pnpm typecheck:all +``` + +Expected: No type errors. + +- [ ] **Step 3: Run lint** + +```bash +pnpm lint:all +``` + +Expected: Clean or only pre-existing warnings. Fix any new lint errors introduced by the removal. + +- [ ] **Step 4: Run all tests** + +```bash +pnpm test:all +``` + +Expected: All tests pass. Any remaining test failures indicate missed task references. + +- [ ] **Step 5: Fix any remaining issues** + +If any step above fails, grep the codebase for remaining references: +```bash +grep -rn "task\|Task" packages/ --include='*.ts' | grep -v node_modules | grep -v '.d.ts' | grep -v 'test/' | grep -iv 'import.*taskCreate\|TaskCreate\|TaskUpdate\|TaskGet' +``` + +Fix any remaining references found. + +- [ ] **Step 6: Suggest commit** + +Suggest a commit with message: +``` +feat!: remove Tasks feature entirely + +Remove all experimental task-augmented execution support: TaskManager, +TaskStore, task schemas, task capability negotiation, and experimental +client/server task APIs. + +BREAKING CHANGE: Tasks feature removed. All task-related types, schemas, +and APIs are no longer available. +``` diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts index c75aea9483..193bc28f0b 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,6 @@ 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(' quit - Exit the client'); console.log(); @@ -229,10 +228,8 @@ class InteractiveOAuthClient { await this.listTools(); } else if (command.startsWith('call ')) { await this.handleCallTool(command); - } else if (command.startsWith('stream ')) { - await this.handleStreamTool(command); } else { - console.log("❌ Unknown command. Try 'list', 'call ', 'stream ', or 'quit'"); + console.log("❌ Unknown command. Try 'list', 'call ', or 'quit'"); } } catch (error) { if (error instanceof Error && error.message === 'SIGINT') { @@ -328,94 +325,6 @@ class InteractiveOAuthClient { } } - private async handleStreamTool(command: string): Promise { - const parts = command.split(/\s+/); - const toolName = parts[1]; - - if (!toolName) { - console.log('❌ Please specify a tool name'); - return; - } - - // Parse arguments (simple JSON-like format) - let toolArgs: Record = {}; - if (parts.length > 2) { - const argsString = parts.slice(2).join(' '); - try { - toolArgs = JSON.parse(argsString); - } catch { - console.log('❌ Invalid arguments format (expected JSON)'); - return; - } - } - - await this.streamTool(toolName, toolArgs); - } - - private async streamTool(toolName: string, toolArgs: Record): Promise { - if (!this.client) { - console.log('❌ Not connected to server'); - return; - } - - try { - // Using the experimental tasks API - WARNING: may change without notice - console.log(`\n🔧 Streaming tool '${toolName}'...`); - - const stream = this.client.experimental.tasks.callToolStream( - { - name: toolName, - arguments: toolArgs - }, - { - 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); - } - } - close(): void { this.rl.close(); if (this.client) { diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/client/src/simpleStreamableHttp.ts index f22d16ba4b..bdc7fdb8e7 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'); @@ -136,11 +125,6 @@ function commandLoop(): void { 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; @@ -155,24 +139,6 @@ function commandLoop(): void { 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 +216,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 +226,6 @@ async function connect(url?: string): Promise { capabilities: { elicitation: { form: {} - }, - tasks: { - taskStore: clientTaskStore, - requests: { - elicitation: { - create: {} - } - } } } } @@ -279,33 +234,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 +377,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 +390,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 +409,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 +426,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 +441,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 +451,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 +654,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 +812,6 @@ async function readResource(uri: string): Promise { } } -async function callToolTask(name: string, args: Record): Promise { - if (!client) { - console.log('Not connected to server.'); - return; - } - - console.log(`Calling tool '${name}' with task-based execution...`); - console.log('Arguments:', args); - - // Use task-based execution - call now, fetch later - // Using the experimental tasks API - WARNING: may change without notice - console.log('This will return immediately while processing continues in the background...'); - - try { - // Call the tool with task metadata using streaming API - const stream = client.experimental.tasks.callToolStream( - { - name, - arguments: args - }, - { - task: { - ttl: 60_000 // Keep results for 60 seconds - } - } - ); - - console.log('Waiting for task completion...'); - - let lastStatus = ''; - for await (const message of stream) { - switch (message.type) { - case 'taskCreated': { - console.log('Task created successfully with ID:', message.task.taskId); - break; - } - case 'taskStatus': { - if (lastStatus !== message.task.status) { - console.log(` ${message.task.status}${message.task.statusMessage ? ` - ${message.task.statusMessage}` : ''}`); - } - lastStatus = message.task.status; - break; - } - case 'result': { - console.log('Task completed!'); - console.log('Tool result:'); - const toolResult = message.result as CallToolResult; - for (const item of toolResult.content) { - if (item.type === 'text') { - console.log(` ${item.text}`); - } - } - break; - } - case 'error': { - throw message.error; - } - } - } - } catch (error) { - console.log(`Error with task-based execution: ${error}`); - } -} - async function cleanup(): Promise { if (client && transport) { try { diff --git a/examples/client/src/simpleTaskInteractiveClient.ts b/examples/client/src/simpleTaskInteractiveClient.ts deleted file mode 100644 index 0a35faba24..0000000000 --- a/examples/client/src/simpleTaskInteractiveClient.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Simple interactive task client demonstrating elicitation and sampling responses. - * - * This client connects to simpleTaskInteractive.ts server and demonstrates: - * - Handling elicitation requests (y/n confirmation) - * - Handling sampling requests (returns a hardcoded haiku) - * - Using task-based tool execution with streaming - */ - -import { createInterface } from 'node:readline'; - -import type { CallToolResult, CreateMessageRequest, CreateMessageResult, TextContent } from '@modelcontextprotocol/client'; -import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -// Create readline interface for user input -const readline = createInterface({ - input: process.stdin, - output: process.stdout -}); - -function question(prompt: string): Promise { - return new Promise(resolve => { - readline.question(prompt, answer => { - resolve(answer.trim()); - }); - }); -} - -function getTextContent(result: { content: Array<{ type: string; text?: string }> }): string { - const textContent = result.content.find((c): c is TextContent => c.type === 'text'); - return textContent?.text ?? '(no text)'; -} - -async function elicitationCallback(params: { - mode?: string; - message: string; - requestedSchema?: object; -}): Promise<{ action: 'accept' | 'cancel' | 'decline'; content?: Record }> { - console.log(`\n[Elicitation] Server asks: ${params.message}`); - - // Simple terminal prompt for y/n - const response = await question('Your response (y/n): '); - const confirmed = ['y', 'yes', 'true', '1'].includes(response.toLowerCase()); - - console.log(`[Elicitation] Responding with: confirm=${confirmed}`); - return { action: 'accept', content: { confirm: confirmed } }; -} - -async function samplingCallback(params: CreateMessageRequest['params']): Promise { - // Get the prompt from the first message - let prompt = 'unknown'; - if (params.messages && params.messages.length > 0) { - const firstMessage = params.messages[0]!; - const content = firstMessage.content; - if (typeof content === 'object' && !Array.isArray(content) && content.type === 'text' && 'text' in content) { - prompt = content.text; - } else if (Array.isArray(content)) { - const textPart = content.find(c => c.type === 'text' && 'text' in c); - if (textPart && 'text' in textPart) { - prompt = textPart.text; - } - } - } - - console.log(`\n[Sampling] Server requests LLM completion for: ${prompt}`); - - // Return a hardcoded haiku (in real use, call your LLM here) - const haiku = `Cherry blossoms fall -Softly on the quiet pond -Spring whispers goodbye`; - - console.log('[Sampling] Responding with haiku'); - return { - model: 'mock-haiku-model', - role: 'assistant', - content: { type: 'text', text: haiku } - }; -} - -async function run(url: string): Promise { - console.log('Simple Task Interactive Client'); - console.log('=============================='); - console.log(`Connecting to ${url}...`); - - // Create client with elicitation and sampling capabilities - const client = new Client( - { name: 'simple-task-interactive-client', version: '1.0.0' }, - { - capabilities: { - elicitation: { form: {} }, - sampling: {} - } - } - ); - - // Set up elicitation request handler - client.setRequestHandler('elicitation/create', async request => { - if (request.params.mode && request.params.mode !== 'form') { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); - } - return elicitationCallback(request.params); - }); - - // Set up sampling request handler - client.setRequestHandler('sampling/createMessage', async request => { - return samplingCallback(request.params) as unknown as ReturnType; - }); - - // Connect to server - const transport = new StreamableHTTPClientTransport(new URL(url)); - await client.connect(transport); - console.log('Connected!\n'); - - // List tools - const toolsResult = await client.listTools(); - console.log(`Available tools: ${toolsResult.tools.map(t => t.name).join(', ')}`); - - // Demo 1: Elicitation (confirm_delete) - console.log('\n--- Demo 1: Elicitation ---'); - console.log('Calling confirm_delete tool...'); - - const confirmStream = client.experimental.tasks.callToolStream( - { name: 'confirm_delete', arguments: { filename: 'important.txt' } }, - { task: { ttl: 60_000 } } - ); - - for await (const message of confirmStream) { - switch (message.type) { - case 'taskCreated': { - console.log(`Task created: ${message.task.taskId}`); - break; - } - case 'taskStatus': { - console.log(`Task status: ${message.task.status}`); - break; - } - case 'result': { - const toolResult = message.result as CallToolResult; - console.log(`Result: ${getTextContent(toolResult)}`); - break; - } - case 'error': { - console.error(`Error: ${message.error}`); - break; - } - } - } - - // Demo 2: Sampling (write_haiku) - console.log('\n--- Demo 2: Sampling ---'); - console.log('Calling write_haiku tool...'); - - const haikuStream = client.experimental.tasks.callToolStream( - { name: 'write_haiku', arguments: { topic: 'autumn leaves' } }, - { task: { ttl: 60_000 } } - ); - - for await (const message of haikuStream) { - switch (message.type) { - case 'taskCreated': { - console.log(`Task created: ${message.task.taskId}`); - break; - } - case 'taskStatus': { - console.log(`Task status: ${message.task.status}`); - break; - } - case 'result': { - const toolResult = message.result as CallToolResult; - console.log(`Result:\n${getTextContent(toolResult)}`); - break; - } - case 'error': { - console.error(`Error: ${message.error}`); - break; - } - } - } - - // Cleanup - console.log('\nDemo complete. Closing connection...'); - await transport.close(); - readline.close(); -} - -// Parse command line arguments -const args = process.argv.slice(2); -let url = 'http://localhost:8000/mcp'; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--url' && args[i + 1]) { - url = args[i + 1]!; - i++; - } -} - -// Run the client -try { - await run(url); -} catch (error) { - console.error('Error running client:', error); - // eslint-disable-next-line unicorn/no-process-exit - process.exit(1); -} diff --git a/examples/server/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/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5fa2e14d94..f9894c6f14 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -29,24 +29,19 @@ import type { Result, ServerCapabilities, SubscribeRequest, - TaskManagerOptions, Tool, Transport, UnsubscribeRequest } from '@modelcontextprotocol/core'; import { - assertClientRequestTaskCapability, - assertToolsCallTaskCapability, CallToolResultSchema, CompleteResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, - extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, LATEST_PROTOCOL_VERSION, @@ -65,8 +60,6 @@ import { SdkErrorCode } from '@modelcontextprotocol/core'; -import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; - /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -141,19 +134,11 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e return { supportsFormMode, supportsUrlMode }; } -/** - * Extended tasks capability that includes runtime configuration (store, messageQueue). - * The runtime-only fields are stripped before advertising capabilities to servers. - */ -export type ClientTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; - export type ClientOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this client. */ - capabilities?: Omit & { - tasks?: ClientTasksCapabilityWithRuntime; - }; + capabilities?: ClientCapabilities; /** * JSON Schema validator for tool output validation. @@ -230,9 +215,6 @@ export class Client extends Protocol { private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); - private _cachedKnownTaskTools: Set = new Set(); - private _cachedRequiredTaskTools: Set = new Set(); - private _experimental?: { tasks: ExperimentalClientTasks }; private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; @@ -244,22 +226,11 @@ export class Client extends Protocol { private _clientInfo: Implementation, options?: ClientOptions ) { - super({ - ...options, - tasks: extractTaskManagerOptions(options?.capabilities?.tasks) - }); + super(options); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; - // Strip runtime-only fields from advertised capabilities - if (options?.capabilities?.tasks) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = - options.capabilities.tasks; - this._capabilities.tasks = wireCapabilities; - } - // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { this._pendingListChangedConfig = options.listChanged; @@ -299,22 +270,6 @@ export class Client extends Protocol { } } - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalClientTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalClientTasks(this) - }; - } - return this._experimental; - } - /** * Registers new capabilities. This can only be called before connecting to a transport. * @@ -360,20 +315,6 @@ export class Client extends Protocol { const result = await handler(request, ctx); - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against ElicitResultSchema const validationResult = parseSchema(ElicitResultSchema, result); if (!validationResult.success) { // Type guard: if success is false, error is guaranteed to exist @@ -416,20 +357,6 @@ export class Client extends Protocol { const result = await handler(request, ctx); - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against appropriate schema based on tools presence const hasTools = params.tools || params.toolChoice; const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; const validationResult = parseSchema(resultSchema, result); @@ -701,14 +628,6 @@ export class Client extends Protocol { } } - protected assertTaskCapability(method: string): void { - assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server'); - } - - protected assertTaskHandlerCapability(method: string): void { - assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client'); - } - async ping(options?: RequestOptions) { return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); } @@ -828,8 +747,6 @@ export class Client extends Protocol { * a problem), and thrown {@linkcode ProtocolError} for protocol-level failures or {@linkcode SdkError} for * SDK-level issues (timeouts, missing capabilities). * - * For task-based execution with streaming behavior, use {@linkcode ExperimentalClientTasks.callToolStream | client.experimental.tasks.callToolStream()} instead. - * * @example Basic usage * ```ts source="./client.examples.ts#Client_callTool_basic" * const result = await client.callTool({ @@ -860,14 +777,6 @@ export class Client extends Protocol { * ``` */ async callTool(params: CallToolRequest['params'], options?: RequestOptions) { - // Guard: required-task tools need experimental API - if (this.isToolTaskRequired(params.name)) { - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` - ); - } - const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); // Check if the tool has an outputSchema @@ -908,30 +817,12 @@ export class Client extends Protocol { return result; } - private isToolTask(toolName: string): boolean { - if (!this._serverCapabilities?.tasks?.requests?.tools?.call) { - return false; - } - - return this._cachedKnownTaskTools.has(toolName); - } - - /** - * Check if a tool requires task-based execution. - * Unlike {@linkcode isToolTask} which includes `'optional'` tools, this only checks for `'required'`. - */ - private isToolTaskRequired(toolName: string): boolean { - return this._cachedRequiredTaskTools.has(toolName); - } - /** * Cache validators for tool output schemas. * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. */ private cacheToolMetadata(tools: Tool[]): void { this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator @@ -939,15 +830,6 @@ export class Client extends Protocol { const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); this._cachedToolOutputValidators.set(tool.name, toolValidator); } - - // If the tool supports task-based execution, cache that information - const taskSupport = tool.execution?.taskSupport; - if (taskSupport === 'required' || taskSupport === 'optional') { - this._cachedKnownTaskTools.add(tool.name); - } - if (taskSupport === 'required') { - this._cachedRequiredTaskTools.add(tool.name); - } } } diff --git a/packages/client/src/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 5c1689ca60..2ba5d85c58 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -51,18 +51,8 @@ export type { } from '../../shared/protocol.js'; export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; -// Task manager types (NOT TaskManager class itself — internal) -export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; - // Response message types -export type { - BaseResponseMessage, - ErrorMessage, - ResponseMessage, - ResultMessage, - TaskCreatedMessage, - TaskStatusMessage -} from '../../shared/responseMessage.js'; +export type { BaseResponseMessage, ErrorMessage, ResponseMessage, ResultMessage } from '../../shared/responseMessage.js'; export { takeResult, toArrayAsync } from '../../shared/responseMessage.js'; // stdio message framing utilities (for custom transport authors) @@ -92,7 +82,6 @@ export { LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, PARSE_ERROR, - RELATED_TASK_META_KEY, SUPPORTED_PROTOCOL_VERSIONS } from '../../types/constants.js'; @@ -114,29 +103,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..f47d456c00 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,8 +6,6 @@ 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/protocol.ts b/packages/core/src/shared/protocol.ts index 361bd6fc7c..1b44a685db 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -21,7 +21,6 @@ import type { NotificationTypeMap, Progress, ProgressNotification, - RelatedTaskMetadata, Request, RequestId, RequestMeta, @@ -29,8 +28,7 @@ import type { RequestTypeMap, Result, ResultTypeMap, - ServerCapabilities, - TaskCreationParams + ServerCapabilities } from '../types/index.js'; import { getNotificationSchema, @@ -46,8 +44,6 @@ import { } from '../types/index.js'; import type { StandardSchemaV1 } from '../util/standardSchema.js'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; -import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; -import { NullTaskManager, TaskManager } from './taskManager.js'; import type { Transport, TransportSendOptions } from './transport.js'; /** @@ -82,16 +78,6 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; - - /** - * Runtime configuration for task management. - * If provided, creates a TaskManager with the given options; otherwise a NullTaskManager is used. - * - * Capability assertions are wired automatically from the protocol's - * `assertTaskCapability()` and `assertTaskHandlerCapability()` methods, - * so they should NOT be included here. - */ - tasks?: TaskManagerOptions; }; /** @@ -105,8 +91,6 @@ export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000; export type RequestOptions = { /** * If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked. - * - * For task-augmented requests: progress notifications continue after {@linkcode CreateTaskResult} is returned and stop automatically when the task reaches a terminal status. */ onprogress?: ProgressCallback; @@ -135,16 +119,6 @@ export type RequestOptions = { * If not specified, there is no maximum total timeout. */ maxTotalTimeout?: number; - - /** - * If provided, augments the request with task creation parameters to enable call-now, fetch-later execution patterns. - */ - task?: TaskCreationParams; - - /** - * If provided, associates this request with a related task. - */ - relatedTask?: RelatedTaskMetadata; } & TransportSendOptions; /** @@ -155,11 +129,6 @@ export type NotificationOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing notification with. */ relatedRequestId?: RequestId; - - /** - * If provided, associates this notification with a related task. - */ - relatedTask?: RelatedTaskMetadata; }; /** @@ -206,12 +175,12 @@ export type BaseContext = { send: { ( request: { method: M; params?: Record }, - options?: TaskRequestOptions + options?: RequestOptions ): Promise; ( request: Request, resultSchema: T, - options?: TaskRequestOptions + options?: RequestOptions ): Promise>; }; @@ -232,11 +201,6 @@ export type BaseContext = { */ authInfo?: AuthInfo; }; - - /** - * Task context, available when task storage is configured. - */ - task?: TaskContext; }; /** @@ -319,8 +283,6 @@ export abstract class Protocol { private _timeoutInfo: Map = new Map(); private _pendingDebouncedNotifications = new Set(); - private _taskManager: TaskManager; - protected _supportedProtocolVersions: string[]; /** @@ -350,10 +312,6 @@ export abstract class Protocol { constructor(private _options?: ProtocolOptions) { this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; - // Create TaskManager from protocol options - this._taskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); - this._bindTaskManager(); - this.setNotificationHandler('notifications/cancelled', notification => { this._oncancel(notification); }); @@ -369,39 +327,6 @@ export abstract class Protocol { ); } - /** - * Access the TaskManager for task orchestration. - * Always available; returns a NullTaskManager when no task store is configured. - */ - get taskManager(): TaskManager { - return this._taskManager; - } - - private _bindTaskManager(): void { - const taskManager = this._taskManager; - const host: TaskManagerHost = { - request: (request, resultSchema, options) => this._requestWithSchema(request, resultSchema, options), - notification: (notification, options) => this.notification(notification, options), - reportError: error => this._onerror(error), - removeProgressHandler: token => this._progressHandlers.delete(token), - registerHandler: (method, handler) => { - const schema = getRequestSchema(method as RequestMethod); - this._requestHandlers.set(method, (request, ctx) => { - // Validate request params via Zod (strips jsonrpc/id, so we pass original to handler) - schema.parse(request); - return handler(request, ctx); - }); - }, - sendOnResponseStream: async (message, relatedRequestId) => { - await this._transport?.send(message, { relatedRequestId }); - }, - enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, - assertTaskCapability: method => this.assertTaskCapability(method), - assertTaskHandlerCapability: method => this.assertTaskHandlerCapability(method) - }; - taskManager.bind(host); - } - /** * Builds the context object for request handlers. Subclasses must override * to return the appropriate context type (e.g., ServerContext adds HTTP request info). @@ -506,7 +431,6 @@ export abstract class Protocol { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); - this._taskManager.onClose(); this._pendingDebouncedNotifications.clear(); for (const info of this._timeoutInfo.values()) { @@ -558,23 +482,10 @@ export abstract class Protocol { // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; - // Delegate context extraction to module (if registered) - const inboundCtx = { - sessionId: capturedTransport?.sessionId, - sendNotification: (notification: Notification, options?: NotificationOptions) => - this.notification(notification, { ...options, relatedRequestId: request.id }), - sendRequest: (r: Request, resultSchema: U, options?: RequestOptions) => - this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }) - }; - - // Delegate to TaskManager for task context, wrapped send/notify, and response routing - const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); - const sendNotification = taskResult.sendNotification; - const sendRequest = taskResult.sendRequest; - const taskContext = taskResult.taskContext; - const routeResponse = taskResult.routeResponse; - const validators: Array<() => void> = []; - if (taskResult.validateInbound) validators.push(taskResult.validateInbound); + const sendNotification = (notification: Notification, options?: NotificationOptions) => + this.notification(notification, { ...options, relatedRequestId: request.id }); + const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }); if (handler === undefined) { const errorResponse: JSONRPCErrorResponse = { @@ -586,16 +497,7 @@ export abstract class Protocol { } }; - // Queue or send the error response based on whether this is a task-related request - routeResponse(errorResponse) - .then(routed => { - if (!routed) { - capturedTransport - ?.send(errorResponse) - .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); - } - }) - .catch(error => this._onerror(new Error(`Failed to enqueue error response: ${error}`))); + capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); return; } @@ -613,7 +515,7 @@ export abstract class Protocol { // literals can't carry overload signatures, so the inferred single-signature type isn't assignable to // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. - send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | TaskRequestOptions, maybeOptions?: TaskRequestOptions) => { + send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => { if (isStandardSchema(schemaOrOptions)) { return sendRequest(r, schemaOrOptions, maybeOptions); } @@ -627,18 +529,12 @@ export abstract class Protocol { }) as BaseContext['mcpReq']['send'], notify: sendNotification }, - http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, - task: taskContext + http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined }; const ctx = this.buildContext(baseCtx, extra); // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => { - for (const validate of validators) { - validate(); - } - }) .then(() => handler(request, ctx)) .then( async result => { @@ -653,11 +549,7 @@ export abstract class Protocol { id: request.id }; - // Queue or send the response based on whether this is a task-related request - const routed = await routeResponse(response); - if (!routed) { - await capturedTransport?.send(response); - } + await capturedTransport?.send(response); }, async error => { if (abortController.signal.aborted) { @@ -675,11 +567,7 @@ export abstract class Protocol { } }; - // Queue or send the error response based on whether this is a task-related request - const routed = await routeResponse(errorResponse); - if (!routed) { - await capturedTransport?.send(errorResponse); - } + await capturedTransport?.send(errorResponse); } ) .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) @@ -722,11 +610,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 +618,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 +660,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 +694,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 +801,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. + // _progressHandlers is NOT cleaned up here: _onresponse deletes it, + // and error paths above delete it inline. if (onAbort) { options?.signal?.removeEventListener('abort', onAbort); } @@ -996,21 +830,16 @@ 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', + method: notification.method, + ...(notification.params && { params: notification.params }) + }; 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. diff --git a/packages/core/src/shared/responseMessage.ts b/packages/core/src/shared/responseMessage.ts index 25922a355f..a66725c065 100644 --- a/packages/core/src/shared/responseMessage.ts +++ b/packages/core/src/shared/responseMessage.ts @@ -1,4 +1,4 @@ -import type { Result, Task } from '../types/index.js'; +import type { Result } from '../types/index.js'; /** * Base message type for the response stream. @@ -7,28 +7,6 @@ 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. * @@ -51,20 +29,11 @@ export interface ErrorMessage extends BaseResponseMessage { } /** - * 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) + * Union of all message types yielded by streaming APIs. * - * Progress notifications are handled through the existing {@linkcode index.RequestOptions | onprogress} callback. - * Side-channeled messages (server requests/notifications) are handled through registered handlers. + * A stream yields either a `result` (success) or `error` (failure) — both terminal. */ -export type ResponseMessage = TaskStatusMessage | TaskCreatedMessage | ResultMessage | ErrorMessage; +export type ResponseMessage = ResultMessage | ErrorMessage; export type AsyncGeneratorValue = T extends AsyncGenerator ? U : never; @@ -81,9 +50,8 @@ export async function toArrayAsync>(it: T): Pr } /** - * 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. + * Consumes a {@linkcode ResponseMessage} stream and returns the final result. + * 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) { diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts deleted file mode 100644 index 257dbec827..0000000000 --- a/packages/core/src/shared/taskManager.ts +++ /dev/null @@ -1,915 +0,0 @@ -import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js'; -import { isTerminal } from '../experimental/tasks/interfaces.js'; -import type { - GetTaskPayloadRequest, - GetTaskRequest, - GetTaskResult, - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - JSONRPCResultResponse, - Notification, - Request, - RequestId, - Result, - Task, - TaskCreationParams, - TaskStatusNotification -} from '../types/index.js'; -import { - CancelTaskResultSchema, - CreateTaskResultSchema, - GetTaskResultSchema, - isJSONRPCErrorResponse, - isJSONRPCRequest, - isJSONRPCResultResponse, - isTaskAugmentedRequestParams, - ListTasksResultSchema, - ProtocolError, - ProtocolErrorCode, - RELATED_TASK_META_KEY, - TaskStatusNotificationSchema -} from '../types/index.js'; -import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; -import type { StandardSchemaV1 } from '../util/standardSchema.js'; -import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js'; -import type { ResponseMessage } from './responseMessage.js'; - -/** - * Host interface for TaskManager to call back into Protocol. @internal - */ -export interface TaskManagerHost { - request( - request: Request, - resultSchema: T, - options?: RequestOptions - ): Promise>; - notification(notification: Notification, options?: NotificationOptions): Promise; - reportError(error: Error): void; - removeProgressHandler(token: number): void; - registerHandler(method: string, handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise): void; - sendOnResponseStream(message: JSONRPCNotification | JSONRPCRequest, relatedRequestId: RequestId): Promise; - enforceStrictCapabilities: boolean; - assertTaskCapability(method: string): void; - assertTaskHandlerCapability(method: string): void; -} - -/** - * Context provided to TaskManager when processing an inbound request. - * @internal - */ -export interface InboundContext { - sessionId?: string; - sendNotification: (notification: Notification, options?: NotificationOptions) => Promise; - sendRequest: ( - request: Request, - resultSchema: U, - options?: RequestOptions - ) => Promise>; -} - -/** - * Result returned by TaskManager after processing an inbound request. - * @internal - */ -export interface InboundResult { - taskContext?: BaseContext['task']; - sendNotification: (notification: Notification) => Promise; - sendRequest: ( - request: Request, - resultSchema: U, - options?: Omit - ) => Promise>; - routeResponse: (message: JSONRPCResponse | JSONRPCErrorResponse) => Promise; - hasTaskCreationParams: boolean; - /** - * Optional validation to run inside the async handler chain (before the request handler). - * Throwing here produces a proper JSON-RPC error response, matching the behavior of - * capability checks on main. - */ - validateInbound?: () => void; -} - -/** - * Options that can be given per request. - */ -// relatedTask is excluded as the SDK controls if this is sent according to if the source is a task. -export type TaskRequestOptions = Omit; - -/** - * Request-scoped TaskStore interface. - */ -export interface RequestTaskStore { - /** - * Creates a new task with the given creation parameters. - * The implementation generates a unique taskId and createdAt timestamp. - * - * @param taskParams - The task creation parameters from the request - * @returns The created task object - */ - createTask(taskParams: CreateTaskOptions): Promise; - - /** - * Gets the current status of a task. - * - * @param taskId - The task identifier - * @returns The task object - * @throws If the task does not exist - */ - getTask(taskId: string): Promise; - - /** - * Stores the result of a task and sets its final status. - * - * @param taskId - The task identifier - * @param status - The final status: 'completed' for success, 'failed' for errors - * @param result - The result to store - */ - storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result): Promise; - - /** - * Retrieves the stored result of a task. - * - * @param taskId - The task identifier - * @returns The stored result - */ - getTaskResult(taskId: string): Promise; - - /** - * Updates a task's status (e.g., to 'cancelled', 'failed', 'completed'). - * - * @param taskId - The task identifier - * @param status - The new status - * @param statusMessage - Optional diagnostic message for failed tasks or other status information - */ - updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string): Promise; - - /** - * Lists tasks, optionally starting from a pagination cursor. - * - * @param cursor - Optional cursor for pagination - * @returns An object containing the tasks array and an optional nextCursor - */ - listTasks(cursor?: string): Promise<{ tasks: Task[]; nextCursor?: string }>; -} - -/** - * Task context provided to request handlers when task storage is configured. - */ -export type TaskContext = { - id?: string; - store: RequestTaskStore; - requestedTtl?: number; -}; - -export type TaskManagerOptions = { - /** - * Task storage implementation. Required for handling incoming task requests (server-side). - * Not required for sending task requests (client-side outbound API). - */ - taskStore?: TaskStore; - /** - * Optional task message queue implementation for managing server-initiated messages - * that will be delivered through the tasks/result response stream. - */ - taskMessageQueue?: TaskMessageQueue; - /** - * Default polling interval (in milliseconds) for task status checks when no pollInterval - * is provided by the server. Defaults to 1000ms if not specified. - */ - defaultTaskPollInterval?: number; - /** - * Maximum number of messages that can be queued per task for side-channel delivery. - * If undefined, the queue size is unbounded. - */ - maxTaskQueueSize?: number; -}; - -/** - * Extracts {@linkcode TaskManagerOptions} from a capability object that mixes in runtime fields. - * Returns `undefined` when no task capability is configured. - */ -export function extractTaskManagerOptions(tasksCapability: TaskManagerOptions | undefined): TaskManagerOptions | undefined { - if (!tasksCapability) return undefined; - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize } = tasksCapability; - return { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize }; -} - -/** - * Manages task orchestration: state, message queuing, and polling. - * Capability checking is delegated to the Protocol host. - * @internal - */ -export class TaskManager { - private _taskStore?: TaskStore; - private _taskMessageQueue?: TaskMessageQueue; - private _taskProgressTokens: Map = new Map(); - private _requestResolvers: Map void> = new Map(); - private _options: TaskManagerOptions; - private _host?: TaskManagerHost; - - constructor(options: TaskManagerOptions) { - this._options = options; - this._taskStore = options.taskStore; - this._taskMessageQueue = options.taskMessageQueue; - } - - bind(host: TaskManagerHost): void { - this._host = host; - - if (this._taskStore) { - host.registerHandler('tasks/get', async (request, ctx) => { - const params = request.params as { taskId: string }; - const task = await this.handleGetTask(params.taskId, ctx.sessionId); - // Per spec: tasks/get responses SHALL NOT include related-task metadata - // as the taskId parameter is the source of truth - return { - ...task - } as Result; - }); - - host.registerHandler('tasks/result', async (request, ctx) => { - const params = request.params as { taskId: string }; - return await this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => { - // Send the message on the response stream by passing the relatedRequestId - // This tells the transport to write the message to the tasks/result response stream - await host.sendOnResponseStream(message, ctx.mcpReq.id); - }); - }); - - host.registerHandler('tasks/list', async (request, ctx) => { - const params = request.params as { cursor?: string } | undefined; - return (await this.handleListTasks(params?.cursor, ctx.sessionId)) as Result; - }); - - host.registerHandler('tasks/cancel', async (request, ctx) => { - const params = request.params as { taskId: string }; - return await this.handleCancelTask(params.taskId, ctx.sessionId); - }); - } - } - - protected get _requireHost(): TaskManagerHost { - if (!this._host) { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskManager is not bound to a Protocol host — call bind() first'); - } - return this._host; - } - - get taskStore(): TaskStore | undefined { - return this._taskStore; - } - - private get _requireTaskStore(): TaskStore { - if (!this._taskStore) { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskStore is not configured'); - } - return this._taskStore; - } - - get taskMessageQueue(): TaskMessageQueue | undefined { - return this._taskMessageQueue; - } - - // -- Public API (client-facing) -- - async *requestStream( - request: Request, - resultSchema: T, - options?: RequestOptions - ): AsyncGenerator>, void, void> { - const host = this._requireHost; - const { task } = options ?? {}; - - if (!task) { - try { - // TODO: SchemaOutput (Zod) and StandardSchemaV1.InferOutput (host.request's return) - // resolve to the same type for Zod schemas, but TS can't unify them generically. - // Removing this cast requires aligning ResponseMessage with StandardSchema. - const result = (await host.request(request, resultSchema, options)) as SchemaOutput; - yield { type: 'result', result }; - } catch (error) { - yield { - type: 'error', - error: error instanceof Error ? error : new Error(String(error)) - }; - } - return; - } - - let taskId: string | undefined; - try { - const createResult = await host.request(request, CreateTaskResultSchema, options); - - if (createResult.task) { - taskId = createResult.task.taskId; - yield { type: 'taskCreated', task: createResult.task }; - } else { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'Task creation did not return a task'); - } - - while (true) { - const task = await this.getTask({ taskId }, options); - yield { type: 'taskStatus', task }; - - if (isTerminal(task.status)) { - switch (task.status) { - case 'completed': - case 'failed': { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: 'result', result }; - break; - } - case 'cancelled': { - yield { - type: 'error', - error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} was cancelled`) - }; - break; - } - } - return; - } - - if (task.status === 'input_required') { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: 'result', result }; - return; - } - - const pollInterval = task.pollInterval ?? this._options.defaultTaskPollInterval ?? 1000; - await new Promise(resolve => setTimeout(resolve, pollInterval)); - options?.signal?.throwIfAborted(); - } - } catch (error) { - yield { - type: 'error', - error: error instanceof Error ? error : new Error(String(error)) - }; - } - } - - async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise { - return this._requireHost.request({ method: 'tasks/get', params }, GetTaskResultSchema, options); - } - - async getTaskResult( - params: GetTaskPayloadRequest['params'], - resultSchema: T, - options?: RequestOptions - ): Promise> { - // TODO: same SchemaOutput vs StandardSchemaV1.InferOutput mismatch as requestStream above. - return this._requireHost.request({ method: 'tasks/result', params }, resultSchema, options) as Promise>; - } - - async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise> { - return this._requireHost.request({ method: 'tasks/list', params }, ListTasksResultSchema, options); - } - - async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise> { - return this._requireHost.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); - } - - // -- Handler bodies (delegated from Protocol's registered handlers) -- - - private async handleGetTask(taskId: string, sessionId?: string): Promise { - const task = await this._requireTaskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); - } - return task; - } - - private async handleGetTaskPayload( - taskId: string, - sessionId: string | undefined, - signal: AbortSignal, - sendOnResponseStream: (message: JSONRPCNotification | JSONRPCRequest) => Promise - ): Promise { - const handleTaskResult = async (): Promise => { - if (this._taskMessageQueue) { - let queuedMessage: QueuedMessage | undefined; - while ((queuedMessage = await this._taskMessageQueue.dequeue(taskId, sessionId))) { - if (queuedMessage.type === 'response' || queuedMessage.type === 'error') { - const message = queuedMessage.message; - const requestId = message.id; - const resolver = this._requestResolvers.get(requestId as RequestId); - - if (resolver) { - this._requestResolvers.delete(requestId as RequestId); - if (queuedMessage.type === 'response') { - resolver(message as JSONRPCResultResponse); - } else { - const errorMessage = message as JSONRPCErrorResponse; - resolver(new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data)); - } - } else { - const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error'; - this._host?.reportError(new Error(`${messageType} handler missing for request ${requestId}`)); - } - continue; - } - - await sendOnResponseStream(queuedMessage.message as JSONRPCNotification | JSONRPCRequest); - } - } - - const task = await this._requireTaskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); - } - - if (!isTerminal(task.status)) { - await this._waitForTaskUpdate(task.pollInterval, signal); - return await handleTaskResult(); - } - - const result = await this._requireTaskStore.getTaskResult(taskId, sessionId); - await this._clearTaskQueue(taskId); - - return { - ...result, - _meta: { - ...result._meta, - [RELATED_TASK_META_KEY]: { taskId } - } - }; - }; - - return await handleTaskResult(); - } - - private async handleListTasks( - cursor: string | undefined, - sessionId?: string - ): Promise<{ tasks: Task[]; nextCursor?: string; _meta: Record }> { - try { - const { tasks, nextCursor } = await this._requireTaskStore.listTasks(cursor, sessionId); - return { tasks, nextCursor, _meta: {} }; - } catch (error) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Failed to list tasks: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - private async handleCancelTask(taskId: string, sessionId?: string): Promise { - try { - const task = await this._requireTaskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); - } - - if (isTerminal(task.status)) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); - } - - await this._requireTaskStore.updateTaskStatus(taskId, 'cancelled', 'Client cancelled task execution.', sessionId); - await this._clearTaskQueue(taskId); - - const cancelledTask = await this._requireTaskStore.getTask(taskId, sessionId); - if (!cancelledTask) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found after cancellation: ${taskId}`); - } - - return { _meta: {}, ...cancelledTask }; - } catch (error) { - if (error instanceof ProtocolError) throw error; - throw new ProtocolError( - ProtocolErrorCode.InvalidRequest, - `Failed to cancel task: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - // -- Internal delegation methods -- - - private prepareOutboundRequest( - jsonrpcRequest: JSONRPCRequest, - options: RequestOptions | undefined, - messageId: number, - responseHandler: (response: JSONRPCResultResponse | Error) => void, - onError: (error: unknown) => void - ): boolean { - const { task, relatedTask } = options ?? {}; - - if (task) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - task: task - }; - } - - if (relatedTask) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - _meta: { - ...jsonrpcRequest.params?._meta, - [RELATED_TASK_META_KEY]: relatedTask - } - }; - } - - const relatedTaskId = relatedTask?.taskId; - if (relatedTaskId) { - this._requestResolvers.set(messageId, responseHandler); - - this._enqueueTaskMessage(relatedTaskId, { - type: 'request', - message: jsonrpcRequest, - timestamp: Date.now() - }).catch(error => { - onError(error); - }); - - return true; - } - - return false; - } - - private extractInboundTaskContext( - request: JSONRPCRequest, - sessionId?: string - ): { - relatedTaskId?: string; - taskCreationParams?: TaskCreationParams; - taskContext?: TaskContext; - } { - const relatedTaskId = (request.params?._meta as Record | undefined)?.[RELATED_TASK_META_KEY]?.taskId; - const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined; - - // Provide task context whenever a task store is configured, - // not just for task-related requests — tools need ctx.task.store - let taskContext: TaskContext | undefined; - if (this._taskStore) { - const store = this.createRequestTaskStore(request, sessionId); - taskContext = { - id: relatedTaskId, - store, - requestedTtl: taskCreationParams?.ttl - }; - } - - if (!relatedTaskId && !taskCreationParams && !taskContext) { - return {}; - } - - return { - relatedTaskId, - taskCreationParams, - taskContext - }; - } - - private wrapSendNotification( - relatedTaskId: string, - originalSendNotification: (notification: Notification, options?: NotificationOptions) => Promise - ): (notification: Notification) => Promise { - return async (notification: Notification) => { - const notificationOptions: NotificationOptions = { relatedTask: { taskId: relatedTaskId } }; - await originalSendNotification(notification, notificationOptions); - }; - } - - private wrapSendRequest( - relatedTaskId: string, - taskStore: RequestTaskStore | undefined, - originalSendRequest: ( - request: Request, - resultSchema: V, - options?: RequestOptions - ) => Promise> - ): ( - request: Request, - resultSchema: V, - options?: TaskRequestOptions - ) => Promise> { - return async (request: Request, resultSchema: V, options?: TaskRequestOptions) => { - const requestOptions: RequestOptions = { ...options }; - if (relatedTaskId && !requestOptions.relatedTask) { - requestOptions.relatedTask = { taskId: relatedTaskId }; - } - - const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; - if (effectiveTaskId && taskStore) { - await taskStore.updateTaskStatus(effectiveTaskId, 'input_required'); - } - - return await originalSendRequest(request, resultSchema, requestOptions); - }; - } - - private handleResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { - const messageId = Number(response.id); - const resolver = this._requestResolvers.get(messageId); - if (resolver) { - this._requestResolvers.delete(messageId); - if (isJSONRPCResultResponse(response)) { - resolver(response); - } else { - resolver(new ProtocolError(response.error.code, response.error.message, response.error.data)); - } - return true; - } - return false; - } - - private shouldPreserveProgressHandler(response: JSONRPCResponse | JSONRPCErrorResponse, messageId: number): boolean { - if (isJSONRPCResultResponse(response) && response.result && typeof response.result === 'object') { - const result = response.result as Record; - if (result.task && typeof result.task === 'object') { - const task = result.task as Record; - if (typeof task.taskId === 'string') { - this._taskProgressTokens.set(task.taskId, messageId); - return true; - } - } - } - return false; - } - - private async routeNotification(notification: Notification, options?: NotificationOptions): Promise { - const relatedTaskId = options?.relatedTask?.taskId; - if (!relatedTaskId) return false; - - const jsonrpcNotification: JSONRPCNotification = { - ...notification, - jsonrpc: '2.0', - params: { - ...notification.params, - _meta: { - ...notification.params?._meta, - [RELATED_TASK_META_KEY]: options!.relatedTask - } - } - }; - - await this._enqueueTaskMessage(relatedTaskId, { - type: 'notification', - message: jsonrpcNotification, - timestamp: Date.now() - }); - - return true; - } - - private async routeResponse( - relatedTaskId: string | undefined, - message: JSONRPCResponse | JSONRPCErrorResponse, - sessionId?: string - ): Promise { - if (!relatedTaskId || !this._taskMessageQueue) return false; - - await (isJSONRPCErrorResponse(message) - ? this._enqueueTaskMessage(relatedTaskId, { type: 'error', message, timestamp: Date.now() }, sessionId) - : this._enqueueTaskMessage( - relatedTaskId, - { type: 'response', message: message as JSONRPCResultResponse, timestamp: Date.now() }, - sessionId - )); - return true; - } - - private createRequestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore { - const taskStore = this._requireTaskStore; - const host = this._host; - - return { - createTask: async taskParams => { - if (!request) throw new Error('No request provided'); - return await taskStore.createTask(taskParams, request.id, { method: request.method, params: request.params }, sessionId); - }, - getTask: async taskId => { - const task = await taskStore.getTask(taskId, sessionId); - if (!task) throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); - return task; - }, - storeTaskResult: async (taskId, status, result) => { - await taskStore.storeTaskResult(taskId, status, result, sessionId); - const task = await taskStore.getTask(taskId, sessionId); - if (task) { - const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ - method: 'notifications/tasks/status', - params: task - }); - await host?.notification(notification as Notification); - if (isTerminal(task.status)) { - this._cleanupTaskProgressHandler(taskId); - } - } - }, - getTaskResult: taskId => taskStore.getTaskResult(taskId, sessionId), - updateTaskStatus: async (taskId, status, statusMessage) => { - const task = await taskStore.getTask(taskId, sessionId); - if (!task) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); - } - if (isTerminal(task.status)) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.` - ); - } - await taskStore.updateTaskStatus(taskId, status, statusMessage, sessionId); - const updatedTask = await taskStore.getTask(taskId, sessionId); - if (updatedTask) { - const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ - method: 'notifications/tasks/status', - params: updatedTask - }); - await host?.notification(notification as Notification); - if (isTerminal(updatedTask.status)) { - this._cleanupTaskProgressHandler(taskId); - } - } - }, - listTasks: cursor => taskStore.listTasks(cursor, sessionId) - }; - } - - // -- Lifecycle methods (called by Protocol directly) -- - - processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { - const taskInfo = this.extractInboundTaskContext(request, ctx.sessionId); - const relatedTaskId = taskInfo?.relatedTaskId; - - const sendNotification = relatedTaskId - ? this.wrapSendNotification(relatedTaskId, ctx.sendNotification) - : (notification: Notification) => ctx.sendNotification(notification); - - const sendRequest = relatedTaskId - ? this.wrapSendRequest(relatedTaskId, taskInfo?.taskContext?.store, ctx.sendRequest) - : taskInfo?.taskContext - ? this.wrapSendRequest('', taskInfo.taskContext.store, ctx.sendRequest) - : ctx.sendRequest; - - const hasTaskCreationParams = !!taskInfo?.taskCreationParams; - - return { - taskContext: taskInfo?.taskContext, - sendNotification, - sendRequest, - routeResponse: async (message: JSONRPCResponse | JSONRPCErrorResponse) => { - if (relatedTaskId) { - return this.routeResponse(relatedTaskId, message, ctx.sessionId); - } - return false; - }, - hasTaskCreationParams, - // Deferred validation: runs inside the async handler chain so errors - // produce proper JSON-RPC error responses (matching main's behavior). - validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined - }; - } - - processOutboundRequest( - jsonrpcRequest: JSONRPCRequest, - options: RequestOptions | undefined, - messageId: number, - responseHandler: (response: JSONRPCResultResponse | Error) => void, - onError: (error: unknown) => void - ): { queued: boolean } { - // Check task capability when sending a task-augmented request (matches main's enforceStrictCapabilities gate) - if (this._requireHost.enforceStrictCapabilities && options?.task) { - this._requireHost.assertTaskCapability(jsonrpcRequest.method); - } - - const queued = this.prepareOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, onError); - return { queued }; - } - - processInboundResponse( - response: JSONRPCResponse | JSONRPCErrorResponse, - messageId: number - ): { consumed: boolean; preserveProgress: boolean } { - const consumed = this.handleResponse(response); - if (consumed) { - return { consumed: true, preserveProgress: false }; - } - const preserveProgress = this.shouldPreserveProgressHandler(response, messageId); - return { consumed: false, preserveProgress }; - } - - async processOutboundNotification( - notification: Notification, - options?: NotificationOptions - ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> { - // Try queuing first - const queued = await this.routeNotification(notification, options); - if (queued) return { queued: true }; - - // Build JSONRPC notification with optional relatedTask metadata - let jsonrpcNotification: JSONRPCNotification = { ...notification, jsonrpc: '2.0' }; - if (options?.relatedTask) { - jsonrpcNotification = { - ...jsonrpcNotification, - params: { - ...jsonrpcNotification.params, - _meta: { - ...jsonrpcNotification.params?._meta, - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - } - return { queued: false, jsonrpcNotification }; - } - - onClose(): void { - this._taskProgressTokens.clear(); - this._requestResolvers.clear(); - } - - // -- Private helpers -- - - private async _enqueueTaskMessage(taskId: string, message: QueuedMessage, sessionId?: string): Promise { - if (!this._taskStore || !this._taskMessageQueue) { - throw new Error('Cannot enqueue task message: taskStore and taskMessageQueue are not configured'); - } - await this._taskMessageQueue.enqueue(taskId, message, sessionId, this._options.maxTaskQueueSize); - } - - private async _clearTaskQueue(taskId: string, sessionId?: string): Promise { - if (this._taskMessageQueue) { - const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); - for (const message of messages) { - if (message.type === 'request' && isJSONRPCRequest(message.message)) { - const requestId = message.message.id as RequestId; - const resolver = this._requestResolvers.get(requestId); - if (resolver) { - resolver(new ProtocolError(ProtocolErrorCode.InternalError, 'Task cancelled or completed')); - this._requestResolvers.delete(requestId); - } else { - this._host?.reportError(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); - } - } - } - } - } - - private async _waitForTaskUpdate(pollInterval: number | undefined, signal: AbortSignal): Promise { - const interval = pollInterval ?? this._options.defaultTaskPollInterval ?? 1000; - - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled')); - return; - } - const timeoutId = setTimeout(resolve, interval); - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeoutId); - reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled')); - }, - { once: true } - ); - }); - } - - private _cleanupTaskProgressHandler(taskId: string): void { - const progressToken = this._taskProgressTokens.get(taskId); - if (progressToken !== undefined) { - this._host?.removeProgressHandler(progressToken); - this._taskProgressTokens.delete(taskId); - } - } -} - -/** - * No-op TaskManager used when tasks capability is not configured. - * Provides passthrough implementations for the hot paths, avoiding - * unnecessary task extraction logic on every request. - */ -export class NullTaskManager extends TaskManager { - constructor() { - super({}); - } - - override processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { - const hasTaskCreationParams = isTaskAugmentedRequestParams(request.params) && !!request.params.task; - return { - taskContext: undefined, - sendNotification: (notification: Notification) => ctx.sendNotification(notification), - sendRequest: ctx.sendRequest, - routeResponse: async () => false, - hasTaskCreationParams, - validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined - }; - } - - // processOutboundRequest is inherited - it handles task/relatedTask augmentation - // and only queues if relatedTask is set (which won't happen without a task store) - - // processInboundResponse is inherited - it checks _requestResolvers (empty for NullTaskManager) - // and _taskProgressTokens (empty for NullTaskManager) - - override async processOutboundNotification( - notification: Notification, - _options?: NotificationOptions - ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> { - return { queued: false, jsonrpcNotification: { ...notification, jsonrpc: '2.0' } }; - } -} diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 878d5111cf..1766f0c8e5 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,8 +2,6 @@ export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; -export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; - /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts index f385b91b42..c8185320a9 100644 --- a/packages/core/src/types/guards.ts +++ b/packages/core/src/types/guards.ts @@ -7,8 +7,7 @@ import { JSONRPCNotificationSchema, JSONRPCRequestSchema, JSONRPCResponseSchema, - JSONRPCResultResponseSchema, - TaskAugmentedRequestParamsSchema + JSONRPCResultResponseSchema } from './schemas.js'; import type { CallToolResult, @@ -22,8 +21,7 @@ import type { JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, - JSONRPCResultResponse, - TaskAugmentedRequestParams + JSONRPCResultResponse } from './types.js'; /** @@ -81,15 +79,6 @@ export const isCallToolResult = (value: unknown): value is CallToolResult => { return CallToolResultSchema.safeParse(value).success; }; -/** - * Checks if a value is a valid {@linkcode TaskAugmentedRequestParams}. - * @param value - The value to check. - * - * @returns True if the value is a valid {@linkcode TaskAugmentedRequestParams}, false otherwise. - */ -export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => - TaskAugmentedRequestParamsSchema.safeParse(value).success; - export const isInitializeRequest = (value: unknown): value is InitializeRequest => InitializeRequestSchema.safeParse(value).success; export const isInitializedNotification = (value: unknown): value is InitializedNotification => diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index a243c1b829..1388b70756 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,6 +1,6 @@ import * as z from 'zod/v4'; -import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; +import { JSONRPC_VERSION } from './constants.js'; import type { JSONArray, JSONObject, @@ -27,42 +27,11 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); -/** - * Task creation parameters, used to ask that the server create a task to represent a request. - */ -export const TaskCreationParamsSchema = z.looseObject({ - /** - * Requested duration in milliseconds to retain task from creation. - */ - ttl: z.number().optional(), - - /** - * Time in milliseconds to wait between task status requests. - */ - pollInterval: z.number().optional() -}); - -export const TaskMetadataSchema = z.object({ - ttl: z.number().optional() -}); - -/** - * Metadata for associating messages with a task. - * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. - */ -export const RelatedTaskMetadataSchema = z.object({ - taskId: z.string() -}); - export const RequestMetaSchema = z.looseObject({ /** * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. */ - progressToken: ProgressTokenSchema.optional(), - /** - * If specified, this request is related to the provided task. - */ - [RELATED_TASK_META_KEY]: RelatedTaskMetadataSchema.optional() + progressToken: ProgressTokenSchema.optional() }); /** @@ -75,21 +44,6 @@ export const BaseRequestParamsSchema = z.object({ _meta: RequestMetaSchema.optional() }); -/** - * Common params for any task-augmented request. - */ -export const TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ - /** - * If specified, the caller is requesting task-augmented execution for this request. - * The request will return a `CreateTaskResult` immediately, and the actual result can be - * retrieved later via `tasks/result`. - * - * Task augmentation is subject to capability negotiation - receivers MUST declare support - * for task augmentation of specific request types in their capabilities. - */ - task: TaskMetadataSchema.optional() -}); - export const RequestSchema = z.object({ method: z.string(), params: BaseRequestParamsSchema.loose().optional() @@ -331,72 +285,6 @@ const ElicitationCapabilitySchema = z.preprocess( ) ); -/** - * Task capabilities for clients, indicating which request types support task creation. - */ -export const ClientTasksCapabilitySchema = z.looseObject({ - /** - * Present if the client supports listing tasks. - */ - list: JSONObjectSchema.optional(), - /** - * Present if the client supports cancelling tasks. - */ - cancel: JSONObjectSchema.optional(), - /** - * Capabilities for task creation on specific request types. - */ - requests: z - .looseObject({ - /** - * Task support for sampling requests. - */ - sampling: z - .looseObject({ - createMessage: JSONObjectSchema.optional() - }) - .optional(), - /** - * Task support for elicitation requests. - */ - elicitation: z - .looseObject({ - create: JSONObjectSchema.optional() - }) - .optional() - }) - .optional() -}); - -/** - * Task capabilities for servers, indicating which request types support task creation. - */ -export const ServerTasksCapabilitySchema = z.looseObject({ - /** - * Present if the server supports listing tasks. - */ - list: JSONObjectSchema.optional(), - /** - * Present if the server supports cancelling tasks. - */ - cancel: JSONObjectSchema.optional(), - /** - * Capabilities for task creation on specific request types. - */ - requests: z - .looseObject({ - /** - * Task support for tool requests. - */ - tools: z - .looseObject({ - call: JSONObjectSchema.optional() - }) - .optional() - }) - .optional() -}); - /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. */ @@ -436,10 +324,6 @@ 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). */ @@ -516,10 +400,6 @@ 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). */ @@ -616,120 +496,6 @@ export const PaginatedResultSchema = ResultSchema.extend({ nextCursor: CursorSchema.optional() }); -/** - * The status of a task. - * */ -export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); - -/* Tasks */ -/** - * A pollable state object associated with a request. - */ -export const TaskSchema = z.object({ - taskId: z.string(), - status: TaskStatusSchema, - /** - * Time in milliseconds to keep task results available after completion. - * If `null`, the task has unlimited lifetime until manually cleaned up. - */ - ttl: z.union([z.number(), z.null()]), - /** - * ISO 8601 timestamp when the task was created. - */ - createdAt: z.string(), - /** - * ISO 8601 timestamp when the task was last updated. - */ - lastUpdatedAt: z.string(), - pollInterval: z.optional(z.number()), - /** - * Optional diagnostic message for failed tasks or other status information. - */ - statusMessage: z.optional(z.string()) -}); - -/** - * Result returned when a task is created, containing the task data wrapped in a `task` field. - */ -export const CreateTaskResultSchema = ResultSchema.extend({ - task: TaskSchema -}); - -/** - * Parameters for task status notification. - */ -export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); - -/** - * A notification sent when a task's status changes. - */ -export const TaskStatusNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/tasks/status'), - params: TaskStatusNotificationParamsSchema -}); - -/** - * A request to get the state of a specific task. - */ -export const GetTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/get'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode GetTaskRequest | tasks/get} request. - */ -export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); - -/** - * A request to get the result of a specific task. - */ -export const GetTaskPayloadRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/result'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a `tasks/result` request. - * The structure matches the result type of the original request. - * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. - * - */ -export const GetTaskPayloadResultSchema = ResultSchema.loose(); - -/** - * A request to list tasks. - */ -export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: z.literal('tasks/list') -}); - -/** - * The response to a {@linkcode ListTasksRequest | tasks/list} request. - */ -export const ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: z.array(TaskSchema) -}); - -/** - * A request to cancel a specific task. - */ -export const CancelTaskRequestSchema = RequestSchema.extend({ - method: z.literal('tasks/cancel'), - params: BaseRequestParamsSchema.extend({ - taskId: z.string() - }) -}); - -/** - * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. - */ -export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); - /* Resources */ /** * The contents of a specific resource or sub-resource. @@ -1282,21 +1048,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. */ @@ -1335,10 +1086,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) @@ -1409,7 +1156,7 @@ export const CompatibilityCallToolResultSchema = CallToolResultSchema.or( /** * Parameters for a `tools/call` request. */ -export const CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const CallToolRequestParamsSchema = BaseRequestParamsSchema.extend({ /** * The name of the tool to call. */ @@ -1607,7 +1354,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 +1593,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 +1620,7 @@ export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.ex /** * Parameters for an {@linkcode ElicitRequest | elicitation/create} request for URL-based elicitation. */ -export const ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ +export const ElicitRequestURLParamsSchema = BaseRequestParamsSchema.extend({ /** * The elicitation mode. */ @@ -2089,19 +1836,14 @@ export const ClientRequestSchema = z.union([ SubscribeRequestSchema, UnsubscribeRequestSchema, CallToolRequestSchema, - ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema + ListToolsRequestSchema ]); export const ClientNotificationSchema = z.union([ CancelledNotificationSchema, ProgressNotificationSchema, InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema + RootsListChangedNotificationSchema ]); export const ClientResultSchema = z.union([ @@ -2109,23 +1851,11 @@ export const ClientResultSchema = z.union([ CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListRootsResultSchema ]); /* Server messages */ -export const ServerRequestSchema = z.union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); +export const ServerRequestSchema = z.union([PingRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, ListRootsRequestSchema]); export const ServerNotificationSchema = z.union([ CancelledNotificationSchema, @@ -2135,7 +1865,6 @@ export const ServerNotificationSchema = z.union([ ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, ElicitationCompleteNotificationSchema ]); @@ -2149,10 +1878,7 @@ export const ServerResultSchema = z.union([ ListResourceTemplatesResultSchema, ReadResourceResultSchema, CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema + ListToolsResultSchema ]); /* Runtime schema lookup — result schemas by method */ @@ -2168,15 +1894,11 @@ const resultSchemas: Record = { 'resources/read': ReadResourceResultSchema, 'resources/subscribe': EmptyResultSchema, 'resources/unsubscribe': EmptyResultSchema, - 'tools/call': z.union([CallToolResultSchema, CreateTaskResultSchema]), + 'tools/call': CallToolResultSchema, 'tools/list': ListToolsResultSchema, - 'sampling/createMessage': z.union([CreateMessageResultWithToolsSchema, CreateTaskResultSchema]), - 'elicitation/create': z.union([ElicitResultSchema, CreateTaskResultSchema]), - 'roots/list': ListRootsResultSchema, - 'tasks/get': GetTaskResultSchema, - 'tasks/result': ResultSchema, - 'tasks/list': ListTasksResultSchema, - 'tasks/cancel': CancelTaskResultSchema + 'sampling/createMessage': CreateMessageResultWithToolsSchema, + 'elicitation/create': ElicitResultSchema, + 'roots/list': ListRootsResultSchema }; /** diff --git a/packages/core/src/types/spec.types.ts b/packages/core/src/types/spec.types.ts index a03f21f134..026a186168 100644 --- a/packages/core/src/types/spec.types.ts +++ b/packages/core/src/types/spec.types.ts @@ -87,23 +87,6 @@ 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. * @@ -281,7 +264,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 * @@ -363,8 +345,6 @@ export interface CancelledNotificationParams extends NotificationParams { * The ID of the request to cancel. * * This MUST correspond to the ID of a request previously issued in the same direction. - * This MUST be provided for cancelling non-task requests. - * This MUST NOT be used for cancelling tasks (use the {@link CancelTaskRequest | tasks/cancel} request instead). */ requestId?: RequestId; @@ -383,8 +363,6 @@ export interface CancelledNotificationParams extends NotificationParams { * * A client MUST NOT attempt to cancel its `initialize` request. * - * For task cancellation, use the {@link CancelTaskRequest | tasks/cancel} request instead of this notification. - * * @example User-requested cancellation * {@includeCode ./examples/CancelledNotification/user-requested-cancellation.json} * @@ -541,43 +519,6 @@ export interface ClientCapabilities { form?: JSONObject; url?: JSONObject; }; - - /** - * Present if the client supports task-augmented requests. - */ - tasks?: { - /** - * Whether this client supports {@link ListTasksRequest | tasks/list}. - */ - list?: JSONObject; - /** - * Whether this client supports {@link CancelTaskRequest | tasks/cancel}. - */ - cancel?: JSONObject; - /** - * Specifies which request types can be augmented with tasks. - */ - requests?: { - /** - * Task support for sampling-related requests. - */ - sampling?: { - /** - * Whether the client supports task-augmented `sampling/createMessage` requests. - */ - createMessage?: JSONObject; - }; - /** - * Task support for elicitation-related requests. - */ - elicitation?: { - /** - * Whether the client supports task-augmented {@link ElicitRequest | elicitation/create} requests. - */ - create?: JSONObject; - }; - }; - }; /** * Optional MCP extensions that the client supports. Keys are extension identifiers * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are @@ -668,33 +609,6 @@ export interface ServerCapabilities { */ listChanged?: boolean; }; - /** - * Present if the server supports task-augmented requests. - */ - tasks?: { - /** - * Whether this server supports {@link ListTasksRequest | tasks/list}. - */ - list?: JSONObject; - /** - * Whether this server supports {@link CancelTaskRequest | tasks/cancel}. - */ - cancel?: JSONObject; - /** - * Specifies which request types can be augmented with tasks. - */ - requests?: { - /** - * Task support for tool-related requests. - */ - tools?: { - /** - * Whether the server supports task-augmented {@link CallToolRequest | tools/call} requests. - */ - call?: JSONObject; - }; - }; - }; /** * Optional MCP extensions that the server supports. Keys are extension identifiers * (e.g., "io.modelcontextprotocol/apps"), and values are per-extension settings @@ -1594,7 +1508,7 @@ export interface CallToolResultResponse extends JSONRPCResultResponse { * * @category `tools/call` */ -export interface CallToolRequestParams extends TaskAugmentedRequestParams { +export interface CallToolRequestParams extends RequestParams { /** * The name of the tool. */ @@ -1687,26 +1601,6 @@ export interface ToolAnnotations { openWorldHint?: boolean; } -/** - * Execution-related properties for a tool. - * - * @category `tools/list` - */ -export interface ToolExecution { - /** - * Indicates whether this tool supports task-augmented execution. - * This allows clients to handle long-running operations through polling - * the task system. - * - * - `"forbidden"`: Tool does not support task-augmented execution (default when absent) - * - `"optional"`: Tool may support task-augmented execution - * - `"required"`: Tool requires task-augmented execution - * - * Default: `"forbidden"` - */ - taskSupport?: 'forbidden' | 'optional' | 'required'; -} - /** * Definition for a tool the client can call. * @@ -1742,11 +1636,6 @@ export interface Tool extends BaseMetadata, Icons { required?: string[]; }; - /** - * Execution-related properties for this tool. - */ - execution?: ToolExecution; - /** * An optional JSON Schema object defining the structure of the tool's output returned in * the structuredContent field of a {@link CallToolResult}. @@ -1771,252 +1660,6 @@ export interface Tool extends BaseMetadata, Icons { _meta?: MetaObject; } -/* Tasks */ - -/** - * The status of a task. - * - * @category `tasks` - */ -export type TaskStatus = - | 'working' // The request is currently being processed - | 'input_required' // The task is waiting for input (e.g., elicitation or sampling) - | 'completed' // The request completed successfully and results are available - | 'failed' // The associated request did not complete successfully. For tool calls specifically, this includes cases where the tool call result has `isError` set to true. - | 'cancelled'; // The request was cancelled before completion - -/** - * Metadata for augmenting a request with task execution. - * Include this in the `task` field of the request parameters. - * - * @category `tasks` - */ -export interface TaskMetadata { - /** - * Requested duration in milliseconds to retain task from creation. - */ - ttl?: number; -} - -/** - * Metadata for associating messages with a task. - * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. - * - * @category `tasks` - */ -export interface RelatedTaskMetadata { - /** - * The task identifier this message is associated with. - */ - taskId: string; -} - -/** - * Data associated with a task. - * - * @category `tasks` - */ -export interface Task { - /** - * The task identifier. - */ - taskId: string; - - /** - * Current task state. - */ - status: TaskStatus; - - /** - * Optional human-readable message describing the current task state. - * This can provide context for any status, including: - * - Reasons for "cancelled" status - * - Summaries for "completed" status - * - Diagnostic information for "failed" status (e.g., error details, what went wrong) - */ - statusMessage?: string; - - /** - * ISO 8601 timestamp when the task was created. - */ - createdAt: string; - - /** - * ISO 8601 timestamp when the task was last updated. - */ - lastUpdatedAt: string; - - /** - * Actual retention duration from creation in milliseconds, null for unlimited. - * @nullable - */ - ttl: number | null; - - /** - * Suggested polling interval in milliseconds. - */ - pollInterval?: number; -} - -/** - * The result returned for a task-augmented request. - * - * @category `tasks` - */ -export interface CreateTaskResult extends Result { - task: Task; -} - -/** - * A successful response for a task-augmented request. - * - * @category `tasks` - */ -export interface CreateTaskResultResponse extends JSONRPCResultResponse { - result: CreateTaskResult; -} - -/** - * A request to retrieve the state of a task. - * - * @category `tasks/get` - */ -export interface GetTaskRequest extends JSONRPCRequest { - method: 'tasks/get'; - params: { - /** - * The task identifier to query. - */ - taskId: string; - }; -} - -/** - * The result returned for a {@link GetTaskRequest | tasks/get} request. - * - * @category `tasks/get` - */ -export type GetTaskResult = Result & Task; - -/** - * A successful response for a {@link GetTaskRequest | tasks/get} request. - * - * @category `tasks/get` - */ -export interface GetTaskResultResponse extends JSONRPCResultResponse { - result: GetTaskResult; -} - -/** - * A request to retrieve the result of a completed task. - * - * @category `tasks/result` - */ -export interface GetTaskPayloadRequest extends JSONRPCRequest { - method: 'tasks/result'; - params: { - /** - * The task identifier to retrieve results for. - */ - taskId: string; - }; -} - -/** - * The result returned for a {@link GetTaskPayloadRequest | tasks/result} request. - * The structure matches the result type of the original request. - * For example, a {@link CallToolRequest | tools/call} task would return the {@link CallToolResult} structure. - * - * @category `tasks/result` - */ -export interface GetTaskPayloadResult extends Result { - [key: string]: unknown; -} - -/** - * A successful response for a {@link GetTaskPayloadRequest | tasks/result} request. - * - * @category `tasks/result` - */ -export interface GetTaskPayloadResultResponse extends JSONRPCResultResponse { - result: GetTaskPayloadResult; -} - -/** - * A request to cancel a task. - * - * @category `tasks/cancel` - */ -export interface CancelTaskRequest extends JSONRPCRequest { - method: 'tasks/cancel'; - params: { - /** - * The task identifier to cancel. - */ - taskId: string; - }; -} - -/** - * The result returned for a {@link CancelTaskRequest | tasks/cancel} request. - * - * @category `tasks/cancel` - */ -export type CancelTaskResult = Result & Task; - -/** - * A successful response for a {@link CancelTaskRequest | tasks/cancel} request. - * - * @category `tasks/cancel` - */ -export interface CancelTaskResultResponse extends JSONRPCResultResponse { - result: CancelTaskResult; -} - -/** - * A request to retrieve a list of tasks. - * - * @category `tasks/list` - */ -export interface ListTasksRequest extends PaginatedRequest { - method: 'tasks/list'; -} - -/** - * The result returned for a {@link ListTasksRequest | tasks/list} request. - * - * @category `tasks/list` - */ -export interface ListTasksResult extends PaginatedResult { - tasks: Task[]; -} - -/** - * A successful response for a {@link ListTasksRequest | tasks/list} request. - * - * @category `tasks/list` - */ -export interface ListTasksResultResponse extends JSONRPCResultResponse { - result: ListTasksResult; -} - -/** - * Parameters for a `notifications/tasks/status` notification. - * - * @category `notifications/tasks/status` - */ -export type TaskStatusNotificationParams = NotificationParams & Task; - -/** - * An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications. - * - * @category `notifications/tasks/status` - */ -export interface TaskStatusNotification extends JSONRPCNotification { - method: 'notifications/tasks/status'; - params: TaskStatusNotificationParams; -} - /* Logging */ /** @@ -2120,7 +1763,7 @@ export type LoggingLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | ' * * @category `sampling/createMessage` */ -export interface CreateMessageRequestParams extends TaskAugmentedRequestParams { +export interface CreateMessageRequestParams extends RequestParams { messages: SamplingMessage[]; /** * The server's preferences for which model to select. The client MAY ignore these preferences. @@ -2782,7 +2425,7 @@ export interface RootsListChangedNotification extends JSONRPCNotification { * * @category `elicitation/create` */ -export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { +export interface ElicitRequestFormParams extends RequestParams { /** * The elicitation mode. */ @@ -2815,7 +2458,7 @@ export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { * * @category `elicitation/create` */ -export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { +export interface ElicitRequestURLParams extends RequestParams { /** * The elicitation mode. */ @@ -3182,42 +2825,17 @@ export type ClientRequest = | SubscribeRequest | UnsubscribeRequest | CallToolRequest - | ListToolsRequest - | GetTaskRequest - | GetTaskPayloadRequest - | ListTasksRequest - | CancelTaskRequest; + | ListToolsRequest; /** @internal */ -export type ClientNotification = - | CancelledNotification - | ProgressNotification - | InitializedNotification - | RootsListChangedNotification - | TaskStatusNotification; +export type ClientNotification = CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification; /** @internal */ -export type ClientResult = - | EmptyResult - | CreateMessageResult - | ListRootsResult - | ElicitResult - | GetTaskResult - | GetTaskPayloadResult - | ListTasksResult - | CancelTaskResult; +export type ClientResult = EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult; /* Server messages */ /** @internal */ -export type ServerRequest = - | PingRequest - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest - | GetTaskRequest - | GetTaskPayloadRequest - | ListTasksRequest - | CancelTaskRequest; +export type ServerRequest = PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest; /** @internal */ export type ServerNotification = @@ -3228,8 +2846,7 @@ export type ServerNotification = | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompleteNotification - | TaskStatusNotification; + | ElicitationCompleteNotification; /** @internal */ export type ServerResult = @@ -3242,9 +2859,4 @@ export type ServerResult = | ListResourcesResult | ReadResourceResult | CallToolResult - | CreateTaskResult - | ListToolsResult - | GetTaskResult - | GetTaskPayloadResult - | ListTasksResult - | CancelTaskResult; + | ListToolsResult; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts index cde3555d07..45e268bb3c 100644 --- a/packages/core/src/types/specTypeSchema.ts +++ b/packages/core/src/types/specTypeSchema.ts @@ -21,7 +21,7 @@ import * as schemas from './schemas.js'; * * This intentionally excludes internal helper schemas exported from `schemas.ts` that have no * matching public type (e.g. `ListChangedOptionsBaseSchema`, `BaseRequestParamsSchema`, - * `NotificationsParamsSchema`, `ClientTasksCapabilitySchema`, `ServerTasksCapabilitySchema`). + * `NotificationsParamsSchema`). * Keeping the list explicit means new public spec types must be added here deliberately, and * internals never leak into `SpecTypeName`. * @@ -41,8 +41,6 @@ const SPEC_SCHEMA_KEYS = [ 'CallToolResultSchema', 'CancelledNotificationSchema', 'CancelledNotificationParamsSchema', - 'CancelTaskRequestSchema', - 'CancelTaskResultSchema', 'ClientCapabilitiesSchema', 'ClientNotificationSchema', 'ClientRequestSchema', @@ -56,7 +54,6 @@ const SPEC_SCHEMA_KEYS = [ 'CreateMessageRequestParamsSchema', 'CreateMessageResultSchema', 'CreateMessageResultWithToolsSchema', - 'CreateTaskResultSchema', 'CursorSchema', 'ElicitationCompleteNotificationSchema', 'ElicitationCompleteNotificationParamsSchema', @@ -71,10 +68,6 @@ const SPEC_SCHEMA_KEYS = [ 'GetPromptRequestSchema', 'GetPromptRequestParamsSchema', 'GetPromptResultSchema', - 'GetTaskPayloadRequestSchema', - 'GetTaskPayloadResultSchema', - 'GetTaskRequestSchema', - 'GetTaskResultSchema', 'IconSchema', 'IconsSchema', 'ImageContentSchema', @@ -101,8 +94,6 @@ const SPEC_SCHEMA_KEYS = [ 'ListResourceTemplatesResultSchema', 'ListRootsRequestSchema', 'ListRootsResultSchema', - 'ListTasksRequestSchema', - 'ListTasksResultSchema', 'ListToolsRequestSchema', 'ListToolsResultSchema', 'LoggingLevelSchema', @@ -130,7 +121,6 @@ const SPEC_SCHEMA_KEYS = [ 'ReadResourceRequestSchema', 'ReadResourceRequestParamsSchema', 'ReadResourceResultSchema', - 'RelatedTaskMetadataSchema', 'RequestSchema', 'RequestIdSchema', 'RequestMetaSchema', @@ -160,13 +150,6 @@ const SPEC_SCHEMA_KEYS = [ 'StringSchemaSchema', 'SubscribeRequestSchema', 'SubscribeRequestParamsSchema', - 'TaskSchema', - 'TaskAugmentedRequestParamsSchema', - 'TaskCreationParamsSchema', - 'TaskMetadataSchema', - 'TaskStatusSchema', - 'TaskStatusNotificationSchema', - 'TaskStatusNotificationParamsSchema', 'TextContentSchema', 'TextResourceContentsSchema', 'TitledMultiSelectEnumSchemaSchema', @@ -174,7 +157,6 @@ const SPEC_SCHEMA_KEYS = [ 'ToolSchema', 'ToolAnnotationsSchema', 'ToolChoiceSchema', - 'ToolExecutionSchema', 'ToolListChangedNotificationSchema', 'ToolResultContentSchema', 'ToolUseContentSchema', diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index a92deec8e1..a9c95360fe 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -17,8 +17,6 @@ import type { CallToolResultSchema, CancelledNotificationParamsSchema, CancelledNotificationSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, ClientCapabilitiesSchema, ClientNotificationSchema, ClientRequestSchema, @@ -32,7 +30,6 @@ import type { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, CursorSchema, ElicitationCompleteNotificationParamsSchema, ElicitationCompleteNotificationSchema, @@ -47,10 +44,6 @@ import type { GetPromptRequestParamsSchema, GetPromptRequestSchema, GetPromptResultSchema, - GetTaskPayloadRequestSchema, - GetTaskPayloadResultSchema, - GetTaskRequestSchema, - GetTaskResultSchema, IconSchema, IconsSchema, ImageContentSchema, @@ -74,8 +67,6 @@ import type { ListResourceTemplatesResultSchema, ListRootsRequestSchema, ListRootsResultSchema, - ListTasksRequestSchema, - ListTasksResultSchema, ListToolsRequestSchema, ListToolsResultSchema, LoggingLevelSchema, @@ -104,7 +95,6 @@ import type { ReadResourceRequestParamsSchema, ReadResourceRequestSchema, ReadResourceResultSchema, - RelatedTaskMetadataSchema, RequestIdSchema, RequestMetaSchema, RequestSchema, @@ -134,20 +124,12 @@ import type { StringSchemaSchema, SubscribeRequestParamsSchema, SubscribeRequestSchema, - TaskAugmentedRequestParamsSchema, - TaskCreationParamsSchema, - TaskMetadataSchema, - TaskSchema, - TaskStatusNotificationParamsSchema, - TaskStatusNotificationSchema, - TaskStatusSchema, TextContentSchema, TextResourceContentsSchema, TitledMultiSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema, ToolAnnotationsSchema, ToolChoiceSchema, - ToolExecutionSchema, ToolListChangedNotificationSchema, ToolResultContentSchema, ToolSchema, @@ -187,7 +169,6 @@ type Infer = Flatten>; export type ProgressToken = Infer; export type Cursor = Infer; export type Request = Infer; -export type TaskAugmentedRequestParams = Infer; export type RequestMeta = Infer; export type Notification = Infer; export type Result = Infer; @@ -232,24 +213,6 @@ export type Progress = Infer; export type ProgressNotificationParams = Infer; export type ProgressNotification = Infer; -/* Tasks */ -export type Task = Infer; -export type TaskStatus = Infer; -export type TaskCreationParams = Infer; -export type TaskMetadata = Infer; -export type RelatedTaskMetadata = Infer; -export type CreateTaskResult = Infer; -export type TaskStatusNotificationParams = Infer; -export type TaskStatusNotification = Infer; -export type GetTaskRequest = Infer; -export type GetTaskResult = Infer; -export type GetTaskPayloadRequest = Infer; -export type ListTasksRequest = Infer; -export type ListTasksResult = Infer; -export type CancelTaskRequest = Infer; -export type CancelTaskResult = Infer; -export type GetTaskPayloadResult = Infer; - /* Pagination */ export type PaginatedRequestParams = Infer; export type PaginatedRequest = Infer; @@ -299,7 +262,6 @@ export type PromptListChangedNotification = Infer; -export type ToolExecution = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; @@ -392,15 +354,11 @@ export type ResultTypeMap = { 'resources/read': ReadResourceResult; 'resources/subscribe': EmptyResult; 'resources/unsubscribe': EmptyResult; - 'tools/call': CallToolResult | CreateTaskResult; + 'tools/call': CallToolResult; 'tools/list': ListToolsResult; - 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; - 'elicitation/create': ElicitResult | CreateTaskResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools; + 'elicitation/create': ElicitResult; 'roots/list': ListRootsResult; - 'tasks/get': GetTaskResult; - 'tasks/result': Result; - 'tasks/list': ListTasksResult; - 'tasks/cancel': CancelTaskResult; }; /** diff --git a/packages/core/test/experimental/inMemory.test.ts b/packages/core/test/experimental/inMemory.test.ts deleted file mode 100644 index 7639cad9f4..0000000000 --- a/packages/core/test/experimental/inMemory.test.ts +++ /dev/null @@ -1,1035 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { QueuedMessage } from '../../src/experimental/tasks/interfaces.js'; -import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../src/experimental/tasks/stores/inMemory.js'; -import type { Request, TaskCreationParams } from '../../src/types/index.js'; - -describe('InMemoryTaskStore', () => { - let store: InMemoryTaskStore; - - beforeEach(() => { - store = new InMemoryTaskStore(); - }); - - afterEach(() => { - store.cleanup(); - }); - - describe('createTask', () => { - it('should create a new task with working status', async () => { - const taskParams: TaskCreationParams = { - ttl: 60_000 - }; - const request: Request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const task = await store.createTask(taskParams, 123, request); - - expect(task).toBeDefined(); - expect(task.taskId).toBeDefined(); - expect(typeof task.taskId).toBe('string'); - expect(task.taskId.length).toBeGreaterThan(0); - expect(task.status).toBe('working'); - expect(task.ttl).toBe(60_000); - expect(task.pollInterval).toBeDefined(); - expect(task.createdAt).toBeDefined(); - expect(new Date(task.createdAt).getTime()).toBeGreaterThan(0); - }); - - it('should create task without ttl', async () => { - const taskParams: TaskCreationParams = {}; - const request: Request = { - method: 'tools/call', - params: {} - }; - - const task = await store.createTask(taskParams, 456, request); - - expect(task).toBeDefined(); - expect(task.ttl).toBeNull(); - }); - - it('should generate unique taskIds', async () => { - const taskParams: TaskCreationParams = {}; - const request: Request = { - method: 'tools/call', - params: {} - }; - - const task1 = await store.createTask(taskParams, 789, request); - const task2 = await store.createTask(taskParams, 790, request); - - expect(task1.taskId).not.toBe(task2.taskId); - }); - }); - - describe('getTask', () => { - it('should return null for non-existent task', async () => { - const task = await store.getTask('non-existent'); - expect(task).toBeNull(); - }); - - it('should return task state', async () => { - const taskParams: TaskCreationParams = {}; - const request: Request = { - method: 'tools/call', - params: {} - }; - - const createdTask = await store.createTask(taskParams, 111, request); - await store.updateTaskStatus(createdTask.taskId, 'working'); - - const task = await store.getTask(createdTask.taskId); - expect(task).toBeDefined(); - expect(task?.status).toBe('working'); - }); - }); - - describe('updateTaskStatus', () => { - let taskId: string; - - beforeEach(async () => { - const taskParams: TaskCreationParams = {}; - const createdTask = await store.createTask(taskParams, 222, { - method: 'tools/call', - params: {} - }); - taskId = createdTask.taskId; - }); - - it('should keep task status as working', async () => { - const task = await store.getTask(taskId); - expect(task?.status).toBe('working'); - }); - - it('should update task status to input_required', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('input_required'); - }); - - it('should update task status to completed', async () => { - await store.updateTaskStatus(taskId, 'completed'); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('completed'); - }); - - it('should update task status to failed with error', async () => { - await store.updateTaskStatus(taskId, 'failed', 'Something went wrong'); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('failed'); - expect(task?.statusMessage).toBe('Something went wrong'); - }); - - it('should update task status to cancelled', async () => { - await store.updateTaskStatus(taskId, 'cancelled'); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('cancelled'); - }); - - it('should throw if task not found', async () => { - await expect(store.updateTaskStatus('non-existent', 'working')).rejects.toThrow('Task with ID non-existent not found'); - }); - - describe('status lifecycle validation', () => { - it('should allow transition from working to input_required', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('input_required'); - }); - - it('should allow transition from working to completed', async () => { - await store.updateTaskStatus(taskId, 'completed'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('completed'); - }); - - it('should allow transition from working to failed', async () => { - await store.updateTaskStatus(taskId, 'failed'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('failed'); - }); - - it('should allow transition from working to cancelled', async () => { - await store.updateTaskStatus(taskId, 'cancelled'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('cancelled'); - }); - - it('should allow transition from input_required to working', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - await store.updateTaskStatus(taskId, 'working'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('working'); - }); - - it('should allow transition from input_required to completed', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - await store.updateTaskStatus(taskId, 'completed'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('completed'); - }); - - it('should allow transition from input_required to failed', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - await store.updateTaskStatus(taskId, 'failed'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('failed'); - }); - - it('should allow transition from input_required to cancelled', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - await store.updateTaskStatus(taskId, 'cancelled'); - const task = await store.getTask(taskId); - expect(task?.status).toBe('cancelled'); - }); - - it('should reject transition from completed to any other status', async () => { - await store.updateTaskStatus(taskId, 'completed'); - await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task'); - }); - - it('should reject transition from failed to any other status', async () => { - await store.updateTaskStatus(taskId, 'failed'); - await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task'); - }); - - it('should reject transition from cancelled to any other status', async () => { - await store.updateTaskStatus(taskId, 'cancelled'); - await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task'); - await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task'); - }); - }); - }); - - describe('storeTaskResult', () => { - let taskId: string; - - beforeEach(async () => { - const taskParams: TaskCreationParams = { - ttl: 60_000 - }; - const createdTask = await store.createTask(taskParams, 333, { - method: 'tools/call', - params: {} - }); - taskId = createdTask.taskId; - }); - - it('should store task result and set status to completed', async () => { - const result = { - content: [{ type: 'text' as const, text: 'Success!' }] - }; - - await store.storeTaskResult(taskId, 'completed', result); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('completed'); - - const storedResult = await store.getTaskResult(taskId); - expect(storedResult).toStrictEqual(result); - }); - - it('should throw if task not found', async () => { - await expect(store.storeTaskResult('non-existent', 'completed', {})).rejects.toThrow('Task with ID non-existent not found'); - }); - - it('should reject storing result for task already in completed status', async () => { - // First complete the task - const firstResult = { - content: [{ type: 'text' as const, text: 'First result' }] - }; - await store.storeTaskResult(taskId, 'completed', firstResult); - - // Try to store result again (should fail) - const secondResult = { - content: [{ type: 'text' as const, text: 'Second result' }] - }; - - await expect(store.storeTaskResult(taskId, 'completed', secondResult)).rejects.toThrow('Cannot store result for task'); - }); - - it('should store result with failed status', async () => { - const result = { - content: [{ type: 'text' as const, text: 'Error details' }], - isError: true - }; - - await store.storeTaskResult(taskId, 'failed', result); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('failed'); - - const storedResult = await store.getTaskResult(taskId); - expect(storedResult).toStrictEqual(result); - }); - - it('should reject storing result for task already in failed status', async () => { - // First fail the task - const firstResult = { - content: [{ type: 'text' as const, text: 'First error' }], - isError: true - }; - await store.storeTaskResult(taskId, 'failed', firstResult); - - // Try to store result again (should fail) - const secondResult = { - content: [{ type: 'text' as const, text: 'Second error' }], - isError: true - }; - - await expect(store.storeTaskResult(taskId, 'failed', secondResult)).rejects.toThrow('Cannot store result for task'); - }); - - it('should reject storing result for cancelled task', async () => { - // Mark task as cancelled - await store.updateTaskStatus(taskId, 'cancelled'); - - // Try to store result (should fail) - const result = { - content: [{ type: 'text' as const, text: 'Cancellation result' }] - }; - - await expect(store.storeTaskResult(taskId, 'completed', result)).rejects.toThrow('Cannot store result for task'); - }); - - it('should allow storing result from input_required status', async () => { - await store.updateTaskStatus(taskId, 'input_required'); - - const result = { - content: [{ type: 'text' as const, text: 'Success!' }] - }; - - await store.storeTaskResult(taskId, 'completed', result); - - const task = await store.getTask(taskId); - expect(task?.status).toBe('completed'); - }); - }); - - describe('getTaskResult', () => { - it('should throw if task not found', async () => { - await expect(store.getTaskResult('non-existent')).rejects.toThrow('Task with ID non-existent not found'); - }); - - it('should throw if task has no result stored', async () => { - const taskParams: TaskCreationParams = {}; - const createdTask = await store.createTask(taskParams, 444, { - method: 'tools/call', - params: {} - }); - - await expect(store.getTaskResult(createdTask.taskId)).rejects.toThrow(`Task ${createdTask.taskId} has no result stored`); - }); - - it('should return stored result', async () => { - const taskParams: TaskCreationParams = {}; - const createdTask = await store.createTask(taskParams, 555, { - method: 'tools/call', - params: {} - }); - - const result = { - content: [{ type: 'text' as const, text: 'Result data' }] - }; - await store.storeTaskResult(createdTask.taskId, 'completed', result); - - const retrieved = await store.getTaskResult(createdTask.taskId); - expect(retrieved).toStrictEqual(result); - }); - }); - - describe('ttl cleanup', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should cleanup task after ttl duration', async () => { - const taskParams: TaskCreationParams = { - ttl: 1000 - }; - const createdTask = await store.createTask(taskParams, 666, { - method: 'tools/call', - params: {} - }); - - // Task should exist initially - let task = await store.getTask(createdTask.taskId); - expect(task).toBeDefined(); - - // Fast-forward past ttl - vi.advanceTimersByTime(1001); - - // Task should be cleaned up - task = await store.getTask(createdTask.taskId); - expect(task).toBeNull(); - }); - - it('should reset cleanup timer when result is stored', async () => { - const taskParams: TaskCreationParams = { - ttl: 1000 - }; - const createdTask = await store.createTask(taskParams, 777, { - method: 'tools/call', - params: {} - }); - - // Fast-forward 500ms - vi.advanceTimersByTime(500); - - // Store result (should reset timer) - await store.storeTaskResult(createdTask.taskId, 'completed', { - content: [{ type: 'text' as const, text: 'Done' }] - }); - - // Fast-forward another 500ms (total 1000ms since creation, but timer was reset) - vi.advanceTimersByTime(500); - - // Task should still exist - const task = await store.getTask(createdTask.taskId); - expect(task).toBeDefined(); - - // Fast-forward remaining time - vi.advanceTimersByTime(501); - - // Now task should be cleaned up - const cleanedTask = await store.getTask(createdTask.taskId); - expect(cleanedTask).toBeNull(); - }); - - it('should not cleanup tasks without ttl', async () => { - const taskParams: TaskCreationParams = {}; - const createdTask = await store.createTask(taskParams, 888, { - method: 'tools/call', - params: {} - }); - - // Fast-forward a long time - vi.advanceTimersByTime(100_000); - - // Task should still exist - const task = await store.getTask(createdTask.taskId); - expect(task).toBeDefined(); - }); - - it('should start cleanup timer when task reaches terminal state', async () => { - const taskParams: TaskCreationParams = { - ttl: 1000 - }; - const createdTask = await store.createTask(taskParams, 999, { - method: 'tools/call', - params: {} - }); - - // Task in non-terminal state, fast-forward - vi.advanceTimersByTime(1001); - - // Task should be cleaned up - let task = await store.getTask(createdTask.taskId); - expect(task).toBeNull(); - - // Create another task - const taskParams2: TaskCreationParams = { - ttl: 2000 - }; - const createdTask2 = await store.createTask(taskParams2, 1000, { - method: 'tools/call', - params: {} - }); - - // Update to terminal state - await store.updateTaskStatus(createdTask2.taskId, 'completed'); - - // Fast-forward past original ttl - vi.advanceTimersByTime(2001); - - // Task should be cleaned up - task = await store.getTask(createdTask2.taskId); - expect(task).toBeNull(); - }); - - it('should return actual TTL in task response', async () => { - // Test that the TaskStore returns the actual TTL it will use - // This implementation uses the requested TTL as-is, but implementations - // MAY override it (e.g., enforce maximum TTL limits) - const requestedTtl = 5000; - const taskParams: TaskCreationParams = { - ttl: requestedTtl - }; - const createdTask = await store.createTask(taskParams, 1111, { - method: 'tools/call', - params: {} - }); - - // The returned task should include the actual TTL that will be used - expect(createdTask.ttl).toBe(requestedTtl); - - // Verify the task is cleaned up after the actual TTL - vi.advanceTimersByTime(requestedTtl + 1); - const task = await store.getTask(createdTask.taskId); - expect(task).toBeNull(); - }); - - it('should support omitted TTL for unlimited lifetime', async () => { - // Test that omitting TTL means unlimited lifetime (server returns null) - // Per spec: clients omit ttl to let server decide, server returns null for unlimited - const taskParams: TaskCreationParams = {}; - const createdTask = await store.createTask(taskParams, 2222, { - method: 'tools/call', - params: {} - }); - - // The returned task should have null TTL (unlimited) - expect(createdTask.ttl).toBeNull(); - - // Task should not be cleaned up even after a long time - vi.advanceTimersByTime(100_000); - const task = await store.getTask(createdTask.taskId); - expect(task).toBeDefined(); - expect(task?.taskId).toBe(createdTask.taskId); - }); - - it('should cleanup tasks regardless of status', async () => { - // Test that TTL cleanup happens regardless of task status - const taskParams: TaskCreationParams = { - ttl: 1000 - }; - - // Create tasks in different statuses - const workingTask = await store.createTask(taskParams, 3333, { - method: 'tools/call', - params: {} - }); - - const completedTask = await store.createTask(taskParams, 4444, { - method: 'tools/call', - params: {} - }); - await store.storeTaskResult(completedTask.taskId, 'completed', { - content: [{ type: 'text' as const, text: 'Done' }] - }); - - const failedTask = await store.createTask(taskParams, 5555, { - method: 'tools/call', - params: {} - }); - await store.storeTaskResult(failedTask.taskId, 'failed', { - content: [{ type: 'text' as const, text: 'Error' }] - }); - - // Fast-forward past TTL - vi.advanceTimersByTime(1001); - - // All tasks should be cleaned up regardless of status - expect(await store.getTask(workingTask.taskId)).toBeNull(); - expect(await store.getTask(completedTask.taskId)).toBeNull(); - expect(await store.getTask(failedTask.taskId)).toBeNull(); - }); - }); - - describe('getAllTasks', () => { - it('should return all tasks', async () => { - await store.createTask({}, 1, { - method: 'tools/call', - params: {} - }); - await store.createTask({}, 2, { - method: 'tools/call', - params: {} - }); - await store.createTask({}, 3, { - method: 'tools/call', - params: {} - }); - - const tasks = store.getAllTasks(); - expect(tasks).toHaveLength(3); - // Verify all tasks have unique IDs - const taskIds = tasks.map(t => t.taskId); - expect(new Set(taskIds).size).toBe(3); - }); - - it('should return empty array when no tasks', () => { - const tasks = store.getAllTasks(); - expect(tasks).toStrictEqual([]); - }); - }); - - describe('listTasks', () => { - it('should return empty list when no tasks', async () => { - const result = await store.listTasks(); - expect(result.tasks).toStrictEqual([]); - expect(result.nextCursor).toBeUndefined(); - }); - - it('should return all tasks when less than page size', async () => { - await store.createTask({}, 1, { - method: 'tools/call', - params: {} - }); - await store.createTask({}, 2, { - method: 'tools/call', - params: {} - }); - await store.createTask({}, 3, { - method: 'tools/call', - params: {} - }); - - const result = await store.listTasks(); - expect(result.tasks).toHaveLength(3); - expect(result.nextCursor).toBeUndefined(); - }); - - it('should paginate when more than page size', async () => { - // Create 15 tasks (page size is 10) - for (let i = 1; i <= 15; i++) { - await store.createTask({}, i, { - method: 'tools/call', - params: {} - }); - } - - // Get first page - const page1 = await store.listTasks(); - expect(page1.tasks).toHaveLength(10); - expect(page1.nextCursor).toBeDefined(); - - // Get second page using cursor - const page2 = await store.listTasks(page1.nextCursor); - expect(page2.tasks).toHaveLength(5); - expect(page2.nextCursor).toBeUndefined(); - }); - - it('should throw error for invalid cursor', async () => { - await store.createTask({}, 1, { - method: 'tools/call', - params: {} - }); - - await expect(store.listTasks('non-existent-cursor')).rejects.toThrow('Invalid cursor: non-existent-cursor'); - }); - - it('should continue from cursor correctly', async () => { - // Create 5 tasks - for (let i = 1; i <= 5; i++) { - await store.createTask({}, i, { - method: 'tools/call', - params: {} - }); - } - - // Get first 3 tasks - const allTaskIds = store.getAllTasks().map(t => t.taskId); - const result = await store.listTasks(allTaskIds[2]); - - // Should get tasks after the third task - expect(result.tasks).toHaveLength(2); - }); - }); - - describe('session isolation', () => { - const baseRequest: Request = { method: 'tools/call', params: { name: 'demo' } }; - - it('should not allow session-b to list tasks created by session-a', async () => { - await store.createTask({}, 1, baseRequest, 'session-a'); - await store.createTask({}, 2, baseRequest, 'session-a'); - - const result = await store.listTasks(undefined, 'session-b'); - expect(result.tasks).toHaveLength(0); - }); - - it('should not allow session-b to read a task created by session-a', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - const result = await store.getTask(task.taskId, 'session-b'); - expect(result).toBeNull(); - }); - - it('should not allow session-b to update a task created by session-a', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - await expect(store.updateTaskStatus(task.taskId, 'cancelled', undefined, 'session-b')).rejects.toThrow('not found'); - }); - - it('should not allow session-b to store a result on session-a task', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - await expect(store.storeTaskResult(task.taskId, 'completed', { content: [] }, 'session-b')).rejects.toThrow('not found'); - }); - - it('should not allow session-b to get the result of session-a task', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - await store.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'secret' }] }, 'session-a'); - - await expect(store.getTaskResult(task.taskId, 'session-b')).rejects.toThrow('not found'); - }); - - it('should allow the owning session to access its own tasks', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - const retrieved = await store.getTask(task.taskId, 'session-a'); - expect(retrieved).toBeDefined(); - expect(retrieved?.taskId).toBe(task.taskId); - }); - - it('should list only tasks belonging to the requesting session', async () => { - await store.createTask({}, 1, baseRequest, 'session-a'); - await store.createTask({}, 2, baseRequest, 'session-b'); - await store.createTask({}, 3, baseRequest, 'session-a'); - - const resultA = await store.listTasks(undefined, 'session-a'); - expect(resultA.tasks).toHaveLength(2); - - const resultB = await store.listTasks(undefined, 'session-b'); - expect(resultB.tasks).toHaveLength(1); - }); - - it('should allow access when no sessionId is provided (backward compatibility)', async () => { - const task = await store.createTask({}, 1, baseRequest, 'session-a'); - - // No sessionId on read = no filtering - const retrieved = await store.getTask(task.taskId); - expect(retrieved).toBeDefined(); - }); - - it('should allow access when task was created without sessionId', async () => { - const task = await store.createTask({}, 1, baseRequest); - - // Any sessionId on read should still see the task - const retrieved = await store.getTask(task.taskId, 'session-b'); - expect(retrieved).toBeDefined(); - }); - - it('should paginate correctly within a session', async () => { - // Create 15 tasks for session-a, 5 for session-b - for (let i = 1; i <= 15; i++) { - await store.createTask({}, i, baseRequest, 'session-a'); - } - for (let i = 16; i <= 20; i++) { - await store.createTask({}, i, baseRequest, 'session-b'); - } - - // First page for session-a should have 10 - const page1 = await store.listTasks(undefined, 'session-a'); - expect(page1.tasks).toHaveLength(10); - expect(page1.nextCursor).toBeDefined(); - - // Second page for session-a should have 5 - const page2 = await store.listTasks(page1.nextCursor, 'session-a'); - expect(page2.tasks).toHaveLength(5); - expect(page2.nextCursor).toBeUndefined(); - - // session-b should only see its 5 - const resultB = await store.listTasks(undefined, 'session-b'); - expect(resultB.tasks).toHaveLength(5); - expect(resultB.nextCursor).toBeUndefined(); - }); - }); - - describe('cleanup', () => { - it('should clear all timers and tasks', async () => { - await store.createTask({ ttl: 1000 }, 1, { - method: 'tools/call', - params: {} - }); - await store.createTask({ ttl: 2000 }, 2, { - method: 'tools/call', - params: {} - }); - - expect(store.getAllTasks()).toHaveLength(2); - - store.cleanup(); - - expect(store.getAllTasks()).toHaveLength(0); - }); - }); -}); - -describe('InMemoryTaskMessageQueue', () => { - let queue: InMemoryTaskMessageQueue; - - beforeEach(() => { - queue = new InMemoryTaskMessageQueue(); - }); - - describe('enqueue and dequeue', () => { - it('should enqueue and dequeue request messages', async () => { - const requestMessage: QueuedMessage = { - type: 'request', - message: { - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-1', requestMessage); - const dequeued = await queue.dequeue('task-1'); - - expect(dequeued).toStrictEqual(requestMessage); - }); - - it('should enqueue and dequeue notification messages', async () => { - const notificationMessage: QueuedMessage = { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: { progress: 50, total: 100 } - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-2', notificationMessage); - const dequeued = await queue.dequeue('task-2'); - - expect(dequeued).toStrictEqual(notificationMessage); - }); - - it('should enqueue and dequeue response messages', async () => { - const responseMessage: QueuedMessage = { - type: 'response', - message: { - jsonrpc: '2.0', - id: 42, - result: { content: [{ type: 'text', text: 'Success' }] } - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-3', responseMessage); - const dequeued = await queue.dequeue('task-3'); - - expect(dequeued).toStrictEqual(responseMessage); - }); - - it('should return undefined when dequeuing from empty queue', async () => { - const dequeued = await queue.dequeue('task-empty'); - expect(dequeued).toBeUndefined(); - }); - - it('should maintain FIFO order for mixed message types', async () => { - const request: QueuedMessage = { - type: 'request', - message: { - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: {} - }, - timestamp: 1000 - }; - - const notification: QueuedMessage = { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: {} - }, - timestamp: 2000 - }; - - const response: QueuedMessage = { - type: 'response', - message: { - jsonrpc: '2.0', - id: 1, - result: {} - }, - timestamp: 3000 - }; - - await queue.enqueue('task-fifo', request); - await queue.enqueue('task-fifo', notification); - await queue.enqueue('task-fifo', response); - - expect(await queue.dequeue('task-fifo')).toStrictEqual(request); - expect(await queue.dequeue('task-fifo')).toStrictEqual(notification); - expect(await queue.dequeue('task-fifo')).toStrictEqual(response); - expect(await queue.dequeue('task-fifo')).toBeUndefined(); - }); - }); - - describe('dequeueAll', () => { - it('should dequeue all messages including responses', async () => { - const request: QueuedMessage = { - type: 'request', - message: { - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: {} - }, - timestamp: 1000 - }; - - const response: QueuedMessage = { - type: 'response', - message: { - jsonrpc: '2.0', - id: 1, - result: {} - }, - timestamp: 2000 - }; - - const notification: QueuedMessage = { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: {} - }, - timestamp: 3000 - }; - - await queue.enqueue('task-all', request); - await queue.enqueue('task-all', response); - await queue.enqueue('task-all', notification); - - const all = await queue.dequeueAll('task-all'); - - expect(all).toHaveLength(3); - expect(all[0]).toStrictEqual(request); - expect(all[1]).toStrictEqual(response); - expect(all[2]).toStrictEqual(notification); - }); - - it('should return empty array for non-existent task', async () => { - const all = await queue.dequeueAll('non-existent'); - expect(all).toStrictEqual([]); - }); - - it('should clear the queue after dequeueAll', async () => { - const message: QueuedMessage = { - type: 'request', - message: { - jsonrpc: '2.0', - id: 1, - method: 'test', - params: {} - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-clear', message); - await queue.dequeueAll('task-clear'); - - const dequeued = await queue.dequeue('task-clear'); - expect(dequeued).toBeUndefined(); - }); - }); - - describe('queue size limits', () => { - it('should throw when maxSize is exceeded', async () => { - const message: QueuedMessage = { - type: 'request', - message: { - jsonrpc: '2.0', - id: 1, - method: 'test', - params: {} - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-limit', message, undefined, 2); - await queue.enqueue('task-limit', message, undefined, 2); - - await expect(queue.enqueue('task-limit', message, undefined, 2)).rejects.toThrow('Task message queue overflow'); - }); - - it('should allow enqueue when under maxSize', async () => { - const message: QueuedMessage = { - type: 'response', - message: { - jsonrpc: '2.0', - id: 1, - result: {} - }, - timestamp: Date.now() - }; - - await expect(queue.enqueue('task-ok', message, undefined, 5)).resolves.toBeUndefined(); - }); - }); - - describe('task isolation', () => { - it('should isolate messages between different tasks', async () => { - const message1: QueuedMessage = { - type: 'request', - message: { - jsonrpc: '2.0', - id: 1, - method: 'test1', - params: {} - }, - timestamp: 1000 - }; - - const message2: QueuedMessage = { - type: 'response', - message: { - jsonrpc: '2.0', - id: 2, - result: {} - }, - timestamp: 2000 - }; - - await queue.enqueue('task-a', message1); - await queue.enqueue('task-b', message2); - - expect(await queue.dequeue('task-a')).toStrictEqual(message1); - expect(await queue.dequeue('task-b')).toStrictEqual(message2); - expect(await queue.dequeue('task-a')).toBeUndefined(); - expect(await queue.dequeue('task-b')).toBeUndefined(); - }); - }); - - describe('response message error handling', () => { - it('should handle response messages with errors', async () => { - const errorResponse: QueuedMessage = { - type: 'error', - message: { - jsonrpc: '2.0', - id: 1, - error: { - code: -32_600, - message: 'Invalid Request' - } - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-error', errorResponse); - const dequeued = await queue.dequeue('task-error'); - - expect(dequeued).toStrictEqual(errorResponse); - expect(dequeued?.type).toBe('error'); - }); - }); -}); diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts index 47e02c9bca..ffee5b9a7d 100644 --- a/packages/core/test/shared/customMethods.test.ts +++ b/packages/core/test/shared/customMethods.test.ts @@ -14,8 +14,6 @@ class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} } async function pair(): Promise<[TestProtocol, TestProtocol]> { diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376a..c1fd3d57c6 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -3,38 +3,10 @@ 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, - JSONRPCErrorResponse, - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - JSONRPCResultResponse, - Notification, - Request, - RequestId, - Result, - ServerCapabilities, - Task, - TaskCreationParams -} from '../../src/types/index.js'; -import { ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/index.js'; +import type { ClientCapabilities, JSONRPCMessage, Request, ServerCapabilities } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing @@ -42,29 +14,13 @@ 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); -} - -// 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>; - }; +function createTestProtocol(options?: ConstructorParameters[0]): TestProtocolImpl { + return new TestProtocolImpl(options); } // Mock Transport class @@ -80,95 +36,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. @@ -825,11 +692,11 @@ describe('protocol tests', () => { expect(sendSpy).toHaveBeenCalledTimes(1); expect(sendSpy).toHaveBeenCalledWith( expect.objectContaining({ - method: 'test/debounced', - params: undefined + method: 'test/debounced' }), undefined ); + expect(sendSpy.mock.calls[0]![0]).not.toHaveProperty('params'); }); it('should send non-debounced notifications immediately and multiple times', async () => { @@ -887,97 +754,6 @@ 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(); - }); - }); -}); - describe('mergeCapabilities', () => { it('should merge client capabilities', () => { const base: ClientCapabilities = { @@ -1067,4614 +843,3 @@ describe('mergeCapabilities', () => { expect(merged).toEqual({}); }); }); - -describe('Task-based execution', () => { - let protocol: Protocol; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = createTestProtocol({ taskStore: createMockTaskStore(), taskMessageQueue: new InMemoryTaskMessageQueue() }); - }); - - describe('request with task metadata', () => { - it('should include task parameters at top level', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000, - pollInterval: 1000 - } - }).catch(() => { - // May not complete, ignore error - }); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tools/call', - params: { - name: 'test-tool', - task: { - ttl: 30000, - pollInterval: 1000 - } - } - }), - expect.any(Object) - ); - }); - - it('should preserve existing _meta and add task parameters at top level', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { - name: 'test-tool', - _meta: { - customField: 'customValue' - } - } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 60000 - } - }).catch(() => { - // May not complete, ignore error - }); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - name: 'test-tool', - _meta: { - customField: 'customValue' - }, - task: { - ttl: 60000 - } - } - }), - expect.any(Object) - ); - }); - - it('should return Promise for task-augmented request', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - const resultPromise = testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000 - } - }); - - expect(resultPromise).toBeDefined(); - expect(resultPromise).toBeInstanceOf(Promise); - }); - }); - - describe('relatedTask metadata', () => { - it('should inject relatedTask metadata into _meta field', async () => { - await protocol.connect(transport); - - const request = { - method: 'notifications/message', - params: { data: 'test' } - }; - - const resultSchema = z.object({}); - - // Start the request (don't await completion, just let it send) - void testRequest(protocol, request, resultSchema, { - relatedTask: { - taskId: 'parent-task-123' - } - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be queued - await new Promise(resolve => setTimeout(resolve, 10)); - - // Requests with relatedTask should be queued, not sent via transport - // This prevents duplicate delivery for bidirectional transports - expect(sendSpy).not.toHaveBeenCalled(); - - // Verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - }); - - it('should work with notification method', async () => { - await protocol.connect(transport); - - await protocol.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { - taskId: 'parent-task-456' - } - } - ); - - // Notifications with relatedTask should be queued, not sent via transport - // This prevents duplicate delivery for bidirectional transports - expect(sendSpy).not.toHaveBeenCalled(); - - // Verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('parent-task-456'); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: 'parent-task-456' }); - }); - }); - - describe('task metadata combination', () => { - it('should combine task, relatedTask, and progress metadata', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - // Start the request (don't await completion, just let it send) - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 60000, - pollInterval: 1000 - }, - relatedTask: { - taskId: 'parent-task' - }, - onprogress: vi.fn() - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be queued - await new Promise(resolve => setTimeout(resolve, 10)); - - // Requests with relatedTask should be queued, not sent via transport - // This prevents duplicate delivery for bidirectional transports - expect(sendSpy).not.toHaveBeenCalled(); - - // Verify the message was queued with all metadata combined - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('parent-task'); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.params).toMatchObject({ - name: 'test-tool', - task: { - ttl: 60000, - pollInterval: 1000 - }, - _meta: { - [RELATED_TASK_META_KEY]: { - taskId: 'parent-task' - }, - progressToken: expect.any(Number) - } - }); - }); - }); - - describe('task status transitions', () => { - it('should not auto-update task status when a task-augmented request completes', async () => { - const mockTaskStore = createMockTaskStore(); - const localProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const localTransport = new MockTransport(); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'done' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { - name: 'test-tool', - arguments: {}, - task: { ttl: 60000, pollInterval: 1000 } - } - }); - - // Allow the request to be processed - await new Promise(resolve => setTimeout(resolve, 20)); - - // The protocol layer must not call updateTaskStatus — that is solely the tool implementor's responsibility - expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); - }); - - it('should handle requests with task creation parameters in top-level task field', async () => { - // This test documents that task creation parameters are now in the top-level task field - // rather than in _meta, and that task management is handled by tool implementors - const mockTaskStore = createMockTaskStore(); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - protocol.setRequestHandler('tools/call', async request => { - // Tool implementor can access task creation parameters from request.params.task - expect(request.params.task).toEqual({ - ttl: 60000, - pollInterval: 1000 - }); - return { content: [{ type: 'text', text: 'success' }] }; - }); - - transport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'test', - arguments: {}, - task: { - ttl: 60000, - pollInterval: 1000 - } - } - }); - - // Wait for the request to be processed - await new Promise(resolve => setTimeout(resolve, 10)); - }); - }); - - describe('assertTaskHandlerCapability', () => { - it('should invoke assertTaskHandlerCapability when an inbound task-augmented request arrives', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - const spy = vi.spyOn(localProtocol, 'assertTaskHandlerCapability' as never); - const localTransport = new MockTransport(); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'my-tool', - arguments: {}, - task: { ttl: 30000, pollInterval: 500 } - } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith('tools/call'); - }); - - it('should not invoke assertTaskHandlerCapability for non-task-augmented requests', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - const spy = vi.spyOn(localProtocol, 'assertTaskHandlerCapability' as never); - const localTransport = new MockTransport(); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'my-tool', arguments: {} } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should succeed with default no-op assertTaskHandlerCapability', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - const localTransport = new MockTransport(); - const localSendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 3, - method: 'tools/call', - params: { - name: 'my-tool', - arguments: {}, - task: { ttl: 30000, pollInterval: 500 } - } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - // The response should be a success, not an error - expect(localSendSpy).toHaveBeenCalledOnce(); - const response = localSendSpy.mock.calls[0]![0] as { error?: unknown }; - expect(response.error).toBeUndefined(); - }); - - it('should send a JSON-RPC error response when assertTaskHandlerCapability throws', async () => { - const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(localProtocol as any, 'assertTaskHandlerCapability').mockImplementation(() => { - throw new Error('Task handler capability not declared'); - }); - const localTransport = new MockTransport(); - const sendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - localProtocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'ok' }] }; - }); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { - name: 'my-tool', - arguments: {}, - task: { ttl: 30000, pollInterval: 500 } - } - }); - - await new Promise(resolve => setTimeout(resolve, 20)); - - // Verify the error was sent back as a JSON-RPC error response (matching main's behavior) - expect(sendSpy).toHaveBeenCalledOnce(); - const response = sendSpy.mock.calls[0]![0] as { error?: { message?: string } }; - expect(response.error).toBeDefined(); - expect(response.error!.message).toBe('Task handler capability not declared'); - }); - }); - - describe('pollInterval fallback in _waitForTaskUpdate', () => { - it('should fall back to defaultTaskPollInterval when task has no pollInterval', async () => { - const mockTaskStore = createMockTaskStore(); - - const task = await mockTaskStore.createTask({ pollInterval: undefined as unknown as number }, 1, { - method: 'test/method', - params: {} - }); - // Override pollInterval to be undefined on the stored task - const storedTask = await mockTaskStore.getTask(task.taskId); - if (storedTask) { - storedTask.pollInterval = undefined as unknown as number; - } - - const localProtocol = createTestProtocol({ - taskStore: mockTaskStore, - defaultTaskPollInterval: 100 - }); - const localTransport = new MockTransport(); - const sendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - // Send tasks/result request — task is non-terminal so it will poll - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Use a macrotask to complete the task AFTER the handler has entered polling - setTimeout(() => { - mockTaskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'done' }] }); - }, 10); - - // At 50ms the 100ms poll hasn't fired yet - await new Promise(resolve => setTimeout(resolve, 50)); - expect(sendSpy).not.toHaveBeenCalled(); - - // At 200ms the poll should have fired and found the completed task - await new Promise(resolve => setTimeout(resolve, 150)); - expect(sendSpy).toHaveBeenCalled(); - }); - - it('should fall back to 1000ms when both pollInterval and defaultTaskPollInterval are absent', async () => { - const mockTaskStore = createMockTaskStore(); - - const task = await mockTaskStore.createTask({ pollInterval: undefined as unknown as number }, 1, { - method: 'test/method', - params: {} - }); - const storedTask = await mockTaskStore.getTask(task.taskId); - if (storedTask) { - storedTask.pollInterval = undefined as unknown as number; - } - - // No defaultTaskPollInterval — should fall back to 1000ms - const localProtocol = createTestProtocol({ - taskStore: mockTaskStore - }); - const localTransport = new MockTransport(); - const sendSpy = vi.spyOn(localTransport, 'send'); - await localProtocol.connect(localTransport); - - localTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Complete the task via macrotask so the handler enters polling first - setTimeout(() => { - mockTaskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'done' }] }); - }, 10); - - // At 500ms the 1000ms poll hasn't fired yet - await new Promise(resolve => setTimeout(resolve, 500)); - expect(sendSpy).not.toHaveBeenCalled(); - - // At 1100ms the poll should have fired - await new Promise(resolve => setTimeout(resolve, 600)); - expect(sendSpy).toHaveBeenCalled(); - }); - }); - - describe('listTasks', () => { - it('should handle tasks/list requests and return tasks from TaskStore', async () => { - const listedTasks = createLatch(); - const mockTaskStore = createMockTaskStore({ - onList: () => listedTasks.releaseLatch() - }); - const task1 = await mockTaskStore.createTask( - { - pollInterval: 500 - }, - 1, - { - method: 'test/method', - params: {} - } - ); - // Manually set status to completed for this test - await mockTaskStore.updateTaskStatus(task1.taskId, 'completed'); - - const task2 = await mockTaskStore.createTask( - { - ttl: 60000, - pollInterval: 1000 - }, - 2, - { - method: 'test/method', - params: {} - } - ); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request - transport.onmessage?.({ - jsonrpc: '2.0', - id: 3, - method: 'tasks/list', - params: {} - }); - - await listedTasks.waitForLatch(); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(3); - expect(sentMessage.result.tasks).toEqual([ - { - taskId: task1.taskId, - status: 'completed', - ttl: null, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - pollInterval: 500 - }, - { - taskId: task2.taskId, - status: 'working', - ttl: 60000, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - pollInterval: 1000 - } - ]); - expect(sentMessage.result._meta).toEqual({}); - }); - - it('should handle tasks/list requests with cursor for pagination', async () => { - const listedTasks = createLatch(); - const mockTaskStore = createMockTaskStore({ - onList: () => listedTasks.releaseLatch() - }); - const task3 = await mockTaskStore.createTask( - { - pollInterval: 500 - }, - 1, - { - method: 'test/method', - params: {} - } - ); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request with cursor - transport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/list', - params: { - cursor: 'task-2' - } - }); - - await listedTasks.waitForLatch(); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith('task-2', undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(2); - expect(sentMessage.result.tasks).toEqual([ - { - taskId: task3.taskId, - status: 'working', - ttl: null, - createdAt: expect.any(String), - lastUpdatedAt: expect.any(String), - pollInterval: 500 - } - ]); - expect(sentMessage.result.nextCursor).toBeUndefined(); - expect(sentMessage.result._meta).toEqual({}); - }); - - it('should handle tasks/list requests with empty results', async () => { - const listedTasks = createLatch(); - const mockTaskStore = createMockTaskStore({ - onList: () => listedTasks.releaseLatch() - }); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request - transport.onmessage?.({ - jsonrpc: '2.0', - id: 3, - method: 'tasks/list', - params: {} - }); - - await listedTasks.waitForLatch(); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(3); - expect(sentMessage.result.tasks).toEqual([]); - expect(sentMessage.result.nextCursor).toBeUndefined(); - expect(sentMessage.result._meta).toEqual({}); - }); - - it('should return error for invalid cursor', async () => { - const mockTaskStore = createMockTaskStore(); - mockTaskStore.listTasks.mockRejectedValue(new Error('Invalid cursor: bad-cursor')); - - protocol = createTestProtocol({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - // Simulate receiving a tasks/list request with invalid cursor - transport.onmessage?.({ - jsonrpc: '2.0', - id: 4, - method: 'tasks/list', - params: { - cursor: 'bad-cursor' - } - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockTaskStore.listTasks).toHaveBeenCalledWith('bad-cursor', undefined); - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(4); - expect(sentMessage.error).toBeDefined(); - expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code - expect(sentMessage.error.message).toContain('Failed to list tasks'); - expect(sentMessage.error.message).toContain('Invalid cursor'); - }); - - it('should call listTasks method from client side', async () => { - await protocol.connect(transport); - - const listTasksPromise = (protocol as unknown as TestProtocolInternals)._taskManager.listTasks(); - - // Simulate server response - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sendSpy.mock.calls[0]![0].id, - result: { - tasks: [ - { - taskId: 'task-1', - status: 'completed', - ttl: null, - createdAt: '2024-01-01T00:00:00Z', - lastUpdatedAt: '2024-01-01T00:00:00Z', - pollInterval: 500 - } - ], - nextCursor: undefined, - _meta: {} - } - }); - }, 10); - - const result = await listTasksPromise; - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tasks/list', - params: undefined - }), - expect.any(Object) - ); - expect(result.tasks).toHaveLength(1); - expect(result.tasks[0]?.taskId).toBe('task-1'); - }); - - it('should call listTasks with cursor from client side', async () => { - await protocol.connect(transport); - - const listTasksPromise = (protocol as unknown as TestProtocolInternals)._taskManager.listTasks({ cursor: 'task-10' }); - - // Simulate server response - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sendSpy.mock.calls[0]![0].id, - result: { - tasks: [ - { - taskId: 'task-11', - status: 'working', - ttl: 30000, - createdAt: '2024-01-01T00:00:00Z', - lastUpdatedAt: '2024-01-01T00:00:00Z', - pollInterval: 1000 - } - ], - nextCursor: 'task-11', - _meta: {} - } - }); - }, 10); - - const result = await listTasksPromise; - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tasks/list', - params: { - cursor: 'task-10' - } - }), - expect.any(Object) - ); - expect(result.tasks).toHaveLength(1); - expect(result.tasks[0]?.taskId).toBe('task-11'); - expect(result.nextCursor).toBe('task-11'); - }); - }); - - describe('cancelTask', () => { - it('should handle tasks/cancel requests and update task status to cancelled', async () => { - const taskDeleted = createLatch(); - const mockTaskStore = createMockTaskStore(); - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - mockTaskStore.getTask.mockResolvedValue(task); - mockTaskStore.updateTaskStatus.mockImplementation(async (taskId: string, status: string) => { - if (taskId === task.taskId && status === 'cancelled') { - taskDeleted.releaseLatch(); - return; - } - throw new Error('Task not found'); - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 5, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - - await taskDeleted.waitForLatch(); - - expect(mockTaskStore.getTask).toHaveBeenCalledWith(task.taskId, undefined); - expect(mockTaskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCResultResponse; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(5); - expect(sentMessage.result._meta).toBeDefined(); - }); - - it('should return error with code -32602 when task does not exist', async () => { - const taskDeleted = createLatch(); - const mockTaskStore = createMockTaskStore(); - - mockTaskStore.getTask.mockResolvedValue(null); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 6, - method: 'tasks/cancel', - params: { - taskId: 'non-existent' - } - }); - - // Wait a bit for the async handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - taskDeleted.releaseLatch(); - - expect(mockTaskStore.getTask).toHaveBeenCalledWith('non-existent', undefined); - const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCErrorResponse; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(6); - expect(sentMessage.error).toBeDefined(); - expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code - expect(sentMessage.error.message).toContain('Task not found'); - }); - - it('should return error with code -32602 when trying to cancel a task in terminal status', async () => { - const mockTaskStore = createMockTaskStore(); - const completedTask = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - // Set task to completed status - await mockTaskStore.updateTaskStatus(completedTask.taskId, 'completed'); - completedTask.status = 'completed'; - - // Reset the mock so we can check it's not called during cancellation - mockTaskStore.updateTaskStatus.mockClear(); - mockTaskStore.getTask.mockResolvedValue(completedTask); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 7, - method: 'tasks/cancel', - params: { - taskId: completedTask.taskId - } - }); - - // Wait a bit for the async handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockTaskStore.getTask).toHaveBeenCalledWith(completedTask.taskId, undefined); - expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); - const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCErrorResponse; - expect(sentMessage.jsonrpc).toBe('2.0'); - expect(sentMessage.id).toBe(7); - expect(sentMessage.error).toBeDefined(); - expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code - expect(sentMessage.error.message).toContain('Cannot cancel task in terminal status'); - }); - - it('should call cancelTask method from client side', async () => { - await protocol.connect(transport); - - const deleteTaskPromise = (protocol as unknown as TestProtocolInternals)._taskManager.cancelTask({ taskId: 'task-to-delete' }); - - // Simulate server response - per MCP spec, CancelTaskResult is Result & Task - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sendSpy.mock.calls[0]![0].id, - result: { - _meta: {}, - taskId: 'task-to-delete', - status: 'cancelled', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString() - } - }); - }, 0); - - const result = await deleteTaskPromise; - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'tasks/cancel', - params: { - taskId: 'task-to-delete' - } - }), - expect.any(Object) - ); - expect(result._meta).toBeDefined(); - expect(result.taskId).toBe('task-to-delete'); - expect(result.status).toBe('cancelled'); - }); - }); - - describe('task status notifications', () => { - it('should call getTask after updateTaskStatus to enable notification sending', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - - await serverProtocol.connect(serverTransport); - - // Simulate cancelling the task - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify that updateTaskStatus was called - expect(mockTaskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - - // Verify that getTask was called after updateTaskStatus - // This is done by the RequestTaskStore wrapper to get the updated task for the notification - const getTaskCalls = mockTaskStore.getTask.mock.calls; - const lastGetTaskCall = getTaskCalls[getTaskCalls.length - 1]; - expect(lastGetTaskCall?.[0]).toBe(task.taskId); - }); - }); - - describe('task metadata handling', () => { - it('should NOT include related-task metadata in tasks/get response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Request task status - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/get', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response does NOT include related-task metadata - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - taskId: task.taskId, - status: 'working' - }) - }) - ); - - // Verify _meta is not present or doesn't contain RELATED_TASK_META_KEY - const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; - expect(response.result?._meta?.[RELATED_TASK_META_KEY]).toBeUndefined(); - }); - - it('should NOT include related-task metadata in tasks/list response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Request task list - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/list', - params: {} - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response does NOT include related-task metadata - const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; - expect(response.result?._meta).toEqual({}); - }); - - it('should NOT include related-task metadata in tasks/cancel response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task first - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Cancel the task - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response does NOT include related-task metadata - const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; - expect(response.result?._meta).toEqual({}); - }); - - it('should include related-task metadata in tasks/result response', async () => { - const mockTaskStore = createMockTaskStore(); - - // Create a task and complete it - const task = await mockTaskStore.createTask({}, 1, { - method: 'test/method', - params: {} - }); - - const testResult = { - content: [{ type: 'text', text: 'test result' }] - }; - - await mockTaskStore.storeTaskResult(task.taskId, 'completed', testResult); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Request task result - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 2, - method: 'tasks/result', - params: { - taskId: task.taskId - } - }); - - // Wait for async processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify response DOES include related-task metadata - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - result: expect.objectContaining({ - content: testResult.content, - _meta: expect.objectContaining({ - [RELATED_TASK_META_KEY]: { - taskId: task.taskId - } - }) - }) - }) - ); - }); - - it('should propagate related-task metadata to handler sendRequest and sendNotification', async () => { - const mockTaskStore = createMockTaskStore(); - - const serverProtocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - const serverTransport = new MockTransport(); - const sendSpy = vi.spyOn(serverTransport, 'send'); - - await serverProtocol.connect(serverTransport); - - // Set up a handler that uses sendRequest and sendNotification - serverProtocol.setRequestHandler('tools/call', async (_request, ctx) => { - // Send a notification using the ctx.mcpReq.notify - await ctx.mcpReq.notify({ - method: 'notifications/message', - params: { level: 'info', data: 'test' } - }); - - return { - content: [{ type: 'text', text: 'done' }] - }; - }); - - // Send a request with related-task metadata - let handlerPromise: Promise | undefined; - const originalOnMessage = serverTransport.onmessage; - - serverTransport.onmessage = message => { - handlerPromise = Promise.resolve(originalOnMessage?.(message)); - return handlerPromise; - }; - - serverTransport.onmessage({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { - name: 'test-tool', - _meta: { - [RELATED_TASK_META_KEY]: { - taskId: 'parent-task-123' - } - } - } - }); - - // Wait for handler to complete - if (handlerPromise) { - await handlerPromise; - } - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify the notification was QUEUED (not sent via transport) - // Messages with relatedTask metadata should be queued for delivery via tasks/result - // to prevent duplicate delivery for bidirectional transports - const queue = (serverProtocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('parent-task-123'); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ - taskId: 'parent-task-123' - }); - - // Verify the notification was NOT sent via transport (should be queued instead) - const notificationCalls = sendSpy.mock.calls.filter(call => 'method' in call[0] && call[0].method === 'notifications/message'); - expect(notificationCalls).toHaveLength(0); - }); - }); -}); - -describe('Request Cancellation vs Task Cancellation', () => { - let protocol: Protocol; - let transport: MockTransport; - let taskStore: TaskStore; - - beforeEach(() => { - transport = new MockTransport(); - taskStore = createMockTaskStore(); - protocol = createTestProtocol({ taskStore }); - }); - - describe('notifications/cancelled behavior', () => { - test('should abort request handler when notifications/cancelled is received', async () => { - await protocol.connect(transport); - - // Set up a request handler that checks if it was aborted - let wasAborted = false; - protocol.setRequestHandler('ping', async (_request, ctx) => { - // Simulate a long-running operation - await new Promise(resolve => setTimeout(resolve, 100)); - wasAborted = ctx.mcpReq.signal.aborted; - return {}; - }); - - // Simulate an incoming request - const requestId = 123; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: requestId, - method: 'ping', - params: {} - }); - } - - // Wait a bit for the handler to start - await new Promise(resolve => setTimeout(resolve, 10)); - - // Send cancellation notification - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: requestId, - reason: 'User cancelled' - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 150)); - - // Verify the request was aborted - expect(wasAborted).toBe(true); - }); - - test('should NOT automatically cancel associated tasks when notifications/cancelled is received', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Send cancellation notification for the request - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: 'req-1', - reason: 'User cancelled' - } - }); - } - - // Wait a bit - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify the task status was NOT changed to cancelled - const updatedTask = await taskStore.getTask(task.taskId); - expect(updatedTask?.status).toBe('working'); - expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'cancelled', expect.any(String)); - }); - }); - - describe('tasks/cancel behavior', () => { - test('should cancel task independently of request cancellation', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Cancel the task using tasks/cancel - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify the task was cancelled - expect(taskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - }); - - test('should reject cancellation of terminal tasks', async () => { - await protocol.connect(transport); - const sendSpy = vi.spyOn(transport, 'send'); - - // Create a task and mark it as completed - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - await taskStore.updateTaskStatus(task.taskId, 'completed'); - - // Try to cancel the completed task - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify an error was sent - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: 999, - error: expect.objectContaining({ - code: ProtocolErrorCode.InvalidParams, - message: expect.stringContaining('Cannot cancel task in terminal status') - }) - }) - ); - }); - - test('should return error when task not found', async () => { - await protocol.connect(transport); - const sendSpy = vi.spyOn(transport, 'send'); - - // Try to cancel a non-existent task - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: 'non-existent-task' - } - }); - } - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify an error was sent - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: 999, - error: expect.objectContaining({ - code: ProtocolErrorCode.InvalidParams, - message: expect.stringContaining('Task not found') - }) - }) - ); - }); - }); - - describe('separation of concerns', () => { - test('should allow request cancellation without affecting task', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Cancel the request (not the task) - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: 'req-1', - reason: 'User cancelled request' - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify task is still working - const updatedTask = await taskStore.getTask(task.taskId); - expect(updatedTask?.status).toBe('working'); - }); - - test('should allow task cancellation without affecting request', async () => { - await protocol.connect(transport); - - // Set up a request handler - let requestCompleted = false; - protocol.setRequestHandler('ping', async () => { - await new Promise(resolve => setTimeout(resolve, 50)); - requestCompleted = true; - return {}; - }); - - // Create a task (simulating a long-running tools/call) - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'tools/call', - params: { name: 'long-running-tool', arguments: {} } - }); - - // Start an unrelated ping request - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 123, - method: 'ping', - params: {} - }); - } - - // Cancel the task (not the request) - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 999, - method: 'tasks/cancel', - params: { - taskId: task.taskId - } - }); - } - - // Wait for request to complete - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify request completed normally - expect(requestCompleted).toBe(true); - - // Verify task was cancelled - expect(taskStore.updateTaskStatus).toHaveBeenCalledWith( - task.taskId, - 'cancelled', - 'Client cancelled task execution.', - undefined - ); - }); - }); -}); - -describe('Progress notification support for tasks', () => { - let protocol: Protocol; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = createTestProtocol({ taskStore: createMockTaskStore() }); - }); - - it('should maintain progress token association after CreateTaskResult is returned', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - // Start a task-augmented request with progress callback - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be sent - await new Promise(resolve => setTimeout(resolve, 10)); - - // Get the message ID from the sent request - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - expect(progressToken).toBe(messageId); - - // Simulate CreateTaskResult response - const taskId = 'test-task-123'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - // Wait for response to be processed - await Promise.resolve(); - await Promise.resolve(); - - // Send a progress notification - should still work after CreateTaskResult - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100 - } - }); - } - - // Wait for notification to be processed - await Promise.resolve(); - - // Verify progress callback was invoked - expect(progressCallback).toHaveBeenCalledWith({ - progress: 50, - total: 100 - }); - }); - - it('should stop progress notifications when task reaches terminal status (completed)', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - // Set up a request handler that will complete the task - protocol.setRequestHandler('tools/call', async (_request, ctx) => { - if (ctx.task?.store) { - const task = await ctx.task.store.createTask({ ttl: 60000 }); - - // Simulate async work then complete the task - const taskStore = ctx.task.store; - setTimeout(async () => { - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [{ type: 'text', text: 'Done' }] - }); - }, 50); - - return { task }; - } - return { content: [] }; - }); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - // Start a task-augmented request with progress callback - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }).catch(() => { - // May not complete, ignore error - }); - - // Wait a bit for the request to be sent - await new Promise(resolve => setTimeout(resolve, 10)); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Create a task in the mock store first so it exists when we try to get it later - const createdTask = await taskStore.createTask({ ttl: 60000 }, messageId, request); - const taskId = createdTask.taskId; - - // Simulate CreateTaskResult response - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: createdTask - } - }); - } - - await Promise.resolve(); - await Promise.resolve(); - - // Progress notification should work while task is working - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100 - } - }); - } - - await Promise.resolve(); - - expect(progressCallback).toHaveBeenCalledTimes(1); - - // Verify the task-progress association was created - const taskProgressTokens = (protocol as unknown as TestProtocolInternals)._taskManager._taskProgressTokens as Map; - expect(taskProgressTokens.has(taskId)).toBe(true); - expect(taskProgressTokens.get(taskId)).toBe(progressToken); - - // Simulate task completion by triggering an inbound request whose handler - // calls storeTaskResult through the task context (the public RequestTaskStore API). - // This is equivalent to how a real server handler would complete a task. - protocol.setRequestHandler('ping', async (_request, ctx) => { - if (ctx.task?.store) { - await ctx.task.store.storeTaskResult(taskId, 'completed', { content: [] }); - } - return {}; - }); - if (transport.onmessage) { - transport.onmessage({ jsonrpc: '2.0', id: 999, method: 'ping', params: {} }); - } - - // Wait for all async operations including notification sending to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the association was cleaned up - expect(taskProgressTokens.has(taskId)).toBe(false); - - // Try to send progress notification after task completion - should be ignored - progressCallback.mockClear(); - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 100, - total: 100 - } - }); - } - - await Promise.resolve(); - - // Progress callback should NOT be invoked after task completion - expect(progressCallback).not.toHaveBeenCalled(); - }); - - it('should stop progress notifications when task reaches terminal status (failed)', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Simulate CreateTaskResult response - const taskId = 'test-task-456'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Simulate task failure via storeTaskResult - await taskStore.storeTaskResult(taskId, 'failed', { - content: [], - isError: true - }); - - // Manually trigger the status notification - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/tasks/status', - params: { - taskId, - status: 'failed', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - statusMessage: 'Task failed' - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Try to send progress notification after task failure - should be ignored - progressCallback.mockClear(); - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 75, - total: 100 - } - }); - } - - expect(progressCallback).not.toHaveBeenCalled(); - }); - - it('should stop progress notifications when task is cancelled', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Simulate CreateTaskResult response - const taskId = 'test-task-789'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Simulate task cancellation via updateTaskStatus - await taskStore.updateTaskStatus(taskId, 'cancelled', 'User cancelled'); - - // Manually trigger the status notification - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/tasks/status', - params: { - taskId, - status: 'cancelled', - ttl: 60000, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - statusMessage: 'User cancelled' - } - }); - } - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Try to send progress notification after cancellation - should be ignored - progressCallback.mockClear(); - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 25, - total: 100 - } - }); - } - - expect(progressCallback).not.toHaveBeenCalled(); - }); - - it('should use the same progressToken throughout task lifetime', async () => { - const taskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore }); - - const transport = new MockTransport(); - const sendSpy = vi.spyOn(transport, 'send'); - await protocol.connect(transport); - - const progressCallback = vi.fn(); - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - void testRequest(protocol, request, resultSchema, { - task: { ttl: 60000 }, - onprogress: progressCallback - }); - - const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; - const messageId = sentRequest.id; - const progressToken = sentRequest.params._meta.progressToken; - - // Simulate CreateTaskResult response - const taskId = 'test-task-consistency'; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: messageId, - result: { - task: { - taskId, - status: 'working', - ttl: 60000, - createdAt: new Date().toISOString() - } - } - }); - } - - await Promise.resolve(); - await Promise.resolve(); - - // Send multiple progress notifications with the same token - const progressUpdates = [ - { progress: 25, total: 100 }, - { progress: 50, total: 100 }, - { progress: 75, total: 100 } - ]; - - for (const update of progressUpdates) { - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, // Same token for all notifications - ...update - } - }); - } - await Promise.resolve(); - } - - // Verify all progress notifications were received with the same token - expect(progressCallback).toHaveBeenCalledTimes(3); - expect(progressCallback).toHaveBeenNthCalledWith(1, { progress: 25, total: 100 }); - expect(progressCallback).toHaveBeenNthCalledWith(2, { progress: 50, total: 100 }); - expect(progressCallback).toHaveBeenNthCalledWith(3, { progress: 75, total: 100 }); - }); - - it('should maintain progressToken throughout task lifetime', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'long-running-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - const onProgressMock = vi.fn(); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 60000 - }, - onprogress: onProgressMock - }); - - const sentMessage = sendSpy.mock.calls[0]![0]; - expect(sentMessage.params._meta.progressToken).toBeDefined(); - }); - - it('should support progress notifications with task-augmented requests', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - content: z.array(z.object({ type: z.literal('text'), text: z.string() })) - }); - - const onProgressMock = vi.fn(); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000 - }, - onprogress: onProgressMock - }); - - const sentMessage = sendSpy.mock.calls[0]![0]; - const progressToken = sentMessage.params._meta.progressToken; - - // Simulate progress notification - transport.onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 50, - total: 100, - message: 'Processing...' - } - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(onProgressMock).toHaveBeenCalledWith({ - progress: 50, - total: 100, - message: 'Processing...' - }); - }); - - it('should continue progress notifications after CreateTaskResult', async () => { - await protocol.connect(transport); - - const request = { - method: 'tools/call', - params: { name: 'test-tool' } - }; - - const resultSchema = z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - ttl: z.number().nullable(), - createdAt: z.string() - }) - }); - - const onProgressMock = vi.fn(); - - void testRequest(protocol, request, resultSchema, { - task: { - ttl: 30000 - }, - onprogress: onProgressMock - }); - - const sentMessage = sendSpy.mock.calls[0]![0]; - const progressToken = sentMessage.params._meta.progressToken; - - // Simulate CreateTaskResult response - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: sentMessage.id, - result: { - task: { - taskId: 'task-123', - status: 'working', - ttl: 30000, - createdAt: new Date().toISOString() - } - } - }); - }, 5); - - // Progress notifications should still work - setTimeout(() => { - transport.onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken, - progress: 75, - total: 100 - } - }); - }, 10); - - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(onProgressMock).toHaveBeenCalledWith({ - progress: 75, - total: 100 - }); - }); -}); - -describe('Capability negotiation for tasks', () => { - it('should use empty objects for capability fields', () => { - const serverCapabilities = { - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - call: {} - } - } - } - }; - - expect(serverCapabilities.tasks.list).toEqual({}); - expect(serverCapabilities.tasks.cancel).toEqual({}); - expect(serverCapabilities.tasks.requests.tools.call).toEqual({}); - }); - - it('should include list and cancel in server capabilities', () => { - const serverCapabilities = { - tasks: { - list: {}, - cancel: {} - } - }; - - expect('list' in serverCapabilities.tasks).toBe(true); - expect('cancel' in serverCapabilities.tasks).toBe(true); - }); - - it('should include list and cancel in client capabilities', () => { - const clientCapabilities = { - tasks: { - list: {}, - cancel: {} - } - }; - - expect('list' in clientCapabilities.tasks).toBe(true); - expect('cancel' in clientCapabilities.tasks).toBe(true); - }); -}); - -describe('Message interception for task-related notifications', () => { - it('should queue notifications with io.modelcontextprotocol/related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task first - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a notification with related task metadata - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { taskId: task.taskId } - } - ); - - // Access the private queue to verify the message was queued - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(task.taskId); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: task.taskId }); - }); - - it('should not queue notifications without related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Send a notification without related task metadata - await server.notification({ - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }); - - // Verify message was not queued (notification without metadata goes through transport) - // We can't directly check the queue, but we know it wasn't queued because - // notifications without relatedTask metadata are sent via transport, not queued - }); - - // Test removed: _taskResultWaiters was removed in favor of polling-based task updates - // The functionality is still tested through integration tests that verify message queuing works - - it('should propagate queue overflow errors without failing the task', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue(), maxTaskQueueSize: 100 }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Fill the queue to max capacity (100 messages) - for (let i = 0; i < 100; i++) { - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: `message ${i}` } - }, - { - relatedTask: { taskId: task.taskId } - } - ); - } - - // Try to add one more message - should throw an error - await expect( - server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'overflow message' } - }, - { - relatedTask: { taskId: task.taskId } - } - ) - ).rejects.toThrow('overflow'); - - // Verify the task was NOT automatically failed by the Protocol - // (implementations can choose to fail tasks on overflow if they want) - expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'failed', expect.anything(), expect.anything()); - }); - - it('should extract task ID correctly from metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - const taskId = 'custom-task-id-123'; - - // Send a notification with custom task ID - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { taskId } - } - ); - - // Verify the message was queued under the correct task ID - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - }); - - it('should preserve message order when queuing multiple notifications', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send multiple notifications - for (let i = 0; i < 5; i++) { - await server.notification( - { - method: 'notifications/message', - params: { level: 'info', data: `message ${i}` } - }, - { - relatedTask: { taskId: task.taskId } - } - ); - } - - // Verify messages are in FIFO order - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - for (let i = 0; i < 5; i++) { - const queuedMessage = await queue!.dequeue(task.taskId); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.params!.data).toBe(`message ${i}`); - } - }); -}); - -describe('Message interception for task-related requests', () => { - it('should queue requests with io.modelcontextprotocol/related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task first - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata (don't await - we're testing queuing) - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Access the private queue to verify the message was queued - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(task.taskId); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.method).toBe('ping'); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: task.taskId }); - - // Verify resolver is stored in _requestResolvers map (not in the message) - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - const resolvers = (server as unknown as TestProtocolInternals)._taskManager._requestResolvers; - expect(resolvers.has(requestId)).toBe(true); - - // Clean up - send a response to prevent hanging promise - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: {} - }); - - await requestPromise; - }); - - it('should not queue requests without related-task metadata', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Send a request without related task metadata - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}) - ); - - // Verify queue exists (but we don't track size in the new API) - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Clean up - send a response - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: {} - }); - - await requestPromise; - }); - - // Test removed: _taskResultWaiters was removed in favor of polling-based task updates - // The functionality is still tested through integration tests that verify message queuing works - - it('should store request resolver for response routing', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Verify the resolver was stored - const resolvers = (server as unknown as TestProtocolInternals)._taskManager._requestResolvers; - expect(resolvers.size).toBe(1); - - // Get the request ID from the queue - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue(task.taskId); - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - - expect(resolvers.has(requestId)).toBe(true); - - // Send a response to trigger resolver - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: {} - }); - - await requestPromise; - - // Verify resolver was cleaned up after response - expect(resolvers.has(requestId)).toBe(false); - }); - - it('should route responses to side-channeled requests', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const queue = new InMemoryTaskMessageQueue(); - const server = createTestProtocol({ taskStore, taskMessageQueue: queue }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata - const requestPromise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({ message: z.string() }), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Get the request ID from the queue - const queuedMessage = await queue.dequeue(task.taskId); - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - - // Enqueue a response message to the queue (simulating client sending response back) - await queue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: requestId, - result: { message: 'pong' } - }, - timestamp: Date.now() - }); - - // Simulate a client calling tasks/result which will process the response - // This is done by creating a mock request handler that will trigger the GetTaskPayloadRequest handler - const mockRequestId = 999; - transport.onmessage?.({ - jsonrpc: '2.0', - id: mockRequestId, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Wait for the response to be processed - await new Promise(resolve => setTimeout(resolve, 50)); - - // Mark task as completed - await taskStore.updateTaskStatus(task.taskId, 'completed'); - await taskStore.storeTaskResult(task.taskId, 'completed', { _meta: {} }); - - // Verify the response was routed correctly - const result = await requestPromise; - expect(result).toEqual({ message: 'pong' }); - }); - - it('should log error when resolver is missing for side-channeled request', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - const errors: Error[] = []; - server.onerror = (error: Error) => { - errors.push(error); - }; - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Send a request with related task metadata - void testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({ message: z.string() }), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Get the request ID from the queue - const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue(task.taskId); - const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; - - // Manually delete the resolver to simulate missing resolver - (server as unknown as TestProtocolInternals)._taskManager._requestResolvers.delete(requestId); - - // Enqueue a response message - this should trigger the error logging when processed - await queue!.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: requestId, - result: { message: 'pong' } - }, - timestamp: Date.now() - }); - - // Simulate a client calling tasks/result which will process the response - const mockRequestId = 888; - transport.onmessage?.({ - jsonrpc: '2.0', - id: mockRequestId, - method: 'tasks/result', - params: { taskId: task.taskId } - }); - - // Wait for the response to be processed - await new Promise(resolve => setTimeout(resolve, 50)); - - // Mark task as completed - await taskStore.updateTaskStatus(task.taskId, 'completed'); - await taskStore.storeTaskResult(task.taskId, 'completed', { _meta: {} }); - - // Wait a bit more for error to be logged - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify error was logged - expect(errors.length).toBeGreaterThanOrEqual(1); - expect(errors.some(e => e.message.includes('Response handler missing for request'))).toBe(true); - }); - - it('should propagate queue overflow errors for requests without failing the task', async () => { - const taskStore = createMockTaskStore(); - const transport = new MockTransport(); - const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue(), maxTaskQueueSize: 100 }); - - await server.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); - - // Fill the queue to max capacity (100 messages) - const promises: Promise[] = []; - for (let i = 0; i < 100; i++) { - const promise = testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ).catch(() => { - // Requests will remain pending until task completes or fails - }); - promises.push(promise); - } - - // Try to add one more request - should throw an error - await expect( - testRequest( - server, - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ) - ).rejects.toThrow('overflow'); - - // Verify the task was NOT automatically failed by the Protocol - // (implementations can choose to fail tasks on overflow if they want) - expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'failed', expect.anything(), expect.anything()); - }); -}); - -describe('Message Interception', () => { - let protocol: Protocol; - let transport: MockTransport; - let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; - - beforeEach(() => { - transport = new MockTransport(); - mockTaskStore = createMockTaskStore(); - protocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - }); - - describe('messages with relatedTask metadata are queued', () => { - it('should queue notifications with relatedTask metadata', async () => { - await protocol.connect(transport); - - // Send a notification with relatedTask metadata - await protocol.notification( - { - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }, - { - relatedTask: { - taskId: 'task-123' - } - } - ); - - // Access the private _taskMessageQueue to verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('task-123'); - assertQueuedNotification(queuedMessage); - expect(queuedMessage!.message.method).toBe('notifications/message'); - }); - - it('should queue requests with relatedTask metadata', async () => { - await protocol.connect(transport); - - const mockSchema = z.object({ result: z.string() }); - - // Send a request with relatedTask metadata - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { - relatedTask: { - taskId: 'task-456' - } - } - ); - - // Access the private _taskMessageQueue to verify the message was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue('task-456'); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.method).toBe('test/request'); - - // Verify resolver is stored in _requestResolvers map (not in the message) - const requestId = queuedMessage.message.id as RequestId; - const resolvers = (protocol as unknown as TestProtocolInternals)._taskManager._requestResolvers; - expect(resolvers.has(requestId)).toBe(true); - - // Clean up the pending request - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: { result: 'success' } - }); - await requestPromise; - }); - }); - - describe('server queues responses/errors for task-related requests', () => { - it('should queue response when handling a request with relatedTask metadata', async () => { - await protocol.connect(transport); - - // Set up a request handler that returns a result - protocol.setRequestHandler('ping', async () => { - return {}; - }); - - // Simulate an incoming request with relatedTask metadata - const requestId = 456; - const taskId = 'task-response-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'ping', - params: { - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - } - }); - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the response was queued instead of sent directly - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage!.type).toBe('response'); - if (queuedMessage!.type === 'response') { - expect(queuedMessage!.message.id).toBe(requestId); - expect(queuedMessage!.message.result).toEqual({}); - } - }); - - it('should queue error when handling a request with relatedTask metadata that throws', async () => { - await protocol.connect(transport); - - // Set up a request handler that throws an error - protocol.setRequestHandler('ping', async () => { - throw new ProtocolError(ProtocolErrorCode.InternalError, 'Test error message'); - }); - - // Simulate an incoming request with relatedTask metadata - const requestId = 789; - const taskId = 'task-error-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'ping', - params: { - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - } - }); - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the error was queued instead of sent directly - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage!.type).toBe('error'); - if (queuedMessage!.type === 'error') { - expect(queuedMessage!.message.id).toBe(requestId); - expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.InternalError); - expect(queuedMessage!.message.error.message).toContain('Test error message'); - } - }); - - it('should queue MethodNotFound error for unknown method with relatedTask metadata', async () => { - await protocol.connect(transport); - - // Simulate an incoming request for unknown method with relatedTask metadata - const requestId = 101; - const taskId = 'task-not-found-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'unknown/method', - params: { - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - } - }); - - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the error was queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - const queuedMessage = await queue!.dequeue(taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage!.type).toBe('error'); - if (queuedMessage!.type === 'error') { - expect(queuedMessage!.message.id).toBe(requestId); - expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.MethodNotFound); - } - }); - - it('should send response normally when request has no relatedTask metadata', async () => { - await protocol.connect(transport); - const sendSpy = vi.spyOn(transport, 'send'); - - // Set up a request handler - protocol.setRequestHandler('tools/call', async () => { - return { content: [{ type: 'text', text: 'done' }] }; - }); - - // Simulate an incoming request WITHOUT relatedTask metadata - const requestId = 202; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'tools/call', - params: { name: 'test-tool' } - }); - - // Wait for the handler to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the response was sent through transport, not queued - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: requestId, - result: { content: [{ type: 'text', text: 'done' }] } - }) - ); - }); - }); - - describe('messages without metadata bypass the queue', () => { - it('should not queue notifications without relatedTask metadata', async () => { - await protocol.connect(transport); - - // Send a notification without relatedTask metadata - await protocol.notification({ - method: 'notifications/message', - params: { level: 'info', data: 'test message' } - }); - - // Access the private _taskMessageQueue to verify no messages were queued - // Since we can't check if queues exist without messages, we verify that - // attempting to dequeue returns undefined (no messages queued) - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - }); - - it('should not queue requests without relatedTask metadata', async () => { - await protocol.connect(transport); - - const mockSchema = z.object({ result: z.string() }); - const sendSpy = vi.spyOn(transport, 'send'); - - // Send a request without relatedTask metadata - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema - ); - - // Access the private _taskMessageQueue to verify no messages were queued - // Since we can't check if queues exist without messages, we verify that - // attempting to dequeue returns undefined (no messages queued) - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Clean up the pending request - const requestId = (sendSpy.mock.calls[0]![0] as JSONRPCResultResponse).id; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - result: { result: 'success' } - }); - await requestPromise; - }); - }); - - describe('task ID extraction from metadata', () => { - it('should extract correct task ID from relatedTask metadata for notifications', async () => { - await protocol.connect(transport); - - const taskId = 'extracted-task-789'; - - // Send a notification with relatedTask metadata - await protocol.notification( - { - method: 'notifications/message', - params: { data: 'test' } - }, - { - relatedTask: { - taskId: taskId - } - } - ); - - // Verify the message was queued under the correct task ID - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify a message was queued for this task - const queuedMessage = await queue!.dequeue(taskId); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.method).toBe('notifications/message'); - }); - - it('should extract correct task ID from relatedTask metadata for requests', async () => { - await protocol.connect(transport); - - const taskId = 'extracted-task-999'; - const mockSchema = z.object({ result: z.string() }); - - // Send a request with relatedTask metadata - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { - relatedTask: { - taskId: taskId - } - } - ); - - // Verify the message was queued under the correct task ID - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Clean up the pending request - const queuedMessage = await queue!.dequeue(taskId); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.method).toBe('test/request'); - transport.onmessage?.({ - jsonrpc: '2.0', - id: queuedMessage.message.id, - result: { result: 'success' } - }); - await requestPromise; - }); - - it('should handle multiple messages for different task IDs', async () => { - await protocol.connect(transport); - - // Send messages for different tasks - await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'task-A' } }); - await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'task-B' } }); - await protocol.notification({ method: 'test3', params: {} }, { relatedTask: { taskId: 'task-A' } }); - - // Verify messages are queued under correct task IDs - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify two messages for task-A - const msg1A = await queue!.dequeue('task-A'); - const msg2A = await queue!.dequeue('task-A'); - const msg3A = await queue!.dequeue('task-A'); // Should be undefined - expect(msg1A).toBeDefined(); - expect(msg2A).toBeDefined(); - expect(msg3A).toBeUndefined(); - - // Verify one message for task-B - const msg1B = await queue!.dequeue('task-B'); - const msg2B = await queue!.dequeue('task-B'); // Should be undefined - expect(msg1B).toBeDefined(); - expect(msg2B).toBeUndefined(); - }); - }); - - describe('queue creation on first message', () => { - it('should queue messages for a task', async () => { - await protocol.connect(transport); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Send first message for a task - await protocol.notification({ method: 'test', params: {} }, { relatedTask: { taskId: 'new-task' } }); - - // Verify message was queued - const msg = await queue!.dequeue('new-task'); - assertQueuedNotification(msg); - expect(msg.message.method).toBe('test'); - }); - - it('should queue multiple messages for the same task', async () => { - await protocol.connect(transport); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Send first message - await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'reuse-task' } }); - - // Send second message - await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'reuse-task' } }); - - // Verify both messages were queued in order - const msg1 = await queue!.dequeue('reuse-task'); - const msg2 = await queue!.dequeue('reuse-task'); - assertQueuedNotification(msg1); - expect(msg1.message.method).toBe('test1'); - assertQueuedNotification(msg2); - expect(msg2.message.method).toBe('test2'); - }); - - it('should queue messages for different tasks separately', async () => { - await protocol.connect(transport); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Send messages for different tasks - await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'task-1' } }); - await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'task-2' } }); - - // Verify messages are queued separately - const msg1 = await queue!.dequeue('task-1'); - const msg2 = await queue!.dequeue('task-2'); - assertQueuedNotification(msg1); - expect(msg1?.message.method).toBe('test1'); - assertQueuedNotification(msg2); - expect(msg2?.message.method).toBe('test2'); - }); - }); - - describe('metadata preservation in queued messages', () => { - it('should preserve relatedTask metadata in queued notification', async () => { - await protocol.connect(transport); - - const relatedTask = { taskId: 'task-meta-123' }; - - await protocol.notification( - { - method: 'test/notification', - params: { data: 'test' } - }, - { relatedTask } - ); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue('task-meta-123'); - - // Verify the metadata is preserved in the queued message - expect(queuedMessage).toBeDefined(); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.params!._meta).toBeDefined(); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual(relatedTask); - }); - - it('should preserve relatedTask metadata in queued request', async () => { - await protocol.connect(transport); - - const relatedTask = { taskId: 'task-meta-456' }; - const mockSchema = z.object({ result: z.string() }); - - const requestPromise = testRequest( - protocol, - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { relatedTask } - ); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue('task-meta-456'); - - // Verify the metadata is preserved in the queued message - expect(queuedMessage).toBeDefined(); - assertQueuedRequest(queuedMessage); - expect(queuedMessage.message.params!._meta).toBeDefined(); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual(relatedTask); - - // Clean up - transport.onmessage?.({ - jsonrpc: '2.0', - id: (queuedMessage!.message as JSONRPCRequest).id, - result: { result: 'success' } - }); - await requestPromise; - }); - - it('should preserve existing _meta fields when adding relatedTask', async () => { - await protocol.connect(transport); - - await protocol.notification( - { - method: 'test/notification', - params: { - data: 'test', - _meta: { - customField: 'customValue', - anotherField: 123 - } - } - }, - { - relatedTask: { taskId: 'task-preserve-meta' } - } - ); - - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const queuedMessage = await queue!.dequeue('task-preserve-meta'); - - // Verify both existing and new metadata are preserved - expect(queuedMessage).toBeDefined(); - assertQueuedNotification(queuedMessage); - expect(queuedMessage.message.params!._meta!.customField).toBe('customValue'); - expect(queuedMessage.message.params!._meta!.anotherField).toBe(123); - expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ - taskId: 'task-preserve-meta' - }); - }); - }); -}); - -describe('Queue lifecycle management', () => { - let protocol: Protocol; - let transport: MockTransport; - let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; - - beforeEach(() => { - transport = new MockTransport(); - mockTaskStore = createMockTaskStore(); - protocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - }); - - describe('queue cleanup on task completion', () => { - it('should clear queue when task reaches completed status', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue some messages for the task - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - await protocol.notification({ method: 'test/notification', params: { data: 'test2' } }, { relatedTask: { taskId } }); - - // Verify messages are queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify messages can be dequeued - const msg1 = await queue!.dequeue(taskId); - const msg2 = await queue!.dequeue(taskId); - expect(msg1).toBeDefined(); - expect(msg2).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // After cleanup, no more messages should be available - const msg3 = await queue!.dequeue(taskId); - expect(msg3).toBeUndefined(); - }); - - it('should clear queue after delivering messages on tasks/result for completed task', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a message - await protocol.notification({ method: 'test/notification', params: { data: 'test' } }, { relatedTask: { taskId } }); - - // Mark task as completed - const completedTask = { ...task, status: 'completed' as const }; - mockTaskStore.getTask.mockResolvedValue(completedTask); - mockTaskStore.getTaskResult.mockResolvedValue({ content: [{ type: 'text', text: 'done' }] }); - - // Simulate tasks/result request - const resultPromise = new Promise(resolve => { - transport.onmessage?.({ - jsonrpc: '2.0', - id: 100, - method: 'tasks/result', - params: { taskId } - }); - setTimeout(resolve, 50); - }); - - await resultPromise; - - // Verify queue is cleared after delivery (no messages available) - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - }); - - describe('queue cleanup on task cancellation', () => { - it('should clear queue when task is cancelled', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue some messages - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - - // Verify message is queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - const msg1 = await queue!.dequeue(taskId); - expect(msg1).toBeDefined(); - - // Re-queue the message for cancellation test - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - - // Mock task as non-terminal - mockTaskStore.getTask.mockResolvedValue(task); - - // Cancel the task - transport.onmessage?.({ - jsonrpc: '2.0', - id: 200, - method: 'tasks/cancel', - params: { taskId } - }); - - // Wait for cancellation to process - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify queue is cleared (no messages available) - const msg2 = await queue!.dequeue(taskId); - expect(msg2).toBeUndefined(); - }); - - it('should reject pending request resolvers when task is cancelled', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a request (catch rejection to avoid unhandled promise rejection) - const requestPromise = testRequest( - protocol, - { method: 'test/request', params: { data: 'test' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Verify request is queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Mock task as non-terminal - mockTaskStore.getTask.mockResolvedValue(task); - - // Cancel the task - transport.onmessage?.({ - jsonrpc: '2.0', - id: 201, - method: 'tasks/cancel', - params: { taskId } - }); - - // Wait for cancellation to process - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify the request promise is rejected - const result = (await requestPromise) as Error; - expect(result).toBeInstanceOf(ProtocolError); - expect(result.message).toContain('Task cancelled or completed'); - - // Verify queue is cleared (no messages available) - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - }); - - describe('queue cleanup on task failure', () => { - it('should clear queue when task reaches failed status', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue some messages - await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); - await protocol.notification({ method: 'test/notification', params: { data: 'test2' } }, { relatedTask: { taskId } }); - - // Verify messages are queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Verify messages can be dequeued - const msg1 = await queue!.dequeue(taskId); - const msg2 = await queue!.dequeue(taskId); - expect(msg1).toBeDefined(); - expect(msg2).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // After cleanup, no more messages should be available - const msg3 = await queue!.dequeue(taskId); - expect(msg3).toBeUndefined(); - }); - - it('should reject pending request resolvers when task fails', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a request (catch the rejection to avoid unhandled promise rejection) - const requestPromise = testRequest( - protocol, - { method: 'test/request', params: { data: 'test' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Verify request is queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // Verify the request promise is rejected - const result = (await requestPromise) as Error; - expect(result).toBeInstanceOf(ProtocolError); - expect(result.message).toContain('Task cancelled or completed'); - - // Verify queue is cleared (no messages available) - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - }); - - describe('resolver rejection on cleanup', () => { - it('should reject all pending request resolvers when queue is cleared', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue multiple requests (catch rejections to avoid unhandled promise rejections) - const request1Promise = testRequest( - protocol, - { method: 'test/request1', params: { data: 'test1' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - const request2Promise = testRequest( - protocol, - { method: 'test/request2', params: { data: 'test2' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - const request3Promise = testRequest( - protocol, - { method: 'test/request3', params: { data: 'test3' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Verify requests are queued - const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; - expect(queue).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // Verify all request promises are rejected - const result1 = (await request1Promise) as Error; - const result2 = (await request2Promise) as Error; - const result3 = (await request3Promise) as Error; - - expect(result1).toBeInstanceOf(ProtocolError); - expect(result1.message).toContain('Task cancelled or completed'); - expect(result2).toBeInstanceOf(ProtocolError); - expect(result2.message).toContain('Task cancelled or completed'); - expect(result3).toBeInstanceOf(ProtocolError); - expect(result3.message).toContain('Task cancelled or completed'); - - // Verify queue is cleared (no messages available) - const msg = await queue!.dequeue(taskId); - expect(msg).toBeUndefined(); - }); - - it('should clean up resolver mappings when rejecting requests', async () => { - await protocol.connect(transport); - - // Create a task - const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); - const taskId = task.taskId; - - // Queue a request (catch rejection to avoid unhandled promise rejection) - const requestPromise = testRequest( - protocol, - { method: 'test/request', params: { data: 'test' } }, - z.object({ result: z.string() }), - { - relatedTask: { taskId } - } - ).catch(err => err); - - // Get the request ID that was sent - const requestResolvers = (protocol as unknown as TestProtocolInternals)._taskManager._requestResolvers; - const initialResolverCount = requestResolvers.size; - expect(initialResolverCount).toBeGreaterThan(0); - - // Complete the task (triggers cleanup) - const completedTask = { ...task, status: 'completed' as const }; - mockTaskStore.getTask.mockResolvedValue(completedTask); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); - - // Verify request promise is rejected - const result = (await requestPromise) as Error; - expect(result).toBeInstanceOf(ProtocolError); - expect(result.message).toContain('Task cancelled or completed'); - - // Verify resolver mapping is cleaned up - // The resolver should be removed from the map - expect(requestResolvers.size).toBeLessThan(initialResolverCount); - }); - }); -}); - -describe('requestStream() method', () => { - const CallToolResultSchema = z.object({ - content: z.array(z.object({ type: z.string(), text: z.string() })), - _meta: z.object({}).optional() - }); - - test('should yield result immediately for non-task requests', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - // Start the request stream - const streamPromise = (async () => { - const messages = []; - const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ); - for await (const message of stream) { - messages.push(message); - } - return messages; - })(); - - // Simulate server response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { - content: [{ type: 'text', text: 'test result' }], - _meta: {} - } - }); - - const messages = await streamPromise; - - // Should yield exactly one result message - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('result'); - expect(messages[0]).toHaveProperty('result'); - }); - - test('should yield error message on request failure', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - // Start the request stream - const streamPromise = (async () => { - const messages = []; - const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ); - for await (const message of stream) { - messages.push(message); - } - return messages; - })(); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Test error' - } - }); - - const messages = await streamPromise; - - // Should yield exactly one error message - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('error'); - expect(messages[0]).toHaveProperty('error'); - if (messages[0]?.type === 'error') { - expect(messages[0]?.error?.message).toContain('Test error'); - } - }); - - test('should handle cancellation via AbortSignal', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const abortController = new AbortController(); - - // Abort immediately before starting the stream - abortController.abort('User cancelled'); - - // Start the request stream with already-aborted signal - const messages = []; - const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema, - { - signal: abortController.signal - } - ); - for await (const message of stream) { - messages.push(message); - } - - // Should yield error message about cancellation - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('error'); - if (messages[0]?.type === 'error') { - expect(messages[0]?.error?.message).toContain('cancelled'); - } - }); - - describe('Error responses', () => { - test('should yield error as terminal message for server error response', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Server error' - } - }); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - expect(lastMessage.error.message).toContain('Server error'); - }); - - test('should yield error as terminal message for timeout', async () => { - vi.useFakeTimers(); - try { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema, - { - timeout: 100 - } - ) - ); - - // Advance time to trigger timeout - await vi.advanceTimersByTimeAsync(101); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - expect(lastMessage.error).toBeInstanceOf(SdkError); - expect((lastMessage.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); - } finally { - vi.useRealTimers(); - } - }); - - test('should yield error as terminal message for cancellation', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const abortController = new AbortController(); - abortController.abort('User cancelled'); - - // Collect messages - const messages = await toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema, - { - signal: abortController.signal - } - ) - ); - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - expect(lastMessage.error.message).toContain('cancelled'); - }); - - test('should not yield any messages after error message', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Test error' - } - }); - - // Collect messages - const messages = await messagesPromise; - - // Verify only one message (the error) was yielded - expect(messages).toHaveLength(1); - expect(messages[0]?.type).toBe('error'); - - // Try to send another message (should be ignored) - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { - content: [{ type: 'text', text: 'should not appear' }] - } - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - // Verify no additional messages were yielded - expect(messages).toHaveLength(1); - }); - - test('should yield error as terminal message for task failure', async () => { - const transport = new MockTransport(); - const mockTaskStore = createMockTaskStore(); - const protocol = createTestProtocol({ taskStore: mockTaskStore }); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate task creation response - await new Promise(resolve => setTimeout(resolve, 10)); - const taskId = 'test-task-123'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { - _meta: { - task: { - taskId, - status: 'working', - createdAt: new Date().toISOString(), - pollInterval: 100 - } - } - } - }); - - // Wait for task creation to be processed - await new Promise(resolve => setTimeout(resolve, 20)); - - // Update task to failed status - const failedTask = { - taskId, - status: 'failed' as const, - createdAt: new Date().toISOString(), - pollInterval: 100, - ttl: null, - statusMessage: 'Task failed' - }; - mockTaskStore.getTask.mockResolvedValue(failedTask); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - }); - - test('should yield error as terminal message for network error', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - // Override send to simulate network error - transport.send = vi.fn().mockRejectedValue(new Error('Network error')); - - const messages = await toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Verify error is terminal and last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage!); - expect(lastMessage.error).toBeDefined(); - }); - - test('should ensure error is always the final message', async () => { - const transport = new MockTransport(); - const protocol = createTestProtocol({}); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( - { method: 'tools/call', params: { name: 'test', arguments: {} } }, - CallToolResultSchema - ) - ); - - // Simulate server error response - await new Promise(resolve => setTimeout(resolve, 10)); - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Test error' - } - }); - - // Collect messages - const messages = await messagesPromise; - - // Verify error is the last message - expect(messages.length).toBeGreaterThan(0); - const lastMessage = messages[messages.length - 1]; - expect(lastMessage?.type).toBe('error'); - - // Verify all messages before the last are not terminal - for (let i = 0; i < messages.length - 1; i++) { - expect(messages[i]?.type).not.toBe('error'); - expect(messages[i]?.type).not.toBe('result'); - } - }); - }); -}); - -describe('Error handling for missing resolvers', () => { - let protocol: Protocol; - let transport: MockTransport; - let taskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; - let taskMessageQueue: TaskMessageQueue; - let errorHandler: MockInstance; - - beforeEach(() => { - taskStore = createMockTaskStore(); - taskMessageQueue = new InMemoryTaskMessageQueue(); - errorHandler = vi.fn(); - - protocol = createTestProtocol({ taskStore, taskMessageQueue, defaultTaskPollInterval: 100 }); - - // @ts-expect-error deliberately overriding error handler with mock - protocol.onerror = errorHandler; - transport = new MockTransport(); - }); - - describe('Response routing with missing resolvers', () => { - it('should log error for unknown request ID without throwing', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue a response message without a corresponding resolver - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 999, // Non-existent request ID - result: { content: [] } - }, - timestamp: Date.now() - }); - - // Set up the GetTaskPayloadRequest handler to process the message - const testProtocol = protocol as unknown as TestProtocolInternals; - - // Simulate dequeuing and processing the response - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage?.type).toBe('response'); - - // Manually trigger the response handling logic - if (queuedMessage && queuedMessage.type === 'response') { - const responseMessage = queuedMessage.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - - if (!resolver) { - // This simulates what happens in the actual handler - protocol.onerror?.(new Error(`Response handler missing for request ${requestId}`)); - } - } - - // Verify error was logged - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Response handler missing for request 999') - }) - ); - }); - - it('should continue processing after missing resolver error', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue a response with missing resolver, then a valid notification - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 999, - result: { content: [] } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: { progress: 50, total: 100 } - }, - timestamp: Date.now() - }); - - // Process first message (response with missing resolver) - const msg1 = await taskMessageQueue.dequeue(task.taskId); - expect(msg1?.type).toBe('response'); - - // Process second message (should work fine) - const msg2 = await taskMessageQueue.dequeue(task.taskId); - expect(msg2?.type).toBe('notification'); - expect(msg2?.message).toMatchObject({ - method: 'notifications/progress' - }); - }); - }); - - describe('Task cancellation with missing resolvers', () => { - it('should log error when resolver is missing during cleanup', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue a request without storing a resolver - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - // Clear the task queue (simulating cancellation) - const testProtocol = protocol as unknown as TestProtocolInternals; - await testProtocol._taskManager._clearTaskQueue(task.taskId); - - // Verify error was logged for missing resolver - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Resolver missing for request 42') - }) - ); - }); - - it('should handle cleanup gracefully when resolver exists', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - const requestId = 42; - const resolverMock = vi.fn(); - - // Store a resolver - const testProtocol = protocol as unknown as TestProtocolInternals; - testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); - - // Enqueue a request - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: requestId, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - // Clear the task queue - await testProtocol._taskManager._clearTaskQueue(task.taskId); - - // Verify resolver was called with cancellation error - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - - // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InternalError); - expect(calledError.message).toContain('Task cancelled or completed'); - - // Verify resolver was removed - expect(testProtocol._taskManager._requestResolvers.has(requestId)).toBe(false); - }); - - it('should handle mixed messages during cleanup', async () => { - await protocol.connect(transport); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - const testProtocol = protocol as unknown as TestProtocolInternals; - - // Enqueue multiple messages: request with resolver, request without, notification - const requestId1 = 42; - const resolverMock = vi.fn(); - testProtocol._taskManager._requestResolvers.set(requestId1, resolverMock); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: requestId1, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: 43, // No resolver for this one - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'notification', - message: { - jsonrpc: '2.0', - method: 'notifications/progress', - params: { progress: 50, total: 100 } - }, - timestamp: Date.now() - }); - - // Clear the task queue - await testProtocol._taskManager._clearTaskQueue(task.taskId); - - // Verify resolver was called for first request - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - - // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InternalError); - expect(calledError.message).toContain('Task cancelled or completed'); - - // Verify error was logged for second request - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Resolver missing for request 43') - }) - ); - - // Verify queue is empty - const remaining = await taskMessageQueue.dequeue(task.taskId); - expect(remaining).toBeUndefined(); - }); - }); - - describe('Side-channeled request error handling', () => { - it('should log error when response handler is missing for side-channeled request', async () => { - await protocol.connect(transport); - - const testProtocol = protocol as unknown as TestProtocolInternals; - const messageId = 123; - - // Create a response resolver without a corresponding response handler - const responseResolver = (response: JSONRPCResultResponse | Error) => { - const handler = testProtocol._responseHandlers.get(messageId); - if (handler) { - handler(response); - } else { - protocol.onerror?.(new Error(`Response handler missing for side-channeled request ${messageId}`)); - } - }; - - // Simulate the resolver being called without a handler - const mockResponse: JSONRPCResultResponse = { - jsonrpc: '2.0', - id: messageId, - result: { content: [] } - }; - - responseResolver(mockResponse); - - // Verify error was logged - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Response handler missing for side-channeled request 123') - }) - ); - }); - }); - - describe('Error handling does not throw exceptions', () => { - it('should not throw when processing response with missing resolver', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 999, - result: { content: [] } - }, - timestamp: Date.now() - }); - - // This should not throw - const processMessage = async () => { - const msg = await taskMessageQueue.dequeue(task.taskId); - if (msg && msg.type === 'response') { - const testProtocol = protocol as unknown as TestProtocolInternals; - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (!resolver) { - protocol.onerror?.(new Error(`Response handler missing for request ${requestId}`)); - } - } - }; - - await expect(processMessage()).resolves.not.toThrow(); - }); - - it('should not throw during task cleanup with missing resolvers', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'request', - message: { - jsonrpc: '2.0', - id: 42, - method: 'tools/call', - params: { name: 'test-tool', arguments: {} } - }, - timestamp: Date.now() - }); - - const testProtocol = protocol as unknown as TestProtocolInternals; - - // This should not throw - await expect(testProtocol._taskManager._clearTaskQueue(task.taskId)).resolves.not.toThrow(); - }); - }); - - describe('Error message routing', () => { - it('should route error messages to resolvers correctly', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const requestId = 42; - const resolverMock = vi.fn(); - - // Store a resolver - const testProtocol = protocol as unknown as TestProtocolInternals; - testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); - - // Enqueue an error message - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: requestId, - error: { - code: ProtocolErrorCode.InvalidRequest, - message: 'Invalid request parameters' - } - }, - timestamp: Date.now() - }); - - // Simulate dequeuing and processing the error - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage?.type).toBe('error'); - - // Manually trigger the error handling logic - if (queuedMessage && queuedMessage.type === 'error') { - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const reqId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(reqId); - - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(reqId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - - // Verify resolver was called with ProtocolError - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InvalidRequest); - expect(calledError.message).toContain('Invalid request parameters'); - - // Verify resolver was removed from map - expect(testProtocol._taskManager._requestResolvers.has(requestId)).toBe(false); - }); - - it('should log error for unknown request ID in error messages', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - // Enqueue an error message without a corresponding resolver - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 999, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Something went wrong' - } - }, - timestamp: Date.now() - }); - - // Simulate dequeuing and processing the error - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - expect(queuedMessage).toBeDefined(); - expect(queuedMessage?.type).toBe('error'); - - // Manually trigger the error handling logic - if (queuedMessage && queuedMessage.type === 'error') { - const testProtocol = protocol as unknown as TestProtocolInternals; - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - - if (!resolver) { - protocol.onerror?.(new Error(`Error handler missing for request ${requestId}`)); - } - } - - // Verify error was logged - expect(errorHandler).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Error handler missing for request 999') - }) - ); - }); - - it('should handle error messages with data field', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const requestId = 42; - const resolverMock = vi.fn(); - - // Store a resolver - const testProtocol = protocol as unknown as TestProtocolInternals; - testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); - - // Enqueue an error message with data field - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: requestId, - error: { - code: ProtocolErrorCode.InvalidParams, - message: 'Validation failed', - data: { field: 'userName', reason: 'required' } - } - }, - timestamp: Date.now() - }); - - // Simulate dequeuing and processing the error - const queuedMessage = await taskMessageQueue.dequeue(task.taskId); - - if (queuedMessage && queuedMessage.type === 'error') { - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const reqId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(reqId); - - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(reqId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - - // Verify resolver was called with ProtocolError including data - expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); - const calledError = resolverMock.mock.calls[0]![0]; - expect(calledError.code).toBe(ProtocolErrorCode.InvalidParams); - expect(calledError.message).toContain('Validation failed'); - expect(calledError.data).toEqual({ field: 'userName', reason: 'required' }); - }); - - it('should not throw when processing error with missing resolver', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 999, - error: { - code: ProtocolErrorCode.InternalError, - message: 'Error occurred' - } - }, - timestamp: Date.now() - }); - - // This should not throw - const processMessage = async () => { - const msg = await taskMessageQueue.dequeue(task.taskId); - if (msg && msg.type === 'error') { - const testProtocol = protocol as unknown as TestProtocolInternals; - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (!resolver) { - protocol.onerror?.(new Error(`Error handler missing for request ${requestId}`)); - } - } - }; - - await expect(processMessage()).resolves.not.toThrow(); - }); - }); - - describe('Response and error message routing integration', () => { - it('should handle mixed response and error messages in queue', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const testProtocol = protocol as unknown as TestProtocolInternals; - - // Set up resolvers for multiple requests - const resolver1 = vi.fn(); - const resolver2 = vi.fn(); - const resolver3 = vi.fn(); - - testProtocol._taskManager._requestResolvers.set(1, resolver1); - testProtocol._taskManager._requestResolvers.set(2, resolver2); - testProtocol._taskManager._requestResolvers.set(3, resolver3); - - // Enqueue mixed messages: response, error, response - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 1, - result: { content: [{ type: 'text', text: 'Success' }] } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 2, - error: { - code: ProtocolErrorCode.InvalidRequest, - message: 'Request failed' - } - }, - timestamp: Date.now() - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { - jsonrpc: '2.0', - id: 3, - result: { content: [{ type: 'text', text: 'Another success' }] } - }, - timestamp: Date.now() - }); - - // Process all messages - let msg; - while ((msg = await taskMessageQueue.dequeue(task.taskId))) { - if (msg.type === 'response') { - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - resolver(responseMessage); - } - } else if (msg.type === 'error') { - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - } - - // Verify all resolvers were called correctly - expect(resolver1).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); - expect(resolver2).toHaveBeenCalledWith(expect.any(ProtocolError)); - expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); - - // Verify error has correct properties - const error = resolver2.mock.calls[0]![0]; - expect(error.code).toBe(ProtocolErrorCode.InvalidRequest); - expect(error.message).toContain('Request failed'); - - // Verify all resolvers were removed - expect(testProtocol._taskManager._requestResolvers.size).toBe(0); - }); - - it('should maintain FIFO order when processing responses and errors', async () => { - await protocol.connect(transport); - - const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); - const testProtocol = protocol as unknown as TestProtocolInternals; - - const callOrder: number[] = []; - const resolver1 = vi.fn(() => callOrder.push(1)); - const resolver2 = vi.fn(() => callOrder.push(2)); - const resolver3 = vi.fn(() => callOrder.push(3)); - - testProtocol._taskManager._requestResolvers.set(1, resolver1); - testProtocol._taskManager._requestResolvers.set(2, resolver2); - testProtocol._taskManager._requestResolvers.set(3, resolver3); - - // Enqueue in specific order - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { jsonrpc: '2.0', id: 1, result: {} }, - timestamp: 1000 - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: 2, - error: { code: -32600, message: 'Error' } - }, - timestamp: 2000 - }); - - await taskMessageQueue.enqueue(task.taskId, { - type: 'response', - message: { jsonrpc: '2.0', id: 3, result: {} }, - timestamp: 3000 - }); - - // Process all messages - let msg; - while ((msg = await taskMessageQueue.dequeue(task.taskId))) { - if (msg.type === 'response') { - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - resolver(responseMessage); - } - } else if (msg.type === 'error') { - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._taskManager._requestResolvers.get(requestId); - if (resolver) { - testProtocol._taskManager._requestResolvers.delete(requestId); - const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - } - - // Verify FIFO order was maintained - expect(callOrder).toEqual([1, 2, 3]); - }); - }); -}); - -describe('Protocol without task configuration', () => { - let protocol: TestProtocolImpl; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = createTestProtocol(); // empty TaskManager options - }); - - test('request/response flow works normally without task config', async () => { - await protocol.connect(transport); - const mockSchema = z.object({ result: z.string() }); - - const requestPromise = testRequest(protocol, { method: 'example', params: {} }, mockSchema, { timeout: 5000 }); - - // Simulate response - transport.onmessage?.({ - jsonrpc: '2.0', - id: 0, - result: { result: 'hello' } - }); - - const result = await requestPromise; - expect(result).toEqual({ result: 'hello' }); - }); - - test('notifications are sent with proper JSONRPC wrapping without task config', async () => { - await protocol.connect(transport); - - await protocol.notification({ method: 'notifications/cancelled', params: { requestId: '1', reason: 'test' } }); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { requestId: '1', reason: 'test' } - }), - undefined - ); - }); - - test('onClose does not error without task config', async () => { - await protocol.connect(transport); - await expect(protocol.close()).resolves.not.toThrow(); - }); - - test('inbound requests dispatch to handlers without task config', async () => { - const handler = vi.fn().mockResolvedValue({ content: 'ok' }); - protocol.setRequestHandler('ping', handler); - - await protocol.connect(transport); - transport.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 1 }); - - // Wait for async handler - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(handler).toHaveBeenCalled(); - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: '2.0', - id: 1, - result: { content: 'ok' } - }) - ); - }); -}); - -describe('TaskManager lifecycle via Protocol', () => { - let protocol: TestProtocolImpl; - let transport: MockTransport; - - beforeEach(() => { - transport = new MockTransport(); - protocol = new TestProtocolImpl(); - }); - - test('bind() is called during Protocol construction', () => { - const bindSpy = vi.spyOn(TaskManager.prototype, 'bind'); - const p = new TestProtocolImpl({ tasks: {} }); - expect(bindSpy).toHaveBeenCalled(); - expect(p.taskManager).toBeInstanceOf(TaskManager); - bindSpy.mockRestore(); - }); - - test('NullTaskManager is created when no tasks config is provided', () => { - const p = new TestProtocolImpl(); - expect(p.taskManager).toBeInstanceOf(NullTaskManager); - }); - - test('onClose() is called when transport closes', async () => { - const p = createTestProtocol({}); - const onCloseSpy = vi.spyOn(p.taskManager, 'onClose'); - - await p.connect(transport); - await p.close(); - - expect(onCloseSpy).toHaveBeenCalled(); - }); -}); - -describe('TaskManager always present (NullTaskManager pattern)', () => { - test('taskManager accessor always returns a TaskManager', () => { - const mockTaskModule = { getTask: vi.fn() }; - const mockClient = { taskManager: mockTaskModule } as any; - expect(mockClient.taskManager).toBe(mockTaskModule); - }); -}); diff --git a/packages/core/test/shared/protocolTransportHandling.test.ts b/packages/core/test/shared/protocolTransportHandling.test.ts index 4e9c33e67d..23e3dad76b 100644 --- a/packages/core/test/shared/protocolTransportHandling.test.ts +++ b/packages/core/test/shared/protocolTransportHandling.test.ts @@ -38,8 +38,6 @@ describe('Protocol transport handling bug', () => { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} protected buildContext(ctx: BaseContext): BaseContext { return ctx; } diff --git a/packages/core/test/shared/wrapHandler.test.ts b/packages/core/test/shared/wrapHandler.test.ts index 6a6e33fb09..452b58194f 100644 --- a/packages/core/test/shared/wrapHandler.test.ts +++ b/packages/core/test/shared/wrapHandler.test.ts @@ -10,8 +10,6 @@ class TestProtocol extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} } describe('Protocol._wrapHandler', () => { diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts index d26a4cd701..24c319d6ea 100644 --- a/packages/core/test/spec.types.test.ts +++ b/packages/core/test/spec.types.test.ts @@ -552,75 +552,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - TaskAugmentedRequestParams: (sdk: SDKTypes.TaskAugmentedRequestParams, spec: SpecTypes.TaskAugmentedRequestParams) => { - sdk = spec; - spec = sdk; - }, - ToolExecution: (sdk: SDKTypes.ToolExecution, spec: SpecTypes.ToolExecution) => { - sdk = spec; - spec = sdk; - }, - TaskStatus: (sdk: SDKTypes.TaskStatus, spec: SpecTypes.TaskStatus) => { - sdk = spec; - spec = sdk; - }, - TaskMetadata: (sdk: SDKTypes.TaskMetadata, spec: SpecTypes.TaskMetadata) => { - sdk = spec; - spec = sdk; - }, - RelatedTaskMetadata: (sdk: SDKTypes.RelatedTaskMetadata, spec: SpecTypes.RelatedTaskMetadata) => { - sdk = spec; - spec = sdk; - }, - Task: (sdk: SDKTypes.Task, spec: SpecTypes.Task) => { - sdk = spec; - spec = sdk; - }, - CreateTaskResult: (sdk: SDKTypes.CreateTaskResult, spec: SpecTypes.CreateTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskResult: (sdk: SDKTypes.GetTaskResult, spec: SpecTypes.GetTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskPayloadRequest) => { - sdk = spec; - spec = sdk; - }, - ListTasksRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListTasksRequest) => { - sdk = spec; - spec = sdk; - }, - ListTasksResult: (sdk: SDKTypes.ListTasksResult, spec: SpecTypes.ListTasksResult) => { - sdk = spec; - spec = sdk; - }, - CancelTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CancelTaskRequest) => { - sdk = spec; - spec = sdk; - }, - CancelTaskResult: (sdk: SDKTypes.CancelTaskResult, spec: SpecTypes.CancelTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskRequest) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadResult: (sdk: SDKTypes.GetTaskPayloadResult, spec: SpecTypes.GetTaskPayloadResult) => { - sdk = spec; - spec = sdk; - }, - TaskStatusNotificationParams: (sdk: SDKTypes.TaskStatusNotificationParams, spec: SpecTypes.TaskStatusNotificationParams) => { - sdk = spec; - spec = sdk; - }, - TaskStatusNotification: (sdk: WithJSONRPC, spec: SpecTypes.TaskStatusNotification) => { - sdk = spec; - spec = sdk; - }, - /* JSON primitives */ JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { sdk = spec; @@ -715,29 +646,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CreateTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - GetTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.GetTaskPayloadResultResponse - ) => { - sdk = spec; - spec = sdk; - }, - CancelTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CancelTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - ListTasksResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListTasksResultResponse) => { - sdk = spec; - spec = sdk; - }, SetLevelResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SetLevelResultResponse) => { sdk = spec; spec = sdk; @@ -799,9 +707,9 @@ type Assert = T; * SamplingMessageContentBlock, ElicitRequestParams, PrimitiveSchemaDefinition, * SingleSelectEnumSchema, MultiSelectEnumSchema, EnumSchema * - * Primitive type aliases — no object keys to compare (8): + * Primitive type aliases — no object keys to compare (7): * JSONValue, JSONArray, Role, LoggingLevel, ProgressToken, RequestId, - * Cursor, TaskStatus + * Cursor */ // -- Simple types (96) -- @@ -895,22 +803,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>; @@ -946,7 +840,6 @@ type _K_LoggingMessageNotification = Assert< AssertExactKeys, SpecTypes.LoggingMessageNotification> >; type _K_InitializedNotification = Assert, SpecTypes.InitializedNotification>>; -type _K_TaskStatusNotification = Assert, SpecTypes.TaskStatusNotification>>; // -- WithJSONRPCRequest-wrapped request types (21) -- // SDK request types do not include `jsonrpc` or `id` — the spec types do. We @@ -971,12 +864,6 @@ type _K_ListPromptsRequest = Assert, SpecTypes.GetPromptRequest>>; type _K_CreateMessageRequest = Assert, SpecTypes.CreateMessageRequest>>; type _K_InitializeRequest = Assert, SpecTypes.InitializeRequest>>; -type _K_GetTaskPayloadRequest = Assert< - AssertExactKeys, SpecTypes.GetTaskPayloadRequest> ->; -type _K_ListTasksRequest = Assert, SpecTypes.ListTasksRequest>>; -type _K_CancelTaskRequest = Assert, SpecTypes.CancelTaskRequest>>; -type _K_GetTaskRequest = Assert, SpecTypes.GetTaskRequest>>; // -- TypedResultResponse-wrapped types (21) -- // The spec defines typed *ResultResponse interfaces that pair JSONRPCResultResponse @@ -1004,17 +891,6 @@ type _K_ListPromptsResultResponse = Assert< type _K_GetPromptResultResponse = Assert, SpecTypes.GetPromptResultResponse>>; type _K_ListToolsResultResponse = Assert, SpecTypes.ListToolsResultResponse>>; type _K_CallToolResultResponse = Assert, SpecTypes.CallToolResultResponse>>; -type _K_CreateTaskResultResponse = Assert< - AssertExactKeys, SpecTypes.CreateTaskResultResponse> ->; -type _K_GetTaskResultResponse = Assert, SpecTypes.GetTaskResultResponse>>; -type _K_GetTaskPayloadResultResponse = Assert< - AssertExactKeys, SpecTypes.GetTaskPayloadResultResponse> ->; -type _K_CancelTaskResultResponse = Assert< - AssertExactKeys, SpecTypes.CancelTaskResultResponse> ->; -type _K_ListTasksResultResponse = Assert, SpecTypes.ListTasksResultResponse>>; type _K_SetLevelResultResponse = Assert, SpecTypes.SetLevelResultResponse>>; type _K_CreateMessageResultResponse = Assert< AssertExactKeys, SpecTypes.CreateMessageResultResponse> @@ -1048,15 +924,14 @@ const KEY_PARITY_EXCLUDED = [ 'SingleSelectEnumSchema', 'MultiSelectEnumSchema', 'EnumSchema', - // Primitive aliases (8) + // Primitive aliases (7) 'JSONValue', 'JSONArray', 'Role', 'LoggingLevel', 'ProgressToken', 'RequestId', - 'Cursor', - 'TaskStatus' + 'Cursor' ]; // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) @@ -1086,7 +961,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(154); }); it('should have up to date list of missing sdk types', () => { 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..688621ef0c 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,46 +146,14 @@ 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) { if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { - throw error; // Return the error to the caller without wrapping in CallToolResult + throw error; } return this.createToolError(error instanceof Error ? error.message : String(error)); } @@ -265,16 +209,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 +236,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 +678,6 @@ export class McpServer { inputSchema: StandardSchemaWithJSON | undefined, outputSchema: StandardSchemaWithJSON | undefined, annotations: ToolAnnotations | undefined, - execution: ToolExecution | undefined, _meta: Record | undefined, handler: AnyToolHandler ): RegisteredTool { @@ -789,7 +693,6 @@ export class McpServer { inputSchema, outputSchema, annotations, - execution, _meta, handler: handler, executor: createToolExecutor(inputSchema, handler), @@ -914,7 +817,6 @@ export class McpServer { normalizeRawShapeSchema(inputSchema), normalizeRawShapeSchema(outputSchema), annotations, - { taskSupport: 'forbidden' }, _meta, cb as ToolCallback ); @@ -1148,14 +1050,14 @@ export type ToolCallback; /** - * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). + * Tool handler type — either a regular callback or (for compatibility) itself. */ -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 +1065,6 @@ export type RegisteredTool = { inputSchema?: StandardSchemaWithJSON; outputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; - execution?: ToolExecution; _meta?: Record; handler: AnyToolHandler; /** @hidden */ @@ -1194,23 +1095,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 +1184,6 @@ type PromptHandler = (args: Record | undefined, ctx: ServerCont type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; -type TaskHandlerInternal = { - createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; -}; - export type RegisteredPrompt = { title?: string; description?: string; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f6a34f02da..89e1de1817 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -28,21 +28,16 @@ import type { Result, ServerCapabilities, ServerContext, - TaskManagerOptions, ToolResultContent, ToolUseContent } from '@modelcontextprotocol/core'; import { - assertClientRequestTaskCapability, - assertToolsCallTaskCapability, CallToolRequestSchema, CallToolResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, - CreateTaskResultSchema, ElicitResultSchema, EmptyResultSchema, - extractTaskManagerOptions, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -56,21 +51,11 @@ import { } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; -import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; - -/** - * Extended tasks capability that includes runtime configuration (store, messageQueue). - * The runtime-only fields are stripped before advertising capabilities to clients. - */ -export type ServerTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; - export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. */ - capabilities?: Omit & { - tasks?: ServerTasksCapabilityWithRuntime; - }; + capabilities?: ServerCapabilities; /** * Optional instructions describing how to use the server and its features. @@ -101,7 +86,6 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; - private _experimental?: { tasks: ExperimentalServerTasks }; /** * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). @@ -115,22 +99,11 @@ export class Server extends Protocol { private _serverInfo: Implementation, options?: ServerOptions ) { - super({ - ...options, - tasks: extractTaskManagerOptions(options?.capabilities?.tasks) - }); + super(options); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); - // Strip runtime-only fields from advertised capabilities - if (options?.capabilities?.tasks) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = - options.capabilities.tasks; - this._capabilities.tasks = wireCapabilities; - } - this.setRequestHandler('initialize', request => this._oninitialize(request)); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); @@ -174,22 +147,6 @@ export class Server extends Protocol { }; } - /** - * Access experimental features. - * - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - get experimental(): { tasks: ExperimentalServerTasks } { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalServerTasks(this) - }; - } - return this._experimental; - } - // Map log levels by session id private _loggingLevels = new Map(); @@ -237,24 +194,8 @@ export class Server extends Protocol { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); } - const { params } = validatedRequest.data; - const result = await handler(request, ctx); - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = parseSchema(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against CallToolResultSchema const validationResult = parseSchema(CallToolResultSchema, result); if (!validationResult.success) { const errorMessage = @@ -410,14 +351,6 @@ export class Server extends Protocol { } } - protected assertTaskCapability(method: string): void { - assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); - } - - protected assertTaskHandlerCapability(method: string): void { - assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); - } - private async _oninitialize(request: InitializeRequest): Promise { const requestedVersion = request.params.protocolVersion; diff --git a/test/helpers/src/helpers/tasks.ts b/test/helpers/src/helpers/tasks.ts deleted file mode 100644 index 4db3231a67..0000000000 --- a/test/helpers/src/helpers/tasks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Task } from '@modelcontextprotocol/core'; - -/** - * Polls the provided getTask function until the task reaches the desired status or times out. - */ -export async function waitForTaskStatus( - getTask: (taskId: string) => Promise, - taskId: string, - desiredStatus: Task['status'], - { - intervalMs = 100, - timeoutMs = 10_000 - }: { - intervalMs?: number; - timeoutMs?: number; - } = {} -): Promise { - const start = Date.now(); - - // eslint-disable-next-line no-constant-condition - while (true) { - const task = await getTask(taskId); - if (task && task.status === desiredStatus) { - return task; - } - - if (Date.now() - start > timeoutMs) { - throw new Error(`Timed out waiting for task ${taskId} to reach status ${desiredStatus}`); - } - - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } -} diff --git a/test/helpers/src/index.ts b/test/helpers/src/index.ts index 1ecfa8e24a..1fd7ce2b9b 100644 --- a/test/helpers/src/index.ts +++ b/test/helpers/src/index.ts @@ -1,3 +1,2 @@ export * from './helpers/http.js'; export * from './helpers/oauth.js'; -export * from './helpers/tasks.js'; diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 52d151bddb..6f6487963f 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 @@ -1784,20 +1781,7 @@ describe('outputSchema validation', () => { version: '1.0.0' }, { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - }, - tasks: { - get: true, - list: {}, - result: true - } - } - } - } + capabilities: {} } ); @@ -1877,20 +1861,7 @@ describe('outputSchema validation', () => { version: '1.0.0' }, { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - }, - tasks: { - get: true, - list: {}, - result: true - } - } - } - } + capabilities: {} } ); @@ -1967,20 +1938,7 @@ describe('outputSchema validation', () => { version: '1.0.0' }, { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - }, - tasks: { - get: true, - list: {}, - result: true - } - } - } - } + capabilities: {} } ); @@ -2053,20 +2011,7 @@ describe('outputSchema validation', () => { version: '1.0.0' }, { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - }, - tasks: { - get: true, - list: {}, - result: true - } - } - } - } + capabilities: {} } ); @@ -2166,20 +2111,7 @@ describe('outputSchema validation', () => { version: '1.0.0' }, { - capabilities: { - tasks: { - requests: { - tools: { - call: {} - }, - tasks: { - get: true, - list: {}, - result: true - } - } - } - } + capabilities: {} } ); @@ -2263,7 +2195,7 @@ describe('outputSchema validation', () => { name: 'test-client', version: '1.0.0' }, - { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + { capabilities: {} } ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -2280,1813 +2212,6 @@ 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(); -}); - describe('getSupportedElicitationModes', () => { test('should support nothing when capabilities are undefined', () => { const result = getSupportedElicitationModes(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..6b1a5c6f5b 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -1,32 +1,23 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Client } from '@modelcontextprotocol/client'; import type { - CreateMessageResult, - ElicitRequestSchema, - ElicitResult, JsonSchemaType, 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 { Server } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; import supertest from 'supertest'; -import * as z from 'zod/v4'; describe('Server with standard protocol methods', () => { /* @@ -1825,249 +1816,6 @@ 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(() => {}); - }); - }); - }); -}); - describe('createMessage backwards compatibility', () => { test('createMessage without tools returns single content (backwards compat)', async () => { const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); @@ -2359,1420 +2107,6 @@ 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(() => {}); - }); - }); -}); - describe('Server registerCapabilities with logging', () => { test('registerCapabilities should register logging/setLevel handler', async () => { const server = new Server({ name: 'test-server', version: '1.0.0' }); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 92af09744c..d66b0648c4 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({ @@ -6444,599 +6281,4 @@ 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(); - }); - }); }); 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); - }); -}); From 8d651abd925176dd061285116f5cfc332ab5a72d Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 15 May 2026 19:47:31 +0300 Subject: [PATCH 2/2] clean up --- .../plans/2026-06-30-remove-tasks.md | 826 ------------------ 1 file changed, 826 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-30-remove-tasks.md diff --git a/docs/superpowers/plans/2026-06-30-remove-tasks.md b/docs/superpowers/plans/2026-06-30-remove-tasks.md deleted file mode 100644 index 2e60dfe510..0000000000 --- a/docs/superpowers/plans/2026-06-30-remove-tasks.md +++ /dev/null @@ -1,826 +0,0 @@ -# Remove Tasks Feature — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Remove the entire Tasks feature (experimental task-augmented execution, TaskManager, task stores, task schemas) from the MCP TypeScript SDK — as if it never existed. - -**Architecture:** Tasks is woven into four layers: Zod schemas/types, the Protocol class (via TaskManager), Server/Client classes (experimental accessors + capability wiring), and barrel exports. We delete bottom-up: schemas/types first, then core TaskManager, then Server/Client integrations, then exports and tests. Each task produces a buildable (but possibly failing-tests) state; the final task verifies everything passes. - -**Tech Stack:** TypeScript, Zod v4, vitest, pnpm workspaces - ---- - -## File Map - -### Files/Directories to DELETE entirely - -``` -packages/core/src/experimental/tasks/ (helpers.ts, interfaces.ts, stores/inMemory.ts) -packages/core/src/experimental/index.ts (only re-exports tasks) -packages/server/src/experimental/tasks/ (index.ts, interfaces.ts, server.ts, mcpServer.ts) -packages/server/src/experimental/index.ts (only re-exports tasks) -packages/client/src/experimental/tasks/ (client.ts, client.examples.ts) -packages/client/src/experimental/index.ts (only re-exports tasks) -packages/core/src/shared/taskManager.ts (915 lines — TaskManager, NullTaskManager) -test/integration/test/experimental/tasks/ (task.test.ts, taskListing.test.ts) -test/integration/test/taskLifecycle.test.ts -packages/core/test/experimental/ (inMemory.test.ts) -test/helpers/src/helpers/tasks.ts -examples/server/src/simpleTaskInteractive.ts -examples/server/src/README-simpleTaskInteractive.md -examples/client/src/simpleTaskInteractiveClient.ts -.changeset/extract-task-manager.md -.changeset/fix-failed-task-result-retrieval.md -.changeset/fix-task-session-isolation.md -``` - -### Files to MODIFY - -``` -packages/core/src/types/schemas.ts — remove ~20 task schemas, TaskAugmentedRequestParamsSchema, ToolExecutionSchema -packages/core/src/types/spec.types.ts — remove task types, TaskAugmentedRequestParams, ToolExecution -packages/core/src/types/types.ts — remove task type aliases -packages/core/src/types/specTypeSchema.ts — remove task schema names from allowlist -packages/core/src/types/guards.ts — remove isTaskAugmentedRequestParams -packages/core/src/types/constants.ts — remove RELATED_TASK_META_KEY -packages/core/src/shared/protocol.ts — remove TaskManager integration entirely -packages/core/src/shared/responseMessage.ts — remove TaskStatusMessage, TaskCreatedMessage -packages/core/src/index.ts — remove taskManager exports, experimental re-export -packages/core/src/exports/public/index.ts — remove task exports -packages/server/src/server/server.ts — remove task capability wiring, experimental getter -packages/server/src/server/mcp.ts — remove task handling logic, experimental getter -packages/server/src/index.ts — remove experimental task exports -packages/client/src/client/client.ts — remove task capability wiring, experimental getter -packages/client/src/index.ts — remove experimental task exports -packages/core/test/shared/protocol.test.ts — remove task-related tests -test/helpers/src/index.ts — remove tasks export -examples/server/src/simpleStreamableHttp.ts — remove task store config and registerToolTask -docs/migration.md — remove task-related sections -docs/migration-SKILL.md — remove task-related sections -CLAUDE.md — remove task references -``` - ---- - -### Task 1: Delete task source directories and standalone files - -**Files:** -- Delete: `packages/core/src/experimental/tasks/` (entire directory) -- Delete: `packages/core/src/experimental/index.ts` -- Delete: `packages/server/src/experimental/tasks/` (entire directory) -- Delete: `packages/server/src/experimental/index.ts` -- Delete: `packages/client/src/experimental/tasks/` (entire directory) -- Delete: `packages/client/src/experimental/index.ts` -- Delete: `packages/core/src/shared/taskManager.ts` - -- [ ] **Step 1: Delete core experimental tasks directory and its barrel** - -```bash -rm -rf packages/core/src/experimental/tasks -rm packages/core/src/experimental/index.ts -rmdir packages/core/src/experimental # should be empty now -``` - -- [ ] **Step 2: Delete server experimental tasks directory and its barrel** - -```bash -rm -rf packages/server/src/experimental/tasks -rm packages/server/src/experimental/index.ts -rmdir packages/server/src/experimental -``` - -- [ ] **Step 3: Delete client experimental tasks directory and its barrel** - -```bash -rm -rf packages/client/src/experimental/tasks -rm packages/client/src/experimental/index.ts -rmdir packages/client/src/experimental -``` - -- [ ] **Step 4: Delete TaskManager** - -```bash -rm packages/core/src/shared/taskManager.ts -``` - ---- - -### Task 2: Remove task schemas from `schemas.ts` - -**Files:** -- Modify: `packages/core/src/types/schemas.ts` - -Remove these schemas and all their JSDoc comments. The schemas are spread across the file, so use line references below. - -- [ ] **Step 1: Remove `RELATED_TASK_META_KEY` import and task-related schemas at top of file** - -In `schemas.ts`, remove the `RELATED_TASK_META_KEY` import from the `constants.js` import line (line 3). Then remove: - -- `TaskCreationParamsSchema` (lines ~33–43) -- `TaskMetadataSchema` (lines ~45–47) -- `RelatedTaskMetadataSchema` (lines ~49–55) -- The `[RELATED_TASK_META_KEY]` field from `BaseRequestParamsSchema` (line ~65) -- `TaskAugmentedRequestParamsSchema` (lines ~79–91) — this is the schema that adds `task` to requests - -- [ ] **Step 2: Remove task capability schemas** - -Remove: -- `ClientTasksCapabilitySchema` (lines ~335–368) -- `ServerTasksCapabilitySchema` (lines ~372–410) -- The `tasks` field from `ClientCapabilitiesSchema` (line ~442) -- The `tasks` field from `ServerCapabilitiesSchema` (line ~522) - -- [ ] **Step 3: Remove task status, task, and task-related request/result schemas** - -Remove: -- `TaskStatusSchema` (line ~620) -- `TaskSchema` (lines ~628–648) -- `CreateTaskResultSchema` (lines ~652–656) -- `TaskStatusNotificationParamsSchema` (line ~659) -- `TaskStatusNotificationSchema` (lines ~664–668) -- `GetTaskRequestSchema` (lines ~672–678) -- `GetTaskResultSchema` (line ~682 — aliases TaskSchema) -- `GetTaskPayloadRequestSchema` (lines ~687–693) -- `GetTaskPayloadResultSchema` (line ~697 — aliases ResultSchema) -- `ListTasksRequestSchema` (lines ~705–709) -- `ListTasksResultSchema` (lines ~712–716) -- `CancelTaskRequestSchema` (lines ~719–725) -- `CancelTaskResultSchema` (line ~729 — aliases EmptyResultSchema) - -- [ ] **Step 4: Remove `ToolExecutionSchema` and `execution` from `ToolSchema`** - -`ToolExecutionSchema` (lines ~1288–1298) only contains `taskSupport` — remove the entire schema. - -In `ToolSchema` (line ~1341), remove the `execution: ToolExecutionSchema.optional()` field. - -- [ ] **Step 5: Change request params schemas to extend `BaseRequestParamsSchema` instead of `TaskAugmentedRequestParamsSchema`** - -Change these lines: -- `CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `CallToolRequestParamsSchema = BaseRequestParamsSchema.extend({` (line ~1412) -- `CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `CreateMessageRequestParamsSchema = BaseRequestParamsSchema.extend({` (line ~1610) -- `ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `ElicitRequestFormParamsSchema = BaseRequestParamsSchema.extend({` (line ~1849) -- `ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({` → `ElicitRequestURLParamsSchema = BaseRequestParamsSchema.extend({` (line ~1876) - -- [ ] **Step 6: Remove task methods from result type mapping** - -At the bottom of `schemas.ts` (lines ~2176–2179), remove: -```typescript -'tasks/get': GetTaskResultSchema, -'tasks/result': ResultSchema, -'tasks/list': ListTasksResultSchema, -'tasks/cancel': CancelTaskResultSchema -``` - ---- - -### Task 3: Remove task types from `spec.types.ts`, `types.ts`, `specTypeSchema.ts`, `guards.ts`, `constants.ts` - -**Files:** -- Modify: `packages/core/src/types/spec.types.ts` -- Modify: `packages/core/src/types/types.ts` -- Modify: `packages/core/src/types/specTypeSchema.ts` -- Modify: `packages/core/src/types/guards.ts` -- Modify: `packages/core/src/types/constants.ts` - -- [ ] **Step 1: Remove task types from `spec.types.ts`** - -Remove: -- `TaskAugmentedRequestParams` interface (lines ~91–104) — change `CallToolRequestParams`, `CreateMessageRequestParams`, `ElicitRequestFormParams`, `ElicitRequestURLParams` to extend `RequestParams` instead -- `ClientCapabilities.tasks` field (lines ~546–578) -- `ServerCapabilities.tasks` field (lines ~672–694) -- `ToolExecution` interface (lines ~1695–1708) -- `Tool.execution` field (line ~1748) -- All types in the `/* Tasks */` section (lines ~1774–1965): `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `Task`, `CreateTaskResult`, `CreateTaskResultResponse`, `GetTaskRequest`, `GetTaskResult`, `GetTaskResultResponse`, `GetTaskPayloadRequest`, `GetTaskPayloadResult`, `ListTasksRequest`, `ListTasksResult`, `CancelTaskRequest`, `CancelTaskResult`, `TaskStatusNotificationParams`, `TaskStatusNotification` -- Remove task-related JSDoc references in `CancelledNotification` (lines ~366–367, ~386) -- Remove task mention from `ErrorCode` docs (line ~284) - -- [ ] **Step 2: Remove task type aliases from `types.ts`** - -Remove all task-related schema imports and type aliases: -```typescript -// Remove these imports from schemas.ts: -CancelTaskRequestSchema, CancelTaskResultSchema, CreateTaskResultSchema, -GetTaskPayloadRequestSchema, GetTaskPayloadResultSchema, GetTaskRequestSchema, -GetTaskResultSchema, ListTasksRequestSchema, ListTasksResultSchema, -RelatedTaskMetadataSchema, TaskAugmentedRequestParamsSchema, TaskCreationParamsSchema, -TaskMetadataSchema, TaskSchema, TaskStatusNotificationParamsSchema, -TaskStatusNotificationSchema, TaskStatusSchema - -// Remove these type aliases (lines ~190, ~235–260): -TaskAugmentedRequestParams, Task, TaskStatus, TaskCreationParams, TaskMetadata, -RelatedTaskMetadata, CreateTaskResult, TaskStatusNotificationParams, -TaskStatusNotification, GetTaskRequest, GetTaskResult, GetTaskPayloadRequest, -GetTaskPayloadResult, ListTasksRequest, ListTasksResult, CancelTaskRequest, CancelTaskResult -``` - -Also remove the `ToolExecution` type alias and `ToolExecutionSchema` import if present. - -- [ ] **Step 3: Remove task schema names from `specTypeSchema.ts` allowlist** - -Remove these entries from the `SPEC_SCHEMA_NAMES` array: -``` -'CancelTaskRequestSchema', 'CancelTaskResultSchema', 'CreateTaskResultSchema', -'GetTaskPayloadRequestSchema', 'GetTaskPayloadResultSchema', -'GetTaskRequestSchema', 'GetTaskResultSchema', -'ListTasksRequestSchema', 'ListTasksResultSchema', -'RelatedTaskMetadataSchema', 'TaskSchema', 'TaskAugmentedRequestParamsSchema', -'TaskCreationParamsSchema', 'TaskMetadataSchema', 'TaskStatusSchema', -'TaskStatusNotificationSchema', 'TaskStatusNotificationParamsSchema' -``` - -Also remove `'ToolExecutionSchema'` if present. - -- [ ] **Step 4: Remove `isTaskAugmentedRequestParams` from `guards.ts`** - -Remove the import of `TaskAugmentedRequestParamsSchema` and `TaskAugmentedRequestParams`. -Remove the `isTaskAugmentedRequestParams` function (lines ~85–91). - -- [ ] **Step 5: Remove `RELATED_TASK_META_KEY` from `constants.ts`** - -Remove line 5: `export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task';` - ---- - -### Task 4: Remove TaskManager integration from `protocol.ts` - -**Files:** -- Modify: `packages/core/src/shared/protocol.ts` - -This is the most complex modification. The TaskManager intercepts request/response/notification flows. - -- [ ] **Step 1: Remove task imports** - -Remove from the type imports (lines 24, 33): -- `RelatedTaskMetadata` -- `TaskCreationParams` - -Remove the taskManager imports (lines 49–50): -```typescript -import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; -import { NullTaskManager, TaskManager } from './taskManager.js'; -``` - -- [ ] **Step 2: Remove `tasks` from `ProtocolOptions`** - -Remove the `tasks?: TaskManagerOptions` field and its JSDoc from `ProtocolOptions` (lines ~87–94). - -- [ ] **Step 3: Remove task fields from `RequestOptions`** - -Remove from `RequestOptions`: -- The `task?: TaskCreationParams` field and JSDoc (lines ~140–142) -- The `relatedTask?: RelatedTaskMetadata` field and JSDoc (lines ~144–147) -- Update the `onprogress` JSDoc to remove the task-related sentence (line ~109) - -- [ ] **Step 4: Remove `relatedTask` from `NotificationOptions`** - -Remove the `relatedTask?: RelatedTaskMetadata` field and JSDoc from `NotificationOptions` (lines ~160–162). - -- [ ] **Step 5: Remove `task?` from `BaseContext`** - -Remove the `task?: TaskContext` field and its JSDoc from `BaseContext` (lines ~236–239). - -Change `TaskRequestOptions` to `RequestOptions` in `BaseContext.mcpReq.send` signatures (lines ~209, ~215). - -- [ ] **Step 6: Remove `_taskManager` field, constructor wiring, and `_bindTaskManager()`** - -In the `Protocol` class: -- Remove `private _taskManager: TaskManager;` (line ~322) -- Remove `taskManager` getter (lines ~376–378) -- In constructor: remove the TaskManager creation lines (lines ~353–355): - ```typescript - this._taskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); - this._bindTaskManager(); - ``` -- Remove entire `_bindTaskManager()` method (lines ~380–403) -- Remove `assertTaskCapability()` abstract method declaration (lines ~785–790) -- Remove `assertTaskHandlerCapability()` abstract method declaration (lines ~792–798) - -- [ ] **Step 7: Simplify `_onclose()` to remove TaskManager call** - -Remove `this._taskManager.onClose();` (line ~509). - -- [ ] **Step 8: Simplify `_onrequest()` to remove TaskManager delegation** - -In `_onrequest()` (starting at line ~555): - -Replace the TaskManager delegation block (lines ~570–631) with direct context building. The key changes: -- Remove `const taskResult = this._taskManager.processInboundRequest(...)` and all destructuring -- Use `inboundCtx.sendNotification` and `inboundCtx.sendRequest` directly in BaseContext -- Remove `taskContext` from BaseContext construction (remove `task: taskContext` at line ~631) -- Replace `routeResponse(...)` calls with direct `capturedTransport?.send(...)` — there are three places: the no-handler error path, the success path, and the error path - -The simplified `_onrequest` should: -1. Build the abort controller and base context directly -2. Call the handler -3. Send responses directly through `capturedTransport?.send()` - -- [ ] **Step 9: Simplify `_onresponse()` to remove TaskManager delegation** - -In `_onresponse()` (starting at line ~722): - -Remove: -```typescript -const taskResult = this._taskManager.processInboundResponse(response, messageId); -if (taskResult.consumed) return; -const preserveProgress = taskResult.preserveProgress; -``` - -And change `if (!preserveProgress)` to unconditionally delete progress handlers: -```typescript -this._progressHandlers.delete(messageId); -``` - -Remove the comment about "Keep progress handler alive for CreateTaskResult responses" (line ~739). - -- [ ] **Step 10: Simplify `_requestWithSchema()` to remove TaskManager delegation** - -In `_requestWithSchema()` (starting at line ~836): - -Remove the entire TaskManager outbound block (lines ~941–964): -```typescript -const responseHandler = ...; -let outboundQueued = false; -try { const taskResult = this._taskManager.processOutboundRequest(...); ... } -``` - -Replace with direct transport send (the code that's currently in the `if (!outboundQueued)` block at line ~966). - -- [ ] **Step 11: Simplify `notification()` to remove TaskManager delegation** - -In `notification()` (starting at line ~992): - -Remove the TaskManager delegation block (lines ~999–1007): -```typescript -const taskResult = await this._taskManager.processOutboundNotification(notification, options); -const queued = taskResult.queued; -const jsonrpcNotification = taskResult.queued ? undefined : taskResult.jsonrpcNotification; -if (queued) { return; } -``` - -Build the JSONRPC notification directly (it was previously done by TaskManager for the non-queued path). The simple version: -```typescript -const jsonrpcNotification: JSONRPCNotification = { - jsonrpc: '2.0', - method: notification.method, - ...(notification.params && { params: notification.params }) -}; -``` - -Also remove `!options?.relatedTask` from the debounce guard (line ~1013). - ---- - -### Task 5: Simplify `responseMessage.ts` - -**Files:** -- Modify: `packages/core/src/shared/responseMessage.ts` - -- [ ] **Step 1: Remove task message types and simplify** - -Remove: -- Import of `Task` from types -- `TaskStatusMessage` interface (lines ~16–19) -- `TaskCreatedMessage` interface (lines ~27–30) -- Task references from `ResponseMessage` union type (line ~67) — becomes: `ResultMessage | ErrorMessage` -- Task references from JSDoc comments throughout the file -- In `takeResult()`, the `taskCreated` and `taskStatus` cases are already handled by the fall-through — the function only returns on `result` and throws on `error`, so no code change needed there, but update the JSDoc to remove task mentions. - ---- - -### Task 6: Clean up Server (`server.ts` and `mcp.ts`) - -**Files:** -- Modify: `packages/server/src/server/server.ts` -- Modify: `packages/server/src/server/mcp.ts` - -- [ ] **Step 1: Clean up `server.ts` imports** - -Remove from the type imports: -- `TaskManagerOptions` (line 31) - -Remove from the value imports: -- `assertClientRequestTaskCapability` (line 36) -- `assertToolsCallTaskCapability` (line 37) -- `CreateTaskResultSchema` (line 42) -- `extractTaskManagerOptions` (line 45) - -Remove: -```typescript -import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; -``` -(line 59) - -- [ ] **Step 2: Remove `ServerTasksCapabilityWithRuntime` and simplify `ServerOptions`** - -Remove the `ServerTasksCapabilityWithRuntime` type (line 65). - -Simplify `ServerOptions.capabilities` — remove the `Omit` and `tasks?` override (lines 71–73). Just use: -```typescript -capabilities?: ServerCapabilities; -``` - -- [ ] **Step 3: Remove task wiring from Server constructor** - -- Remove `tasks: extractTaskManagerOptions(options?.capabilities?.tasks)` from super call (line 120) — just pass `...options` -- Remove the entire `if (options?.capabilities?.tasks)` block that strips runtime fields (lines 127–132) -- Remove `private _experimental?: { tasks: ExperimentalServerTasks };` (line 104) -- Remove the `get experimental()` getter (lines 184–191) - -- [ ] **Step 4: Remove task validation from `_wrapHandler()`** - -In `_wrapHandler()` for `tools/call` (lines 225–267): - -Remove the `if (params.task)` block (lines 245–255) that validates `CreateTaskResult`. The method should only validate against `CallToolResultSchema`. - -Also change the return type annotation on the handler from `Promise` to `Promise` if present. - -- [ ] **Step 5: Remove `assertTaskCapability()` and `assertTaskHandlerCapability()`** - -Remove both methods (lines 413–418). - -- [ ] **Step 6: Clean up `mcp.ts` imports** - -Remove from imports: -- `CreateTaskResult` (line 8) -- `CreateTaskServerContext` (line 9) -- `ToolExecution` (line 26) - -Remove: -```typescript -import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; -import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; -``` -(lines 44–45) - -- [ ] **Step 7: Remove `_experimental` and experimental getter from `McpServer`** - -Remove `private _experimental?: { tasks: ExperimentalMcpServerTasks };` (line 75). -Remove the `get experimental()` getter (lines 88–95). - -- [ ] **Step 8: Simplify `tools/call` handler in McpServer** - -In the `tools/call` handler (lines 163–216), remove all task logic: - -Remove: -- `const isTaskRequest = !!request.params.task;` (line 173) -- `const taskSupport = tool.execution?.taskSupport;` (line 174) -- `const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler);` (line 175) -- The taskSupport validation block (lines 178–183) -- The `taskSupport === 'required'` guard (lines 186–191) -- The `taskSupport === 'optional'` automatic polling block (lines 194–196) -- The `if (isTaskRequest) { return result; }` block (lines 203–205) - -The handler becomes just: validate input → execute → validate output → return. - -Change the handler return type from `Promise` to `Promise`. - -- [ ] **Step 9: Remove `handleAutomaticTaskPolling()` method** - -Delete the entire `handleAutomaticTaskPolling()` method (lines 310–339). - -- [ ] **Step 10: Simplify `validateToolOutput()` and `executeToolHandler()` signatures** - -In `validateToolOutput()` (line 268): Change parameter from `result: CallToolResult | CreateTaskResult` to `result: CallToolResult`. Remove the `if (!('content' in result))` guard (lines 274–276). - -In `executeToolHandler()` (line 302): Change return type from `Promise` to `Promise`. - -- [ ] **Step 11: Remove `ToolExecution` from tool registration types** - -In `mcp.ts`, find the `RegisteredTool` type and the `tool()` method overloads. Remove `execution?: ToolExecution` from any interfaces/types that carry it. If `ToolExecution` was the type for `execution`, note that `ToolExecutionSchema` is already deleted — the `execution` field on `ToolSchema` was removed in Task 2. - -Also remove `taskSupport: 'forbidden'` default from any tool registration code (around line ~917 — search for it). - ---- - -### Task 7: Clean up Client (`client.ts`) - -**Files:** -- Modify: `packages/client/src/client/client.ts` - -- [ ] **Step 1: Clean up imports** - -Remove from type imports: -- `TaskManagerOptions` (line 32) - -Remove from value imports: -- `assertClientRequestTaskCapability` (line 38) -- `assertToolsCallTaskCapability` (line 39) -- `CreateTaskResultSchema` (line 45) -- `extractTaskManagerOptions` (line 49) - -Remove: -```typescript -import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; -``` -(line 68) - -- [ ] **Step 2: Remove `ClientTasksCapabilityWithRuntime` and simplify `ClientOptions`** - -Remove the `ClientTasksCapabilityWithRuntime` type (line 148). - -Simplify `ClientOptions.capabilities` — remove the `Omit` and `tasks?` override (lines 154–156). Just use: -```typescript -capabilities?: ClientCapabilities; -``` - -- [ ] **Step 3: Remove task wiring from Client constructor** - -- Remove `tasks: extractTaskManagerOptions(options?.capabilities?.tasks)` from super call (line 249) — just pass `...options` -- Remove the entire `if (options?.capabilities?.tasks)` block that strips runtime fields (lines 256–261) - -- [ ] **Step 4: Remove task fields and experimental getter** - -Remove: -- `private _cachedKnownTaskTools: Set = new Set();` (line 233) -- `private _cachedRequiredTaskTools: Set = new Set();` (line 234) -- `private _experimental?: { tasks: ExperimentalClientTasks };` (line 235) -- The `get experimental()` getter (lines 309–316) - -- [ ] **Step 5: Remove task validation from `_wrapHandler()` for elicitation** - -In `_wrapHandler()` for `elicitation/create` (around line ~339): - -Remove the `if (params.task)` block (lines ~363–374) that validates `CreateTaskResult`. Keep only the non-task `ElicitResultSchema` validation path. - -- [ ] **Step 6: Remove task validation from `_wrapHandler()` for sampling** - -Find the `sampling/createMessage` section in `_wrapHandler()` (around line ~420). Remove the `if (params.task)` block (lines ~420–429). - -- [ ] **Step 7: Remove task guard from `callTool()`** - -In `callTool()` (line ~862), remove the task-required guard (lines ~863–869): -```typescript -if (this.isToolTaskRequired(params.name)) { - throw new ProtocolError(...); -} -``` - -Also remove the task-related JSDoc comment about `client.experimental.tasks.callToolStream()` (line ~831). - -- [ ] **Step 8: Remove task tool caching methods** - -Remove: -- `isToolTask()` method (lines ~911–917) -- `isToolTaskRequired()` method (lines ~923–925) - -In `cacheToolMetadata()` (lines ~931–952): -- Remove `this._cachedKnownTaskTools.clear();` and `this._cachedRequiredTaskTools.clear();` -- Remove the `taskSupport` caching block (lines ~943–950) - -- [ ] **Step 9: Remove `assertTaskCapability()` and `assertTaskHandlerCapability()`** - -Remove both methods (lines ~704–709). - ---- - -### Task 8: Update barrel exports - -**Files:** -- Modify: `packages/core/src/index.ts` -- Modify: `packages/core/src/exports/public/index.ts` -- Modify: `packages/server/src/index.ts` -- Modify: `packages/client/src/index.ts` - -- [ ] **Step 1: Clean up `packages/core/src/index.ts`** - -Remove: -```typescript -export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; -export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; -``` -(lines 9–10) - -Remove: -```typescript -export * from './experimental/index.js'; -``` -(line 21) - -- [ ] **Step 2: Clean up `packages/core/src/exports/public/index.ts`** - -Remove the task manager types block (lines 54–55): -```typescript -// Task manager types (NOT TaskManager class itself — internal) -export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; -``` - -Remove task response message types from the response message export block (lines 63–64): -```typescript -TaskCreatedMessage, -TaskStatusMessage -``` - -Remove the experimental task types and classes block (lines 121–138): -```typescript -// Experimental task types and classes -export { assertClientRequestTaskCapability, assertToolsCallTaskCapability } from '../../experimental/tasks/helpers.js'; -export type { ... } from '../../experimental/tasks/interfaces.js'; -export { isTerminal } from '../../experimental/tasks/interfaces.js'; -export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; -``` - -Remove `isTaskAugmentedRequestParams` from the guards export (line 117). - -Remove `RELATED_TASK_META_KEY` from the constants export (line 95). - -- [ ] **Step 3: Clean up `packages/server/src/index.ts`** - -Remove the experimental exports block (lines 43–46): -```typescript -// 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'; -``` - -- [ ] **Step 4: Clean up `packages/client/src/index.ts`** - -Remove the experimental exports block (lines 74–75): -```typescript -// experimental exports -export { ExperimentalClientTasks } from './experimental/tasks/client.js'; -``` - ---- - -### Task 9: Clean up tests and examples - -**Files:** -- Delete: `test/integration/test/experimental/tasks/` (entire directory) -- Delete: `test/integration/test/taskLifecycle.test.ts` -- Delete: `packages/core/test/experimental/` (entire directory) -- Delete: `test/helpers/src/helpers/tasks.ts` -- Delete: `examples/server/src/simpleTaskInteractive.ts` -- Delete: `examples/server/src/README-simpleTaskInteractive.md` -- Delete: `examples/client/src/simpleTaskInteractiveClient.ts` -- Modify: `test/helpers/src/index.ts` -- Modify: `packages/core/test/shared/protocol.test.ts` -- Modify: `examples/server/src/simpleStreamableHttp.ts` - -- [ ] **Step 1: Delete task-specific test files** - -```bash -rm -rf test/integration/test/experimental/tasks -rm test/integration/test/taskLifecycle.test.ts -rm -rf packages/core/test/experimental -``` - -- [ ] **Step 2: Delete task test helpers** - -```bash -rm test/helpers/src/helpers/tasks.ts -``` - -Remove the re-export from `test/helpers/src/index.ts`: -```typescript -export * from './helpers/tasks.js'; -``` - -- [ ] **Step 3: Delete task example files** - -```bash -rm examples/server/src/simpleTaskInteractive.ts -rm examples/server/src/README-simpleTaskInteractive.md -rm examples/client/src/simpleTaskInteractiveClient.ts -``` - -- [ ] **Step 4: Remove task-related tests from `protocol.test.ts`** - -In `packages/core/test/shared/protocol.test.ts`: - -Remove all task-related imports (lines ~10–19): -- `TaskMessageQueue`, `TaskStore` from experimental interfaces -- `InMemoryTaskMessageQueue` from experimental stores -- `TaskManagerOptions`, `NullTaskManager`, `TaskManager` from taskManager - -Remove `assertTaskCapability()` and `assertTaskHandlerCapability()` stubs from `TestProtocolImpl` (lines ~45–46). - -Remove `taskOptions` parameter from `createTestProtocol()` (lines ~52–53). - -Remove the `createMockTaskStore()` helper and all test blocks that use task functionality. Search for `describe` blocks containing "task" in their names and remove them entirely. - -- [ ] **Step 5: Remove task configuration from `simpleStreamableHttp.ts` example** - -In `examples/server/src/simpleStreamableHttp.ts`: - -Remove imports of `InMemoryTaskMessageQueue`, `InMemoryTaskStore` (line 14). - -Remove the task store creation (lines 25–26): -```typescript -const taskStore = new InMemoryTaskStore(); -``` - -Remove the `tasks` field from server capabilities (lines 40–43): -```typescript -tasks: { - ... - taskStore, - taskMessageQueue: new InMemoryTaskMessageQueue() -} -``` - -Remove the `registerToolTask` call and its entire implementation (lines ~442–483). - ---- - -### Task 10: Delete changesets and update documentation - -**Files:** -- Delete: `.changeset/extract-task-manager.md` -- Delete: `.changeset/fix-failed-task-result-retrieval.md` -- Delete: `.changeset/fix-task-session-isolation.md` -- Modify: `docs/migration.md` -- Modify: `docs/migration-SKILL.md` -- Modify: `CLAUDE.md` - -- [ ] **Step 1: Delete task-related changesets** - -```bash -rm .changeset/extract-task-manager.md -rm .changeset/fix-failed-task-result-retrieval.md -rm .changeset/fix-task-session-isolation.md -``` - -- [ ] **Step 2: Remove task references from `docs/migration.md`** - -Remove: -- The `` `CreateTaskResult` `` mention in the return type description (line ~488) -- The `extra.taskStore` → `ctx.task?.store` migration rows (lines ~594–596) -- The task code examples (lines ~603–625) -- The `task?` mention in context field descriptions (line ~624) -- The entire "Experimental: TaskCreationParams.ttl no longer accepts null" section (lines ~856–895) - -- [ ] **Step 3: Remove task references from `docs/migration-SKILL.md`** - -Remove: -- The `extra.taskStore`/`extra.taskId`/`extra.taskRequestedTtl` migration rows (lines ~423–425) -- The entire section "12. Experimental: TaskCreationParams.ttl no longer accepts null" (lines ~476–493) - -- [ ] **Step 4: Remove task references from `CLAUDE.md`** - -Remove: -- The `task?` field from `BaseContext` description -- The `task?` field from `ServerContext` description -- References to `TaskManager` and experimental tasks -- The `- **Tasks**: Long-running task support with polling/resumption` line under Experimental Features - ---- - -### Task 11: Build and test - -**Files:** None (verification only) - -- [ ] **Step 1: Build all packages** - -```bash -pnpm build:all -``` - -Expected: Clean build with no errors. - -- [ ] **Step 2: Type-check all packages** - -```bash -pnpm typecheck:all -``` - -Expected: No type errors. - -- [ ] **Step 3: Run lint** - -```bash -pnpm lint:all -``` - -Expected: Clean or only pre-existing warnings. Fix any new lint errors introduced by the removal. - -- [ ] **Step 4: Run all tests** - -```bash -pnpm test:all -``` - -Expected: All tests pass. Any remaining test failures indicate missed task references. - -- [ ] **Step 5: Fix any remaining issues** - -If any step above fails, grep the codebase for remaining references: -```bash -grep -rn "task\|Task" packages/ --include='*.ts' | grep -v node_modules | grep -v '.d.ts' | grep -v 'test/' | grep -iv 'import.*taskCreate\|TaskCreate\|TaskUpdate\|TaskGet' -``` - -Fix any remaining references found. - -- [ ] **Step 6: Suggest commit** - -Suggest a commit with message: -``` -feat!: remove Tasks feature entirely - -Remove all experimental task-augmented execution support: TaskManager, -TaskStore, task schemas, task capability negotiation, and experimental -client/server task APIs. - -BREAKING CHANGE: Tasks feature removed. All task-related types, schemas, -and APIs are no longer available. -```