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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..b18fd2935 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 67d9dcbfe..2179f0272 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: run_install: false - uses: actions/setup-node@v6 @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: run_install: false - uses: actions/setup-node@v6 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index aab034bf3..02a573954 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: run_install: false - uses: actions/setup-node@v6 @@ -41,13 +41,13 @@ jobs: run: bash scripts/generate-multidoc.sh tmp/docs-combined - name: Configure Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: tmp/docs-combined - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3ab2c5094..f19a11ebf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 id: pnpm-install with: run_install: false @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 id: pnpm-install with: run_install: false @@ -71,7 +71,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: run_install: false - uses: actions/setup-node@v6 @@ -108,7 +108,7 @@ jobs: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 id: pnpm-install with: run_install: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2257e21b..af7ec5d35 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: run_install: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3f4ecf6d..c3d84029c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: run_install: false diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml index 1c67ef2f1..2d66ae053 100644 --- a/.github/workflows/update-spec-types.yml +++ b/.github/workflows/update-spec-types.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 id: pnpm-install with: run_install: false diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 0056795c3..c2f42b5f5 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -433,11 +433,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: @@ -461,7 +480,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 21f8b67c9..5d7763cbe 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -757,6 +757,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 66ac8ef0a..d980f304c 100644 --- a/packages/core/src/experimental/tasks/interfaces.ts +++ b/packages/core/src/experimental/tasks/interfaces.ts @@ -26,7 +26,7 @@ import type { * @experimental */ export type CreateTaskServerContext = ServerContext & { - task: { store: RequestTaskStore; requestedTtl?: number | null }; + task: { store: RequestTaskStore; requestedTtl?: number }; }; /** @@ -34,7 +34,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 }; }; /** @@ -137,7 +137,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/taskManager.ts b/packages/core/src/shared/taskManager.ts index 7ca7f7b4a..d7d40c550 100644 --- a/packages/core/src/shared/taskManager.ts +++ b/packages/core/src/shared/taskManager.ts @@ -151,7 +151,7 @@ export interface RequestTaskStore { export type TaskContext = { id?: string; store: RequestTaskStore; - requestedTtl?: number | null; + requestedTtl?: number; }; export type TaskManagerOptions = { diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index c8f0c9978..309b6ade2 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -32,10 +32,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. diff --git a/packages/core/test/experimental/inMemory.test.ts b/packages/core/test/experimental/inMemory.test.ts index edbbca040..7639cad9f 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/packages/core/test/shared/transport.test.ts b/packages/core/test/shared/transport.test.ts new file mode 100644 index 000000000..bdef03a5e --- /dev/null +++ b/packages/core/test/shared/transport.test.ts @@ -0,0 +1,182 @@ +import { createFetchWithInit, type FetchLike, normalizeHeaders } from '../../src/shared/transport.js'; + +describe('normalizeHeaders', () => { + test('returns empty object for undefined', () => { + expect(normalizeHeaders(undefined)).toEqual({}); + }); + + test('handles Headers instance', () => { + const headers = new Headers({ + 'x-foo': 'bar', + 'content-type': 'application/json' + }); + expect(normalizeHeaders(headers)).toEqual({ + 'x-foo': 'bar', + 'content-type': 'application/json' + }); + }); + + test('handles array of tuples', () => { + const headers: [string, string][] = [ + ['x-foo', 'bar'], + ['x-baz', 'qux'] + ]; + expect(normalizeHeaders(headers)).toEqual({ + 'x-foo': 'bar', + 'x-baz': 'qux' + }); + }); + + test('handles plain object', () => { + const headers = { 'x-foo': 'bar', 'x-baz': 'qux' }; + expect(normalizeHeaders(headers)).toEqual({ + 'x-foo': 'bar', + 'x-baz': 'qux' + }); + }); + + test('returns a shallow copy for plain objects', () => { + const headers = { 'x-foo': 'bar' }; + const result = normalizeHeaders(headers); + expect(result).not.toBe(headers); + expect(result).toEqual(headers); + }); +}); + +describe('createFetchWithInit', () => { + test('returns baseFetch unchanged when no baseInit provided', () => { + const mockFetch: FetchLike = vi.fn(); + const result = createFetchWithInit(mockFetch); + expect(result).toBe(mockFetch); + }); + + test('passes baseInit to fetch when no call init provided', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + method: 'POST', + credentials: 'include' + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + credentials: 'include' + }) + ); + }); + + test('merges baseInit with call init, call init wins for non-header fields', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + method: 'POST', + credentials: 'include' + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'PUT' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'PUT', + credentials: 'include' + }) + ); + }); + + test('merges headers from both base and call init', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + headers: { 'x-base': 'base-value', 'x-shared': 'base' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { + headers: { 'x-call': 'call-value', 'x-shared': 'call' } + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + headers: { + 'x-base': 'base-value', + 'x-call': 'call-value', + 'x-shared': 'call' + } + }) + ); + }); + + test('uses baseInit headers when call init has no headers', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + headers: { 'x-base': 'base-value' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'POST' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + headers: { 'x-base': 'base-value' } + }) + ); + }); + + test('handles URL object as first argument', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { method: 'GET' }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + const url = new URL('https://example.com/path'); + await wrappedFetch(url); + + expect(mockFetch).toHaveBeenCalledWith(url, expect.objectContaining({ method: 'GET' })); + }); + + test('passes all baseInit properties when call init is empty object', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + method: 'POST', + credentials: 'include', + headers: { 'x-base': 'value' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', {}); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + headers: { 'x-base': 'value' } + }) + ); + }); + + test('passes Headers instance through when call init has no headers', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseHeaders = new Headers({ 'x-base': 'value' }); + const baseInit: RequestInit = { + headers: baseHeaders + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'POST' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + headers: baseHeaders + }) + ); + }); +}); 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); + }); +});