diff --git a/.changeset/busy-rice-smoke.md b/.changeset/busy-rice-smoke.md new file mode 100644 index 000000000..69badd88c --- /dev/null +++ b/.changeset/busy-rice-smoke.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +tasks - disallow requesting a null TTL diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index e5de064da..518c1fbc6 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -434,11 +434,30 @@ const tool = await client.callTool({ name: 'my-tool', arguments: {} }); Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. -## 12. Client Behavioral Changes +## 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 `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. -## 13. Runtime-Specific JSON Schema Validators (Enhancement) +## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: @@ -462,7 +481,7 @@ new McpServer({ name: 'server', version: '1.0.0' }, {}); Access validators via `_shims` export: `import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';` -## 14. Migration Steps (apply in this order) +## 15. 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 64540bc92..993bc440e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -742,6 +742,46 @@ 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/packages/core/src/experimental/tasks/interfaces.ts b/packages/core/src/experimental/tasks/interfaces.ts index 8b3459b7c..a9458db34 100644 --- a/packages/core/src/experimental/tasks/interfaces.ts +++ b/packages/core/src/experimental/tasks/interfaces.ts @@ -25,7 +25,7 @@ import type { * @experimental */ export type CreateTaskServerContext = ServerContext & { - task: { store: RequestTaskStore; requestedTtl?: number | null }; + task: { store: RequestTaskStore; requestedTtl?: number }; }; /** @@ -33,7 +33,7 @@ export type CreateTaskServerContext = ServerContext & { * @experimental */ export type TaskServerContext = ServerContext & { - task: { id: string; store: RequestTaskStore; requestedTtl?: number | null }; + task: { id: string; store: RequestTaskStore; requestedTtl?: number }; }; /** @@ -136,7 +136,7 @@ export interface TaskMessageQueue { */ export interface CreateTaskOptions { /** - * Time in milliseconds to keep task results available after completion. + * Duration in milliseconds to retain task from creation. * If `null`, the task has unlimited lifetime until manually cleaned up. */ ttl?: number | null; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index b82731582..a5c117036 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -258,7 +258,7 @@ export interface RequestTaskStore { export type TaskContext = { id?: string; store: RequestTaskStore; - requestedTtl?: number | null; + requestedTtl?: number; }; /** diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index bcb5f4dad..da760cdbe 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -76,10 +76,9 @@ export const CursorSchema = z.string(); */ export const TaskCreationParamsSchema = z.looseObject({ /** - * Time in milliseconds to keep task results available after completion. - * If `null`, the task has unlimited lifetime until manually cleaned up. + * Requested duration in milliseconds to retain task from creation. */ - ttl: z.union([z.number(), z.null()]).optional(), + ttl: z.number().optional(), /** * Time in milliseconds to wait between task status requests. @@ -752,7 +751,7 @@ export const TaskSchema = z.object({ taskId: z.string(), status: TaskStatusSchema, /** - * Time in milliseconds to keep task results available after completion. + * Duration in milliseconds to retain task from creation. * If `null`, the task has unlimited lifetime until manually cleaned up. */ ttl: z.union([z.number(), z.null()]), diff --git a/packages/core/test/experimental/inMemory.test.ts b/packages/core/test/experimental/inMemory.test.ts index ea6a3a8dc..6c9b6af34 100644 --- a/packages/core/test/experimental/inMemory.test.ts +++ b/packages/core/test/experimental/inMemory.test.ts @@ -488,17 +488,16 @@ describe('InMemoryTaskStore', () => { expect(task).toBeNull(); }); - it('should support null TTL for unlimited lifetime', async () => { - // Test that null TTL means unlimited lifetime - const taskParams: TaskCreationParams = { - ttl: null - }; + 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 + // The returned task should have null TTL (unlimited) expect(createdTask.ttl).toBeNull(); // Task should not be cleaned up even after a long time diff --git a/test/integration/test/experimental/tasks/task.test.ts b/test/integration/test/experimental/tasks/task.test.ts index 848e22c98..d2aca2cc0 100644 --- a/test/integration/test/experimental/tasks/task.test.ts +++ b/test/integration/test/experimental/tasks/task.test.ts @@ -1,5 +1,5 @@ -import { isTerminal } from '@modelcontextprotocol/core'; -import type { Task } from '@modelcontextprotocol/server'; +import type { Task } from '@modelcontextprotocol/core'; +import { isTerminal, TaskCreationParamsSchema } from '@modelcontextprotocol/core'; import { describe, expect, it } from 'vitest'; describe('Task utility functions', () => { @@ -115,3 +115,30 @@ describe('Task Schema Validation', () => { } }); }); + +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); + }); +});