From 940054e71c190abec45768b06f23e61e0b79c831 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 11 Apr 2025 14:23:57 +0200 Subject: [PATCH 01/45] chore: fix typo Fixes small typo from https://github.com/modelcontextprotocol/typescript-sdk/pull/247. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fcdff17..3cb7ff07 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ putMessageTool.disable() const upgradeAuthTool = server.tool( "upgradeAuth", - { permission: z.enum(["write', vadmin"])}, + { permission: z.enum(["write', admin"])}, // Any mutations here will automatically emit `listChanged` notifications async ({ permission }) => { const { ok, err, previous } = await upgradeAuthAndStoreToken(permission) From 04cbbfbb0d35d4038ac376effed0544a36985415 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 20 May 2025 15:10:40 +0100 Subject: [PATCH 02/45] elicitation example --- src/client/index.test.ts | 38 +++ src/client/index.ts | 8 + src/examples/client/simpleStreamableHttp.ts | 211 ++++++++++++- src/examples/server/simpleStreamableHttp.ts | 153 +++++++++- src/server/index.test.ts | 313 ++++++++++++++++++++ src/server/index.ts | 52 ++++ src/shared/protocol.test.ts | 2 + src/types.ts | 116 ++++++++ 8 files changed, 891 insertions(+), 2 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index bbfa80fa..abd0c34e 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -14,6 +14,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ErrorCode, } from "../types.js"; @@ -597,6 +598,43 @@ test("should only allow setRequestHandler for declared capabilities", () => { }).toThrow("Client does not support roots capability"); }); +test("should allow setRequestHandler for declared elicitation capability", () => { + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // This should work because elicitation is a declared capability + expect(() => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: "test-model", + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + })); + }).toThrow("Client does not support sampling capability"); +}); + /*** * Test: Type Checking * Test that custom request/notification/result schemas can be used with the Client class. diff --git a/src/client/index.ts b/src/client/index.ts index 98618a17..53ef8c52 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -303,6 +303,14 @@ export class Client< } break; + case "elicitation/create": + if (!this._capabilities.elicitation) { + throw new Error( + `Client does not support elicitation capability (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._capabilities.roots) { throw new Error( diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 0328f0d2..63efdd2f 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,7 +14,9 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ElicitRequestSchema, } from '../../types.js'; +import { Ajv } from 'ajv'; // Create readline interface for user input const readline = createInterface({ @@ -54,6 +56,7 @@ function printHelp(): void { console.log(' call-tool [args] - Call a tool with optional JSON arguments'); console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' collect-info [type] - Test elicitation with collect-user-info tool (contact/preferences/feedback)'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); @@ -114,6 +117,10 @@ function commandLoop(): void { await callMultiGreetTool(args[1] || 'MCP User'); break; + case 'collect-info': + await callCollectInfoTool(args[1] || 'contact'); + break; + case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; const count = args[2] ? parseInt(args[2], 10) : 10; @@ -183,15 +190,212 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client + // Create a new client with elicitation capability client = new Client({ name: 'example-client', version: '1.0.0' + }, { + capabilities: { + elicitation: {}, + }, }); client.onerror = (error) => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); } + // Set up elicitation request handler with proper validation + client.setRequestHandler(ElicitRequestSchema, async (request) => { + console.log('\nšŸ”” Elicitation Request Received:'); + console.log(`Message: ${request.params.message}`); + console.log('Requested Schema:'); + console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + + const schema = request.params.requestedSchema; + const properties = schema.properties; + const required = schema.required || []; + + // Set up AJV validator for the requested schema + const ajv = new Ajv({ strict: false, validateFormats: true }); + const validate = ajv.compile(schema); + + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`); + + const content: Record = {}; + let inputCancelled = false; + + // Collect input for each field + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const field = fieldSchema as { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + format?: string; + }; + + const isRequired = required.includes(fieldName); + let prompt = `${field.title || fieldName}`; + + // Add helpful information to the prompt + if (field.description) { + prompt += ` (${field.description})`; + } + if (field.enum) { + prompt += ` [options: ${field.enum.join(', ')}]`; + } + if (field.type === 'number' || field.type === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + prompt += ` [${field.minimum}-${field.maximum}]`; + } else if (field.minimum !== undefined) { + prompt += ` [min: ${field.minimum}]`; + } else if (field.maximum !== undefined) { + prompt += ` [max: ${field.maximum}]`; + } + } + if (field.type === 'string' && field.format) { + prompt += ` [format: ${field.format}]`; + } + if (isRequired) { + prompt += ' *required*'; + } + if (field.default !== undefined) { + prompt += ` [default: ${field.default}]`; + } + + prompt += ': '; + + const answer = await new Promise((resolve) => { + readline.question(prompt, (input) => { + resolve(input.trim()); + }); + }); + + // Check for cancellation + if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') { + inputCancelled = true; + break; + } + + // Parse and validate the input + try { + if (answer === '' && field.default !== undefined) { + content[fieldName] = field.default; + } else if (answer === '' && !isRequired) { + // Skip optional empty fields + continue; + } else if (answer === '') { + throw new Error(`${fieldName} is required`); + } else { + // Parse the value based on type + let parsedValue: unknown; + + if (field.type === 'boolean') { + parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; + } else if (field.type === 'number') { + parsedValue = parseFloat(answer); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid number`); + } + } else if (field.type === 'integer') { + parsedValue = parseInt(answer, 10); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid integer`); + } + } else if (field.enum) { + if (!field.enum.includes(answer)) { + throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + } + parsedValue = answer; + } else { + parsedValue = answer; + } + + content[fieldName] = parsedValue; + } + } catch (error) { + console.log(`āŒ Error: ${error}`); + // Continue to next attempt + break; + } + } + + if (inputCancelled) { + return { action: 'cancel' }; + } + + // If we didn't complete all fields due to an error, try again + if (Object.keys(content).length !== Object.keys(properties).filter(name => + required.includes(name) || content[name] !== undefined + ).length) { + if (attempts < maxAttempts) { + console.log('Please try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + } + } + + // Validate the complete object against the schema + const isValid = validate(content); + + if (!isValid) { + console.log('āŒ Validation errors:'); + validate.errors?.forEach(error => { + console.log(` - ${error.instancePath || 'root'}: ${error.message}`); + }); + + if (attempts < maxAttempts) { + console.log('Please correct the errors and try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + } + } + + // Show the collected data and ask for confirmation + console.log('\nāœ… Collected data:'); + console.log(JSON.stringify(content, null, 2)); + + const confirmAnswer = await new Promise((resolve) => { + readline.question('\nSubmit this information? (yes/no/cancel): ', (input) => { + resolve(input.trim().toLowerCase()); + }); + }); + + + if (confirmAnswer === 'yes' || confirmAnswer === 'y') { + return { + action: 'accept', + content, + }; + } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { + return { action: 'cancel' }; + } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return { action: 'decline' }; + } + } + } + + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + }); + transport = new StreamableHTTPClientTransport( new URL(serverUrl), { @@ -362,6 +566,11 @@ async function callMultiGreetTool(name: string): Promise { await callTool('multi-greet', { name }); } +async function callCollectInfoTool(infoType: string): Promise { + console.log(`Testing elicitation with collect-user-info tool (${infoType})...`); + await callTool('collect-user-info', { infoType }); +} + async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c331192..4ef50446 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -5,7 +5,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; @@ -84,6 +84,157 @@ const getServer = () => { } ); + // Register a tool that demonstrates elicitation (user input collection) + // This creates a closure that captures the server instance + server.tool( + 'collect-user-info', + 'A tool that collects user information through elicitation', + { + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'), + }, + async ({ infoType }): Promise => { + let message: string; + let requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + switch (infoType) { + case 'contact': + message = 'Please provide your contact information'; + requestedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name', + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email', + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)', + }, + }, + required: ['name', 'email'], + }; + break; + case 'preferences': + message = 'Please set your preferences'; + requestedSchema = { + type: 'object', + properties: { + theme: { + type: 'string', + title: 'Theme', + description: 'Choose your preferred theme', + enum: ['light', 'dark', 'auto'], + enumNames: ['Light', 'Dark', 'Auto'], + }, + notifications: { + type: 'boolean', + title: 'Enable Notifications', + description: 'Would you like to receive notifications?', + default: true, + }, + frequency: { + type: 'string', + title: 'Notification Frequency', + description: 'How often would you like notifications?', + enum: ['daily', 'weekly', 'monthly'], + enumNames: ['Daily', 'Weekly', 'Monthly'], + }, + }, + required: ['theme'], + }; + break; + case 'feedback': + message = 'Please provide your feedback'; + requestedSchema = { + type: 'object', + properties: { + rating: { + type: 'integer', + title: 'Rating', + description: 'Rate your experience (1-5)', + minimum: 1, + maximum: 5, + }, + comments: { + type: 'string', + title: 'Comments', + description: 'Additional comments (optional)', + maxLength: 500, + }, + recommend: { + type: 'boolean', + title: 'Would you recommend this?', + description: 'Would you recommend this to others?', + }, + }, + required: ['rating', 'recommend'], + }; + break; + default: + throw new Error(`Unknown info type: ${infoType}`); + } + + try { + // Use the underlying server instance to elicit input from the client + const result = await server.server.elicitInput({ + message, + requestedSchema, + }); + + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`, + }, + ], + }; + } else if (result.action === 'decline') { + return { + content: [ + { + type: 'text', + text: `No information was collected. User declined to provide ${infoType} information.`, + }, + ], + }; + } else { + return { + content: [ + { + type: 'text', + text: `Information collection was cancelled by the user.`, + }, + ], + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error collecting ${infoType} information: ${error}`, + }, + ], + }; + } + } + ); + + // Register a simple prompt server.prompt( 'greeting-template', diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 7c0fbc51..ce54247a 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -10,6 +10,7 @@ import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, CreateMessageRequestSchema, + ElicitRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, @@ -267,6 +268,318 @@ test("should respect client capabilities", async () => { await expect(server.listRoots()).rejects.toThrow(/^Client does not support/); }); +test("should respect client elicitation capabilities", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + client.setRequestHandler(ElicitRequestSchema, (params) => ({ + action: "accept", + content: { + username: params.params.message.includes("username") ? "test-user" : undefined, + confirmed: true, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(server.getClientCapabilities()).toEqual({ elicitation: {} }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: "Please provide your username", + requestedSchema: { + type: "object", + properties: { + username: { + type: "string", + title: "Username", + description: "Your username", + }, + confirmed: { + type: "boolean", + title: "Confirm", + description: "Please confirm", + default: false, + }, + }, + required: ["username"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + username: "test-user", + confirmed: true, + }, + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10, + }), + ).rejects.toThrow(/^Client does not support/); +}); + +test("should validate elicitation response against requested schema", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with valid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).resolves.toEqual({ + action: "accept", + content: { + name: "John Doe", + email: "john@example.com", + age: 30, + }, + }); +}); + +test("should reject elicitation response with invalid data", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + // Set up client to return invalid response (missing required field, invalid age) + client.setRequestHandler(ElicitRequestSchema, (request) => ({ + action: "accept", + content: { + email: "", // Invalid - too short + age: -5, // Invalid age + }, + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // Test with invalid response + await expect( + server.elicitInput({ + message: "Please provide your information", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + }, + email: { + type: "string", + minLength: 1, + }, + age: { + type: "integer", + minimum: 0, + maximum: 150, + }, + }, + required: ["name", "email"], + }, + }), + ).rejects.toThrow(/does not match requested schema/); +}); + +test("should allow elicitation decline and cancel without validation", async () => { + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + elicitation: {}, + }, + }, + ); + + let requestCount = 0; + client.setRequestHandler(ElicitRequestSchema, (request) => { + requestCount++; + if (requestCount === 1) { + return { action: "decline" }; + } else { + return { action: "cancel" }; + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const schema = { + type: "object" as const, + properties: { + name: { type: "string" as const }, + }, + required: ["name"], + }; + + // Test decline - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "decline", + }); + + // Test cancel - should not validate + await expect( + server.elicitInput({ + message: "Please provide your name", + requestedSchema: schema, + }), + ).resolves.toEqual({ + action: "cancel", + }); +}); + test("should respect server notification capabilities", async () => { const server = new Server( { diff --git a/src/server/index.ts b/src/server/index.ts index 3901099e..6a819db8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,6 +8,9 @@ import { ClientCapabilities, CreateMessageRequest, CreateMessageResultSchema, + ElicitRequest, + ElicitResult, + ElicitResultSchema, EmptyResultSchema, Implementation, InitializedNotificationSchema, @@ -18,6 +21,8 @@ import { ListRootsRequest, ListRootsResultSchema, LoggingMessageNotification, + McpError, + ErrorCode, Notification, Request, ResourceUpdatedNotification, @@ -28,6 +33,7 @@ import { ServerResult, SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; +import { Ajv } from "ajv"; export type ServerOptions = ProtocolOptions & { /** @@ -129,6 +135,14 @@ export class Server< } break; + case "elicitation/create": + if (!this._clientCapabilities?.elicitation) { + throw new Error( + `Client does not support elicitation (required for ${method})`, + ); + } + break; + case "roots/list": if (!this._clientCapabilities?.roots) { throw new Error( @@ -294,6 +308,44 @@ export class Server< ); } + async elicitInput( + params: ElicitRequest["params"], + options?: RequestOptions, + ): Promise { + const result = await this.request( + { method: "elicitation/create", params }, + ElicitResultSchema, + options, + ); + + // Validate the response content against the requested schema if action is "accept" + if (result.action === "accept" && result.content) { + try { + const ajv = new Ajv({ strict: false, validateFormats: true }); + + const validate = ajv.compile(params.requestedSchema); + const isValid = validate(result.content); + + if (!isValid) { + throw new McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`, + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InternalError, + `Error validating elicitation response: ${error}`, + ); + } + } + + return result; + } + async listRoots( params?: ListRootsRequest["params"], options?: RequestOptions, diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index e0141da1..ac453b17 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -339,6 +339,7 @@ describe("mergeCapabilities", () => { experimental: { feature: true, }, + elicitation: {}, roots: { newProp: true, }, @@ -347,6 +348,7 @@ describe("mergeCapabilities", () => { const merged = mergeCapabilities(base, additional); expect(merged).toEqual({ sampling: {}, + elicitation: {}, roots: { listChanged: true, newProp: true, diff --git a/src/types.ts b/src/types.ts index ae25848e..e5a544b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -218,6 +218,10 @@ export const ClientCapabilitiesSchema = z * Present if the client supports sampling from an LLM. */ sampling: z.optional(z.object({}).passthrough()), + /** + * Present if the client supports eliciting user input. + */ + elicitation: z.optional(z.object({}).passthrough()), /** * Present if the client supports listing roots. */ @@ -1088,6 +1092,107 @@ export const CreateMessageResultSchema = ResultSchema.extend({ ]), }); +/* Elicitation */ +/** + * Primitive schema definition for boolean fields. + */ +export const BooleanSchemaSchema = z + .object({ + type: z.literal("boolean"), + title: z.optional(z.string()), + description: z.optional(z.string()), + default: z.optional(z.boolean()), + }) + .passthrough(); + +/** + * Primitive schema definition for string fields. + */ +export const StringSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + minLength: z.optional(z.number()), + maxLength: z.optional(z.number()), + format: z.optional(z.enum(["email", "uri", "date", "date-time"])), + }) + .passthrough(); + +/** + * Primitive schema definition for number fields. + */ +export const NumberSchemaSchema = z + .object({ + type: z.enum(["number", "integer"]), + title: z.optional(z.string()), + description: z.optional(z.string()), + minimum: z.optional(z.number()), + maximum: z.optional(z.number()), + }) + .passthrough(); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z + .object({ + type: z.literal("string"), + title: z.optional(z.string()), + description: z.optional(z.string()), + enum: z.array(z.string()), + enumNames: z.optional(z.array(z.string())), + }) + .passthrough(); + +/** + * Union of all primitive schema definitions. + */ +export const PrimitiveSchemaDefinitionSchema = z.union([ + BooleanSchemaSchema, + StringSchemaSchema, + NumberSchemaSchema, + EnumSchemaSchema, +]); + +/** + * A request from the server to elicit user input via the client. + * The client should present the message and form fields to the user. + */ +export const ElicitRequestSchema = RequestSchema.extend({ + method: z.literal("elicitation/create"), + params: BaseRequestParamsSchema.extend({ + /** + * The message to present to the user. + */ + message: z.string(), + /** + * The schema for the requested user input. + */ + requestedSchema: z + .object({ + type: z.literal("object"), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + }), +}); + +/** + * The client's response to an elicitation/create request from the server. + */ +export const ElicitResultSchema = ResultSchema.extend({ + /** + * The user's response action. + */ + action: z.enum(["accept", "decline", "cancel"]), + /** + * The collected user input content (only present if action is "accept"). + */ + content: z.optional(z.record(z.string(), z.unknown())), +}); + /* Autocomplete */ /** * A reference to a resource or resource template definition. @@ -1227,6 +1332,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + ElicitResultSchema, ListRootsResultSchema, ]); @@ -1234,6 +1340,7 @@ export const ClientResultSchema = z.union([ export const ServerRequestSchema = z.union([ PingRequestSchema, CreateMessageRequestSchema, + ElicitRequestSchema, ListRootsRequestSchema, ]); @@ -1376,6 +1483,15 @@ export type SamplingMessage = Infer; export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; +/* Elicitation */ +export type BooleanSchema = Infer; +export type StringSchema = Infer; +export type NumberSchema = Infer; +export type EnumSchema = Infer; +export type PrimitiveSchemaDefinition = Infer; +export type ElicitRequest = Infer; +export type ElicitResult = Infer; + /* Autocomplete */ export type ResourceReference = Infer; export type PromptReference = Infer; From a89f950222a4d129437f01731766fdb4ca7347b2 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 21 May 2025 17:12:42 -0400 Subject: [PATCH 03/45] post-rebase fixes --- src/examples/client/simpleStreamableHttp.ts | 6 +++--- src/server/index.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63efdd2f..4bcaf94c 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -16,7 +16,7 @@ import { ResourceListChangedNotificationSchema, ElicitRequestSchema, } from '../../types.js'; -import { Ajv } from 'ajv'; +import Ajv from "ajv"; // Create readline interface for user input const readline = createInterface({ @@ -215,7 +215,7 @@ async function connect(url?: string): Promise { const required = schema.required || []; // Set up AJV validator for the requested schema - const ajv = new Ajv({ strict: false, validateFormats: true }); + const ajv = new Ajv(); const validate = ajv.compile(schema); let attempts = 0; @@ -352,7 +352,7 @@ async function connect(url?: string): Promise { if (!isValid) { console.log('āŒ Validation errors:'); validate.errors?.forEach(error => { - console.log(` - ${error.instancePath || 'root'}: ${error.message}`); + console.log(` - ${error.dataPath || 'root'}: ${error.message}`); }); if (attempts < maxAttempts) { diff --git a/src/server/index.ts b/src/server/index.ts index 6a819db8..506589d9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,7 +33,8 @@ import { ServerResult, SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; -import { Ajv } from "ajv"; +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; export type ServerOptions = ProtocolOptions & { /** @@ -321,7 +322,7 @@ export class Server< // Validate the response content against the requested schema if action is "accept" if (result.action === "accept" && result.content) { try { - const ajv = new Ajv({ strict: false, validateFormats: true }); + const ajv = new Ajv(); const validate = ajv.compile(params.requestedSchema); const isValid = validate(result.content); From eea1519994f78750b516f527dc135697a69f8eac Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 21 May 2025 23:42:00 -0400 Subject: [PATCH 04/45] lint --- src/server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index 506589d9..5d482d32 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -34,7 +34,6 @@ import { SUPPORTED_PROTOCOL_VERSIONS, } from "../types.js"; import Ajv from "ajv"; -import type { ValidateFunction } from "ajv"; export type ServerOptions = ProtocolOptions & { /** From d38a5979ac8c8003aad74fb8ddbee551d3b46cd4 Mon Sep 17 00:00:00 2001 From: Daniel Leal Date: Thu, 22 May 2025 19:11:23 +0100 Subject: [PATCH 05/45] Add request init headers defined on the sse transport to the start --- src/client/sse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 878a4919..7939e8cb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -99,11 +99,11 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers: HeadersInit = { ...this._requestInit?.headers }; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { - headers["Authorization"] = `Bearer ${tokens.access_token}`; + (headers as Record)["Authorization"] = `Bearer ${tokens.access_token}`; } } From 5f5180c4d32f1c53cd93f036f5ce3632cc064e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 17:20:02 -0700 Subject: [PATCH 06/45] Register completions capabilities --- src/server/mcp.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..38c869c7 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -236,6 +236,10 @@ export class McpServer { CompleteRequestSchema.shape.method.value, ); + this.server.registerCapabilities({ + completions: {}, + }); + this.server.setRequestHandler( CompleteRequestSchema, async (request): Promise => { From 269c8caf8ec7a248693bc54f22e013ac6259fc8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 18:00:28 -0700 Subject: [PATCH 07/45] Remove server-specific capabilities definition from Client constructor in mcp tests --- src/server/mcp.test.ts | 271 +++++++++++------------------------------ 1 file changed, 72 insertions(+), 199 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..bef8ae02 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -913,18 +913,10 @@ describe("tool()", () => { name: "test server", version: "1.0", }); - - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -1056,17 +1048,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema mcpServer.registerTool( @@ -1169,17 +1154,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema that returns only content without structuredContent mcpServer.registerTool( @@ -1233,17 +1211,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); // Register a tool with outputSchema that returns invalid data mcpServer.registerTool( @@ -1308,17 +1279,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedSessionId: string | undefined; mcpServer.tool("test-tool", async (extra) => { @@ -1364,17 +1328,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.tool("request-id-test", async (extra) => { @@ -1423,17 +1380,10 @@ describe("tool()", () => { { capabilities: { logging: {} } }, ); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedLogMessage: string | undefined; const loggingMessage = "hello here is log message 1"; @@ -1480,17 +1430,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool( "test", @@ -1546,17 +1489,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("error-test", async () => { throw new Error("Tool execution failed"); @@ -1598,17 +1534,10 @@ describe("tool()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.tool("test-tool", async () => ({ content: [ @@ -2401,17 +2330,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2469,17 +2391,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.resource( "test", @@ -2540,17 +2455,10 @@ describe("resource()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - resources: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => { @@ -3052,17 +2960,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test", @@ -3258,17 +3159,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt("test-prompt", async () => ({ messages: [ @@ -3312,17 +3206,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3380,17 +3267,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); mcpServer.prompt( "test-prompt", @@ -3450,17 +3330,10 @@ describe("prompt()", () => { version: "1.0", }); - const client = new Client( - { - name: "test client", - version: "1.0", - }, - { - capabilities: { - prompts: {}, - }, - }, - ); + const client = new Client({ + name: "test client", + version: "1.0", + }); let receivedRequestId: string | number | undefined; mcpServer.prompt("request-id-test", async (extra) => { From 7e0521550b3bf21a5fe043d7ee62aacc0f8a8c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Sun, 25 May 2025 19:07:26 -0700 Subject: [PATCH 08/45] Add tests for completions capability registration on template resource with completion or prompt completable argument registration --- src/server/mcp.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index bef8ae02..0ba1998d 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -2321,6 +2321,48 @@ describe("resource()", () => { ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a resource template with a complete callback is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{category}", { + list: undefined, + complete: { + category: () => ["books", "movies", "music"], + }, + }), + async () => ({ + contents: [ + { + uri: "test://resource/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Resource Template Parameter Completion */ @@ -3197,6 +3239,49 @@ describe("prompt()", () => { ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test("should advertise support for completion when a prompt with a completable argument is defined", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), () => ["Alice", "Bob", "Charlie"]), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }) + }) + /*** * Test: Prompt Argument Completion */ From 49777d565265424726af6b653adc99b592dbfd9b Mon Sep 17 00:00:00 2001 From: PapacyDai Date: Thu, 29 May 2025 15:15:52 +0800 Subject: [PATCH 09/45] fix: Ensure _meta object is not lost when onprogress option is passed When the onprogress option is passed, the _meta object in the request params was being overwritten, causing any existing _meta content to be lost. --- src/shared/protocol.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 4694929d..98f8e1c9 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -541,7 +541,10 @@ export abstract class Protocol< this._progressHandlers.set(messageId, options.onprogress); jsonrpcRequest.params = { ...request.params, - _meta: { progressToken: messageId }, + _meta: { + ...(request.params._meta || {}), + progressToken: messageId + }, }; } From cdcb00975cbcfbec6b3fdaa2a5cedbff048332a3 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 29 May 2025 14:59:47 +0100 Subject: [PATCH 10/45] fix build and add tests --- src/shared/protocol.test.ts | 126 ++++++++++++++++++++++++++++++++++++ src/shared/protocol.ts | 2 +- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index e0141da1..05bc8f3b 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -27,9 +27,11 @@ class MockTransport implements Transport { describe("protocol tests", () => { let protocol: Protocol; let transport: MockTransport; + let sendSpy: jest.SpyInstance; beforeEach(() => { transport = new MockTransport(); + sendSpy = jest.spyOn(transport, 'send'); protocol = new (class extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} @@ -63,6 +65,130 @@ describe("protocol tests", () => { expect(oncloseMock).toHaveBeenCalled(); }); + describe("_meta preservation with onprogress", () => { + test("should preserve existing _meta when adding progressToken", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123 + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue", + anotherField: 123, + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should create _meta with progressToken when no _meta exists", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test" + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should not modify _meta when onprogress is not provided", async () => { + await protocol.connect(transport); + const request = { + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + + protocol.request(request, mockSchema); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + data: "test", + _meta: { + customField: "customValue" + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + + test("should handle params being undefined with onprogress", async () => { + await protocol.connect(transport); + const request = { + method: "example" + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string(), + }); + const onProgressMock = jest.fn(); + + protocol.request(request, mockSchema, { + onprogress: onProgressMock, + }); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + method: "example", + params: { + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: "2.0", + id: expect.any(Number) + }), expect.any(Object)); + }); + }); + describe("progress notification timeout behavior", () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 98f8e1c9..a04f26eb 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -542,7 +542,7 @@ export abstract class Protocol< jsonrpcRequest.params = { ...request.params, _meta: { - ...(request.params._meta || {}), + ...(request.params?._meta || {}), progressToken: messageId }, }; From d46d4882e6168ec242d7355f7e50f511f6453172 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 29 May 2025 15:39:24 +0100 Subject: [PATCH 11/45] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 467800fc..fb1dd3cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.0", + "version": "1.12.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 6cf034595dbfb58c2061a370a49da4078e429d1e Mon Sep 17 00:00:00 2001 From: Carl Peaslee Date: Fri, 30 May 2025 11:32:10 -0700 Subject: [PATCH 12/45] updates listed resources from resource templates to favor their own metadata --- src/server/mcp.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 5b864b8b..65ed1b8f 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -369,8 +369,9 @@ export class McpServer { const result = await template.resourceTemplate.listCallback(extra); for (const resource of result.resources) { templateResources.push({ - ...resource, ...template.metadata, + // the defined resource metadata should override the template metadata if present + ...resource, }); } } From 1878143f1a8ffc3ce5f7acd4dc61cef67b589b4b Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 5 Jun 2025 11:52:59 -0400 Subject: [PATCH 13/45] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fb1dd3cf..764ce2cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.1", + "version": "1.12.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 2d61b4b8fc0a63162a4b64aa52793af7e9f27d15 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 10 Jun 2025 15:52:20 +0100 Subject: [PATCH 14/45] adding README --- README.md | 102 +++++++++++++++++ src/server/mcp.test.ts | 255 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) diff --git a/README.md b/README.md index c9e27c27..6c100735 100644 --- a/README.md +++ b/README.md @@ -635,6 +635,108 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Eliciting User Input + +MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation: + +```typescript +// Server-side: Restaurant booking tool that asks for alternatives +server.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await server.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + // Book the table + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } +); + +// Client-side: Handle elicitation requests + + +// This is a placeholder - implement based on your UI framework +async function getInputFromUser(message: string, schema: any): Promise<{ + action: "accept" | "decline" | "cancel"; + data?: Record; +}> { + // This should be implemented depending on the app + throw new Error("getInputFromUser must be implemented for your platform"); +} + +client.setRequestHandler(ElicitRequestSchema, async (request) => { + const userResponse = await getInputFromUser( + request.params.message, + request.params.requestedSchema + ); + + return { + action: userResponse.action, + content: userResponse.action === "accept" ? userResponse.data : undefined + }; +}); +``` + +**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization. + ### Writing MCP Clients The SDK provides a high-level client interface: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 0ba1998d..bcf09d52 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,6 +14,7 @@ import { LoggingMessageNotificationSchema, Notification, TextContent, + ElicitRequestSchema, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; @@ -3457,4 +3458,258 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); + + /** + * Test: Restaurant booking example with elicitation from README + */ + describe("Restaurant booking elicitation example", () => { + // Mock restaurant booking functions + const checkAvailability = jest.fn().mockResolvedValue(false); + const findAlternatives = jest.fn().mockResolvedValue([]); + const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: "restaurant-booking-server", + version: "1.0.0", + }); + + // Register the restaurant booking tool from README example + mcpServer.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" + }, + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] + } + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [{ + type: "text", + text: `Found these alternatives: ${alternatives.join(", ")}` + }] + }; + } + + return { + content: [{ + type: "text", + text: "No booking made. Original date not available." + }] + }; + } + + // Book the table + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, + }, + } + ); + }); + + test("should successfully book when table is available", async () => { + // Mock availability check to return true + checkAvailability.mockResolvedValue(true); + makeBooking.mockResolvedValue("BOOKING-123"); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(makeBooking).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(result.content).toEqual([{ + type: "text", + text: "Booked table for 2 at ABC Restaurant on 2024-12-25" + }]); + }); + + test("should ask for alternatives when no availability and user accepts", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async (request) => { + expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); + return { + action: "accept", + content: { + checkAlternatives: true, + flexibleDates: "same_week" + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); + expect(result.content).toEqual([{ + type: "text", + text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" + }]); + }); + + test("should handle user declining to check alternatives", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to decline alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "accept", + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); + + test("should handle user cancelling the elicitation", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "cancel" + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); + }); }); From c08871998530d3f5f10e53d52342c645e2a73230 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 10 Jun 2025 17:26:48 +0100 Subject: [PATCH 15/45] tests --- src/server/mcp.test.ts | 393 +++++++++++++++++++---------------------- 1 file changed, 179 insertions(+), 214 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index bcf09d52..7cb0c489 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3458,258 +3458,223 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); +}); - /** - * Test: Restaurant booking example with elicitation from README - */ - describe("Restaurant booking elicitation example", () => { - // Mock restaurant booking functions - const checkAvailability = jest.fn().mockResolvedValue(false); - const findAlternatives = jest.fn().mockResolvedValue([]); - const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); +describe("elicitInput()", () => { - let mcpServer: McpServer; - let client: Client; + const checkAvailability = jest.fn().mockResolvedValue(false); + const findAlternatives = jest.fn().mockResolvedValue([]); + const makeBooking = jest.fn().mockResolvedValue("BOOKING-123"); - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Create server with restaurant booking tool - mcpServer = new McpServer({ - name: "restaurant-booking-server", - version: "1.0.0", - }); + let mcpServer: McpServer; + let client: Client; - // Register the restaurant booking tool from README example - mcpServer.tool( - "book-restaurant", - { - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }, - async ({ restaurant, date, partySize }) => { - // Check availability - const available = await checkAvailability(restaurant, date, partySize); - - if (!available) { - // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ - message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, - requestedSchema: { - type: "object", - properties: { - checkAlternatives: { - type: "boolean", - title: "Check alternative dates", - description: "Would you like me to check other dates?" - }, - flexibleDates: { - type: "string", - title: "Date flexibility", - description: "How flexible are your dates?", - enum: ["next_day", "same_week", "next_week"], - enumNames: ["Next day", "Same week", "Next week"] - } + beforeEach(() => { + jest.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: "restaurant-booking-server", + version: "1.0.0", + }); + + // Register the restaurant booking tool from README example + mcpServer.tool( + "book-restaurant", + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: "object", + properties: { + checkAlternatives: { + type: "boolean", + title: "Check alternative dates", + description: "Would you like me to check other dates?" }, - required: ["checkAlternatives"] - } - }); - - if (result.action === "accept" && result.content?.checkAlternatives) { - const alternatives = await findAlternatives( - restaurant, - date, - partySize, - result.content.flexibleDates as string - ); - return { - content: [{ - type: "text", - text: `Found these alternatives: ${alternatives.join(", ")}` - }] - }; + flexibleDates: { + type: "string", + title: "Date flexibility", + description: "How flexible are your dates?", + enum: ["next_day", "same_week", "next_week"], + enumNames: ["Next day", "Same week", "Next week"] + } + }, + required: ["checkAlternatives"] } - + }); + + if (result.action === "accept" && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); return { content: [{ type: "text", - text: "No booking made. Original date not available." + text: `Found these alternatives: ${alternatives.join(", ")}` }] }; } - - // Book the table - await makeBooking(restaurant, date, partySize); + return { content: [{ type: "text", - text: `Booked table for ${partySize} at ${restaurant} on ${date}` + text: "No booking made. Original date not available." }] }; } - ); - // Create client with elicitation capability - client = new Client( - { - name: "test-client", - version: "1.0.0", + await makeBooking(restaurant, date, partySize); + return { + content: [{ + type: "text", + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + }] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + elicitation: {}, }, - { - capabilities: { - elicitation: {}, - }, + } + ); + }); + + test("should successfully elicit additional information", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async (request) => { + expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); + return { + action: "accept", + content: { + checkAlternatives: true, + flexibleDates: "same_week" } - ); + }; }); - test("should successfully book when table is available", async () => { - // Mock availability check to return true - checkAvailability.mockResolvedValue(true); - makeBooking.mockResolvedValue("BOOKING-123"); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(makeBooking).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(result.content).toEqual([{ - type: "text", - text: "Booked table for 2 at ABC Restaurant on 2024-12-25" - }]); + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } }); - test("should ask for alternatives when no availability and user accepts", async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - findAlternatives.mockResolvedValue(["2024-12-26", "2024-12-27", "2024-12-28"]); + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); + expect(result.content).toEqual([{ + type: "text", + text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" + }]); + }); - // Set up client to accept alternative date checking - client.setRequestHandler(ElicitRequestSchema, async (request) => { - expect(request.params.message).toContain("No tables available at ABC Restaurant on 2024-12-25"); - return { - action: "accept", - content: { - checkAlternatives: true, - flexibleDates: "same_week" - } - }; - }); + test("should handle user declining to elicitation request", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 + // Set up client to decline alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "accept", + content: { + checkAlternatives: false } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(findAlternatives).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2, "same_week"); - expect(result.content).toEqual([{ - type: "text", - text: "Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28" - }]); + }; }); - test("should handle user declining to check alternatives", async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Set up client to decline alternative date checking - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: "accept", - content: { - checkAlternatives: false - } - }; - }); + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([{ - type: "text", - text: "No booking made. Original date not available." - }]); + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } }); - test("should handle user cancelling the elicitation", async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); + }); - // Set up client to cancel the elicitation - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: "cancel" - }; - }); + test("should handle user cancelling the elicitation", async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.server.connect(serverTransport), - ]); - - // Call the tool - const result = await client.callTool({ - name: "book-restaurant", - arguments: { - restaurant: "ABC Restaurant", - date: "2024-12-25", - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([{ - type: "text", - text: "No booking made. Original date not available." - }]); + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: "cancel" + }; }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Call the tool + const result = await client.callTool({ + name: "book-restaurant", + arguments: { + restaurant: "ABC Restaurant", + date: "2024-12-25", + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith("ABC Restaurant", "2024-12-25", 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([{ + type: "text", + text: "No booking made. Original date not available." + }]); }); }); From 84971c83ea19f6481f72e4e489f101c8a2055458 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 10 Jun 2025 14:07:10 +0100 Subject: [PATCH 16/45] Set Mcp-Protocol-Version in client requests after init, and warn when it doesn't match negotiated version (it SHOULD match) --- src/client/index.ts | 2 + src/client/sse.ts | 14 +- src/client/streamableHttp.ts | 6 +- .../stateManagementStreamableHttp.test.ts | 21 +++ src/server/index.ts | 11 +- src/server/sse.ts | 1 + src/server/streamableHttp.test.ts | 145 +++++++++++++++++- src/server/streamableHttp.ts | 55 ++++++- src/shared/transport.ts | 7 + 9 files changed, 243 insertions(+), 19 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 98618a17..0e7d6cf4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -165,6 +165,8 @@ export class Client< this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; + // HTTP transports must set the protocol version in each header after initialization. + transport.protocolVersion = result.protocolVersion; this._instructions = result.instructions; diff --git a/src/client/sse.ts b/src/client/sse.ts index 7939e8cb..a80e06eb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -62,6 +62,7 @@ export class SSEClientTransport implements Transport { private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -99,13 +100,18 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = { ...this._requestInit?.headers }; + const headers = { + ...this._requestInit?.headers, + } as HeadersInit & Record; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { - (headers as Record)["Authorization"] = `Bearer ${tokens.access_token}`; + headers["Authorization"] = `Bearer ${tokens.access_token}`; } } + if (this.protocolVersion) { + headers["mcp-protocol-version"] = this.protocolVersion; + } return headers; } @@ -214,7 +220,9 @@ export class SSEClientTransport implements Transport { try { const commonHeaders = await this._commonHeaders(); - const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + // Note: this._requestInit?.headers already set in _commonHeaders + // const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + const headers = new Headers(commonHeaders); headers.set("content-type", "application/json"); const init = { ...this._requestInit, diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bcfbb2d..1bc2724e 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -124,6 +124,7 @@ export class StreamableHTTPClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -162,7 +163,7 @@ export class StreamableHTTPClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit = {}; + const headers: HeadersInit & Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { @@ -173,6 +174,9 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } + if (this.protocolVersion != null) { + headers["mcp-protocol-version"] = this.protocolVersion; + } return new Headers( { ...headers, ...this._requestInit?.headers } diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index b7ff17e6..503be80a 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -211,6 +211,27 @@ describe('Streamable HTTP Transport Session Management', () => { // Clean up await transport.close(); }); + + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); + + await client.connect(transport); + + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe('2025-03-26'); + + // Clean up + await transport.close(); + }); }); describe('Stateful Mode', () => { diff --git a/src/server/index.ts b/src/server/index.ts index 3901099e..d05c5489 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -251,10 +251,15 @@ export class Server< this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - return { - protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) + const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion - : LATEST_PROTOCOL_VERSION, + : LATEST_PROTOCOL_VERSION; + if (this.transport) { + this.transport.protocolVersion = protocolVersion; + } + + return { + protocolVersion, capabilities: this.getCapabilities(), serverInfo: this._serverInfo, ...(this._instructions && { instructions: this._instructions }), diff --git a/src/server/sse.ts b/src/server/sse.ts index 03f6fefc..b2f85ce0 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -18,6 +18,7 @@ export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b961f6c4..51cc4c47 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -185,6 +185,8 @@ async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMe if (sessionId) { headers["mcp-session-id"] = sessionId; + // After initialization, include the protocol version header + headers["mcp-protocol-version"] = "2025-03-26"; } return fetch(baseUrl, { @@ -277,7 +279,7 @@ describe("StreamableHTTPServerTransport", () => { expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); }); - it("should pandle post requests via sse response correctly", async () => { + it("should handle post requests via sse response correctly", async () => { sessionId = await initializeServer(); const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); @@ -376,6 +378,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -417,6 +420,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -448,6 +452,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -459,6 +464,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -477,6 +483,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "application/json", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -670,6 +677,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -705,7 +713,10 @@ describe("StreamableHTTPServerTransport", () => { // Now DELETE the session const deleteResponse = await fetch(tempUrl, { method: "DELETE", - headers: { "mcp-session-id": tempSessionId || "" }, + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, }); expect(deleteResponse.status).toBe(200); @@ -721,13 +732,129 @@ describe("StreamableHTTPServerTransport", () => { // Try to delete with invalid session ID const response = await fetch(baseUrl, { method: "DELETE", - headers: { "mcp-session-id": "invalid-session-id" }, + headers: { + "mcp-session-id": "invalid-session-id", + "mcp-protocol-version": "2025-03-26", + }, }); expect(response.status).toBe(404); const errorData = await response.json(); expectErrorResponse(errorData, -32001, /Session not found/); }); + + describe("protocol version header validation", () => { + it("should accept requests with matching protocol version", async () => { + sessionId = await initializeServer(); + + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + }); + + it("should accept requests without protocol version header", async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + expect(response.status).toBe(200); + }); + + it("should reject requests with unsupported protocol version", async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "1999-01-01", // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + }); + + it("should accept but warn when protocol version differs from negotiated version", async () => { + sessionId = await initializeServer(); + + // Spy on console.warn to verify warning is logged + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Send request with different but supported protocol version + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "2024-11-05", // Different but supported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), + }); + + // Request should still succeed + expect(response.status).toBe(200); + + // But warning should have been logged + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Request has header with protocol version 2024-11-05, but version previously negotiated is 2025-03-26") + ); + + warnSpy.mockRestore(); + }); + + it("should handle protocol version validation for GET requests", async () => { + sessionId = await initializeServer(); + + // GET request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "invalid-version", + }, + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + }); + + it("should handle protocol version validation for DELETE requests", async () => { + sessionId = await initializeServer(); + + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId, + "mcp-protocol-version": "invalid-version", + }, + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + }); + }); }); describe("StreamableHTTPServerTransport with AuthInfo", () => { @@ -1120,6 +1247,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", }, }); @@ -1196,6 +1324,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { headers: { Accept: "text/event-stream", "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-03-26", "last-event-id": firstEventId }, }); @@ -1282,14 +1411,20 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // Open first SSE stream const stream1 = await fetch(baseUrl, { method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26" + }, }); expect(stream1.status).toBe(200); // Open second SSE stream - should still be rejected, stateless mode still only allows one const stream2 = await fetch(baseUrl, { method: "GET", - headers: { Accept: "text/event-stream" }, + headers: { + Accept: "text/event-stream", + "mcp-protocol-version": "2025-03-26" + }, }); expect(stream2.status).toBe(409); // Conflict - only one stream allowed }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index dc99c306..b387f2ff 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -110,7 +110,8 @@ export class StreamableHTTPServerTransport implements Transport { private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; - sessionId?: string | undefined; + sessionId?: string; + protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; @@ -172,6 +173,9 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + if (!this.validateProtocolVersion(req, res)) { + return; + } // Handle resumability: check for Last-Event-ID header if (this._eventStore) { const lastEventId = req.headers['last-event-id'] as string | undefined; @@ -378,11 +382,17 @@ export class StreamableHTTPServerTransport implements Transport { } } - // If an Mcp-Session-Id is returned by the server during initialization, - // clients using the Streamable HTTP transport MUST include it - // in the Mcp-Session-Id header on all of their subsequent HTTP requests. - if (!isInitializationRequest && !this.validateSession(req, res)) { - return; + if (!isInitializationRequest) { + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + if (!this.validateSession(req, res)) { + return; + } + // Mcp-Protocol-Version header is required for all requests after initialization. + if (!this.validateProtocolVersion(req, res)) { + return; + } } @@ -457,6 +467,9 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + if (!this.validateProtocolVersion(req, res)) { + return; + } await this.close(); res.writeHead(200).end(); } @@ -524,6 +537,34 @@ export class StreamableHTTPServerTransport implements Transport { return true; } + private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean { + let protocolVersion = req.headers["mcp-protocol-version"]; + if (Array.isArray(protocolVersion)) { + protocolVersion = protocolVersion[protocolVersion.length - 1]; + } + + if (protocolVersion == null || protocolVersion === undefined) { + // If the protocol version is not set, we assume the client supports the implicit protocol version + return true; + } + + protocolVersion = String(protocolVersion).trim(); + if (this.protocolVersion !== undefined && this.protocolVersion !== protocolVersion) { + console.warn(`Request has header with protocol version ${protocolVersion}, but version previously negotiated is ${this.protocolVersion}.`); + } + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: 'Bad Request: Unsupported protocol version' + }, + id: null + })); + return false; + } + return true; + } async close(): Promise { // Close all SSE connections diff --git a/src/shared/transport.ts b/src/shared/transport.ts index fe0a60e6..976be79d 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -75,4 +75,11 @@ export interface Transport { * The session ID generated for this connection. */ sessionId?: string; + + /** + * The protocol version used for the connection. + * + * Only set after the initialize response was received. + */ + protocolVersion?: string; } From 255d30cbf2fb070898f3517c88957753ccbb5d2f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 12 Jun 2025 15:10:12 +0100 Subject: [PATCH 17/45] Rename ResourceReference to ResourceTemplateReference --- src/server/mcp.ts | 4 ++-- src/types.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 38c869c7..a20659b9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -21,7 +21,7 @@ import { CompleteRequest, CompleteResult, PromptReference, - ResourceReference, + ResourceTemplateReference, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -297,7 +297,7 @@ export class McpServer { private async handleResourceCompletion( request: CompleteRequest, - ref: ResourceReference, + ref: ResourceTemplateReference, ): Promise { const template = Object.values(this._registeredResourceTemplates).find( (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, diff --git a/src/types.ts b/src/types.ts index ae25848e..9334e4c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1092,7 +1092,7 @@ export const CreateMessageResultSchema = ResultSchema.extend({ /** * A reference to a resource or resource template definition. */ -export const ResourceReferenceSchema = z +export const ResourceTemplateReferenceSchema = z .object({ type: z.literal("ref/resource"), /** @@ -1102,6 +1102,11 @@ export const ResourceReferenceSchema = z }) .passthrough(); +/** + * @deprecated Use ResourceTemplateReferenceSchema instead + */ +export const ResourceReferenceSchema = ResourceTemplateReferenceSchema; + /** * Identifies a prompt. */ @@ -1121,7 +1126,7 @@ export const PromptReferenceSchema = z export const CompleteRequestSchema = RequestSchema.extend({ method: z.literal("completion/complete"), params: BaseRequestParamsSchema.extend({ - ref: z.union([PromptReferenceSchema, ResourceReferenceSchema]), + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), /** * The argument's information */ @@ -1377,7 +1382,11 @@ export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; /* Autocomplete */ -export type ResourceReference = Infer; +export type ResourceTemplateReference = Infer; +/** + * @deprecated Use ResourceTemplateReference instead + */ +export type ResourceReference = ResourceTemplateReference; export type PromptReference = Infer; export type CompleteRequest = Infer; export type CompleteResult = Infer; From bb0592bd50935ca487e3f08568b9e4d7313a7c64 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 12 Jun 2025 18:42:51 +0100 Subject: [PATCH 18/45] add supported versions to error message --- src/server/streamableHttp.test.ts | 6 +++--- src/server/streamableHttp.ts | 15 +++++---------- src/types.ts | 1 + 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 51cc4c47..f00721e2 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -788,7 +788,7 @@ describe("StreamableHTTPServerTransport", () => { expect(response.status).toBe(400); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); it("should accept but warn when protocol version differs from negotiated version", async () => { @@ -835,7 +835,7 @@ describe("StreamableHTTPServerTransport", () => { expect(response.status).toBe(400); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); it("should handle protocol version validation for DELETE requests", async () => { @@ -852,7 +852,7 @@ describe("StreamableHTTPServerTransport", () => { expect(response.status).toBe(400); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version/); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); }); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b387f2ff..c6010fd3 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS } from "../types.js"; +import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -538,17 +538,12 @@ export class StreamableHTTPServerTransport implements Transport { } private validateProtocolVersion(req: IncomingMessage, res: ServerResponse): boolean { - let protocolVersion = req.headers["mcp-protocol-version"]; + let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; if (Array.isArray(protocolVersion)) { protocolVersion = protocolVersion[protocolVersion.length - 1]; } - - if (protocolVersion == null || protocolVersion === undefined) { - // If the protocol version is not set, we assume the client supports the implicit protocol version - return true; - } - - protocolVersion = String(protocolVersion).trim(); + + if (this.protocolVersion !== undefined && this.protocolVersion !== protocolVersion) { console.warn(`Request has header with protocol version ${protocolVersion}, but version previously negotiated is ${this.protocolVersion}.`); } @@ -557,7 +552,7 @@ export class StreamableHTTPServerTransport implements Transport { jsonrpc: "2.0", error: { code: -32000, - message: 'Bad Request: Unsupported protocol version' + message: `Bad Request: Unsupported protocol version (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})` }, id: null })); diff --git a/src/types.ts b/src/types.ts index ae25848e..4bf87ef6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import { z, ZodTypeAny } from "zod"; export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, "2024-11-05", From a28e25a9c0c4e4bd3ee1ead292c30b1238d9d057 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 12 Jun 2025 18:43:06 +0100 Subject: [PATCH 19/45] use const in test --- src/integration-tests/stateManagementStreamableHttp.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 503be80a..d12a4f99 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../types.js'; import { z } from 'zod'; describe('Streamable HTTP Transport Session Management', () => { @@ -227,7 +227,7 @@ describe('Streamable HTTP Transport Session Management', () => { await client.connect(transport); // Verify protocol version is set after connecting - expect(transport.protocolVersion).toBe('2025-03-26'); + expect(transport.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); // Clean up await transport.close(); From e573a0f342f92b24250d860bc0f3d2a6aa55c53e Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 12 Jun 2025 18:43:49 +0100 Subject: [PATCH 20/45] nits: remove dead comment & align test style on local idioms --- src/client/sse.ts | 2 -- src/client/streamableHttp.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index a80e06eb..3111cd9d 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -220,8 +220,6 @@ export class SSEClientTransport implements Transport { try { const commonHeaders = await this._commonHeaders(); - // Note: this._requestInit?.headers already set in _commonHeaders - // const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); const headers = new Headers(commonHeaders); headers.set("content-type", "application/json"); const init = { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1bc2724e..2a1895f5 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -174,7 +174,7 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } - if (this.protocolVersion != null) { + if (this.protocolVersion) { headers["mcp-protocol-version"] = this.protocolVersion; } From bc5312991ebf47d5133b22d04e9a56cb3b9a3f15 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 12 Jun 2025 18:47:16 +0100 Subject: [PATCH 21/45] adding tests to prevent regressions --- src/server/mcp.test.ts | 140 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 49f852d6..b7bfdffb 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3499,4 +3499,144 @@ describe("prompt()", () => { expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); expect(result.messages[0].content.text).toContain("Received request ID:"); }); + + /*** + * Test: Resource Template Metadata Priority + */ + test("should prioritize individual resource metadata over template metadata", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{id}", { + list: async () => ({ + resources: [ + { + name: "Resource 1", + uri: "test://resource/1", + description: "Individual resource description", + mimeType: "text/plain", + }, + { + name: "Resource 2", + uri: "test://resource/2", + // This resource has no description or mimeType + }, + ], + }), + }), + { + description: "Template description", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(2); + + // Resource 1 should have its own metadata + expect(result.resources[0].name).toBe("Resource 1"); + expect(result.resources[0].description).toBe("Individual resource description"); + expect(result.resources[0].mimeType).toBe("text/plain"); + + // Resource 2 should inherit template metadata + expect(result.resources[1].name).toBe("Resource 2"); + expect(result.resources[1].description).toBe("Template description"); + expect(result.resources[1].mimeType).toBe("application/json"); + }); + + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test("should allow resource to override all template metadata fields", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{id}", { + list: async () => ({ + resources: [ + { + name: "Overridden Name", + uri: "test://resource/1", + description: "Overridden description", + mimeType: "text/markdown", + // Add any other metadata fields if they exist + }, + ], + }), + }), + { + name: "Template Name", + description: "Template description", + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/list", + }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(1); + + // All fields should be from the individual resource, not the template + expect(result.resources[0].name).toBe("Overridden Name"); + expect(result.resources[0].description).toBe("Overridden description"); + expect(result.resources[0].mimeType).toBe("text/markdown"); + }); }); From ea6d97d5abe21fb147d49ede26eafbeee4e89ca1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 12 Jun 2025 19:21:45 +0100 Subject: [PATCH 22/45] 1.12.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 764ce2cb..6b184f31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.12.2", + "version": "1.12.3", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4aaa26d900d452b61165cc440776b8e960b88a99 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 13 Jun 2025 13:35:38 +0100 Subject: [PATCH 23/45] remove protocol version mismatch warning --- src/server/streamableHttp.test.ts | 7 +------ src/server/streamableHttp.ts | 4 ---- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index f00721e2..d66083fe 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -791,7 +791,7 @@ describe("StreamableHTTPServerTransport", () => { expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); }); - it("should accept but warn when protocol version differs from negotiated version", async () => { + it("should accept when protocol version differs from negotiated version", async () => { sessionId = await initializeServer(); // Spy on console.warn to verify warning is logged @@ -812,11 +812,6 @@ describe("StreamableHTTPServerTransport", () => { // Request should still succeed expect(response.status).toBe(200); - // But warning should have been logged - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Request has header with protocol version 2024-11-05, but version previously negotiated is 2025-03-26") - ); - warnSpy.mockRestore(); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index c6010fd3..62503120 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -543,10 +543,6 @@ export class StreamableHTTPServerTransport implements Transport { protocolVersion = protocolVersion[protocolVersion.length - 1]; } - - if (this.protocolVersion !== undefined && this.protocolVersion !== protocolVersion) { - console.warn(`Request has header with protocol version ${protocolVersion}, but version previously negotiated is ${this.protocolVersion}.`); - } if (!SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", From 33257f14d4d4e8b09b20f58126ab1fb8ef9e05ab Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 13 Jun 2025 13:36:04 +0100 Subject: [PATCH 24/45] switch to optional Transport.setProtocolVersion --- src/client/index.ts | 4 +++- src/client/sse.ts | 10 +++++++--- src/client/streamableHttp.ts | 13 ++++++++++--- src/server/index.ts | 3 --- src/server/sse.ts | 2 -- src/server/streamableHttp.ts | 1 - src/shared/transport.ts | 6 ++---- 7 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 0e7d6cf4..a8fbdcee 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -166,7 +166,9 @@ export class Client< this._serverCapabilities = result.capabilities; this._serverVersion = result.serverInfo; // HTTP transports must set the protocol version in each header after initialization. - transport.protocolVersion = result.protocolVersion; + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.protocolVersion); + } this._instructions = result.instructions; diff --git a/src/client/sse.ts b/src/client/sse.ts index 3111cd9d..5aa99abb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -62,7 +62,7 @@ export class SSEClientTransport implements Transport { private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; - protocolVersion?: string; + private _protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -109,8 +109,8 @@ export class SSEClientTransport implements Transport { headers["Authorization"] = `Bearer ${tokens.access_token}`; } } - if (this.protocolVersion) { - headers["mcp-protocol-version"] = this.protocolVersion; + if (this._protocolVersion) { + headers["mcp-protocol-version"] = this._protocolVersion; } return headers; @@ -255,4 +255,8 @@ export class SSEClientTransport implements Transport { throw error; } } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 2a1895f5..4117bb1b 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -124,7 +124,7 @@ export class StreamableHTTPClientTransport implements Transport { private _authProvider?: OAuthClientProvider; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; - protocolVersion?: string; + private _protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -174,8 +174,8 @@ export class StreamableHTTPClientTransport implements Transport { if (this._sessionId) { headers["mcp-session-id"] = this._sessionId; } - if (this.protocolVersion) { - headers["mcp-protocol-version"] = this.protocolVersion; + if (this._protocolVersion) { + headers["mcp-protocol-version"] = this._protocolVersion; } return new Headers( @@ -520,4 +520,11 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + get protocolVersion(): string | undefined { + return this._protocolVersion; + } } diff --git a/src/server/index.ts b/src/server/index.ts index d05c5489..caf72f9c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -254,9 +254,6 @@ export class Server< const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; - if (this.transport) { - this.transport.protocolVersion = protocolVersion; - } return { protocolVersion, diff --git a/src/server/sse.ts b/src/server/sse.ts index b2f85ce0..e9a4d53a 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -17,8 +17,6 @@ const MAXIMUM_MESSAGE_SIZE = "4mb"; export class SSEServerTransport implements Transport { private _sseResponse?: ServerResponse; private _sessionId: string; - - protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 62503120..34b2ab68 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -111,7 +111,6 @@ export class StreamableHTTPServerTransport implements Transport { private _onsessioninitialized?: (sessionId: string) => void; sessionId?: string; - protocolVersion?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 976be79d..b75e072e 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -77,9 +77,7 @@ export interface Transport { sessionId?: string; /** - * The protocol version used for the connection. - * - * Only set after the initialize response was received. + * Sets the protocol version used for the connection (called when the initialize response is received). */ - protocolVersion?: string; + setProtocolVersion?: (version: string) => void; } From e7284d29c8271f62208caf4a90d70d2a2cd46bee Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 18:47:44 +0100 Subject: [PATCH 25/45] added _meta to more objects --- src/types.ts | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 740b6c43..822ba8d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,8 @@ export const RequestSchema = z.object({ const BaseNotificationParamsSchema = z .object({ /** - * This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications. - */ + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -58,8 +58,8 @@ export const NotificationSchema = z.object({ export const ResultSchema = z .object({ /** - * This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses. - */ + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -463,6 +463,11 @@ export const ResourceSchema = z * The MIME type of this resource, if known. */ mimeType: z.optional(z.string()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -494,6 +499,11 @@ export const ResourceTemplateSchema = z * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. */ mimeType: z.optional(z.string()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -677,6 +687,11 @@ export const TextContentSchema = z * The text content of the message. */ text: z.string(), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -694,6 +709,11 @@ export const ImageContentSchema = z * The MIME type of the image. Different providers may support different image types. */ mimeType: z.string(), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -711,6 +731,11 @@ export const AudioContentSchema = z * The MIME type of the audio. Different providers may support different audio types. */ mimeType: z.string(), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -847,12 +872,17 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), required: z.optional(z.array(z.string())), }) - .passthrough() + .passthrough() ), /** * Optional additional tool information. */ annotations: z.optional(ToolAnnotationsSchema), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -1182,6 +1212,11 @@ export const RootSchema = z * An optional name for the root. */ name: z.optional(z.string()), + + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); From 4d03b243e37a6b1965c8cbe601cd37c94b9b0080 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 20:42:25 +0100 Subject: [PATCH 26/45] add title --- README.md | 138 +++++++++--- src/examples/client/simpleStreamableHttp.ts | 7 +- src/examples/server/simpleStreamableHttp.ts | 29 ++- src/server/mcp.ts | 158 +++++++++++++- src/server/title.test.ts | 194 +++++++++++++++++ src/shared/metadataUtils.ts | 21 ++ src/types.ts | 227 +++++++++----------- 7 files changed, 601 insertions(+), 173 deletions(-) create mode 100644 src/server/title.test.ts create mode 100644 src/shared/metadataUtils.ts diff --git a/README.md b/README.md index c9e27c27..d13378b5 100644 --- a/README.md +++ b/README.md @@ -54,26 +54,35 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ - name: "Demo", - version: "1.0.0" + name: "demo-server", + version: "1.0.0", + title: "Demo Server" // Optional display name }); // Add an addition tool -server.tool("add", - { a: z.number(), b: z.number() }, +server.registerTool("add", + { + title: "Addition Tool", + description: "Add two numbers", + inputSchema: { a: z.number(), b: z.number() } + }, async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }) ); // Add a dynamic greeting resource -server.resource( +server.registerResource( "greeting", new ResourceTemplate("greeting://{name}", { list: undefined }), - async (uri, { name }) => ({ + { + title: "Greeting Resource", // Display name for UI + description: "Dynamic greeting generator" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Hello, ${name}!` + text: `Hello, ${params.name}!` }] }) ); @@ -100,8 +109,9 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "My App", - version: "1.0.0" + name: "my-app", // Unique identifier for your server + version: "1.0.0", // Server version + title: "My Application" // Optional display name for UI }); ``` @@ -111,9 +121,14 @@ Resources are how you expose data to LLMs. They're similar to GET endpoints in a ```typescript // Static resource -server.resource( +server.registerResource( "config", "config://app", + { + title: "Application Config", + description: "Application configuration data", + mimeType: "text/plain" + }, async (uri) => ({ contents: [{ uri: uri.href, @@ -123,13 +138,17 @@ server.resource( ); // Dynamic resource with parameters -server.resource( +server.registerResource( "user-profile", new ResourceTemplate("users://{userId}/profile", { list: undefined }), - async (uri, { userId }) => ({ + { + title: "User Profile", + description: "User profile information" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Profile data for user ${userId}` + text: `Profile data for user ${params.userId}` }] }) ); @@ -141,11 +160,15 @@ Tools let LLMs take actions through your server. Unlike resources, tools are exp ```typescript // Simple tool with parameters -server.tool( +server.registerTool( "calculate-bmi", { - weightKg: z.number(), - heightM: z.number() + title: "BMI Calculator", + description: "Calculate Body Mass Index", + inputSchema: { + weightKg: z.number(), + heightM: z.number() + } }, async ({ weightKg, heightM }) => ({ content: [{ @@ -156,9 +179,13 @@ server.tool( ); // Async tool with external API call -server.tool( +server.registerTool( "fetch-weather", - { city: z.string() }, + { + title: "Weather Fetcher", + description: "Get weather data for a city", + inputSchema: { city: z.string() } + }, async ({ city }) => { const response = await fetch(`https://api.weather.com/${city}`); const data = await response.text(); @@ -174,9 +201,13 @@ server.tool( Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript -server.prompt( +server.registerPrompt( "review-code", - { code: z.string() }, + { + title: "Code Review", + description: "Review code for best practices and potential issues", + arguments: { code: z.string() } + }, ({ code }) => ({ messages: [{ role: "user", @@ -189,6 +220,22 @@ server.prompt( ); ``` +### Display Names and Metadata + +All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name, while `name` remains the unique identifier. + +**Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. + + +When building clients, use the provided utility to get the appropriate display name: + +```typescript +import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; + +// Falls back to 'name' if 'title' is not provided +const displayName = getDisplayName(tool); // Returns title if available, otherwise name +``` + ## Running Your Server MCP servers in TypeScript need to be connected to a transport to communicate with clients. How you start the server depends on the choice of transport: @@ -401,32 +448,45 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc import { z } from "zod"; const server = new McpServer({ - name: "Echo", - version: "1.0.0" + name: "echo-server", + version: "1.0.0", + title: "Echo Server" }); -server.resource( +server.registerResource( "echo", new ResourceTemplate("echo://{message}", { list: undefined }), - async (uri, { message }) => ({ + { + title: "Echo Resource", + description: "Echoes back messages as resources" + }, + async (uri, params) => ({ contents: [{ uri: uri.href, - text: `Resource echo: ${message}` + text: `Resource echo: ${params.message}` }] }) ); -server.tool( +server.registerTool( "echo", - { message: z.string() }, + { + title: "Echo Tool", + description: "Echoes back the provided message", + inputSchema: { message: z.string() } + }, async ({ message }) => ({ content: [{ type: "text", text: `Tool echo: ${message}` }] }) ); -server.prompt( +server.registerPrompt( "echo", - { message: z.string() }, + { + title: "Echo Prompt", + description: "Creates a prompt to process a message", + arguments: { message: z.string() } + }, ({ message }) => ({ messages: [{ role: "user", @@ -450,8 +510,9 @@ import { promisify } from "util"; import { z } from "zod"; const server = new McpServer({ - name: "SQLite Explorer", - version: "1.0.0" + name: "sqlite-explorer", + version: "1.0.0", + title: "SQLite Explorer" }); // Helper to create DB connection @@ -463,9 +524,14 @@ const getDb = () => { }; }; -server.resource( +server.registerResource( "schema", "schema://main", + { + title: "Database Schema", + description: "SQLite database schema", + mimeType: "text/plain" + }, async (uri) => { const db = getDb(); try { @@ -484,9 +550,13 @@ server.resource( } ); -server.tool( +server.registerTool( "query", - { sql: z.string() }, + { + title: "SQL Query", + description: "Execute SQL queries on the database", + inputSchema: { sql: z.string() } + }, async ({ sql }) => { const db = getDb(); try { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 19d32bbc..63d7d60a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,6 +15,7 @@ import { LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, } from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; // Create readline interface for user input const readline = createInterface({ @@ -317,7 +318,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${tool.name}: ${tool.description}`); + console.log(` - ${getDisplayName(tool)}: ${tool.description}`); } } } catch (error) { @@ -429,7 +430,7 @@ async function listPrompts(): Promise { console.log(' No prompts available'); } else { for (const prompt of promptsResult.prompts) { - console.log(` - ${prompt.name}: ${prompt.description}`); + console.log(` - ${getDisplayName(prompt)}: ${prompt.description}`); } } } catch (error) { @@ -480,7 +481,7 @@ async function listResources(): Promise { console.log(' No resources available'); } else { for (const resource of resourcesResult.resources) { - console.log(` - ${resource.name}: ${resource.uri}`); + console.log(` - ${getDisplayName(resource)}: ${resource.uri}`); } } } catch (error) { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c331192..b66be93d 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -18,14 +18,18 @@ const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', version: '1.0.0', + title: 'Simple Streamable HTTP Server', // Display name for UI }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting - server.tool( + server.registerTool( 'greet', - 'A simple greeting tool', { - name: z.string().describe('Name to greet'), + title: 'Greeting Tool', // Display name for UI + description: 'A simple greeting tool', + inputSchema: { + name: z.string().describe('Name to greet'), + }, }, async ({ name }): Promise => { return { @@ -84,12 +88,15 @@ const getServer = () => { } ); - // Register a simple prompt - server.prompt( + // Register a simple prompt with title + server.registerPrompt( 'greeting-template', - 'A simple greeting prompt template', { - name: z.string().describe('Name to include in greeting'), + title: 'Greeting Template', // Display name for UI + description: 'A simple greeting prompt template', + arguments: { + name: z.string().describe('Name to include in greeting'), + }, }, async ({ name }): Promise => { return { @@ -148,10 +155,14 @@ const getServer = () => { ); // Create a simple resource at a fixed URI - server.resource( + server.registerResource( 'greeting-resource', 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, + { + title: 'Default Greeting', // Display name for UI + description: 'A simple greeting resource', + mimeType: 'text/plain' + }, async (): Promise => { return { contents: [ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7fba043f..7c77add1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -122,6 +122,10 @@ export class McpServer { annotations: tool.annotations, }; + if (tool.title !== undefined) { + toolDefinition.title = tool.title; + } + if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( tool.outputSchema, @@ -467,13 +471,19 @@ export class McpServer { ([, prompt]) => prompt.enabled, ).map( ([name, prompt]): Prompt => { - return { + const promptDefinition: Prompt = { name, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, }; + + if (prompt.title !== undefined) { + promptDefinition.title = prompt.title; + } + + return promptDefinition; }, ), }), @@ -634,6 +644,83 @@ export class McpServer { } } + /** + * Registers a resource with a config object and callback. + * For static resources, use a URI string. For dynamic resources, use a ResourceTemplate. + */ + registerResource( + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata & { + title?: string; + description?: string; + mimeType?: string; + }, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === "string") { + if (this._registeredResources[uriOrTemplate]) { + throw new Error(`Resource ${uriOrTemplate} is already registered`); + } + + const registeredResource: RegisteredResource = { + name, + metadata: config, + readCallback: readCallback as ReadResourceCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: (updates) => { + if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { + delete this._registeredResources[uriOrTemplate] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResources[uriOrTemplate] = registeredResource; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; + } else { + if (this._registeredResourceTemplates[name]) { + throw new Error(`Resource template ${name} is already registered`); + } + + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: uriOrTemplate, + metadata: config, + readCallback: readCallback as ReadResourceTemplateCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } + } + private _createRegisteredTool( name: string, description: string | undefined, @@ -659,6 +746,7 @@ export class McpServer { delete this._registeredTools[name] if (updates.name) this._registeredTools[updates.name] = registeredTool } + if (typeof updates.title !== "undefined") registeredTool.title = updates.title if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback @@ -789,6 +877,7 @@ export class McpServer { registerTool( name: string, config: { + title?: string; description?: string; inputSchema?: InputArgs; outputSchema?: OutputArgs; @@ -800,16 +889,23 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { description, inputSchema, outputSchema, annotations } = config; + const { title, description, inputSchema, outputSchema, annotations } = config; - return this._createRegisteredTool( + const registeredTool = this._createRegisteredTool( name, description, inputSchema, outputSchema, annotations, cb as ToolCallback - ) + ); + + // Set title if provided + if (title !== undefined) { + registeredTool.title = title; + } + + return registeredTool; } /** @@ -870,6 +966,7 @@ export class McpServer { delete this._registeredPrompts[name] if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback @@ -885,6 +982,54 @@ export class McpServer { return registeredPrompt } + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + arguments?: Args; + }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, arguments: argsSchema } = config; + + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback: cb as PromptCallback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, + }; + this._registeredPrompts[name] = registeredPrompt; + + this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt; + } + /** * Checks if the server is connected to a transport. * @returns True if the server is connected @@ -1000,6 +1145,7 @@ export type ToolCallback = : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { + title?: string; description?: string; inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; @@ -1011,6 +1157,7 @@ export type RegisteredTool = { update( updates: { name?: string | null, + title?: string, description?: string, paramsSchema?: InputArgs, outputSchema?: OutputArgs, @@ -1110,13 +1257,14 @@ export type PromptCallback< : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { + title?: string; description?: string; argsSchema?: ZodObject; callback: PromptCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/server/title.test.ts b/src/server/title.test.ts new file mode 100644 index 00000000..7874fb62 --- /dev/null +++ b/src/server/title.test.ts @@ -0,0 +1,194 @@ +import { Server } from "./index.js"; +import { Client } from "../client/index.js"; +import { InMemoryTransport } from "../inMemory.js"; +import { z } from "zod"; +import { McpServer } from "./mcp.js"; + +describe("Title field backwards compatibility", () => { + it("should work with tools that have title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool with title + server.registerTool( + "test-tool", + { + title: "Test Tool Display Name", + description: "A test tool", + inputSchema: { + value: z.string() + } + }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBe("Test Tool Display Name"); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with tools without title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register tool without title + server.tool( + "test-tool", + "A test tool", + { value: z.string() }, + async () => ({ content: [{ type: "text", text: "result" }] }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe("test-tool"); + expect(tools.tools[0].title).toBeUndefined(); + expect(tools.tools[0].description).toBe("A test tool"); + }); + + it("should work with prompts that have title using update", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title by updating after creation + const prompt = server.prompt( + "test-prompt", + "A test prompt", + async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] }) + ); + prompt.update({ title: "Test Prompt Display Name" }); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + }); + + it("should work with prompts using registerPrompt", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register prompt with title using registerPrompt + server.registerPrompt( + "test-prompt", + { + title: "Test Prompt Display Name", + description: "A test prompt", + arguments: { input: z.string() } + }, + async ({ input }) => ({ + messages: [{ + role: "user", + content: { type: "text", text: `test: ${input}` } + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe("test-prompt"); + expect(prompts.prompts[0].title).toBe("Test Prompt Display Name"); + expect(prompts.prompts[0].description).toBe("A test prompt"); + expect(prompts.prompts[0].arguments).toHaveLength(1); + }); + + it("should work with resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register resource with title using registerResource + server.registerResource( + "test-resource", + "https://example.com/test", + { + title: "Test Resource Display Name", + description: "A test resource", + mimeType: "text/plain" + }, + async () => ({ + contents: [{ + uri: "https://example.com/test", + text: "test content" + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0].name).toBe("test-resource"); + expect(resources.resources[0].title).toBe("Test Resource Display Name"); + expect(resources.resources[0].description).toBe("A test resource"); + expect(resources.resources[0].mimeType).toBe("text/plain"); + }); + + it("should support serverInfo with title", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new Server( + { + name: "test-server", + version: "1.0.0", + title: "Test Server Display Name" + }, + { capabilities: {} } + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe("test-server"); + expect(serverInfo?.version).toBe("1.0.0"); + expect(serverInfo?.title).toBe("Test Server Display Name"); + }); +}); \ No newline at end of file diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts new file mode 100644 index 00000000..d581a6b2 --- /dev/null +++ b/src/shared/metadataUtils.ts @@ -0,0 +1,21 @@ +import { BaseMetadata } from "../types.js"; + +/** + * Utilities for working with BaseMetadata objects. + */ + +/** + * Gets the display name for an object with BaseMetadata. + * Returns the title if available, otherwise falls back to name. + * This implements the spec requirement: "if no title is provided, name should be used for display purposes" + */ +export function getDisplayName(metadata: BaseMetadata): string { + return metadata.title ?? metadata.name; +} + +/** + * Checks if an object has a custom title different from its name. + */ +export function hasCustomTitle(metadata: BaseMetadata): boolean { + return metadata.title !== undefined && metadata.title !== metadata.name; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 822ba8d7..9ece70d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -195,16 +195,26 @@ export const CancelledNotificationSchema = NotificationSchema.extend({ }), }); +/* Base Metadata */ +/** + * Base metadata interface for common properties across resources, tools, prompts, and implementations. + */ +export const BaseMetadataSchema = z + .object({ + /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ + name: z.string(), + /** Intended for UI and end-user contexts — optimized to be human-readable */ + title: z.optional(z.string()), + }) + .passthrough(); + /* Initialization */ /** * Describes the name and version of an MCP implementation. */ -export const ImplementationSchema = z - .object({ - name: z.string(), - version: z.string(), - }) - .passthrough(); +export const ImplementationSchema = BaseMetadataSchema.extend({ + version: z.string(), +}); /** * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. @@ -438,74 +448,56 @@ export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ /** * A known resource that the server is capable of reading. */ -export const ResourceSchema = z - .object({ - /** - * The URI of this resource. - */ - uri: z.string(), +export const ResourceSchema = BaseMetadataSchema.extend({ + /** + * The URI of this resource. + */ + uri: z.string(), - /** - * A human-readable name for this resource. - * - * This can be used by clients to populate UI elements. - */ - name: z.string(), + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), - /** - * A description of what this resource represents. - * - * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - */ - description: z.optional(z.string()), + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), - /** - * The MIME type of this resource, if known. - */ - mimeType: z.optional(z.string()), - - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .passthrough(); + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * A template description for resources available on the server. */ -export const ResourceTemplateSchema = z - .object({ - /** - * A URI template (according to RFC 6570) that can be used to construct resource URIs. - */ - uriTemplate: z.string(), +export const ResourceTemplateSchema = BaseMetadataSchema.extend({ + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + */ + uriTemplate: z.string(), - /** - * A human-readable name for the type of resource this template refers to. - * - * This can be used by clients to populate UI elements. - */ - name: z.string(), + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), - /** - * A description of what this template is for. - * - * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. - */ - description: z.optional(z.string()), + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType: z.optional(z.string()), - /** - * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. - */ - mimeType: z.optional(z.string()), - - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .passthrough(); + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of resources the server has. @@ -629,22 +621,16 @@ export const PromptArgumentSchema = z /** * A prompt or prompt template that the server offers. */ -export const PromptSchema = z - .object({ - /** - * The name of the prompt or prompt template. - */ - name: z.string(), - /** - * An optional description of what this prompt provides - */ - description: z.optional(z.string()), - /** - * A list of arguments to use for templating the prompt. - */ - arguments: z.optional(z.array(PromptArgumentSchema)), - }) - .passthrough(); +export const PromptSchema = BaseMetadataSchema.extend({ + /** + * An optional description of what this prompt provides + */ + description: z.optional(z.string()), + /** + * A list of arguments to use for templating the prompt. + */ + arguments: z.optional(z.array(PromptArgumentSchema)), +}); /** * Sent from the client to request a list of prompts and prompt templates the server has. @@ -842,49 +828,43 @@ export const ToolAnnotationsSchema = z /** * Definition for a tool the client can call. */ -export const ToolSchema = z - .object({ - /** - * The name of the tool. - */ - name: z.string(), - /** - * A human-readable description of the tool. - */ - description: z.optional(z.string()), - /** - * A JSON Schema object defining the expected parameters for the tool. - */ - inputSchema: z - .object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - required: z.optional(z.array(z.string())), - }) - .passthrough(), - /** - * An optional JSON Schema object defining the structure of the tool's output returned in - * the structuredContent field of a CallToolResult. - */ - outputSchema: z.optional( - z.object({ - type: z.literal("object"), - properties: z.optional(z.object({}).passthrough()), - required: z.optional(z.array(z.string())), - }) - .passthrough() - ), - /** - * Optional additional tool information. - */ - annotations: z.optional(ToolAnnotationsSchema), +export const ToolSchema = BaseMetadataSchema.extend({ + /** + * A human-readable description of the tool. + */ + description: z.optional(z.string()), + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: z + .object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough(), + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + */ + outputSchema: z.optional( + z.object({ + type: z.literal("object"), + properties: z.optional(z.object({}).passthrough()), + required: z.optional(z.array(z.string())), + }) + .passthrough() + ), + /** + * Optional additional tool information. + */ + annotations: z.optional(ToolAnnotationsSchema), - /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ - _meta: z.optional(z.object({}).passthrough()), - }) - .passthrough(); + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), +}); /** * Sent from the client to request a list of tools the server has. @@ -1347,6 +1327,9 @@ export type EmptyResult = Infer; /* Cancellation */ export type CancelledNotification = Infer; +/* Base Metadata */ +export type BaseMetadata = Infer; + /* Initialization */ export type Implementation = Infer; export type ClientCapabilities = Infer; From 7e59081b43ca156771470a195794cdff716f3859 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 15 Jun 2025 22:19:53 +0100 Subject: [PATCH 27/45] add resource link --- README.md | 32 +++ src/examples/client/simpleStreamableHttp.ts | 74 ++++++- src/examples/server/simpleStreamableHttp.ts | 95 ++++++++- src/types.test.ts | 211 +++++++++++++++++++- src/types.ts | 37 ++-- 5 files changed, 433 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index d13378b5..e7fa574c 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,40 @@ server.registerTool( }; } ); + +// Tool that returns ResourceLinks +server.registerTool( + "list-files", + { + title: "List Files", + description: "List project files", + inputSchema: { pattern: z.string() } + }, + async ({ pattern }) => ({ + content: [ + { type: "text", text: `Found files matching "${pattern}":` }, + // ResourceLinks let tools return references without file content + { + type: "resource_link" as const, + uri: "file:///project/README.md", + name: "README.md", + mimeType: "text/markdown" + }, + { + type: "resource_link" as const, + uri: "file:///project/src/index.ts", + name: "index.ts", + mimeType: "text/typescript" + } + ] + }) +); ``` +#### ResourceLinks + +Tools can return `ResourceLink` objects to reference resources without embedding their full content. This is essential for performance when dealing with large files or many resources - clients can then selectively read only the resources they need using the provided URIs. + ### Prompts Prompts are reusable templates that help LLMs interact with your server effectively: diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..8b539d97 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -14,6 +14,9 @@ import { ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, + ReadResourceRequest, + ReadResourceResultSchema, + ResourceLink, } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; @@ -60,6 +63,7 @@ function printHelp(): void { console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); console.log(' list-resources - List available resources'); + console.log(' read-resource - Read a specific resource by URI'); console.log(' help - Show this help'); console.log(' quit - Exit the program'); } @@ -155,6 +159,14 @@ function commandLoop(): void { await listResources(); break; + case 'read-resource': + if (args.length < 2) { + console.log('Usage: read-resource '); + } else { + await readResource(args[1]); + } + break; + case 'help': printHelp(); break; @@ -345,13 +357,37 @@ async function callTool(name: string, args: Record): Promise { if (item.type === 'text') { console.log(` ${item.text}`); + } else if (item.type === 'resource_link') { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` šŸ“ Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); } else { - console.log(` ${item.type} content:`, item); + console.log(` [Unknown content type]:`, item); } }); + + // Offer to read resource links + if (resourceLinks.length > 0) { + console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); + } } catch (error) { console.log(`Error calling tool ${name}: ${error}`); } @@ -489,6 +525,42 @@ async function listResources(): Promise { } } +async function readResource(uri: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: ReadResourceRequest = { + method: 'resources/read', + params: { uri } + }; + + console.log(`Reading resource: ${uri}`); + const result = await client.request(request, ReadResourceResultSchema); + + console.log('Resource contents:'); + for (const content of result.contents) { + console.log(` URI: ${content.uri}`); + if (content.mimeType) { + console.log(` Type: ${content.mimeType}`); + } + + if ('text' in content && typeof content.text === 'string') { + console.log(' Content:'); + console.log(' ---'); + console.log(content.text.split('\n').map((line: string) => ' ' + line).join('\n')); + console.log(' ---'); + } else if ('blob' in content && typeof content.blob === 'string') { + console.log(` [Binary data: ${content.blob.length} bytes]`); + } + } + } catch (error) { + console.log(`Error reading resource ${uri}: ${error}`); + } +} + async function cleanup(): Promise { if (client && transport) { try { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b66be93d..e5d35a82 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -5,7 +5,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult, ResourceLink } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; @@ -174,6 +174,99 @@ const getServer = () => { }; } ); + + // Create additional resources for ResourceLink demonstration + server.registerResource( + 'example-file-1', + 'file:///example/file1.txt', + { + title: 'Example File 1', + description: 'First example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file1.txt', + text: 'This is the content of file 1', + }, + ], + }; + } + ); + + server.registerResource( + 'example-file-2', + 'file:///example/file2.txt', + { + title: 'Example File 2', + description: 'Second example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file2.txt', + text: 'This is the content of file 2', + }, + ], + }; + } + ); + + // Register a tool that returns ResourceLinks + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: { + includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'), + }, + }, + async ({ includeDescriptions = true }): Promise => { + const resourceLinks: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'https://example.com/greetings/default', + name: 'Default Greeting', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'A simple greeting resource' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file1.txt', + name: 'Example File 1', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file2.txt', + name: 'Example File 2', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) + } + ]; + + return { + content: [ + { + type: 'text', + text: 'Here are the available files as resource links:', + }, + ...resourceLinks, + { + type: 'text', + text: '\nYou can read any of these resources using their URI.', + } + ], + }; + } + ); + return server; }; diff --git a/src/types.test.ts b/src/types.test.ts index 0fbc003d..ca49867a 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,4 +1,11 @@ -import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js"; +import { + LATEST_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + ResourceLinkSchema, + ContentBlockSchema, + PromptMessageSchema, + CallToolResultSchema +} from "./types.js"; describe("Types", () => { @@ -14,4 +21,206 @@ describe("Types", () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); }); + describe("ResourceLink", () => { + test("should validate a minimal ResourceLink", () => { + const resourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt", + name: "file.txt" + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource_link"); + expect(result.data.uri).toBe("file:///path/to/file.txt"); + expect(result.data.name).toBe("file.txt"); + } + }); + + test("should validate a ResourceLink with all optional fields", () => { + const resourceLink = { + type: "resource_link", + uri: "https://example.com/resource", + name: "Example Resource", + title: "A comprehensive example resource", + description: "This resource demonstrates all fields", + mimeType: "text/plain", + _meta: { custom: "metadata" } + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe("A comprehensive example resource"); + expect(result.data.description).toBe("This resource demonstrates all fields"); + expect(result.data.mimeType).toBe("text/plain"); + expect(result.data._meta).toEqual({ custom: "metadata" }); + } + }); + + test("should fail validation for invalid type", () => { + const invalidResourceLink = { + type: "invalid_type", + uri: "file:///path/to/file.txt", + name: "file.txt" + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + + test("should fail validation for missing required fields", () => { + const invalidResourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt" + // missing name + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + }); + + describe("ContentBlock", () => { + test("should validate text content", () => { + const textContent = { + type: "text", + text: "Hello, world!" + }; + + const result = ContentBlockSchema.safeParse(textContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("text"); + } + }); + + test("should validate image content", () => { + const imageContent = { + type: "image", + data: "aGVsbG8=", // base64 encoded "hello" + mimeType: "image/png" + }; + + const result = ContentBlockSchema.safeParse(imageContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("image"); + } + }); + + test("should validate audio content", () => { + const audioContent = { + type: "audio", + data: "aGVsbG8=", // base64 encoded "hello" + mimeType: "audio/mp3" + }; + + const result = ContentBlockSchema.safeParse(audioContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("audio"); + } + }); + + test("should validate resource link content", () => { + const resourceLink = { + type: "resource_link", + uri: "file:///path/to/file.txt", + name: "file.txt", + mimeType: "text/plain" + }; + + const result = ContentBlockSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource_link"); + } + }); + + test("should validate embedded resource content", () => { + const embeddedResource = { + type: "resource", + resource: { + uri: "file:///path/to/file.txt", + mimeType: "text/plain", + text: "File contents" + } + }; + + const result = ContentBlockSchema.safeParse(embeddedResource); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("resource"); + } + }); + }); + + describe("PromptMessage with ContentBlock", () => { + test("should validate prompt message with resource link", () => { + const promptMessage = { + role: "assistant", + content: { + type: "resource_link", + uri: "file:///project/src/main.rs", + name: "main.rs", + description: "Primary application entry point", + mimeType: "text/x-rust" + } + }; + + const result = PromptMessageSchema.safeParse(promptMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content.type).toBe("resource_link"); + } + }); + }); + + describe("CallToolResult with ContentBlock", () => { + test("should validate tool result with resource links", () => { + const toolResult = { + content: [ + { + type: "text", + text: "Found the following files:" + }, + { + type: "resource_link", + uri: "file:///project/src/main.rs", + name: "main.rs", + description: "Primary application entry point", + mimeType: "text/x-rust" + }, + { + type: "resource_link", + uri: "file:///project/src/lib.rs", + name: "lib.rs", + description: "Library exports", + mimeType: "text/x-rust" + } + ] + }; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toHaveLength(3); + expect(result.data.content[0].type).toBe("text"); + expect(result.data.content[1].type).toBe("resource_link"); + expect(result.data.content[2].type).toBe("resource_link"); + } + }); + + test("should validate empty content array with default", () => { + const toolResult = {}; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toEqual([]); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 9ece70d4..0d051ede 100644 --- a/src/types.ts +++ b/src/types.ts @@ -735,18 +735,33 @@ export const EmbeddedResourceSchema = z }) .passthrough(); +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + */ +export const ResourceLinkSchema = ResourceSchema.extend({ + type: z.literal("resource_link"), +}); + +/** + * A content block that can be used in prompts and tool results. + */ +export const ContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema, +]); + /** * Describes a message returned as part of a prompt. */ export const PromptMessageSchema = z .object({ role: z.enum(["user", "assistant"]), - content: z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ]), + content: ContentBlockSchema, }) .passthrough(); @@ -890,13 +905,7 @@ export const CallToolResultSchema = ResultSchema.extend({ * If the Tool does not define an outputSchema, this field MUST be present in the result. * For backwards compatibility, this field is always present, but it may be empty. */ - content: z.array( - z.union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - EmbeddedResourceSchema, - ])).default([]), + content: z.array(ContentBlockSchema).default([]), /** * An object containing structured tool output. @@ -1376,6 +1385,8 @@ export type TextContent = Infer; export type ImageContent = Infer; export type AudioContent = Infer; export type EmbeddedResource = Infer; +export type ResourceLink = Infer; +export type ContentBlock = Infer; export type PromptMessage = Infer; export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; From d1c94a2c696c131f65419f2e3d68efe4a18a1d27 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 10:43:31 +0100 Subject: [PATCH 28/45] refactor --- README.md | 26 +- src/examples/client/simpleStreamableHttp.ts | 7 +- src/server/mcp.test.ts | 138 +++++++++ src/server/mcp.ts | 303 ++++++++++---------- src/shared/metadataUtils.ts | 19 +- src/types.ts | 9 +- 6 files changed, 348 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index d13378b5..f9f080b9 100644 --- a/README.md +++ b/README.md @@ -226,14 +226,36 @@ All resources, tools, and prompts support an optional `title` field for better U **Note:** The `register*` methods (`registerTool`, `registerPrompt`, `registerResource`) are the recommended approach for new code. The older methods (`tool`, `prompt`, `resource`) remain available for backwards compatibility. +#### Title Precedence for Tools + +For tools specifically, there are two ways to specify a title: +- `title` field in the tool configuration +- `annotations.title` field (when using the older `tool()` method with annotations) + +The precedence order is: `title` → `annotations.title` → `name` + +```typescript +// Using registerTool (recommended) +server.registerTool("my_tool", { + title: "My Tool", // This title takes precedence + annotations: { + title: "Annotation Title" // This is ignored if title is set + } +}, handler); + +// Using tool with annotations (older API) +server.tool("my_tool", "description", { + title: "Annotation Title" // This is used as title +}, handler); +``` When building clients, use the provided utility to get the appropriate display name: ```typescript import { getDisplayName } from "@modelcontextprotocol/sdk/shared/metadataUtils.js"; -// Falls back to 'name' if 'title' is not provided -const displayName = getDisplayName(tool); // Returns title if available, otherwise name +// Automatically handles the precedence: title → annotations.title → name +const displayName = getDisplayName(tool); ``` ## Running Your Server diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 63d7d60a..efa176df 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,7 +318,12 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - console.log(` - ${getDisplayName(tool)}: ${tool.description}`); + const displayName = getDisplayName(tool); + if (displayName !== tool.name) { + console.log(` - ${tool.name} (${displayName}): ${tool.description}`); + } else { + console.log(` - ${tool.name}: ${tool.description}`); + } } } } catch (error) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 6ef33540..36a8f7b8 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -18,6 +18,7 @@ import { import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; +import { getDisplayName } from "../shared/metadataUtils.js"; describe("McpServer", () => { /*** @@ -3598,3 +3599,140 @@ describe("prompt()", () => { expect(result.resources[0].mimeType).toBe("text/markdown"); }); }); + +describe("Tool title precedence", () => { + test("should follow correct title precedence: title → annotations.title → name", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + // Tool 1: Only name + mcpServer.tool( + "tool_name_only", + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 2: Name and annotations.title + mcpServer.tool( + "tool_with_annotations_title", + "Tool with annotations title", + { + title: "Annotations Title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + "tool_with_title", + { + title: "Regular Title", + description: "Tool with regular title" + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + "tool_with_all_titles", + { + title: "Regular Title Wins", + description: "Tool with all titles", + annotations: { + title: "Annotations Title Should Not Show" + } + }, + async () => ({ + content: [{ type: "text", text: "Response" }], + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + + expect(result.tools).toHaveLength(4); + + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === "tool_name_only"); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe("tool_name_only"); + + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === "tool_with_annotations_title"); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe("Annotations Title"); + expect(getDisplayName(tool2!)).toBe("Annotations Title"); + + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === "tool_with_title"); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe("Regular Title"); + expect(getDisplayName(tool3!)).toBe("Regular Title"); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === "tool_with_all_titles"); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe("Regular Title Wins"); + expect(tool4!.annotations?.title).toBe("Annotations Title Should Not Show"); + expect(getDisplayName(tool4!)).toBe("Regular Title Wins"); + }); + + test("getDisplayName unit tests for title precedence", () => { + + // Test 1: Only name + expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); + + // Test 2: Name and title - title wins + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" + })).toBe("Tool Title"); + + // Test 3: Name and annotations.title - annotations.title wins + expect(getDisplayName({ + name: "tool_name", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 4: All three - title wins (correct precedence) + expect(getDisplayName({ + name: "tool_name", + title: "Regular Title", + annotations: { title: "Annotations Title" } + })).toBe("Regular Title"); + + // Test 5: Empty title should not be used + expect(getDisplayName({ + name: "tool_name", + title: "", + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + + // Test 6: Undefined vs null handling + expect(getDisplayName({ + name: "tool_name", + title: undefined, + annotations: { title: "Annotations Title" } + })).toBe("Annotations Title"); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7c77add1..3c1c00d9 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -22,6 +22,7 @@ import { CompleteResult, PromptReference, ResourceTemplateReference, + BaseMetadata, Resource, ListResourcesResult, ListResourceTemplatesRequestSchema, @@ -128,7 +129,7 @@ export class McpServer { if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( - tool.outputSchema, + tool.outputSchema, { strictUnions: true } ) as Tool["outputSchema"]; } @@ -586,27 +587,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -616,27 +603,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + undefined, + uriOrTemplate, metadata, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -651,11 +624,7 @@ export class McpServer { registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata & { - title?: string; - description?: string; - mimeType?: string; - }, + config: ResourceMetadata, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { if (typeof uriOrTemplate === "string") { @@ -663,27 +632,13 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - const registeredResource: RegisteredResource = { + const registeredResource = this._createRegisteredResource( name, - metadata: config, - readCallback: readCallback as ReadResourceCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource - } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResources[uriOrTemplate] = registeredResource; + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -693,27 +648,13 @@ export class McpServer { throw new Error(`Resource template ${name} is already registered`); } - const registeredResourceTemplate: RegisteredResourceTemplate = { - resourceTemplate: uriOrTemplate, - metadata: config, - readCallback: readCallback as ReadResourceTemplateCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate - } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() - }, - }; - this._registeredResourceTemplates[name] = registeredResourceTemplate; + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); this.setResourceRequestHandlers(); this.sendResourceListChanged(); @@ -721,8 +662,108 @@ export class McpServer { } } + private _createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceCallback + ): RegisteredResource { + const registeredResource: RegisteredResource = { + name, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: (updates) => { + if (typeof updates.uri !== "undefined" && updates.uri !== uri) { + delete this._registeredResources[uri] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.title !== "undefined") registeredResource.title = updates.title + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResources[uri] = registeredResource; + return registeredResource; + } + + private _createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate { + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: template, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.title !== "undefined") registeredResourceTemplate.title = updates.title + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + return registeredResourceTemplate; + } + + private _createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: PromptArgsRawShape | undefined, + callback: PromptCallback + ): RegisteredPrompt { + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + callback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, + }; + this._registeredPrompts[name] = registeredPrompt; + return registeredPrompt; + } + private _createRegisteredTool( name: string, + title: string | undefined, description: string | undefined, inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, @@ -730,6 +771,7 @@ export class McpServer { callback: ToolCallback ): RegisteredTool { const registeredTool: RegisteredTool = { + title, description, inputSchema: inputSchema === undefined ? undefined : z.object(inputSchema), @@ -868,7 +910,7 @@ export class McpServer { } const callback = rest[0] as ToolCallback; - return this._createRegisteredTool(name, description, inputSchema, outputSchema, annotations, callback) + return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, callback) } /** @@ -891,21 +933,15 @@ export class McpServer { const { title, description, inputSchema, outputSchema, annotations } = config; - const registeredTool = this._createRegisteredTool( + return this._createRegisteredTool( name, + title, description, inputSchema, outputSchema, annotations, cb as ToolCallback ); - - // Set title if provided - if (title !== undefined) { - registeredTool.title = title; - } - - return registeredTool; } /** @@ -953,28 +989,13 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, + undefined, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1000,29 +1021,13 @@ export class McpServer { const { title, description, arguments: argsSchema } = config; - const registeredPrompt: RegisteredPrompt = { + const registeredPrompt = this._createRegisteredPrompt( + name, title, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), - callback: cb as PromptCallback, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt - } - if (typeof updates.title !== "undefined") registeredPrompt.title = updates.title - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() - }, - }; - this._registeredPrompts[name] = registeredPrompt; + argsSchema, + cb as PromptCallback + ); this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1155,16 +1160,16 @@ export type RegisteredTool = { enable(): void; disable(): void; update( - updates: { - name?: string | null, + updates: { + name?: string | null, title?: string, - description?: string, - paramsSchema?: InputArgs, - outputSchema?: OutputArgs, - annotations?: ToolAnnotations, - callback?: ToolCallback, - enabled?: boolean - }): void + description?: string, + paramsSchema?: InputArgs, + outputSchema?: OutputArgs, + annotations?: ToolAnnotations, + callback?: ToolCallback, + enabled?: boolean + }): void remove(): void }; @@ -1212,12 +1217,13 @@ export type ReadResourceCallback = ( export type RegisteredResource = { name: string; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void + update(updates: { name?: string, title?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void remove(): void }; @@ -1232,12 +1238,13 @@ export type ReadResourceTemplateCallback = ( export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; + title?: string; metadata?: ResourceMetadata; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + update(updates: { name?: string | null, title?: string, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void remove(): void }; diff --git a/src/shared/metadataUtils.ts b/src/shared/metadataUtils.ts index d581a6b2..410827a5 100644 --- a/src/shared/metadataUtils.ts +++ b/src/shared/metadataUtils.ts @@ -6,11 +6,26 @@ import { BaseMetadata } from "../types.js"; /** * Gets the display name for an object with BaseMetadata. - * Returns the title if available, otherwise falls back to name. + * For tools, the precedence is: title → annotations.title → name + * For other objects: title → name * This implements the spec requirement: "if no title is provided, name should be used for display purposes" */ export function getDisplayName(metadata: BaseMetadata): string { - return metadata.title ?? metadata.name; + // First check for title (not undefined and not empty string) + if (metadata.title !== undefined && metadata.title !== '') { + return metadata.title; + } + + // Then check for annotations.title (only present in Tool objects) + if ('annotations' in metadata) { + const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; + if (metadataWithAnnotations.annotations?.title) { + return metadataWithAnnotations.annotations.title; + } + } + + // Finally fall back to name + return metadata.name; } /** diff --git a/src/types.ts b/src/types.ts index 9ece70d4..4d1d14d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -203,7 +203,14 @@ export const BaseMetadataSchema = z .object({ /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ name: z.string(), - /** Intended for UI and end-user contexts — optimized to be human-readable */ + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ title: z.optional(z.string()), }) .passthrough(); From bce2dbfb83c6936be6ab05598d30b3d4d5e09d82 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 10:55:02 +0100 Subject: [PATCH 29/45] small fixes like argsSchema --- README.md | 13 ++-- src/examples/server/simpleStreamableHttp.ts | 9 ++- src/server/mcp.ts | 4 +- src/server/title.test.ts | 80 ++++++++++----------- 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f9f080b9..5ae9e043 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,7 @@ import { z } from "zod"; // Create an MCP server const server = new McpServer({ name: "demo-server", - version: "1.0.0", - title: "Demo Server" // Optional display name + version: "1.0.0" }); // Add an addition tool @@ -109,9 +108,8 @@ The McpServer is your core interface to the MCP protocol. It handles connection ```typescript const server = new McpServer({ - name: "my-app", // Unique identifier for your server - version: "1.0.0", // Server version - title: "My Application" // Optional display name for UI + name: "my-app", + version: "1.0.0" }); ``` @@ -471,8 +469,7 @@ import { z } from "zod"; const server = new McpServer({ name: "echo-server", - version: "1.0.0", - title: "Echo Server" + version: "1.0.0" }); server.registerResource( @@ -507,7 +504,7 @@ server.registerPrompt( { title: "Echo Prompt", description: "Creates a prompt to process a message", - arguments: { message: z.string() } + argsSchema: { message: z.string() } }, ({ message }) => ({ messages: [{ diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index b66be93d..fca0ff5a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -17,8 +17,7 @@ const useOAuth = process.argv.includes('--oauth'); const getServer = () => { const server = new McpServer({ name: 'simple-streamable-http-server', - version: '1.0.0', - title: 'Simple Streamable HTTP Server', // Display name for UI + version: '1.0.0' }, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting @@ -94,7 +93,7 @@ const getServer = () => { { title: 'Greeting Template', // Display name for UI description: 'A simple greeting prompt template', - arguments: { + argsSchema: { name: z.string().describe('Name to include in greeting'), }, }, @@ -158,10 +157,10 @@ const getServer = () => { server.registerResource( 'greeting-resource', 'https://example.com/greetings/default', - { + { title: 'Default Greeting', // Display name for UI description: 'A simple greeting resource', - mimeType: 'text/plain' + mimeType: 'text/plain' }, async (): Promise => { return { diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3c1c00d9..7394ecbf 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1011,7 +1011,7 @@ export class McpServer { config: { title?: string; description?: string; - arguments?: Args; + argsSchema?: Args; }, cb: PromptCallback ): RegisteredPrompt { @@ -1019,7 +1019,7 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, arguments: argsSchema } = config; + const { title, description, argsSchema } = config; const registeredPrompt = this._createRegisteredPrompt( name, diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 7874fb62..02bafe12 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -7,12 +7,12 @@ import { McpServer } from "./mcp.js"; describe("Title field backwards compatibility", () => { it("should work with tools that have title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register tool with title server.registerTool( "test-tool", @@ -25,12 +25,12 @@ describe("Title field backwards compatibility", () => { }, async () => ({ content: [{ type: "text", text: "result" }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const tools = await client.listTools(); expect(tools.tools).toHaveLength(1); expect(tools.tools[0].name).toBe("test-tool"); @@ -40,12 +40,12 @@ describe("Title field backwards compatibility", () => { it("should work with tools without title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register tool without title server.tool( "test-tool", @@ -53,12 +53,12 @@ describe("Title field backwards compatibility", () => { { value: z.string() }, async () => ({ content: [{ type: "text", text: "result" }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const tools = await client.listTools(); expect(tools.tools).toHaveLength(1); expect(tools.tools[0].name).toBe("test-tool"); @@ -68,12 +68,12 @@ describe("Title field backwards compatibility", () => { it("should work with prompts that have title using update", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register prompt with title by updating after creation const prompt = server.prompt( "test-prompt", @@ -81,12 +81,12 @@ describe("Title field backwards compatibility", () => { async () => ({ messages: [{ role: "user", content: { type: "text", text: "test" } }] }) ); prompt.update({ title: "Test Prompt Display Name" }); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const prompts = await client.listPrompts(); expect(prompts.prompts).toHaveLength(1); expect(prompts.prompts[0].name).toBe("test-prompt"); @@ -96,33 +96,33 @@ describe("Title field backwards compatibility", () => { it("should work with prompts using registerPrompt", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register prompt with title using registerPrompt server.registerPrompt( "test-prompt", { title: "Test Prompt Display Name", description: "A test prompt", - arguments: { input: z.string() } + argsSchema: { input: z.string() } }, - async ({ input }) => ({ - messages: [{ - role: "user", - content: { type: "text", text: `test: ${input}` } - }] + async ({ input }) => ({ + messages: [{ + role: "user", + content: { type: "text", text: `test: ${input}` } + }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const prompts = await client.listPrompts(); expect(prompts.prompts).toHaveLength(1); expect(prompts.prompts[0].name).toBe("test-prompt"); @@ -133,12 +133,12 @@ describe("Title field backwards compatibility", () => { it("should work with resources using registerResource", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new McpServer( { name: "test-server", version: "1.0.0" }, { capabilities: {} } ); - + // Register resource with title using registerResource server.registerResource( "test-resource", @@ -148,19 +148,19 @@ describe("Title field backwards compatibility", () => { description: "A test resource", mimeType: "text/plain" }, - async () => ({ - contents: [{ + async () => ({ + contents: [{ uri: "https://example.com/test", - text: "test content" - }] + text: "test content" + }] }) ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.server.connect(serverTransport); await client.connect(clientTransport); - + const resources = await client.listResources(); expect(resources.resources).toHaveLength(1); expect(resources.resources[0].name).toBe("test-resource"); @@ -171,21 +171,21 @@ describe("Title field backwards compatibility", () => { it("should support serverInfo with title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const server = new Server( - { + { name: "test-server", version: "1.0.0", title: "Test Server Display Name" }, { capabilities: {} } ); - + const client = new Client({ name: "test-client", version: "1.0.0" }); - + await server.connect(serverTransport); await client.connect(clientTransport); - + const serverInfo = client.getServerVersion(); expect(serverInfo?.name).toBe("test-server"); expect(serverInfo?.version).toBe("1.0.0"); From 8719f933571e5ba8e0f5b1dc47fb901cb84d9a48 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:00:20 +0100 Subject: [PATCH 30/45] clean up mcp.ts --- src/server/mcp.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7394ecbf..ac6cf772 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -114,6 +114,7 @@ export class McpServer { ([name, tool]): Tool => { const toolDefinition: Tool = { name, + title: tool.title, description: tool.description, inputSchema: tool.inputSchema ? (zodToJsonSchema(tool.inputSchema, { @@ -123,10 +124,6 @@ export class McpServer { annotations: tool.annotations, }; - if (tool.title !== undefined) { - toolDefinition.title = tool.title; - } - if (tool.outputSchema) { toolDefinition.outputSchema = zodToJsonSchema( tool.outputSchema, @@ -472,19 +469,14 @@ export class McpServer { ([, prompt]) => prompt.enabled, ).map( ([name, prompt]): Prompt => { - const promptDefinition: Prompt = { + return { name, + title: prompt.title, description: prompt.description, arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined, }; - - if (prompt.title !== undefined) { - promptDefinition.title = prompt.title; - } - - return promptDefinition; }, ), }), From 3f939be6c7336af0fd91e3fd4ded20a4d0d02a57 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:21:07 +0100 Subject: [PATCH 31/45] readme fixes and template tests for title --- README.md | 12 +++++------ src/server/title.test.ts | 44 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5ae9e043..c753e3b1 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ server.registerResource( title: "Greeting Resource", // Display name for UI description: "Dynamic greeting generator" }, - async (uri, params) => ({ + async (uri, { name }) => ({ contents: [{ uri: uri.href, - text: `Hello, ${params.name}!` + text: `Hello, ${name}!` }] }) ); @@ -143,10 +143,10 @@ server.registerResource( title: "User Profile", description: "User profile information" }, - async (uri, params) => ({ + async (uri, { userId }) => ({ contents: [{ uri: uri.href, - text: `Profile data for user ${params.userId}` + text: `Profile data for user ${userId}` }] }) ); @@ -479,10 +479,10 @@ server.registerResource( title: "Echo Resource", description: "Echoes back messages as resources" }, - async (uri, params) => ({ + async (uri, { message }) => ({ contents: [{ uri: uri.href, - text: `Resource echo: ${params.message}` + text: `Resource echo: ${message}` }] }) ); diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 02bafe12..480257cd 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -2,7 +2,7 @@ import { Server } from "./index.js"; import { Client } from "../client/index.js"; import { InMemoryTransport } from "../inMemory.js"; import { z } from "zod"; -import { McpServer } from "./mcp.js"; +import { McpServer, ResourceTemplate } from "./mcp.js"; describe("Title field backwards compatibility", () => { it("should work with tools that have title", async () => { @@ -169,6 +169,48 @@ describe("Title field backwards compatibility", () => { expect(resources.resources[0].mimeType).toBe("text/plain"); }); + it("should work with dynamic resources using registerResource", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + // Register dynamic resource with title using registerResource + server.registerResource( + "user-profile", + new ResourceTemplate("users://{userId}/profile", { list: undefined }), + { + title: "User Profile", + description: "User profile information" + }, + async (uri, { userId }, extra) => ({ + contents: [{ + uri: uri.href, + text: `Profile data for user ${userId}` + }] + }) + ); + + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0].name).toBe("user-profile"); + expect(resourceTemplates.resourceTemplates[0].title).toBe("User Profile"); + expect(resourceTemplates.resourceTemplates[0].description).toBe("User profile information"); + expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe("users://{userId}/profile"); + + // Test reading the resource + const readResult = await client.readResource({ uri: "users://123/profile" }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents[0].text).toBe("Profile data for user 123"); + }); + it("should support serverInfo with title", async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); From e78c1b787d663c17d3e122d9372d96998f268be7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:27:34 +0100 Subject: [PATCH 32/45] fix --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c753e3b1..0170b5fe 100644 --- a/README.md +++ b/README.md @@ -530,8 +530,7 @@ import { z } from "zod"; const server = new McpServer({ name: "sqlite-explorer", - version: "1.0.0", - title: "SQLite Explorer" + version: "1.0.0" }); // Helper to create DB connection From 90e45950b3c0100469a340b661731f929c02298e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:36:08 +0100 Subject: [PATCH 33/45] show name and title in example --- src/examples/client/simpleStreamableHttp.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index efa176df..e7232fe0 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -318,12 +318,7 @@ async function listTools(): Promise { console.log(' No tools available'); } else { for (const tool of toolsResult.tools) { - const displayName = getDisplayName(tool); - if (displayName !== tool.name) { - console.log(` - ${tool.name} (${displayName}): ${tool.description}`); - } else { - console.log(` - ${tool.name}: ${tool.description}`); - } + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); } } } catch (error) { @@ -386,7 +381,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num try { console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`); console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`); - + const request: CallToolRequest = { method: 'tools/call', params: { @@ -399,7 +394,7 @@ async function runNotificationsToolWithResumability(interval: number, count: num notificationsToolLastEventId = event; console.log(`Updated resumption token: ${event}`); }; - + const result = await client.request(request, CallToolResultSchema, { resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate @@ -435,7 +430,7 @@ async function listPrompts(): Promise { console.log(' No prompts available'); } else { for (const prompt of promptsResult.prompts) { - console.log(` - ${getDisplayName(prompt)}: ${prompt.description}`); + console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`); } } } catch (error) { @@ -486,7 +481,7 @@ async function listResources(): Promise { console.log(' No resources available'); } else { for (const resource of resourcesResult.resources) { - console.log(` - ${getDisplayName(resource)}: ${resource.uri}`); + console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`); } } } catch (error) { From f1a07a940a13b79da8d2ac3b13ba7f2590de65d5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 11:45:35 +0100 Subject: [PATCH 34/45] ci fix --- src/server/title.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 480257cd..3f64570b 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -185,7 +185,7 @@ describe("Title field backwards compatibility", () => { title: "User Profile", description: "User profile information" }, - async (uri, { userId }, extra) => ({ + async (uri, { userId }, _extra) => ({ contents: [{ uri: uri.href, text: `Profile data for user ${userId}` From 97519d3e32472410b04d8e20a4102f778c5d82aa Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:21 +0100 Subject: [PATCH 35/45] add description to resourse link --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0cf2b85e..ff6c482f 100644 --- a/README.md +++ b/README.md @@ -206,16 +206,18 @@ server.registerTool( { type: "text", text: `Found files matching "${pattern}":` }, // ResourceLinks let tools return references without file content { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/README.md", name: "README.md", - mimeType: "text/markdown" + mimeType: "text/markdown", + description: 'A README file' }, { - type: "resource_link" as const, + type: "resource_link", uri: "file:///project/src/index.ts", name: "index.ts", - mimeType: "text/typescript" + mimeType: "text/typescript", + description: 'An index file' } ] }) From 1e5f0e16ba4801d7c5cfa8227b2becdff2ec6571 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 12:08:59 +0100 Subject: [PATCH 36/45] whitespaces --- src/types.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/types.test.ts b/src/types.test.ts index ca49867a..d163f03d 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,5 +1,5 @@ -import { - LATEST_PROTOCOL_VERSION, +import { + LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS, ResourceLinkSchema, ContentBlockSchema, @@ -28,7 +28,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -48,7 +48,7 @@ describe("Types", () => { mimeType: "text/plain", _meta: { custom: "metadata" } }; - + const result = ResourceLinkSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -65,7 +65,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt", name: "file.txt" }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -76,7 +76,7 @@ describe("Types", () => { uri: "file:///path/to/file.txt" // missing name }; - + const result = ResourceLinkSchema.safeParse(invalidResourceLink); expect(result.success).toBe(false); }); @@ -88,7 +88,7 @@ describe("Types", () => { type: "text", text: "Hello, world!" }; - + const result = ContentBlockSchema.safeParse(textContent); expect(result.success).toBe(true); if (result.success) { @@ -102,7 +102,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "image/png" }; - + const result = ContentBlockSchema.safeParse(imageContent); expect(result.success).toBe(true); if (result.success) { @@ -116,7 +116,7 @@ describe("Types", () => { data: "aGVsbG8=", // base64 encoded "hello" mimeType: "audio/mp3" }; - + const result = ContentBlockSchema.safeParse(audioContent); expect(result.success).toBe(true); if (result.success) { @@ -131,7 +131,7 @@ describe("Types", () => { name: "file.txt", mimeType: "text/plain" }; - + const result = ContentBlockSchema.safeParse(resourceLink); expect(result.success).toBe(true); if (result.success) { @@ -148,7 +148,7 @@ describe("Types", () => { text: "File contents" } }; - + const result = ContentBlockSchema.safeParse(embeddedResource); expect(result.success).toBe(true); if (result.success) { @@ -169,7 +169,7 @@ describe("Types", () => { mimeType: "text/x-rust" } }; - + const result = PromptMessageSchema.safeParse(promptMessage); expect(result.success).toBe(true); if (result.success) { @@ -202,7 +202,7 @@ describe("Types", () => { } ] }; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) { @@ -215,7 +215,7 @@ describe("Types", () => { test("should validate empty content array with default", () => { const toolResult = {}; - + const result = CallToolResultSchema.safeParse(toolResult); expect(result.success).toBe(true); if (result.success) { From c90d6c9853ab64cf94c2d3d016f615ae4376fa89 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:11:10 +0100 Subject: [PATCH 37/45] add context with arguments to completable --- src/server/completable.ts | 3 + src/server/mcp.test.ts | 271 +++++++++++++++++++++++++++++++++++--- src/server/mcp.ts | 7 +- src/types.test.ts | 90 ++++++++++++- src/types.ts | 8 ++ 5 files changed, 357 insertions(+), 22 deletions(-) diff --git a/src/server/completable.ts b/src/server/completable.ts index 3b5bc164..652eaf72 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -15,6 +15,9 @@ export enum McpZodTypeKind { export type CompleteCallback = ( value: T["_input"], + context?: { + arguments?: Record; + }, ) => T["_input"][] | Promise; export interface CompletableDef diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 36a8f7b8..23600db4 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3521,12 +3521,12 @@ describe("prompt()", () => { ); expect(result.resources).toHaveLength(2); - + // Resource 1 should have its own metadata expect(result.resources[0].name).toBe("Resource 1"); expect(result.resources[0].description).toBe("Individual resource description"); expect(result.resources[0].mimeType).toBe("text/plain"); - + // Resource 2 should inherit template metadata expect(result.resources[1].name).toBe("Resource 2"); expect(result.resources[1].description).toBe("Template description"); @@ -3592,7 +3592,7 @@ describe("prompt()", () => { ); expect(result.resources).toHaveLength(1); - + // All fields should be from the individual resource, not the template expect(result.resources[0].name).toBe("Overridden Name"); expect(result.resources[0].description).toBe("Overridden description"); @@ -3698,41 +3698,274 @@ describe("Tool title precedence", () => { }); test("getDisplayName unit tests for title precedence", () => { - + // Test 1: Only name expect(getDisplayName({ name: "tool_name" })).toBe("tool_name"); - + // Test 2: Name and title - title wins - expect(getDisplayName({ - name: "tool_name", - title: "Tool Title" + expect(getDisplayName({ + name: "tool_name", + title: "Tool Title" })).toBe("Tool Title"); - + // Test 3: Name and annotations.title - annotations.title wins - expect(getDisplayName({ + expect(getDisplayName({ name: "tool_name", annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); - + // Test 4: All three - title wins (correct precedence) - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: "Regular Title", annotations: { title: "Annotations Title" } })).toBe("Regular Title"); - + // Test 5: Empty title should not be used - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: "", annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); - + // Test 6: Undefined vs null handling - expect(getDisplayName({ - name: "tool_name", + expect(getDisplayName({ + name: "tool_name", title: undefined, annotations: { title: "Annotations Title" } })).toBe("Annotations Title"); }); + + test("should support resource template completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.resource( + "test", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } else if (context?.arguments?.["owner"] === "org2") { + return ["repo1", "repo2", "repo3"].filter(r => r.startsWith(value)); + } + return []; + }, + }, + }), + async () => ({ + contents: [ + { + uri: "github://repos/test/test", + text: "Test content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with microsoft owner + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "p", + }, + context: { + arguments: { + owner: "org1", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["project1", "project2", "project3"]); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "r", + }, + context: { + arguments: { + owner: "org2", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["repo1", "repo2", "repo3"]); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/resource", + uri: "github://repos/{owner}/{repo}", + }, + argument: { + name: "repo", + value: "t", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test("should support prompt argument completion with resolved context", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.prompt( + "test-prompt", + { + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["category"] === "developers") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["category"] === "managers") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Hello ${name}`, + }, + }, + ], + }), + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + // Test with developers category + const result1 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "A", + }, + context: { + arguments: { + category: "developers", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result1.completion.values).toEqual(["Alice"]); + + // Test with managers category + const result2 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "D", + }, + context: { + arguments: { + category: "managers", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result2.completion.values).toEqual(["David"]); + + // Test with no resolved context + const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual(["Guest"]); + }); }); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index ac6cf772..3d9673da 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -293,7 +293,7 @@ export class McpServer { } const def: CompletableDef = field._def; - const suggestions = await def.complete(request.params.argument.value); + const suggestions = await def.complete(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -324,7 +324,7 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value); + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -1068,6 +1068,9 @@ export class McpServer { */ export type CompleteResourceTemplateCallback = ( value: string, + context?: { + arguments?: Record; + }, ) => string[] | Promise; /** diff --git a/src/types.test.ts b/src/types.test.ts index d163f03d..bc109110 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -4,7 +4,8 @@ import { ResourceLinkSchema, ContentBlockSchema, PromptMessageSchema, - CallToolResultSchema + CallToolResultSchema, + CompleteRequestSchema } from "./types.js"; describe("Types", () => { @@ -223,4 +224,91 @@ describe("Types", () => { } }); }); + + describe("CompleteRequest", () => { + test("should validate a CompleteRequest without resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "greeting" }, + argument: { name: "name", value: "A" } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe("completion/complete"); + expect(result.data.params.ref.type).toBe("ref/prompt"); + expect(result.data.params.context).toBeUndefined(); + } + }); + + test("should validate a CompleteRequest with resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "github://repos/{owner}/{repo}" }, + argument: { name: "repo", value: "t" }, + context: { + arguments: { + "{owner}": "microsoft" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{owner}": "microsoft" + }); + } + }); + + test("should validate a CompleteRequest with empty resolved field", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/prompt", name: "test" }, + argument: { name: "arg", value: "" }, + context: { + arguments: {} + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({}); + } + }); + + test("should validate a CompleteRequest with multiple resolved variables", () => { + const request = { + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "api://v1/{tenant}/{resource}/{id}" }, + argument: { name: "id", value: "123" }, + context: { + arguments: { + "{tenant}": "acme-corp", + "{resource}": "users" + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + "{tenant}": "acme-corp", + "{resource}": "users" + }); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 8e8b7d33..1bc22591 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1169,6 +1169,14 @@ export const CompleteRequestSchema = RequestSchema.extend({ value: z.string(), }) .passthrough(), + context: z.optional( + z.object({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: z.optional(z.record(z.string(), z.string())), + }) + ), }), }); From 42c3967d8b04f2a95550102967f65763719fceb5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:23:17 +0100 Subject: [PATCH 38/45] add to readme --- README.md | 111 ++++++++++++++++++++++++++++++++++++++++- src/server/mcp.test.ts | 28 +++++++---- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ff6c482f..63acfb06 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,33 @@ server.registerResource( }] }) ); + +// Resource with context-aware completion +server.registerResource( + "repository", + new ResourceTemplate("github://repos/{owner}/{repo}", { + list: undefined, + complete: { + // Provide intelligent completions based on previously resolved parameters + repo: (value, context) => { + if (context?.arguments?.["owner"] === "org1") { + return ["project1", "project2", "project3"].filter(r => r.startsWith(value)); + } + return ["default-repo"].filter(r => r.startsWith(value)); + } + } + }), + { + title: "GitHub Repository", + description: "Repository information" + }, + async (uri, { owner, repo }) => ({ + contents: [{ + uri: uri.href, + text: `Repository: ${owner}/${repo}` + }] + }) +); ``` ### Tools @@ -233,12 +260,14 @@ Tools can return `ResourceLink` objects to reference resources without embedding Prompts are reusable templates that help LLMs interact with your server effectively: ```typescript +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + server.registerPrompt( "review-code", { title: "Code Review", description: "Review code for best practices and potential issues", - arguments: { code: z.string() } + argsSchema: { code: z.string() } }, ({ code }) => ({ messages: [{ @@ -250,6 +279,35 @@ server.registerPrompt( }] }) ); + +// Prompt with context-aware completion +server.registerPrompt( + "team-greeting", + { + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + // Completable arguments can use context for intelligent suggestions + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["department"] === "engineering") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["department"] === "sales") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }) + } + }, + ({ name }) => ({ + messages: [{ + role: "assistant", + content: { + type: "text", + text: `Hello ${name}, welcome to the team!` + } + }] + }) +); ``` ### Display Names and Metadata @@ -637,6 +695,57 @@ server.registerTool( ## Advanced Usage +### Context-Aware Completions + +MCP supports intelligent completions that can use previously resolved values as context. This is useful for creating dependent parameter completions where later parameters depend on earlier ones: + +```typescript +import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + +// For resource templates +server.registerResource( + "database-query", + new ResourceTemplate("db://{database}/{table}/{query}", { + list: undefined, + complete: { + // Table completions depend on the selected database + table: (value, context) => { + const database = context?.arguments?.["database"]; + if (database === "users_db") { + return ["profiles", "sessions", "preferences"].filter(t => t.startsWith(value)); + } else if (database === "products_db") { + return ["items", "categories", "inventory"].filter(t => t.startsWith(value)); + } + return []; + } + } + }), + metadata, + handler +); + +// For prompts with completable arguments +server.registerPrompt( + "api-request", + { + argsSchema: { + endpoint: z.string(), + // Method completions can be context-aware + method: completable(z.string(), (value, context) => { + const endpoint = context?.arguments?.["endpoint"]; + if (endpoint?.includes("/readonly/")) { + return ["GET"].filter(m => m.startsWith(value.toUpperCase())); + } + return ["GET", "POST", "PUT", "DELETE"].filter(m => m.startsWith(value.toUpperCase())); + }) + } + }, + handler +); +``` + +The context object contains an `arguments` field with previously resolved parameter values, allowing you to provide more intelligent and contextual completions. + ### Dynamic Servers If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 23600db4..685987bf 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3747,7 +3747,7 @@ describe("Tool title precedence", () => { version: "1.0", }); - mcpServer.resource( + mcpServer.registerResource( "test", new ResourceTemplate("github://repos/{owner}/{repo}", { list: undefined, @@ -3762,6 +3762,10 @@ describe("Tool title precedence", () => { }, }, }), + { + title: "GitHub Repository", + description: "Repository information" + }, async () => ({ contents: [ { @@ -3865,17 +3869,21 @@ describe("Tool title precedence", () => { version: "1.0", }); - mcpServer.prompt( + mcpServer.registerPrompt( "test-prompt", { - name: completable(z.string(), (value, context) => { - if (context?.arguments?.["category"] === "developers") { - return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); - } else if (context?.arguments?.["category"] === "managers") { - return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); - } - return ["Guest"].filter(n => n.startsWith(value)); - }), + title: "Team Greeting", + description: "Generate a greeting for team members", + argsSchema: { + name: completable(z.string(), (value, context) => { + if (context?.arguments?.["category"] === "developers") { + return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); + } else if (context?.arguments?.["category"] === "managers") { + return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } + return ["Guest"].filter(n => n.startsWith(value)); + }), + } }, async ({ name }) => ({ messages: [ From 0e86a6d139e0a3da564101cb751f53d3c3fb00fe Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 14:36:35 +0100 Subject: [PATCH 39/45] improve readme --- README.md | 68 ++++++++---------------------------------- src/server/mcp.test.ts | 51 ++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 63acfb06..08b711c2 100644 --- a/README.md +++ b/README.md @@ -287,23 +287,30 @@ server.registerPrompt( title: "Team Greeting", description: "Generate a greeting for team members", argsSchema: { - // Completable arguments can use context for intelligent suggestions + department: completable(z.string(), (value) => { + // Department suggestions + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), name: completable(z.string(), (value, context) => { - if (context?.arguments?.["department"] === "engineering") { + // Name suggestions based on selected department + const department = context?.arguments?.["department"]; + if (department === "engineering") { return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); - } else if (context?.arguments?.["department"] === "sales") { + } else if (department === "sales") { return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); } return ["Guest"].filter(n => n.startsWith(value)); }) } }, - ({ name }) => ({ + ({ department, name }) => ({ messages: [{ role: "assistant", content: { type: "text", - text: `Hello ${name}, welcome to the team!` + text: `Hello ${name}, welcome to the ${department} team!` } }] }) @@ -695,57 +702,6 @@ server.registerTool( ## Advanced Usage -### Context-Aware Completions - -MCP supports intelligent completions that can use previously resolved values as context. This is useful for creating dependent parameter completions where later parameters depend on earlier ones: - -```typescript -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; - -// For resource templates -server.registerResource( - "database-query", - new ResourceTemplate("db://{database}/{table}/{query}", { - list: undefined, - complete: { - // Table completions depend on the selected database - table: (value, context) => { - const database = context?.arguments?.["database"]; - if (database === "users_db") { - return ["profiles", "sessions", "preferences"].filter(t => t.startsWith(value)); - } else if (database === "products_db") { - return ["items", "categories", "inventory"].filter(t => t.startsWith(value)); - } - return []; - } - } - }), - metadata, - handler -); - -// For prompts with completable arguments -server.registerPrompt( - "api-request", - { - argsSchema: { - endpoint: z.string(), - // Method completions can be context-aware - method: completable(z.string(), (value, context) => { - const endpoint = context?.arguments?.["endpoint"]; - if (endpoint?.includes("/readonly/")) { - return ["GET"].filter(m => m.startsWith(value.toUpperCase())); - } - return ["GET", "POST", "PUT", "DELETE"].filter(m => m.startsWith(value.toUpperCase())); - }) - } - }, - handler -); -``` - -The context object contains an `arguments` field with previously resolved parameter values, allowing you to provide more intelligent and contextual completions. - ### Dynamic Servers If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notifications: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 685987bf..15be3d98 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -3875,23 +3875,29 @@ describe("Tool title precedence", () => { title: "Team Greeting", description: "Generate a greeting for team members", argsSchema: { + department: completable(z.string(), (value) => { + return ["engineering", "sales", "marketing", "support"].filter(d => d.startsWith(value)); + }), name: completable(z.string(), (value, context) => { - if (context?.arguments?.["category"] === "developers") { + const department = context?.arguments?.["department"]; + if (department === "engineering") { return ["Alice", "Bob", "Charlie"].filter(n => n.startsWith(value)); - } else if (context?.arguments?.["category"] === "managers") { + } else if (department === "sales") { return ["David", "Eve", "Frank"].filter(n => n.startsWith(value)); + } else if (department === "marketing") { + return ["Grace", "Henry", "Iris"].filter(n => n.startsWith(value)); } return ["Guest"].filter(n => n.startsWith(value)); }), } }, - async ({ name }) => ({ + async ({ department, name }) => ({ messages: [ { role: "assistant", content: { type: "text", - text: `Hello ${name}`, + text: `Hello ${name}, welcome to the ${department} team!`, }, }, ], @@ -3906,7 +3912,7 @@ describe("Tool title precedence", () => { mcpServer.server.connect(serverTransport), ]); - // Test with developers category + // Test with engineering department const result1 = await client.request( { method: "completion/complete", @@ -3921,7 +3927,7 @@ describe("Tool title precedence", () => { }, context: { arguments: { - category: "developers", + department: "engineering", }, }, }, @@ -3931,7 +3937,7 @@ describe("Tool title precedence", () => { expect(result1.completion.values).toEqual(["Alice"]); - // Test with managers category + // Test with sales department const result2 = await client.request( { method: "completion/complete", @@ -3946,7 +3952,7 @@ describe("Tool title precedence", () => { }, context: { arguments: { - category: "managers", + department: "sales", }, }, }, @@ -3956,8 +3962,33 @@ describe("Tool title precedence", () => { expect(result2.completion.values).toEqual(["David"]); - // Test with no resolved context + // Test with marketing department const result3 = await client.request( + { + method: "completion/complete", + params: { + ref: { + type: "ref/prompt", + name: "test-prompt", + }, + argument: { + name: "name", + value: "G", + }, + context: { + arguments: { + department: "marketing", + }, + }, + }, + }, + CompleteResultSchema, + ); + + expect(result3.completion.values).toEqual(["Grace"]); + + // Test with no resolved context + const result4 = await client.request( { method: "completion/complete", params: { @@ -3974,6 +4005,6 @@ describe("Tool title precedence", () => { CompleteResultSchema, ); - expect(result3.completion.values).toEqual(["Guest"]); + expect(result4.completion.values).toEqual(["Guest"]); }); }); From c0c78ad412c36403ec70fae28f767dd0a756eaa5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 15:04:23 +0100 Subject: [PATCH 40/45] add how to use Completions to the readme --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 08b711c2..8d319f67 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,32 @@ server.registerPrompt( ); ``` +### Completions + +MCP supports argument completions to help users fill in prompt arguments and resource template parameters. See the examples above for [resource completions](#resources) and [prompt completions](#prompts). + +#### Client Usage + +```typescript +// Request completions for any argument +const result = await client.complete({ + ref: { + type: "ref/prompt", // or "ref/resource" + name: "example" // or uri: "template://..." + }, + argument: { + name: "argumentName", + value: "partial" // What the user has typed so far + }, + context: { // Optional: Include previously resolved arguments + arguments: { + previousArg: "value" + } + } +}); +console.log(result.completion.values); // ["suggestion1", "suggestion2", ...] +``` + ### Display Names and Metadata All resources, tools, and prompts support an optional `title` field for better UI presentation. The `title` is used as a display name, while `name` remains the unique identifier. @@ -870,6 +896,18 @@ const result = await client.callTool({ arg1: "value" } }); + +// Request completions +const completions = await client.complete({ + ref: { + type: "ref/prompt", + name: "example-prompt" + }, + argument: { + name: "arg1", + value: "partial" + } +}); ``` ### Proxy Authorization Requests Upstream From 4549250a4301ac2f44b60923e38ef2db1f8b7051 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 15:04:33 +0100 Subject: [PATCH 41/45] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d319f67..a73d8a38 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ const result = await client.complete({ } } }); -console.log(result.completion.values); // ["suggestion1", "suggestion2", ...] + ``` ### Display Names and Metadata From 5b875aa542337f253d9a31b166f068ed519a724b Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 15:19:21 +0100 Subject: [PATCH 42/45] clean up --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index a73d8a38..272eeb12 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [Resources](#resources) - [Tools](#tools) - [Prompts](#prompts) + - [Completions](#completions) - [Running Your Server](#running-your-server) - [stdio](#stdio) - [Streamable HTTP](#streamable-http) @@ -897,17 +898,6 @@ const result = await client.callTool({ } }); -// Request completions -const completions = await client.complete({ - ref: { - type: "ref/prompt", - name: "example-prompt" - }, - argument: { - name: "arg1", - value: "partial" - } -}); ``` ### Proxy Authorization Requests Upstream From 3df732545f0f0d38c13181b6db1381e0eafff41d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Jun 2025 16:06:13 +0100 Subject: [PATCH 43/45] Add missing _meta fields to match spec requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added optional _meta field to ResourceContentsSchema, PromptSchema, and EmbeddedResourceSchema to align with spec change PR #710. These were the only schemas missing the _meta field that were required by the specification update. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/types.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/types.ts b/src/types.ts index 1bc22591..1c248d77 100644 --- a/src/types.ts +++ b/src/types.ts @@ -435,6 +435,10 @@ export const ResourceContentsSchema = z * The MIME type of this resource, if known. */ mimeType: z.optional(z.string()), + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -637,6 +641,10 @@ export const PromptSchema = BaseMetadataSchema.extend({ * A list of arguments to use for templating the prompt. */ arguments: z.optional(z.array(PromptArgumentSchema)), + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }); /** @@ -739,6 +747,10 @@ export const EmbeddedResourceSchema = z .object({ type: z.literal("resource"), resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), + /** + * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + */ + _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); From 4af2d4fe9fbcd983262e248cc18e2102887d8cce Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 16 Jun 2025 19:14:52 +0100 Subject: [PATCH 44/45] separate code block in README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ab5a898..9fa07de3 100644 --- a/README.md +++ b/README.md @@ -923,10 +923,11 @@ server.tool( }; } ); +``` -// Client-side: Handle elicitation requests - +Client-side: Handle elicitation requests +```typescript // This is a placeholder - implement based on your UI framework async function getInputFromUser(message: string, schema: any): Promise<{ action: "accept" | "decline" | "cancel"; From 784d94437764dc0f8e46ccebab62337202b31a48 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 16 Jun 2025 14:52:33 -0400 Subject: [PATCH 45/45] update _meta comments --- src/types.ts | 54 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/types.ts b/src/types.ts index 1c248d77..762708f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,9 @@ export const RequestSchema = z.object({ const BaseNotificationParamsSchema = z .object({ /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -58,8 +59,9 @@ export const NotificationSchema = z.object({ export const ResultSchema = z .object({ /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -436,7 +438,8 @@ export const ResourceContentsSchema = z */ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -478,8 +481,9 @@ export const ResourceSchema = BaseMetadataSchema.extend({ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -505,8 +509,9 @@ export const ResourceTemplateSchema = BaseMetadataSchema.extend({ mimeType: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -642,7 +647,8 @@ export const PromptSchema = BaseMetadataSchema.extend({ */ arguments: z.optional(z.array(PromptArgumentSchema)), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }); @@ -690,8 +696,9 @@ export const TextContentSchema = z text: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -712,8 +719,9 @@ export const ImageContentSchema = z mimeType: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -734,8 +742,9 @@ export const AudioContentSchema = z mimeType: z.string(), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough(); @@ -748,7 +757,8 @@ export const EmbeddedResourceSchema = z type: z.literal("resource"), resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ _meta: z.optional(z.object({}).passthrough()), }) @@ -895,8 +905,9 @@ export const ToolSchema = BaseMetadataSchema.extend({ annotations: z.optional(ToolAnnotationsSchema), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }); @@ -1230,8 +1241,9 @@ export const RootSchema = z name: z.optional(z.string()), /** - * Reserved by MCP for protocol-level metadata; implementations must not make assumptions about its contents. - */ + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ _meta: z.optional(z.object({}).passthrough()), }) .passthrough();