Brief | V1 Problems | V2 Scope | V2 Tech Stack | V2 UX | V2 Auth | V2 New Spec Impact
Web Client | CLI, TUI, Launcher | Server | Storage
Overview | Server List File
Replaces the hardcoded SEED_SERVERS in clients/web/src/App.tsx:47 with a file-backed list at ~/.mcp-inspector/mcp.json, read at startup, mutated via REST endpoints, surfaced through a useServers hook. The file also stores the per-server settings (custom headers, request metadata, timeouts, pre-configured OAuth credentials) edited by ServerSettingsForm — see Per-server settings below for the on-disk shape, UI rationale, and write/read invariants. Post-#1358 those settings fields live as direct keys on the entry (matching the Claude Code / Cursor / Cline .mcp.json convention) rather than under a nested settings block.
- Persist the user's server list across restarts.
- Use the canonical
{ mcpServers: { ... } }format so the file is interoperable with Claude Desktop / Cursor / Cline and editable by hand. - Reuse the file-I/O facility already ported from v1.5 (
core/storage/store-io.ts) and the parser already incore/mcp/node/config.ts. - Land full CRUD in one pass (per the scope decision) so the
onServerAdd/onServerEdit/onServerClone/onServerRemovestubs inApp.tsx:639stop lying.
- Sync with Claude Desktop's
claude_desktop_config.jsonlocation. We pick our own path; symlinking is the user's call. - Server schema validation beyond what
loadMcpServersConfigalready does (structural; no command-existence check). - Multi-user / multi-machine sync.
- Migrating CLI/TUI to the default path — they already accept
--config <path>viacore/mcp/node/config.ts. The new default-path helper will be in core so they can adopt it later.
- Path:
~/.mcp-inspector/mcp.json(Windows:%USERPROFILE%\.mcp-inspector\mcp.json). - Why this dir:
~/.mcp-inspector/storage/already exists for the Zustand-persist stores (OAuth, settings); one Inspector dir under$HOMEis friendlier than two. Resolution uses the sameprocess.env.HOME || process.env.USERPROFILEfallback asgetDefaultStorageDir()incore/storage/store-io.ts:13. - Why canonical filename: lets users symlink to/from Claude Desktop and similar tools.
- Permissions:
0o600, matchingwriteStoreFileincore/storage/store-io.ts:55.
- Matches
MCPConfigincore/mcp/types.ts:68. typeomitted → normalized to"stdio";type: "http"→"streamable-http"(normalizeServerTypeincore/mcp/node/config.ts:81).- The map key is the server
id.ServerEntry.idalready documents itself this way (core/mcp/types.ts:89: "The MCPConfig.mcpServers map key"). - Display name: derived from the map key. The edit dialog treats id and display name as the same field; renaming = key-rotate + carry config across.
- Each entry may optionally carry Inspector-extension fields (
headers,metadata,connectionTimeout,requestTimeout,oauth) as direct keys on the entry — post-#1358 these are no longer nested under asettingswrapper. Theheadersandoauthshapes match the Claude Code / Cursor / Cline.mcp.jsonconvention, so a file written by any of those tools is loadable on first connect.metadata/connectionTimeout/requestTimeoutare Inspector-only and other tools simply ignore them.
If the file does not exist when the backend boots, write a file containing the two current SEED_SERVERS. User immediately sees a non-empty Servers screen and discovers the file by editing one of the seeds. Subsequent boots read whatever the user has saved.
| Concern | File | What we reuse |
|---|---|---|
Atomic R/W + ENOENT handling + 0o600 + mkdir -p |
core/storage/store-io.ts |
readStoreFile, writeStoreFile, deleteStoreFile, parseStore, serializeStore |
mcp.json parsing + type normalization |
core/mcp/node/config.ts |
loadMcpServersConfig (already used by the CLI/TUI runner code); normalizeServerType needs to be exported |
| Hono backend + auth + storage routes pattern | core/mcp/remote/node/server.ts |
/api/storage/:storeId is the template for the new /api/servers routes |
| Auth'd fetch from browser | wired via getAuthToken() in clients/web/src/App.tsx:84 |
useServers will call the backend with x-mcp-remote-auth: Bearer <token> |
core/storage/adapters/file-storage.ts is a Zustand persist adapter — it wraps the payload as { state, version } so the file ends up looking like {"state":{...},"version":0}. That breaks the "human-editable canonical mcp.json" goal. We use the underlying store-io.ts primitives instead.
export function getDefaultMcpConfigPath(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
return path.join(homeDir, ".mcp-inspector", "mcp.json");
}Co-located with getDefaultStorageDir() so the two path conventions stay in one file.
Pure converters between on-disk MCPConfig and in-memory ServerEntry[]. No I/O — easy to unit-test under happy-dom.
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[];
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig;
export function DEFAULT_SEED_CONFIG: MCPConfig; // the two existing seedsmcpConfigToServerEntries sets connection: { status: "disconnected" } and uses the map key as both id and name. serverEntriesToMcpConfig strips connection / info (runtime-only) before serializing.
Also re-export normalizeServerType from core/mcp/node/config.ts (or move it into serverList.ts and import it back into config.ts).
Add granular endpoints (mirror of /api/storage/:storeId, but specialized so the UI can do per-row mutations without read-modify-write across tabs):
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/api/servers |
— | { mcpServers: {...} } — creates the file with seeds if absent |
POST |
/api/servers |
{ id: string, config: MCPServerConfig } |
{ ok: true }; 409 if id already exists |
PUT |
/api/servers/:id |
{ id?: string, config: MCPServerConfig } |
{ ok: true }; supports id rename (rewrites mcpServers key; migrates keychain secrets — see §Secret storage) |
DELETE |
/api/servers/:id |
— | { ok: true } (ignores missing) |
id is validated with validateStoreId (same alphanum+hyphen+underscore rule as store IDs — prevents anyone slipping .. into the key). All routes serialize through the same atomic writeStoreFile, so concurrent writes are well-defined (last writer wins per-write; granularity reduces blast radius).
RemoteServerOptions gains:
/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */
mcpConfigPath?: string;Defaulted via getDefaultMcpConfigPath(). clients/web/server/vite-hono-plugin.ts:62 and clients/web/server/server.ts:48 get a one-line update to pass config.mcpConfigPath if/when the web config grows the field; for v1, the default is sufficient.
export interface UseServersResult {
servers: ServerEntry[];
loading: boolean;
error: string | undefined;
refresh: () => Promise<void>;
addServer: (id: string, config: MCPServerConfig) => Promise<void>;
updateServer: (originalId: string, newId: string, config: MCPServerConfig) => Promise<void>;
removeServer: (id: string) => Promise<void>;
}
export function useServers(opts: {
baseUrl: string; // window.location.origin by default in callers
authToken: string | undefined;
}): UseServersResult;Fetches on mount via fetch(${baseUrl}/api/servers, ...) with the auth header. Holds the list in useState. Mutators do the HTTP call then re-fetch to keep server-and-client in sync (the list is small, ~tens of entries; optimistic merging is not worth the bug surface).
- Remove the
SEED_SERVERSconstant (seeds move tocore/mcp/serverList.tsasDEFAULT_SEED_CONFIG). - Replace
const [servers] = useState<ServerEntry[]>(SEED_SERVERS);withconst { servers, addServer, updateServer, removeServer } = useServers({ baseUrl: window.location.origin, authToken: getAuthToken() });. - Wire
onServerAdd/onServerEdit/onServerClone/onServerRemove(currentlytodoNoopatclients/web/src/App.tsx:639–646) to the hook. - Disconnect-on-remove: if the user removes the
activeServerId, callinspectorClient?.disconnect()and clear active server state before the mutation. Matches the lifecycle theuseEffectatApp.tsx:272already enforces on unmount. - Active-server pinning across rename:
updateServerreturns the new id; iforiginalId === activeServerId, updateactiveServerIdto the new id.
The InspectorView prop interface already declares onServerAdd / onServerEdit / onServerClone / onServerRemove / onServerImportConfig / onServerImportJson. The dialogs themselves are TBD — out of scope for this spec is the visual design; in scope is wiring them to the new hook. If the Add/Edit dialog component does not exist yet, ship a minimal Mantine Modal + TextInput + transport-specific fields. Follow clients/web/src/components/... conventions (subcomponent constants via .withProps(), theme variants for styling — per AGENTS.md's React rules).
onServerImportConfig / onServerImportJson map naturally to "paste a full mcpServers block" and "upload an mcp.json file"; both become bulk POST /api/servers calls in a loop (or a single PUT /api/servers that we add later). Defer until basic add/edit/remove is working.
Place tests per AGENTS.md's integration-folder convention.
clients/web/src/test/core/mcp/serverList.test.ts— round-tripMCPConfig↔ServerEntry[]; verifies the map key becomes the id, thatconnection/infoare stripped on serialize, and thatnormalizeServerTypeis applied on parse.clients/web/src/test/core/storage/getDefaultMcpConfigPath.test.ts— env-var permutations (HOMEset /USERPROFILEset / neither).
clients/web/src/test/integration/mcp/remote/servers-route.test.ts— spin upcreateRemoteAppwith a tmpmcpConfigPath, exercise GET (file absent → seeds written; file present → returned), POST (success + 409 on dup), PUT (rename + payload update + keychain secret migration on rename), DELETE (existing + missing). Mirrors theadapters.test.tspattern already inclients/web/src/test/integration/storage/adapters.test.ts.clients/web/src/test/integration/react/useServers.test.tsx— render the hook against a realcreateRemoteAppHono instance (no mocking); assert load, add, update, remove flows reflect what the backend has on disk.
The 90% per-file gate applies to the new files. The pure converters and route handlers are easy; the React hook's error path needs explicit coverage (network error, 4xx response, 5xx response).
Per AGENTS.md's "test new or modified code" rule plus the UI-changes guidance: run npm run dev, verify (a) first launch writes the seeds, (b) editing the file by hand and reloading the browser shows the edit, (c) Add/Edit/Remove from the UI persist across a hard reload, (d) deleting the active server cleanly disconnects.
- Concurrent writes from multiple browser tabs. Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention is last-write-wins, which is fine for a config the user is editing manually. We do not add file locking; the cost outweighs the rare case.
- User edits the file while the browser is open. Browser holds a stale list until the user hits Refresh on the Servers screen (the
refreshreturned by the hook). Acceptable for v1; auto-watching the file is a possible follow-up butfs.watchsemantics across OSes are a long tail of bugs. - Schema drift with Claude Desktop / Cursor. They occasionally add fields (e.g. Claude Desktop's
disabled).loadMcpServersConfigcurrently doesJSON.parse(...) as MCPConfig— extra fields survive the round-trip as long as we don't filter them. The converters inserverList.tsshould preserve unknown fields onMCPServerConfigrather than copying a fixed allow-list. - Migration from
SEED_SERVERS. Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memoryuseStatelist today, so nothing to migrate.
Our UI design separates the basic server configuration (transport, URL or command + args + env) from settings (custom headers, connect/request timeout, global request metadata, client id/secret) into two dialogs. The reason they're separated in the UI is that custom settings are less likely to be needed than basic config, so a simpler, friendlier form greets most users.
#1352 originally persisted these settings under a nested settings block on each entry. #1358 flattens them onto the entry as direct keys, so the on-disk shape matches the .mcp.json convention Claude Code / Cursor / Cline use (headers as a Record<string, string>, oauth as a nested object). The in-memory + wire shape is unchanged: InspectorServerSettings keeps pair-array headers and flat oauthClientId / oauthClientSecret / oauthScopes because the form needs them in that shape for controlled-component editing.
Each server entry may carry these Inspector-extension fields at the top level:
{
"mcpServers": {
"my-server": {
"type": "streamable-http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" },
"metadata": [{ "key": "tenant", "value": "acme" }],
"connectionTimeout": 30000,
"requestTimeout": 60000,
"oauth": {
"clientId": "...",
"clientSecret": "...",
"scopes": "read:tools write:tools"
}
}
}
}- Shape:
- On disk (
StoredMCPServerincore/mcp/types.ts):headersasRecord<string, string>,metadataas a pair-array (Inspector-only — no compat target), numeric timeouts,oauthas a nested{ clientId?, clientSecret?, scopes? }object. Every field optional; absent fields are omitted on disk so the file diff stays minimal. - In memory + on the wire (
InspectorServerSettingsincore/mcp/types.ts):headersas a pair-array{ key, value }[], flatoauthClientId/oauthClientSecret/oauthScopesfields, requiredmetadata(pair-array) + required numericconnectionTimeout/requestTimeout(the form needs concrete values to render; 0 is the SDK's "no timeout" signal). - The bidirectional conversion lives in
core/mcp/serverList.ts(storedFieldsToInspectorSettings/inspectorSettingsToStoredFields) and is invoked bymcpConfigToServerEntries/serverEntriesToMcpConfigand by the server route'sbuildStoredEntry.
- On disk (
- Where it takes effect:
headers→ wire headers on every SSE / streamable-http request (core/mcp/node/transport.tsconsumes the pair-array viaInspectorServerSettings.headers).metadata→ default_metapayload merged into every outgoing MCP request (core/mcp/inspectorClient.ts'smergeMetahelper). Per-call metadata wins on key collision.requestTimeout→InspectorClientOptions.timeout.connectionTimeout→Promise.racewrapper aroundInspectorClient.connect()in the web client.oauth.clientId/oauth.clientSecret/oauth.scopes→ pre-seeded OAuth client credentials viaInspectorClientOptions.oauth(the disk-sideoauthobject is lifted into the flatoauthClientId/ etc. fields onInspectorServerSettingsfor the form).
- First-connect contract: settings apply on the first outbound request after the entry loads from disk — no need to open the settings form. The browser sends
settingsto the backend in the/api/mcp/connectbody; the backend reads it fromRemoteConnectRequestand threads it intocreateTransportNode. - Secret storage (#1356):
oauth.clientSecretand stdioenvvalues are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via@napi-rs/keyring), keyed by(serverId, field)under the service namemcp-inspector. Field names:oauth-client-secret,env:<KEY>(one per stdio env variable). The on-diskmcp.jsonis stripped of these values —oauth.clientSecretis omitted entirely, stdio env keys are preserved with empty-string placeholders ("env": { "API_KEY": "" }) so the file still documents the env interface the server expects. The wire shape returned byGET /api/serversis unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. TUI/CLI rehydration: the sharedloadServerEntries()path (core/mcp/node/servers.ts) callsrehydrateMcpConfigFromKeychain()(core/mcp/node/server-secrets.ts) after readingmcp.json, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the webGET /api/serversmigration sweep). The keychain interactions live incore/auth/node/secret-store.tsbehind aSecretStoreinterface;KeyringSecretStoreis the production impl andInMemorySecretStoreis the test double the integration suite injects viaRemoteServerOptions.secretStore.- Migration: on every
GET /api/servers, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for(serverId, field), the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material. - Linux without libsecret:
KeyringSecretStoreis tolerant — only thesetoperation throwsKeychainUnavailableError(translated to a503by the handlers);getreturnsnulland the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs. - Migration tolerance: when migration encounters
KeychainUnavailableError, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action. - Write ordering on POST/PUT: keychain writes happen before the disk write, and obsolete-field deletions happen after. The intent is that a
setfailure (the only hard-fail path) leaves both stores in their pre-write state — no half-applied entry on disk that would trap a retry POST at409, and no premature deletion of an obsolete field whose disk write later fails. - Id rename (
PUTwith newid): secrets are keyed by server id (themcpServersmap key), not URL. On rename the handler reads existing keychain entries for the original id (expectedSecretFields+readKeychainEntriesFor), merges them with any secrets in the PUT body (body wins on conflict), filters to fields the new on-disk entry still expects (so removed stdio env keys are not carried over), writes under the new id, updates disk, thendeleteAllForServer(originalId). This matters for config-only renames (e.g. Server Config modal) that do not re-send OAuth client secrets or stdio env values — those live only in the keychain after #1356. Covered byservers-route.test.ts(PUT rename moves … when it exists only in the keychainfor OAuth and stdio env). - Out of scope for this PR: the OAuth handshake itself still runs in the browser via the MCP SDK, so during the token exchange the secret transits the wire (browser → MCP SDK → OAuth provider's token endpoint). The on-disk win this PR delivers is that the secret is no longer in the shareable / symlinked
mcp.jsonand is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately.
- Migration: on every
- Hard-cutover legacy behavior (per #1358 decision 4): files written by the one pre-#1358 build of v2/main have a nested
settingsblock.normalizeMcpServersdrops the node on read and logs a one-line warn including the server id; the persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read. Users re-enter them via the settings form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between #1353 merging and this change. - UI:
ServerSettingsModalis opened from the server card's settings affordance. Saving routes throughuseServers.updateServerSettings(id, settings)which issues a settings-onlyPUT /api/servers/:idwith{ id, settings }— the route preserves the on-disk transport config inside its write lock. Conversely,useServers.updateServer(driven by the basic-config modal) issues a config-only PUT with{ id, config }and the route preserves the on-disk settings fields. Edits in either modal cannot silently wipe the other half. - Save cadence: the form fires
onSettingsChangeon every keystroke.App.tsxdebounces 300 ms and flushes on modal close so a burst of edits coalesces into a single PUT. If the close-flush PUT fails (network hiccup, server 500), a red@mantine/notificationstoast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved. PUT /api/servers/:idpatch semantics (kept-envelope wire shape per #1358 decision 5): bothconfigandsettingsare independent patches on the wire, even though the on-disk shape has nosettingswrapper. The envelope-on-the-wire keeps the preserve/clear/apply semantics #1353 introduced — the backend splats validatedsettings.*into top-level disk keys when assembling the next on-disk shape.- Field omitted → preserve the on-disk value.
- Explicit
nullonsettings→ clear all Inspector-extension fields on disk (headers/metadata/connectionTimeout/requestTimeout/oauth). (configmay not benull; a body that wants to update only settings should omitconfigentirely.) - Field present and well-formed → validate and apply.
- A bare
PUT { id: "renamed" }is a pure rename preserving both halves.
- Write-path gates:
validateSettingsrejects malformed shapes (non-object, wrong-typedheaders/metadata, non-numeric timeouts) with400+ descriptive message and picks-and-builds the validated value so unknown stowaway keys silently drop.buildStoredEntrystrips any of the Inspector-extension keys (settings,headers,metadata,connectionTimeout,requestTimeout,oauth) smuggled inside the incomingconfigand logs awarnwith the server id, so the wire envelope'ssettingsfield remains the only path those values reach disk. - Read-path gates:
normalizeMcpServerspasses the entry's flat Inspector-extension fields through verbatim — the form'sstoredFieldsToInspectorSettingsdoes the lift into the in-memory pair-array / flat-OAuth shape. A legacy nestedsettingsblock triggers the hard-cutover drop described above.
- Import-from-Claude-Desktop button (read
~/Library/Application Support/Claude/claude_desktop_config.jsonor the Windows/Linux equivalent, merge into our file). - File watching for hot reload of external edits.
- Per-server tags / folders / groups.
- Export current list as JSON.
- CLI/TUI: switch their default
--configtogetDefaultMcpConfigPath()when no--configflag is given. Touch when those clients are wired up to v2. While porting, re-add a--headerflag that writes to the entry's top-levelheadersfield on disk (post-#1358 flat shape) rather than toMCPServerConfig.
{ "mcpServers": { "filesystem-server-default": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }, "everything-server-default": { "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"] }, "acme-api": { "type": "streamable-http", "url": "https://api.acme.example/mcp", // Inspector-extension fields (post-#1358) live as direct keys on the // entry, matching the Claude Code / Cursor / Cline `.mcp.json` // convention. See [Per-server settings](#per-server-settings-1352) // for the full contract; the in-memory + wire shape keeps pair-array // headers and flat oauth* fields for the form's controlled-component // editing. "headers": { "X-Tenant": "acme" }, "metadata": [{ "key": "trace", "value": "abc" }], "connectionTimeout": 30000, "requestTimeout": 60000, "oauth": { "clientId": "client-abc", "scopes": "read:tools write:tools" } } } }