diff --git a/.changeset/injectable-sdk-logger.md b/.changeset/injectable-sdk-logger.md new file mode 100644 index 0000000000..b3c97f08bb --- /dev/null +++ b/.changeset/injectable-sdk-logger.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add a `logger` protocol option so client and server SDK diagnostics can be routed through user-provided logging. diff --git a/docs/client.md b/docs/client.md index c2bb5b05b1..b7e98bd2b8 100644 --- a/docs/client.md +++ b/docs/client.md @@ -388,6 +388,24 @@ const client = new Client( ); ``` +### SDK diagnostics logger + +SDK-internal diagnostics, such as capability-gating debug messages and schema conversion warnings, use the {@linkcode @modelcontextprotocol/client!index.ProtocolOptions.logger | logger} constructor option. It defaults to `console`. The logger is partial: each method is optional, and omitted levels are silently skipped. + +```ts source="../examples/client/src/clientGuide.examples.ts#sdkLogger_basic" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + logger: { + warn: (...args) => console.warn('[mcp-sdk]', ...args), + debug: () => { + // Drop debug diagnostics + } + } + } +); +``` + ### Manual notification handlers For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}: diff --git a/docs/server.md b/docs/server.md index 468bf0cb2a..b2e69ad35c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -346,6 +346,8 @@ server.registerPrompt( > [!WARNING] > MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to stderr logging (STDIO servers) or OpenTelemetry. +This section covers MCP protocol logging sent from your server to the client. SDK-internal diagnostics, such as tool-name validation warnings and schema conversion fallbacks, are separate and use the `logger` constructor option on `Server` and `McpServer`. The option defaults to `console`; each logger method is optional, so omitted levels are skipped. + Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler: diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 99a8383bc8..3327372d70 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -368,6 +368,24 @@ async function listChanged_basic() { return client; } +/** Example: Route SDK diagnostics through an application logger. */ +function sdkLogger_basic() { + //#region sdkLogger_basic + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + logger: { + warn: (...args) => console.warn('[mcp-sdk]', ...args), + debug: () => { + // Drop debug diagnostics + } + } + } + ); + //#endregion sdkLogger_basic + return client; +} + // --------------------------------------------------------------------------- // Handling server-initiated requests // --------------------------------------------------------------------------- @@ -615,6 +633,7 @@ void complete_basic; void notificationHandler_basic; void setLoggingLevel_basic; void listChanged_basic; +void sdkLogger_basic; void capabilities_declaration; void sampling_handler; void elicitation_handler; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8277225d79..25e4186b6b 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -692,7 +692,7 @@ export class Client extends Protocol { async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support prompts - console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); + this._logger.debug?.('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); return { prompts: [] }; } return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options); @@ -723,7 +723,7 @@ export class Client extends Protocol { async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources - console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); + this._logger.debug?.('Client.listResources() called but server does not advertise resources capability - returning empty list'); return { resources: [] }; } return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options); @@ -738,7 +738,7 @@ export class Client extends Protocol { async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support resources - console.debug( + this._logger.debug?.( 'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list' ); return { resourceTemplates: [] }; @@ -887,7 +887,7 @@ export class Client extends Protocol { async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { // Respect capability negotiation: server does not support tools - console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); + this._logger.debug?.('Client.listTools() called but server does not advertise tools capability - returning empty list'); return { tools: [] }; } const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts new file mode 100644 index 0000000000..9b4522dd5f --- /dev/null +++ b/packages/client/test/client/logger.test.ts @@ -0,0 +1,39 @@ +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import { Client } from '../../src/client/client'; + +describe('Client logger option', () => { + it('routes SDK diagnostics to the configured logger', async () => { + const debug = vi.fn(); + const consoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { logger: { debug } }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + serverTransport.onmessage = async message => { + if ('method' in message && 'id' in message && message.method === 'initialize') { + await serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + } + }; + + await Promise.all([client.connect(clientTransport), serverTransport.start()]); + + await expect(client.listTools()).resolves.toEqual({ tools: [] }); + expect(debug).toHaveBeenCalledWith( + 'Client.listTools() called but server does not advertise tools capability - returning empty list' + ); + expect(consoleDebug).not.toHaveBeenCalled(); + + consoleDebug.mockRestore(); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); +}); diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a87..196a367508 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 88d4942a74..4c798ac7e9 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -40,6 +40,9 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; +// Logging +export type { SdkLogger } from '../../shared/logger'; + // Protocol types (NOT the Protocol class itself or mergeCapabilities) export type { BaseContext, diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index fb89b0383d..95d3c77c3a 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -2,6 +2,7 @@ export * from './auth/errors'; export * from './errors/sdkErrors'; export * from './shared/auth'; export * from './shared/authUtils'; +export * from './shared/logger'; export * from './shared/metadataUtils'; export * from './shared/protocol'; export * from './shared/stdio'; diff --git a/packages/core-internal/src/shared/logger.ts b/packages/core-internal/src/shared/logger.ts new file mode 100644 index 0000000000..4032a18d08 --- /dev/null +++ b/packages/core-internal/src/shared/logger.ts @@ -0,0 +1,9 @@ +/** + * Logger used by SDK internals for diagnostics. + */ +export type SdkLogger = { + debug?: (...args: unknown[]) => void; + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; +}; diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 3b7efec2a4..6efa458a78 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -44,6 +44,7 @@ import { } from '../types/index'; import type { StandardSchemaV1 } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; +import type { SdkLogger } from './logger'; import type { Transport, TransportSendOptions } from './transport'; /** @@ -78,6 +79,13 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; + + /** + * Logger used by SDK internals for diagnostics. + * + * @default console + */ + logger?: SdkLogger; }; /** @@ -292,6 +300,7 @@ export abstract class Protocol { private _pendingDebouncedNotifications = new Set(); protected _supportedProtocolVersions: string[]; + protected _logger: SdkLogger; /** * Callback for when the connection is closed for any reason. @@ -319,6 +328,7 @@ export abstract class Protocol { constructor(private _options?: ProtocolOptions) { this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._logger = _options?.logger ?? console; this.setNotificationHandler('notifications/cancelled', notification => { this._oncancel(notification); diff --git a/packages/core-internal/src/shared/toolNameValidation.ts b/packages/core-internal/src/shared/toolNameValidation.ts index 41bc44953d..6ff8ef298d 100644 --- a/packages/core-internal/src/shared/toolNameValidation.ts +++ b/packages/core-internal/src/shared/toolNameValidation.ts @@ -10,6 +10,8 @@ * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986 | SEP-986: Specify Format for Tool Names} */ +import type { SdkLogger } from './logger'; + /** * Regular expression for valid tool names according to SEP-986 specification */ @@ -86,16 +88,17 @@ export function validateToolName(name: string): { * Issues warnings for non-conforming tool names * @param name - The tool name that triggered the warnings * @param warnings - Array of warning messages + * @param logger - Logger to emit warnings through */ -export function issueToolNameWarning(name: string, warnings: string[]): void { +export function issueToolNameWarning(name: string, warnings: string[], logger: SdkLogger = console): void { if (warnings.length > 0) { - console.warn(`Tool name validation warning for "${name}":`); + logger.warn?.(`Tool name validation warning for "${name}":`); for (const warning of warnings) { - console.warn(` - ${warning}`); + logger.warn?.(` - ${warning}`); } - console.warn('Tool registration will proceed, but this may cause compatibility issues.'); - console.warn('Consider updating the tool name to conform to the MCP tool naming standard.'); - console.warn( + logger.warn?.('Tool registration will proceed, but this may cause compatibility issues.'); + logger.warn?.('Consider updating the tool name to conform to the MCP tool naming standard.'); + logger.warn?.( 'See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details.' ); } @@ -104,13 +107,14 @@ export function issueToolNameWarning(name: string, warnings: string[]): void { /** * Validates a tool name and issues warnings for non-conforming names * @param name - The tool name to validate + * @param logger - Logger to emit warnings through * @returns `true` if the name is valid, `false` otherwise */ -export function validateAndWarnToolName(name: string): boolean { +export function validateAndWarnToolName(name: string, logger?: SdkLogger): boolean { const result = validateToolName(name); // Always issue warnings for any validation issues (both invalid names and warnings) - issueToolNameWarning(name, result.warnings); + issueToolNameWarning(name, result.warnings, logger); return result.isValid; } diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index b938885de0..2aff834fe2 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -8,6 +8,8 @@ import * as z from 'zod/v4'; +import type { SdkLogger } from '../shared/logger'; + // Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) export interface StandardTypedV1 { @@ -174,7 +176,11 @@ let warnedZodFallback = false; * Throws if the schema has an explicit non-object `type` (e.g. `z.string()`), * since that cannot satisfy the MCP spec. */ -export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { +export function standardSchemaToJsonSchema( + schema: StandardJSONSchemaV1, + io: 'input' | 'output' = 'input', + logger: SdkLogger = console +): Record { const std = schema['~standard']; let result: Record; if (std.jsonSchema) { @@ -192,7 +198,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in } if (!warnedZodFallback) { warnedZodFallback = true; - console.warn( + logger.warn?.( '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' ); @@ -237,9 +243,10 @@ export async function validateStandardSchema( // Prompt argument extraction export function promptArgumentsFromStandardSchema( - schema: StandardJSONSchemaV1 + schema: StandardJSONSchemaV1, + logger: SdkLogger = console ): Array<{ name: string; description?: string; required: boolean }> { - const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); + const jsonSchema = standardSchemaToJsonSchema(schema, 'input', logger); const properties = (jsonSchema.properties as Record) || {}; const required = (jsonSchema.required as string[]) || []; diff --git a/packages/core-internal/test/util/standardSchema.zodFallback.test.ts b/packages/core-internal/test/util/standardSchema.zodFallback.test.ts index f8862b08a3..7bb7b0f867 100644 --- a/packages/core-internal/test/util/standardSchema.zodFallback.test.ts +++ b/packages/core-internal/test/util/standardSchema.zodFallback.test.ts @@ -6,7 +6,8 @@ type SchemaArg = Parameters[0]; describe('standardSchemaToJsonSchema — zod fallback paths', () => { it('falls back to z.toJSONSchema for zod 4.0–4.1 (vendor=zod, no ~standard.jsonSchema, has _zod)', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const real = z.object({ a: z.string() }); // Simulate zod 4.0–4.1: shadow `~standard` on the real instance with `jsonSchema` removed. // Keeps the rest of the zod 4 object (including `_zod`) intact so z.toJSONSchema can introspect it. @@ -14,12 +15,13 @@ describe('standardSchemaToJsonSchema — zod fallback paths', () => { void _drop; Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); - const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg, 'input', { warn }); expect(result.type).toBe('object'); expect((result.properties as unknown as Record)?.a).toBeDefined(); expect(warn).toHaveBeenCalledOnce(); expect(warn.mock.calls[0]?.[0]).toContain('zod 4.2.0'); - warn.mockRestore(); + expect(consoleWarn).not.toHaveBeenCalled(); + consoleWarn.mockRestore(); }); it('throws a clear error for zod 3 (vendor=zod, no ~standard.jsonSchema, no _zod)', () => { diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index be18a7c7cd..5cd8e6d12d 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -17,6 +17,7 @@ import type { Resource, ResourceTemplateReference, Result, + SdkLogger, ServerContext, StandardSchemaWithJSON, Tool, @@ -68,8 +69,10 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private readonly _logger: SdkLogger; constructor(serverInfo: Implementation, options?: ServerOptions) { + this._logger = options?.logger ?? console; this.server = new Server(serverInfo, options); // Per the MCP spec, a server that declares a primitive capability MUST respond to its @@ -141,7 +144,7 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: tool.inputSchema - ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + ? (standardSchemaToJsonSchema(tool.inputSchema, 'input', this._logger) as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, icons: tool.icons, @@ -150,7 +153,11 @@ export class McpServer { }; if (tool.outputSchema) { - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + toolDefinition.outputSchema = standardSchemaToJsonSchema( + tool.outputSchema, + 'output', + this._logger + ) as Tool['outputSchema']; } return toolDefinition; @@ -458,7 +465,7 @@ export class McpServer { name, title: prompt.title, description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema, this._logger) : undefined, icons: prompt.icons, _meta: prompt._meta }; @@ -709,7 +716,7 @@ export class McpServer { handler: AnyToolHandler ): RegisteredTool { // Validate tool name according to SEP specification - validateAndWarnToolName(name); + validateAndWarnToolName(name, this._logger); // Track current handler for executor regeneration let currentHandler = handler; @@ -732,7 +739,7 @@ export class McpServer { update: updates => { if (updates.name !== undefined && updates.name !== name) { if (typeof updates.name === 'string') { - validateAndWarnToolName(updates.name); + validateAndWarnToolName(updates.name, this._logger); } delete this._registeredTools[name]; if (updates.name) this._registeredTools[updates.name] = registeredTool; diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 6c87e25f12..829d725e36 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -7,6 +7,20 @@ import type { InferRawShape } from '../../src/server/mcp'; import { completable } from '../../src/server/completable'; describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { + it('routes tool-name warnings to the configured logger', () => { + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 't', version: '1.0.0' }, { logger: { warn } }); + + server.registerTool('bad tool', {}, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }] + })); + + expect(warn).toHaveBeenCalledWith('Tool name validation warning for "bad tool":'); + expect(consoleWarn).not.toHaveBeenCalled(); + consoleWarn.mockRestore(); + }); + it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => { const server = new McpServer({ name: 't', version: '1.0.0' }); @@ -77,6 +91,51 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); }); + it('routes prompt argsSchema conversion warnings to the configured logger', async () => { + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 't', version: '1.0.0' }, { logger: { warn } }); + + const argsSchema = z.object({ topic: z.string() }); + const { jsonSchema: _drop, ...stdNoJson } = argsSchema['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(argsSchema, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + server.registerPrompt('p', { argsSchema }, async ({ topic }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: topic } }] + })); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + try { + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, method: 'prompts/list' } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('zod 4.2.0')); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + await server.close(); + } + }); + it('callback receives validated, typed args end-to-end via tools/call', async () => { const server = new McpServer({ name: 't', version: '1.0.0' });