Skip to content

Commit 8a8501b

Browse files
committed
feat: add injectable SDK logger
1 parent e4227d1 commit 8a8501b

11 files changed

Lines changed: 122 additions & 21 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/client': minor
4+
'@modelcontextprotocol/server': minor
5+
---
6+
7+
Add a `logger` protocol option so client and server SDK diagnostics can be routed through user-provided logging.

packages/client/src/client/client.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ export class Client extends Protocol<ClientContext> {
692692
async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) {
693693
if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) {
694694
// Respect capability negotiation: server does not support prompts
695-
console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list');
695+
this._logger.debug?.('Client.listPrompts() called but server does not advertise prompts capability - returning empty list');
696696
return { prompts: [] };
697697
}
698698
return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options);
@@ -723,7 +723,7 @@ export class Client extends Protocol<ClientContext> {
723723
async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) {
724724
if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) {
725725
// Respect capability negotiation: server does not support resources
726-
console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list');
726+
this._logger.debug?.('Client.listResources() called but server does not advertise resources capability - returning empty list');
727727
return { resources: [] };
728728
}
729729
return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options);
@@ -738,7 +738,7 @@ export class Client extends Protocol<ClientContext> {
738738
async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) {
739739
if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) {
740740
// Respect capability negotiation: server does not support resources
741-
console.debug(
741+
this._logger.debug?.(
742742
'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list'
743743
);
744744
return { resourceTemplates: [] };
@@ -887,7 +887,7 @@ export class Client extends Protocol<ClientContext> {
887887
async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) {
888888
if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) {
889889
// Respect capability negotiation: server does not support tools
890-
console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list');
890+
this._logger.debug?.('Client.listTools() called but server does not advertise tools capability - returning empty list');
891891
return { tools: [] };
892892
}
893893
const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { Client } from '../../src/client/client';
4+
5+
describe('Client logger option', () => {
6+
it('routes SDK diagnostics to the configured logger', async () => {
7+
const debug = vi.fn();
8+
const consoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => {});
9+
const client = new Client({ name: 'test-client', version: '1.0.0' }, { logger: { debug } });
10+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
11+
12+
serverTransport.onmessage = async message => {
13+
if ('method' in message && 'id' in message && message.method === 'initialize') {
14+
await serverTransport.send({
15+
jsonrpc: '2.0',
16+
id: message.id,
17+
result: {
18+
protocolVersion: LATEST_PROTOCOL_VERSION,
19+
capabilities: {},
20+
serverInfo: { name: 'test-server', version: '1.0.0' }
21+
}
22+
});
23+
}
24+
};
25+
26+
await Promise.all([client.connect(clientTransport), serverTransport.start()]);
27+
28+
await expect(client.listTools()).resolves.toEqual({ tools: [] });
29+
expect(debug).toHaveBeenCalledWith(
30+
'Client.listTools() called but server does not advertise tools capability - returning empty list'
31+
);
32+
expect(consoleDebug).not.toHaveBeenCalled();
33+
34+
consoleDebug.mockRestore();
35+
await client.close();
36+
await clientTransport.close();
37+
await serverTransport.close();
38+
});
39+
});

packages/core-internal/src/exports/public/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/aut
4040
// Metadata utilities
4141
export { getDisplayName } from '../../shared/metadataUtils';
4242

43+
// Logging
44+
export type { SdkLogger } from '../../shared/logger';
45+
4346
// Protocol types (NOT the Protocol class itself or mergeCapabilities)
4447
export type {
4548
BaseContext,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Logger used by SDK internals for diagnostics.
3+
*/
4+
export type SdkLogger = {
5+
debug?: (...args: unknown[]) => void;
6+
info?: (...args: unknown[]) => void;
7+
warn?: (...args: unknown[]) => void;
8+
error?: (...args: unknown[]) => void;
9+
};

packages/core-internal/src/shared/protocol.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from '../types/index';
4545
import type { StandardSchemaV1 } from '../util/standardSchema';
4646
import { isStandardSchema, validateStandardSchema } from '../util/standardSchema';
47+
import type { SdkLogger } from './logger';
4748
import type { Transport, TransportSendOptions } from './transport';
4849

4950
/**
@@ -78,6 +79,13 @@ export type ProtocolOptions = {
7879
* e.g., `['notifications/tools/list_changed']`
7980
*/
8081
debouncedNotificationMethods?: string[];
82+
83+
/**
84+
* Logger used by SDK internals for diagnostics.
85+
*
86+
* @default console
87+
*/
88+
logger?: SdkLogger;
8189
};
8290

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

294302
protected _supportedProtocolVersions: string[];
303+
protected _logger: SdkLogger;
295304

296305
/**
297306
* Callback for when the connection is closed for any reason.
@@ -319,6 +328,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
319328

320329
constructor(private _options?: ProtocolOptions) {
321330
this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
331+
this._logger = _options?.logger ?? console;
322332

323333
this.setNotificationHandler('notifications/cancelled', notification => {
324334
this._oncancel(notification);

packages/core-internal/src/shared/toolNameValidation.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986 | SEP-986: Specify Format for Tool Names}
1111
*/
1212

13+
import type { SdkLogger } from './logger';
14+
1315
/**
1416
* Regular expression for valid tool names according to SEP-986 specification
1517
*/
@@ -86,16 +88,17 @@ export function validateToolName(name: string): {
8688
* Issues warnings for non-conforming tool names
8789
* @param name - The tool name that triggered the warnings
8890
* @param warnings - Array of warning messages
91+
* @param logger - Logger to emit warnings through
8992
*/
90-
export function issueToolNameWarning(name: string, warnings: string[]): void {
93+
export function issueToolNameWarning(name: string, warnings: string[], logger: SdkLogger = console): void {
9194
if (warnings.length > 0) {
92-
console.warn(`Tool name validation warning for "${name}":`);
95+
logger.warn?.(`Tool name validation warning for "${name}":`);
9396
for (const warning of warnings) {
94-
console.warn(` - ${warning}`);
97+
logger.warn?.(` - ${warning}`);
9598
}
96-
console.warn('Tool registration will proceed, but this may cause compatibility issues.');
97-
console.warn('Consider updating the tool name to conform to the MCP tool naming standard.');
98-
console.warn(
99+
logger.warn?.('Tool registration will proceed, but this may cause compatibility issues.');
100+
logger.warn?.('Consider updating the tool name to conform to the MCP tool naming standard.');
101+
logger.warn?.(
99102
'See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details.'
100103
);
101104
}
@@ -104,13 +107,14 @@ export function issueToolNameWarning(name: string, warnings: string[]): void {
104107
/**
105108
* Validates a tool name and issues warnings for non-conforming names
106109
* @param name - The tool name to validate
110+
* @param logger - Logger to emit warnings through
107111
* @returns `true` if the name is valid, `false` otherwise
108112
*/
109-
export function validateAndWarnToolName(name: string): boolean {
113+
export function validateAndWarnToolName(name: string, logger?: SdkLogger): boolean {
110114
const result = validateToolName(name);
111115

112116
// Always issue warnings for any validation issues (both invalid names and warnings)
113-
issueToolNameWarning(name, result.warnings);
117+
issueToolNameWarning(name, result.warnings, logger);
114118

115119
return result.isValid;
116120
}

packages/core-internal/src/util/standardSchema.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import * as z from 'zod/v4';
1010

11+
import type { SdkLogger } from '../shared/logger';
12+
1113
// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)
1214

1315
export interface StandardTypedV1<Input = unknown, Output = Input> {
@@ -174,7 +176,11 @@ let warnedZodFallback = false;
174176
* Throws if the schema has an explicit non-object `type` (e.g. `z.string()`),
175177
* since that cannot satisfy the MCP spec.
176178
*/
177-
export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record<string, unknown> {
179+
export function standardSchemaToJsonSchema(
180+
schema: StandardJSONSchemaV1,
181+
io: 'input' | 'output' = 'input',
182+
logger: SdkLogger = console
183+
): Record<string, unknown> {
178184
const std = schema['~standard'];
179185
let result: Record<string, unknown>;
180186
if (std.jsonSchema) {
@@ -192,7 +198,7 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in
192198
}
193199
if (!warnedZodFallback) {
194200
warnedZodFallback = true;
195-
console.warn(
201+
logger.warn?.(
196202
'[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' +
197203
'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.'
198204
);

packages/core-internal/test/util/standardSchema.zodFallback.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@ type SchemaArg = Parameters<typeof standardSchemaToJsonSchema>[0];
66

77
describe('standardSchemaToJsonSchema — zod fallback paths', () => {
88
it('falls back to z.toJSONSchema for zod 4.0–4.1 (vendor=zod, no ~standard.jsonSchema, has _zod)', () => {
9-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
9+
const warn = vi.fn();
10+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1011
const real = z.object({ a: z.string() });
1112
// Simulate zod 4.0–4.1: shadow `~standard` on the real instance with `jsonSchema` removed.
1213
// Keeps the rest of the zod 4 object (including `_zod`) intact so z.toJSONSchema can introspect it.
1314
const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record<string, unknown>;
1415
void _drop;
1516
Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true });
1617

17-
const result = standardSchemaToJsonSchema(real as unknown as SchemaArg);
18+
const result = standardSchemaToJsonSchema(real as unknown as SchemaArg, 'input', { warn });
1819
expect(result.type).toBe('object');
1920
expect((result.properties as unknown as Record<string, unknown>)?.a).toBeDefined();
2021
expect(warn).toHaveBeenCalledOnce();
2122
expect(warn.mock.calls[0]?.[0]).toContain('zod 4.2.0');
22-
warn.mockRestore();
23+
expect(consoleWarn).not.toHaveBeenCalled();
24+
consoleWarn.mockRestore();
2325
});
2426

2527
it('throws a clear error for zod 3 (vendor=zod, no ~standard.jsonSchema, no _zod)', () => {

packages/server/src/server/mcp.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
Resource,
1818
ResourceTemplateReference,
1919
Result,
20+
SdkLogger,
2021
ServerContext,
2122
StandardSchemaWithJSON,
2223
Tool,
@@ -68,8 +69,10 @@ export class McpServer {
6869
} = {};
6970
private _registeredTools: { [name: string]: RegisteredTool } = {};
7071
private _registeredPrompts: { [name: string]: RegisteredPrompt } = {};
72+
private readonly _logger: SdkLogger;
7173

7274
constructor(serverInfo: Implementation, options?: ServerOptions) {
75+
this._logger = options?.logger ?? console;
7376
this.server = new Server(serverInfo, options);
7477

7578
// Per the MCP spec, a server that declares a primitive capability MUST respond to its
@@ -141,7 +144,7 @@ export class McpServer {
141144
title: tool.title,
142145
description: tool.description,
143146
inputSchema: tool.inputSchema
144-
? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema'])
147+
? (standardSchemaToJsonSchema(tool.inputSchema, 'input', this._logger) as Tool['inputSchema'])
145148
: EMPTY_OBJECT_JSON_SCHEMA,
146149
annotations: tool.annotations,
147150
icons: tool.icons,
@@ -150,7 +153,11 @@ export class McpServer {
150153
};
151154

152155
if (tool.outputSchema) {
153-
toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema'];
156+
toolDefinition.outputSchema = standardSchemaToJsonSchema(
157+
tool.outputSchema,
158+
'output',
159+
this._logger
160+
) as Tool['outputSchema'];
154161
}
155162

156163
return toolDefinition;
@@ -709,7 +716,7 @@ export class McpServer {
709716
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
710717
): RegisteredTool {
711718
// Validate tool name according to SEP specification
712-
validateAndWarnToolName(name);
719+
validateAndWarnToolName(name, this._logger);
713720

714721
// Track current handler for executor regeneration
715722
let currentHandler = handler;
@@ -732,7 +739,7 @@ export class McpServer {
732739
update: updates => {
733740
if (updates.name !== undefined && updates.name !== name) {
734741
if (typeof updates.name === 'string') {
735-
validateAndWarnToolName(updates.name);
742+
validateAndWarnToolName(updates.name, this._logger);
736743
}
737744
delete this._registeredTools[name];
738745
if (updates.name) this._registeredTools[updates.name] = registeredTool;

0 commit comments

Comments
 (0)