inspector/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts
Cliff Hall ec185f1053
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) (#1724)
* 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>
2026-07-19 17:08:22 -04:00

628 lines
24 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
import {
eraToVersionNegotiation,
MODERN_PROTOCOL_VERSION,
} from "@inspector/core/mcp/types.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
createEchoTool,
createSendNotificationTool,
createMrtrTool,
createMrtrMultiRoundTool,
createMrtrRootsTool,
createMrtrSamplingTool,
createMrtrLoopTool,
createMrtrEdgeCaseTool,
} from "@modelcontextprotocol/inspector-test-server";
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
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
* 2025 test servers negotiate legacy only; the `modern` preset here mounts the
* SDK's `createMcpHandler`, so an SDK v2 client negotiating
* `protocolEra: "auto" | "modern"` reaches the modern leg end-to-end. This is
* the live counterpart to the `auto → legacy` (stdio) test in
* `inspectorClient.test.ts`.
*/
describe("modern-era negotiation (2026-07-28)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// Ignore disconnect errors
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// Ignore server stop errors
}
server = null;
}
});
async function startServer(
modern: ServerConfig["modern"] = {},
): Promise<TestServerHttp> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("modern-era-test", "1.0.0"),
tools: [createEchoTool()],
modern,
});
await started.start();
server = started;
return started;
}
async function connectWithEra(
url: string,
era: "legacy" | "auto" | "modern",
): Promise<InspectorClient> {
const connected = new InspectorClient(
{ type: "streamable-http", url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation(era),
},
);
await connected.connect();
client = connected;
return connected;
}
it("negotiates the modern era under 'auto' with a populated discover result", async () => {
const started = await startServer();
const connected = await connectWithEra(started.url, "auto");
expect(connected.getProtocolEra()).toBe("modern");
expect(connected.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION);
// Unlike a legacy server, a modern server answers server/discover, so the
// discover result is populated (supported versions + capabilities).
const discover = connected.getDiscoverResult();
expect(discover).toBeDefined();
expect(discover?.supportedVersions).toContain(MODERN_PROTOCOL_VERSION);
});
it("negotiates the modern era under a 'modern' pin", async () => {
const started = await startServer();
const connected = await connectWithEra(started.url, "modern");
expect(connected.getProtocolEra()).toBe("modern");
expect(connected.getProtocolVersion()).toBe(MODERN_PROTOCOL_VERSION);
expect(connected.getDiscoverResult()?.supportedVersions).toContain(
MODERN_PROTOCOL_VERSION,
);
});
it("still connects a legacy client via the stateless fallback (dual-era)", async () => {
const started = await startServer({ legacy: "stateless" });
const connected = await connectWithEra(started.url, "legacy");
// A plain 2025 initialize handshake is routed to the stateless legacy leg,
// so the client connects and reports the legacy era.
expect(connected.getProtocolEra()).toBe("legacy");
expect(connected.getDiscoverResult()).toBeUndefined();
expect((await connected.listTools()).tools.length).toBeGreaterThan(0);
});
it("exercises the modern surface after an 'auto' connect (tools/call)", async () => {
const started = await startServer();
const connected = await connectWithEra(started.url, "auto");
const { tools } = await connected.listTools();
const echo = tools.find((t) => t.name === "echo");
expect(echo).toBeDefined();
const result = await connected.callTool(echo!, { message: "hi" });
expect(result.success).toBe(true);
const content = result.result!.content as ContentBlock[];
expect(content[0]).toHaveProperty("type", "text");
expect("text" in content[0] && content[0].text).toContain("hi");
});
// Collect every outbound `tools/call` request frame the transport captured,
// so a test can assert what the MRTR retry actually put on the wire.
function collectToolCallRequests(
connected: InspectorClient,
): JSONRPCRequest[] {
const frames: JSONRPCRequest[] = [];
connected.addEventListener("message", (event) => {
const entry = event.detail;
if (
entry.direction === "request" &&
"method" in entry.message &&
entry.message.method === "tools/call"
) {
frames.push(entry.message as JSONRPCRequest);
}
});
return frames;
}
async function startMrtrServer(
tool: ReturnType<typeof createMrtrTool>,
): Promise<TestServerHttp> {
const started = createTestServerHttp({
serverInfo: createTestServerInfo("modern-mrtr-test", "1.0.0"),
tools: [tool],
modern: {},
});
await started.start();
server = started;
return started;
}
it("drives an MRTR round-trip manually (pauses at the pending UI, retries with inputResponses + requestState on a new id)", async () => {
// The modern (2026-07-28) leg has no server→client requests; a tool that
// needs input returns `input_required` embedding the request. With the SDK's
// auto-fulfil disabled, InspectorClient drives the loop itself: it surfaces
// the embedded elicitation through the pending-request UI, then retries the
// original call with the answer.
const started = await startMrtrServer(createMrtrTool());
const connected = await connectWithEra(started.url, "modern");
const toolCallFrames = collectToolCallRequests(connected);
// Prove the pending UX actually paused: record that an elicitation was
// surfaced BEFORE we answer it (the manual driver enqueued it), then answer.
let pausedAtPendingUi = false;
connected.addEventListener("newPendingElicitation", (event) => {
pausedAtPendingUi = true;
// The manual driver tags an MRTR round's request "input-required".
expect(event.detail.origin).toBe("input-required");
void event.detail.respond({
action: "accept",
content: { confirm: true },
});
});
const { tools } = await connected.listTools();
const mrtr = tools.find((t) => t.name === "mrtr_confirm");
expect(mrtr).toBeDefined();
const result = await connected.callTool(mrtr!, { action: "deploy" });
expect(result.success).toBe(true);
expect(pausedAtPendingUi).toBe(true);
const content = result.result!.content as ContentBlock[];
const text = content[0] && "text" in content[0] ? content[0].text : "";
// The final result is only reachable after the input_required round was
// fulfilled and the original call retried — i.e. the full MRTR loop ran.
expect(text).toContain("MRTR complete");
expect(text).toContain("confirm");
// Two tools/call frames: the original and the retry. The retry has a
// DIFFERENT json-rpc id and carries inputResponses + the echoed requestState.
expect(toolCallFrames.length).toBe(2);
const [original, retry] = toolCallFrames;
expect(retry.id).not.toBe(original.id);
const retryParams = retry.params as {
inputResponses?: Record<string, unknown>;
requestState?: unknown;
};
expect(retryParams.inputResponses?.confirm).toEqual({
action: "accept",
content: { confirm: true },
});
// The original call carries no requestState; the retry echoes the opaque
// server-minted token verbatim (shape: `mrtr:<action>:<n>`).
expect(
(original.params as { requestState?: unknown }).requestState,
).toBeUndefined();
expect(retryParams.requestState).toMatch(/^mrtr:deploy:/);
});
it("drives a multi-round MRTR (two embedded elicitations in sequence, then completes)", async () => {
const started = await startMrtrServer(createMrtrMultiRoundTool());
const connected = await connectWithEra(started.url, "modern");
const seenMessages: string[] = [];
connected.addEventListener("newPendingElicitation", (event) => {
seenMessages.push(event.detail.request.params.message ?? "");
void event.detail.respond({
action: "accept",
content: { value: `answer-${seenMessages.length}` },
});
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_two_step");
const result = await connected.callTool(tool!, {});
expect(result.success).toBe(true);
// Both rounds surfaced, in order.
expect(seenMessages).toEqual([
"Step 1: enter the first value",
"Step 2: enter the second value",
]);
const content = result.result!.content as ContentBlock[];
const text = content[0] && "text" in content[0] ? content[0].text : "";
expect(text).toContain("MRTR two-step complete");
});
it("auto-answers an embedded roots/list request from configured roots (no pending UI)", async () => {
const started = await startMrtrServer(createMrtrRootsTool());
const connected = new InspectorClient(
{ type: "streamable-http", url: started.url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation("modern"),
roots: [{ uri: "file:///workspace", name: "workspace" }],
},
);
client = connected;
await connected.connect();
let surfacedPending = false;
connected.addEventListener("newPendingElicitation", () => {
surfacedPending = true;
});
connected.addEventListener("newPendingSample", () => {
surfacedPending = true;
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_roots");
const result = await connected.callTool(tool!, {});
expect(result.success).toBe(true);
// roots is answered silently — nothing pauses at the pending UI.
expect(surfacedPending).toBe(false);
const content = result.result!.content as ContentBlock[];
const text = content[0] && "text" in content[0] ? content[0].text : "";
expect(text).toContain("client reported 1 root");
});
it("surfaces an embedded sampling request through the pending-sample UI", async () => {
const started = await startMrtrServer(createMrtrSamplingTool());
const connected = await connectWithEra(started.url, "modern");
let sampleOrigin: string | undefined;
connected.addEventListener("newPendingSample", (event) => {
sampleOrigin = event.detail.origin;
void event.detail.respond({
model: "test-model",
stopReason: "endTurn",
role: "assistant",
content: { type: "text", text: "hello" },
});
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_sample");
const result = await connected.callTool(tool!, {});
expect(result.success).toBe(true);
expect(sampleOrigin).toBe("input-required");
const content = result.result!.content as ContentBlock[];
const text = content[0] && "text" in content[0] ? content[0].text : "";
expect(text).toContain("MRTR sample complete");
});
it("echoes a declined elicitation back to the server (decline is not an abort)", async () => {
const started = await startMrtrServer(createMrtrTool());
const connected = await connectWithEra(started.url, "modern");
connected.addEventListener("newPendingElicitation", (event) => {
void event.detail.respond({ action: "decline" });
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_confirm");
// The tool still completes — the declined result is echoed back, and the
// server returns its final result citing the declined answer.
const result = await connected.callTool(tool!, { action: "deploy" });
expect(result.success).toBe(true);
const content = result.result!.content as ContentBlock[];
const text = content[0] && "text" in content[0] ? content[0].text : "";
expect(text).toContain("MRTR complete");
expect(text).toContain("decline");
});
it("bounds a pathological MRTR that never completes (MRTR_MAX_ROUNDS)", async () => {
const started = await startMrtrServer(createMrtrLoopTool());
const connected = await connectWithEra(started.url, "modern");
// Auto-answer every round; the server never completes, so the driver's cap
// must stop the loop rather than spin forever.
connected.addEventListener("newPendingElicitation", (event) => {
void event.detail.respond({ action: "accept", content: { value: "x" } });
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_loop");
await expect(connected.callTool(tool!, {})).rejects.toThrow(
/exceeded .* input_required rounds/,
);
});
it("handles requestState-only and inputRequests-only rounds (param shaping)", async () => {
const started = await startMrtrServer(createMrtrEdgeCaseTool());
const connected = await connectWithEra(started.url, "modern");
const toolCallFrames = collectToolCallRequests(connected);
connected.addEventListener("newPendingElicitation", (event) => {
void event.detail.respond({ action: "accept", content: { value: "n" } });
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_edge");
const result = await connected.callTool(tool!, {});
expect(result.success).toBe(true);
const content = result.result!.content as ContentBlock[];
const text = content[0] && "text" in content[0] ? content[0].text : "";
expect(text).toContain("MRTR edge complete");
// Three rounds: original + two retries.
expect(toolCallFrames.length).toBe(3);
// Round-1 retry carries inputResponses but NO requestState (round 1 minted
// none).
const retry1 = toolCallFrames[1].params as {
inputResponses?: unknown;
requestState?: unknown;
};
expect(retry1.inputResponses).toBeDefined();
expect(retry1.requestState).toBeUndefined();
// Round-2 retry carries requestState and no meaningful inputResponses (round
// 2 was requestState-only; the driver adds none — the modern SDK codec may
// serialize an empty `{}` on the wire).
const retry2 = toolCallFrames[2].params as {
inputResponses?: Record<string, unknown>;
requestState?: unknown;
};
expect(retry2.requestState).toBeDefined();
expect(Object.keys(retry2.inputResponses ?? {})).toHaveLength(0);
});
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
const started = await startMrtrServer(createMrtrTool());
const connected = await connectWithEra(started.url, "modern");
// Cancel the tool call the moment its embedded elicitation is surfaced,
// instead of answering it.
connected.addEventListener("newPendingElicitation", () => {
connected.cancelToolCall();
});
const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "mrtr_confirm");
await expect(
connected.callTool(tool!, { action: "deploy" }),
).rejects.toThrow(ToolCallCancelledError);
// The pending elicitation was removed when the call aborted.
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(
{ type: "streamable-http", url: started.url },
{
environment: { transport: createTransportNode },
versionNegotiation: eraToVersionNegotiation("legacy"),
},
);
client = failing;
await expect(failing.connect()).rejects.toThrow();
});
});