Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/injectable-sdk-logger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/server': minor
---

Add a `logger` protocol option so client and server SDK diagnostics can be routed through user-provided logging.
Comment thread
mattzcarey marked this conversation as resolved.
Comment thread
mattzcarey marked this conversation as resolved.
20 changes: 20 additions & 0 deletions docs/servers/logging-progress-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +74 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The docs section "Route SDK diagnostics" and the changeset make an unqualified claim that SDK warnings can be routed through the new logger option, but two client-package [mcp-sdk] diagnostics in the OAuth flow (packages/client/src/client/auth.ts:136 and auth.ts:1174) still go straight to console.warn and cannot reach the injected logger, since the auth helpers take an OAuthClientProvider rather than ProtocolOptions. Non-blocking (the auth code is pre-existing and untouched by this PR): either soften the docs/changeset wording to note the OAuth-flow exception, or thread a logger into the auth helpers in a follow-up, mirroring how the createMcpHandler warning was resolved here.

Extended reasoning...

What the gap is

This PR introduces the injectable SdkLogger (ProtocolOptions.logger) and migrates the SDK's raw console diagnostics to it — the client capability console.debug lines, the x-mcp-header exclusion warning at client.ts:2444, the tool-name warnings, the zod-fallback warning (tools and prompts paths), the SEP-2243 registration warning in mcp.ts, and (after an earlier review comment) the createMcpHandler responseMode: 'json' warning via a new CreateMcpHandlerOptions.logger option. Two client-package [mcp-sdk] diagnostics survive on the raw console with no route to the injected logger:

  • packages/client/src/client/auth.ts:136 — the SEP-2352 "stored OAuth credential has no issuer stamp" warning in discardIfIssuerMismatch
  • packages/client/src/client/auth.ts:1174 — the SEP-2352 "OAuthClientProvider does not implement saveDiscoveryState()" warning

Why they cannot be routed today

The OAuth helpers are standalone functions that take an OAuthClientProvider (plus an options bag), not ProtocolOptions, and the transports that invoke auth() are constructed independently of the Client. So a user configuring new Client(info, { logger }) — the exact scenario from #1262 (capture or silence SDK console output) — still gets these two lines on the raw console with no supported redirect. There is structurally no way for the new ProtocolOptions.logger to reach these call sites without an API change to the auth helpers.

Why this is worth noting on this PR

The tension is not with the auth code (which is pre-existing and untouched by this diff) but with the prose this diff adds:

  • The changeset says "client and server SDK diagnostics can be routed through user-provided logging" — a blanket claim.
  • The new docs section (docs/servers/logging-progress-cancellation.md, "Route SDK diagnostics") says "Pass logger to route local warnings and debug messages from SDK internals."

A repo-wide grep of packages/client/src shows these two auth.ts sites are the only remaining raw-console SDK diagnostics after this PR (the other console usage is the intentional, user-overridable withLogging middleware default at middleware.ts:181); packages/server/src has zero left. So the claim is almost fully backed — these are the last two exceptions.

Concrete walk-through

  1. A user adopts the new option: const client = new Client({ name: 'c', version: '1.0.0' }, { logger: { warn: myWarn } }).
  2. They connect over Streamable HTTP with an authProvider that predates SEP-2352 (no saveDiscoveryState()/discoveryState(), or credentials stored without an issuer stamp).
  3. During the OAuth flow, discardIfIssuerMismatch runs at auth.ts:136 and/or the discovery-state check runs at auth.ts:1174 — both call console.warn('[mcp-sdk] ...') directly.
  4. myWarn is never called; the warning lands on the raw console despite the configured logger — the exact behavior the docs sentence says the option prevents.

Impact and how to fix

Impact is bounded: a couple of warn lines during OAuth flows, only for legacy or incomplete providers; nothing breaks functionally. Two acceptable resolutions: (a) soften the changeset/docs wording to note that OAuth-flow diagnostics are not yet routable (e.g. "most local warnings and debug messages"), or (b) thread an optional logger into the auth helpers in a follow-up, mirroring how the createMcpHandler warning was resolved in this PR by adding CreateMcpHandlerOptions.logger. Routing a logger through the provider-based auth flow is an API-surface decision rather than a mechanical replacement, so it should not block this merge — hence a nit, not a blocker.


