* feat(servers): persist server list to ~/.mcp-inspector/mcp.json (#1343) Replaces the hardcoded SEED_SERVERS in App.tsx with a file-backed list at ~/.mcp-inspector/mcp.json. Full CRUD round-trips through new /api/servers routes; first launch writes the two existing seeds if the file is absent. Backend (core): - store-io: getDefaultMcpConfigPath() — sibling of getDefaultStorageDir() - core/mcp/serverList.ts: pure converters MCPConfig <-> ServerEntry[], DEFAULT_SEED_CONFIG, and normalizeServerType (moved from node/config.ts to keep serverList.ts Node-free) - core/mcp/remote/node/server.ts: GET/POST/PUT/:id/DELETE/:id /api/servers routes, plus RemoteServerOptions.mcpConfigPath defaulting to getDefaultMcpConfigPath(). Ids validated with validateStoreId. Frontend: - core/react/useServers.ts: { servers, loading, error, refresh, addServer, updateServer, removeServer } over the new endpoints - App.tsx: drops SEED_SERVERS, wires useServers, disconnects on remove of the active server, follows id on rename of the active server - ServerConfigModal: Add/Edit/Clone form (stdio: command/args/env/cwd; sse/streamable-http: url/headers as one-per-line text) - ServerRemoveConfirmModal: confirm-on-remove with id + transport summary Tests (52 new): - unit: getDefaultMcpConfigPath env-var permutations, serverList round-trip, hook flows against real Hono app.fetch (no TCP), modal forms + validation - integration: /api/servers routes against a real createRemoteApp + tmp file - stories with play functions for both new modals Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR #1349 review (correctness + nits) Bugs: - normalizeServerType: validate against the allowed set and default unknown / non-string types to "stdio" (matches the missing-type case). Prevents a hand-edited {"type":"websocket"} from leaking past `as ServerType` and surprising downstream narrowing. - onConfirmRemove: when removing the active server, also destroy() the 9 state managers and null them + the client out so they GC immediately instead of waiting for the next server switch. - /api/servers mutating routes: serialize POST/PUT/DELETE through an in-process write-lock so concurrent read-modify-write cycles can't lose updates. New integration test fires 25 parallel POSTs and asserts every entry lands. - readMcpConfig comment: update to match actual behavior (valid-JSON file with no `mcpServers` → empty; invalid JSON → 500 from the outer try, so corruption surfaces rather than silently emptying the list). New integration tests pin the 500 path on both GET and POST. Nits: - Add --inspector-surface-subtle token, use it in ServerRemoveConfirmModal instead of the raw Mantine var. - Rename useServers buildHeaders boolean param to includeJsonBody. - Add a comment on the env-value vs header-value trim asymmetry (env values preserve trailing whitespace; header values follow RFC 7230). Tests: 1630 unit + 404 integration + 306 storybook all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ServerConfigModal): null currentTarget in functional setState updater React nulls SyntheticEvent.currentTarget after the synchronous handler returns. Reading e.currentTarget.value inside `setForm((f) => ...)` worked under happy-dom (which keeps currentTarget around) but threw "Cannot read properties of null (reading 'value')" on the first keystroke in a real browser. Capture the value before the functional updater closes over it. All seven onChange handlers in ServerConfigModal had the bug. Extended the AddEmpty storybook play to type into Server ID and Command — the real-Chromium runner now exercises this path so a future regression fails the test suite instead of the user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(remote-server): surface stderr + transport_error when subprocess crashes during startup A stdio subprocess that spawns successfully but exits before the browser opens the SSE events stream used to vanish into a bare "Session not found" 404. The fix has three parts: 1. RemoteSession.markTransportDead always pushes the transport_error event (via pushEvent's queue) rather than dropping it when no consumer is attached. The queued event drains when the consumer eventually arrives. 2. /api/mcp/connect's transport.onclose handler no longer deletes the session immediately when no consumer is attached. Instead it holds the session (with queued stderr + transport_error) for a 30s grace window so the events endpoint can drain it. A timeout sweeps if no consumer ever connects. 3. /api/mcp/events detects isTransportDead after attaching the consumer (which just drained the queue), yields once to flush, then closes the stream and deletes the session. End result: the user sees the actual error (e.g. "Cannot find module" from stderr + the transport-closed reason) instead of a confusing 404. Tests: - New unit tests (clients/web/src/test/integration/mcp/remote/remote-session.test.ts) cover the queueing behavior, live delivery, and clearEventConsumer signaling under all transport-state permutations. - New integration test (connect-crash.test.ts) spawns a node subprocess that writes to stderr and exits, then verifies the events stream delivers both the stderr line and the transport_error event before closing — the exact scenario from PR #1349. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(state): gate listRequestorTasks on the server's `tasks` capability ManagedRequestorTasksState was calling tasks/list on every connect. Servers that don't advertise the `tasks` capability (the common case) reply with -32601 "Method not found", which then surfaces in the inspector's console for every connect. Skip the call when the server doesn't declare `tasks` — empty tasks list is the right semantics. Existing tests opt into capabilities so the live-path coverage is preserved; two new tests pin the gated behavior (direct refresh + connect-event-triggered refresh). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(App): clear activeServerId on disconnect so other server cards re-enable ServerCard dims any card whose id differs from `activeServer` (and marks it inert), so cards stay non-interactive after a disconnect until the user reconnects elsewhere. activeServerId was set on connect but never reset on disconnect, leaving the whole screen locked to the just- disconnected card. Subscribe to InspectorClient's `disconnect` event and clear activeServerId there. Covers all three disconnect paths uniformly: - user toggles the connected card off - user clicks the header Disconnect button - mid-session transport failure (subprocess exit / network drop) Event-based instead of effect-based on connectionStatus to avoid the first-render-clobbers-new-id trap (status starts as "disconnected" for a freshly-created client before connect() runs). 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
e9ac57c548
commit
db1916cd34
5 changed files with 3176 additions and 137 deletions
195
clients/web/src/test/core/mcp/serverList.test.ts
Normal file
195
clients/web/src/test/core/mcp/serverList.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_SEED_CONFIG,
|
||||
mcpConfigToServerEntries,
|
||||
normalizeServerType,
|
||||
serverEntriesToMcpConfig,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import type { MCPConfig, ServerEntry } from "@inspector/core/mcp/types.js";
|
||||
|
||||
describe("normalizeServerType", () => {
|
||||
it("defaults missing type to stdio", () => {
|
||||
expect(normalizeServerType({ command: "node" })).toEqual({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites 'http' to 'streamable-http'", () => {
|
||||
expect(
|
||||
normalizeServerType({ type: "http", url: "https://x.test" }),
|
||||
).toEqual({
|
||||
type: "streamable-http",
|
||||
url: "https://x.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves sse / stdio / streamable-http alone", () => {
|
||||
expect(
|
||||
normalizeServerType({ type: "sse", url: "https://x.test" }),
|
||||
).toMatchObject({ type: "sse" });
|
||||
expect(
|
||||
normalizeServerType({ type: "stdio", command: "node" }),
|
||||
).toMatchObject({ type: "stdio" });
|
||||
expect(
|
||||
normalizeServerType({ type: "streamable-http", url: "https://x.test" }),
|
||||
).toMatchObject({ type: "streamable-http" });
|
||||
});
|
||||
|
||||
it("defaults an unknown string type to stdio (lenient on read)", () => {
|
||||
expect(
|
||||
normalizeServerType({ type: "websocket", command: "node" } as never),
|
||||
).toMatchObject({ type: "stdio" });
|
||||
expect(
|
||||
normalizeServerType({ type: "", command: "node" } as never),
|
||||
).toMatchObject({ type: "stdio" });
|
||||
});
|
||||
|
||||
it("defaults a non-string type to stdio", () => {
|
||||
expect(
|
||||
normalizeServerType({ type: 42, command: "node" } as never),
|
||||
).toMatchObject({ type: "stdio" });
|
||||
expect(
|
||||
normalizeServerType({ type: null, command: "node" } as never),
|
||||
).toMatchObject({ type: "stdio" });
|
||||
});
|
||||
|
||||
it("returns a fresh object (does not mutate input)", () => {
|
||||
const input = { command: "node" };
|
||||
const out = normalizeServerType(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect(input).not.toHaveProperty("type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpConfigToServerEntries", () => {
|
||||
it("uses the map key as both id and name", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "node" },
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(cfg);
|
||||
expect(entry.id).toBe("alpha");
|
||||
expect(entry.name).toBe("alpha");
|
||||
});
|
||||
|
||||
it("initializes connection to disconnected", () => {
|
||||
const [entry] = mcpConfigToServerEntries({
|
||||
mcpServers: { alpha: { type: "stdio", command: "node" } },
|
||||
});
|
||||
expect(entry.connection).toEqual({ status: "disconnected" });
|
||||
});
|
||||
|
||||
it("normalizes legacy 'http' and missing type", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
legacy: { command: "node" } as never,
|
||||
httpish: { type: "http", url: "https://x.test" } as never,
|
||||
},
|
||||
};
|
||||
const entries = mcpConfigToServerEntries(cfg);
|
||||
expect(entries[0]?.config.type).toBe("stdio");
|
||||
expect(entries[1]?.config.type).toBe("streamable-http");
|
||||
});
|
||||
|
||||
it("preserves insertion order across entries", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
a: { type: "stdio", command: "1" },
|
||||
b: { type: "stdio", command: "2" },
|
||||
c: { type: "stdio", command: "3" },
|
||||
},
|
||||
};
|
||||
expect(mcpConfigToServerEntries(cfg).map((e) => e.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("yields an empty list for an empty map", () => {
|
||||
expect(mcpConfigToServerEntries({ mcpServers: {} })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serverEntriesToMcpConfig", () => {
|
||||
it("strips runtime-only fields (connection, info, name)", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "Alpha (pretty)",
|
||||
config: { type: "stdio", command: "node" },
|
||||
connection: { status: "connected" },
|
||||
info: { name: "alpha-impl", version: "1.0.0" },
|
||||
},
|
||||
];
|
||||
const cfg = serverEntriesToMcpConfig(entries);
|
||||
expect(cfg).toEqual({
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "node" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through mcpConfigToServerEntries without data loss on config", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "node", args: ["-y", "x"] },
|
||||
beta: {
|
||||
type: "sse",
|
||||
url: "https://x.test",
|
||||
headers: { Authorization: "Bearer y" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("preserves insertion order on serialize", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "a",
|
||||
name: "a",
|
||||
config: { type: "stdio", command: "1" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
name: "b",
|
||||
config: { type: "stdio", command: "2" },
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
expect(Object.keys(serverEntriesToMcpConfig(entries).mcpServers)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_SEED_CONFIG", () => {
|
||||
it("contains the two canonical seed servers", () => {
|
||||
expect(Object.keys(DEFAULT_SEED_CONFIG.mcpServers)).toEqual([
|
||||
"filesystem-server-default",
|
||||
"everything-server-default",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses stdio + npx for both seeds", () => {
|
||||
for (const cfg of Object.values(DEFAULT_SEED_CONFIG.mcpServers)) {
|
||||
expect(cfg.type).toBe("stdio");
|
||||
if (cfg.type === "stdio") {
|
||||
expect(cfg.command).toBe("npx");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes the filesystem server to /tmp by default", () => {
|
||||
const fs = DEFAULT_SEED_CONFIG.mcpServers["filesystem-server-default"];
|
||||
if (fs?.type === "stdio") {
|
||||
expect(fs.args).toContain("/tmp");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -26,7 +26,10 @@ describe("ManagedRequestorTasksState", () => {
|
|||
let state: ManagedRequestorTasksState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
// Default to a server that advertises `tasks` so the existing flow tests
|
||||
// exercise the live `listRequestorTasks` path; capability-absent tests
|
||||
// below override this.
|
||||
client = new FakeInspectorClient({ capabilities: { tasks: {} } });
|
||||
state = new ManagedRequestorTasksState(client);
|
||||
});
|
||||
|
||||
|
|
@ -46,6 +49,38 @@ describe("ManagedRequestorTasksState", () => {
|
|||
expect(client.listRequestorTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh skips listRequestorTasks when the server doesn't advertise tasks capability", async () => {
|
||||
// Regression: pre-fix we always called tasks/list on connect; servers
|
||||
// that don't implement task tracking (the common case) replied with
|
||||
// -32601 "Method not found" and the error surfaced in the console.
|
||||
const taskless = new FakeInspectorClient({
|
||||
capabilities: { tools: {}, prompts: {}, resources: {} },
|
||||
});
|
||||
taskless.setStatus("connected");
|
||||
const tasklessState = new ManagedRequestorTasksState(taskless);
|
||||
|
||||
const result = await tasklessState.refresh();
|
||||
expect(result).toEqual([]);
|
||||
expect(taskless.listRequestorTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connect against a tasks-less server doesn't fire listRequestorTasks", async () => {
|
||||
// The connect event runs refresh; the capability gate must also catch it
|
||||
// there, not only the publicly-callable refresh().
|
||||
const taskless = new FakeInspectorClient({
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
taskless.setStatus("connected");
|
||||
const tasklessState = new ManagedRequestorTasksState(taskless);
|
||||
|
||||
taskless.dispatchTypedEvent("connect");
|
||||
// Yield once so the async refresh chained off connect runs.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(taskless.listRequestorTasks).not.toHaveBeenCalled();
|
||||
expect(tasklessState.getTasks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refresh fetches a single page and dispatches tasksChange", async () => {
|
||||
client.setStatus("connected");
|
||||
client.queueTaskPages({ tasks: [task("t1"), task("t2")] });
|
||||
|
|
|
|||
|
|
@ -20,7 +20,12 @@ describe("useManagedRequestorTasks", () => {
|
|||
let state: ManagedRequestorTasksState;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient({ status: "connected" });
|
||||
// Capabilities include `tasks` so refresh() reaches the live
|
||||
// listRequestorTasks path; the state manager gates on capability.
|
||||
client = new FakeInspectorClient({
|
||||
status: "connected",
|
||||
capabilities: { tasks: {} },
|
||||
});
|
||||
state = new ManagedRequestorTasksState(client);
|
||||
});
|
||||
|
||||
|
|
|
|||
294
clients/web/src/test/core/react/useServers.test.tsx
Normal file
294
clients/web/src/test/core/react/useServers.test.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* Tests for useServers — runs in happy-dom under the unit project, but
|
||||
* exercises a real `createRemoteApp` Hono instance via `app.fetch` (no TCP,
|
||||
* no port juggling) backed by a per-test tmp `mcp.json`. The route handlers
|
||||
* and on-disk persistence are exercised end-to-end alongside the React hook.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { useServers } from "@inspector/core/react/useServers";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server";
|
||||
import { DEFAULT_SEED_CONFIG } from "@inspector/core/mcp/serverList";
|
||||
import type { MCPConfig } from "@inspector/core/mcp/types";
|
||||
|
||||
interface Harness {
|
||||
fetchFn: typeof fetch;
|
||||
configPath: string;
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
function setupHarness(): Harness {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "inspector-useServers-"));
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
mcpConfigPath: configPath,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
});
|
||||
// Wrap so the typing matches Web fetch — Hono's app.fetch expects a Request
|
||||
// (not a URL string), so build one from string/URL inputs before dispatch.
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const req =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(input as string | URL, init);
|
||||
return app.fetch(req) as Promise<Response>;
|
||||
};
|
||||
return { fetchFn, configPath, tempDir };
|
||||
}
|
||||
|
||||
function teardownHarness(h: Harness): void {
|
||||
try {
|
||||
rmSync(h.tempDir, { recursive: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig(path: string): MCPConfig {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as MCPConfig;
|
||||
}
|
||||
|
||||
describe("useServers", () => {
|
||||
let h: Harness;
|
||||
|
||||
beforeEach(() => {
|
||||
h = setupHarness();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownHarness(h);
|
||||
});
|
||||
|
||||
it("starts in loading state, then loads and converts the seed config", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.servers).toEqual([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
const ids = result.current.servers.map((s) => s.id);
|
||||
expect(ids).toEqual([
|
||||
"filesystem-server-default",
|
||||
"everything-server-default",
|
||||
]);
|
||||
// Map key is used as both id and name; connection initializes disconnected
|
||||
for (const s of result.current.servers) {
|
||||
expect(s.name).toBe(s.id);
|
||||
expect(s.connection).toEqual({ status: "disconnected" });
|
||||
}
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("addServer persists and refreshes the list", async () => {
|
||||
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.addServer("alpha", {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers.some((s) => s.id === "alpha")).toBe(true);
|
||||
});
|
||||
expect(readConfig(h.configPath).mcpServers.alpha).toEqual({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
});
|
||||
});
|
||||
|
||||
it("addServer throws on 409 (duplicate id)", async () => {
|
||||
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.addServer("alpha", {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.addServer("alpha", {
|
||||
type: "stdio",
|
||||
command: "other",
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it("updateServer renames a key and updates config", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { alpha: { type: "stdio", command: "old" } },
|
||||
}),
|
||||
);
|
||||
|
||||
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.updateServer("alpha", "alpha-renamed", {
|
||||
type: "stdio",
|
||||
command: "new",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"alpha-renamed",
|
||||
]);
|
||||
});
|
||||
const cfg = readConfig(h.configPath);
|
||||
expect(cfg.mcpServers).not.toHaveProperty("alpha");
|
||||
expect(cfg.mcpServers["alpha-renamed"]).toEqual({
|
||||
type: "stdio",
|
||||
command: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("removeServer drops the entry and refreshes", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
alpha: { type: "stdio", command: "node" },
|
||||
beta: { type: "stdio", command: "node" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.removeServer("alpha");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh() re-reads from disk when the file changes externally", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// External edit (simulates the user editing mcp.json by hand)
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { hand: { type: "stdio", command: "hand-edited" } },
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["hand"]);
|
||||
});
|
||||
|
||||
it("captures the error message on fetch network failure", async () => {
|
||||
const failingFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockRejectedValue(new Error("network down"));
|
||||
const { result } = renderHook(() =>
|
||||
useServers({
|
||||
baseUrl: "http://test.local",
|
||||
fetchFn: failingFetch as typeof fetch,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("network down");
|
||||
expect(result.current.servers).toEqual([]);
|
||||
});
|
||||
|
||||
it("captures the error message on HTTP 500 from the backend", async () => {
|
||||
const failingFetch = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "disk full" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useServers({
|
||||
baseUrl: "http://test.local",
|
||||
fetchFn: failingFetch as typeof fetch,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("disk full");
|
||||
});
|
||||
|
||||
it("falls back to HTTP status when the error body has no `error` field", async () => {
|
||||
const failingFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response("oops", { status: 502 }));
|
||||
const { result } = renderHook(() =>
|
||||
useServers({
|
||||
baseUrl: "http://test.local",
|
||||
fetchFn: failingFetch as typeof fetch,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("HTTP 502");
|
||||
});
|
||||
|
||||
it("sends the x-mcp-remote-auth header when authToken is provided", async () => {
|
||||
const seenHeaders: Headers[] = [];
|
||||
const sniffingFetch: typeof fetch = async (input, init) => {
|
||||
seenHeaders.push(new Headers(init?.headers));
|
||||
return h.fetchFn(input, init);
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useServers({
|
||||
baseUrl: "http://test.local",
|
||||
authToken: "secret",
|
||||
fetchFn: sniffingFetch,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(seenHeaders[0]?.get("x-mcp-remote-auth")).toBe("Bearer secret");
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(
|
||||
Object.keys(DEFAULT_SEED_CONFIG.mcpServers),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
getDefaultMcpConfigPath,
|
||||
getDefaultStorageDir,
|
||||
} from "@inspector/core/storage/store-io.js";
|
||||
|
||||
describe("getDefaultMcpConfigPath", () => {
|
||||
const origHome = process.env.HOME;
|
||||
const origProfile = process.env.USERPROFILE;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.HOME;
|
||||
delete process.env.USERPROFILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origProfile;
|
||||
});
|
||||
|
||||
it("prefers HOME when set", () => {
|
||||
process.env.HOME = "/home/example";
|
||||
expect(getDefaultMcpConfigPath()).toBe(
|
||||
join("/home/example", ".mcp-inspector", "mcp.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to USERPROFILE when HOME is unset (Windows)", () => {
|
||||
process.env.USERPROFILE = "C:\\Users\\example";
|
||||
expect(getDefaultMcpConfigPath()).toBe(
|
||||
join("C:\\Users\\example", ".mcp-inspector", "mcp.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to '.' when neither env var is set", () => {
|
||||
expect(getDefaultMcpConfigPath()).toBe(
|
||||
join(".", ".mcp-inspector", "mcp.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("co-locates with getDefaultStorageDir under the same parent", () => {
|
||||
process.env.HOME = "/home/example";
|
||||
const cfg = getDefaultMcpConfigPath();
|
||||
const storage = getDefaultStorageDir();
|
||||
// mcp.json sits one level above the storage/ subdir
|
||||
expect(cfg).toBe(join("/home/example", ".mcp-inspector", "mcp.json"));
|
||||
expect(storage).toBe(join("/home/example", ".mcp-inspector", "storage"));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue