|
| 1 | +# Inspector V2 Tech Stack - Storage - Server List File |
| 2 | + |
| 3 | +### Brief | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) |
| 4 | + |
| 5 | +#### [Web Client](v2_web_client.md) | [Server](v2_server.md) | Storage |
| 6 | +##### [Overview](v2_storage.md) | Server List File |
| 7 | + |
| 8 | +## Summary |
| 9 | + |
| 10 | +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. |
| 11 | + |
| 12 | +## Goals |
| 13 | + |
| 14 | +- Persist the user's server list across restarts. |
| 15 | +- Use the canonical `{ mcpServers: { ... } }` format so the file is interoperable with Claude Desktop / Cursor / Cline and editable by hand. |
| 16 | +- Reuse the file-I/O facility already ported from v1.5 (`core/storage/store-io.ts`) and the parser already in `core/mcp/node/config.ts`. |
| 17 | +- Land full CRUD in one pass (per the scope decision) so the `onServerAdd` / `onServerEdit` / `onServerClone` / `onServerRemove` stubs in `App.tsx:639` stop lying. |
| 18 | + |
| 19 | +## Non-goals |
| 20 | + |
| 21 | +- Sync with Claude Desktop's `claude_desktop_config.json` location. We pick our own path; symlinking is the user's call. |
| 22 | +- Server schema validation beyond what `loadMcpServersConfig` already does (structural; no command-existence check). |
| 23 | +- Multi-user / multi-machine sync. |
| 24 | +- Migrating CLI/TUI to the default path — they already accept `--config <path>` via `core/mcp/node/config.ts`. The new default-path helper will be in core so they can adopt it later. |
| 25 | + |
| 26 | +## File location |
| 27 | + |
| 28 | +- **Path**: `~/.mcp-inspector/mcp.json` (Windows: `%USERPROFILE%\.mcp-inspector\mcp.json`). |
| 29 | +- **Why this dir**: `~/.mcp-inspector/storage/` already exists for the Zustand-persist stores (OAuth, settings); one Inspector dir under `$HOME` is friendlier than two. Resolution uses the same `process.env.HOME || process.env.USERPROFILE` fallback as `getDefaultStorageDir()` in `core/storage/store-io.ts:13`. |
| 30 | +- **Why canonical filename**: lets users symlink to/from Claude Desktop and similar tools. |
| 31 | +- **Permissions**: `0o600`, matching `writeStoreFile` in `core/storage/store-io.ts:55`. |
| 32 | + |
| 33 | +## On-disk format |
| 34 | + |
| 35 | +```jsonc |
| 36 | +{ |
| 37 | + "mcpServers": { |
| 38 | + "filesystem-server-default": { |
| 39 | + "type": "stdio", |
| 40 | + "command": "npx", |
| 41 | + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] |
| 42 | + }, |
| 43 | + "everything-server-default": { |
| 44 | + "type": "stdio", |
| 45 | + "command": "npx", |
| 46 | + "args": ["-y", "@modelcontextprotocol/server-everything"] |
| 47 | + } |
| 48 | + } |
| 49 | +} |
| 50 | +``` |
| 51 | + |
| 52 | +- Matches `MCPConfig` in `core/mcp/types.ts:68`. |
| 53 | +- `type` omitted → normalized to `"stdio"`; `type: "http"` → `"streamable-http"` (`normalizeServerType` in `core/mcp/node/config.ts:81`). |
| 54 | +- The map key is the **server `id`**. `ServerEntry.id` already documents itself this way (`core/mcp/types.ts:89`: "The MCPConfig.mcpServers map key"). |
| 55 | +- Display name: derived from the map key. The Inspector adds **no extension fields** to keep `mcp.json` clean and tool-interoperable. The edit dialog therefore treats id and display name as the same field; renaming = key-rotate + carry config across. |
| 56 | + |
| 57 | +## First-run behavior |
| 58 | + |
| 59 | +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. |
| 60 | + |
| 61 | +## Architecture |
| 62 | + |
| 63 | +### Reused |
| 64 | + |
| 65 | +| Concern | File | What we reuse | |
| 66 | +|---|---|---| |
| 67 | +| Atomic R/W + ENOENT handling + 0o600 + `mkdir -p` | `core/storage/store-io.ts` | `readStoreFile`, `writeStoreFile`, `deleteStoreFile`, `parseStore`, `serializeStore` | |
| 68 | +| `mcp.json` parsing + type normalization | `core/mcp/node/config.ts` | `loadMcpServersConfig` (already used by the CLI/TUI runner code); `normalizeServerType` needs to be exported | |
| 69 | +| Hono backend + auth + storage routes pattern | `core/mcp/remote/node/server.ts` | `/api/storage/:storeId` is the template for the new `/api/servers` routes | |
| 70 | +| 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>` | |
| 71 | + |
| 72 | +### Why not `createFileStorageAdapter` directly |
| 73 | + |
| 74 | +`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. |
| 75 | + |
| 76 | +### New code |
| 77 | + |
| 78 | +#### `core/storage/store-io.ts` (extend) |
| 79 | + |
| 80 | +```ts |
| 81 | +export function getDefaultMcpConfigPath(): string { |
| 82 | + const homeDir = process.env.HOME || process.env.USERPROFILE || "."; |
| 83 | + return path.join(homeDir, ".mcp-inspector", "mcp.json"); |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +Co-located with `getDefaultStorageDir()` so the two path conventions stay in one file. |
| 88 | + |
| 89 | +#### `core/mcp/serverList.ts` (new) |
| 90 | + |
| 91 | +Pure converters between on-disk `MCPConfig` and in-memory `ServerEntry[]`. No I/O — easy to unit-test under happy-dom. |
| 92 | + |
| 93 | +```ts |
| 94 | +export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[]; |
| 95 | +export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig; |
| 96 | +export function DEFAULT_SEED_CONFIG: MCPConfig; // the two existing seeds |
| 97 | +``` |
| 98 | + |
| 99 | +`mcpConfigToServerEntries` sets `connection: { status: "disconnected" }` and uses the map key as both `id` and `name`. `serverEntriesToMcpConfig` strips `connection` / `info` (runtime-only) before serializing. |
| 100 | + |
| 101 | +Also re-export `normalizeServerType` from `core/mcp/node/config.ts` (or move it into `serverList.ts` and import it back into `config.ts`). |
| 102 | + |
| 103 | +#### `core/mcp/remote/node/server.ts` (extend) |
| 104 | + |
| 105 | +Add granular endpoints (mirror of `/api/storage/:storeId`, but specialized so the UI can do per-row mutations without read-modify-write across tabs): |
| 106 | + |
| 107 | +| Method | Path | Body | Response | |
| 108 | +|---|---|---|---| |
| 109 | +| `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent | |
| 110 | +| `POST` | `/api/servers` | `{ id: string, config: MCPServerConfig }` | `{ ok: true }`; 409 if `id` already exists | |
| 111 | +| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (delete old key + write new) | |
| 112 | +| `DELETE` | `/api/servers/:id` | — | `{ ok: true }` (ignores missing) | |
| 113 | + |
| 114 | +`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). |
| 115 | + |
| 116 | +`RemoteServerOptions` gains: |
| 117 | +```ts |
| 118 | +/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */ |
| 119 | +mcpConfigPath?: string; |
| 120 | +``` |
| 121 | + |
| 122 | +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. |
| 123 | + |
| 124 | +#### `core/react/useServers.ts` (new) |
| 125 | + |
| 126 | +```ts |
| 127 | +export interface UseServersResult { |
| 128 | + servers: ServerEntry[]; |
| 129 | + loading: boolean; |
| 130 | + error: string | undefined; |
| 131 | + refresh: () => Promise<void>; |
| 132 | + addServer: (id: string, config: MCPServerConfig) => Promise<void>; |
| 133 | + updateServer: (originalId: string, newId: string, config: MCPServerConfig) => Promise<void>; |
| 134 | + removeServer: (id: string) => Promise<void>; |
| 135 | +} |
| 136 | + |
| 137 | +export function useServers(opts: { |
| 138 | + baseUrl: string; // window.location.origin by default in callers |
| 139 | + authToken: string | undefined; |
| 140 | +}): UseServersResult; |
| 141 | +``` |
| 142 | + |
| 143 | +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). |
| 144 | + |
| 145 | +### `clients/web/src/App.tsx` changes |
| 146 | + |
| 147 | +1. Remove the `SEED_SERVERS` constant (seeds move to `core/mcp/serverList.ts` as `DEFAULT_SEED_CONFIG`). |
| 148 | +2. Replace `const [servers] = useState<ServerEntry[]>(SEED_SERVERS);` with `const { servers, addServer, updateServer, removeServer } = useServers({ baseUrl: window.location.origin, authToken: getAuthToken() });`. |
| 149 | +3. Wire `onServerAdd` / `onServerEdit` / `onServerClone` / `onServerRemove` (currently `todoNoop` at `clients/web/src/App.tsx:639`–`646`) to the hook. |
| 150 | +4. Disconnect-on-remove: if the user removes the `activeServerId`, call `inspectorClient?.disconnect()` and clear active server state before the mutation. Matches the lifecycle the `useEffect` at `App.tsx:272` already enforces on unmount. |
| 151 | +5. Active-server pinning across rename: `updateServer` returns the new id; if `originalId === activeServerId`, update `activeServerId` to the new id. |
| 152 | + |
| 153 | +### UI surfaces |
| 154 | + |
| 155 | +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). |
| 156 | + |
| 157 | +`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. |
| 158 | + |
| 159 | +## Test plan |
| 160 | + |
| 161 | +Place tests per `AGENTS.md`'s integration-folder convention. |
| 162 | + |
| 163 | +### Unit (`unit` vitest project, happy-dom) |
| 164 | + |
| 165 | +- `clients/web/src/test/core/mcp/serverList.test.ts` — round-trip `MCPConfig` ↔ `ServerEntry[]`; verifies the map key becomes the id, that `connection` / `info` are stripped on serialize, and that `normalizeServerType` is applied on parse. |
| 166 | +- `clients/web/src/test/core/storage/getDefaultMcpConfigPath.test.ts` — env-var permutations (`HOME` set / `USERPROFILE` set / neither). |
| 167 | + |
| 168 | +### Integration (`integration` vitest project, node env, 30s) |
| 169 | + |
| 170 | +- `clients/web/src/test/integration/mcp/remote/servers-route.test.ts` — spin up `createRemoteApp` with a tmp `mcpConfigPath`, exercise GET (file absent → seeds written; file present → returned), POST (success + 409 on dup), PUT (rename + payload update), DELETE (existing + missing). Mirrors the `adapters.test.ts` pattern already in `clients/web/src/test/integration/storage/adapters.test.ts`. |
| 171 | +- `clients/web/src/test/integration/react/useServers.test.tsx` — render the hook against a real `createRemoteApp` Hono instance (no mocking); assert load, add, update, remove flows reflect what the backend has on disk. |
| 172 | + |
| 173 | +### Coverage |
| 174 | + |
| 175 | +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). |
| 176 | + |
| 177 | +### Manual |
| 178 | + |
| 179 | +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. |
| 180 | + |
| 181 | +## Risks |
| 182 | + |
| 183 | +- **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. |
| 184 | +- **User edits the file while the browser is open.** Browser holds a stale list until the user hits Refresh on the Servers screen (the `refresh` returned by the hook). Acceptable for v1; auto-watching the file is a possible follow-up but `fs.watch` semantics across OSes are a long tail of bugs. |
| 185 | +- **Schema drift with Claude Desktop / Cursor.** They occasionally add fields (e.g. Claude Desktop's `disabled`). `loadMcpServersConfig` currently does `JSON.parse(...) as MCPConfig` — extra fields survive the round-trip as long as we don't filter them. The converters in `serverList.ts` should preserve unknown fields on `MCPServerConfig` rather than copying a fixed allow-list. |
| 186 | +- **Migration from `SEED_SERVERS`.** Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memory `useState` list today, so nothing to migrate. |
| 187 | + |
| 188 | +## Out of scope (follow-ups) |
| 189 | + |
| 190 | +- Import-from-Claude-Desktop button (read `~/Library/Application Support/Claude/claude_desktop_config.json` or the Windows/Linux equivalent, merge into our file). |
| 191 | +- File watching for hot reload of external edits. |
| 192 | +- Per-server tags / folders / groups. |
| 193 | +- Export current list as JSON. |
| 194 | +- CLI/TUI: switch their default `--config` to `getDefaultMcpConfigPath()` when no `--config` flag is given. Touch when those clients are wired up to v2. |
0 commit comments