From 264335636550d7272e5e41b10277244f76741857 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:02:00 +0200 Subject: [PATCH 01/13] feat: add injectable SDK logger --- .changeset/injectable-sdk-logger.md | 7 ++++ packages/client/src/client/client.ts | 8 ++-- packages/client/test/client/logger.test.ts | 39 +++++++++++++++++++ .../core-internal/src/exports/public/index.ts | 3 ++ packages/core-internal/src/shared/logger.ts | 9 +++++ packages/core-internal/src/shared/protocol.ts | 10 +++++ .../src/shared/toolNameValidation.ts | 20 ++++++---- .../core-internal/src/util/standardSchema.ts | 9 ++++- .../util/standardSchema.zodFallback.test.ts | 8 ++-- packages/server/src/server/mcp.ts | 36 +++++++++++------ .../server/test/server/mcp.compat.test.ts | 14 +++++++ 11 files changed, 134 insertions(+), 29 deletions(-) create mode 100644 .changeset/injectable-sdk-logger.md create mode 100644 packages/client/test/client/logger.test.ts create mode 100644 packages/core-internal/src/shared/logger.ts diff --git a/.changeset/injectable-sdk-logger.md b/.changeset/injectable-sdk-logger.md new file mode 100644 index 0000000000..acb2452875 --- /dev/null +++ b/.changeset/injectable-sdk-logger.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': 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/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 096a8660e2..14652ce86c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1628,7 +1628,7 @@ export class Client extends Protocol { async listPrompts(params?: ListPromptsRequest['params'], options?: CacheableRequestOptions): Promise { 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: [] }; } if (params?.cursor !== undefined) { @@ -1668,7 +1668,7 @@ export class Client extends Protocol { async listResources(params?: ListResourcesRequest['params'], options?: CacheableRequestOptions): Promise { 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: [] }; } if (params?.cursor !== undefined) { @@ -1697,7 +1697,7 @@ export class Client extends Protocol { ): Promise { 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: [] }; @@ -2556,7 +2556,7 @@ export class Client extends Protocol { async listTools(params?: ListToolsRequest['params'], options?: CacheableRequestOptions): Promise { 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: [] }; } if (params?.cursor !== undefined) { diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts new file mode 100644 index 0000000000..e6248bc1d3 --- /dev/null +++ b/packages/client/test/client/logger.test.ts @@ -0,0 +1,39 @@ +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +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/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index bf985d4160..598e60a9f6 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -45,6 +45,9 @@ export { isJsonContentType } from '../../shared/mediaType'; // Metadata utilities export { getDisplayName } from '../../shared/metadataUtils'; +// Logging +export type { SdkLogger } from '../../shared/logger'; + // Protocol types and the Protocol base class export type { BaseContext, 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 9d60be8efe..a31aceff4b 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -59,6 +59,7 @@ import { MODERN_WIRE_REVISION } from '../wire/codec'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; +import type { SdkLogger } from './logger'; import type { Transport, TransportSendOptions } from './transport'; /** @@ -95,6 +96,13 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; + + /** + * Logger used by SDK internals for warnings and diagnostic messages. + * + * @default console + */ + logger?: SdkLogger; }; /** @@ -590,6 +598,7 @@ export abstract class Protocol { } protected _supportedProtocolVersions: string[]; + protected _logger: SdkLogger; /** * Callback for when the connection is closed for any reason. @@ -617,6 +626,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 ebaaa57572..eacc937fff 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -8,6 +8,7 @@ import * as z from 'zod/v4'; +import type { SdkLogger } from '../shared/logger'; import type { StringSchema } from '../types/types'; // Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) @@ -180,7 +181,11 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12'; * `io: 'output'` a non-object root is returned as-is; the `"object"` default is * applied only when the root is provably object-shaped. */ -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) { @@ -198,7 +203,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.' ); 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 bfb3b798b0..a52f8f5d63 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -19,6 +19,7 @@ import type { Resource, ResourceTemplateReference, Result, + SdkLogger, ServerContext, StandardSchemaWithJSON, Tool, @@ -123,6 +124,8 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private readonly _logger: SdkLogger; + /** Per-tool JSON-converted `inputSchema`, filled on first use by `toolInputSchemaJson()`. */ private _toolInputSchemaJson: { [name: string]: Record } = {}; @@ -141,7 +144,7 @@ export class McpServer { if (tool.inputSchema === undefined) return EMPTY_OBJECT_JSON_SCHEMA; // A conversion failure returns `undefined` so it surfaces where it always has (`tools/list`). try { - const json = standardSchemaToJsonSchema(tool.inputSchema, 'input'); + const json = standardSchemaToJsonSchema(tool.inputSchema, 'input', this._logger); this._toolInputSchemaJson[name] = json; return json; } catch { @@ -150,6 +153,7 @@ export class McpServer { } constructor(serverInfo: Implementation, options?: McpServerOptions) { + this._logger = options?.logger ?? console; this.server = new Server(serverInfo, options); this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements); @@ -272,7 +276,7 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: tool.inputSchema - ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) + ? (convertListedInputSchema(name, tool.inputSchema, this._logger) as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, icons: tool.icons, @@ -285,7 +289,11 @@ export class McpServer { // `{type:'object',properties:{result:},required:['result']}` toward // 2025-era clients) lives in the 2025 wire codec's `encodeResult('tools/list', …)` // — this handler is era-blind and emits the natural converted schema. - toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + toolDefinition.outputSchema = standardSchemaToJsonSchema( + tool.outputSchema, + 'output', + this._logger + ) as Tool['outputSchema']; } return toolDefinition; @@ -937,19 +945,20 @@ 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; let outputSchemaJson: Record | undefined; + const logger = this._logger; const registeredTool: RegisteredTool = { title, description, inputSchema, outputSchema, get outputSchemaJson() { - return (outputSchemaJson ??= convertOutputSchemaJson(registeredTool.outputSchema)); + return (outputSchemaJson ??= convertOutputSchemaJson(registeredTool.outputSchema, logger)); }, set outputSchemaJson(value) { outputSchemaJson = value; @@ -972,7 +981,7 @@ export class McpServer { // `_toolInputSchemaJson` slot rather than the original. if (updates.name !== undefined && updates.name !== name) { if (typeof updates.name === 'string') { - validateAndWarnToolName(updates.name); + validateAndWarnToolName(updates.name, this._logger); } delete this._registeredTools[name]; delete this._toolInputSchemaJson[name]; @@ -1009,7 +1018,7 @@ export class McpServer { if (updates.outputSchema !== undefined) { registeredTool.outputSchema = updates.outputSchema; - registeredTool.outputSchemaJson = convertOutputSchemaJson(updates.outputSchema); + registeredTool.outputSchemaJson = convertOutputSchemaJson(updates.outputSchema, this._logger); } if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; if (updates.icons !== undefined) registeredTool.icons = updates.icons; @@ -1456,11 +1465,11 @@ const EMPTY_OBJECT_JSON_SCHEMA = { }; /** Converts a tool's `inputSchema` for `tools/list` and warns on an invalid SEP-2243 `x-mcp-header` declaration. */ -function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJSON): Record { - const json = standardSchemaToJsonSchema(inputSchema, 'input'); +function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJSON, logger: SdkLogger): Record { + const json = standardSchemaToJsonSchema(inputSchema, 'input', logger); const scan = scanXMcpHeaderDeclarations(json); if (!scan.valid) { - console.warn( + logger.warn( `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + `conforming Streamable HTTP clients: ${scan.reason}` ); @@ -1474,10 +1483,13 @@ function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJ * `tools/list` emits (and that the 2025 codec's `encodeResult('tools/list', …)` may wrap). A conversion * failure yields `undefined` so the failure surfaces where it always has (`tools/list`). */ -function convertOutputSchemaJson(outputSchema: StandardSchemaWithJSON | undefined): Record | undefined { +function convertOutputSchemaJson( + outputSchema: StandardSchemaWithJSON | undefined, + logger: SdkLogger +): Record | undefined { if (outputSchema === undefined) return undefined; try { - return standardSchemaToJsonSchema(outputSchema, 'output'); + return standardSchemaToJsonSchema(outputSchema, 'output', logger); } catch { return undefined; } diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index ae7a0438b5..a5948bfca2 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' }); From 958ce2a041227a4fcf14b6b4da9a6ddf00a47765 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 15:38:24 +0200 Subject: [PATCH 02/13] fix: route prompt schema warnings through logger --- .../core-internal/src/util/standardSchema.ts | 5 ++- packages/server/src/server/mcp.ts | 2 +- .../server/test/server/mcp.compat.test.ts | 45 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/core-internal/src/util/standardSchema.ts b/packages/core-internal/src/util/standardSchema.ts index eacc937fff..f1faa89ea4 100644 --- a/packages/core-internal/src/util/standardSchema.ts +++ b/packages/core-internal/src/util/standardSchema.ts @@ -358,9 +358,10 @@ export function isLibraryFormatPattern(format: NonNullable { - 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/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index a52f8f5d63..67dbef70ed 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -651,7 +651,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 }; diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index a5948bfca2..f40d795193 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -91,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' }); From b9b5deecffdff723c6e796ecb6d3dcbf45f8ab94 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:50:38 +0200 Subject: [PATCH 03/13] fix: align logger branch with package split --- packages/client/test/client/logger.test.ts | 2 +- packages/core-internal/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts index e6248bc1d3..9b4522dd5f 100644 --- a/packages/client/test/client/logger.test.ts +++ b/packages/client/test/client/logger.test.ts @@ -1,4 +1,4 @@ -import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; import { describe, expect, it, vi } from 'vitest'; import { Client } from '../../src/client/client'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index c93a604fa1..790582248d 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -10,6 +10,7 @@ export * from './shared/inboundClassification'; export * from './shared/inputRequired'; export * from './shared/inputRequiredDriver'; export * from './shared/inputRequiredEngine'; +export * from './shared/logger'; export * from './shared/mcpParamHeaders'; export * from './shared/mediaType'; export * from './shared/metadataUtils'; From e86f82d930a5bc712b9b70a53be2af7503b4d898 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 26 Jun 2026 00:10:56 +0200 Subject: [PATCH 04/13] fix(changeset): bump core-internal for logger --- .changeset/injectable-sdk-logger.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/injectable-sdk-logger.md b/.changeset/injectable-sdk-logger.md index acb2452875..b3c97f08bb 100644 --- a/.changeset/injectable-sdk-logger.md +++ b/.changeset/injectable-sdk-logger.md @@ -1,5 +1,5 @@ --- -'@modelcontextprotocol/core': minor +'@modelcontextprotocol/core-internal': minor '@modelcontextprotocol/client': minor '@modelcontextprotocol/server': minor --- From cb4d6cf87f4855a4e687ad4e26a7f0abd2ed5d4c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 09:42:07 +0100 Subject: [PATCH 05/13] fix(server): guard optional logger warning --- packages/server/src/server/mcp.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 67dbef70ed..78b7a8afcb 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1469,7 +1469,7 @@ function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJ const json = standardSchemaToJsonSchema(inputSchema, 'input', logger); const scan = scanXMcpHeaderDeclarations(json); if (!scan.valid) { - logger.warn( + logger.warn?.( `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + `conforming Streamable HTTP clients: ${scan.reason}` ); @@ -1483,10 +1483,7 @@ function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJ * `tools/list` emits (and that the 2025 codec's `encodeResult('tools/list', …)` may wrap). A conversion * failure yields `undefined` so the failure surfaces where it always has (`tools/list`). */ -function convertOutputSchemaJson( - outputSchema: StandardSchemaWithJSON | undefined, - logger: SdkLogger -): Record | undefined { +function convertOutputSchemaJson(outputSchema: StandardSchemaWithJSON | undefined, logger: SdkLogger): Record | undefined { if (outputSchema === undefined) return undefined; try { return standardSchemaToJsonSchema(outputSchema, 'output', logger); From fe2fc81e9056b22f05b3151e2a0ff751f52b877a Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 10:32:42 +0100 Subject: [PATCH 06/13] fix(client): route tool exclusion warning to logger --- packages/client/src/client/client.ts | 4 +- packages/client/test/client/logger.test.ts | 58 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 14652ce86c..aa7beecb6d 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2603,7 +2603,9 @@ export class Client extends Protocol { const filtered = result.tools.filter(tool => { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) { - console.warn(`[mcp-sdk] excluding tool '${tool.name}' from tools/list: invalid x-mcp-header declaration — ${scan.reason}`); + this._logger.warn?.( + `[mcp-sdk] excluding tool '${tool.name}' from tools/list: invalid x-mcp-header declaration — ${scan.reason}` + ); return false; } return true; diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts index 9b4522dd5f..53e6a69d34 100644 --- a/packages/client/test/client/logger.test.ts +++ b/packages/client/test/client/logger.test.ts @@ -1,7 +1,10 @@ +import type { JSONRPCRequest, Tool } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; import { describe, expect, it, vi } from 'vitest'; import { Client } from '../../src/client/client'; +const MODERN = '2026-07-28'; + describe('Client logger option', () => { it('routes SDK diagnostics to the configured logger', async () => { const debug = vi.fn(); @@ -36,4 +39,59 @@ describe('Client logger option', () => { await clientTransport.close(); await serverTransport.close(); }); + + it('routes invalid x-mcp-header tool exclusion warnings to the configured logger', async () => { + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { logger: { warn }, versionNegotiation: { mode: { pin: MODERN } } } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const invalidTool: Tool = { + name: 'bad-tool', + inputSchema: { type: 'object', properties: { data: { type: 'object', 'x-mcp-header': 'Data' } } } + }; + + serverTransport.onmessage = async message => { + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + + if (request.method === 'server/discover') { + await serverTransport.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + } else if (request.method === 'tools/list') { + await serverTransport.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 60_000, + cacheScope: 'public', + tools: [invalidTool] + } + }); + } + }; + + await Promise.all([client.connect(clientTransport), serverTransport.start()]); + + const result = await client.listTools(); + expect(result.tools).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'bad-tool'")); + expect(consoleWarn).not.toHaveBeenCalled(); + + consoleWarn.mockRestore(); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); }); From fd67eca6e7c6689bba3b7e59e039b17a5dc56d66 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Wed, 1 Jul 2026 10:58:49 +0100 Subject: [PATCH 07/13] fix(server): route handler warnings to logger --- packages/server/src/server/createMcpHandler.ts | 10 ++++++---- .../server/test/server/createMcpHandler.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 4ef836c636..66117200c6 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -35,7 +35,8 @@ import type { InboundLadderRejection, InboundLegacyRoute, InboundModernRoute, - RequestId + RequestId, + SdkLogger } from '@modelcontextprotocol/core-internal'; import { classifyInboundRequest, @@ -168,6 +169,8 @@ export interface CreateMcpHandlerOptions { legacy?: 'stateless' | 'reject'; /** Callback for out-of-band errors and rejected requests (reporting only; it never alters the response). */ onerror?: (error: Error) => void; + /** Logger used for SDK diagnostics emitted by the HTTP handler. Defaults to `console`. */ + logger?: SdkLogger; /** * Response shaping for modern (2026-07-28) request exchanges: * @@ -680,7 +683,7 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown, op * request headers. */ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { - const { legacy, onerror, responseMode } = options; + const { legacy, onerror, responseMode, logger = console } = options; const maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize); // Construction-time guard for JavaScript callers passing a handler as the @@ -714,8 +717,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa onerror: reportError }); if (responseMode === 'json') { - // eslint-disable-next-line no-console - console.warn( + logger.warn?.( "responseMode: 'json' drops mid-call notifications. subscriptions/listen streams are always served over SSE regardless; " + 'other notifications emitted before a result are dropped.' ); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 1069faba2e..354cffb47f 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -854,6 +854,21 @@ describe('createMcpHandler — responseMode', () => { expect(text).toContain('json only'); }); + it("routes the responseMode: 'json' warning to the configured logger", () => { + const { factory } = testFactory(); + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + createMcpHandler(factory, { responseMode: 'json', logger: { warn } }); + } finally { + consoleWarn.mockRestore(); + } + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("responseMode: 'json' drops mid-call notifications")); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + it("responseMode: 'sse' streams even when the handler emits nothing before its result", async () => { const { factory } = testFactory(); const handler = createMcpHandler(factory, { responseMode: 'sse' }); From a40faf2871698c1b4c7eede4591da17f1e720805 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Mon, 6 Jul 2026 19:59:07 +0100 Subject: [PATCH 08/13] docs: explain injectable SDK diagnostics logger --- docs/servers/logging-progress-cancellation.md | 20 +++++++++++++++++++ .../logging-progress-cancellation.examples.ts | 13 ++++++++++++ packages/core-internal/src/shared/logger.ts | 6 +++++- packages/core-internal/src/shared/protocol.ts | 3 ++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index a23aff35b5..4a46e37f61 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -71,6 +71,25 @@ Only the result comes back: `progress` must increase on every notification for the same token; `total` and `message` are optional. +## Route SDK diagnostics + +Pass `logger` to route local warnings and debug messages from SDK internals. On stdio, send every level to stderr so diagnostics never enter the JSON-RPC channel. + +```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#sdkLogger_stderr" +const sdkLogger = { + debug: (...args: unknown[]) => console.error('[debug]', ...args), + info: (...args: unknown[]) => console.error('[info]', ...args), + warn: (...args: unknown[]) => console.error('[warn]', ...args), + error: (...args: unknown[]) => console.error('[error]', ...args) +}; + +const diagnosticServer = new McpServer({ name: 'file-processor', version: '1.0.0' }, { logger: sdkLogger }); +``` + +The same option works on `Client` and low-level `Server`; `createMcpHandler` also accepts it for handler diagnostics. Every method is optional, and an omitted method discards diagnostics at that level. The default is `console`. + +This logger stays local to the process. It does not send MCP `notifications/message`; use `ctx.mcpReq.log` for that deprecated protocol feature. + ## Log to the client ::: warning Deprecated — SEP-2577 @@ -221,6 +240,7 @@ Resolve an identifier against a fixed list, as `fetch-source` does. A tool that - Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`. - `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one. +- The `logger` option routes local SDK diagnostics; it is separate from MCP protocol logging. - `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577). - Declaring `logging` also installs `logging/setLevel`; after `client.setLoggingLevel(level)` the SDK drops messages below that level for the session. - `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O. diff --git a/examples/guides/servers/logging-progress-cancellation.examples.ts b/examples/guides/servers/logging-progress-cancellation.examples.ts index 347d3f1e89..ee73935f94 100644 --- a/examples/guides/servers/logging-progress-cancellation.examples.ts +++ b/examples/guides/servers/logging-progress-cancellation.examples.ts @@ -20,6 +20,19 @@ import * as z from 'zod/v4'; const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } }); //#endregion logging_capability +// "Route SDK diagnostics" — keep local SDK output off the stdio protocol channel. +//#region sdkLogger_stderr +const sdkLogger = { + debug: (...args: unknown[]) => console.error('[debug]', ...args), + info: (...args: unknown[]) => console.error('[info]', ...args), + warn: (...args: unknown[]) => console.error('[warn]', ...args), + error: (...args: unknown[]) => console.error('[error]', ...args) +}; + +const diagnosticServer = new McpServer({ name: 'file-processor', version: '1.0.0' }, { logger: sdkLogger }); +//#endregion sdkLogger_stderr +void diagnosticServer; + // "Report progress from a handler" — the page's lead block. //#region registerTool_progress server.registerTool( diff --git a/packages/core-internal/src/shared/logger.ts b/packages/core-internal/src/shared/logger.ts index 4032a18d08..762c66ed60 100644 --- a/packages/core-internal/src/shared/logger.ts +++ b/packages/core-internal/src/shared/logger.ts @@ -1,5 +1,9 @@ /** - * Logger used by SDK internals for diagnostics. + * Console-compatible sink for local SDK diagnostics. + * + * This is separate from MCP protocol logging (`notifications/message`). Every method is + * optional; diagnostics at an omitted level are discarded. Pass an adapter around a + * structured logger when its methods do not already accept console-style arguments. */ export type SdkLogger = { debug?: (...args: unknown[]) => void; diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index a31aceff4b..693b439ec8 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -98,7 +98,8 @@ export type ProtocolOptions = { debouncedNotificationMethods?: string[]; /** - * Logger used by SDK internals for warnings and diagnostic messages. + * Console-compatible sink for local SDK diagnostics. This does not affect MCP protocol + * logging (`notifications/message`). Omitted methods discard diagnostics at that level. * * @default console */ From 0856ac677253abad589b3591aca673af80ef2648 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 10:51:47 +0000 Subject: [PATCH 09/13] fix(client): route SkillsClient diagnostics to logger --- packages/client/src/client/client.ts | 3 ++ .../client/src/ext/skills/skillsClient.ts | 5 +-- packages/client/test/client/logger.test.ts | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index aa7beecb6d..44117ca126 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -43,6 +43,7 @@ import type { RequestOptions, ResolvedInputRequiredDriverConfig, Result, + SdkLogger, ServerCapabilities, StandardSchemaV1, SubscribeRequest, @@ -438,6 +439,7 @@ const DEFAULT_LIST_MAX_PAGES = 64; // Package-internal (not exported from the package): what the `ext/` clients share with Client. Set in Client's static block. export const clientInternals = {} as { enforceStrictCapabilities(client: Client): boolean; + logger(client: Client): SdkLogger; walkPages( client: Client, method: string, @@ -590,6 +592,7 @@ export class Client extends Protocol { static { clientInternals.enforceStrictCapabilities = client => client._enforceStrictCapabilities; + clientInternals.logger = client => client._logger; clientInternals.walkPages = (client, method, fetchPage, items) => client._walkPages(method, fetchPage, items); clientInternals.readResource = (client, params, options) => client._readResource(params, options); } diff --git a/packages/client/src/ext/skills/skillsClient.ts b/packages/client/src/ext/skills/skillsClient.ts index f1aea033c1..7daaaac287 100644 --- a/packages/client/src/ext/skills/skillsClient.ts +++ b/packages/client/src/ext/skills/skillsClient.ts @@ -180,8 +180,9 @@ export class SkillsClient { async list(params: { cursor?: string } = {}, options?: RequestOptions): Promise { if (!this.supported) { if (clientInternals.enforceStrictCapabilities(this.#client)) throw unsupported(SKILLS_LIST_METHOD); - // eslint-disable-next-line no-console - console.debug(`SkillsClient.list() called but the server does not declare ${SKILLS_EXTENSION_ID} - returning an empty list`); + clientInternals + .logger(this.#client) + .debug?.(`SkillsClient.list() called but the server does not declare ${SKILLS_EXTENSION_ID} - returning an empty list`); return { skills: [], invalid: [] }; } const page = await this.#pages(SKILLS_LIST_METHOD, this.#envelopes().list, {}, params.cursor, options, result => result.skills); diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts index 53e6a69d34..3454dbc21d 100644 --- a/packages/client/test/client/logger.test.ts +++ b/packages/client/test/client/logger.test.ts @@ -2,6 +2,7 @@ import type { JSONRPCRequest, Tool } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; import { describe, expect, it, vi } from 'vitest'; import { Client } from '../../src/client/client'; +import { SkillsClient } from '../../src/ext/skills/index'; const MODERN = '2026-07-28'; @@ -94,4 +95,36 @@ describe('Client logger option', () => { await clientTransport.close(); await serverTransport.close(); }); + + it('routes SkillsClient diagnostics to the Client 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(new SkillsClient(client).list()).resolves.toEqual({ skills: [], invalid: [] }); + expect(debug).toHaveBeenCalledWith(expect.stringContaining('SkillsClient.list() called but the server does not declare')); + expect(consoleDebug).not.toHaveBeenCalled(); + + consoleDebug.mockRestore(); + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); }); From a01f642910857702f5ab9f0de2b41693ca040d48 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 11:58:20 +0000 Subject: [PATCH 10/13] docs: scope logger docs to the classes that use it --- .changeset/injectable-sdk-logger.md | 2 +- docs/servers/logging-progress-cancellation.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/injectable-sdk-logger.md b/.changeset/injectable-sdk-logger.md index b3c97f08bb..fffde9251d 100644 --- a/.changeset/injectable-sdk-logger.md +++ b/.changeset/injectable-sdk-logger.md @@ -4,4 +4,4 @@ '@modelcontextprotocol/server': minor --- -Add a `logger` protocol option so client and server SDK diagnostics can be routed through user-provided logging. +Add a `logger` option to `Client`, `McpServer` and `createMcpHandler` so their SDK diagnostics can be routed through user-provided logging. Omitting it keeps the current `console` output. The OAuth helpers (`auth()` and the built-in providers) still write to `console.warn`. diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index 4a46e37f61..9eab9ff123 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -73,7 +73,7 @@ Only the result comes back: ## Route SDK diagnostics -Pass `logger` to route local warnings and debug messages from SDK internals. On stdio, send every level to stderr so diagnostics never enter the JSON-RPC channel. +Pass `logger` to `McpServer` to route the warnings and debug messages the SDK prints locally. On stdio, send every level to stderr so diagnostics never enter the JSON-RPC channel. ```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#sdkLogger_stderr" const sdkLogger = { @@ -86,7 +86,9 @@ const sdkLogger = { const diagnosticServer = new McpServer({ name: 'file-processor', version: '1.0.0' }, { logger: sdkLogger }); ``` -The same option works on `Client` and low-level `Server`; `createMcpHandler` also accepts it for handler diagnostics. Every method is optional, and an omitted method discards diagnostics at that level. The default is `console`. +`Client` and `createMcpHandler` take the same option. The low-level `Server` accepts it too, but has no diagnostics of its own to send through it. Every method is optional, and an omitted method discards diagnostics at that level. The default is `console`. + +The OAuth helpers in `@modelcontextprotocol/client` (`auth()` and the built-in providers such as `ClientCredentialsProvider`) run outside a `Client`, so their warnings still go to `console.warn`. This logger stays local to the process. It does not send MCP `notifications/message`; use `ctx.mcpReq.log` for that deprecated protocol feature. From 91ac1e507c7687d64c34aa1351208e1ea2222600 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:17:13 +0000 Subject: [PATCH 11/13] refactor: take logger only on Client and McpServer, not low-level Server --- packages/client/src/client/client.ts | 10 ++++++++++ packages/core-internal/src/shared/protocol.ts | 11 ----------- packages/server/src/server/mcp.ts | 10 +++++++++- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 44117ca126..08f4e2d0ed 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -187,6 +187,14 @@ export type ClientOptions = ProtocolOptions & { */ capabilities?: ClientCapabilities; + /** + * Console-compatible sink for local SDK diagnostics. This does not affect MCP protocol + * logging (`notifications/message`). Omitted methods discard diagnostics at that level. + * + * @default console + */ + logger?: SdkLogger; + /** * JSON Schema validator for tool output validation. * @@ -551,6 +559,7 @@ export class Client extends Protocol { private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private readonly _logger: SdkLogger; /** * The response-cache substrate. Owns the backing store, the per-method * eviction-generation counter, the user-supplied/default flag, and the @@ -659,6 +668,7 @@ export class Client extends Protocol { options?: ClientOptions ) { super(options); + this._logger = options?.logger ?? console; this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 693b439ec8..9d60be8efe 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -59,7 +59,6 @@ import { MODERN_WIRE_REVISION } from '../wire/codec'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; -import type { SdkLogger } from './logger'; import type { Transport, TransportSendOptions } from './transport'; /** @@ -96,14 +95,6 @@ export type ProtocolOptions = { * e.g., `['notifications/tools/list_changed']` */ debouncedNotificationMethods?: string[]; - - /** - * Console-compatible sink for local SDK diagnostics. This does not affect MCP protocol - * logging (`notifications/message`). Omitted methods discard diagnostics at that level. - * - * @default console - */ - logger?: SdkLogger; }; /** @@ -599,7 +590,6 @@ export abstract class Protocol { } protected _supportedProtocolVersions: string[]; - protected _logger: SdkLogger; /** * Callback for when the connection is closed for any reason. @@ -627,7 +617,6 @@ 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/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 78b7a8afcb..14a9224da3 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -87,9 +87,17 @@ function resolveMaxToolInputElements(value: number | undefined): number | undefi } /** - * Options for {@linkcode McpServer}: everything {@linkcode ServerOptions} accepts, plus `maxToolInputElements`. + * Options for {@linkcode McpServer}: everything {@linkcode ServerOptions} accepts, plus `maxToolInputElements` and `logger`. */ export type McpServerOptions = ServerOptions & { + /** + * Console-compatible sink for local SDK diagnostics. This does not affect MCP protocol + * logging (`notifications/message`). Omitted methods discard diagnostics at that level. + * + * @default console + */ + logger?: SdkLogger; + /** * Largest combined number of array elements and object members a single `tools/call` `arguments` payload may contain. * A number of at least 1; unset or `Infinity` means no limit. From b502097dc5dc3f0711e3771b299e810bc28f8917 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:17:13 +0000 Subject: [PATCH 12/13] feat(client): route OAuth warnings through the auth provider's logger --- .changeset/injectable-sdk-logger.md | 2 +- docs/clients/machine-auth.md | 2 + docs/clients/oauth.md | 4 ++ docs/servers/logging-progress-cancellation.md | 4 +- packages/client/src/client/auth.ts | 31 ++++++++--- packages/client/src/client/authExtensions.ts | 52 ++++++++++++++++--- packages/client/test/client/auth.test.ts | 27 ++++++++++ .../client/test/client/authExtensions.test.ts | 17 +++++- 8 files changed, 120 insertions(+), 19 deletions(-) diff --git a/.changeset/injectable-sdk-logger.md b/.changeset/injectable-sdk-logger.md index fffde9251d..30a9060b6a 100644 --- a/.changeset/injectable-sdk-logger.md +++ b/.changeset/injectable-sdk-logger.md @@ -4,4 +4,4 @@ '@modelcontextprotocol/server': minor --- -Add a `logger` option to `Client`, `McpServer` and `createMcpHandler` so their SDK diagnostics can be routed through user-provided logging. Omitting it keeps the current `console` output. The OAuth helpers (`auth()` and the built-in providers) still write to `console.warn`. +Add a `logger` option to `Client`, `McpServer`, `createMcpHandler` and `OAuthClientProvider` (including the built-in providers such as `ClientCredentialsProvider`) so SDK diagnostics can be routed through user-provided logging. Omitting it keeps the current `console` output. diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index d3c7d2caa5..f8f41533a4 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -29,6 +29,8 @@ await client.connect(transport); `expectedIssuer` is the `issuer` of the authorization server the credential was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. `PrivateKeyJwtProvider` and `CrossAppAccessProvider` below take it too; omitting it is deprecated, and the credential then goes to whichever authorization server the MCP server advertises. +Every built-in provider also takes `logger`, a console-compatible `SdkLogger` for the warnings the SDK prints about it, such as that deprecation. It defaults to `console`. + ## Bring your own bearer token When something outside the SDK already owns the token — an API key, a gateway, a platform secret store — implement `AuthProvider` with only `token()`. diff --git a/docs/clients/oauth.md b/docs/clients/oauth.md index f03a3e8af0..86207e8807 100644 --- a/docs/clients/oauth.md +++ b/docs/clients/oauth.md @@ -101,6 +101,10 @@ The SDK calls the `save*` methods as the flow produces values and reads them bac When the requested scope includes `offline_access`, the SDK adds `prompt=consent` to the authorization URL. Set `skipOfflineAccessConsentPrompt = true` on the provider to omit it when consent is already granted out of band, such as a Microsoft Entra ID tenant with admin consent and user consent disabled. ::: +::: tip +`auth()` prints warnings while it runs, for example when a stored credential has no `issuer` stamp or a token refresh fails. Set `logger` on the provider to route them somewhere other than `console`. It takes the same console-compatible `SdkLogger` as `Client`; on a stdio client, send every level to stderr. +::: + ## Finish the flow from the callback The authorization server redirects the end user to `redirectUrl` with `code` and `state` in the query. Compare `state`, hand the whole query to `finishAuth`, and reconnect. diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index 9eab9ff123..e665378638 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -86,9 +86,9 @@ const sdkLogger = { const diagnosticServer = new McpServer({ name: 'file-processor', version: '1.0.0' }, { logger: sdkLogger }); ``` -`Client` and `createMcpHandler` take the same option. The low-level `Server` accepts it too, but has no diagnostics of its own to send through it. Every method is optional, and an omitted method discards diagnostics at that level. The default is `console`. +`Client` and `createMcpHandler` take the same option. The low-level `Server` has no diagnostics of its own, so it does not take one. Every method is optional, and an omitted method discards diagnostics at that level. The default is `console`. -The OAuth helpers in `@modelcontextprotocol/client` (`auth()` and the built-in providers such as `ClientCredentialsProvider`) run outside a `Client`, so their warnings still go to `console.warn`. +The client's OAuth helpers run outside a `Client`, so they read the logger from the auth provider instead: set `logger` on your `OAuthClientProvider`, or pass it to a built-in provider such as `ClientCredentialsProvider`. This logger stays local to the process. It does not send MCP `notifications/message`; use `ctx.mcpReq.log` for that deprecated protocol feature. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 69366521da..8fbf39d2e7 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -9,6 +9,7 @@ import type { OAuthMetadata, OAuthProtectedResourceMetadata, OAuthTokens, + SdkLogger, StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; @@ -119,7 +120,7 @@ export interface OAuthClientInformationContext { * as `undefined`, so a credential issued by one authorization server is never reused * at another — the flow falls through to re-registration / re-authorization exactly * as if nothing were stored. An unstamped value (legacy provider or pre-SEP-2352 - * storage) is returned as-is with a `console.warn`; {@linkcode auth} writes the + * storage) is returned as-is with a warning; {@linkcode auth} writes the * stamp back on first use so the window closes after one call. * * {@linkcode auth} stamps every value it writes via `saveTokens` / `saveClientInformation`, @@ -130,18 +131,19 @@ export interface OAuthClientInformationContext { * @param opts.canPersistStamp - When `false`, suppresses the unstamped-credential * warning: the caller cannot back-stamp (no `saveClientInformation`), so the * "binding on first use" claim would be false and would fire on every call. + * @param opts.logger - Where the unstamped-credential warning goes. Defaults to `console`. */ export function discardIfIssuerMismatch( stored: T | null | undefined, issuer: string, - opts?: { canPersistStamp?: boolean } + opts?: { canPersistStamp?: boolean; logger?: SdkLogger } ): T | undefined { // Nothing stored (`null` from a `JSON.parse(storage.getItem(...))`-style getter included). if (!stored) return undefined; // A stamp that is not a string (raw storage) counts as no stamp. if (typeof stored.issuer !== 'string') { if (opts?.canPersistStamp !== false) { - console.warn( + (opts?.logger ?? console).warn?.( `[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or ` + `provider not round-tripping the value). SEP-2352 isolation is inactive for this read; ` + `ensure your provider round-trips the issuer field.` @@ -271,6 +273,15 @@ export interface OAuthClientProvider { */ skipOfflineAccessConsentPrompt?: boolean; + /** + * Console-compatible sink for the warnings {@linkcode auth} prints while it runs: unstamped + * stored credentials, a missing `discoveryState()`, a failed token refresh and discarded + * credentials. Omitted methods discard diagnostics at that level. + * + * @default console + */ + logger?: SdkLogger; + /** * Metadata about this OAuth client. */ @@ -1077,7 +1088,9 @@ function warnCredentialInvalidation(provider: OAuthClientProvider, error: OAuthE // JSON-stringify the AS-supplied values so attacker-supplied control characters cannot // forge log lines — the authorization server is resolved from the resource server's // metadata, and both `code` and `message` are echoed from its response verbatim. - console.warn(`[mcp-sdk] OAuth ${JSON.stringify(error.code)} — ${action}. Cause: ${JSON.stringify(error.message)}`); + (provider.logger ?? console).warn?.( + `[mcp-sdk] OAuth ${JSON.stringify(error.code)} — ${action}. Cause: ${JSON.stringify(error.message)}` + ); } /** @@ -1163,6 +1176,7 @@ async function authInternal( forceReauthorization }: AuthOptions ): Promise { + const logger = provider.logger ?? console; // SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope() // intentionally reads the raw provider.clientMetadata instead. const clientMetadata = resolveClientMetadata(provider); @@ -1273,7 +1287,7 @@ async function authInternal( issuer ); } - console.warn( + logger.warn?.( '[mcp-sdk] OAuthClientProvider does not implement saveDiscoveryState()/discoveryState(); ' + 'the SEP-2352 callback-leg authorization-server binding cannot be checked. ' + 'Implement discoveryState (persist alongside codeVerifier) — see docs/migration/upgrade-to-v2.md §SEP-2352.' @@ -1313,7 +1327,8 @@ async function authInternal( // re-registers exactly as if nothing were stored. const rawClientInfo = await Promise.resolve(provider.clientInformation(infoCtx)); let clientInformation = discardIfIssuerMismatch(rawClientInfo, issuer, { - canPersistStamp: provider.saveClientInformation !== undefined + canPersistStamp: provider.saveClientInformation !== undefined, + logger }); if (clientInformation === undefined && rawClientInfo?.issuer && provider.saveClientInformation === undefined) { // Static-credential provider (no DCR) whose `expectedIssuer` stamp names a different @@ -1398,7 +1413,7 @@ async function authInternal( // SEP-2352: a refresh_token stamped for a different authorization server reads back // as `undefined`, so it is never POSTed to this AS's token endpoint. - let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer); + let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer, { logger }); if (tokens && tokens.issuer === undefined) { // SEP-2352 back-stamp: bind a legacy unstamped token set to the first-resolved AS // so the stamp check is effective from the next call onward. @@ -1439,7 +1454,7 @@ async function authInternal( // redirectToAuthorization() is a no-op — so say why it happened. // JSON-stringify the cause: on the non-OAuth-shaped path it carries the raw // response body, so it is arbitrary attacker-supplied bytes. - console.warn( + logger.warn?.( `[mcp-sdk] Could not refresh OAuth tokens; falling back to a new authorization request. ` + `Cause: ${JSON.stringify(error instanceof Error ? error.message : String(error))}` ); diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index e210786b7e..e59aabdda9 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -5,7 +5,13 @@ * for common machine-to-machine authentication scenarios. */ -import type { FetchLike, OAuthClientMetadata, StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; +import type { + FetchLike, + OAuthClientMetadata, + SdkLogger, + StoredOAuthClientInformation, + StoredOAuthTokens +} from '@modelcontextprotocol/core-internal'; import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth'; @@ -98,9 +104,9 @@ export function createPrivateKeyJwtAuth(options: { * The `issuer` stamp for constructor-supplied client information. Omitting `expectedIssuer` is * deprecated: nothing else says which authorization server such credentials belong to. */ -function checkedExpectedIssuer(expectedIssuer: string | undefined): string | undefined { +function checkedExpectedIssuer(expectedIssuer: string | undefined, logger: SdkLogger): string | undefined { if (expectedIssuer === undefined) { - console.warn( + logger.warn?.( '[mcp-sdk] Omitting `expectedIssuer` is deprecated. Without it, the MCP server decides which authorization ' + "server receives this client's credentials; pass your authorization server's issuer URL as " + '`expectedIssuer` so they are only sent there.' @@ -144,6 +150,12 @@ export interface ClientCredentialsProviderOptions { * the MCP server advertises. */ expectedIssuer?: string; + + /** + * Console-compatible sink for SDK warnings about this provider, including those `auth()` + * prints while using it. Defaults to `console`. + */ + logger?: SdkLogger; } /** @@ -166,6 +178,7 @@ export interface ClientCredentialsProviderOptions { * ``` */ export class ClientCredentialsProvider implements OAuthClientProvider { + readonly logger: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -174,10 +187,11 @@ export class ClientCredentialsProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: ClientCredentialsProviderOptions); constructor(options: ClientCredentialsProviderOptions) { + this.logger = options.logger ?? console; this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) }; this._clientMetadata = { client_name: options.clientName ?? 'client-credentials-client', @@ -282,6 +296,12 @@ export interface PrivateKeyJwtProviderOptions { * Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. */ expectedIssuer?: string; + + /** + * Console-compatible sink for SDK warnings about this provider, including those `auth()` + * prints while using it. Defaults to `console`. + */ + logger?: SdkLogger; } /** @@ -306,6 +326,7 @@ export interface PrivateKeyJwtProviderOptions { * ``` */ export class PrivateKeyJwtProvider implements OAuthClientProvider { + readonly logger: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -315,9 +336,10 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: PrivateKeyJwtProviderOptions); constructor(options: PrivateKeyJwtProviderOptions) { + this.logger = options.logger ?? console; this._clientInfo = { client_id: options.clientId, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) }; this._clientMetadata = { client_name: options.clientName ?? 'private-key-jwt-client', @@ -411,6 +433,12 @@ export interface StaticPrivateKeyJwtProviderOptions { * Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. */ expectedIssuer?: string; + + /** + * Console-compatible sink for SDK warnings about this provider, including those `auth()` + * prints while using it. Defaults to `console`. + */ + logger?: SdkLogger; } /** @@ -421,6 +449,7 @@ export interface StaticPrivateKeyJwtProviderOptions { * uses it directly for authentication. */ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { + readonly logger: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -430,9 +459,10 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: StaticPrivateKeyJwtProviderOptions); constructor(options: StaticPrivateKeyJwtProviderOptions) { + this.logger = options.logger ?? console; this._clientInfo = { client_id: options.clientId, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) }; this._clientMetadata = { client_name: options.clientName ?? 'static-private-key-jwt-client', @@ -587,6 +617,12 @@ export interface CrossAppAccessProviderOptions { * Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}. */ expectedIssuer?: string; + + /** + * Console-compatible sink for SDK warnings about this provider, including those `auth()` + * prints while using it. Defaults to `console`. + */ + logger?: SdkLogger; } /** @@ -630,6 +666,7 @@ export interface CrossAppAccessProviderOptions { * ``` */ export class CrossAppAccessProvider implements OAuthClientProvider { + readonly logger: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -643,10 +680,11 @@ export class CrossAppAccessProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: CrossAppAccessProviderOptions); constructor(options: CrossAppAccessProviderOptions) { + this.logger = options.logger ?? console; this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) }; this._clientMetadata = { client_name: options.clientName ?? 'cross-app-access-client', diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index b3cd42bd78..e9fc488beb 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3280,6 +3280,23 @@ describe('OAuth Authorization', () => { warn.mockRestore(); }); + it("routes auth() warnings to the provider's logger instead of console", async () => { + mockDiscoveryWithTokenEndpoint(() => oauthErrorResponse(OAuthErrorCode.ServerError, 'AS is having a bad day')); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); + (mockProvider.tokens as Mock).mockResolvedValue(storedTokens); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const provider: OAuthClientProvider = Object.create(mockProvider, { logger: { value: { warn } } }); + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp-server' })).resolves.toBe('REDIRECT'); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Could not refresh OAuth tokens')); + expect(consoleWarn).not.toHaveBeenCalled(); + consoleWarn.mockRestore(); + }); + it('warns, and cannot recover, when invalid_grant hits a provider with no invalidateCredentials (#2034)', async () => { let tokenPosts = 0; mockDiscoveryWithTokenEndpoint(() => { @@ -5256,6 +5273,16 @@ describe('OAuth Authorization', () => { warn.mockRestore(); }); + it('discardIfIssuerMismatch: sends the unstamped warning to opts.logger', () => { + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const unstamped: StoredOAuthTokens = { access_token: 'a', token_type: 'Bearer' }; + expect(discardIfIssuerMismatch(unstamped, AS_ONE, { logger: { warn } })).toBe(unstamped); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("no 'issuer' stamp")); + expect(consoleWarn).not.toHaveBeenCalled(); + consoleWarn.mockRestore(); + }); + it('clientInformation stamped for AS-one is discarded at AS-two → re-registers (single-slot blob provider)', async () => { const srv = createMigratingFetch(); const provider = createBlobProvider(); diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index cb4f36a1e1..3547412490 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -1,3 +1,4 @@ +import type { SdkLogger } from '@modelcontextprotocol/core-internal'; import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; import { describe, expect, it, vi } from 'vitest'; @@ -256,7 +257,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { }); describe('expectedIssuer', () => { - const providers: Array<[string, (options: { expectedIssuer?: string }) => OAuthClientProvider]> = [ + const providers: Array<[string, (options: { expectedIssuer?: string; logger?: SdkLogger }) => OAuthClientProvider]> = [ ['ClientCredentialsProvider', o => new ClientCredentialsProvider({ clientId: 'c', clientSecret: 's', ...o })], ['PrivateKeyJwtProvider', o => new PrivateKeyJwtProvider({ clientId: 'c', privateKey: 'k'.repeat(64), algorithm: 'HS256', ...o })], ['StaticPrivateKeyJwtProvider', o => new StaticPrivateKeyJwtProvider({ clientId: 'c', jwtBearerAssertion: 'a', ...o })], @@ -277,6 +278,20 @@ describe('expectedIssuer', () => { warn.mockRestore(); }); + it.each(providers)('%s sends the deprecation message to options.logger instead of console', async (_name, create) => { + const warn = vi.fn(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchFn = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL }); + + const provider = create({ logger: { warn } }); + expect(provider.logger).toEqual({ warn }); + expect(await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn })).toBe('AUTHORIZED'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Omitting `expectedIssuer` is deprecated')); + expect(consoleWarn).not.toHaveBeenCalled(); + consoleWarn.mockRestore(); + }); + it.each([null, '', 42, new URL(AUTH_SERVER_URL)])('rejects %j at construction', value => { expect(() => new ClientCredentialsProvider({ clientId: 'c', clientSecret: 's', expectedIssuer: value as string })).toThrow( 'expectedIssuer must be' From d2ff67fd7a57234bb6bfd5f6864fdc743f60b82c Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 27 Oct 2026 12:41:10 +0000 Subject: [PATCH 13/13] fix: guard SDK loggers and narrow SdkLogger to the levels the SDK uses - wrap every stored logger so a null logger falls back to console and a throwing logger can't fail the SDK call that emitted the diagnostic - declare SdkLogger methods with method syntax so loggers with a typed first parameter are accepted, and drop the unused info/error levels - fix the createMcpHandler logger test asserting after mockRestore --- docs/servers/logging-progress-cancellation.md | 6 +-- .../logging-progress-cancellation.examples.ts | 4 +- packages/client/src/client/auth.ts | 7 +-- packages/client/src/client/authExtensions.ts | 25 ++++----- packages/client/src/client/client.ts | 3 +- packages/client/test/client/auth.test.ts | 15 ++++++ packages/client/test/client/logger.test.ts | 54 +++++++++++++++++++ packages/core-internal/src/shared/logger.ts | 35 +++++++++--- .../server/src/server/createMcpHandler.ts | 4 +- packages/server/src/server/mcp.ts | 3 +- .../test/server/createMcpHandler.test.ts | 23 +++++++- .../server/test/server/mcp.compat.test.ts | 33 +++++++++++- 12 files changed, 178 insertions(+), 34 deletions(-) diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index e665378638..89fe0e2acc 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -78,15 +78,13 @@ Pass `logger` to `McpServer` to route the warnings and debug messages the SDK pr ```ts source="../../examples/guides/servers/logging-progress-cancellation.examples.ts#sdkLogger_stderr" const sdkLogger = { debug: (...args: unknown[]) => console.error('[debug]', ...args), - info: (...args: unknown[]) => console.error('[info]', ...args), - warn: (...args: unknown[]) => console.error('[warn]', ...args), - error: (...args: unknown[]) => console.error('[error]', ...args) + warn: (...args: unknown[]) => console.error('[warn]', ...args) }; const diagnosticServer = new McpServer({ name: 'file-processor', version: '1.0.0' }, { logger: sdkLogger }); ``` -`Client` and `createMcpHandler` take the same option. The low-level `Server` has no diagnostics of its own, so it does not take one. Every method is optional, and an omitted method discards diagnostics at that level. The default is `console`. +`Client` and `createMcpHandler` take the same option. The low-level `Server` has no diagnostics of its own, so it does not take one. The SDK logs at two levels, `debug` and `warn`. Both methods are optional, and an omitted one discards diagnostics at that level. The default is `console`, and a logger that throws is ignored. The client's OAuth helpers run outside a `Client`, so they read the logger from the auth provider instead: set `logger` on your `OAuthClientProvider`, or pass it to a built-in provider such as `ClientCredentialsProvider`. diff --git a/examples/guides/servers/logging-progress-cancellation.examples.ts b/examples/guides/servers/logging-progress-cancellation.examples.ts index ee73935f94..49e2e80a34 100644 --- a/examples/guides/servers/logging-progress-cancellation.examples.ts +++ b/examples/guides/servers/logging-progress-cancellation.examples.ts @@ -24,9 +24,7 @@ const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { cap //#region sdkLogger_stderr const sdkLogger = { debug: (...args: unknown[]) => console.error('[debug]', ...args), - info: (...args: unknown[]) => console.error('[info]', ...args), - warn: (...args: unknown[]) => console.error('[warn]', ...args), - error: (...args: unknown[]) => console.error('[error]', ...args) + warn: (...args: unknown[]) => console.error('[warn]', ...args) }; const diagnosticServer = new McpServer({ name: 'file-processor', version: '1.0.0' }, { logger: sdkLogger }); diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 8fbf39d2e7..25e21183b3 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -17,6 +17,7 @@ import { brandedHasInstance, checkResourceAllowed, fetchWithinOrigin, + guardLogger, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, @@ -143,7 +144,7 @@ export function discardIfIssuerMismatch( // A stamp that is not a string (raw storage) counts as no stamp. if (typeof stored.issuer !== 'string') { if (opts?.canPersistStamp !== false) { - (opts?.logger ?? console).warn?.( + guardLogger(opts?.logger).warn?.( `[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or ` + `provider not round-tripping the value). SEP-2352 isolation is inactive for this read; ` + `ensure your provider round-trips the issuer field.` @@ -1088,7 +1089,7 @@ function warnCredentialInvalidation(provider: OAuthClientProvider, error: OAuthE // JSON-stringify the AS-supplied values so attacker-supplied control characters cannot // forge log lines — the authorization server is resolved from the resource server's // metadata, and both `code` and `message` are echoed from its response verbatim. - (provider.logger ?? console).warn?.( + guardLogger(provider.logger).warn?.( `[mcp-sdk] OAuth ${JSON.stringify(error.code)} — ${action}. Cause: ${JSON.stringify(error.message)}` ); } @@ -1176,7 +1177,7 @@ async function authInternal( forceReauthorization }: AuthOptions ): Promise { - const logger = provider.logger ?? console; + const logger = guardLogger(provider.logger); // SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope() // intentionally reads the raw provider.clientMetadata instead. const clientMetadata = resolveClientMetadata(provider); diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index e59aabdda9..3f49f56d1a 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -12,6 +12,7 @@ import type { StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; +import { guardLogger } from '@modelcontextprotocol/core-internal'; import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth'; @@ -178,7 +179,7 @@ export interface ClientCredentialsProviderOptions { * ``` */ export class ClientCredentialsProvider implements OAuthClientProvider { - readonly logger: SdkLogger; + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -187,11 +188,11 @@ export class ClientCredentialsProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: ClientCredentialsProviderOptions); constructor(options: ClientCredentialsProviderOptions) { - this.logger = options.logger ?? console; + this.logger = options.logger; this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'client-credentials-client', @@ -326,7 +327,7 @@ export interface PrivateKeyJwtProviderOptions { * ``` */ export class PrivateKeyJwtProvider implements OAuthClientProvider { - readonly logger: SdkLogger; + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -336,10 +337,10 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: PrivateKeyJwtProviderOptions); constructor(options: PrivateKeyJwtProviderOptions) { - this.logger = options.logger ?? console; + this.logger = options.logger; this._clientInfo = { client_id: options.clientId, - issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'private-key-jwt-client', @@ -449,7 +450,7 @@ export interface StaticPrivateKeyJwtProviderOptions { * uses it directly for authentication. */ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { - readonly logger: SdkLogger; + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -459,10 +460,10 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: StaticPrivateKeyJwtProviderOptions); constructor(options: StaticPrivateKeyJwtProviderOptions) { - this.logger = options.logger ?? console; + this.logger = options.logger; this._clientInfo = { client_id: options.clientId, - issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'static-private-key-jwt-client', @@ -666,7 +667,7 @@ export interface CrossAppAccessProviderOptions { * ``` */ export class CrossAppAccessProvider implements OAuthClientProvider { - readonly logger: SdkLogger; + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -680,11 +681,11 @@ export class CrossAppAccessProvider implements OAuthClientProvider { /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ constructor(options: CrossAppAccessProviderOptions); constructor(options: CrossAppAccessProviderOptions) { - this.logger = options.logger ?? console; + this.logger = options.logger; this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: checkedExpectedIssuer(options.expectedIssuer, this.logger) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'cross-app-access-client', diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 08f4e2d0ed..5728432c5a 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -58,6 +58,7 @@ import { codecForVersion, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + guardLogger, HEADER_MISMATCH_ERROR_CODE, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -668,7 +669,7 @@ export class Client extends Protocol { options?: ClientOptions ) { super(options); - this._logger = options?.logger ?? console; + this._logger = guardLogger(options?.logger); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index e9fc488beb..6ad7065bb3 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3297,6 +3297,21 @@ describe('OAuth Authorization', () => { consoleWarn.mockRestore(); }); + it('auth() still completes when the provider logger throws', async () => { + mockDiscoveryWithTokenEndpoint(() => oauthErrorResponse(OAuthErrorCode.ServerError, 'AS is having a bad day')); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); + (mockProvider.tokens as Mock).mockResolvedValue(storedTokens); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + const warn = vi.fn(() => { + throw new Error('log sink closed'); + }); + const provider: OAuthClientProvider = Object.create(mockProvider, { logger: { value: { warn } } }); + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp-server' })).resolves.toBe('REDIRECT'); + expect(warn).toHaveBeenCalled(); + }); + it('warns, and cannot recover, when invalid_grant hits a provider with no invalidateCredentials (#2034)', async () => { let tokenPosts = 0; mockDiscoveryWithTokenEndpoint(() => { diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts index 3454dbc21d..962083a61d 100644 --- a/packages/client/test/client/logger.test.ts +++ b/packages/client/test/client/logger.test.ts @@ -96,6 +96,60 @@ describe('Client logger option', () => { await serverTransport.close(); }); + it('still returns the filtered tool list when the logger throws', async () => { + const warn = vi.fn(() => { + throw new Error('log sink closed'); + }); + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { logger: { warn }, versionNegotiation: { mode: { pin: MODERN } } } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const invalidTool: Tool = { + name: 'bad-tool', + inputSchema: { type: 'object', properties: { data: { type: 'object', 'x-mcp-header': 'Data' } } } + }; + + serverTransport.onmessage = async message => { + const request = message as JSONRPCRequest; + if (request.id === undefined) return; + + if (request.method === 'server/discover') { + await serverTransport.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + } else if (request.method === 'tools/list') { + await serverTransport.send({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 60_000, + cacheScope: 'public', + tools: [invalidTool] + } + }); + } + }; + + await Promise.all([client.connect(clientTransport), serverTransport.start()]); + + const result = await client.listTools(); + expect(result.tools).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'bad-tool'")); + + await client.close(); + await clientTransport.close(); + await serverTransport.close(); + }); + it('routes SkillsClient diagnostics to the Client logger', async () => { const debug = vi.fn(); const consoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => {}); diff --git a/packages/core-internal/src/shared/logger.ts b/packages/core-internal/src/shared/logger.ts index 762c66ed60..a681e18807 100644 --- a/packages/core-internal/src/shared/logger.ts +++ b/packages/core-internal/src/shared/logger.ts @@ -2,12 +2,35 @@ * Console-compatible sink for local SDK diagnostics. * * This is separate from MCP protocol logging (`notifications/message`). Every method is - * optional; diagnostics at an omitted level are discarded. Pass an adapter around a - * structured logger when its methods do not already accept console-style arguments. + * optional; diagnostics at an omitted level are discarded. `console` fits as-is; pass an + * adapter around a structured logger when its methods do not accept console-style arguments. */ export type SdkLogger = { - debug?: (...args: unknown[]) => void; - info?: (...args: unknown[]) => void; - warn?: (...args: unknown[]) => void; - error?: (...args: unknown[]) => void; + debug?(...args: unknown[]): void; + warn?(...args: unknown[]): void; }; + +/** + * Wraps a user-supplied {@linkcode SdkLogger} so a diagnostic can never change an operation's + * result: a `null` or `undefined` logger falls back to `console`, and anything a logger method + * throws is swallowed. Store the result wherever a `logger` option is accepted. + */ +export function guardLogger(logger: SdkLogger | null | undefined): SdkLogger { + const target = logger ?? console; + return { + debug: (...args) => { + try { + target.debug?.(...args); + } catch { + // A failing diagnostic sink must not fail the SDK call that emitted it. + } + }, + warn: (...args) => { + try { + target.warn?.(...args); + } catch { + // A failing diagnostic sink must not fail the SDK call that emitted it. + } + } + }; +} diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 66117200c6..462ca25e98 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -42,6 +42,7 @@ import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + guardLogger, httpStatusForErrorCode, isJsonContentType, mediaTypeEssence, @@ -683,7 +684,8 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown, op * request headers. */ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { - const { legacy, onerror, responseMode, logger = console } = options; + const { legacy, onerror, responseMode } = options; + const logger = guardLogger(options.logger); const maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize); // Construction-time guard for JavaScript callers passing a handler as the diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 14a9224da3..4bb5be40e1 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -33,6 +33,7 @@ import { assertCompleteRequestResourceTemplate, assertValidCacheHint, attachCacheHintFallback, + guardLogger, isInputRequiredResult, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, @@ -161,7 +162,7 @@ export class McpServer { } constructor(serverInfo: Implementation, options?: McpServerOptions) { - this._logger = options?.logger ?? console; + this._logger = guardLogger(options?.logger); this.server = new Server(serverInfo, options); this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 354cffb47f..6288332dce 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -7,6 +7,7 @@ * faces, the per-request era write + client-identity backfill, notification * routing, the response-mode knob, and close() teardown of the modern leg. */ +import type { SdkLogger } from '@modelcontextprotocol/core-internal'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; @@ -861,12 +862,30 @@ describe('createMcpHandler — responseMode', () => { try { createMcpHandler(factory, { responseMode: 'json', logger: { warn } }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("responseMode: 'json' drops mid-call notifications")); + expect(consoleWarn).not.toHaveBeenCalled(); } finally { consoleWarn.mockRestore(); } + }); + + it('falls back to console for a null logger and ignores a logger that throws', () => { + const { factory } = testFactory(); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + createMcpHandler(factory, { responseMode: 'json', logger: null as unknown as SdkLogger }); + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("responseMode: 'json' drops mid-call notifications")); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("responseMode: 'json' drops mid-call notifications")); - expect(consoleWarn).not.toHaveBeenCalled(); + const throwing: SdkLogger = { + warn: () => { + throw new Error('log sink closed'); + } + }; + expect(() => createMcpHandler(factory, { responseMode: 'json', logger: throwing })).not.toThrow(); + } finally { + consoleWarn.mockRestore(); + } }); it("responseMode: 'sse' streams even when the handler emits nothing before its result", async () => { diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index f40d795193..ca18a46838 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import type { JSONRPCMessage, SdkLogger } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; @@ -21,6 +21,37 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = consoleWarn.mockRestore(); }); + it('ignores a logger that throws', () => { + const server = new McpServer( + { name: 't', version: '1.0.0' }, + { + logger: { + warn: () => { + throw new Error('log sink closed'); + } + } + } + ); + + expect(() => + server.registerTool('bad tool', {}, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }] + })) + ).not.toThrow(); + }); + + it('accepts a logger whose methods take a typed first parameter', () => { + const messages: string[] = []; + const logger: SdkLogger = { warn: (message: string) => messages.push(message) }; + const server = new McpServer({ name: 't', version: '1.0.0' }, { logger }); + + server.registerTool('bad tool', {}, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }] + })); + + expect(messages).toContain('Tool name validation warning for "bad tool":'); + }); + it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => { const server = new McpServer({ name: 't', version: '1.0.0' });