* feat(servers): persist per-server settings to mcp.json (#1352) - Add optional `settings` node alongside the transport config in each `~/.mcp-inspector/mcp.json` entry. Round-trips through `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and the `/api/servers` POST/PUT routes. - Drop `MCPServerConfig.headers` from `SseServerConfig` / `StreamableHttpServerConfig` and remove the headers textarea from `ServerConfigModal`. Custom headers are now entered only in `ServerSettingsForm` and reach the wire via `settings.headers`. - Wire `ServerSettingsModal` into `App.tsx`; saves dispatch through a new `useServers.updateServerSettings` mutator. `updateServer` carries the existing settings across config-only saves so they aren't dropped. - Apply settings on the *first* outbound request after a server loads — no need to open the settings form first: - `settings.headers` → SSE / streamable-http wire headers. - `settings.metadata` → `InspectorClientOptions.defaultMetadata`, merged into every outgoing `_meta` (call-time keys win). - `settings.requestTimeout` → `InspectorClientOptions.timeout`. - `settings.connectionTimeout` → Promise.race wrapper around connect(). - `settings.oauth*` → pre-seeded OAuth client credentials. - `RemoteConnectRequest` carries `settings` so the remote backend can source transport headers from the same source as the local path. - `oauthClientSecret` is persisted in `mcp.json` alongside stdio `env` values, both protected by the file's `0o600` permission. - Update `specification/v2_servers_file.md` with the new shape and the removal of the legacy headers field. Tests: round-trip in `serverList.test.ts`; persistence on POST/PUT in `servers-route.test.ts`; first-connect header injection in `transport.test.ts`; `defaultMetadata` merging in `inspectorClient.test.ts`; `useServers.updateServerSettings` + `updateServer` settings-preservation in `useServers.test.tsx`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): rebuild InspectorClient on every connect so settings refresh The previous onToggleConnection branch reused the existing InspectorClient on a same-server reconnect, which froze settings at the moment the client was first constructed. Editing headers, metadata, timeouts, or OAuth credentials in the settings form was therefore invisible until the user either switched servers or reloaded the page — surprising right after a visible save. Always call setupClientForServer on (re)connect so the latest target.settings flow through to the transport's headers / requestInit, to defaultMetadata, and to the OAuth manager. Also adds remoteClientTransport tests that pin the wire-level contract: settings round-trip through the /api/mcp/connect body when supplied, and the field is omitted when no settings are configured. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(history): stop wiping pendingRequestEntries inside the connect event State managers (ManagedToolsState, ManagedPromptsState, ManagedResourcesState, ManagedResourceTemplatesState, ManagedRequestorTasksState) handle the client's "connect" event by calling `void this.refresh()`. The synchronous portion of refresh runs through SDK Protocol.request → transport.send → MessageTrackingTransport.send → trackRequest, all before yielding to the network — so each list request gets tracked into MessageLogState's pendingRequestEntries inside the same connect event dispatch. MessageLogState's own "connect" listener was registered after those state managers (it's the last one constructed in App.tsx) and cleared pendingRequestEntries on every connect. Listener order meant the clear ran *after* the requests were tracked but before their responses arrived, leaving the history with unmatched "response" entries marked PENDING. The clear-on-disconnect (via statusChange → disconnected) already handles session-boundary cleanup, so clear-on-connect was purely defensive and now actively harmful. Drop the connect listener; keep the disconnect path. Tests: updated the unit test to assert the new behavior (connect does not clear); added an integration regression that wires a sibling connect listener firing listTools and asserts the response folds into the request entry instead of appearing as an orphan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop accidentally tracked debugging screenshot pending-requests.png was a local debugging artifact attached to a conversation, not source. Remove it from the tree (the working copy is kept so the user still has it locally) and rely on the existing untracked behavior going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(servers): add e2e check that settings.headers reach upstream MCP Stitches the full v2 pipeline together in one assertion: browser-style RemoteClientTransport → POST /api/mcp/connect (settings in body) → backend createTransportNode applies settings.headers to requestInit → SDK StreamableHTTPClientTransport → upstream MCP test server Captures the upstream HTTP request via FetchRequestLogState and asserts both `X-Tenant` and `X-Trace` are present on the outbound POST. Catches any regression that breaks the wire between the form and the upstream server in a single integration test, instead of the previous coverage which exercised each hop in isolation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ServerSettingsModal): invert ListToggle compact prop so icon matches state ListToggle's `compact` prop represents the list's *current* compact state: true means "currently compact" and renders the expand icon to advertise the action (open up). The settings modal was passing `compact={allExpanded}` — exactly inverted. When all sections were open it showed the expand icon (suggesting "open more") and when collapsed it showed the collapse icon, opposite of every other ListToggle caller in the app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(HistoryListPanel): match ServerListScreen Export button — rename + default variant Renames "Export JSON" to "Export" and switches from the subtle ToolbarButton to the same `Button variant="default"` used by ServerListControls so the two Export affordances look identical across screens. Also moves the button to the right of the ListToggle (mirroring the order on the server list screen) and disables it when there are no entries to export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(HistoryListPanel): move Clear button to top toolbar, match Export style Moves the Clear affordance out of the "History (N)" section header and up to the panel toolbar alongside Export, where it now uses the same `Button variant="default"` styling. Order in the toolbar is now: ListToggle · Clear · Export. Clear is disabled when there are no unpinned entries to remove (pinned entries are intentionally preserved). ToolbarButton helper is unused after this change and dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: ignore local debugging screenshots at repo root Stops `git add -A` from re-staging the `*.png` files Claude Code drops at the repo root from attached conversation screenshots. The pattern only matches the top-level (`/*.png`) so legitimate image assets in subdirectories (e.g. `clients/web/public/`) still get tracked when explicitly added. Also untracks the two screenshots that slipped through previously (initialize-request.png, pending-requests.png). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(LogStreamPanel): match HistoryListPanel button styling Clear / Export / Copy All on the Logging screen were rendered with the subtle ToolbarButton helper while History's equivalents use `Button variant="default"`. Switch all three to the default variant so the toolbars look identical across screens and mirror the disabling behavior — each button is now disabled when `entries.length === 0`. ToolbarButton helper is unused after this change and dropped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review — debounce save, preserve-on-omit PUT, validate shape, lift connect timeout Round-up of fixes from https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4526861959: 1. ServerSettingsModal saves were firing on every keystroke (one PUT + full mcp.json refresh per character). App.tsx now debounces by 300ms and flushes on modal close so a burst of edits coalesces into one PUT and the form value can't flicker back mid-typing. 2. `PUT /api/servers/:id` with an omitted `settings` field used to silently wipe the persisted settings node. The route now treats the three intents distinctly: - field omitted → preserve existing on-disk settings - explicit null → clear the settings node - settings object → validate + apply `useServers.updateServer` is simplified to drop the client-side carry-through hack, and the test that documented the broken behavior is flipped + augmented with explicit-clear and bad-shape cases. 3. `connectionTimeout` Promise.race is moved from App.tsx into `InspectorClient.connect()`, reading `this.serverSettings?.connectionTimeout`. TUI/CLI consumers now get the same behavior; on timeout the client internally disconnects so the next connect starts clean. 4. Custom Headers hint in `ServerSettingsForm` notes that `Authorization` is owned by the OAuth flow when OAuth is configured — avoids the sharp edge where the SDK's authProvider silently overwrites a user-set Authorization header. 5. New `validateSettings` on the server route rejects malformed shapes (`settings: []`, `settings.headers: "oops"`, non-numeric timeouts, etc.) with 400 instead of letting them persist and crash the UI. 7 (nit). Renamed `settings` → `savedSettings` inside `setupClientForServer` so the variable holding the persisted settings doesn't shadow the later locally-derived values. Follow-ups opened separately: #1356 — Move oauthClientSecret out of mcp.json into the OS keychain (review #6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review pass 2 — connect-timeout test, unmount cleanup, tagged intent, lenient settings read Pass-2 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4527089705: (a) New integration test pins the new client-layer connect-timeout contract: a hanging stub transport with `connectionTimeout: 50` rejects with the descriptive error and lands the client in "error" status. Documents what TUI/CLI consumers should expect from the lifted Promise.race in InspectorClient.connect(). (b) useEffect cleanup clears `pendingSettingsTimerRef` on unmount so a route change or HMR mid-debounce can't fire one stale PUT against an unmounted component. (c) Refactor the PUT-route `settingsIntent` discriminator from `"preserve" | "clear" | { value }` to a tagged union with `kind`, and consume it with a `switch` for exhaustiveness — reads cleaner and makes adding a fourth intent painless. (d) `normalizeMcpServers` now runs `validateSettings` on the on-disk settings node and silently strips it if invalid, mirroring the existing lenient-on-read pattern from `normalizeServerType`. Closes the read/write asymmetry where a hand-edited malformed `settings` could survive on disk and get carried through preserve-on-PUT. New route test pins the strip-on-GET + no-propagate-on-preserve-PUT behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-3 review — patch-style PUT, toast on close-flush fail Pass-3 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529276897. (b) `PUT /api/servers/:id` now treats both `config` and `settings` as optional patches: - field omitted → preserve the on-disk value - explicit body value → apply - settings: null → clear `updateServerSettings` stops reading `existing.config` from the in-memory `servers` snapshot, which closes the closure-capture hazard where a refresh between debounce-schedule and flush could silently revert a separate edit. The route is the single source of truth for transport config now. New tests cover settings-only PUT, rejected non-object config, and the empty-body no-op patch case. The previous "rejects a missing config" test is rewritten to document the new no-op-preserve semantics. (c) Inline comment near the PUT rebuild loop documents that writing the full mcpServers map back is what gives the route its desirable self-healing property for malformed settings on *other* entries — deliberate side-effect, not an accident of normalization. (d) `flushPendingSettings` surfaces failures via @mantine/notifications instead of swallowing them. The modal already closed by the time the flush PUT resolves, so a silent fail would leave the user thinking their last edits saved (especially painful for the OAuth client secret). The notifications wiring was already mounted in main.tsx. Pass-3 (a) — settings form being effectively uncontrolled mid-edit — is acknowledged in the reply: working as intended today; the proper fix is internal modal state when file-watching (#1345) forces the issue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-4 review — strip smuggled settings out of config Pass-4 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529861349. Main finding: `normalizeServerType` spreads unknown keys, so a body that nested `settings` inside `config` would smuggle a settings field onto the stored entry without ever passing through `validateSettings`. Strip the settings key off the incoming config in `buildStoredEntry` so `validateSettings` remains the single write path for the settings node — the "one source of truth" invariant pass 2 set up holds again. Two new tests pin the strip on both routes: - POST with `config.settings = { bogus }` lands an entry with no settings node on disk. - PUT with `config.settings = { bogus }` + `settings: null` clears the real settings *and* doesn't re-attach the bogus payload via the spread. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-5 review — absorb late connect rejection, tighten validateSettings, log smuggle warning Pass-5 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529954490. Main finding: when the connect-time `Promise.race` lost to the timeout, the original `this.client.connect(this.transport)` promise was left pending. After `disconnect()` tore the transport down, real transports (SSE / streamable-http) would reject that promise later — with no handler attached, producing a Node `unhandledRejection` (and a vitest suite warning). Attach a no-op `.catch` right after creating the connect promise so Node sees the late rejection as handled. Nit (a): `validateSettings` was casting the raw object through to `InspectorServerSettings`, which let unknown keys ride along onto disk. Replaced the cast with an explicit pick-and-build over the known fields. Unknown stowaways now silently drop on the way through. New route test pins the behavior. Nit (b): `buildStoredEntry` now logs a `warn` via the route's pino logger when an incoming `config` carries a `settings` key. The strip is still defensive (it isn't a documented contract) but the warning lets a misconfigured client find out about the wasted field instead of debugging a silent drop. Nit (c) — the defensive `?? {}` fallback in `buildStoredEntry` — acknowledged in the review reply; left in place as belt-and-braces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): pass-6 polish — scope absorber, thread id, drop empty OAuth, guard `in` Pass-6 review at https://github.com/modelcontextprotocol/inspector/pull/1353#issuecomment-4529979135. Reviewer signed off as ready; landing the four marginal cleanups since they're all small. (a) `connectPromise.catch(() => {})` moves inside the `if (connectTimeoutMs > 0)` branch. The await-only path propagates rejection cleanly through the outer try/catch — the absorber is only needed when the race leaves the original promise unattended. Code now reads as "absorber is tied to the race," which is the real invariant. (b) Thread `id` (POST id, PUT newId) through `buildStoredEntry` and include it in the smuggle warning's pino bindings. Two clients smuggling simultaneously are distinguishable in the log instead of indistinguishable. (c) `validateSettings` coerces empty-string OAuth fields to absent. The form emits `""` when the user clears an input, and an empty `oauthClientId` on disk would later be misread as "OAuth configured." New route test pins it. (d) `buildStoredEntry` adds a null/object guard before `"settings" in configObj`. The route layer pre-checks this on POST and PUT, but the helper's `unknown` parameter is a weak contract; the guard makes it safe to call from anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): reflect settings persistence + patch-PUT contract in v2_servers_file The doc was authored when `mcp.json` only stored the server list. Bring it forward to match what shipped in #1352 / PR #1353: - Summary calls out that the file also stores per-server settings and links into the Per-server settings section. - On-disk format note explains that `settings` is an optional Inspector- specific extension that other MCP tools ignore. - New top paragraph on the Per-server settings section explains the basic-config / settings UI split rationale (simpler form for the common case, advanced settings tucked behind a second dialog). - UI bullet is rewritten to match the patch-style PUT semantics that emerged across the review cycle: settings-only and config-only PUTs preserve their counterpart inside the write lock, so neither modal can silently clobber the other half. The earlier "must re-send settings on PUT" hack is gone. - New bullets capture the save cadence (300ms debounce + flush-on-close + failure toast), the patch-PUT contract (omit-preserve / null-clear / rename), and the write-path / read-path validator gates. - Secret storage bullet links to #1356 (OS-keychain follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): show a third entry with the optional settings node The On-disk format example demonstrated only basic stdio entries. Add a streamable-http entry with a populated `settings` node so readers can see the shape inline rather than having to scroll to the Per-server settings section. Inline comment in the block cross-links there for the full contract and notes that other MCP tools ignore the extension key. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
420da2e415
commit
85a4b4c057
34 changed files with 1884 additions and 355 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ServerEntry | null>(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<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
|
||||
|
|
@ -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}
|
||||
/>
|
||||
<ServerSettingsModal
|
||||
opened={settingsModalTargetId !== undefined}
|
||||
settings={settingsModalValue}
|
||||
onClose={onSettingsModalClose}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>
|
||||
<ServerRemoveConfirmModal
|
||||
opened={removeTarget !== null}
|
||||
target={removeTarget}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<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>
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,6 @@ const PanelContainer = Paper.withProps({
|
|||
variant: "panel",
|
||||
});
|
||||
|
||||
const ToolbarButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const EmptyCenter = Stack.withProps({
|
||||
flex: 1,
|
||||
align: "center",
|
||||
|
|
@ -88,9 +83,27 @@ export function LogStreamPanel({
|
|||
checked={autoScroll}
|
||||
onChange={onToggleAutoScroll}
|
||||
/>
|
||||
<ToolbarButton onClick={onClear}>Clear</ToolbarButton>
|
||||
<ToolbarButton onClick={onExport}>Export</ToolbarButton>
|
||||
<ToolbarButton onClick={onCopyAll}>Copy All</ToolbarButton>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClear}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onExport}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onCopyAll}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
Copy All
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
{filteredEntries.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ const stdioConfig: MCPServerConfig = {
|
|||
const sseConfig: MCPServerConfig = {
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
headers: { Authorization: "Bearer xxx" },
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ServerConfigModal> = {
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ServerConfigModal
|
||||
{...base()}
|
||||
|
|
@ -105,11 +105,11 @@ describe("ServerConfigModal", () => {
|
|||
/>,
|
||||
);
|
||||
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(
|
||||
<ServerConfigModal
|
||||
{...props}
|
||||
mode="edit"
|
||||
initialId="remote"
|
||||
initialConfig={{ type: "sse", url: "https://x.test/sse" }}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ServerConfigModal
|
||||
{...base()}
|
||||
|
|
@ -181,12 +159,10 @@ describe("ServerConfigModal", () => {
|
|||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
} {
|
||||
const out: Record<string, string> = {};
|
||||
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({
|
|||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
label="URL"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={form.url}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, url: next }));
|
||||
}}
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Textarea
|
||||
label="Headers"
|
||||
description='"Header-Name: value" per line.'
|
||||
placeholder="Authorization: Bearer xxxxx"
|
||||
value={form.headersText}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, headersText: next }));
|
||||
}}
|
||||
autosize
|
||||
minRows={2}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</>
|
||||
<TextInput
|
||||
label="URL"
|
||||
placeholder="https://example.com/mcp"
|
||||
value={form.url}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, url: next }));
|
||||
}}
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
</FieldGrid>
|
||||
|
||||
|
|
|
|||
|
|
@ -135,7 +135,9 @@ export function ServerSettingsForm({
|
|||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<HintText>
|
||||
Headers sent with every HTTP request to this server
|
||||
Headers sent with every HTTP request to this server. If OAuth is
|
||||
configured below, the `Authorization` header is owned by the
|
||||
OAuth flow and any value set here is ignored.
|
||||
</HintText>
|
||||
<AddButton onClick={onAddHeader}>+ Add Header</AddButton>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export function ServerSettingsModal({
|
|||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<ListToggle
|
||||
compact={allExpanded}
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,12 @@ describe("LoggingScreen", () => {
|
|||
it("invokes onClear when clear is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = vi.fn();
|
||||
renderWithMantine(<LoggingScreen {...baseProps} onClear={onClear} />);
|
||||
const entries = [
|
||||
{ receivedAt: new Date(), params: { level: "info" as const, data: "x" } },
|
||||
];
|
||||
renderWithMantine(
|
||||
<LoggingScreen {...baseProps} entries={entries} onClear={onClear} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -43,11 +48,23 @@ describe("LoggingScreen", () => {
|
|||
it("invokes onCopyAll when Copy All is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCopyAll = vi.fn();
|
||||
renderWithMantine(<LoggingScreen {...baseProps} onCopyAll={onCopyAll} />);
|
||||
const entries = [
|
||||
{ receivedAt: new Date(), params: { level: "info" as const, data: "x" } },
|
||||
];
|
||||
renderWithMantine(
|
||||
<LoggingScreen {...baseProps} entries={entries} onCopyAll={onCopyAll} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Copy All" }));
|
||||
expect(onCopyAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables Clear / Export / Copy All when there are no entries", () => {
|
||||
renderWithMantine(<LoggingScreen {...baseProps} />);
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Copy All" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("toggles a single level on level button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const entries = [
|
||||
|
|
|
|||
|
|
@ -146,36 +146,6 @@ describe("resolveServerConfigs — single mode", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("attaches headers on remote configs when provided", () => {
|
||||
const [config] = resolveServerConfigs(
|
||||
{
|
||||
target: ["http://example.com/mcp"],
|
||||
headers: { Authorization: "Bearer x" },
|
||||
},
|
||||
"single",
|
||||
);
|
||||
expect(config).toMatchObject({
|
||||
type: "streamable-http",
|
||||
url: "http://example.com/mcp",
|
||||
headers: { Authorization: "Bearer x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches headers on sse configs when provided", () => {
|
||||
const [config] = resolveServerConfigs(
|
||||
{
|
||||
target: ["http://example.com/sse"],
|
||||
headers: { Authorization: "Bearer x" },
|
||||
},
|
||||
"single",
|
||||
);
|
||||
expect(config).toMatchObject({
|
||||
type: "sse",
|
||||
url: "http://example.com/sse",
|
||||
headers: { Authorization: "Bearer x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects args passed alongside a URL target", () => {
|
||||
expect(() =>
|
||||
resolveServerConfigs(
|
||||
|
|
@ -323,7 +293,7 @@ describe("resolveServerConfigs — single mode", () => {
|
|||
).toThrow(/Server 'bar' not found/);
|
||||
});
|
||||
|
||||
it("applies env/cwd/headers overrides when loading from config", () => {
|
||||
it("applies env/cwd overrides when loading from config", () => {
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
|
|
@ -350,19 +320,6 @@ describe("resolveServerConfigs — single mode", () => {
|
|||
env: { X: "1" },
|
||||
cwd: "/tmp",
|
||||
});
|
||||
|
||||
const [http] = resolveServerConfigs(
|
||||
{
|
||||
configPath,
|
||||
serverName: "bar",
|
||||
headers: { Authorization: "Bearer x" },
|
||||
},
|
||||
"single",
|
||||
);
|
||||
expect(http).toMatchObject({
|
||||
type: "streamable-http",
|
||||
headers: { Authorization: "Bearer x" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -460,13 +417,15 @@ describe("getNamedServerConfigs", () => {
|
|||
const named = getNamedServerConfigs({
|
||||
configPath,
|
||||
env: { X: "1" },
|
||||
headers: { Authorization: "Bearer x" },
|
||||
});
|
||||
expect((named.a as { env: Record<string, string> }).env).toEqual({
|
||||
X: "1",
|
||||
});
|
||||
expect((named.b as { headers: Record<string, string> }).headers).toEqual({
|
||||
Authorization: "Bearer x",
|
||||
// env override does not apply to non-stdio servers; the streamable-http
|
||||
// entry stays as-is.
|
||||
expect(named.b).toMatchObject({
|
||||
type: "streamable-http",
|
||||
url: "http://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -322,4 +322,62 @@ describe("RemoteClientTransport", () => {
|
|||
expect((err as Error).message).toMatch(/Remote connect failed \(401\)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("start() includes settings on the /api/mcp/connect body when provided", async () => {
|
||||
const seenBodies: string[] = [];
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
seenBodies.push(init.body as string);
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return sseStreamResponse();
|
||||
});
|
||||
const settings = {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
};
|
||||
const transport = new RemoteClientTransport(
|
||||
{
|
||||
baseUrl,
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
settings,
|
||||
},
|
||||
config,
|
||||
);
|
||||
await transport.start();
|
||||
await transport.close();
|
||||
|
||||
expect(seenBodies.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(seenBodies[0]!) as { settings?: unknown };
|
||||
expect(parsed.settings).toEqual(settings);
|
||||
});
|
||||
|
||||
it("start() omits the settings field when no settings are provided", async () => {
|
||||
const seenBodies: string[] = [];
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
seenBodies.push(init.body as string);
|
||||
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return sseStreamResponse();
|
||||
});
|
||||
const transport = new RemoteClientTransport(
|
||||
{ baseUrl, fetchFn: fetchFn as unknown as typeof fetch },
|
||||
config,
|
||||
);
|
||||
await transport.start();
|
||||
await transport.close();
|
||||
const parsed = JSON.parse(seenBodies[0]!) as Record<string, unknown>;
|
||||
expect("settings" in parsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
beta: {
|
||||
type: "sse",
|
||||
url: "https://x.test",
|
||||
headers: { Authorization: "Bearer y" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -148,6 +147,70 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips a populated settings node through both converters", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
gamma: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
oauthClientId: "client-abc",
|
||||
oauthClientSecret: "secret-def",
|
||||
oauthScopes: "read:tools",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("lifts the settings node from the stored entry onto ServerEntry.settings", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
alpha: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(cfg);
|
||||
expect(entry?.settings).toEqual({
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
// `settings` should not leak back into config — the SDK transport must
|
||||
// see a clean MCPServerConfig.
|
||||
expect(
|
||||
(entry?.config as unknown as Record<string, unknown>).settings,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the settings key on disk when ServerEntry.settings is undefined", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "stdio", command: "node" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
const cfg = serverEntriesToMcpConfig(entries);
|
||||
expect(cfg.mcpServers.alpha).not.toHaveProperty("settings");
|
||||
});
|
||||
|
||||
it("preserves insertion order on serialize", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -156,14 +156,14 @@ describe("MessageLogState", () => {
|
|||
expect(keep).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears on connect", () => {
|
||||
it("does not clear on connect (state managers fire requests synchronously inside their own connect listeners; clearing here would wipe their pendingRequestEntries before responses arrive)", () => {
|
||||
client.dispatchTypedEvent("message", notificationEntry());
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
let dispatched = false;
|
||||
state.addEventListener("messagesChange", () => (dispatched = true));
|
||||
client.dispatchTypedEvent("connect");
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(dispatched).toBe(true);
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(dispatched).toBe(false);
|
||||
});
|
||||
|
||||
it("clears on disconnect (statusChange -> disconnected)", () => {
|
||||
|
|
|
|||
|
|
@ -281,6 +281,113 @@ describe("useServers", () => {
|
|||
expect(seenHeaders[0]?.get("x-mcp-remote-auth")).toBe("Bearer secret");
|
||||
});
|
||||
|
||||
it("updateServerSettings persists the new settings node without touching config", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
alpha: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateServerSettings("alpha", {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers[0]?.settings).toEqual({
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
});
|
||||
});
|
||||
const stored = readConfig(h.configPath).mcpServers.alpha as {
|
||||
type?: string;
|
||||
url?: string;
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.type).toBe("streamable-http");
|
||||
expect(stored.url).toBe("https://x.test/mcp");
|
||||
expect(stored.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.updateServerSettings("nonexistent", {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it("updateServer preserves the existing settings node across a config update", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
alpha: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Keep", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
// ServerConfigModal saves do not touch settings; updateServer must not
|
||||
// drop them, otherwise the user loses persisted headers / metadata when
|
||||
// they tweak the URL.
|
||||
await result.current.updateServer("alpha", "alpha", {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/other",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers[0]?.config).toMatchObject({
|
||||
url: "https://x.test/other",
|
||||
});
|
||||
});
|
||||
expect(result.current.servers[0]?.settings).toEqual({
|
||||
headers: [{ key: "X-Keep", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses DEFAULT_SEED_CONFIG keys on the first load (seed-write contract)", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ describe("InspectorClient", () => {
|
|||
pagedPromptsState.destroy();
|
||||
});
|
||||
|
||||
it("MessageLogState clears on connect when attached to client", async () => {
|
||||
it("MessageLogState clears across a disconnect → reconnect cycle", async () => {
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
|
|
@ -289,6 +289,51 @@ describe("InspectorClient", () => {
|
|||
}
|
||||
messageLogState.destroy();
|
||||
});
|
||||
|
||||
it("rejects connect() with a timeout error when serverSettings.connectionTimeout fires", async () => {
|
||||
// Stub transport whose start() never resolves — simulates a slow /
|
||||
// unreachable upstream. InspectorClient.connect() should race against
|
||||
// serverSettings.connectionTimeout and reject with a descriptive error;
|
||||
// status should end up in "error", and the client should have
|
||||
// internally torn down the transport (next connect() must rebuild).
|
||||
const hangingTransport = {
|
||||
start: () => new Promise<void>(() => {}),
|
||||
send: async () => {},
|
||||
close: async () => {},
|
||||
onclose: undefined,
|
||||
onerror: undefined,
|
||||
onmessage: undefined,
|
||||
sessionId: undefined,
|
||||
};
|
||||
const fakeFactory = () => ({
|
||||
transport:
|
||||
hangingTransport as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport,
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "streamable-http", url: "http://localhost:1/never" },
|
||||
{
|
||||
environment: { transport: fakeFactory },
|
||||
serverSettings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 50,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
await expect(client.connect()).rejects.toThrow(
|
||||
/Connection timed out after 50 ms/,
|
||||
);
|
||||
const elapsed = Date.now() - before;
|
||||
// Sanity: the race should fire near the configured timeout, not at
|
||||
// some far-future SDK default.
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
// connect() rejected → the outer catch transitions status to "error"
|
||||
// (the same end state any other handshake failure would produce).
|
||||
expect(client.getStatus()).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message Tracking", () => {
|
||||
|
|
@ -397,6 +442,52 @@ describe("InspectorClient", () => {
|
|||
);
|
||||
messageLogState.destroy();
|
||||
});
|
||||
|
||||
it("matches responses to requests when a sibling listener fires a request inside the same connect event (regression for unmatched */list responses)", async () => {
|
||||
// Reproduces the pre-fix bug where MessageLogState's clear-on-connect
|
||||
// listener ran after sibling state-manager onConnect listeners had
|
||||
// already synchronously tracked their initial list requests. That clear
|
||||
// wiped pendingRequestEntries before the responses arrived, leaving
|
||||
// the history with unmatched "response" entries marked PENDING.
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{ environment: { transport: createTransportNode } },
|
||||
);
|
||||
const messageLogState = new MessageLogState(client);
|
||||
|
||||
// Simulate the App.tsx wiring: ManagedToolsState et al. register their
|
||||
// own connect listeners that synchronously fire `void client.listTools()`.
|
||||
const refreshClient = client;
|
||||
client.addEventListener("connect", () => {
|
||||
void refreshClient.listTools();
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
// Settle: drain pending microtasks so the listTools response can fold.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
const requests = messageLogState
|
||||
.getMessages()
|
||||
.filter((m) => m.direction === "request");
|
||||
const orphanResponses = messageLogState
|
||||
.getMessages()
|
||||
.filter((m) => m.direction === "response");
|
||||
const listToolsReq = requests.find(
|
||||
(m) => (m.message as { method?: string }).method === "tools/list",
|
||||
);
|
||||
expect(listToolsReq).toBeDefined();
|
||||
// Its response must be folded into the request entry, not pushed as a
|
||||
// separate "response" entry.
|
||||
expect(listToolsReq?.response).toBeDefined();
|
||||
expect(listToolsReq?.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(orphanResponses).toEqual([]);
|
||||
|
||||
messageLogState.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fetch Request Tracking", () => {
|
||||
|
|
@ -778,6 +869,115 @@ describe("InspectorClient", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Default metadata (server-wide _meta)", () => {
|
||||
function metaOf(req: { message: unknown }): Record<string, unknown> {
|
||||
const params = (req.message as { params?: { _meta?: unknown } }).params;
|
||||
return (params?._meta as Record<string, unknown>) ?? {};
|
||||
}
|
||||
|
||||
it("merges defaultMetadata into the _meta of outgoing tools/list and tools/call", async () => {
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
defaultMetadata: { tenant: "acme", env: "prod" },
|
||||
},
|
||||
);
|
||||
const messageLogState = new MessageLogState(client);
|
||||
await client.connect();
|
||||
await client.listTools();
|
||||
const tool = await getTool(client, "echo");
|
||||
await client.callTool(tool, { message: "hi" });
|
||||
|
||||
const requests = messageLogState
|
||||
.getMessages()
|
||||
.filter((m) => m.direction === "request");
|
||||
const listToolsReq = requests.find(
|
||||
(m) => (m.message as { method?: string }).method === "tools/list",
|
||||
);
|
||||
const callToolReq = requests.find(
|
||||
(m) => (m.message as { method?: string }).method === "tools/call",
|
||||
);
|
||||
expect(listToolsReq).toBeDefined();
|
||||
expect(callToolReq).toBeDefined();
|
||||
expect(metaOf(listToolsReq!)).toMatchObject({
|
||||
tenant: "acme",
|
||||
env: "prod",
|
||||
});
|
||||
expect(metaOf(callToolReq!)).toMatchObject({
|
||||
tenant: "acme",
|
||||
env: "prod",
|
||||
});
|
||||
messageLogState.destroy();
|
||||
});
|
||||
|
||||
it("call-time metadata overrides defaultMetadata on key collision", async () => {
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
defaultMetadata: { tenant: "acme" },
|
||||
},
|
||||
);
|
||||
const messageLogState = new MessageLogState(client);
|
||||
await client.connect();
|
||||
const tool = await getTool(client, "echo");
|
||||
await client.callTool(tool, { message: "hi" }, { tenant: "override" });
|
||||
|
||||
const callToolReq = messageLogState
|
||||
.getMessages()
|
||||
.find(
|
||||
(m) =>
|
||||
m.direction === "request" &&
|
||||
(m.message as { method?: string }).method === "tools/call",
|
||||
);
|
||||
expect(callToolReq).toBeDefined();
|
||||
expect(metaOf(callToolReq!).tenant).toBe("override");
|
||||
messageLogState.destroy();
|
||||
});
|
||||
|
||||
it("does not inject defaults into _meta when defaultMetadata is unset", async () => {
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: "stdio",
|
||||
command: serverCommand.command,
|
||||
args: serverCommand.args,
|
||||
},
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
// defaultMetadata omitted on purpose
|
||||
},
|
||||
);
|
||||
const messageLogState = new MessageLogState(client);
|
||||
await client.connect();
|
||||
await client.listTools();
|
||||
|
||||
const listToolsReq = messageLogState
|
||||
.getMessages()
|
||||
.find(
|
||||
(m) =>
|
||||
m.direction === "request" &&
|
||||
(m.message as { method?: string }).method === "tools/list",
|
||||
);
|
||||
expect(listToolsReq).toBeDefined();
|
||||
// The SDK auto-injects a `progressToken` for progress-tracked requests
|
||||
// — that's an SDK concern, not user metadata. Assert only that none of
|
||||
// our example default keys leak through when defaultMetadata is unset.
|
||||
const meta = metaOf(listToolsReq!);
|
||||
expect(meta.tenant).toBeUndefined();
|
||||
expect(meta.env).toBeUndefined();
|
||||
messageLogState.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resource Methods", () => {
|
||||
beforeEach(async () => {
|
||||
client = new InspectorClient(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
FetchRequestEntryBase,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
|
@ -191,5 +192,127 @@ describe("Transport", () => {
|
|||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies settings.headers to the outgoing streamable-http request", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
serverType: "streamable-http",
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: server.url,
|
||||
};
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [
|
||||
{ key: "X-Tenant", value: "acme" },
|
||||
{ key: "X-Trace", value: "abc123" },
|
||||
{ key: "", value: "ignored-empty-key" },
|
||||
],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
};
|
||||
|
||||
const fetchRequests: FetchRequestEntryBase[] = [];
|
||||
const result = createTransportNode(config, {
|
||||
settings,
|
||||
onFetchRequest: (entry) => {
|
||||
fetchRequests.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{ name: "test-client", version: "1.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
await client.connect(result.transport);
|
||||
await client.close();
|
||||
|
||||
// The very first outbound request — the initialize handshake — must
|
||||
// already carry settings.headers (acceptance criterion: applied on
|
||||
// *first* outbound request, no settings-form open required).
|
||||
expect(fetchRequests.length).toBeGreaterThan(0);
|
||||
const first = fetchRequests[0];
|
||||
const lowered: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(first?.requestHeaders ?? {})) {
|
||||
lowered[k.toLowerCase()] = v;
|
||||
}
|
||||
expect(lowered["x-tenant"]).toBe("acme");
|
||||
expect(lowered["x-trace"]).toBe("abc123");
|
||||
// Rows with an empty key are dropped.
|
||||
expect(Object.keys(lowered)).not.toContain("");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies settings.headers to the outgoing SSE request", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
serverType: "sse",
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
|
||||
const config: MCPServerConfig = { type: "sse", url: server.url };
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
};
|
||||
|
||||
const fetchRequests: FetchRequestEntryBase[] = [];
|
||||
const result = createTransportNode(config, {
|
||||
settings,
|
||||
onFetchRequest: (entry) => {
|
||||
fetchRequests.push(entry);
|
||||
},
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{ name: "test-client", version: "1.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
await client.connect(result.transport);
|
||||
await client.close();
|
||||
|
||||
// SSE initiates with a GET — that GET must already carry the header.
|
||||
expect(fetchRequests.length).toBeGreaterThan(0);
|
||||
const getRequest = fetchRequests.find((r) => r.method === "GET");
|
||||
expect(getRequest).toBeDefined();
|
||||
const lowered: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(getRequest?.requestHeaders ?? {})) {
|
||||
lowered[k.toLowerCase()] = v;
|
||||
}
|
||||
expect(lowered["x-tenant"]).toBe("acme");
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits headers when settings.headers is empty", async () => {
|
||||
const config: MCPServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: "http://localhost:3000/mcp",
|
||||
};
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
};
|
||||
const result = createTransportNode(config, { settings });
|
||||
// Just exercise the empty-headers path — no transport construction
|
||||
// should throw and no client connection is necessary.
|
||||
expect(result.transport).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -344,13 +344,19 @@ describe("/api/servers routes", () => {
|
|||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects a missing config", async () => {
|
||||
it("accepts a body with neither config nor settings (no-op patch)", async () => {
|
||||
// Both fields are now optional patches. An empty body is a degenerate
|
||||
// but valid request — it preserves both config and settings on disk.
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/alpha`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: "alpha" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.status).toBe(200);
|
||||
expect(readConfig(h.configPath).mcpServers.alpha).toEqual({
|
||||
type: "stdio",
|
||||
command: "old",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed JSON body", async () => {
|
||||
|
|
@ -363,6 +369,429 @@ describe("/api/servers routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("settings round-trip", () => {
|
||||
it("persists a settings node on POST", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "gamma",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
oauthClientId: "client-abc",
|
||||
oauthScopes: "read:tools",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.gamma as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toEqual({
|
||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
oauthClientId: "client-abc",
|
||||
oauthScopes: "read:tools",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a settings node on PUT", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
delta: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/delta`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 45000,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.delta as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toEqual({
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 45000,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-object settings field", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "bad-settings",
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: "not-an-object",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("preserves the settings node when PUT omits it (no clobber on config-only save)", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
epsilon: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Keep", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// PUT without a settings field: the existing settings node must
|
||||
// survive. A caller updating only the transport config (e.g. the
|
||||
// server config modal) must not silently wipe persisted headers.
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/epsilon`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "streamable-http", url: "https://x.test/other" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.epsilon as {
|
||||
settings?: { headers: { key: string; value: string }[] };
|
||||
};
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored.settings?.headers).toEqual([
|
||||
{ key: "X-Keep", value: "yes" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears the settings node when PUT sends settings: null (explicit intent)", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
zeta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/zeta`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: null,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.zeta as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a malformed settings shape with 400", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
eta: { type: "stdio", command: "node" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/eta`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "stdio", command: "node" },
|
||||
// headers should be an array of {key, value}; "oops" is a string.
|
||||
settings: {
|
||||
headers: "oops",
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toMatch(/settings\.headers/);
|
||||
});
|
||||
|
||||
it("accepts a settings-only PUT and preserves config from disk", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
theta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/theta`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
// No config — server should preserve the existing one inside its
|
||||
// write lock and apply only the settings patch.
|
||||
settings: {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.theta as {
|
||||
type?: string;
|
||||
url?: string;
|
||||
settings?: { headers: { key: string; value: string }[] };
|
||||
};
|
||||
expect(stored.type).toBe("streamable-http");
|
||||
expect(stored.url).toBe("https://x.test/mcp");
|
||||
expect(stored.settings?.headers).toEqual([
|
||||
{ key: "X-Tenant", value: "acme" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a non-object config on PUT with 400", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
iota: { type: "stdio", command: "node" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/iota`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: "not-an-object",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("validateSettings coerces empty-string OAuth fields to absent (cleared inputs don't read as 'configured')", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "empty-oauth",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthClientId: "",
|
||||
oauthClientSecret: "",
|
||||
oauthScopes: "",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers["empty-oauth"] as {
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored.settings).not.toHaveProperty("oauthClientId");
|
||||
expect(stored.settings).not.toHaveProperty("oauthClientSecret");
|
||||
expect(stored.settings).not.toHaveProperty("oauthScopes");
|
||||
});
|
||||
|
||||
it("validateSettings drops unknown keys (explicit pick-and-build, not spread)", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "unknown-keys",
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [{ key: "X-A", value: "1" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
// Unknown stowaway — must not survive the validator.
|
||||
stowaway: { keep: "me" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers["unknown-keys"] as {
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored.settings).not.toHaveProperty("stowaway");
|
||||
expect(stored.settings).toEqual({
|
||||
headers: [{ key: "X-A", value: "1" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips smuggled config.settings on POST (validateSettings is the only write path)", async () => {
|
||||
// `normalizeServerType` spreads unknown keys verbatim. Without the
|
||||
// strip in buildStoredEntry, a body that nests `settings` inside
|
||||
// `config` would land it on the stored entry without ever passing
|
||||
// through `validateSettings`. Pin the strip.
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "smuggle-post",
|
||||
config: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
settings: { bogus: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers["smuggle-post"] as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips smuggled config.settings on PUT even when settings:null clears the real node", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
smuggle: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
settings: {
|
||||
headers: [{ key: "X-Real", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// settings: null clears the real settings node; the bogus key nested
|
||||
// under config must not re-attach via the spread inside normalizeServerType.
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/smuggle`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
settings: { bogus: true },
|
||||
},
|
||||
settings: null,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.smuggle as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a settings array (not an object) with 400", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "arrays-not-allowed",
|
||||
config: { type: "stdio", command: "node" },
|
||||
settings: [],
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toMatch(/object/);
|
||||
});
|
||||
|
||||
it("strips a malformed settings node on read (lenient-on-read; preserve-on-PUT cannot propagate it)", async () => {
|
||||
// Hand-edited mcp.json with a malformed `settings.headers` (string
|
||||
// instead of array). The write-path validator would reject it, but a
|
||||
// user could write it directly. GET should surface the entry with
|
||||
// settings dropped, and a subsequent config-only PUT should not
|
||||
// re-attach the broken node via the preserve branch.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
broken: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: "oops",
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// GET surfaces the entry without the malformed settings.
|
||||
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as {
|
||||
mcpServers: Record<string, { settings?: unknown }>;
|
||||
};
|
||||
expect(getBody.mcpServers.broken).toBeDefined();
|
||||
expect(getBody.mcpServers.broken!.settings).toBeUndefined();
|
||||
|
||||
// PUT without settings preserves the (now-stripped) absence, not the
|
||||
// original malformed node.
|
||||
const putRes = await fetch(`${h.baseUrl}/api/servers/broken`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: { type: "streamable-http", url: "https://x.test/other" },
|
||||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.broken as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent mutations", () => {
|
||||
it("does not lose updates when many adds fire in parallel (write-lock)", async () => {
|
||||
// Without the in-process mutex, concurrent POSTs would all read the
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue