diff --git a/.gitignore b/.gitignore index 06424dbb3..e360080fe 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index f219554f4..f3e834fdb 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -97,7 +97,6 @@ export function webServerConfigToInitialPayload( return { defaultTransport: "sse", defaultServerUrl: mc.url, - defaultHeaders: mc.headers ?? undefined, defaultEnvironment, }; } @@ -105,21 +104,18 @@ export function webServerConfigToInitialPayload( 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 }; + // 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, }; } diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 4df0a19b3..d15610af5 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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, @@ -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, @@ -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"; @@ -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 @@ -149,6 +158,9 @@ function App() { mode: ServerConfigModalMode; targetId?: string; } | null>(null); + const [settingsModalTargetId, setSettingsModalTargetId] = useState< + string | undefined + >(undefined); const [removeTarget, setRemoveTarget] = useState(null); // The active connection target. `null` between sessions; set as soon as @@ -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 @@ -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); @@ -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 @@ -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(() => { + 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 | 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 @@ -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) => { @@ -805,6 +948,12 @@ function App() { onClose={() => setConfigModal(null)} onSubmit={onConfigSubmit} /> + { - it("renders the title and Export JSON button", () => { + it("renders the title and Export button", () => { renderWithMantine(); 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", () => { @@ -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( @@ -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); }); diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index 0bd6a6f71..37fee63b8 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -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", @@ -101,13 +96,22 @@ export function HistoryListPanel({ Requests - Export JSON {hasResults && ( setCompact((c) => !c)} /> )} + + @@ -136,12 +140,9 @@ export function HistoryListPanel({ {unpinnedEntries.length > 0 && ( <> - - - {formatHistoryTitle(unpinnedEntries.length)} - - Clear - + + {formatHistoryTitle(unpinnedEntries.length)} + {unpinnedEntries.map((entry) => ( - Clear - Export - Copy All + + + {filteredEntries.length > 0 ? ( diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx index ba1d7310c..5bd4c539a 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx @@ -39,7 +39,6 @@ const stdioConfig: MCPServerConfig = { const sseConfig: MCPServerConfig = { type: "sse", url: "https://example.com/sse", - headers: { Authorization: "Bearer xxx" }, }; const meta: Meta = { @@ -120,6 +119,7 @@ export const EditSse: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); await expect(await body.findByLabelText(/^URL/)).toBeInTheDocument(); - await expect(body.getByLabelText(/Headers/i)).toBeInTheDocument(); + // Headers are no longer entered here — they live in ServerSettingsForm. + await expect(body.queryByLabelText(/Headers/i)).toBeNull(); }, }; diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx index d07a3dacc..6b75611eb 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx @@ -95,7 +95,7 @@ describe("ServerConfigModal", () => { // open reliably; we exercise the sse rendering branch by seeding the form // with an sse initialConfig (the same code path the Select onChange takes). - it("renders url + headers (and hides stdio fields) when transport is sse", () => { + it("renders url (and hides stdio fields) when transport is sse", () => { renderWithMantine( { />, ); expect(screen.getByLabelText(/^URL/)).toBeInTheDocument(); - expect(screen.getByLabelText(/Headers/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Headers/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument(); }); - it("submits an sse config with parsed headers", async () => { + it("submits an sse config with just the url (headers move to the settings form)", async () => { const user = userEvent.setup(); const props = base(); renderWithMantine( @@ -122,38 +122,16 @@ describe("ServerConfigModal", () => { ); await user.type(screen.getByLabelText(/^URL/), "https://x.test/sse"); - await user.type( - screen.getByLabelText(/Headers/i), - "Authorization: Bearer xxx", - ); await user.click(screen.getByRole("button", { name: /^Save$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); expect(props.onSubmit).toHaveBeenCalledWith("remote", { type: "sse", url: "https://x.test/sse", - headers: { Authorization: "Bearer xxx" }, }); }); - it("rejects malformed header lines on sse submission", async () => { - const user = userEvent.setup(); - const props = base(); - renderWithMantine( - , - ); - await user.type(screen.getByLabelText(/Headers/i), "BAD_HEADER_LINE"); - await user.click(screen.getByRole("button", { name: /^Save$/ })); - expect(screen.getByRole("alert")).toHaveTextContent(/Invalid header/i); - expect(props.onSubmit).not.toHaveBeenCalled(); - }); - - it("submits a streamable-http config (with no headers)", async () => { + it("submits a streamable-http config", async () => { const user = userEvent.setup(); const props = base(); renderWithMantine( @@ -172,7 +150,7 @@ describe("ServerConfigModal", () => { }); }); - it("loads initial headers + url from a streamable-http config", () => { + it("loads the url from a streamable-http config", () => { renderWithMantine( { initialConfig={{ type: "streamable-http", url: "https://x.test/mcp", - headers: { "X-Trace": "abc" }, }} />, ); expect(screen.getByLabelText(/^URL/)).toHaveValue("https://x.test/mcp"); - expect(screen.getByLabelText(/Headers/i)).toHaveValue("X-Trace: abc"); }); it("rejects an empty URL on sse submission", async () => { diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index 32be68268..ca7970433 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -11,9 +11,7 @@ import { } from "@mantine/core"; import type { MCPServerConfig, - SseServerConfig, StdioServerConfig, - StreamableHttpServerConfig, } from "@inspector/core/mcp/types.js"; /** Allowed id pattern — mirrors validateStoreId in core/storage/store-io.ts */ @@ -47,7 +45,6 @@ interface FormState { cwd: string; // sse / streamable-http url: string; - headersText: string; } const SectionStack = Stack.withProps({ gap: "md" }); @@ -77,7 +74,6 @@ function configToFormState( envText: "", cwd: "", url: "", - headersText: "", }; } if (transport === "stdio") { @@ -92,11 +88,13 @@ function configToFormState( .join("\n"), cwd: c.cwd ?? "", url: "", - headersText: "", }; } - // sse / streamable-http - const c = initialConfig as SseServerConfig | StreamableHttpServerConfig; + // sse / streamable-http — custom headers live in ServerSettingsForm now. + const url = + initialConfig.type === "sse" || initialConfig.type === "streamable-http" + ? initialConfig.url + : ""; return { id, transport, @@ -104,10 +102,7 @@ function configToFormState( argsText: "", envText: "", cwd: "", - url: c.url ?? "", - headersText: Object.entries(c.headers ?? {}) - .map(([k, v]) => `${k}: ${v}`) - .join("\n"), + url: url ?? "", }; } @@ -139,8 +134,7 @@ function parseEnv(raw: string): } const key = line.slice(0, eq).trim(); // env values preserve trailing whitespace — they're shell-style strings - // where spaces / tabs can be load-bearing; header values are HTTP-style - // and parseHeaders below trims them per RFC 7230 §3.2.4. + // where spaces / tabs can be load-bearing. const value = line.slice(eq + 1); if (!key) return { ok: false, error: `Invalid env line "${line}". Empty key.` }; @@ -149,38 +143,6 @@ function parseEnv(raw: string): return { ok: true, value: out }; } -function parseHeaders(raw: string): - | { - ok: true; - value: Record; - } - | { - ok: false; - error: string; - } { - const out: Record = {}; - const lines = raw - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - for (const line of lines) { - const colon = line.indexOf(":"); - if (colon <= 0) { - return { - ok: false, - error: `Invalid header line "${line}". Use "Header-Name: value".`, - }; - } - const key = line.slice(0, colon).trim(); - const value = line.slice(colon + 1).trim(); - if (!key) { - return { ok: false, error: `Invalid header line "${line}". Empty name.` }; - } - out[key] = value; - } - return { ok: true, value: out }; -} - export function ServerConfigModal({ opened, mode, @@ -247,14 +209,14 @@ export function ServerConfigModal({ if (!form.url.trim()) { return { ok: false, error: "URL is required for sse / streamable-http." }; } - const headers = parseHeaders(form.headersText); - if (!headers.ok) return headers; const base = { url: form.url.trim() }; - const config: SseServerConfig | StreamableHttpServerConfig = + // Custom headers live in ServerSettingsForm now (persisted under + // settings.headers on the entry); the SSE / streamable-http config here + // only carries the canonical transport fields. + const config: MCPServerConfig = form.transport === "sse" ? { type: "sse", ...base } : { type: "streamable-http", ...base }; - if (Object.keys(headers.value).length > 0) config.headers = headers.value; return { ok: true, config }; } @@ -384,32 +346,17 @@ export function ServerConfigModal({ /> ) : ( - <> - { - const next = e.currentTarget.value; - setForm((f) => ({ ...f, url: next })); - }} - required - disabled={submitting} - /> -