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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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': 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.

Check failure on line 7 in .changeset/injectable-sdk-logger.md

View check run for this annotation

Claude / Claude Code Review

Changeset bumps @modelcontextprotocol/core instead of the modified @modelcontextprotocol/core-internal package

The changeset declares a minor bump for `@modelcontextprotocol/core`, but that package (the schemas-only public package under `packages/core`) is untouched by this PR and does not expose `ProtocolOptions`, `SdkLogger`, or any logger surface — the package actually modified is `@modelcontextprotocol/core-internal`. As-is, the next Version Packages run will publish a misleading minor bump and CHANGELOG entry on `@modelcontextprotocol/core` for a feature it does not contain, while `core-internal` ge
Comment thread
mattzcarey marked this conversation as resolved.
18 changes: 18 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,24 @@ const client = new Client(
);
```

### SDK diagnostics logger

SDK-internal diagnostics, such as capability-gating debug messages and schema conversion warnings, use the {@linkcode @modelcontextprotocol/client!index.ProtocolOptions.logger | logger} constructor option. It defaults to `console`. The logger is partial: each method is optional, and omitted levels are silently skipped.

```ts source="../examples/client/src/clientGuide.examples.ts#sdkLogger_basic"
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
logger: {
warn: (...args) => console.warn('[mcp-sdk]', ...args),
debug: () => {
// Drop debug diagnostics
}
}
}
);
```

### Manual notification handlers

For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}:
Expand Down
2 changes: 2 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ server.registerPrompt(
> [!WARNING]
> MCP logging is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). Migrate to stderr logging (STDIO servers) or OpenTelemetry.

This section covers MCP protocol logging sent from your server to the client. SDK-internal diagnostics, such as tool-name validation warnings and schema conversion fallbacks, are separate and use the `logger` constructor option on `Server` and `McpServer`. The option defaults to `console`; each logger method is optional, so omitted levels are skipped.

Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification).

Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler:
Expand Down
19 changes: 19 additions & 0 deletions examples/client/src/clientGuide.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,24 @@ async function listChanged_basic() {
return client;
}

/** Example: Route SDK diagnostics through an application logger. */
function sdkLogger_basic() {
//#region sdkLogger_basic
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
logger: {
warn: (...args) => console.warn('[mcp-sdk]', ...args),
debug: () => {
// Drop debug diagnostics
}
}
}
);
//#endregion sdkLogger_basic
return client;
}

// ---------------------------------------------------------------------------
// Handling server-initiated requests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -615,6 +633,7 @@ void complete_basic;
void notificationHandler_basic;
void setLoggingLevel_basic;
void listChanged_basic;
void sdkLogger_basic;
void capabilities_declaration;
void sampling_handler;
void elicitation_handler;
Expand Down
8 changes: 4 additions & 4 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,7 @@ export class Client extends Protocol<ClientContext> {
async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) {
if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) {
// Respect capability negotiation: server does not support prompts
console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list');
this._logger.debug?.('Client.listPrompts() called but server does not advertise prompts capability - returning empty list');
return { prompts: [] };
}
return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options);
Expand Down Expand Up @@ -723,7 +723,7 @@ export class Client extends Protocol<ClientContext> {
async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) {
if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) {
// Respect capability negotiation: server does not support resources
console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list');
this._logger.debug?.('Client.listResources() called but server does not advertise resources capability - returning empty list');
return { resources: [] };
}
return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options);
Expand All @@ -738,7 +738,7 @@ export class Client extends Protocol<ClientContext> {
async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) {
if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) {
// Respect capability negotiation: server does not support resources
console.debug(
this._logger.debug?.(
'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list'
);
return { resourceTemplates: [] };
Expand Down Expand Up @@ -887,7 +887,7 @@ export class Client extends Protocol<ClientContext> {
async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) {
if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) {
// Respect capability negotiation: server does not support tools
console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list');
this._logger.debug?.('Client.listTools() called but server does not advertise tools capability - returning empty list');
return { tools: [] };
}
const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options);
Expand Down
39 changes: 39 additions & 0 deletions packages/client/test/client/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { Client } from '../../src/client/client';

describe('Client logger option', () => {
it('routes SDK diagnostics to the configured logger', async () => {
const debug = vi.fn();
const consoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => {});
const client = new Client({ name: 'test-client', version: '1.0.0' }, { logger: { debug } });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

serverTransport.onmessage = async message => {
if ('method' in message && 'id' in message && message.method === 'initialize') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: { name: 'test-server', version: '1.0.0' }
}
});
}
};

await Promise.all([client.connect(clientTransport), serverTransport.start()]);

await expect(client.listTools()).resolves.toEqual({ tools: [] });
expect(debug).toHaveBeenCalledWith(
'Client.listTools() called but server does not advertise tools capability - returning empty list'
);
expect(consoleDebug).not.toHaveBeenCalled();

consoleDebug.mockRestore();
await client.close();
await clientTransport.close();
await serverTransport.close();
});
});
12 changes: 6 additions & 6 deletions packages/codemod/src/generated/versions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate.
export const V2_PACKAGE_VERSIONS: Record<string, string> = {
'@modelcontextprotocol/client': '^2.0.0-alpha.2',
'@modelcontextprotocol/server': '^2.0.0-alpha.2',
'@modelcontextprotocol/node': '^2.0.0-alpha.2',
'@modelcontextprotocol/express': '^2.0.0-alpha.2',
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2',
'@modelcontextprotocol/core': '^2.0.0-alpha.0'
'@modelcontextprotocol/client': '^2.0.0-alpha.3',
'@modelcontextprotocol/server': '^2.0.0-alpha.3',
'@modelcontextprotocol/node': '^2.0.0-alpha.3',
'@modelcontextprotocol/express': '^2.0.0-alpha.3',
'@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3',
'@modelcontextprotocol/core': '^2.0.0-alpha.1'
};
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 @@ -40,6 +40,9 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut
// Metadata utilities
export { getDisplayName } from '../../shared/metadataUtils';

// Logging
export type { SdkLogger } from '../../shared/logger';

// Protocol types (NOT the Protocol class itself or mergeCapabilities)
export type {
BaseContext,
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 @@ -2,6 +2,7 @@ export * from './auth/errors';
export * from './errors/sdkErrors';
export * from './shared/auth';
export * from './shared/authUtils';
export * from './shared/logger';
export * from './shared/metadataUtils';
export * from './shared/protocol';
export * from './shared/stdio';
Expand Down
9 changes: 9 additions & 0 deletions packages/core-internal/src/shared/logger.ts
Original file line number Diff line number Diff line change
@@ -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;
};
10 changes: 10 additions & 0 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from '../types/index';
import type { StandardSchemaV1 } from '../util/standardSchema';
import { isStandardSchema, validateStandardSchema } from '../util/standardSchema';
import type { SdkLogger } from './logger';
import type { Transport, TransportSendOptions } from './transport';

/**
Expand Down Expand Up @@ -78,6 +79,13 @@ export type ProtocolOptions = {
* e.g., `['notifications/tools/list_changed']`
*/
debouncedNotificationMethods?: string[];

/**
* Logger used by SDK internals for diagnostics.
*
* @default console
*/
logger?: SdkLogger;
};
Comment thread
claude[bot] marked this conversation as resolved.

/**
Expand Down Expand Up @@ -292,6 +300,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
private _pendingDebouncedNotifications = new Set<string>();

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

/**
* Callback for when the connection is closed for any reason.
Expand Down Expand Up @@ -319,6 +328,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 @@ -174,7 +176,11 @@ let warnedZodFallback = false;
* Throws if the schema has an explicit non-object `type` (e.g. `z.string()`),
* since that cannot satisfy the MCP spec.
*/
export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record<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 @@ -192,7 +198,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
}
if (!warnedZodFallback) {
warnedZodFallback = true;
console.warn(
logger.warn?.(
'[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' +
'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.'
);
Expand Down Expand Up @@ -237,9 +243,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