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
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:
parent
b3b04c6939
commit
ec185f1053
36 changed files with 1087 additions and 67 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,5 +17,6 @@ test-servers/build
|
|||
# Should never be part of the source tree.
|
||||
/*.png
|
||||
/inspector-history-*.json
|
||||
/inspector-network-*.json
|
||||
/*.server.json
|
||||
/configs/
|
||||
|
|
|
|||
|
|
@ -114,7 +114,9 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era**
|
|||
|
||||
`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active.
|
||||
|
||||
`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an *N pages loaded* status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
|
||||
`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect.
|
||||
|
||||
`test-servers/configs/logging-legacy-http.json` and `test-servers/configs/logging-modern-http.json` are the **logging era-fork showcase** (#1629). Both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level; the legacy one is a plain streamable-HTTP server (`logging/setLevel` era) and the modern one sets `transport.modern: true`. Connect to the legacy server and open the **Logs** tab to get the session-scoped **Set Active Level** selector + **Set** button; calling `send_notification` streams the log into the panel. Connect to the modern one with **Protocol Era = Modern** and the same tab instead shows the **Log Level per Request** control — pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body); calling `send_notification` then streams the log into the panel over the request's SSE response. Set the control back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`): on the modern 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`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream.
|
||||
|
||||
## Building
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import type {
|
|||
} from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_MODERN_LOG_LEVEL,
|
||||
DEFAULT_TASK_TTL_MS,
|
||||
eraToVersionNegotiation,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -780,6 +781,14 @@ function App() {
|
|||
// the parent keeps the current value locally.
|
||||
const [currentLogLevel, setCurrentLogLevel] = useState<LoggingLevel>("info");
|
||||
|
||||
// Modern-era per-request log level (#1629). `null` = not opted in (the modern
|
||||
// default: logs stay absent until the user picks a level, which the client
|
||||
// then stamps on every request's `_meta`). Separate from `currentLogLevel`
|
||||
// because the modern control has an "off" state legacy doesn't.
|
||||
const [modernLogLevel, setModernLogLevel] = useState<LoggingLevel | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// In-flight call panel state. Tracked here (rather than inside the
|
||||
// respective screens) so the panels can reflect pending → ok/error
|
||||
// transitions and so `onClear*` handlers can reset the panel without
|
||||
|
|
@ -1170,6 +1179,7 @@ function App() {
|
|||
setConsoleUi(EMPTY_CONSOLE_UI);
|
||||
setProgressByTaskId({});
|
||||
setCurrentLogLevel("info");
|
||||
setModernLogLevel(null);
|
||||
setPendingStepUp(null);
|
||||
setPendingReauth(null);
|
||||
setReAuthBanner(null);
|
||||
|
|
@ -2256,6 +2266,13 @@ function App() {
|
|||
});
|
||||
|
||||
setInspectorClient(client);
|
||||
// #1629: seed the live modern per-request log level from the server
|
||||
// setting so the Logs-tab control reflects what the client stamps by
|
||||
// default (the client was seeded the same way in its constructor). "off"
|
||||
// means not opted in (null). Only affects modern connections.
|
||||
const seededModernLevel =
|
||||
savedSettings?.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL;
|
||||
setModernLogLevel(seededModernLevel === "off" ? null : seededModernLevel);
|
||||
setManagedToolsState(new ManagedToolsState(client));
|
||||
setPagedToolsState(new PagedToolsState(client));
|
||||
setPagedPromptsState(new PagedPromptsState(client));
|
||||
|
|
@ -3243,6 +3260,16 @@ function App() {
|
|||
[inspectorClient, runWithCommandAuthRecovery],
|
||||
);
|
||||
|
||||
// Modern era (#1629): no request is sent — the client stores the level and
|
||||
// stamps it on every subsequent request's `_meta`. `null` opts back out.
|
||||
const onSetModernLogLevel = useCallback(
|
||||
(level: LoggingLevel | null) => {
|
||||
setModernLogLevel(level);
|
||||
inspectorClient?.setModernLogLevel(level ?? undefined);
|
||||
},
|
||||
[inspectorClient],
|
||||
);
|
||||
|
||||
// Refresh acts per pagination mode: in paginated mode reload page 1 (the
|
||||
// paged state); in all-pages mode re-fetch the whole aggregate with auth
|
||||
// recovery (the pre-existing path). See usePaginatedList / #1721.
|
||||
|
|
@ -4264,6 +4291,8 @@ function App() {
|
|||
onClearCompletedTasks={onClearCompletedTasks}
|
||||
onRefreshTasks={onRefreshTasks}
|
||||
onSetLogLevel={onSetLogLevel}
|
||||
modernLogLevel={modernLogLevel}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
onLogsUiChange={setLogsUi}
|
||||
onClearLogs={onClearLogs}
|
||||
onExportLogs={onExportLogs}
|
||||
|
|
@ -4323,6 +4352,16 @@ function App() {
|
|||
settings={settingsModalValue}
|
||||
serverType={settingsModalServerType}
|
||||
isStdio={settingsModalIsStdio}
|
||||
// The negotiated era only applies when this settings modal targets the
|
||||
// live-connected server; otherwise the server isn't connected and the
|
||||
// era is unknown (#1629). Lets the form hide the modern log-level control
|
||||
// once an `auto` server resolves to legacy.
|
||||
negotiatedEra={
|
||||
connectionStatus === "connected" &&
|
||||
settingsModalTargetId === activeServerId
|
||||
? protocolEra
|
||||
: undefined
|
||||
}
|
||||
onClose={onSettingsModalClose}
|
||||
onSettingsChange={onSettingsChange}
|
||||
onClearStoredOAuth={
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const meta: Meta<typeof LogControls> = {
|
|||
onFilterChange: fn(),
|
||||
onToggleLevel: fn(),
|
||||
onToggleAllLevels: fn(),
|
||||
onSetModernLogLevel: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -68,3 +69,26 @@ export const DebugLevel: Story = {
|
|||
visibleLevels: allLevelsVisible,
|
||||
},
|
||||
};
|
||||
|
||||
// Modern era (#1629): the session-scoped `Set` selector is replaced by the
|
||||
// per-request opt-in control. Not opted in yet, so the level reads "Off".
|
||||
export const ModernNotOptedIn: Story = {
|
||||
args: {
|
||||
currentLevel: "info",
|
||||
filterText: "",
|
||||
visibleLevels: allLevelsVisible,
|
||||
protocolEra: "modern",
|
||||
modernLogLevel: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Modern era with a level chosen — stamped on every request's `_meta`.
|
||||
export const ModernOptedIn: Story = {
|
||||
args: {
|
||||
currentLevel: "info",
|
||||
filterText: "",
|
||||
visibleLevels: allLevelsVisible,
|
||||
protocolEra: "modern",
|
||||
modernLogLevel: "debug",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -202,4 +202,84 @@ describe("LogControls", () => {
|
|||
await user.click(screen.getByRole("button", { name: "debug" }));
|
||||
expect(onToggleLevel).toHaveBeenCalledWith("debug", true);
|
||||
});
|
||||
|
||||
describe("modern era (#1629)", () => {
|
||||
it("replaces the legacy Set selector with the per-request control", () => {
|
||||
renderWithMantine(<LogControls {...baseProps} protocolEra="modern" />);
|
||||
// Legacy affordances are gone...
|
||||
expect(screen.queryByText("Set Active Level")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Set" })).toBeNull();
|
||||
// ...replaced by the per-request opt-in control + its explanation.
|
||||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/logs arrive on the originating request/i),
|
||||
).toBeInTheDocument();
|
||||
// Filter-by-level survives the era fork.
|
||||
expect(screen.getByText("Filter by Level")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Off when not opted in", () => {
|
||||
renderWithMantine(
|
||||
<LogControls
|
||||
{...baseProps}
|
||||
protocolEra="modern"
|
||||
modernLogLevel={null}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByDisplayValue("Off (no logs)").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the stamped level when opted in", () => {
|
||||
renderWithMantine(
|
||||
<LogControls
|
||||
{...baseProps}
|
||||
protocolEra="modern"
|
||||
modernLogLevel="warning"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByDisplayValue("warning").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("invokes onSetModernLogLevel with the chosen level", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSetModernLogLevel = vi.fn();
|
||||
renderWithMantine(
|
||||
<LogControls
|
||||
{...baseProps}
|
||||
protocolEra="modern"
|
||||
modernLogLevel={null}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByDisplayValue("Off (no logs)")[0]);
|
||||
const errorOption = await screen.findByRole("option", {
|
||||
name: "error",
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(errorOption);
|
||||
expect(onSetModernLogLevel).toHaveBeenCalledWith("error");
|
||||
});
|
||||
|
||||
it("invokes onSetModernLogLevel with null when Off is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSetModernLogLevel = vi.fn();
|
||||
renderWithMantine(
|
||||
<LogControls
|
||||
{...baseProps}
|
||||
protocolEra="modern"
|
||||
modernLogLevel="debug"
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByDisplayValue("debug")[0]);
|
||||
const offOption = await screen.findByRole("option", {
|
||||
name: "Off (no logs)",
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(offOption);
|
||||
expect(onSetModernLogLevel).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { Button, Group, Select, Stack, TextInput, Title } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { LoggingLevel } from "@modelcontextprotocol/client";
|
||||
import type { LoggingLevel, ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import { FilterToggleButton } from "../../elements/FilterToggleButton/FilterToggleButton";
|
||||
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
||||
|
||||
const LOG_LEVELS: LoggingLevel[] = [
|
||||
"debug",
|
||||
|
|
@ -25,11 +34,25 @@ const LEVEL_COLORS: Record<LoggingLevel, { c: string }> = {
|
|||
emergency: { c: "red" },
|
||||
};
|
||||
|
||||
// Sentinel `Select` value for "don't opt in" on the modern per-request control.
|
||||
// Not a valid `LoggingLevel`, so it can never collide with a real level.
|
||||
const MODERN_OFF_VALUE = "__off__";
|
||||
|
||||
const SubtleButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "xs",
|
||||
});
|
||||
|
||||
const HelpText = Text.withProps({
|
||||
size: "xs",
|
||||
c: "var(--inspector-text-secondary)",
|
||||
});
|
||||
|
||||
const LEVEL_OPTIONS = LOG_LEVELS.map((level) => ({
|
||||
value: level,
|
||||
label: level,
|
||||
}));
|
||||
|
||||
export interface LogControlsProps {
|
||||
currentLevel: LoggingLevel;
|
||||
filterText: string;
|
||||
|
|
@ -38,8 +61,81 @@ export interface LogControlsProps {
|
|||
onFilterChange: (text: string) => void;
|
||||
onToggleLevel: (level: LoggingLevel, visible: boolean) => void;
|
||||
onToggleAllLevels: () => void;
|
||||
/**
|
||||
* Negotiated protocol era. On the modern era (2026-07-28) `logging/setLevel`
|
||||
* is gone; the level selector is replaced by the per-request opt-in control
|
||||
* below. Undefined / legacy keeps the session-scoped `Set` selector (#1629).
|
||||
*/
|
||||
protocolEra?: ProtocolEra;
|
||||
/**
|
||||
* Modern-era per-request log level currently stamped on every request, or
|
||||
* `null` when not opted in (no logs). Only meaningful on the modern era.
|
||||
*/
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
}
|
||||
|
||||
// Legacy: session-scoped `logging/setLevel` — a level selector plus a "Set"
|
||||
// button that sends the request. The value is optimistic (there's no echo).
|
||||
const LegacyLevelControl = ({
|
||||
currentLevel,
|
||||
onSetLevel,
|
||||
}: Pick<LogControlsProps, "currentLevel" | "onSetLevel">) => (
|
||||
<>
|
||||
<Title order={5}>Set Active Level</Title>
|
||||
<Group wrap="nowrap">
|
||||
<Select
|
||||
aria-label="Set Active Level"
|
||||
flex={1}
|
||||
data={LEVEL_OPTIONS}
|
||||
value={currentLevel}
|
||||
onChange={(value) => {
|
||||
if (value && LOG_LEVELS.includes(value as LoggingLevel)) {
|
||||
onSetLevel(value as LoggingLevel);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={() => onSetLevel(currentLevel)}>
|
||||
Set
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
|
||||
// Modern: per-request opt-in via the `io.modelcontextprotocol/logLevel` `_meta`
|
||||
// key. There is no session level and no `Set` — the chosen level is stamped on
|
||||
// every subsequent request and takes effect immediately. "Off" stops opting in.
|
||||
const ModernLevelControl = ({
|
||||
modernLogLevel,
|
||||
onSetModernLogLevel,
|
||||
}: Pick<LogControlsProps, "modernLogLevel" | "onSetModernLogLevel">) => (
|
||||
<>
|
||||
<Title order={5}>Log Level per Request</Title>
|
||||
<HelpText>
|
||||
Modern servers only emit logs for requests that opt in. The level you
|
||||
choose is stamped on every request, and logs arrive on the originating
|
||||
request's stream. Choose Off to stop requesting logs.
|
||||
</HelpText>
|
||||
<Select
|
||||
aria-label="Log Level per Request"
|
||||
data={[
|
||||
{ value: MODERN_OFF_VALUE, label: "Off (no logs)" },
|
||||
...LEVEL_OPTIONS,
|
||||
]}
|
||||
value={modernLogLevel ?? MODERN_OFF_VALUE}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => {
|
||||
if (value === MODERN_OFF_VALUE) {
|
||||
onSetModernLogLevel?.(null);
|
||||
} else if (value && LOG_LEVELS.includes(value as LoggingLevel)) {
|
||||
onSetModernLogLevel?.(value as LoggingLevel);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export function LogControls({
|
||||
currentLevel,
|
||||
filterText,
|
||||
|
|
@ -48,6 +144,9 @@ export function LogControls({
|
|||
onFilterChange,
|
||||
onToggleLevel,
|
||||
onToggleAllLevels,
|
||||
protocolEra,
|
||||
modernLogLevel = null,
|
||||
onSetModernLogLevel,
|
||||
}: LogControlsProps) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
|
@ -63,23 +162,17 @@ export function LogControls({
|
|||
}
|
||||
/>
|
||||
|
||||
<Title order={5}>Set Active Level</Title>
|
||||
<Group wrap="nowrap">
|
||||
<Select
|
||||
aria-label="Set Active Level"
|
||||
flex={1}
|
||||
data={LOG_LEVELS.map((level) => ({ value: level, label: level }))}
|
||||
value={currentLevel}
|
||||
onChange={(value) => {
|
||||
if (value && LOG_LEVELS.includes(value as LoggingLevel)) {
|
||||
onSetLevel(value as LoggingLevel);
|
||||
}
|
||||
}}
|
||||
{isModernEra(protocolEra) ? (
|
||||
<ModernLevelControl
|
||||
modernLogLevel={modernLogLevel}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
/>
|
||||
<Button size="sm" onClick={() => onSetLevel(currentLevel)}>
|
||||
Set
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<LegacyLevelControl
|
||||
currentLevel={currentLevel}
|
||||
onSetLevel={onSetLevel}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Title order={5}>Filter by Level</Title>
|
||||
|
|
|
|||
|
|
@ -149,6 +149,12 @@ function InteractiveRender(args: ServerSettingsFormProps) {
|
|||
settings: { ...args.settings, protocolEra: value },
|
||||
});
|
||||
}}
|
||||
onModernLogLevelChange={(value) => {
|
||||
args.onModernLogLevelChange(value);
|
||||
updateArgs({
|
||||
settings: { ...args.settings, modernLogLevel: value },
|
||||
});
|
||||
}}
|
||||
onOAuthChange={(oauth) => {
|
||||
args.onOAuthChange(oauth);
|
||||
updateArgs({
|
||||
|
|
@ -187,6 +193,7 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
|||
onPaginatedListsChange: fn(),
|
||||
onMaxFetchRequestsChange: fn(),
|
||||
onProtocolEraChange: fn(),
|
||||
onModernLogLevelChange: fn(),
|
||||
onOAuthChange: fn(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const baseHandlers = {
|
|||
onPaginatedListsChange: vi.fn(),
|
||||
onMaxFetchRequestsChange: vi.fn(),
|
||||
onProtocolEraChange: vi.fn(),
|
||||
onModernLogLevelChange: vi.fn(),
|
||||
onOAuthChange: vi.fn(),
|
||||
onAddRoot: vi.fn(),
|
||||
onRemoveRoot: vi.fn(),
|
||||
|
|
@ -130,6 +131,110 @@ describe("ServerSettingsForm", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The modern per-request control only shows for a modern-capable era. Base
|
||||
// these tests on a modern-pinned settings object so the Select renders.
|
||||
const modernSettings = { ...emptySettings, protocolEra: "modern" as const };
|
||||
|
||||
it("defaults the Log Level per Request select to Debug when unset (#1629)", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={modernSettings}
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
// Two selects can display "debug" (the value plus its hidden input), so
|
||||
// assert at least one and that the control's label is present.
|
||||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
expect(screen.getAllByDisplayValue("debug").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("invokes onModernLogLevelChange with the selected level (#1629)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={modernSettings}
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByDisplayValue("debug")[0]);
|
||||
await user.click(screen.getByText("warning"));
|
||||
expect(baseHandlers.onModernLogLevelChange).toHaveBeenCalledWith("warning");
|
||||
});
|
||||
|
||||
it("selects Off to opt out of per-request logs (#1629)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={modernSettings}
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByDisplayValue("debug")[0]);
|
||||
await user.click(screen.getByText("Off (no logs)"));
|
||||
expect(baseHandlers.onModernLogLevelChange).toHaveBeenCalledWith("off");
|
||||
});
|
||||
|
||||
it("reflects the configured modernLogLevel in the select value (#1629)", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...modernSettings, modernLogLevel: "off" }}
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue("Off (no logs)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Log Level per Request control when the era is legacy (#1629)", () => {
|
||||
// emptySettings has no protocolEra → defaults to legacy → control hidden.
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={emptySettings}
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Log Level per Request")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the control for an 'auto' server that negotiated legacy (#1629)", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, protocolEra: "auto" }}
|
||||
negotiatedEra="legacy"
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Log Level per Request")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the control for an 'auto' server not yet connected (era unknown) (#1629)", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, protocolEra: "auto" }}
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the control for an 'auto' server that negotiated modern (#1629)", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
settings={{ ...emptySettings, protocolEra: "auto" }}
|
||||
negotiatedEra="modern"
|
||||
expandedSections={["options"]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty hints for headers and metadata when no entries exist", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
|
|
|
|||
|
|
@ -12,13 +12,19 @@ import {
|
|||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
ModernLogLevel,
|
||||
OAuthSettings,
|
||||
ServerProtocolEra,
|
||||
ServerType,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { DEFAULT_PROTOCOL_ERA } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
DEFAULT_MODERN_LOG_LEVEL,
|
||||
DEFAULT_PROTOCOL_ERA,
|
||||
MODERN_LOG_LEVELS,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
|
||||
import type { Root } from "@modelcontextprotocol/client";
|
||||
|
||||
|
|
@ -61,6 +67,15 @@ export interface ServerSettingsFormProps {
|
|||
onPaginatedListsChange: (value: boolean) => void;
|
||||
onMaxFetchRequestsChange: (value: number) => void;
|
||||
onProtocolEraChange: (value: ServerProtocolEra) => void;
|
||||
onModernLogLevelChange: (value: ModernLogLevel) => void;
|
||||
/**
|
||||
* The era this server actually negotiated, when it is the live connection —
|
||||
* `undefined` when this server isn't the connected one. Used to hide the
|
||||
* modern-only "Log Level per Request" control once an `auto` server resolves
|
||||
* to legacy at connect time (#1629). A pinned `legacy` era hides it
|
||||
* regardless (no connection needed).
|
||||
*/
|
||||
negotiatedEra?: ProtocolEra;
|
||||
onOAuthChange: (oauth: OAuthSettings) => void;
|
||||
onClearStoredOAuth?: () => void;
|
||||
onAddRoot: () => void;
|
||||
|
|
@ -85,6 +100,22 @@ function isProtocolEra(value: string | null): value is ServerProtocolEra {
|
|||
return value !== null && PROTOCOL_ERA_VALUES.has(value as ServerProtocolEra);
|
||||
}
|
||||
|
||||
// Modern per-request log-level options (#1629): "Off" plus the eight levels.
|
||||
// Only meaningful on the modern era; labeled so the "Off" state reads clearly.
|
||||
const MODERN_LOG_LEVEL_OPTIONS: { value: ModernLogLevel; label: string }[] =
|
||||
MODERN_LOG_LEVELS.map((level) => ({
|
||||
value: level,
|
||||
label: level === "off" ? "Off (no logs)" : level,
|
||||
}));
|
||||
|
||||
const MODERN_LOG_LEVEL_VALUES: ReadonlySet<ModernLogLevel> = new Set(
|
||||
MODERN_LOG_LEVELS,
|
||||
);
|
||||
|
||||
function isModernLogLevelValue(value: string | null): value is ModernLogLevel {
|
||||
return value !== null && MODERN_LOG_LEVEL_VALUES.has(value as ModernLogLevel);
|
||||
}
|
||||
|
||||
const RemoveIcon = ActionIcon.withProps({
|
||||
color: "red",
|
||||
variant: "subtle",
|
||||
|
|
@ -243,6 +274,8 @@ export function ServerSettingsForm({
|
|||
onPaginatedListsChange,
|
||||
onMaxFetchRequestsChange,
|
||||
onProtocolEraChange,
|
||||
onModernLogLevelChange,
|
||||
negotiatedEra,
|
||||
onOAuthChange,
|
||||
onClearStoredOAuth,
|
||||
onAddRoot,
|
||||
|
|
@ -250,6 +283,15 @@ export function ServerSettingsForm({
|
|||
onRootChange,
|
||||
}: ServerSettingsFormProps) {
|
||||
const oauthCapable = isOAuthCapableServerType(serverType);
|
||||
// The modern per-request "Log Level per Request" control is meaningless on a
|
||||
// legacy connection (#1629). Hide it when legacy is pinned, or when an `auto`
|
||||
// server negotiated legacy at connect time. A pinned `modern` server, and an
|
||||
// `auto` server that is either not yet connected or resolved to modern, keep
|
||||
// it visible.
|
||||
const configuredEra = settings.protocolEra ?? DEFAULT_PROTOCOL_ERA;
|
||||
const showModernLogLevel =
|
||||
configuredEra === "modern" ||
|
||||
(configuredEra === "auto" && negotiatedEra !== "legacy");
|
||||
const handleMaxFetchRequestsChange = (value: number | string) => {
|
||||
if (typeof value === "number") {
|
||||
onMaxFetchRequestsChange(value);
|
||||
|
|
@ -310,6 +352,22 @@ export function ServerSettingsForm({
|
|||
}}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{showModernLogLevel && (
|
||||
<Select
|
||||
label="Log Level per Request"
|
||||
description="Modern-era only. On 2026-07-28 servers there is no logging/setLevel — the client opts into logs per request by stamping this level in each request's _meta, and logs arrive on the originating request's stream. Off requests no logs. Defaults to Debug so a modern connection surfaces server logs out of the box. Legacy servers ignore this and use Set Active Level."
|
||||
data={MODERN_LOG_LEVEL_OPTIONS}
|
||||
value={settings.modernLogLevel ?? DEFAULT_MODERN_LOG_LEVEL}
|
||||
onChange={(value) => {
|
||||
// Select emits `string | null`; not clearable, so the value
|
||||
// always resolves to a known option.
|
||||
/* v8 ignore next -- Select never emits an out-of-range value */
|
||||
if (isModernLogLevelValue(value))
|
||||
onModernLogLevelChange(value);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label="Auto Refresh on List Changed Notifications"
|
||||
description="When checked, tool/prompt/resource lists refresh automatically when the server sends a */list_changed notification. When unchecked, the list-changed indicator appears and you refresh on demand."
|
||||
|
|
|
|||
|
|
@ -139,6 +139,44 @@ describe("ServerSettingsModal", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("maps the selected modern log level into settings (#1629)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
// The modern log-level control only shows for a modern-capable era.
|
||||
settings={{ ...emptySettings, protocolEra: "modern" }}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
// The Options section (with Log Level per Request) is expanded by default;
|
||||
// it defaults to "debug".
|
||||
await user.click(screen.getAllByDisplayValue("debug")[0]);
|
||||
await user.click(screen.getByText("Off (no logs)"));
|
||||
expect(onSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modernLogLevel: "off" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => {
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={{ ...emptySettings, protocolEra: "auto" }}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
negotiatedEra="legacy"
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Log Level per Request")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when adding a header after expanding the section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, Stack } from "@mantine/core";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
ModernLogLevel,
|
||||
OAuthSettings,
|
||||
ServerProtocolEra,
|
||||
ServerType,
|
||||
|
|
@ -42,6 +44,13 @@ export interface ServerSettingsModalProps {
|
|||
* section.
|
||||
*/
|
||||
isStdio: boolean;
|
||||
/**
|
||||
* The era this server actually negotiated, when it is the live connection.
|
||||
* Forwarded to the form to hide the modern "Log Level per Request" control on
|
||||
* an `auto` server that resolved to legacy (#1629). Undefined when this server
|
||||
* isn't the connected one.
|
||||
*/
|
||||
negotiatedEra?: ProtocolEra;
|
||||
onClose: () => void;
|
||||
onSettingsChange: (settings: InspectorServerSettings) => void;
|
||||
onClearStoredOAuth?: () => void;
|
||||
|
|
@ -52,6 +61,7 @@ export function ServerSettingsModal({
|
|||
settings,
|
||||
serverType,
|
||||
isStdio,
|
||||
negotiatedEra,
|
||||
onClose,
|
||||
onSettingsChange,
|
||||
onClearStoredOAuth,
|
||||
|
|
@ -174,6 +184,10 @@ export function ServerSettingsModal({
|
|||
onSettingsChange({ ...settings, protocolEra: value });
|
||||
}
|
||||
|
||||
function handleModernLogLevelChange(value: ModernLogLevel) {
|
||||
onSettingsChange({ ...settings, modernLogLevel: value });
|
||||
}
|
||||
|
||||
function handleAddRoot() {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
|
|
@ -239,6 +253,8 @@ export function ServerSettingsModal({
|
|||
onPaginatedListsChange={handlePaginatedListsChange}
|
||||
onMaxFetchRequestsChange={handleMaxFetchRequestsChange}
|
||||
onProtocolEraChange={handleProtocolEraChange}
|
||||
onModernLogLevelChange={handleModernLogLevelChange}
|
||||
negotiatedEra={negotiatedEra}
|
||||
onOAuthChange={handleOAuthChange}
|
||||
onClearStoredOAuth={onClearStoredOAuth}
|
||||
onAddRoot={handleAddRoot}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const meta: Meta<typeof LoggingScreen> = {
|
|||
args: {
|
||||
currentLevel: "info",
|
||||
onSetLevel: fn(),
|
||||
onSetModernLogLevel: fn(),
|
||||
onClear: fn(),
|
||||
onExport: fn(),
|
||||
ui: EMPTY_LOGS_UI,
|
||||
|
|
@ -104,3 +105,13 @@ export const MixedLevels: Story = {
|
|||
entries: allLevelEntries,
|
||||
},
|
||||
};
|
||||
|
||||
// Modern era (#1629): the sidebar shows the per-request opt-in control instead
|
||||
// of the legacy `logging/setLevel` selector.
|
||||
export const ModernEra: Story = {
|
||||
args: {
|
||||
entries: mixedEntries,
|
||||
protocolEra: "modern",
|
||||
modernLogLevel: "debug",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -132,6 +132,32 @@ describe("LoggingScreen", () => {
|
|||
expect(onSetLevel).toHaveBeenCalledWith("info");
|
||||
});
|
||||
|
||||
it("shows the per-request control instead of the Set button on the modern era (#1629)", () => {
|
||||
renderWithMantine(<LoggingScreen {...baseProps} protocolEra="modern" />);
|
||||
expect(screen.queryByRole("button", { name: "Set" })).toBeNull();
|
||||
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards the chosen modern level via onSetModernLogLevel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSetModernLogLevel = vi.fn();
|
||||
renderWithMantine(
|
||||
<LoggingScreen
|
||||
{...baseProps}
|
||||
protocolEra="modern"
|
||||
modernLogLevel={null}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getAllByDisplayValue("Off (no logs)")[0]);
|
||||
const debugOption = await screen.findByRole("option", {
|
||||
name: "debug",
|
||||
hidden: true,
|
||||
});
|
||||
await user.click(debugOption);
|
||||
expect(onSetModernLogLevel).toHaveBeenCalledWith("debug");
|
||||
});
|
||||
|
||||
it("drops the filter sidebar when embedded, keeping the stream", () => {
|
||||
renderWithMantine(<LoggingScreen {...baseProps} embedded />);
|
||||
expect(screen.getByText("Log Stream")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Card, Flex, Stack } from "@mantine/core";
|
||||
import type { LoggingLevel } from "@modelcontextprotocol/client";
|
||||
import type { LoggingLevel, ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import { LogControls } from "../../groups/LogControls/LogControls";
|
||||
import { LogStreamPanel } from "../../groups/LogStreamPanel/LogStreamPanel";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
|
|
@ -12,6 +12,15 @@ export interface LoggingScreenProps {
|
|||
ui: LogsUiState;
|
||||
onUiChange: (next: LogsUiState) => void;
|
||||
onSetLevel: (level: LoggingLevel) => void;
|
||||
/**
|
||||
* Negotiated protocol era (#1629). On the modern era the level selector is
|
||||
* replaced by the per-request opt-in control; legacy keeps `logging/setLevel`.
|
||||
*/
|
||||
protocolEra?: ProtocolEra;
|
||||
/** Modern per-request log level currently stamped, or `null` when opted out. */
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
onClear: () => void;
|
||||
onExport: () => void;
|
||||
sortDirection: SortDirection;
|
||||
|
|
@ -54,6 +63,9 @@ export function LoggingScreen({
|
|||
ui,
|
||||
onUiChange,
|
||||
onSetLevel,
|
||||
protocolEra,
|
||||
modernLogLevel = null,
|
||||
onSetModernLogLevel,
|
||||
onClear,
|
||||
onExport,
|
||||
sortDirection,
|
||||
|
|
@ -93,6 +105,9 @@ export function LoggingScreen({
|
|||
filterText={filterText}
|
||||
visibleLevels={visibleLevels}
|
||||
onSetLevel={onSetLevel}
|
||||
protocolEra={protocolEra}
|
||||
modernLogLevel={modernLogLevel}
|
||||
onSetModernLogLevel={onSetModernLogLevel}
|
||||
onFilterChange={(value) =>
|
||||
onUiChange({ ...ui, filterText: value })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -536,6 +536,14 @@ export interface InspectorViewProps {
|
|||
onRefreshTasks: () => void;
|
||||
|
||||
onSetLogLevel: (level: LoggingLevel) => void;
|
||||
/**
|
||||
* Modern-era per-request log level currently stamped, or `null` when opted
|
||||
* out (#1629). On modern connections the Logs sidebar shows a per-request
|
||||
* opt-in control instead of the legacy `logging/setLevel` selector.
|
||||
*/
|
||||
modernLogLevel?: LoggingLevel | null;
|
||||
/** Set (or clear, with `null`) the modern per-request log level. */
|
||||
onSetModernLogLevel?: (level: LoggingLevel | null) => void;
|
||||
onLogsUiChange: (next: LogsUiState) => void;
|
||||
onClearLogs: () => void;
|
||||
onExportLogs: () => void;
|
||||
|
|
@ -652,6 +660,8 @@ export function InspectorView({
|
|||
onClearCompletedTasks,
|
||||
onRefreshTasks,
|
||||
onSetLogLevel,
|
||||
modernLogLevel = null,
|
||||
onSetModernLogLevel,
|
||||
onLogsUiChange,
|
||||
onClearLogs,
|
||||
onExportLogs,
|
||||
|
|
@ -1113,6 +1123,9 @@ export function InspectorView({
|
|||
ui: logsUi,
|
||||
onUiChange: onLogsUiChange,
|
||||
onSetLevel: onSetLogLevel,
|
||||
protocolEra,
|
||||
modernLogLevel,
|
||||
onSetModernLogLevel,
|
||||
onClear: onClearLogs,
|
||||
onExport: onExportLogs,
|
||||
sortDirection: logsSort,
|
||||
|
|
|
|||
|
|
@ -406,6 +406,56 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect("protocolEra" in (round.mcpServers["era-legacy"] ?? {})).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips modernLogLevel: lifts a non-default value to settings and back to disk (#1629)", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
"log-off": {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
modernLogLevel: "off",
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.modernLogLevel).toBe("off");
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("drops an unknown modernLogLevel literal on read (hand-edited file) (#1629)", () => {
|
||||
const badLevel: object = { modernLogLevel: "verbose" };
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
"log-bad": {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
...badLevel,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.modernLogLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits modernLogLevel from disk when it equals the default (debug) (#1629)", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
"log-default": {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
modernLogLevel: "debug",
|
||||
connectionTimeout: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.modernLogLevel).toBe("debug");
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect("modernLogLevel" in (round.mcpServers["log-default"] ?? {})).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue