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
3 changed files with 1087 additions and 67 deletions

View file

@ -11,6 +11,7 @@ import {
type TestServerHttp,
createTestServerInfo,
createEchoTool,
createSendNotificationTool,
createMrtrTool,
createMrtrMultiRoundTool,
createMrtrRootsTool,
@ -23,6 +24,8 @@ import type {
ContentBlock,
JSONRPCRequest,
} from "@modelcontextprotocol/client";
import { LOG_LEVEL_META_KEY } from "@modelcontextprotocol/client";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
/**
* Live coverage of the modern (2026-07-28) connection path (#1700). The bundled
@ -405,6 +408,211 @@ describe("modern-era negotiation (2026-07-28)", () => {
expect(connected.getPendingElicitations()).toHaveLength(0);
});
// #1629: modern per-request log level. `logging/setLevel` is gone on the
// modern leg; the client opts into logs by stamping the
// `io.modelcontextprotocol/logLevel` `_meta` key on each request.
function metaOf(frame: JSONRPCRequest): Record<string, unknown> {
const params = frame.params as { _meta?: Record<string, unknown> };
return params?._meta ?? {};
}
it("stamps the per-request log level on outgoing requests, tracks changes, and stops when cleared", async () => {
const started = await startServer();
const connected = await connectWithEra(started.url, "modern");
const frames = collectToolCallRequests(connected);
const { tools } = await connected.listTools();
const echo = tools.find((t) => t.name === "echo")!;
// No server setting was passed, so the client seeds the default modern log
// level (DEFAULT_MODERN_LOG_LEVEL = "debug") — opted in from the start.
expect(connected.getModernLogLevel()).toBe("debug");
await connected.callTool(echo, { message: "a" });
// Change the level — subsequent requests carry the new one.
connected.setModernLogLevel("warning");
expect(connected.getModernLogLevel()).toBe("warning");
await connected.callTool(echo, { message: "b" });
// Opt back out — the stamp disappears.
connected.setModernLogLevel(undefined);
expect(connected.getModernLogLevel()).toBeUndefined();
await connected.callTool(echo, { message: "c" });
expect(frames).toHaveLength(3);
const [seeded, changed, cleared] = frames;
expect(metaOf(seeded)[LOG_LEVEL_META_KEY]).toBe("debug");
expect(metaOf(changed)[LOG_LEVEL_META_KEY]).toBe("warning");
expect(metaOf(cleared)[LOG_LEVEL_META_KEY]).toBeUndefined();
});
it("seeds the per-request log level from the server setting (defaults opted-in), and 'off' opts out (#1629)", async () => {
const started = await startServer();
// A server setting of "info" seeds the client opted-in from the start — the
// first tools/call stamps it without any UI interaction.
const seeded = new InspectorClient(
{ type: "streamable-http", url: started.url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation("modern"),
serverSettings: {
headers: [],
metadata: [],
env: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
modernLogLevel: "info",
},
},
);
client = seeded;
const seededFrames = collectToolCallRequests(seeded);
await seeded.connect();
expect(seeded.getModernLogLevel()).toBe("info");
const { tools } = await seeded.listTools();
const echo = tools.find((t) => t.name === "echo")!;
await seeded.callTool(echo, { message: "seeded" });
expect(metaOf(seededFrames[0])[LOG_LEVEL_META_KEY]).toBe("info");
await seeded.disconnect();
// A server setting of "off" seeds not-opted-in: no stamp.
const offClient = new InspectorClient(
{ type: "streamable-http", url: started.url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation("modern"),
serverSettings: {
headers: [],
metadata: [],
env: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
modernLogLevel: "off",
},
},
);
client = offClient;
const offFrames = collectToolCallRequests(offClient);
await offClient.connect();
expect(offClient.getModernLogLevel()).toBeUndefined();
const echo2 = (await offClient.listTools()).tools.find(
(t) => t.name === "echo",
)!;
await offClient.callTool(echo2, { message: "off" });
expect(metaOf(offFrames[0])[LOG_LEVEL_META_KEY]).toBeUndefined();
});
// A modern server that actually emits logs: the `send_notification` tool routes
// through the SDK's request-scoped `ctx.mcpReq.log`, which on the modern leg
// gates on the per-request `logLevel` opt-in and streams the admitted log on
// the originating request's SSE response.
async function startLoggingServer(): Promise<TestServerHttp> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("modern-logging-test", "1.0.0"),
tools: [createEchoTool(), createSendNotificationTool()],
logging: true,
modern: {},
});
await started.start();
server = started;
return started;
}
// Connect a modern client, seeding the per-request log level from a server
// setting (like the app does). Collect every received `notifications/message`.
async function connectLoggingClient(
url: string,
modernLogLevel: "off" | "debug" | "info" | "warning",
): Promise<{ client: InspectorClient; logs: MessageEntry[] }> {
const connected = new InspectorClient(
{ type: "streamable-http", url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation("modern"),
serverSettings: {
headers: [],
metadata: [],
env: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
modernLogLevel,
},
},
);
const logs: MessageEntry[] = [];
connected.addEventListener("message", (event) => {
const entry = event.detail;
if (
entry.direction === "notification" &&
"method" in entry.message &&
entry.message.method === "notifications/message"
) {
logs.push(entry);
}
});
await connected.connect();
client = connected;
return { client: connected, logs };
}
it("delivers a server log over the request stream when opted in (#1629)", async () => {
const started = await startLoggingServer();
const { client: connected, logs } = await connectLoggingClient(
started.url,
"info",
);
const send = (await connected.listTools()).tools.find(
(t) => t.name === "send_notification",
)!;
await connected.callTool(send, {
message: "modern log delivered",
level: "warning",
});
// The opted-in request's SSE response carried the notifications/message.
expect(logs).toHaveLength(1);
const params = (logs[0].message as { params?: Record<string, unknown> })
.params!;
expect((params.data as { message: string }).message).toBe(
"modern log delivered",
);
expect(params.level).toBe("warning");
expect(params.logger).toBe("test-server");
});
it("gates the server log when not opted in (setting 'off') (#1629)", async () => {
const started = await startLoggingServer();
const { client: connected, logs } = await connectLoggingClient(
started.url,
"off",
);
expect(connected.getModernLogLevel()).toBeUndefined();
const send = (await connected.listTools()).tools.find(
(t) => t.name === "send_notification",
)!;
// The tool still returns a result, but with no logLevel opt-in on the
// request the modern server suppresses the notifications/message (plain
// JSON response, nothing on an SSE stream).
const result = await connected.callTool(send, {
message: "should be gated",
level: "warning",
});
expect(result.success).toBe(true);
expect(logs).toHaveLength(0);
});
it("rejects a legacy client against a strict modern-only server", async () => {
const started = await startServer({ legacy: "reject" });
const failing = new InspectorClient(

View file

@ -69,6 +69,7 @@ import type {
ContentBlock,
} from "@modelcontextprotocol/client";
import {
LOG_LEVEL_META_KEY,
RELATED_TASK_META_KEY,
SdkError,
SdkErrorCode,
@ -2544,6 +2545,41 @@ describe("InspectorClient", () => {
}
});
it("does not stamp the modern per-request log level on a legacy connection (#1629)", async () => {
client = new InspectorClient(
{
type: "stdio",
command: serverCommand.command,
args: serverCommand.args,
},
{ environment: { transport: createTransportNode } },
);
const messageLogState = new MessageLogState(client);
await client.connect();
// Setting the modern level is a no-op on a legacy server: the era gate in
// mergeMeta means the `logLevel` `_meta` key is never stamped there.
client.setModernLogLevel("debug");
expect(client.getModernLogLevel()).toBe("debug");
const tool = await getTool(client, "echo");
await client.callTool(tool, { message: "hi" });
const callToolReq = messageLogState
.getMessages()
.find(
(m) =>
m.direction === "request" &&
(m.message as { method?: string }).method === "tools/call",
);
expect(callToolReq).toBeDefined();
const params = (callToolReq!.message as { params?: { _meta?: unknown } })
.params;
const meta = (params?._meta as Record<string, unknown>) ?? {};
expect(meta[LOG_LEVEL_META_KEY]).toBeUndefined();
messageLogState.destroy();
});
it("should track stderr logs for stdio transport", async () => {
client = new InspectorClient(
{

View file

@ -477,6 +477,11 @@ describe("server.ts supplemental coverage", () => {
expect((await res.json()).error).toMatch(/protocolEra/);
});
it("rejects an unknown modernLogLevel (#1629)", async () => {
const res = await postSettings({ ...base, modernLogLevel: "verbose" });
expect((await res.json()).error).toMatch(/modernLogLevel/);
});
it("accepts a fully-populated valid settings payload", async () => {
const res = await postSettings({
...base,
@ -487,6 +492,7 @@ describe("server.ts supplemental coverage", () => {
paginatedLists: true,
maxFetchRequests: 5,
protocolEra: "modern",
modernLogLevel: "off",
oauthClientId: "cid",
oauthScopes: "a b",
enterpriseManaged: true,
@ -524,6 +530,8 @@ describe("server.ts supplemental coverage", () => {
maxFetchRequests: -1,
// unknown era literal → isProtocolEra branch
protocolEra: "future",
// unknown modern log level → isModernLogLevel branch (#1629)
modernLogLevel: "verbose",
// enterpriseManaged non-boolean → isOauthObject inner branch
oauth: { enterpriseManaged: "yes" },
// a non-string `name` → isRootArray inner branch
@ -552,6 +560,7 @@ describe("server.ts supplemental coverage", () => {
"taskTtl",
"maxFetchRequests",
"protocolEra",
"modernLogLevel",
"oauth",
"roots",
]) {