Skip to content
6 changes: 6 additions & 0 deletions .changeset/busy-rice-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

tasks - disallow requesting a null TTL
25 changes: 22 additions & 3 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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`
Expand Down
40 changes: 40 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/experimental/tasks/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ import type {
* @experimental
*/
export type CreateTaskServerContext = ServerContext & {
task: { store: RequestTaskStore; requestedTtl?: number | null };
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 | null };
task: { id: string; store: RequestTaskStore; requestedTtl?: number };
};

/**
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export interface RequestTaskStore {
export type TaskContext = {
id?: string;
store: RequestTaskStore;
requestedTtl?: number | null;
requestedTtl?: number;
};

/**
Expand Down
7 changes: 3 additions & 4 deletions packages/core/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()]),
Expand Down
11 changes: 5 additions & 6 deletions packages/core/test/experimental/inMemory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions test/integration/test/experimental/tasks/task.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
Loading