```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
Expand Down Expand Up @@ -201,5 +220,6 @@ 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).
- `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 7 additions & 5 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,7 @@ export class Client extends Protocol<ClientContext> {
async listPrompts(params?: ListPromptsRequest['params'], options?: CacheableRequestOptions): Promise<ListPromptsResult> {
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) {
Expand Down Expand Up @@ -1540,7 +1540,7 @@ export class Client extends Protocol<ClientContext> {
async listResources(params?: ListResourcesRequest['params'], options?: CacheableRequestOptions): Promise<ListResourcesResult> {
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) {
Expand Down Expand Up @@ -1571,7 +1571,7 @@ export class Client extends Protocol<ClientContext> {
): Promise<ListResourceTemplatesResult> {
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: [] };
Expand Down Expand Up @@ -2395,7 +2395,7 @@ export class Client extends Protocol<ClientContext> {
async listTools(params?: ListToolsRequest['params'], options?: CacheableRequestOptions): Promise<ListToolsResult> {
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: [] };
}
Comment thread
mattzcarey marked this conversation as resolved.
if (params?.cursor !== undefined) {
Expand Down Expand Up @@ -2442,7 +2442,9 @@ export class Client extends Protocol<ClientContext> {
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;
Expand Down
97 changes: 97 additions & 0 deletions packages/client/test/client/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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();
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();
});

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();
});
});
3 changes: 3 additions & 0 deletions packages/core-internal/src/exports/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (NOT the Protocol class itself or mergeCapabilities)
export type {
BaseContext,
Expand Down
1 change: 1 addition & 0 deletions packages/core-internal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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';
Expand Down
13 changes: 13 additions & 0 deletions packages/core-internal/src/shared/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* 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;
info?: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
error?: (...args: unknown[]) => void;
};
11 changes: 11 additions & 0 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { bootstrapOutboundCodec } from '../wire/bootstrap';
import type { LiftedWireMaterial, WireCodec } from '../wire/codec';
import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod, MODERN_WIRE_REVISION } from '../wire/codec';
import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine';
import type { SdkLogger } from './logger';
import type { Transport, TransportSendOptions } from './transport';

/**
Expand Down Expand Up @@ -87,6 +88,14 @@ 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;
};
Comment thread
claude[bot] marked this conversation as resolved.

/**
Expand Down Expand Up @@ -580,6 +589,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
}

protected _supportedProtocolVersions: string[];
protected _logger: SdkLogger;

/**
* Callback for when the connection is closed for any reason.
Expand Down Expand Up @@ -607,6 +617,7 @@ export abstract class Protocol<ContextT extends BaseContext> {

constructor(private _options?: ProtocolOptions) {
this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
this._logger = _options?.logger ?? console;

this.setNotificationHandler('notifications/cancelled', notification => {
this._oncancel(notification);
Expand Down
20 changes: 12 additions & 8 deletions packages/core-internal/src/shared/toolNameValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.'
);
}
Expand All @@ -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;
}
15 changes: 11 additions & 4 deletions packages/core-internal/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import * as z from 'zod/v4';

import type { SdkLogger } from '../shared/logger';

// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)

export interface StandardTypedV1<Input = unknown, Output = Input> {
Expand Down Expand Up @@ -175,7 +177,11 @@ let warnedZodFallback = false;
* `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<string, unknown> {
export function standardSchemaToJsonSchema(
schema: StandardJSONSchemaV1,
io: 'input' | 'output' = 'input',
logger: SdkLogger = console
): Record<string, unknown> {
const std = schema['~standard'];
let result: Record<string, unknown>;
if (std.jsonSchema) {
Comment thread
claude[bot] marked this conversation as resolved.
Expand All @@ -193,7 +199,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.'
);
Expand Down Expand Up @@ -278,9 +284,10 @@ export async function validateStandardSchema<T extends StandardSchemaV1>(
// Prompt argument extraction

export function promptArgumentsFromStandardSchema(
schema: StandardJSONSchemaV1
schema: StandardJSONSchemaV1,
logger: SdkLogger = console
): Array<{ name: string; description?: string; required: boolean }> {
const jsonSchema = standardSchemaToJsonSchema(schema, 'input');
const jsonSchema = standardSchemaToJsonSchema(schema, 'input', logger);
const properties = (jsonSchema.properties as Record<string, { description?: string }>) || {};
const required = (jsonSchema.required as string[]) || [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@ type SchemaArg = Parameters<typeof standardSchemaToJsonSchema>[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.
const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record<string, unknown>;
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<string, unknown>)?.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)', () => {
Expand Down
Loading
Loading