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` 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.
2 changes: 2 additions & 0 deletions docs/clients/machine-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
4 changes: 4 additions & 0 deletions docs/clients/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions docs/servers/logging-progress-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ Only the result comes back:

`progress` must increase on every notification for the same token; `total` and `message` are optional.

## Route SDK diagnostics

Pass `logger` to `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
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 24 additions & 8 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import type {
OAuthMetadata,
OAuthProtectedResourceMetadata,
OAuthTokens,
SdkLogger,
StoredOAuthClientInformation,
StoredOAuthTokens
} from '@modelcontextprotocol/core-internal';
import {
brandedHasInstance,
checkResourceAllowed,
fetchWithinOrigin,
guardLogger,
LATEST_PROTOCOL_VERSION,
OAuthClientInformationFullSchema,
OAuthError,
Expand Down Expand Up @@ -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`,
Expand All @@ -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<T extends { issuer?: string }>(
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.`
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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)}`
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
);
}

/**
Expand Down Expand Up @@ -1163,6 +1177,7 @@ async function authInternal(
forceReauthorization
}: AuthOptions
): Promise<AuthResult> {
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);
Expand Down Expand Up @@ -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.'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))}`
);
Expand Down
53 changes: 46 additions & 7 deletions packages/client/src/client/authExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.'
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -166,6 +179,7 @@ export interface ClientCredentialsProviderOptions {
* ```
*/
export class ClientCredentialsProvider implements OAuthClientProvider {
readonly logger?: SdkLogger;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
Expand All @@ -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',
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -306,6 +327,7 @@ export interface PrivateKeyJwtProviderOptions {
* ```
*/
export class PrivateKeyJwtProvider implements OAuthClientProvider {
readonly logger?: SdkLogger;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
Expand All @@ -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',
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -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',
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -630,6 +667,7 @@ export interface CrossAppAccessProviderOptions {
* ```
*/
export class CrossAppAccessProvider implements OAuthClientProvider {
readonly logger?: SdkLogger;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
Expand All @@ -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',
Expand Down
Loading
Loading