diff --git a/.changeset/injectable-sdk-logger.md b/.changeset/injectable-sdk-logger.md new file mode 100644 index 0000000000..30a9060b6a --- /dev/null +++ b/.changeset/injectable-sdk-logger.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +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 a23aff35b5..89fe0e2acc 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 `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 = { + debug: (...args: unknown[]) => console.error('[debug]', ...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. 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`. + +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..49e2e80a34 100644 --- a/examples/guides/servers/logging-progress-cancellation.examples.ts +++ b/examples/guides/servers/logging-progress-cancellation.examples.ts @@ -20,6 +20,17 @@ 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), + warn: (...args: unknown[]) => console.error('[warn]', ...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/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 69366521da..25e21183b3 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'; @@ -16,6 +17,7 @@ import { brandedHasInstance, checkResourceAllowed, fetchWithinOrigin, + guardLogger, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, @@ -119,7 +121,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 +132,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( + 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.` @@ -271,6 +274,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 +1089,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)}`); + guardLogger(provider.logger).warn?.( + `[mcp-sdk] OAuth ${JSON.stringify(error.code)} — ${action}. Cause: ${JSON.stringify(error.message)}` + ); } /** @@ -1163,6 +1177,7 @@ async function authInternal( forceReauthorization }: AuthOptions ): Promise { + 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); @@ -1273,7 +1288,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 +1328,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 +1414,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 +1455,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..3f49f56d1a 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -5,7 +5,14 @@ * 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 { guardLogger } from '@modelcontextprotocol/core-internal'; import type { CryptoKey, JWK } from 'jose'; import type { AddClientAuthentication, OAuthClientProvider } from './auth'; @@ -98,9 +105,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 +151,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 +179,7 @@ export interface ClientCredentialsProviderOptions { * ``` */ export class ClientCredentialsProvider implements OAuthClientProvider { + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -174,10 +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; this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'client-credentials-client', @@ -282,6 +297,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 +327,7 @@ export interface PrivateKeyJwtProviderOptions { * ``` */ export class PrivateKeyJwtProvider implements OAuthClientProvider { + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -315,9 +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; this._clientInfo = { client_id: options.clientId, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'private-key-jwt-client', @@ -411,6 +434,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 +450,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 +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; this._clientInfo = { client_id: options.clientId, - issuer: checkedExpectedIssuer(options.expectedIssuer) + issuer: checkedExpectedIssuer(options.expectedIssuer, guardLogger(options.logger)) }; this._clientMetadata = { client_name: options.clientName ?? 'static-private-key-jwt-client', @@ -587,6 +618,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 +667,7 @@ export interface CrossAppAccessProviderOptions { * ``` */ export class CrossAppAccessProvider implements OAuthClientProvider { + readonly logger?: SdkLogger; private _tokens?: StoredOAuthTokens; private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; @@ -643,10 +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; this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: checkedExpectedIssuer(options.expectedIssuer) + 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 096a8660e2..5728432c5a 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, @@ -57,6 +58,7 @@ import { codecForVersion, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + guardLogger, HEADER_MISMATCH_ERROR_CODE, isJSONRPCErrorResponse, isJSONRPCRequest, @@ -186,6 +188,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. * @@ -438,6 +448,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, @@ -549,6 +560,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 @@ -590,6 +602,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); } @@ -656,6 +669,7 @@ export class Client extends Protocol { options?: ClientOptions ) { super(options); + this._logger = guardLogger(options?.logger); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; @@ -1628,7 +1642,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 +1682,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 +1711,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 +2570,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) { @@ -2603,7 +2617,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/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/auth.test.ts b/packages/client/test/client/auth.test.ts index b3cd42bd78..6ad7065bb3 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3280,6 +3280,38 @@ 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('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(() => { @@ -5256,6 +5288,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' diff --git a/packages/client/test/client/logger.test.ts b/packages/client/test/client/logger.test.ts new file mode 100644 index 0000000000..962083a61d --- /dev/null +++ b/packages/client/test/client/logger.test.ts @@ -0,0 +1,184 @@ +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'; + +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(); + }); + + 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(() => {}); + 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(); + }); +}); 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/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'; diff --git a/packages/core-internal/src/shared/logger.ts b/packages/core-internal/src/shared/logger.ts new file mode 100644 index 0000000000..a681e18807 --- /dev/null +++ b/packages/core-internal/src/shared/logger.ts @@ -0,0 +1,36 @@ +/** + * 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. `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; + 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/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..f1faa89ea4 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.' ); @@ -353,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/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/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 4ef836c636..462ca25e98 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -35,12 +35,14 @@ import type { InboundLadderRejection, InboundLegacyRoute, InboundModernRoute, - RequestId + RequestId, + SdkLogger } from '@modelcontextprotocol/core-internal'; import { classifyInboundRequest, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + guardLogger, httpStatusForErrorCode, isJsonContentType, mediaTypeEssence, @@ -168,6 +170,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: * @@ -681,6 +685,7 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown, op */ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { 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 @@ -714,8 +719,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/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..4bb5be40e1 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, @@ -32,6 +33,7 @@ import { assertCompleteRequestResourceTemplate, assertValidCacheHint, attachCacheHintFallback, + guardLogger, isInputRequiredResult, normalizeRawShapeSchema, promptArgumentsFromStandardSchema, @@ -86,9 +88,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. @@ -123,6 +133,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 +153,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 +162,7 @@ export class McpServer { } constructor(serverInfo: Implementation, options?: McpServerOptions) { + this._logger = guardLogger(options?.logger); this.server = new Server(serverInfo, options); this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements); @@ -272,7 +285,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 +298,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; @@ -643,7 +660,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 }; @@ -937,19 +954,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 +990,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 +1027,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 +1474,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 +1492,10 @@ 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/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 1069faba2e..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'; @@ -854,6 +855,39 @@ 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 } }); + 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")); + + 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 () => { const { factory } = testFactory(); const handler = createMcpHandler(factory, { responseMode: 'sse' }); diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index ae7a0438b5..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'; @@ -7,6 +7,51 @@ 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('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' }); @@ -77,6 +122,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' });