Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1170,6 +1179,7 @@ function App() {
setConsoleUi(EMPTY_CONSOLE_UI);
setProgressByTaskId({});
setCurrentLogLevel("info");
setModernLogLevel(null);
setPendingStepUp(null);
setPendingReauth(null);
setReAuthBanner(null);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -4264,6 +4291,8 @@ function App() {
onClearCompletedTasks={onClearCompletedTasks}
onRefreshTasks={onRefreshTasks}
onSetLogLevel={onSetLogLevel}
modernLogLevel={modernLogLevel}
onSetModernLogLevel={onSetModernLogLevel}
onLogsUiChange={setLogsUi}
onClearLogs={onClearLogs}
onExportLogs={onExportLogs}
Expand Down Expand Up @@ -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={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const meta: Meta<typeof LogControls> = {
onFilterChange: fn(),
onToggleLevel: fn(),
onToggleAllLevels: fn(),
onSetModernLogLevel: fn(),
},
};

Expand Down Expand Up @@ -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",
},
};
80 changes: 80 additions & 0 deletions clients/web/src/components/groups/LogControls/LogControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
129 changes: 111 additions & 18 deletions clients/web/src/components/groups/LogControls/LogControls.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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;
Expand All @@ -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&apos;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,
Expand All @@ -48,6 +144,9 @@ export function LogControls({
onFilterChange,
onToggleLevel,
onToggleAllLevels,
protocolEra,
modernLogLevel = null,
onSetModernLogLevel,
}: LogControlsProps) {
return (
<Stack gap="md">
Expand All @@ -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>
Expand Down
Loading
Loading