Logging: per-request log level on modern servers (era fork) (#1629) (#1724)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

* Logging: per-request log level on modern servers (era fork) (#1629)

On modern (2026-07-28) servers `logging/setLevel` and the session-scoped
level are gone: the client opts into logs per request via the
`io.modelcontextprotocol/logLevel` `_meta` key, and the SDK does not
attach it automatically, so server logs are silently absent until stamped.

Core (`InspectorClient`):
- `setModernLogLevel(level | undefined)` / `getModernLogLevel()` store the
  opt-in level; `mergeMeta` stamps it on every outgoing request, gated on
  the negotiated modern era so legacy connections are untouched.
- Cleared on disconnect so it can't leak into the next connection.

Web UI:
- `LogControls` forks on `protocolEra`: legacy keeps the `logging/setLevel`
  selector + Set button; modern shows a per-request opt-in Select (Off +
  levels, applied immediately) with copy explaining logs arrive on the
  originating request's stream.
- Threaded `protocolEra` / `modernLogLevel` / `onSetModernLogLevel` through
  LoggingScreen and InspectorView from App.

Tests: unit coverage for the modern LogControls/LoggingScreen fork;
integration coverage proving the `logLevel` `_meta` is stamped on a real
modern server once opted in (and not on legacy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: logging era-fork showcase configs + proof screenshots (#1629)

Add two manual-testing configs and README docs for the logging era fork:

- logging-legacy-http.json: plain streamable-HTTP server with `logging: true`
  and a `send_notification` tool. The Logs tab shows the legacy session-scoped
  "Set Active Level" selector + Set button; calling the tool streams logs.
- logging-modern-http.json: same, with `transport.modern: true`. Connect with
  Protocol Era = Modern and the Logs tab shows the per-request opt-in control;
  the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every
  request (visible in the Network tab request body).

pr-screenshots/ documents the verified behavior end-to-end against both real
servers: the legacy control with logs streaming, the modern per-request
control, and the modern tools/call request body carrying the stamped logLevel
_meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: send_notification emits via per-request notifier so modern logs stream (#1629)

The `send_notification` tool sent its `notifications/message` through the
global `server.server.notification()`. On the modern (2026-07-28) stateless
leg that isn't tied to the in-flight request, so the SDK answered the tool
call with plain JSON and dropped the log — nothing reached the Inspector's
Logs panel.

Prefer the per-request `extra.sendNotification` (rebuilt from
`ctx.mcpReq.notify`): scoped to the current request, it makes the SDK upgrade
the response to SSE and stream the log back on the originating request's
stream. Falls back to the global notifier (legacy path / no per-request
context). Verified end-to-end: opting in via the Logs-tab "Log Level per
Request" control and calling send_notification now streams the entry on the
modern server; legacy behavior unchanged (server-notification integration
tests green).

README documents the modern logging showcase and the caveat that this test
server streams whenever a tool emits — the SDK beta strips the per-request
envelope from the handler context, so the tool can't gate on the client's
logLevel opt-in (the client-side `_meta` stamp #1629 owns is still fully
visible in the Network tab).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: send_notification uses SDK request-scoped log() → spec-faithful modern gating (#1629)

The previous fix routed `send_notification` through `extra.sendNotification`
(`ctx.mcpReq.notify`), which streams on the modern leg but emits
unconditionally — the log showed up even when the client hadn't opted in via
the per-request `logLevel`.

The SDK already provides the correct primitive: `ctx.mcpReq.log(level, data,
logger)` (added in `McpServer.buildContext`). It applies the era-correct
threshold gating itself — on the modern (2026-07-28) leg it reads the
per-request `logLevel` opt-in from the request envelope and drops the message
when the client didn't opt in or the level is below the requested severity; on
legacy it honors the session level from `logging/setLevel` — and emits through
`notify`, so the modern response still upgrades to SSE and the log rides the
originating request's stream.

Surface it as `extra.log` (`toHandlerExtra` maps `ctx.mcpReq.log`) and have
`send_notification` prefer it, falling back to the global notifier for callers
without per-request context. Verified end to end: opting in via the Logs-tab
"Log Level per Request" control stamps `logLevel` in the request `_meta` and
streams the log; setting it back to Off omits the key and the server gates the
log (plain JSON response, no notification). Legacy server-notification
integration tests still green. (No SDK change needed — the earlier apparent
gap was our test server using the wrong low-level API.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* web+core: add "Log Level per Request" as a per-server setting, defaulting to on (#1629)

Adds the modern per-request log level as a persisted Server Settings option
(Options → "Log Level per Request", below Protocol Era), so a server can opt
into logs by default instead of the Logs-tab control starting at Off.

- types.ts: `ModernLogLevel` (`"off"` | the 8 levels), `DEFAULT_MODERN_LOG_LEVEL`
  = "debug" (default-on so a modern connection surfaces server logs out of the
  box), `MODERN_LOG_LEVELS`, and an `isModernLogLevel` guard. Field added to
  `InspectorServerSettings` and `StoredMCPServer`.
- serverList.ts: round-trip the field (lift on read, omit-on-default on write,
  drop unknown literals) mirroring `protocolEra`; add to the persisted field
  allowlist.
- remote/node/server.ts: validateSettings accepts/validates it and the
  normalize path drops a malformed literal.
- inspectorClient.ts: the constructor seeds `modernLogLevel` from the setting
  (`"off"` → not opted in), so a modern connection stamps the level from the
  first request without UI interaction. mergeMeta still gates on the modern era.
- ServerSettingsForm/Modal: a Select (Off + 8 levels) with a modern-era
  description. App seeds the live Logs-tab control from the setting on connect
  so the two stay in sync.

Tests: form Select (default/onChange/off/reflect), modal handler, serverList
round-trips, route validation (accept/reject/drop), and constructor seeding
(seeded level stamped from the first request; "off" opts out). The prior
modern-era "stamps once set" test is updated for the new default-on behavior.
Coverage gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Fix CI + review: format ServerSettingsForm, drop stray network export, broaden ignore

- Prettier-format ServerSettingsForm.tsx (the format:check failure that broke
  CI — my pre-push `npm run format` from the repo root was a silent no-op since
  there is no root format script; formatting is per-client).
- Remove `inspector-network-logging-modern-2026-07-19T15-11-27.013Z.json`, a
  Network-tab export debugging artifact accidentally committed in ca2aa526
  (flagged by review).
- Broaden the .gitignore debugging-artifacts section with
  `/inspector-network-*.json` so future network exports are ignored (the
  existing `/inspector-history-*.json` didn't match the network export naming).

Verified with the full `npm run ci` (validate → coverage → smoke → storybook),
green across all clients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test: add modern-era log delivery + gating e2e tests (#1629)

The modern-era suite covered the client-side `_meta` logLevel stamp but not
that a log actually flows back — the legacy suite already has that
(`should receive server-initiated notifications via SSE/streamable-http`), so
the modern side was the gap.

Add two live e2e tests against a modern (`createMcpHandler`) server serving the
`send_notification` tool with `logging: true`:
- opted-in (server setting = a level): calling the tool delivers a
  `notifications/message` over the request's SSE stream, with the right
  level/logger/data.
- not opted in (setting "off"): the tool still returns a result, but the server
  gates the log — no `notifications/message` is received.

This exercises the full path end to end: the client seeding + `_meta` opt-in,
and the server's `send_notification` → `ctx.mcpReq.log` era-correct gating.

Verified with the full `npm run ci` (green across all clients).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* web: hide the modern "Log Level per Request" setting on legacy connections (#1629)

The per-request log level is meaningless on a legacy server (which uses
logging/setLevel), so hide it in Server Settings when the era is legacy:
- protocolEra pinned to "legacy" → hidden (no connection needed), or
- protocolEra "auto" that negotiated legacy at connect time → hidden.

It stays visible for a pinned "modern" server, and for an "auto" server that is
either not yet connected (era unknown) or resolved to modern.

Threads the negotiated era down: App passes `negotiatedEra` to
ServerSettingsModal only when the settings target is the live-connected server
(`connectionStatus === "connected" && settingsModalTargetId === activeServerId`);
the modal forwards it to ServerSettingsForm, which gates the control on
`configuredEra === "modern" || (configuredEra === "auto" && negotiatedEra !== "legacy")`.

Tests: visibility matrix on the form (legacy hidden; auto+legacy hidden;
auto+unconnected and auto+modern and modern shown) and a modal hide case; the
existing modern-log tests now base on a modern-era settings object. Verified in
the browser (legacy hides, switching to modern reveals it reactively) and with
the full `npm run ci` (green across all clients).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-19 17:08:22 -04:00 • committed by GitHub
commit ec185f1053
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1087 additions and 67 deletions

View file

@ -27,6 +27,7 @@ export type {
AppRendererClient,
} from "./types.js";
import { getServerType as getServerTypeFromConfig } from "./config.js";
import { DEFAULT_MODERN_LOG_LEVEL } from "./types.js";
// Fallback client identity, used ONLY when a caller doesn't pass
// `clientIdentity`. Real clients supply their own: the Node clients (CLI, TUI)
// read the single-source version from the root package.json via
@ -98,6 +99,7 @@ import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
import {
isInputRequiredResult,
withInputRequired,
LOG_LEVEL_META_KEY,
} from "@modelcontextprotocol/client";
import {
EmptyResultSchema,
@ -274,6 +276,13 @@ export class InspectorClient extends InspectorClientEventTarget {
private ambientAuthChallengeInFlight = new Map<string, Promise<void>>();
private pipeStderr: boolean;
private initialLoggingLevel?: LoggingLevel;
// Modern-era per-request log level (#1629). On 2026-07-28 servers
// `logging/setLevel` is gone; the client opts into logs per request via the
// `io.modelcontextprotocol/logLevel` `_meta` key, and the SDK does not attach
// it automatically. When set, `mergeMeta` stamps this level on every outgoing
// request so server logs arrive on each request's stream; `undefined` means
// "don't opt in" (logs stay silently absent). Only honored on the modern era.
private modernLogLevel?: LoggingLevel;
private sample: boolean;
private elicit: boolean | { form?: boolean; url?: boolean };
private progress: boolean;
@ -382,6 +391,14 @@ export class InspectorClient extends InspectorClientEventTarget {
? options.defaultMetadata
: undefined;
this.serverSettings = options.serverSettings;
// Seed the modern per-request log level from the server setting (#1629), so
// a modern connection opts into logs by default without the user touching
// the Logs-tab control. Absence means DEFAULT_MODERN_LOG_LEVEL; `"off"`
// clears the opt-in. Only stamped on modern connections (see mergeMeta) —
// legacy uses `logging/setLevel`.
const settingLevel =
options.serverSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL;
this.modernLogLevel = settingLevel === "off" ? undefined : settingLevel;
// Default to the legacy 2025-11-25 era when the caller doesn't pin one, per
// the SDK guidance that a debugging tool must not auto-probe (#1626).
this.versionNegotiation = options.versionNegotiation ?? { mode: "legacy" };
@ -643,10 +660,21 @@ export class InspectorClient extends InspectorClientEventTarget {
callMetadata?: Record<string, string>,
): Record<string, string> | undefined {
const defaults = this.defaultMetadata;
const hasDefaults = defaults && Object.keys(defaults).length > 0;
const hasCall = callMetadata && Object.keys(callMetadata).length > 0;
if (!hasDefaults && !hasCall) return undefined;
return { ...(defaults ?? {}), ...(callMetadata ?? {}) };
// Modern-era per-request log level (#1629): stamp the opt-in `_meta` key on
// every request so the server emits `notifications/message` on this
// request's stream. Gated on the negotiated era — legacy servers use
// `logging/setLevel` instead, so we never stamp it there. Placed before the
// call-time keys so an explicit per-call `logLevel` (if ever passed) wins.
const logMeta =
this.protocolEra === "modern" && this.modernLogLevel
? { [LOG_LEVEL_META_KEY]: this.modernLogLevel }
: undefined;
const merged = {
...(defaults ?? {}),
...(logMeta ?? {}),
...(callMetadata ?? {}),
};
return Object.keys(merged).length > 0 ? merged : undefined;
}
private getRequestOptions(
@ -1517,6 +1545,9 @@ export class InspectorClient extends InspectorClientEventTarget {
this.protocolVersion = undefined;
this.protocolEra = undefined;
this.discoverResult = undefined;
// Drop the modern per-request log-level opt-in so it doesn't leak into the
// next connection's `_meta` (#1629).
this.modernLogLevel = undefined;
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
this.dispatchTypedEvent(
"pendingElicitationsChange",
@ -2092,7 +2123,12 @@ export class InspectorClient extends InspectorClientEventTarget {
}
/**
* Set the logging level for the MCP server
* Set the logging level for the MCP server (legacy era only).
*
* On legacy servers logging is session-scoped: one `logging/setLevel` request
* sets the level for all subsequent `notifications/message`. Modern servers
* removed this method — use {@link setModernLogLevel} there instead.
*
* @param level Logging level to set
* @throws Error if client is not connected or server doesn't support logging
*/
@ -2106,6 +2142,28 @@ export class InspectorClient extends InspectorClientEventTarget {
await this.client.setLoggingLevel(level, this.getRequestOptions());
}
/**
* Set (or clear) the modern-era per-request log level (#1629).
*
* On 2026-07-28 servers `logging/setLevel` is gone and there is no
* session-scoped level: the client opts into logs per request by stamping the
* `io.modelcontextprotocol/logLevel` `_meta` key, and the server MUST NOT emit
* `notifications/message` for requests that omit it. This stores the level so
* {@link mergeMeta} stamps it on every subsequent request; pass `undefined` to
* stop opting in (logs then stay silently absent). Takes effect immediately —
* no request is sent, and it is a no-op on the wire until the next request.
*
* @param level Level to stamp on every request, or `undefined` to opt out.
*/
setModernLogLevel(level: LoggingLevel | undefined): void {
this.modernLogLevel = level;
}
/** The modern-era per-request log level, or `undefined` when not opted in. */
getModernLogLevel(): LoggingLevel | undefined {
return this.modernLogLevel;
}
/**
* Fetch a single page of tools without updating the client's internal list.
*/

View file

@ -36,6 +36,7 @@ import { AuthChallengeError } from "../../../auth/challenge.js";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
isModernLogLevel,
} from "../../types.js";
import type {
InspectorServerSettings,
@ -1186,6 +1187,16 @@ export function createRemoteApp(
);
delete valObj.protocolEra;
}
if (
"modernLogLevel" in valObj &&
!isModernLogLevel(valObj.modernLogLevel)
) {
logWarn(
{ route: "/api/servers", id, droppedKey: "modernLogLevel" },
"Dropping malformed `modernLogLevel` field — expected 'off' or a logging level.",
);
delete valObj.modernLogLevel;
}
out[id] = normalizeServerType(
valObj as Record<string, unknown> & { type?: string },
@ -1463,6 +1474,18 @@ export function createRemoteApp(
error: "settings.protocolEra must be 'legacy', 'auto', or 'modern'",
};
}
// modernLogLevel is optional on the wire; when present it must be "off" or
// one of the eight logging levels, otherwise it defaults to absent below.
if (
obj.modernLogLevel !== undefined &&
!isModernLogLevel(obj.modernLogLevel)
) {
return {
ok: false,
error:
"settings.modernLogLevel must be 'off' or a logging level (debug…emergency)",
};
}
// Build the validated value from explicitly named fields rather than
// casting the raw object through. Unknown keys silently drop so a
// misconfigured client can't smuggle stowaways onto disk, and consumers
@ -1528,6 +1551,11 @@ export function createRemoteApp(
if (isProtocolEra(obj.protocolEra)) {
value.protocolEra = obj.protocolEra;
}
// Optional; carry a valid modern log level. Absence reads back as the
// default (DEFAULT_MODERN_LOG_LEVEL) downstream.
if (isModernLogLevel(obj.modernLogLevel)) {
value.modernLogLevel = obj.modernLogLevel;
}
// Empty cwd coerces to absent on the value (matching the read side); the
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
// (preserve) via `cwdProvided` below rather than the value alone.

View file

@ -7,8 +7,10 @@
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_MODERN_LOG_LEVEL,
DEFAULT_PROTOCOL_ERA,
DEFAULT_TASK_TTL_MS,
isModernLogLevel,
} from "./types.js";
import type { Root } from "@modelcontextprotocol/client";
import type {
@ -116,6 +118,7 @@ type StoredInspectorFields = Pick<
| "headers"
| "metadata"
| "protocolEra"
| "modernLogLevel"
| "connectionTimeout"
| "requestTimeout"
| "taskTtl"
@ -192,6 +195,7 @@ export function storedFieldsToInspectorSettings(
stored.oauth !== undefined ||
stored.roots !== undefined ||
stored.protocolEra !== undefined ||
stored.modernLogLevel !== undefined ||
stored.env !== undefined ||
stored.cwd !== undefined;
if (!hasAny) return undefined;
@ -227,6 +231,12 @@ export function storedFieldsToInspectorSettings(
if (isProtocolEra(stored.protocolEra)) {
settings.protocolEra = stored.protocolEra;
}
// Like `protocolEra`: absent reads back as the default modern log level (the
// form defaults via `?? DEFAULT_MODERN_LOG_LEVEL`), and an unknown literal from
// a hand-edited file is dropped rather than surfaced.
if (isModernLogLevel(stored.modernLogLevel)) {
settings.modernLogLevel = stored.modernLogLevel;
}
// Truthiness drops empty-string OAuth fields — mirrors the write-side
// coercion in `validateSettings` (server.ts) so a round-trip can't
// accidentally surface `oauthClientId: ""` to the form, where the
@ -311,6 +321,16 @@ export function inspectorSettingsToStoredFields(
out.protocolEra = settings.protocolEra;
}
// Persist only when it differs from the default modern log level; absent reads
// back as DEFAULT_MODERN_LOG_LEVEL, so writing the default would inject the
// field into files that never set it and break byte-stable round-trips.
if (
settings.modernLogLevel !== undefined &&
settings.modernLogLevel !== DEFAULT_MODERN_LOG_LEVEL
) {
out.modernLogLevel = settings.modernLogLevel;
}
// Persist only when it differs from the default. Unlike the timeouts, 0 is a
// meaningful value here (unlimited), so the omit-sentinel is the default
// itself rather than 0 — writing the default would inject the field into
@ -363,6 +383,7 @@ const INSPECTOR_FIELD_KEY_MAP = {
headers: true,
metadata: true,
protocolEra: true,
modernLogLevel: true,
connectionTimeout: true,
requestTimeout: true,
taskTtl: true,

View file

@ -110,6 +110,13 @@ export type StoredMCPServer = MCPServerConfig & {
* (`"legacy"`). (#1626)
*/
protocolEra?: ServerProtocolEra;
/**
* Modern-era per-request log level stamped by default (`"off"` or one of the
* eight logging levels). Inspector-specific. Omitted on disk when it equals
* `DEFAULT_MODERN_LOG_LEVEL` (`"debug"`). Only affects modern connections.
* (#1629)
*/
modernLogLevel?: ModernLogLevel;
/** Inspector-specific connect-time timeout (ms). */
connectionTimeout?: number;
/** Inspector-specific request timeout (ms). */
@ -480,6 +487,45 @@ export type ServerProtocolEra = "legacy" | "auto" | "modern";
/** The default per-server protocol era when none is configured. */
export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy";
/**
* Per-server modern (2026-07-28) per-request log level (#1629). `logging/setLevel`
* is gone on the modern era; instead the client opts into logs by stamping
* `_meta["io.modelcontextprotocol/logLevel"]` on each request. This setting is
* the level stamped by default on a modern connection — one of the eight logging
* levels, or `"off"` to not opt in (no server logs). Legacy connections ignore
* it (they use the session-scoped `logging/setLevel` instead).
*/
export type ModernLogLevel = LoggingLevel | "off";
/**
* The default modern per-request log level when none is configured. Defaults to
* opted-in at the most verbose level so a modern connection surfaces server logs
* out of the box (the Inspector is a debugging tool); set `"off"` per server to
* opt back out.
*/
export const DEFAULT_MODERN_LOG_LEVEL: ModernLogLevel = "debug";
/** All modern-log-level values, for form options and the runtime guard. */
export const MODERN_LOG_LEVELS: ModernLogLevel[] = [
"off",
"debug",
"info",
"notice",
"warning",
"error",
"critical",
"alert",
"emergency",
];
/** Runtime guard for the {@link ModernLogLevel} literal (hand-edited files). */
export function isModernLogLevel(value: unknown): value is ModernLogLevel {
return (
typeof value === "string" &&
(MODERN_LOG_LEVELS as string[]).includes(value)
);
}
/**
* The modern protocol revision `"modern"` era pins to. The successor to
* 2025-11-25; the first revision with the per-request-metadata / sessionless
@ -582,6 +628,14 @@ export interface InspectorServerSettings {
* omitted when it equals the default, keeping the file diff minimal.
*/
protocolEra?: ServerProtocolEra;
/**
* Modern-era per-request log level stamped by default on this server's
* connections (#1629). One of the eight logging levels, or `"off"` to not opt
* in. Absence means {@link DEFAULT_MODERN_LOG_LEVEL} (`"debug"`). Only affects
* modern (2026-07-28) connections; legacy uses `logging/setLevel`. Persisted
* on disk as `modernLogLevel` and omitted when it equals the default.
*/
modernLogLevel?: ModernLogLevel;
}
/**