Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6cf48a0
feat(servers): persist per-server settings to mcp.json (#1352)
cliffhall May 23, 2026
e8ac7f6
fix(servers): rebuild InspectorClient on every connect so settings re…
cliffhall May 23, 2026
fd0abfe
fix(history): stop wiping pendingRequestEntries inside the connect event
cliffhall May 23, 2026
777a6e5
chore: drop accidentally tracked debugging screenshot
cliffhall May 23, 2026
0392d41
test(servers): add e2e check that settings.headers reach upstream MCP
cliffhall May 23, 2026
ea4058e
fix(ServerSettingsModal): invert ListToggle compact prop so icon matc…
cliffhall May 23, 2026
ce71111
style(HistoryListPanel): match ServerListScreen Export button — renam…
cliffhall May 23, 2026
b31cc4b
style(HistoryListPanel): move Clear button to top toolbar, match Expo…
cliffhall May 23, 2026
de2861e
chore: ignore local debugging screenshots at repo root
cliffhall May 23, 2026
55bb56f
style(LogStreamPanel): match HistoryListPanel button styling
cliffhall May 23, 2026
2702ed2
fix(servers): address PR review — debounce save, preserve-on-omit PUT…
cliffhall May 24, 2026
425b625
fix(servers): address PR review pass 2 — connect-timeout test, unmoun…
cliffhall May 24, 2026
60189ff
fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail
cliffhall May 24, 2026
a4469d6
fix(servers): pass-4 review — strip smuggled settings out of config
cliffhall May 24, 2026
02801e1
fix(servers): pass-5 review — absorb late connect rejection, tighten …
cliffhall May 24, 2026
dac8069
fix(servers): pass-6 polish — scope absorber, thread id, drop empty O…
cliffhall May 24, 2026
7818144
docs(spec): reflect settings persistence + patch-PUT contract in v2_s…
cliffhall May 24, 2026
2678860
docs(spec): show a third entry with the optional settings node
cliffhall May 24, 2026
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ dist
test-servers/build
.env
/.playwright-mcp/

# Local debugging screenshots dropped at the repo root (e.g. attached to
# Claude Code conversations). Should never be part of the source tree.
/*.png
12 changes: 4 additions & 8 deletions clients/web/server/web-server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,29 +97,25 @@ export function webServerConfigToInitialPayload(
return {
defaultTransport: "sse",
defaultServerUrl: mc.url,
defaultHeaders: mc.headers ?? undefined,
defaultEnvironment,
};
}
if (mc.type === "streamable-http") {
return {
defaultTransport: "streamable-http",
defaultServerUrl: mc.url,
defaultHeaders: mc.headers ?? undefined,
defaultEnvironment,
};
}
// Forward-compat fallback: if a future SDK ships a new transport type the
// launcher hasn't taught us about yet, prefer streamable-http (the most
// permissive shape with `url` + optional `headers`) over throwing. Worst
// case the UI shows the URL the user supplied; throwing here would deny
// the user a hint at startup. Add a real branch above once the type is
// known.
const c = mc as unknown as { url: string; headers?: Record<string, string> };
// permissive shape with `url`) over throwing. Worst case the UI shows the
// URL the user supplied; throwing here would deny the user a hint at
// startup. Add a real branch above once the type is known.
const c = mc as unknown as { url: string };
return {
defaultTransport: "streamable-http",
defaultServerUrl: c.url,
defaultHeaders: c.headers,
defaultEnvironment,
};
}
Expand Down
161 changes: 155 additions & 6 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import type {
InitializeResult,
LoggingLevel,
Expand All @@ -10,6 +11,7 @@ import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type {
InspectorServerSettings,
MCPServerConfig,
MessageEntry,
ServerEntry,
Expand Down Expand Up @@ -45,6 +47,7 @@ import {
ServerConfigModal,
type ServerConfigModalMode,
} from "./components/groups/ServerConfigModal/ServerConfigModal";
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import { downloadJsonFile } from "./lib/downloadFile";
import { createWebEnvironment } from "./lib/environmentFactory";
Expand Down Expand Up @@ -135,7 +138,13 @@ function App() {
// Server list — sourced from ~/.mcp-inspector/mcp.json via the backend's
// `/api/servers` routes. First-launch seeds are written by the backend when
// the file is absent, so this hook returns a non-empty list on first load.
const { servers, addServer, updateServer, removeServer } = useServers({
const {
servers,
addServer,
updateServer,
updateServerSettings,
removeServer,
} = useServers({
baseUrl:
typeof window !== "undefined"
? window.location.origin
Expand All @@ -149,6 +158,9 @@ function App() {
mode: ServerConfigModalMode;
targetId?: string;
} | null>(null);
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
string | undefined
>(undefined);
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);

// The active connection target. `null` between sessions; set as soon as
Expand Down Expand Up @@ -336,6 +348,35 @@ function App() {
getAuthToken(),
redirectUrlProvider,
);
// The settings node persisted in mcp.json for this server — distinct
// from the InspectorClient options we're about to derive from it.
const savedSettings = server.settings;
// Flatten the persisted settings into the InspectorClient options shape.
// Empty / zero values stay unset so the SDK defaults apply.
const defaultMetadata = savedSettings?.metadata
? Object.fromEntries(
savedSettings.metadata
.filter((m) => m.key.trim() !== "")
.map((m) => [m.key, m.value]),
)
: undefined;
const oauth =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes)
? {
...(savedSettings.oauthClientId && {
clientId: savedSettings.oauthClientId,
}),
...(savedSettings.oauthClientSecret && {
clientSecret: savedSettings.oauthClientSecret,
}),
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
}
: undefined;
const client = new InspectorClient(server.config, {
environment,
// The Tasks tab needs the receiver-task pipeline; the
Expand All @@ -344,6 +385,16 @@ function App() {
// Sampling / elicitation are on by default; keep the parameterized
// options off until the UI grows the surface to render them.
elicit: { form: true, url: true },
...(savedSettings &&
savedSettings.requestTimeout > 0 && {
timeout: savedSettings.requestTimeout,
}),
...(defaultMetadata &&
Object.keys(defaultMetadata).length > 0 && {
defaultMetadata,
}),
...(oauth && { oauth }),
...(savedSettings && { serverSettings: savedSettings }),
});

setInspectorClient(client);
Expand Down Expand Up @@ -396,16 +447,23 @@ function App() {
const target = servers.find((s) => s.id === id);
if (!target) return;

// Different server (or first connect): rebuild the client + managers.
let client = inspectorClient;
if (id !== activeServerId || client === null) {
client = setupClientForServer(target);
// Always rebuild the InspectorClient on a (re)connect so the latest
// `target.settings` (headers, metadata, timeouts, OAuth credentials)
// are picked up. Reusing the previous client object would freeze the
// settings at the moment it was first constructed, which would be
// surprising right after the user edited them in the settings modal.
const client = setupClientForServer(target);
if (id !== activeServerId) {
setActiveServerId(id);
}

setErrorMessage(undefined);
connectStartRef.current = Date.now();
try {
// `settings.connectionTimeout` is consumed inside InspectorClient.connect
// (Promise.race + transport teardown live there now), so this branch
// stays unaware of the per-server timeout. TUI/CLI consumers get the
// same behavior by reading from `serverSettings` on the client.
await client.connect();
} catch (err) {
// Handshake-only. A mid-session transport failure transitions the
Expand Down Expand Up @@ -705,6 +763,91 @@ function App() {
return servers.find((s) => s.id === configModal.targetId);
}, [configModal, servers]);

const settingsModalTarget = useMemo(() => {
if (!settingsModalTargetId) return undefined;
return servers.find((s) => s.id === settingsModalTargetId);
}, [settingsModalTargetId, servers]);

// Stable starting shape for entries that have no `settings` node yet — the
// form needs concrete arrays / numbers to render.
const settingsModalValue = useMemo<InspectorServerSettings>(() => {
return (
settingsModalTarget?.settings ?? {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
}
);
}, [settingsModalTarget]);

// The settings modal fires onSettingsChange on every keystroke. Persisting
// each character would run a tight PUT-then-full-refresh loop against the
// backend (and the in-memory `servers` state could flicker mid-typing as
// each refresh resets the modal's prop). Debounce so a burst of edits
// coalesces into a single PUT, and flush on modal close so nothing is lost.
const pendingSettingsRef = useRef<{
id: string;
settings: InspectorServerSettings;
} | null>(null);
const pendingSettingsTimerRef = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);

const flushPendingSettings = useCallback(() => {
if (pendingSettingsTimerRef.current) {
clearTimeout(pendingSettingsTimerRef.current);
pendingSettingsTimerRef.current = undefined;
}
const pending = pendingSettingsRef.current;
if (!pending) return;
pendingSettingsRef.current = null;
// Fire-and-forget — but surface failures via toast. The modal closes
// immediately on user dismiss, so a silent fail-on-flush would leave
// the user thinking their last edits saved when they didn't (especially
// painful for the OAuth client secret).
updateServerSettings(pending.id, pending.settings).catch((err) => {
notifications.show({
title: `Failed to save settings for "${pending.id}"`,
message: err instanceof Error ? err.message : String(err),
color: "red",
});
});
}, [updateServerSettings]);

const onSettingsChange = useCallback(
(next: InspectorServerSettings) => {
if (!settingsModalTargetId) return;
pendingSettingsRef.current = {
id: settingsModalTargetId,
settings: next,
};
if (pendingSettingsTimerRef.current) {
clearTimeout(pendingSettingsTimerRef.current);
}
pendingSettingsTimerRef.current = setTimeout(flushPendingSettings, 300);
},
[settingsModalTargetId, flushPendingSettings],
);

const onSettingsModalClose = useCallback(() => {
flushPendingSettings();
setSettingsModalTargetId(undefined);
}, [flushPendingSettings]);

// Cancel any pending debounce on unmount (route change / HMR). Without
// this a stale setTimeout could still fire one final `updateServerSettings`
// against an unmounted component — harmless today (just an extra PUT of
// the final payload) but the cleanest pattern is to clear the timer.
useEffect(() => {
return () => {
if (pendingSettingsTimerRef.current) {
clearTimeout(pendingSettingsTimerRef.current);
pendingSettingsTimerRef.current = undefined;
}
};
}, []);

// The Resources screen needs `isSubscribed` to flip the Subscribe button
// label to "Unsubscribe". Derive it from the live subscriptions list rather
// than threading it through every setReadResourceState site — that way the
Expand Down Expand Up @@ -756,7 +899,7 @@ function App() {
onServerImportJson={todoNoop}
onServerExport={onServerExport}
onServerInfo={todoNoop}
onServerSettings={todoNoop}
onServerSettings={(id) => setSettingsModalTargetId(id)}
onServerEdit={(id) => setConfigModal({ mode: "edit", targetId: id })}
onServerClone={(id) => setConfigModal({ mode: "clone", targetId: id })}
onServerRemove={(id) => {
Expand Down Expand Up @@ -805,6 +948,12 @@ function App() {
onClose={() => setConfigModal(null)}
onSubmit={onConfigSubmit}
/>
<ServerSettingsModal
opened={settingsModalTargetId !== undefined}
settings={settingsModalValue}
onClose={onSettingsModalClose}
onSettingsChange={onSettingsChange}
/>
<ServerRemoveConfirmModal
opened={removeTarget !== null}
target={removeTarget}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@ const baseProps = {
};

describe("HistoryListPanel", () => {
it("renders the title and Export JSON button", () => {
it("renders the title and Export button", () => {
renderWithMantine(<HistoryListPanel {...baseProps} entries={[]} />);
expect(screen.getByText("Requests")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Export JSON" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
});

it("renders the empty state when there are no entries", () => {
Expand Down Expand Up @@ -134,7 +132,7 @@ describe("HistoryListPanel", () => {
expect(screen.getByText("History (1)")).toBeInTheDocument();
});

it("invokes onExport when Export JSON is clicked", async () => {
it("invokes onExport when Export is clicked", async () => {
const user = userEvent.setup();
const onExport = vi.fn();
renderWithMantine(
Expand All @@ -144,7 +142,7 @@ describe("HistoryListPanel", () => {
onExport={onExport}
/>,
);
await user.click(screen.getByRole("button", { name: "Export JSON" }));
await user.click(screen.getByRole("button", { name: "Export" }));
expect(onExport).toHaveBeenCalledTimes(1);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ export interface HistoryListPanelProps {
onTogglePin: (id: string) => void;
}

const ToolbarButton = Button.withProps({
variant: "subtle",
size: "sm",
});

const PanelContainer = Paper.withProps({
withBorder: true,
p: "lg",
Expand Down Expand Up @@ -101,13 +96,22 @@ export function HistoryListPanel({
<Group justify="space-between" mb="sm">
<Title order={4}>Requests</Title>
<Group gap="xs">
<ToolbarButton onClick={onExport}>Export JSON</ToolbarButton>
{hasResults && (
<ListToggle
compact={compact}
onToggle={() => setCompact((c) => !c)}
/>
)}
<Button
variant="default"
onClick={onClearAll}
disabled={unpinnedEntries.length === 0}
>
Clear
</Button>
<Button variant="default" onClick={onExport} disabled={!hasResults}>
Export
</Button>
</Group>
</Group>

Expand Down Expand Up @@ -136,12 +140,9 @@ export function HistoryListPanel({

{unpinnedEntries.length > 0 && (
<>
<Group justify="space-between">
<Title order={5}>
{formatHistoryTitle(unpinnedEntries.length)}
</Title>
<ToolbarButton onClick={onClearAll}>Clear</ToolbarButton>
</Group>
<Title order={5}>
{formatHistoryTitle(unpinnedEntries.length)}
</Title>
{unpinnedEntries.map((entry) => (
<HistoryEntry
key={entry.id}
Expand Down
Loading
Loading