Design for replacing the hardcoded SEED_SERVERS in App.tsx with a file-backed list at ~/.mcp-inspector/mcp.json, reusing the v1.5-ported storage primitives and the canonical mcp.json parser. Ties the new doc into the Storage section nav as the first sub-menu item. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
Inspector V2 Tech Stack - Storage - Server List File
Brief | V1 Problems | V2 Scope | V2 Tech Stack | V2 UX
Web Client | Server | Storage
Overview | Server List File
Summary
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.
Goals
- 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.
Non-goals
- 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.
File location
- 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.
On-disk format
{
"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"]
}
}
}
- 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 Inspector adds no extension fields to keep
mcp.jsonclean and tool-interoperable. The edit dialog therefore treats id and display name as the same field; renaming = key-rotate + carry config across.
First-run behavior
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.
Architecture
Reused
| 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> |
Why not createFileStorageAdapter directly
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.
New code
core/storage/store-io.ts (extend)
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.
core/mcp/serverList.ts (new)
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 seeds
mcpConfigToServerEntries 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).
core/mcp/remote/node/server.ts (extend)
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 (delete old key + write new) |
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.
core/react/useServers.ts (new)
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).
clients/web/src/App.tsx changes
- 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.
UI surfaces
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.
Test plan
Place tests per AGENTS.md's integration-folder convention.
Unit (unit vitest project, happy-dom)
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).
Integration (integration vitest project, node env, 30s)
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), 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.
Coverage
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).
Manual
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.
Risks
- 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.
Out of scope (follow-ups)
- 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.