Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/register-prompt-context-only-overload.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
},
cb: PromptCallback
): RegisteredPrompt;
registerPrompt<Args extends StandardSchemaWithJSON>(
name: string,
config: {
Expand Down Expand Up @@ -1152,7 +1165,7 @@ export class McpServer {
scopeChallenge?: ScopeChallengeHandler;
_meta?: Record<string, unknown>;
},
cb: PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
cb: PromptCallback | PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
): RegisteredPrompt {
if (this._registeredPrompts[name]) {
throw new Error(`Prompt ${name} is already registered`);
Expand Down
38 changes: 38 additions & 0 deletions packages/server/test/server/registerPromptNoArgs.test.ts
Original file line number Diff line number Diff line change
@@ -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<ServerContext>();
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();
});
});
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions test/conformance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -51,6 +52,7 @@
"cors": "catalog:runtimeServerOnly",
"express": "catalog:runtimeServerOnly",
"tsx": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"zod": "catalog:runtimeShared"
}
}
5 changes: 4 additions & 1 deletion test/conformance/src/everythingServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
})
Expand Down
2 changes: 2 additions & 0 deletions test/conformance/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 3 additions & 1 deletion test/helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
}
2 changes: 2 additions & 0 deletions test/integration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions test/integration/test/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});

Expand Down
10 changes: 8 additions & 2 deletions test/integration/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}));
Expand Down Expand Up @@ -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
}
}));
Expand Down
2 changes: 1 addition & 1 deletion test/integration/test/server/cloudflareWorkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }).bin.wrangler;
const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record<string, string> }).bin.wrangler!;
return path.resolve(path.dirname(pkgPath), bin);
})();

Expand Down
4 changes: 2 additions & 2 deletions test/integration/test/server/declaredCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
8 changes: 4 additions & 4 deletions test/integration/test/server/dualEraStdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
14 changes: 8 additions & 6 deletions test/integration/test/server/elicitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' } };
Expand Down Expand Up @@ -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<string, unknown> = { 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({
Expand Down
27 changes: 14 additions & 13 deletions test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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}`
}
]
})
Expand Down Expand Up @@ -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' }]
}));

Expand All @@ -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' } }]
}));

Expand Down
Loading
Loading