Skip to content
Open
Show file tree
Hide file tree
Changes from all 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-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.
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