diff --git a/.changeset/register-prompt-context-only-overload.md b/.changeset/register-prompt-context-only-overload.md new file mode 100644 index 0000000000..40d0eb38ab --- /dev/null +++ b/.changeset/register-prompt-context-only-overload.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`McpServer.registerPrompt()` now type-checks the no-`argsSchema` form. When `config` carries no `argsSchema`, the prompt callback is invoked with the server context as its only argument, but both existing overloads constrained `Args` to a schema type, so the argument-less form resolved to the deprecated raw-shape signature and typed the parameter as the arguments record: reading `ctx.mcpReq` was a type error even though it works at runtime. A dedicated `argsSchema?: undefined` overload now types that callback as `PromptCallback`. Callbacks that take no parameters, and every schema-bearing form, are unchanged. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 70a5539bfb..c6ac2d391b 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1116,6 +1116,19 @@ export class McpServer { * ); * ``` */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: undefined; + icons?: Icon[]; + /** Determines whether this prompt retrieval needs an OAuth scope challenge. */ + scopeChallenge?: ScopeChallengeHandler; + _meta?: Record; + }, + cb: PromptCallback + ): RegisteredPrompt; registerPrompt( name: string, config: { @@ -1152,7 +1165,7 @@ export class McpServer { scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, - cb: PromptCallback | LegacyPromptCallback + cb: PromptCallback | PromptCallback | LegacyPromptCallback ): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); diff --git a/packages/server/test/server/registerPromptNoArgs.test.ts b/packages/server/test/server/registerPromptNoArgs.test.ts new file mode 100644 index 0000000000..1efcdb4a1d --- /dev/null +++ b/packages/server/test/server/registerPromptNoArgs.test.ts @@ -0,0 +1,38 @@ +/** + * Type-surface pin for the no-`argsSchema` prompt registration form. + * + * With no `argsSchema`, `createPromptHandler` invokes the callback as + * `callback(ctx)` — the context is the ONLY argument (see `mcp.ts`, + * the `else` branch of `createPromptHandler`). Both generic overloads + * constrain `Args` to a schema type, so before the dedicated overload + * existed the argument-less form resolved to the deprecated raw-shape + * signature and typed `ctx` as the arguments record: reading + * `ctx.mcpReq` was a type error even though it works at runtime. + */ +import type { ServerContext } from '@modelcontextprotocol/core-internal'; +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { McpServer } from '../../src/server/mcp'; + +describe('registerPrompt without argsSchema', () => { + test('types the callback parameter as the server context', () => { + const server = new McpServer({ name: 'test server', version: '1.0' }); + + server.registerPrompt('ctx-only', {}, async ctx => { + expectTypeOf(ctx).toEqualTypeOf(); + return { messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: String(ctx.mcpReq.id) } }] }; + }); + + expect(server.server).toBeDefined(); + }); + + test('still accepts a callback that ignores the context', () => { + const server = new McpServer({ name: 'test server', version: '1.0' }); + + server.registerPrompt('no-args', { description: 'takes nothing' }, async () => ({ + messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: 'ok' } }] + })); + + expect(server.server).toBeDefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c663ad7086..cb32c9289c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1972,6 +1972,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 cors: specifier: catalog:runtimeServerOnly version: 2.8.6 @@ -2083,6 +2086,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -2122,6 +2128,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) diff --git a/test/conformance/package.json b/test/conformance/package.json index f51e84d9ea..293180e6e3 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint", @@ -51,6 +52,7 @@ "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "tsx": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", "zod": "catalog:runtimeShared" } } diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 25926ba2a7..6ede22b2bd 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -200,7 +200,10 @@ function createMcpServer() { inputSchema: fromJsonSchema<{ region?: string; level?: number }>({ type: 'object', properties: { - region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' }, + region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' } as Record< + string, + unknown + >, level: { type: 'integer', description: 'non-mirrored argument' } } }) diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index b424eb35ec..a7617960f8 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -13,7 +13,9 @@ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], diff --git a/test/helpers/package.json b/test/helpers/package.json index c359fbe6c2..60e0c6cae6 100644 --- a/test/helpers/package.json +++ b/test/helpers/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint" @@ -32,6 +33,7 @@ "vitest": "catalog:devTools", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^" + "@modelcontextprotocol/eslint-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools" } } diff --git a/test/integration/package.json b/test/integration/package.json index ed75fb1b0c..df35ac13a5 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint test/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint test/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint", @@ -42,6 +43,7 @@ "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", "@valibot/to-json-schema": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", "arktype": "catalog:devTools", "supertest": "catalog:devTools", "valibot": "catalog:devTools", diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 92140b531f..c3e58b8573 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -73,7 +73,9 @@ test('should initialize with matching protocol version', async () => { * Test: Initialize with Supported Older Protocol Version */ test('should initialize with supported older protocol version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + // Index 1 is always present: SUPPORTED_PROTOCOL_VERSIONS is a fixed 5-element + // list, but `noUncheckedIndexedAccess` cannot see that through the index. + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]!; const clientTransport: Transport = { start: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -277,7 +279,9 @@ test('should reject unsupported protocol version', async () => { * Test: Connect New Client to Old Supported Server Version */ test('should connect new client to old, supported server version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + // Index 1 is always present: SUPPORTED_PROTOCOL_VERSIONS is a fixed 5-element + // list, but `noUncheckedIndexedAccess` cannot see that through the index. + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]!; const server = new Server( { name: 'test server', @@ -725,7 +729,7 @@ test('should only allow setRequestHandler for declared capabilities', () => { // This should throw because roots listing is not a declared capability expect(() => { - client.setRequestHandler('roots/list', () => ({})); + client.setRequestHandler('roots/list', () => ({ roots: [] })); }).toThrow('Client does not support roots capability'); }); diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index 51cde29161..61c8c9b3f8 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -352,8 +352,11 @@ test('should respect client elicitation capabilities', async () => { client.setRequestHandler('elicitation/create', params => ({ action: 'accept', + // Omit `username` rather than sending an explicit `undefined`: elicitation + // content values are string | number | boolean | string[], so an absent key + // is the only way to express "not provided". content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, + ...(params.params.message.includes('username') ? { username: 'test-user' } : {}), confirmed: true } })); @@ -436,8 +439,11 @@ test('should use elicitInput with mode: "form" by default for backwards compatib client.setRequestHandler('elicitation/create', params => ({ action: 'accept', + // Omit `username` rather than sending an explicit `undefined`: elicitation + // content values are string | number | boolean | string[], so an absent key + // is the only way to express "not provided". content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, + ...(params.params.message.includes('username') ? { username: 'test-user' } : {}), confirmed: true } })); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 02add64201..45d0117510 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -36,7 +36,7 @@ const SERVER_VERSION_NONCE = randomUUID(); */ const WRANGLER_BIN = (() => { const pkgPath = createRequire(import.meta.url).resolve('wrangler/package.json'); - const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler; + const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler!; return path.resolve(path.dirname(pkgPath), bin); })(); diff --git a/test/integration/test/server/declaredCapabilities.test.ts b/test/integration/test/server/declaredCapabilities.test.ts index 60e214d129..dcab27f4a6 100644 --- a/test/integration/test/server/declaredCapabilities.test.ts +++ b/test/integration/test/server/declaredCapabilities.test.ts @@ -140,12 +140,12 @@ describe('deterministic tools/list ordering (draft spec)', () => { const client = await connect(mcpServer); // Disable a tool in the middle: relative order of the remaining tools is unchanged. - registered[2].disable(); + registered[2]!.disable(); const whileDisabled = await client.listTools(); expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']); // Re-enable it: the original insertion order is restored, not appended at the end. - registered[2].enable(); + registered[2]!.enable(); const afterReenable = await client.listTools(); expect(afterReenable.tools.map(t => t.name)).toEqual(names); }); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index 9cef3b8f7b..04dd468d3e 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -53,9 +53,9 @@ function spawnFixtureTransport(): StdioClientTransport { function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { const inbound: JSONRPCMessage[] = []; const original = transport.onmessage; - transport.onmessage = (message, extra) => { + transport.onmessage = message => { inbound.push(message); - original?.(message, extra); + original?.(message); }; return inbound; } @@ -64,9 +64,9 @@ function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { function recordOutbound(transport: StdioClientTransport): JSONRPCMessage[] { const outbound: JSONRPCMessage[] = []; const originalSend = transport.send.bind(transport); - transport.send = async (message, options) => { + transport.send = async message => { outbound.push(message); - return originalSend(message, options); + return originalSend(message); }; return outbound; } diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 13fb77e944..11d9f1d0a5 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -8,7 +8,7 @@ */ import { Client } from '@modelcontextprotocol/client'; -import type { ElicitRequestFormParams } from '@modelcontextprotocol/core-internal'; +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker'; @@ -338,7 +338,9 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => { let requestCount = 0; - client.setRequestHandler('elicitation/create', request => { + // Annotated so each branch widens to the shared result type instead of + // inferring a union whose members carry `age?: undefined` etc. + client.setRequestHandler('elicitation/create', (request): ElicitResult => { requestCount++; if (request.params.message.includes('name')) { return { action: 'accept', content: { name: 'Alice' } }; @@ -996,12 +998,12 @@ describe('declared-dialect requestedSchema (default validator)', () => { const requestedSchema = { $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', - properties: { name: { type: 'string', minLength: 1 } }, + type: 'object' as const, + properties: { name: { type: 'string' as const, minLength: 1 } }, required: ['name'] - } as const; + }; - let content: Record = { name: 'John' }; + let content: { [key: string]: string | number | boolean | string[] } = { name: 'John' }; client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content })); await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({ diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 4b9a3865f0..a382780f1c 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,5 +1,5 @@ import { Client } from '@modelcontextprotocol/client'; -import type { Notification, TextContent } from '@modelcontextprotocol/core-internal'; +import type { Notification, ServerContext, TextContent } from '@modelcontextprotocol/core-internal'; import { getDisplayName, InMemoryTransport, @@ -411,16 +411,15 @@ describe('Zod v4', () => { expect(template.listCallback).toBe(list); const abortController = new AbortController(); + // The callback only reads what the fixture provides, so a partial + // context is cast rather than fully constructed. const result = await template.listCallback?.({ - signal: abortController.signal, - requestId: 'not-implemented', - sendRequest: () => { - throw new Error('Not implemented'); - }, - sendNotification: () => { - throw new Error('Not implemented'); + mcpReq: { + id: 'not-implemented', + method: 'resources/list', + signal: abortController.signal } - }); + } as unknown as ServerContext); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); }); @@ -598,11 +597,13 @@ describe('Zod v4', () => { name: z.string(), value: z.number() }), - callback: async ({ name, value }) => ({ + // `update()` is not generic over the new schema, so its `callback` + // receives `args: unknown`; narrow it at the boundary. + callback: async args => ({ content: [ { type: 'text', - text: `Updated: ${name}, ${value}` + text: `Updated: ${(args as { name: string }).name}, ${(args as { value: number }).value}` } ] }) @@ -850,7 +851,7 @@ describe('Zod v4', () => { version: '1.0' }); - mcpServer.registerResource('test://resource', 'Test Resource', async () => ({ + mcpServer.registerResource('test-resource', 'test://resource', {}, async () => ({ contents: [{ uri: 'test://resource', text: 'Test' }] })); @@ -871,7 +872,7 @@ describe('Zod v4', () => { version: '1.0' }); - mcpServer.registerPrompt('test-prompt', async () => ({ + mcpServer.registerPrompt('test-prompt', {}, async () => ({ messages: [{ role: 'assistant', content: { type: 'text', text: 'Test' } }] })); diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index b9ab0284f3..cc82977a9e 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -57,8 +57,8 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('greet'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]?.name).toBe('greet'); + expect(result.tools[0]?.inputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { @@ -67,7 +67,7 @@ describe('Standard Schema Support', () => { } }); // Check required array contains both fields (order may vary by library) - expect(result.tools[0].inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); + expect(result.tools[0]?.inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); }); test('should register tool with ArkType input and output schemas', async () => { @@ -91,7 +91,7 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); - expect(result.tools[0].outputSchema).toMatchObject({ + expect(result.tools[0]?.outputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { @@ -99,7 +99,7 @@ describe('Standard Schema Support', () => { operation: { type: 'string' } } }); - expect(result.tools[0].outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); + expect(result.tools[0]?.outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); }); }); @@ -212,8 +212,8 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('greet'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]?.name).toBe('greet'); + expect(result.tools[0]?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' }, @@ -239,7 +239,7 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); - expect(result.tools[0].inputSchema.properties).toMatchObject({ + expect(result.tools[0]?.inputSchema.properties).toMatchObject({ city: { type: 'string', description: 'The city name' }, country: { type: 'string', description: 'The country code' } }); @@ -396,7 +396,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); const listed = await client.request({ method: 'tools/list' }); - expect(listed.tools[0].inputSchema).toMatchObject({ + expect(listed.tools[0]?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' } }, required: ['name']