diff --git a/.gitignore b/.gitignore index 09f8427e4..949abb829 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 845f4d7de..a413e47ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index d55945a84..60b1e814d 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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("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( + 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={ diff --git a/clients/web/src/components/groups/LogControls/LogControls.stories.tsx b/clients/web/src/components/groups/LogControls/LogControls.stories.tsx index 90bec5a65..d2186f694 100644 --- a/clients/web/src/components/groups/LogControls/LogControls.stories.tsx +++ b/clients/web/src/components/groups/LogControls/LogControls.stories.tsx @@ -22,6 +22,7 @@ const meta: Meta = { 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", + }, +}; diff --git a/clients/web/src/components/groups/LogControls/LogControls.test.tsx b/clients/web/src/components/groups/LogControls/LogControls.test.tsx index b9970fc06..d346126c9 100644 --- a/clients/web/src/components/groups/LogControls/LogControls.test.tsx +++ b/clients/web/src/components/groups/LogControls/LogControls.test.tsx @@ -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(); + // 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( + , + ); + expect( + screen.getAllByDisplayValue("Off (no logs)").length, + ).toBeGreaterThan(0); + }); + + it("shows the stamped level when opted in", () => { + renderWithMantine( + , + ); + expect(screen.getAllByDisplayValue("warning").length).toBeGreaterThan(0); + }); + + it("invokes onSetModernLogLevel with the chosen level", async () => { + const user = userEvent.setup(); + const onSetModernLogLevel = vi.fn(); + renderWithMantine( + , + ); + 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( + , + ); + 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); + }); + }); }); diff --git a/clients/web/src/components/groups/LogControls/LogControls.tsx b/clients/web/src/components/groups/LogControls/LogControls.tsx index d4da7a2b9..ebdc2e090 100644 --- a/clients/web/src/components/groups/LogControls/LogControls.tsx +++ b/clients/web/src/components/groups/LogControls/LogControls.tsx @@ -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 = { 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) => ( + <> + Set Active Level + + { + 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 ( @@ -63,23 +162,17 @@ export function LogControls({ } /> - Set Active Level - - { + // 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} + /> + )} { ); }); + it("maps the selected modern log level into settings (#1629)", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + // 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( + , + ); + 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(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index 7d1ccb87c..cf8af3181 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -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} diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx index 8f2346692..9e1e618ee 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx @@ -12,6 +12,7 @@ const meta: Meta = { 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", + }, +}; diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index 942d208c5..0c4a0bba3 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -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(); + 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( + , + ); + 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(); expect(screen.getByText("Log Stream")).toBeInTheDocument(); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index c8515cd22..a02c9b1b2 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -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 }) } diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 829ffdae3..a15260e77 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -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, diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 6b6b6be7f..e2b33ee1f 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -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: { diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index a2e8e33bb..6251340d5 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -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 { + const params = frame.params as { _meta?: Record }; + 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 { + 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 }) + .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( diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 14bcf9a54..13c57f75c 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -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) ?? {}; + expect(meta[LOG_LEVEL_META_KEY]).toBeUndefined(); + messageLogState.destroy(); + }); + it("should track stderr logs for stdio transport", async () => { client = new InspectorClient( { diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index afb19929b..e6b998123 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -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", ]) { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 932d73b1e..e9d8033c3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -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>(); 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, ): Record | 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. */ diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index ea6310840..fc6600e7d 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -36,6 +36,7 @@ import { AuthChallengeError } from "../../../auth/challenge.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, + isModernLogLevel, } from "../../types.js"; import type { InspectorServerSettings, @@ -1186,6 +1187,16 @@ export function createRemoteApp( ); delete valObj.protocolEra; } + if ( + "modernLogLevel" in valObj && + !isModernLogLevel(valObj.modernLogLevel) + ) { + logWarn( + { route: "/api/servers", id, droppedKey: "modernLogLevel" }, + "Dropping malformed `modernLogLevel` field — expected 'off' or a logging level.", + ); + delete valObj.modernLogLevel; + } out[id] = normalizeServerType( valObj as Record & { type?: string }, @@ -1463,6 +1474,18 @@ export function createRemoteApp( error: "settings.protocolEra must be 'legacy', 'auto', or 'modern'", }; } + // modernLogLevel is optional on the wire; when present it must be "off" or + // one of the eight logging levels, otherwise it defaults to absent below. + if ( + obj.modernLogLevel !== undefined && + !isModernLogLevel(obj.modernLogLevel) + ) { + return { + ok: false, + error: + "settings.modernLogLevel must be 'off' or a logging level (debug…emergency)", + }; + } // Build the validated value from explicitly named fields rather than // casting the raw object through. Unknown keys silently drop so a // misconfigured client can't smuggle stowaways onto disk, and consumers @@ -1528,6 +1551,11 @@ export function createRemoteApp( if (isProtocolEra(obj.protocolEra)) { value.protocolEra = obj.protocolEra; } + // Optional; carry a valid modern log level. Absence reads back as the + // default (DEFAULT_MODERN_LOG_LEVEL) downstream. + if (isModernLogLevel(obj.modernLogLevel)) { + value.modernLogLevel = obj.modernLogLevel; + } // Empty cwd coerces to absent on the value (matching the read side); the // write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted" // (preserve) via `cwdProvided` below rather than the value alone. diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 278c555d2..57cc6c1d7 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -7,8 +7,10 @@ import { DEFAULT_MAX_FETCH_REQUESTS, + DEFAULT_MODERN_LOG_LEVEL, DEFAULT_PROTOCOL_ERA, DEFAULT_TASK_TTL_MS, + isModernLogLevel, } from "./types.js"; import type { Root } from "@modelcontextprotocol/client"; import type { @@ -116,6 +118,7 @@ type StoredInspectorFields = Pick< | "headers" | "metadata" | "protocolEra" + | "modernLogLevel" | "connectionTimeout" | "requestTimeout" | "taskTtl" @@ -192,6 +195,7 @@ export function storedFieldsToInspectorSettings( stored.oauth !== undefined || stored.roots !== undefined || stored.protocolEra !== undefined || + stored.modernLogLevel !== undefined || stored.env !== undefined || stored.cwd !== undefined; if (!hasAny) return undefined; @@ -227,6 +231,12 @@ export function storedFieldsToInspectorSettings( if (isProtocolEra(stored.protocolEra)) { settings.protocolEra = stored.protocolEra; } + // Like `protocolEra`: absent reads back as the default modern log level (the + // form defaults via `?? DEFAULT_MODERN_LOG_LEVEL`), and an unknown literal from + // a hand-edited file is dropped rather than surfaced. + if (isModernLogLevel(stored.modernLogLevel)) { + settings.modernLogLevel = stored.modernLogLevel; + } // Truthiness drops empty-string OAuth fields — mirrors the write-side // coercion in `validateSettings` (server.ts) so a round-trip can't // accidentally surface `oauthClientId: ""` to the form, where the @@ -311,6 +321,16 @@ export function inspectorSettingsToStoredFields( out.protocolEra = settings.protocolEra; } + // Persist only when it differs from the default modern log level; absent reads + // back as DEFAULT_MODERN_LOG_LEVEL, so writing the default would inject the + // field into files that never set it and break byte-stable round-trips. + if ( + settings.modernLogLevel !== undefined && + settings.modernLogLevel !== DEFAULT_MODERN_LOG_LEVEL + ) { + out.modernLogLevel = settings.modernLogLevel; + } + // Persist only when it differs from the default. Unlike the timeouts, 0 is a // meaningful value here (unlimited), so the omit-sentinel is the default // itself rather than 0 — writing the default would inject the field into @@ -363,6 +383,7 @@ const INSPECTOR_FIELD_KEY_MAP = { headers: true, metadata: true, protocolEra: true, + modernLogLevel: true, connectionTimeout: true, requestTimeout: true, taskTtl: true, diff --git a/core/mcp/types.ts b/core/mcp/types.ts index cefc10bd3..2b4cb8633 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -110,6 +110,13 @@ export type StoredMCPServer = MCPServerConfig & { * (`"legacy"`). (#1626) */ protocolEra?: ServerProtocolEra; + /** + * Modern-era per-request log level stamped by default (`"off"` or one of the + * eight logging levels). Inspector-specific. Omitted on disk when it equals + * `DEFAULT_MODERN_LOG_LEVEL` (`"debug"`). Only affects modern connections. + * (#1629) + */ + modernLogLevel?: ModernLogLevel; /** Inspector-specific connect-time timeout (ms). */ connectionTimeout?: number; /** Inspector-specific request timeout (ms). */ @@ -480,6 +487,45 @@ export type ServerProtocolEra = "legacy" | "auto" | "modern"; /** The default per-server protocol era when none is configured. */ export const DEFAULT_PROTOCOL_ERA: ServerProtocolEra = "legacy"; +/** + * Per-server modern (2026-07-28) per-request log level (#1629). `logging/setLevel` + * is gone on the modern era; instead the client opts into logs by stamping + * `_meta["io.modelcontextprotocol/logLevel"]` on each request. This setting is + * the level stamped by default on a modern connection — one of the eight logging + * levels, or `"off"` to not opt in (no server logs). Legacy connections ignore + * it (they use the session-scoped `logging/setLevel` instead). + */ +export type ModernLogLevel = LoggingLevel | "off"; + +/** + * The default modern per-request log level when none is configured. Defaults to + * opted-in at the most verbose level so a modern connection surfaces server logs + * out of the box (the Inspector is a debugging tool); set `"off"` per server to + * opt back out. + */ +export const DEFAULT_MODERN_LOG_LEVEL: ModernLogLevel = "debug"; + +/** All modern-log-level values, for form options and the runtime guard. */ +export const MODERN_LOG_LEVELS: ModernLogLevel[] = [ + "off", + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency", +]; + +/** Runtime guard for the {@link ModernLogLevel} literal (hand-edited files). */ +export function isModernLogLevel(value: unknown): value is ModernLogLevel { + return ( + typeof value === "string" && + (MODERN_LOG_LEVELS as string[]).includes(value) + ); +} + /** * The modern protocol revision `"modern"` era pins to. The successor to * 2025-11-25; the first revision with the per-request-metadata / sessionless @@ -582,6 +628,14 @@ export interface InspectorServerSettings { * omitted when it equals the default, keeping the file diff minimal. */ protocolEra?: ServerProtocolEra; + /** + * Modern-era per-request log level stamped by default on this server's + * connections (#1629). One of the eight logging levels, or `"off"` to not opt + * in. Absence means {@link DEFAULT_MODERN_LOG_LEVEL} (`"debug"`). Only affects + * modern (2026-07-28) connections; legacy uses `logging/setLevel`. Persisted + * on disk as `modernLogLevel` and omitted when it equals the default. + */ + modernLogLevel?: ModernLogLevel; } /** diff --git a/pr-screenshots/01-tools-all-pages-default.png b/pr-screenshots/01-tools-all-pages-default.png deleted file mode 100644 index 6b9ed126a..000000000 Binary files a/pr-screenshots/01-tools-all-pages-default.png and /dev/null differ diff --git a/pr-screenshots/02-tools-single-page-first.png b/pr-screenshots/02-tools-single-page-first.png deleted file mode 100644 index 5232b0fda..000000000 Binary files a/pr-screenshots/02-tools-single-page-first.png and /dev/null differ diff --git a/pr-screenshots/03-tools-single-page-load-more.png b/pr-screenshots/03-tools-single-page-load-more.png deleted file mode 100644 index 1e8e3bc1e..000000000 Binary files a/pr-screenshots/03-tools-single-page-load-more.png and /dev/null differ diff --git a/pr-screenshots/04-server-setting-checkbox.png b/pr-screenshots/04-server-setting-checkbox.png deleted file mode 100644 index 9f6a7b7dc..000000000 Binary files a/pr-screenshots/04-server-setting-checkbox.png and /dev/null differ diff --git a/pr-screenshots/README.md b/pr-screenshots/README.md deleted file mode 100644 index 4c18af574..000000000 --- a/pr-screenshots/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Single-page pagination — proof of functionality (#1721) - -Captured against `test-servers/configs/pagination-http.json` (12 tools / -resources / prompts, `maxPageSize` 4 → 3 pages each), driven through the web app. - -| # | Screenshot | What it shows | -| - | ---------- | ------------- | -| 01 | `01-tools-all-pages-default.png` | Default **all-pages** mode: the **Paginated** switch is off and the sidebar shows the full aggregated list (all 12 tools). | -| 02 | `02-tools-single-page-first.png` | **Paginated** on: only page 1 (4 tools). The row is *Paginated (left) … Load next page (right)* with *1 page loaded* on its own line below. | -| 03 | `03-tools-single-page-load-more.png` | After clicking **Load next page**: the next 4 tools are appended (8 total) and the status reads *2 pages loaded*. | -| 04 | `04-server-setting-checkbox.png` | Server Settings → **Fetch Lists One Page at a Time** is checked — the sidebar toggle persisted the server-wide setting. | diff --git a/pr-screenshots/logging-legacy-era.png b/pr-screenshots/logging-legacy-era.png new file mode 100644 index 000000000..65b890738 Binary files /dev/null and b/pr-screenshots/logging-legacy-era.png differ diff --git a/pr-screenshots/logging-modern-era-control.png b/pr-screenshots/logging-modern-era-control.png new file mode 100644 index 000000000..7f5f62704 Binary files /dev/null and b/pr-screenshots/logging-modern-era-control.png differ diff --git a/pr-screenshots/logging-modern-era-loglevel-stamp.png b/pr-screenshots/logging-modern-era-loglevel-stamp.png new file mode 100644 index 000000000..ef6adab2c Binary files /dev/null and b/pr-screenshots/logging-modern-era-loglevel-stamp.png differ diff --git a/pr-screenshots/logging-modern-server-setting.png b/pr-screenshots/logging-modern-server-setting.png new file mode 100644 index 000000000..fb48084e8 Binary files /dev/null and b/pr-screenshots/logging-modern-server-setting.png differ diff --git a/test-servers/configs/logging-legacy-http.json b/test-servers/configs/logging-legacy-http.json new file mode 100644 index 000000000..7924214df --- /dev/null +++ b/test-servers/configs/logging-legacy-http.json @@ -0,0 +1,12 @@ +{ + "serverInfo": { + "name": "logging-legacy", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "send_notification" }], + "logging": true, + "transport": { + "type": "streamable-http", + "port": 3210 + } +} diff --git a/test-servers/configs/logging-modern-http.json b/test-servers/configs/logging-modern-http.json new file mode 100644 index 000000000..5e84ca5d0 --- /dev/null +++ b/test-servers/configs/logging-modern-http.json @@ -0,0 +1,13 @@ +{ + "serverInfo": { + "name": "logging-modern", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "send_notification" }], + "logging": true, + "transport": { + "type": "streamable-http", + "port": 3211, + "modern": true + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 3ddb7f9cd..b814e5191 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -82,6 +82,14 @@ export interface HandlerExtra { method: string; params?: Record; }) => Promise; + /** + * Request-scoped, threshold-gated logging (the SDK's `ctx.mcpReq.log`). Emits + * a `notifications/message` only when the connection's negotiated level admits + * it — the modern per-request `logLevel` opt-in, or the legacy session level — + * and streams it on this request's response. Prefer over {@link sendNotification} + * for server logs so the era-correct gating is applied for you. + */ + log?: (level: string, data: unknown, logger?: string) => Promise | void; /** * MRTR (2026-07-28): the bare input responses a retried request echoes back, * keyed by the identifiers the server assigned in `inputRequests`. Present @@ -102,6 +110,17 @@ interface McpReqContext { signal?: AbortSignal; send?: HandlerExtra["sendRequest"]; notify?: HandlerExtra["sendNotification"]; + /** + * Request-scoped logging helper the SDK adds in `McpServer.buildContext`. + * It applies the era-correct threshold gating for us: 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`. Emits through `notify`, so on the + * modern leg the response upgrades to SSE and the log rides this request's + * stream. Prefer this over a raw `notify`/`notification` for logs. + */ + log?: HandlerExtra["log"]; /** MRTR input responses on a retried request (2026-07-28). */ inputResponses?: Record; /** MRTR request-state accessor (resolves the echoed opaque token). */ @@ -118,6 +137,7 @@ function toHandlerExtra(ctx: ServerContext | undefined): HandlerExtra { signal: mcpReq?.signal, sendRequest: mcpReq?.send, sendNotification: mcpReq?.notify, + log: mcpReq?.log?.bind(mcpReq), inputResponses: mcpReq?.inputResponses, requestState: mcpReq?.requestState?.(), }; diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 2cd1cdb69..4a5419799 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -231,10 +231,7 @@ export function createGetWeatherTool(): ToolDefinition { description: "Get the weather for a city (its `city` argument mirrors to Mcp-Param-City)", inputSchema: { - city: z - .string() - .describe("City name") - .meta({ "x-mcp-header": "City" }), + city: z.string().describe("City name").meta({ "x-mcp-header": "City" }), }, handler: async (params: Record) => { return toToolResult(`Weather in ${params.city as string}: sunny, 24°C`); @@ -912,29 +909,33 @@ export function createSendNotificationTool(): ToolDefinition { handler: async ( params: Record, context?: TestServerContext, + extra?: HandlerExtra, ): Promise => { if (!context) { throw new Error("Server context not available"); } - const server = context.server; const message = params.message as string; const level = (params.level as string) || "info"; - // Send a notification from the server - // Notifications don't have an id and use the jsonrpc format + // Emit the log through the SDK's request-scoped, threshold-gated + // `extra.log` (`ctx.mcpReq.log`) when available. It applies the era-correct + // gating for us: on the modern (2026-07-28) leg it drops the message unless + // the client opted in via the per-request `logLevel` `_meta` (and honors + // that level's severity), and streams the admitted log on THIS request's + // SSE response; on legacy it honors the session level from + // `logging/setLevel`. The global `server.server.notification()` fallback is + // for any caller without per-request context (older/in-process paths) and + // emits unconditionally on the session transport. try { - await server.server.notification({ - method: "notifications/message", - params: { - level, - logger: "test-server", - data: { - message, - }, - }, - }); - + if (extra?.log) { + await extra.log(level, { message }, "test-server"); + } else { + await context.server.server.notification({ + method: "notifications/message", + params: { level, logger: "test-server", data: { message } }, + }); + } return toToolResult(`Notification sent: ${message}`); } catch (error) { console.error("[send_notification] Error sending notification:", error); @@ -1551,8 +1552,7 @@ export function createAddToolTool(): ToolDefinition { { description: params.description as string, inputSchema: params.inputSchema as - | Record - | undefined, + Record | undefined, }, async () => { return { @@ -1656,8 +1656,7 @@ export function createAddPromptTool(): ToolDefinition { { description: params.description as string | undefined, argsSchema: params.argsSchema as - | Record - | undefined, + Record | undefined, }, async () => { return { @@ -1801,9 +1800,7 @@ export function createSendProgressTool( // Extract progressToken from metadata const progressToken = extra?._meta?.progressToken as - | string - | number - | undefined; + string | number | undefined; // Send progress notifications let sent = 0; @@ -2239,12 +2236,9 @@ export function createTaskTool( handler: { createTask: async (args, extra) => { const message = (args as Record)?.message as - | string - | undefined; + string | undefined; const progressToken = extra._meta?.progressToken as - | string - | number - | undefined; + string | number | undefined; const task = await extra.taskStore.createTask({}); runTaskExecution({ task,