test: raise per-file coverage gate to 90 across all clients (closes #1271) (#1532)

* test(web): audit branch-coverage outliers, raise per-file gate to 70 (#1271)

The web per-file coverage gate had drifted red: the recently-landed EMA
auth feature (core/auth/ema/*) and ClientSettings components shipped
undertested, and the prior 50% branch floor let business-logic branches
go untested. CI does not run the coverage gate, so this accumulated.

Add tests to bring every failing file above the gate and push branch
coverage well past 70 across the board, then raise the per-file branches
threshold 50 -> 70 in clients/web/vite.config.ts.

- EMA core (emaFlow, resourceContext, jwt, wire): 50-88% -> 86-100% branch
- EMA OIDC/transport (idpOidc, tokenEndpoint, transportProvider): -> 97-100%
- oauthManager / discovery: 73-83% -> 88-92%
- ClientSettingsModal/Form, ConnectionInfoContent: 0-88% -> 96-100%
- useClientSettingsDraft / useEmaIdpLoginState hooks: 20% -> 100%
- TaskStatusBadge / SortableServerCard outliers: 50% -> 100%

Coverable branches reached by mocking useComputedColorScheme (dark theme),
dnd-kit useSortable (isDragging), and fetch/OIDC paths. Remaining sub-100
branches are genuinely-unreachable defensive guards or happy-dom-inherent
(portal / useMediaQuery / typeof window) and documented in test comments.

No source files changed. Global web branch coverage 88.73% -> 90.63%.
AGENTS.md updated; cli/tui/launcher stay at 50 (out of scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: raise per-file coverage gate to 90/90/90/90 across all clients

Follows the #1271 web branch-coverage audit (50 -> 70) by raising the
whole per-file gate to >=90 on all four dimensions — lines, statements,
functions, branches — for web, cli, tui, and launcher (core/ is measured
under the web suite). A codebase-wide audit added real tests for every
outlier and brought global coverage up across the board.

Config: all four vitest/vite coverage configs set to 90/90/90/90.
Two pure-type modules (core/mcp/import/types.ts, clients/web/server/
types.ts) added to the web coverage exclude list, matching the existing
type-only exclusions.

Tests: ~50 test files extended/created across core (auth, ema, mcp,
remote, state, storage, react), web components/screens/server, and the
cli/tui clients. Highlights: full EMA OAuth flow + transport coverage,
inspectorClient/remote server backfill, every Mantine control's handler
branches, and the cli unsupported-method / argv-fallback paths.

Genuinely-unreachable branches are annotated at the source with justified
`/* v8 ignore -- <reason> */` comments rather than lowering the gate:
happy-dom-inherent paths (Mantine portal, useMediaQuery, typeof window
SSR guards), React StrictMode effect replay, and provably-dead defensive
guards (?? fallbacks the types make impossible, Select.onChange values
outside the allowed list). No runtime logic changed; the only structural
edits are two behavior-preserving refactors (ResourceTemplatePanel hoists
the field value to a local so the ignore applies in JSX; ToolsScreen
swaps a ternary for an if) and prettier line-wrapping.

Also fixed a flaky real-timer debounce assertion in PromptArgumentsForm
(asserted an exact sibling-typing interleaving that raced the 300ms
debounce; now asserts the load-bearing fact — a non-empty fire-time
sibling capture).

AGENTS.md updated to describe the uniform 90 gate and the v8-ignore
policy. All four client coverage gates, root validate, storybook (370),
and smoke pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): make connect-crash SSE reader stop on the terminal event

`readSseEvents` looped until the server closed the stream (`done`). The
server closes the events stream immediately after writing the terminal
`transport_error`, but under the heavy parallel scheduler pressure of a
full `coverage` run (v8 instrumentation across all workers) that close
can lag far enough behind the event to trip the 30s test timeout — a
hang, not a wrong result. This made the test intermittently fail under
`npm run coverage` (it passed in isolation and under CI's lighter
`test:integration`).

Stop reading as soon as the terminal `transport_error` event arrives
(cancelling the reader) instead of waiting only for `done`. That event is
always the last one the session queue emits, so nothing is lost, and the
test no longer depends on stream-close timing. The `done` break is kept
for any stream that closes first.

Verified green across two consecutive full `npm run test:coverage` runs
(3094 tests) where it previously hung, plus isolation (~0.5s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-27 15:26:03 -04:00 • committed by GitHub
commit c96db0402e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 8793 additions and 63 deletions

View file

@ -314,4 +314,24 @@ describe("getAuthorizationServerUrl", () => {
new URL(authUrl),
);
});
it("throws a descriptive error when the MCP server URL is invalid", () => {
// "not a url" cannot be parsed by `new URL("/", serverUrl)`, exercising the
// catch block that wraps the underlying parse failure (discovery.ts 21-22).
expect(() => getAuthorizationServerUrl("not a url")).toThrow(
/Invalid MCP server URL: "not a url"/,
);
});
it("includes the underlying error detail in the thrown message", () => {
let captured: Error | undefined;
try {
getAuthorizationServerUrl("");
} catch (err) {
captured = err instanceof Error ? err : undefined;
}
expect(captured).toBeInstanceOf(Error);
// The wrapped message carries the parenthesised detail from the parse failure.
expect(captured?.message).toMatch(/Invalid MCP server URL: "" \(.+\)/);
});
});

View file

@ -1,9 +1,30 @@
import { describe, it, expect, afterEach } from "vitest";
import { connect } from "node:net";
import {
createOAuthCallbackServer,
type OAuthCallbackServer,
} from "@inspector/core/auth/node/oauth-callback-server.js";
/**
* Send a raw HTTP request line over a socket so we can use request targets
* (like `//`) that `fetch` would normalize/reject before they reach the
* server. Returns the raw response text.
*/
function rawRequest(port: number, requestLine: string): Promise<string> {
return new Promise((resolve, reject) => {
const socket = connect(port, "127.0.0.1", () => {
socket.write(`${requestLine}\r\nHost: localhost\r\n\r\n`);
});
let data = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
data += chunk;
});
socket.on("end", () => resolve(data));
socket.on("error", reject);
});
}
describe("OAuthCallbackServer", () => {
let server: OAuthCallbackServer;
@ -219,6 +240,90 @@ describe("OAuthCallbackServer", () => {
expect(first.status).toBe(200);
});
it("returns 400 when the request target is an unparseable URL", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0 });
// `GET //` makes req.url === "//", which `new URL("//", base)` rejects,
// exercising the try/catch 400 branch in handleRequest.
const raw = await rawRequest(result.port, "GET // HTTP/1.1");
expect(raw).toContain("400 Bad Request");
expect(raw).toContain("OAuth complete");
});
it("returns 400 JSON for an unparseable URL when Accept: application/json", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0 });
const raw = await rawRequest(
result.port,
"GET // HTTP/1.1\r\nAccept: application/json",
);
expect(raw).toContain("400 Bad Request");
expect(raw).toContain('{"error":"Bad Request"}');
});
it("start() rejects when the callback path is not absolute", async () => {
server = createOAuthCallbackServer();
await expect(
server.start({ port: 0, path: "no-leading-slash" }),
).rejects.toThrow(/Callback path must start with '\/'/);
});
it("POST returns 405 with a JSON body when Accept: application/json", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0 });
const res = await fetch(
`http://localhost:${result.port}/oauth/callback?code=x`,
{ method: "POST", headers: { Accept: "application/json" } },
);
expect(res.status).toBe(405);
expect(res.headers.get("content-type")).toContain("text/html");
expect(await res.json()).toEqual({ error: "Method Not Allowed" });
});
it("returns 404 with a JSON body when Accept: application/json", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0 });
const res = await fetch(`http://localhost:${result.port}/nope`, {
headers: { Accept: "application/json" },
});
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: "Not Found" });
});
it("returns 409 with a JSON body when Accept: application/json", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
server = createOAuthCallbackServer();
const result = await server.start({
port: 0,
onCallback: async () => {
await gate;
},
});
const firstP = fetch(
`http://localhost:${result.port}/oauth/callback?code=first`,
);
await new Promise((r) => setTimeout(r, 50));
const second = await fetch(
`http://localhost:${result.port}/oauth/callback?code=second`,
{ headers: { Accept: "application/json" } },
);
expect(second.status).toBe(409);
expect(await second.json()).toEqual({ error: "Callback already handled" });
release();
await firstP;
});
it("onCallback rejection returns 500 and error HTML", async () => {
server = createOAuthCallbackServer();
const result = await server.start({
@ -237,4 +342,51 @@ describe("OAuthCallbackServer", () => {
expect(html).toContain("OAuth failed");
expect(html).toContain("exchange failed");
});
it("onCallback rejection with a non-Error stringifies the value (500)", async () => {
server = createOAuthCallbackServer();
const result = await server.start({
port: 0,
onCallback: async () => {
throw "string-failure";
},
});
const res = await fetch(
`http://localhost:${result.port}/oauth/callback?code=abc`,
);
expect(res.status).toBe(500);
expect(await res.text()).toContain("string-failure");
});
it("GET with error but no error_description still returns 400 and calls onError", async () => {
server = createOAuthCallbackServer();
const errors: Array<{
error: string;
error_description?: string | null;
}> = [];
const result = await server.start({
port: 0,
onError: (p) => errors.push(p),
});
const res = await fetch(
`http://localhost:${result.port}/oauth/callback?error=invalid_request`,
);
expect(res.status).toBe(400);
expect(errors).toHaveLength(1);
expect(errors[0]!.error).toBe("invalid_request");
expect(errors[0]!.error_description).toBeUndefined();
});
it("builds a bracketed redirect URL for an IPv6 hostname", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0, hostname: "::1" });
expect(result.redirectUrl).toBe(
`http://[::1]:${result.port}/oauth/callback`,
);
});
});

View file

@ -311,6 +311,13 @@ describe("KeyringSecretStore (mocked native bindings)", () => {
).toBe("p");
});
it("KeychainUnavailableError stringifies a non-Error cause", () => {
const err = new KeychainUnavailableError("plain string cause");
expect(err).toBeInstanceOf(KeychainUnavailableError);
expect(err.message).toContain("plain string cause");
expect(err.message).toMatch(/libsecret/);
});
it("KeychainUnavailableError carries the underlying error message", async () => {
keyringMocks.failures.setThrows = true;
try {

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
NodeOAuthStorage,
getOAuthStore,
@ -590,6 +590,22 @@ describe("NodeOAuthStorage with custom storagePath", () => {
expect(all.getServerState("http://server-b.test").tokens).toBeUndefined();
});
it("clearAllOAuthClientState tolerates a store whose servers map is absent", () => {
// Exercises the `state.servers ?? {}` nullish fallback: when the persisted
// state lacks a `servers` map, the iteration must default to {} (no throw).
const defaultStore = getOAuthStore();
const clearServerState = vi.fn();
vi.spyOn(defaultStore, "getState").mockReturnValue({
servers: undefined,
clearServerState,
} as unknown as ReturnType<typeof defaultStore.getState>);
expect(() => clearAllOAuthClientState()).not.toThrow();
expect(clearServerState).not.toHaveBeenCalled();
vi.mocked(defaultStore.getState).mockRestore();
});
it("should isolate state from default store", async () => {
const customPath = path.join(
os.tmpdir(),

View file

@ -401,6 +401,268 @@ describe("OAuthStateMachine", () => {
).rejects.toThrow("Client information not available for token exchange");
});
it("wraps a non-Error thrown by resource metadata discovery into an Error", async () => {
const { discoverOAuthProtectedResourceMetadata } =
await import("@modelcontextprotocol/sdk/client/auth.js");
// Reject with a non-Error so the catch takes the `new Error(String(e))`
// branch (line 45).
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
"string-rejection",
);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
const call = vi
.mocked(updateState)
.mock.calls.find((c) => "resourceMetadataError" in c[0]);
expect(call?.[0].resourceMetadataError).toBeInstanceOf(Error);
expect(call?.[0].resourceMetadataError?.message).toBe("string-rejection");
});
it("throws when authorization server metadata discovery returns nothing", async () => {
const {
discoverOAuthProtectedResourceMetadata,
discoverAuthorizationServerMetadata,
} = await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
new Error("no resource metadata"),
);
// SDK returns undefined → execute throws "Failed to discover OAuth
// metadata" (line 69).
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(
undefined as unknown as OAuthMetadata,
);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await expect(stateMachine.executeStep(state)).rejects.toThrow(
"Failed to discover OAuth metadata",
);
});
it("client_registration falls back to metadata.scopes_supported when provider scope is empty", async () => {
const { registerClient } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(registerClient).mockResolvedValue({
redirect_uris: ["http://localhost/callback"],
client_id: "registered",
});
// provider.scope empty → falls into the scope-derivation block; the
// resourceMetadata has no scopes_supported so it uses metadata's.
const providerEmptyScope = {
...mockProvider,
scope: "",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
client_name: "Test Client",
scope: "",
},
clientInformation: vi.fn(async () => undefined),
} as unknown as BaseOAuthClientProvider;
const regState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "client_registration",
oauthMetadata: {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
scopes_supported: ["alpha", "beta"],
} as OAuthMetadata,
resourceMetadata: {
resource: "http://localhost:3000",
} as OAuthProtectedResourceMetadata,
};
await oauthTransitions.client_registration.execute({
state: regState,
serverUrl,
provider: providerEmptyScope,
updateState,
});
expect(providerEmptyScope.clientMetadata.scope).toBe("alpha beta");
expect(registerClient).toHaveBeenCalled();
});
it("authorization_redirect discovers scopes when provider scope is empty", async () => {
const { startAuthorization } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(startAuthorization).mockResolvedValue({
authorizationUrl: new URL("http://localhost:3000/authorize?x=1"),
codeVerifier: "verifier-xyz",
});
const providerEmptyScope = {
...mockProvider,
scope: "",
saveCodeVerifier: vi.fn(),
state: vi.fn(() => "st"),
redirectUrl: "http://localhost:3000/callback",
} as unknown as BaseOAuthClientProvider;
const redirectState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_redirect",
oauthMetadata: {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
} as OAuthMetadata,
oauthClientInfo: { client_id: "test-client" },
// resourceMetadata with scopes_supported so discoverScopes resolves.
resourceMetadata: {
resource: "http://localhost:3000",
scopes_supported: ["s1", "s2"],
} as OAuthProtectedResourceMetadata,
};
await oauthTransitions.authorization_redirect.execute({
state: redirectState,
serverUrl,
provider: providerEmptyScope,
updateState,
});
expect(startAuthorization).toHaveBeenCalledWith(
serverUrl,
expect.objectContaining({ scope: "s1 s2" }),
);
expect(providerEmptyScope.saveCodeVerifier).toHaveBeenCalledWith(
"verifier-xyz",
);
});
it("authorization_code execute sets a validation error and throws when code is blank", async () => {
const codeState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
authorizationCode: " ",
};
await expect(
oauthTransitions.authorization_code.execute({
state: codeState,
serverUrl,
provider: mockProvider,
updateState,
}),
).rejects.toThrow("Authorization code required");
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
validationError: "You need to provide an authorization code",
}),
);
});
it("authorization_code execute advances to token_request when a code is present", async () => {
const codeState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
authorizationCode: "good-code",
};
await oauthTransitions.authorization_code.execute({
state: codeState,
serverUrl,
provider: mockProvider,
updateState,
});
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
validationError: null,
oauthStep: "token_request",
}),
);
});
it("token_request execute throws when provider metadata is unavailable", async () => {
const providerNoMeta = {
...mockProvider,
codeVerifier: vi.fn(() => "cv"),
getServerMetadata: vi.fn(() => null),
} as unknown as BaseOAuthClientProvider;
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
authorizationCode: "code",
oauthClientInfo: { client_id: "c" },
};
await expect(
oauthTransitions.token_request.execute({
state: tokenState,
serverUrl,
provider: providerNoMeta,
updateState,
}),
).rejects.toThrow("OAuth metadata not available");
});
it("token_request execute coerces a string resource into a URL for exchange", async () => {
const { exchangeAuthorization } =
await import("@modelcontextprotocol/sdk/client/auth.js");
const metadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
vi.mocked(exchangeAuthorization).mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
});
const providerWithMeta = {
...mockProvider,
codeVerifier: vi.fn(() => "cv"),
getServerMetadata: vi.fn(() => metadata),
clientInformation: vi.fn(async () => ({ client_id: "c" })),
saveTokens: vi.fn(),
redirectUrl: "http://localhost:3000/callback",
} as unknown as BaseOAuthClientProvider;
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
authorizationCode: "code",
oauthClientInfo: { client_id: "c" },
// resource as a *string* exercises the `new URL(resource)` branch.
resource: "http://localhost:3000/resource" as unknown as URL,
};
await oauthTransitions.token_request.execute({
state: tokenState,
serverUrl,
provider: providerWithMeta,
updateState,
});
expect(exchangeAuthorization).toHaveBeenCalledWith(
serverUrl,
expect.objectContaining({
resource: new URL("http://localhost:3000/resource"),
}),
);
});
it("complete.canTransition always returns false (terminal state)", async () => {
const result = await oauthTransitions.complete.canTransition({
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },

View file

@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { resolveImportSource } from "@inspector/core/mcp/import/resolveSource.js";
import * as strategies from "@inspector/core/mcp/import/strategies.js";
import type { ImportStrategy } from "@inspector/core/mcp/import/types.js";
const HOME = "/home/alice";
const cursorPath = "/home/alice/.cursor/mcp.json";
@ -61,4 +63,42 @@ describe("resolveImportSource", () => {
expect(result?.found).toBe(true);
expect(result?.error).toMatch(/Failed to read .*EACCES/);
});
it("stringifies a non-Error thrown by the reader (String(err) branch)", () => {
const result = resolveImportSource("cursor", "linux", HOME, () => {
// Throwing a non-Error exercises the `String(err)` arm of the ternary.
throw "disk gone";
});
expect(result?.found).toBe(true);
expect(result?.path).toBe(cursorPath);
expect(result?.error).toBe(`Failed to read ${cursorPath}: disk gone`);
});
it("stringifies a non-Error thrown by the parser (String(err) branch)", () => {
// The built-in strategy parsers always throw Error, so the parse-error
// `String(err)` arm is only reachable via a strategy whose parse() throws a
// non-Error. Stub getImportStrategy to return one, then restore.
const fake: ImportStrategy = {
id: "fake",
label: "Fake",
defaultPaths: () => ["/some/path.json"],
parse: () => {
throw "kaboom"; // non-Error throw
},
};
const spy = vi.spyOn(strategies, "getImportStrategy").mockReturnValue(fake);
try {
const result = resolveImportSource(
"fake",
"linux",
HOME,
() => "{}", // exists + valid JSON, so parse() runs and throws
);
expect(result?.found).toBe(true);
expect(result?.path).toBe("/some/path.json");
expect(result?.error).toBe("kaboom");
} finally {
spy.mockRestore();
}
});
});

File diff suppressed because it is too large Load diff

View file

@ -46,43 +46,62 @@ interface ParsedSseEvent {
}
/**
* Read a streaming Response body and parse Server-Sent Events. Stops when
* the server closes the stream (read returns done).
* Read a streaming Response body and parse Server-Sent Events. Stops as soon
* as the terminal `stopAtType` event arrives (default `transport_error`),
* cancelling the reader — or when the server closes the stream (`done`).
*
* We stop on the terminal event rather than waiting only for `done` because
* the server closes the stream immediately *after* writing `transport_error`,
* and under heavy parallel scheduler pressure (a full `coverage` run with v8
* instrumentation across all workers) that close can lag far enough behind the
* event to trip the 30s test timeout — a hang, not a wrong result. Bailing on
* the terminal event keeps the test fast and deterministic regardless of load;
* `transport_error` is always the last event the queue emits, so nothing is
* lost. The `done` break still covers any stream that closes first.
*
* The SSE `data:` field carries the full SessionEvent JSON (both `type` and
* `data`), per the writer in /api/mcp/events — we unwrap and surface just
* the inner `.data` as the event payload, so callers can read e.g.
* `event.data.message` for stdio_log without an extra hop.
*/
async function readSseEvents(res: Response): Promise<ParsedSseEvent[]> {
async function readSseEvents(
res: Response,
stopAtType = "transport_error",
): Promise<ParsedSseEvent[]> {
if (!res.body) return [];
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const events: ParsedSseEvent[] = [];
while (true) {
const { value, done } = await reader.read();
if (value) buffer += decoder.decode(value, { stream: true });
// SSE events terminate with a blank line — split on it.
let split: number;
while ((split = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let type: string | undefined;
let dataLine: string | undefined;
for (const line of block.split("\n")) {
if (line.startsWith("event:"))
type = line.slice("event:".length).trim();
else if (line.startsWith("data:"))
dataLine = line.slice("data:".length).trim();
}
if (type && dataLine) {
const parsed = JSON.parse(dataLine) as { data?: unknown };
events.push({ type, data: parsed.data });
try {
while (true) {
const { value, done } = await reader.read();
if (value) buffer += decoder.decode(value, { stream: true });
// SSE events terminate with a blank line — split on it.
let split: number;
let sawTerminal = false;
while ((split = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let type: string | undefined;
let dataLine: string | undefined;
for (const line of block.split("\n")) {
if (line.startsWith("event:"))
type = line.slice("event:".length).trim();
else if (line.startsWith("data:"))
dataLine = line.slice("data:".length).trim();
}
if (type && dataLine) {
const parsed = JSON.parse(dataLine) as { data?: unknown };
events.push({ type, data: parsed.data });
if (type === stopAtType) sawTerminal = true;
}
}
if (sawTerminal || done) break;
}
if (done) break;
} finally {
await reader.cancel().catch(() => {});
}
return events;
}

View file

@ -6,8 +6,23 @@
* no network) so the integration runner is comfortable for it.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js";
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
function makeFetchEntry(
overrides: Partial<FetchRequestEntryBase> = {},
): FetchRequestEntryBase {
return {
id: "req-1",
timestamp: new Date("2026-01-01T00:00:00Z"),
method: "GET",
url: "http://example.com/",
requestHeaders: {},
...overrides,
};
}
describe("RemoteSession", () => {
it("queues events before a consumer attaches and drains them on attach", () => {
@ -75,4 +90,87 @@ describe("RemoteSession", () => {
session.markTransportDead("boom");
expect(session.clearEventConsumer()).toBe(true);
});
it("flushes a non-empty queue in order on consumer attach", () => {
const session = new RemoteSession("s6");
session.onStderr({ timestamp: new Date(), message: "first" });
session.onMessage({ jsonrpc: "2.0", id: 1, method: "ping" });
session.onFetchResponseBody("req-1", "body");
const received: string[] = [];
session.setEventConsumer((event) => received.push(event.type));
expect(received).toEqual([
"stdio_log",
"message",
"fetch_request_body_update",
]);
});
it("setEventConsumer with an empty queue flushes nothing", () => {
const session = new RemoteSession("s7");
const consumer = vi.fn();
session.setEventConsumer(consumer);
expect(consumer).not.toHaveBeenCalled();
});
it("setTransport stores the transport", () => {
const session = new RemoteSession("s8");
const transport = { foo: "bar" } as unknown as Transport;
session.setTransport(transport);
expect(session.transport).toBe(transport);
});
it("hasEventConsumer reflects whether a consumer is attached", () => {
const session = new RemoteSession("s9");
expect(session.hasEventConsumer()).toBe(false);
session.setEventConsumer(() => {});
expect(session.hasEventConsumer()).toBe(true);
});
it("delivers events live to an attached consumer (pushEvent direct path)", () => {
const session = new RemoteSession("s10");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) =>
received.push({ type: event.type, data: event.data }),
);
session.onMessage({ jsonrpc: "2.0", id: 1, result: {} });
session.onFetchResponseBody("req-1", "the-body");
expect(received.map((e) => e.type)).toEqual([
"message",
"fetch_request_body_update",
]);
expect(received[1]?.data).toEqual({
id: "req-1",
responseBody: "the-body",
});
});
it("onFetchRequest serializes a Date timestamp to an ISO string", () => {
const session = new RemoteSession("s11");
const received: { type: string; data: unknown }[] = [];
session.setEventConsumer((event) =>
received.push({ type: event.type, data: event.data }),
);
session.onFetchRequest(
makeFetchEntry({ timestamp: new Date("2026-01-01T00:00:00Z") }),
);
expect(received[0]?.type).toBe("fetch_request");
expect((received[0]?.data as { timestamp: string }).timestamp).toBe(
"2026-01-01T00:00:00.000Z",
);
});
it("onFetchRequest leaves a non-Date timestamp untouched", () => {
const session = new RemoteSession("s12");
const received: { data: unknown }[] = [];
session.setEventConsumer((event) => received.push({ data: event.data }));
// A pre-serialized (string) timestamp exercises the ternary's else branch.
session.onFetchRequest(
makeFetchEntry({
timestamp: "2026-01-01T00:00:00.000Z" as unknown as Date,
}),
);
expect((received[0]?.data as { timestamp: string }).timestamp).toBe(
"2026-01-01T00:00:00.000Z",
);
});
});

View file

@ -0,0 +1,611 @@
/**
* Focused unit tests for RemoteClientTransport that drive the error and
* SSE-parsing branches that the full e2e remote-server harness does not
* reliably reach (connect/events/send non-OK responses, missing sessionId,
* missing event-stream body, transport_error events, malformed SSE JSON,
* stream read errors, and the SSE final-buffer flush path).
*
* These use a hand-built mock `fetchFn` plus a controllable SSE
* ReadableStream rather than a real Hono backend so each branch can be
* exercised deterministically.
*/
import { describe, it, expect, vi } from "vitest";
import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js";
import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteClientTransport.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
const CONFIG: MCPServerConfig = {
type: "sse",
url: "http://upstream.test/sse",
};
/** Build a JSON Response. */
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
/**
* Build an SSE Response whose body streams the supplied chunks (raw strings).
* The returned `pushDone` flag is implicit: chunks are enqueued immediately
* and the stream then closes.
*/
function sseResponse(chunks: string[], init?: ResponseInit): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
return new Response(stream, { status: 200, ...init });
}
/** Encode a RemoteEvent as a single SSE `data:` frame. */
function sseFrame(event: RemoteEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
interface MockFetchPlan {
connect?: () => Response | Promise<Response>;
events?: () => Response | Promise<Response>;
send?: () => Response | Promise<Response>;
disconnect?: () => Response | Promise<Response>;
}
/**
* Returns a fetch implementation that dispatches by URL path to the plan's
* handlers, defaulting to sensible success responses when a handler is omitted.
*/
function mockFetch(plan: MockFetchPlan): typeof fetch {
const fn = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/mcp/connect")) {
return plan.connect
? plan.connect()
: jsonResponse({ sessionId: "sess-1" });
}
if (url.includes("/api/mcp/events")) {
return plan.events ? plan.events() : sseResponse([]);
}
if (url.includes("/api/mcp/send")) {
return plan.send ? plan.send() : jsonResponse({ ok: true });
}
if (url.includes("/api/mcp/disconnect")) {
return plan.disconnect ? plan.disconnect() : jsonResponse({ ok: true });
}
throw new Error(`unexpected fetch to ${url}`);
});
return fn as unknown as typeof fetch;
}
function makeTransport(
plan: MockFetchPlan,
extra: Partial<RemoteTransportOptions> = {},
): RemoteClientTransport {
return new RemoteClientTransport(
{
baseUrl: "http://remote.test/", // trailing slash exercises baseUrl trim
authToken: "tok",
fetchFn: mockFetch(plan),
...extra,
},
CONFIG,
);
}
/** Wait a tick so the detached consumeEventStream loop can advance. */
const tick = () => new Promise<void>((r) => setTimeout(r, 10));
describe("RemoteClientTransport (focused branch coverage)", () => {
describe("start()", () => {
it("throws Remote connect failed with status on non-OK connect", async () => {
const t = makeTransport({
connect: () =>
new Response("nope", { status: 401, statusText: "Unauthorized" }),
});
await expect(t.start()).rejects.toThrow(/Remote connect failed \(401\)/);
try {
await t.start();
} catch (e) {
expect((e as { status?: number }).status).toBe(401);
}
});
it("throws when the remote returns no sessionId", async () => {
const t = makeTransport({
connect: () => jsonResponse({ sessionId: "" }),
});
await expect(t.start()).rejects.toThrow(/did not return sessionId/);
});
it("throws Remote events stream failed on non-OK events response", async () => {
const t = makeTransport({
events: () => new Response("bad", { status: 500 }),
});
await expect(t.start()).rejects.toThrow(
/Remote events stream failed \(500\)/,
);
});
it("throws when the events stream has no body", async () => {
const t = makeTransport({
// A 204 No Content has a null body.
events: () => new Response(null, { status: 200 }),
});
await expect(t.start()).rejects.toThrow(/no body/);
});
it("forwards OAuth tokens from authProvider into the connect body", async () => {
let connectBody: unknown;
const fetchFn = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/connect")) {
connectBody = JSON.parse(init!.body as string);
return jsonResponse({ sessionId: "s" });
}
return sseResponse([]);
},
);
const authProvider = {
tokens: async () => ({
access_token: "AT",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "RT",
}),
} as unknown as NonNullable<RemoteTransportOptions["authProvider"]>;
const t = new RemoteClientTransport(
{
baseUrl: "http://remote.test",
fetchFn: fetchFn as unknown as typeof fetch,
authProvider,
},
CONFIG,
);
await t.start();
expect(connectBody).toMatchObject({
oauthTokens: { access_token: "AT", refresh_token: "RT" },
});
await t.close();
});
it("handles an authProvider that has no tokens", async () => {
const authProvider = {
tokens: async () => undefined,
} as unknown as NonNullable<RemoteTransportOptions["authProvider"]>;
const t = makeTransport({}, { authProvider });
await t.start();
await t.close();
});
it("start() is a no-op when already closed throws closed", async () => {
const t = makeTransport({});
await t.close();
await expect(t.start()).rejects.toThrow(/Transport is closed/);
});
});
describe("consumeEventStream() event handling", () => {
it("delivers message events to onmessage", async () => {
const msg: JSONRPCMessage = { jsonrpc: "2.0", id: 1, result: {} };
const t = makeTransport({
events: () => sseResponse([sseFrame({ type: "message", data: msg })]),
});
const received: JSONRPCMessage[] = [];
t.onmessage = (m) => received.push(m);
await t.start();
await tick();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ id: 1 });
});
it("delivers fetch_request events (string timestamp coerced to Date)", async () => {
const ts = new Date().toISOString();
const entry = {
id: "f1",
url: "http://x",
method: "GET",
timestamp: ts,
};
const onFetchRequest = vi.fn();
const t = makeTransport(
{
events: () =>
sseResponse([
sseFrame({
type: "fetch_request",
data: entry as never,
}),
]),
},
{ onFetchRequest },
);
await t.start();
await tick();
expect(onFetchRequest).toHaveBeenCalledTimes(1);
const arg = onFetchRequest.mock.calls[0]![0] as { timestamp: Date };
expect(arg.timestamp).toBeInstanceOf(Date);
});
it("delivers fetch_request events keeping a Date timestamp as-is", async () => {
const onFetchRequest = vi.fn();
// A numeric/Date timestamp triggers the non-string branch.
const entry = { id: "f2", url: "u", method: "POST", timestamp: 123 };
const t = makeTransport(
{
events: () =>
sseResponse([
sseFrame({ type: "fetch_request", data: entry as never }),
]),
},
{ onFetchRequest },
);
await t.start();
await tick();
const arg = onFetchRequest.mock.calls[0]![0] as { timestamp: unknown };
expect(arg.timestamp).toBe(123);
});
it("delivers fetch_request_body_update events", async () => {
const onFetchResponseBody = vi.fn();
const t = makeTransport(
{
events: () =>
sseResponse([
sseFrame({
type: "fetch_request_body_update",
data: { id: "f1", responseBody: "BODY" },
}),
]),
},
{ onFetchResponseBody },
);
await t.start();
await tick();
expect(onFetchResponseBody).toHaveBeenCalledWith("f1", "BODY");
});
it("delivers stdio_log events to onStderr", async () => {
const onStderr = vi.fn();
const t = makeTransport(
{
events: () =>
sseResponse([
sseFrame({
type: "stdio_log",
data: {
timestamp: new Date().toISOString(),
message: "stderr line",
},
}),
]),
},
{ onStderr },
);
await t.start();
await tick();
expect(onStderr).toHaveBeenCalledTimes(1);
expect(onStderr.mock.calls[0]![0].message).toBe("stderr line");
});
it("handles transport_error events: onerror + onclose, with code", async () => {
const onerror = vi.fn();
const onclose = vi.fn();
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({
type: "transport_error",
data: { error: "boom", code: 1006 },
}),
]),
});
t.onerror = onerror;
t.onclose = onclose;
await t.start();
await tick();
expect(onerror).toHaveBeenCalledTimes(1);
const err = onerror.mock.calls[0]![0] as {
message: string;
code?: number;
};
expect(err.message).toBe("boom");
expect(err.code).toBe(1006);
expect(onclose).toHaveBeenCalledTimes(1);
});
it("handles transport_error events without a code", async () => {
const onerror = vi.fn();
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({ type: "transport_error", data: { error: "no-code" } }),
]),
});
t.onerror = onerror;
await t.start();
await tick();
const err = onerror.mock.calls[0]![0] as { code?: number };
expect(err.code).toBeUndefined();
});
it("reports malformed SSE JSON via onerror but keeps consuming", async () => {
const onerror = vi.fn();
const msg: JSONRPCMessage = { jsonrpc: "2.0", id: 7, result: {} };
const t = makeTransport({
events: () =>
// First frame is invalid JSON (parse error), second is a valid message.
sseResponse([
`data: {not json}\n\n`,
sseFrame({ type: "message", data: msg }),
]),
});
const received: JSONRPCMessage[] = [];
t.onerror = onerror;
t.onmessage = (m) => received.push(m);
await t.start();
await tick();
expect(onerror).toHaveBeenCalled();
// Consumption continued: the valid message after the bad frame arrived.
expect(received).toHaveLength(1);
});
it("flushes a trailing SSE frame with no terminating blank line", async () => {
const msg: JSONRPCMessage = { jsonrpc: "2.0", id: 9, result: {} };
// No trailing \n\n — exercises the post-loop buffer-flush path in parseSSE.
const frame = `event: message\ndata: ${JSON.stringify({
type: "message",
data: msg,
})}`;
const t = makeTransport({
events: () => sseResponse([frame]),
});
const received: JSONRPCMessage[] = [];
t.onmessage = (m) => received.push(m);
await t.start();
await tick();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ id: 9 });
});
it("handles a trailing buffer whose final unterminated line is an event:", async () => {
// The stream ends mid-frame on an `event:` line (no terminating blank
// line and no data), exercising the event-line branch of parseSSE's
// post-loop buffer flush. Nothing is yielded (no data), but the branch
// runs without error.
const onmessage = vi.fn();
const t = makeTransport({
events: () => sseResponse([`data: ignored\n\nevent: trailing`]),
});
t.onmessage = onmessage;
await t.start();
await tick();
// event-only trailing frame yields nothing
expect(onmessage).not.toHaveBeenCalled();
});
it("stops delivering once the transport is closed mid-stream", async () => {
// Two frames arrive; closing the transport from the first onmessage
// handler makes the `if (this.closed) break` guard fire before the
// second frame is delivered.
const m1: JSONRPCMessage = { jsonrpc: "2.0", id: 1, result: {} };
const m2: JSONRPCMessage = { jsonrpc: "2.0", id: 2, result: {} };
const received: JSONRPCMessage[] = [];
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({ type: "message", data: m1 }),
sseFrame({ type: "message", data: m2 }),
]),
});
t.onmessage = (m) => {
received.push(m);
// Close after the first message; the loop's closed-check breaks before
// the second frame is processed.
void t.close();
};
await t.start();
await tick();
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ id: 1 });
});
it("coerces a non-Error thrown during event processing into an Error", async () => {
// onmessage throws a string (not an Error); the per-frame catch coerces
// it via `err instanceof Error ? err : new Error(String(err))`.
const onerror = vi.fn();
const m: JSONRPCMessage = { jsonrpc: "2.0", id: 3, result: {} };
const t = makeTransport({
events: () => sseResponse([sseFrame({ type: "message", data: m })]),
});
t.onmessage = () => {
throw "string failure";
};
t.onerror = onerror;
await t.start();
await tick();
expect(onerror).toHaveBeenCalled();
const err = onerror.mock.calls[0]![0] as Error;
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("string failure");
});
it("reports a stream read error via onerror (non-abort)", async () => {
const onerror = vi.fn();
const onclose = vi.fn();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error("stream blew up"));
},
});
const t = makeTransport({
events: () => new Response(stream, { status: 200 }),
});
t.onerror = onerror;
t.onclose = onclose;
await t.start();
await tick();
expect(onerror).toHaveBeenCalled();
expect(onerror.mock.calls[0]![0].message).toMatch(/stream blew up/);
// finally{} closes the transport.
expect(onclose).toHaveBeenCalledTimes(1);
});
it("closes via finally when the stream ends without error", async () => {
const onclose = vi.fn();
const t = makeTransport({ events: () => sseResponse([]) });
t.onclose = onclose;
await t.start();
await tick();
expect(onclose).toHaveBeenCalledTimes(1);
});
});
describe("send()", () => {
it("throws when not started", async () => {
const t = makeTransport({});
await expect(
t.send({ jsonrpc: "2.0", id: 1, method: "ping" }),
).rejects.toThrow(/not started/);
});
it("throws closed when send() runs after a transport_error closed it", async () => {
// close() clears _sessionId so send() would hit the "not started" guard
// first. The transport_error event path sets closed=true while LEAVING
// _sessionId populated, which is the only way to reach send()'s
// "Transport is closed" guard.
const t = makeTransport({
events: () =>
sseResponse([
sseFrame({ type: "transport_error", data: { error: "died" } }),
]),
});
await t.start();
await tick();
await expect(
t.send({ jsonrpc: "2.0", id: 1, method: "ping" }),
).rejects.toThrow(/Transport is closed/);
});
it("posts a message including relatedRequestId and succeeds", async () => {
let sentBody: { relatedRequestId?: unknown } | undefined;
const fetchFn = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/connect")) return jsonResponse({ sessionId: "s" });
if (url.includes("/events"))
return new Response(
new ReadableStream<Uint8Array>({ start() {} }),
{ status: 200 },
);
if (url.includes("/send")) {
sentBody = JSON.parse(init!.body as string);
return jsonResponse({ ok: true });
}
return jsonResponse({ ok: true });
},
);
const t = new RemoteClientTransport(
{
baseUrl: "http://remote.test",
fetchFn: fetchFn as unknown as typeof fetch,
},
CONFIG,
);
await t.start();
await t.send(
{ jsonrpc: "2.0", id: 5, method: "ping" },
{ relatedRequestId: 42 },
);
expect(sentBody?.relatedRequestId).toBe(42);
await t.close();
});
it("throws Remote send failed with status on non-OK send", async () => {
const t = makeTransport({
events: () =>
new Response(new ReadableStream<Uint8Array>({ start() {} }), {
status: 200,
}),
send: () => new Response("err", { status: 503 }),
});
await t.start();
try {
await t.send({ jsonrpc: "2.0", id: 1, method: "ping" });
throw new Error("expected send to throw");
} catch (e) {
expect((e as Error).message).toMatch(/Remote send failed \(503\)/);
expect((e as { status?: number }).status).toBe(503);
}
await t.close();
});
});
describe("close()", () => {
it("is idempotent and swallows disconnect errors", async () => {
const onclose = vi.fn();
const t = makeTransport({
events: () =>
new Response(new ReadableStream<Uint8Array>({ start() {} }), {
status: 200,
}),
disconnect: () => {
throw new Error("disconnect failed");
},
});
t.onclose = onclose;
await t.start();
await t.close(); // disconnect throws but is swallowed; onclose fires
await t.close(); // second close is a no-op (early return)
expect(onclose).toHaveBeenCalledTimes(1);
});
it("close() before start() returns early without calling disconnect", async () => {
const disconnect = vi.fn(() => jsonResponse({ ok: true }));
const t = makeTransport({ disconnect });
await t.close();
// No sessionId yet → disconnect branch is skipped, but onclose still fires.
expect(disconnect).not.toHaveBeenCalled();
});
});
describe("sessionId getter", () => {
it("always returns undefined (intentional)", () => {
const t = makeTransport({});
expect(t.sessionId).toBeUndefined();
});
});
describe("headers/fetchFn defaults", () => {
it("omits auth header when no authToken is configured", async () => {
const seen: Array<Record<string, string>> = [];
const fetchFn = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
seen.push((init?.headers as Record<string, string>) ?? {});
if (url.includes("/connect")) return jsonResponse({ sessionId: "s" });
return sseResponse([]);
},
);
const t = new RemoteClientTransport(
{
baseUrl: "http://remote.test",
fetchFn: fetchFn as unknown as typeof fetch,
},
CONFIG,
);
await t.start();
await t.close();
// connect headers should not include the auth header
expect(seen[0]!["x-mcp-remote-auth"]).toBeUndefined();
});
});
});

View file

@ -0,0 +1,791 @@
/**
* Supplemental coverage for createRemoteApp (core/mcp/remote/node/server.ts).
*
* Targets the remaining uncovered branches the broader suites don't exercise:
* - forwardLogEvent message/binding shape variants (/api/log)
* - validateSettings per-field rejection branches
* - /api/fetch headers / streaming content-type / network-error paths
* - normalizeMcpServers malformed-field drops, on both the logger and the
* console.warn fallback path
* - generic (non-keychain) 500 catch on POST / PUT / DELETE / order
* - keychain-unavailable migration with a logger (fileLogger.warn branch)
* - GET fast-path re-check when a concurrent write removed the plaintext
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import type pinoType from "pino";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import {
InMemorySecretStore,
KeychainUnavailableError,
type SecretStore,
} from "@inspector/core/auth/node/secret-store.js";
interface Harness {
baseUrl: string;
server: ServerType;
configPath: string;
tempDir: string;
}
interface StartOpts {
secretStore?: SecretStore;
logger?: pinoType.Logger;
seedConfig?: string;
}
async function start(opts: StartOpts = {}): Promise<Harness> {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-server-extra-"));
const configPath = join(tempDir, "mcp.json");
if (opts.seedConfig !== undefined) {
writeFileSync(configPath, opts.seedConfig);
}
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
secretStore: opts.secretStore ?? new InMemorySecretStore(),
logger: opts.logger,
});
const { baseUrl, server } = await new Promise<{
baseUrl: string;
server: ServerType;
}>((resolve, reject) => {
const s = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info) => {
const port =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: 0;
resolve({ baseUrl: `http://127.0.0.1:${port}`, server: s });
},
);
s.on("error", reject);
});
return { baseUrl, server, configPath, tempDir };
}
async function stop(h: Harness): Promise<void> {
await new Promise<void>((r) => h.server.close(() => r()));
try {
rmSync(h.tempDir, { recursive: true });
} catch {
/* ignore */
}
}
/**
* Minimal in-memory pino-shaped logger that captures forwarded records so a
* test can assert which level/object/message the server emitted.
*/
function makeCapturingLogger(): {
logger: pinoType.Logger;
records: Array<{ level: string; args: unknown[] }>;
} {
const records: Array<{ level: string; args: unknown[] }> = [];
const mk =
(level: string) =>
(...args: unknown[]) => {
records.push({ level, args });
};
// Only the methods the server reaches are needed; cast through unknown to
// satisfy the pino.Logger surface without pulling in the full type.
const logger = {
info: mk("info"),
warn: mk("warn"),
error: mk("error"),
debug: mk("debug"),
trace: mk("trace"),
fatal: mk("fatal"),
} as unknown as pinoType.Logger;
return { logger, records };
}
describe("server.ts supplemental coverage", () => {
describe("/api/log forwardLogEvent shapes", () => {
let h: Harness;
let records: Array<{ level: string; args: unknown[] }>;
beforeEach(async () => {
const cap = makeCapturingLogger();
records = cap.records;
h = await start({ logger: cap.logger });
});
afterEach(async () => {
await stop(h);
});
async function postLog(body: unknown): Promise<Response> {
return fetch(`${h.baseUrl}/api/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
it("forwards an event whose first message is an object (obj + msg + args)", async () => {
const res = await postLog({
level: { label: "warn", value: 40 },
bindings: [{ component: "x" }, { category: "y" }],
messages: [{ extra: 1 }, "hello", "a", "b"],
});
expect(res.status).toBe(200);
const rec = records.find((r) => r.level === "warn");
expect(rec).toBeDefined();
expect(rec!.args[0]).toMatchObject({
component: "x",
category: "y",
extra: 1,
});
expect(rec!.args[1]).toBe("hello");
expect(rec!.args.slice(2)).toEqual(["a", "b"]);
});
it("forwards an event with no messages (bindings-only object)", async () => {
const res = await postLog({
level: { label: "info", value: 30 },
bindings: [{ a: 1 }],
messages: [],
});
expect(res.status).toBe(200);
const rec = records.find((r) => r.level === "info");
expect(rec).toBeDefined();
expect(rec!.args).toEqual([{ a: 1 }]);
});
it("forwards a string-first message (bindings + msg + args)", async () => {
const res = await postLog({
level: { label: "info", value: 30 },
bindings: [{ b: 2 }],
messages: ["just a string", 1, 2],
});
expect(res.status).toBe(200);
const rec = records.find(
(r) => r.level === "info" && r.args[1] === "just a string",
);
expect(rec).toBeDefined();
expect(rec!.args[0]).toMatchObject({ b: 2 });
expect(rec!.args.slice(2)).toEqual([1, 2]);
});
it("defaults the level to info and tolerates non-array bindings/messages", async () => {
const res = await postLog({ bindings: "nope", messages: "nope" });
expect(res.status).toBe(200);
const rec = records.find((r) => r.level === "info");
expect(rec).toBeDefined();
// bindings non-array → {} ; messages non-array → [] → bindings-only call
expect(rec!.args).toEqual([{}]);
});
it("drops an event whose level has no matching logger method", async () => {
const res = await postLog({
level: { label: "nonsense", value: 1 },
messages: ["x"],
});
expect(res.status).toBe(200);
// No record captured for an unknown level.
expect(records.length).toBe(0);
});
});
describe("/api/log without a logger is a no-op", () => {
it("returns 200 and swallows the event", async () => {
const h = await start();
try {
const res = await fetch(`${h.baseUrl}/api/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: ["x"] }),
});
expect(res.status).toBe(200);
// Invalid JSON also tolerated (catch → {}).
const res2 = await fetch(`${h.baseUrl}/api/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res2.status).toBe(200);
} finally {
await stop(h);
}
});
});
describe("/api/fetch", () => {
let h: Harness;
let target: ServerType;
let targetUrl: string;
beforeEach(async () => {
h = await start();
// A tiny upstream HTTP server we can point /api/fetch at.
const { createServer } = await import("node:http");
const srv = createServer((req, res) => {
if (req.url === "/stream") {
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write("data: hi\n\n");
res.end();
return;
}
if (req.url === "/echo-headers") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ got: req.headers["x-probe"] ?? null }));
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("plain body");
});
await new Promise<void>((resolve) =>
srv.listen(0, "127.0.0.1", () => resolve()),
);
target = srv as unknown as ServerType;
const addr = srv.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
targetUrl = `http://127.0.0.1:${port}`;
});
afterEach(async () => {
await new Promise<void>((r) =>
(target as unknown as { close: (cb: () => void) => void }).close(() =>
r(),
),
);
await stop(h);
});
it("forwards method + headers and returns the response body", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: `${targetUrl}/echo-headers`,
method: "GET",
headers: { "x-probe": "abc" },
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
ok: boolean;
status: number;
headers: Record<string, string>;
body?: string;
};
expect(body.ok).toBe(true);
expect(body.status).toBe(200);
expect(body.headers["content-type"]).toContain("application/json");
expect(JSON.parse(body.body!)).toEqual({ got: "abc" });
});
it("omits the body for an event-stream content type", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stream` }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { body?: string; status: number };
expect(body.status).toBe(200);
expect(body.body).toBeUndefined();
});
it("returns 400 when url is missing", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "GET" }),
});
expect(res.status).toBe(400);
});
it("returns 400 on invalid JSON", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{bad",
});
expect(res.status).toBe(400);
});
it("returns 500 when the upstream fetch throws", async () => {
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "http://127.0.0.1:1/nope" }),
});
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(typeof body.error).toBe("string");
});
});
describe("validateSettings rejection branches (POST /api/servers)", () => {
let h: Harness;
beforeEach(async () => {
h = await start();
});
afterEach(async () => {
await stop(h);
});
async function postSettings(settings: unknown): Promise<Response> {
return fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "srv",
config: { type: "stdio", command: "node" },
settings,
}),
});
}
const base = {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
};
it("rejects a non-object settings value", async () => {
const res = await postSettings([1, 2, 3]);
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/must be an object/);
});
it("rejects malformed headers", async () => {
const res = await postSettings({ ...base, headers: "oops" });
expect((await res.json()).error).toMatch(/headers/);
});
it("rejects malformed metadata", async () => {
const res = await postSettings({
...base,
metadata: [{ key: 1, value: "x" }],
});
expect((await res.json()).error).toMatch(/metadata/);
});
it("rejects malformed env", async () => {
const res = await postSettings({ ...base, env: "oops" });
expect((await res.json()).error).toMatch(/env/);
});
it("rejects a non-string cwd", async () => {
const res = await postSettings({ ...base, cwd: 42 });
expect((await res.json()).error).toMatch(/cwd/);
});
it("rejects a negative connectionTimeout", async () => {
const res = await postSettings({ ...base, connectionTimeout: -1 });
expect((await res.json()).error).toMatch(/connectionTimeout/);
});
it("rejects a non-numeric requestTimeout", async () => {
const res = await postSettings({ ...base, requestTimeout: "x" });
expect((await res.json()).error).toMatch(/requestTimeout/);
});
it("rejects a negative taskTtl", async () => {
const res = await postSettings({ ...base, taskTtl: -5 });
expect((await res.json()).error).toMatch(/taskTtl/);
});
it("rejects a non-boolean autoRefreshOnListChanged", async () => {
const res = await postSettings({
...base,
autoRefreshOnListChanged: "yes",
});
expect((await res.json()).error).toMatch(/autoRefreshOnListChanged/);
});
it("rejects a negative maxFetchRequests", async () => {
const res = await postSettings({ ...base, maxFetchRequests: -2 });
expect((await res.json()).error).toMatch(/maxFetchRequests/);
});
it("rejects a non-string OAuth field", async () => {
const res = await postSettings({ ...base, oauthClientId: 7 });
expect((await res.json()).error).toMatch(/oauthClientId/);
});
it("rejects a non-boolean enterpriseManaged", async () => {
const res = await postSettings({ ...base, enterpriseManaged: "yes" });
expect((await res.json()).error).toMatch(/enterpriseManaged/);
});
it("rejects malformed roots", async () => {
const res = await postSettings({ ...base, roots: [{ uri: 1 }] });
expect((await res.json()).error).toMatch(/roots/);
});
it("accepts a fully-populated valid settings payload", async () => {
const res = await postSettings({
...base,
env: [{ key: "K", value: "V" }],
cwd: "/tmp",
taskTtl: 1000,
autoRefreshOnListChanged: true,
maxFetchRequests: 5,
oauthClientId: "cid",
oauthScopes: "a b",
enterpriseManaged: true,
roots: [{ uri: "file:///x", name: "x" }],
});
expect(res.status).toBe(200);
});
});
describe("normalizeMcpServers malformed-field drops (console fallback, no logger)", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("drops a legacy settings node and every malformed field, logging to console.warn", async () => {
const h = await start({
seedConfig: JSON.stringify({
mcpServers: {
// non-object entry → skipped
bad: 5,
srv: {
type: "http",
url: "https://x.test/mcp",
settings: { legacy: true },
// object with a non-string value → isStringRecord inner branch
headers: { Authorization: 123 },
metadata: [{ key: 1 }],
connectionTimeout: Infinity,
requestTimeout: -1,
taskTtl: "x",
maxFetchRequests: -1,
// enterpriseManaged non-boolean → isOauthObject inner branch
oauth: { enterpriseManaged: "yes" },
// a non-string `name` → isRootArray inner branch
roots: [{ uri: "file:///ok", name: 5 }],
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
const srv = body.mcpServers.srv;
expect(srv).toBeDefined();
// type:"http" normalized to streamable-http
expect(srv.type).toBe("streamable-http");
// Every malformed field dropped.
for (const k of [
"settings",
"headers",
"metadata",
"connectionTimeout",
"requestTimeout",
"taskTtl",
"maxFetchRequests",
"oauth",
"roots",
]) {
expect(srv).not.toHaveProperty(k);
}
expect(body.mcpServers.bad).toBeUndefined();
expect(warnSpy).toHaveBeenCalled();
} finally {
await stop(h);
}
});
});
describe("normalizeMcpServers additional validator inner branches", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("drops oauth with a non-string clientId and roots with a null entry", async () => {
const h = await start({
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
// clientId non-string → isOauthObject clientId branch
oauth: { clientId: 5 },
// a null entry → isRootArray `e === null` branch
roots: [null],
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
expect(body.mcpServers.srv).not.toHaveProperty("oauth");
expect(body.mcpServers.srv).not.toHaveProperty("roots");
} finally {
await stop(h);
}
});
});
describe("plaintext-secret migration over a mixed config", () => {
it("migrates servers with secrets and passes through servers without", async () => {
// With an available keychain, the GET slow path migrates plaintext into
// the store. A mixed config (one server with a secret, one without)
// exercises both the migrate branch and the no-secret continue branch.
const store = new InMemorySecretStore();
const h = await start({
secretStore: store,
seedConfig: JSON.stringify({
mcpServers: {
plain: { type: "stdio", command: "node" },
withSecret: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "shh" },
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
mcpServers: Record<string, Record<string, unknown>>;
};
// Both entries present; the secret value is rehydrated from keychain.
expect(body.mcpServers.plain).toBeDefined();
expect(body.mcpServers.withSecret).toBeDefined();
// Disk no longer holds the plaintext clientSecret (migrated out).
const onDisk = readFileSync(h.configPath, "utf-8");
expect(onDisk).not.toContain("shh");
} finally {
await stop(h);
}
});
});
describe("normalizeMcpServers malformed-field drops (logger path)", () => {
it("routes the drop warnings through fileLogger.warn", async () => {
const cap = makeCapturingLogger();
const h = await start({
logger: cap.logger,
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "stdio",
command: "node",
headers: 123,
},
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
const warned = cap.records.filter((r) => r.level === "warn");
expect(warned.length).toBeGreaterThan(0);
} finally {
await stop(h);
}
});
});
describe("generic 500 catch on mutating routes", () => {
// A secret store whose deleteAllForServer throws a plain (non-keychain)
// error drives the generic 500 catch on POST and DELETE; the writeKeychain
// path covers PUT.
class ThrowingSecretStore implements SecretStore {
async get(): Promise<string | null> {
return null;
}
async set(): Promise<void> {
throw new Error("boom-set");
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
throw new Error("boom-sweep");
}
}
it("POST returns 500 when the keychain sweep throws a generic error", async () => {
const h = await start({ secretStore: new ThrowingSecretStore() });
try {
const res = await fetch(`${h.baseUrl}/api/servers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "srv",
config: { type: "stdio", command: "node" },
}),
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to add server/);
} finally {
await stop(h);
}
});
it("DELETE returns 500 when the keychain sweep throws a generic error", async () => {
const h = await start({
secretStore: new ThrowingSecretStore(),
seedConfig: JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "DELETE",
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to delete server/);
} finally {
await stop(h);
}
});
it("PUT returns 500 when the keychain write throws a generic error", async () => {
const h = await start({
secretStore: new ThrowingSecretStore(),
seedConfig: JSON.stringify({
mcpServers: {
srv: { type: "streamable-http", url: "https://x.test/mcp" },
},
}),
});
try {
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: { type: "streamable-http", url: "https://x.test/mcp" },
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
oauthClientSecret: "shh",
},
}),
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to update server/);
} finally {
await stop(h);
}
});
it("order returns 500 when the disk write throws", async () => {
// Point the config at a path whose parent is a file, so the atomic
// write fails with ENOTDIR — a generic error surfaced as 500.
const tempDir = mkdtempSync(join(tmpdir(), "inspector-order-500-"));
const filePath = join(tempDir, "afile");
writeFileSync(filePath, "x");
const badConfigPath = join(filePath, "mcp.json");
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: badConfigPath,
initialConfig: { defaultEnvironment: {} },
secretStore: new InMemorySecretStore(),
});
const { baseUrl, server } = await new Promise<{
baseUrl: string;
server: ServerType;
}>((resolve, reject) => {
const s = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info) => {
const port =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: 0;
resolve({ baseUrl: `http://127.0.0.1:${port}`, server: s });
},
);
s.on("error", reject);
});
try {
// readMcpConfig sees ENOENT (parent is a file) → empty list, so an
// empty order set-matches and we proceed to the failing write.
const res = await fetch(`${baseUrl}/api/servers/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order: [] }),
});
expect(res.status).toBe(500);
expect((await res.json()).error).toMatch(/Failed to reorder servers/);
} finally {
await new Promise<void>((r) => server.close(() => r()));
rmSync(tempDir, { recursive: true });
}
});
});
describe("keychain-unavailable migration with a logger", () => {
class UnavailableStore implements SecretStore {
async get(): Promise<string | null> {
return null;
}
async set(): Promise<void> {
throw new KeychainUnavailableError(new Error("libsecret missing"));
}
async delete(): Promise<void> {
/* no-op */
}
async deleteAllForServer(): Promise<void> {
/* no-op */
}
}
it("warns via the logger and preserves on-disk plaintext", async () => {
const cap = makeCapturingLogger();
const h = await start({
secretStore: new UnavailableStore(),
logger: cap.logger,
seedConfig: JSON.stringify({
mcpServers: {
srv: {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", clientSecret: "plaintext" },
},
},
}),
});
try {
const before = readFileSync(h.configPath, "utf-8");
const res = await fetch(`${h.baseUrl}/api/servers`);
expect(res.status).toBe(200);
// Disk plaintext preserved (migration abandoned).
expect(readFileSync(h.configPath, "utf-8")).toBe(before);
const warned = cap.records.filter(
(r) =>
r.level === "warn" &&
JSON.stringify(r.args).includes("Keychain unavailable"),
);
expect(warned.length).toBeGreaterThan(0);
} finally {
await stop(h);
}
});
});
});

View file

@ -159,6 +159,34 @@ describe("createSandboxController", () => {
}
});
it("resolves with empty values + logs generically when listen fails with a non-EADDRINUSE error", async () => {
// An unresolvable bind host makes Node emit an `error` whose `code` is
// not EADDRINUSE (ENOTFOUND/EADDRNOTAVAIL depending on platform). This
// drives the non-EADDRINUSE branch of the error handler: a generic
// "Sandbox server error" log plus the same resolve-with-empty contract.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const controller = createSandboxController({
port: 0,
host: "256.256.256.256",
});
try {
const result = await controller.start();
expect(result).toEqual({ port: 0, url: "" });
expect(controller.getUrl()).toBeNull();
await expect(controller.close()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
"Sandbox server error:",
expect.objectContaining({ code: expect.any(String) }),
);
// It must NOT have taken the EADDRINUSE branch.
expect(errorSpy).not.toHaveBeenCalledWith(
expect.stringContaining("in use"),
);
} finally {
errorSpy.mockRestore();
}
});
it("resolves with empty values when listen fails (EADDRINUSE)", async () => {
// Bind a placeholder HTTP server to claim a port, then point a sandbox
// controller at the same port to force EADDRINUSE. The Vite plugin awaits
@ -191,4 +219,38 @@ describe("createSandboxController", () => {
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
it("serves a fallback page when the sandbox HTML file can't be read", async () => {
// The static `sandbox_proxy.html` always ships, so the only way to reach
// the read-failure fallback is to make `readFileSync` throw. Mock
// `node:fs` for an isolated module instance so the rest of the suite keeps
// the real fs.
vi.resetModules();
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
readFileSync: () => {
throw new Error("disk gone");
},
};
});
try {
const mod = await import("../../../../server/sandbox-controller.js");
const controller = mod.createSandboxController({ port: 0 });
try {
const { port } = await controller.start();
const res = await fetch(`http://localhost:${port}/sandbox`);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("Sandbox not loaded");
expect(body).toContain("disk gone");
} finally {
await controller.close();
}
} finally {
vi.doUnmock("node:fs");
vi.resetModules();
}
});
});

View file

@ -0,0 +1,285 @@
/**
* Tests for shared storage path resolution and atomic file I/O (store-io.ts).
* Node-only fs code -> lives in the integration project (node env).
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import {
mkdtempSync,
rmSync,
existsSync,
readFileSync,
writeFileSync,
chmodSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
getDefaultStorageDir,
getDefaultMcpConfigPath,
getStoreFilePath,
readStoreFile,
writeStoreFile,
flushStoreFileWrites,
deleteStoreFile,
serializeStore,
parseStore,
} from "@inspector/core/storage/store-io.js";
describe("store-io", () => {
describe("getDefaultStorageDir / getDefaultMcpConfigPath", () => {
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
afterEach(() => {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
});
it("uses HOME when set", () => {
process.env.HOME = "/home/alice";
delete process.env.USERPROFILE;
expect(getDefaultStorageDir()).toBe(
join("/home/alice", ".mcp-inspector", "storage"),
);
expect(getDefaultMcpConfigPath()).toBe(
join("/home/alice", ".mcp-inspector", "mcp.json"),
);
});
it("falls back to USERPROFILE when HOME is unset (Windows)", () => {
delete process.env.HOME;
process.env.USERPROFILE = "C:\\Users\\bob";
expect(getDefaultStorageDir()).toBe(
join("C:\\Users\\bob", ".mcp-inspector", "storage"),
);
expect(getDefaultMcpConfigPath()).toBe(
join("C:\\Users\\bob", ".mcp-inspector", "mcp.json"),
);
});
it('falls back to "." when neither HOME nor USERPROFILE is set', () => {
delete process.env.HOME;
delete process.env.USERPROFILE;
expect(getDefaultStorageDir()).toBe(
join(".", ".mcp-inspector", "storage"),
);
expect(getDefaultMcpConfigPath()).toBe(
join(".", ".mcp-inspector", "mcp.json"),
);
});
});
describe("getStoreFilePath", () => {
it("joins the storage dir with the store id and .json", () => {
expect(getStoreFilePath("/dir", "myStore")).toBe(
join("/dir", "myStore.json"),
);
});
});
describe("readStoreFile", () => {
let tempDir: string | null = null;
afterEach(() => {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true });
} catch {
// Ignore cleanup errors
}
tempDir = null;
}
});
it("returns the file contents when the file exists", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-readstore-"));
const filePath = join(tempDir, "store.json");
writeFileSync(filePath, '{"a":1}', "utf-8");
expect(await readStoreFile(filePath)).toBe('{"a":1}');
});
it("returns null when the file does not exist (ENOENT)", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-readstore-"));
const filePath = join(tempDir, "missing.json");
expect(await readStoreFile(filePath)).toBeNull();
});
it("throws on a non-ENOENT read error (e.g. reading a directory)", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-readstore-"));
// Reading a directory as a file yields EISDIR, not ENOENT.
await expect(readStoreFile(tempDir)).rejects.toThrow();
});
});
describe("writeStoreFile / flushStoreFileWrites", () => {
let tempDir: string | null = null;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-writestore-"));
});
afterEach(() => {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true });
} catch {
// Ignore cleanup errors
}
tempDir = null;
}
});
it("writes data atomically and creates parent directories", async () => {
const filePath = join(tempDir!, "nested", "deep", "store.json");
await writeStoreFile(filePath, '{"hi":1}');
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf-8")).toBe('{"hi":1}');
});
it("chains concurrent writes to the same path (last write wins)", async () => {
const filePath = join(tempDir!, "store.json");
const a = writeStoreFile(filePath, '{"v":1}');
const b = writeStoreFile(filePath, '{"v":2}');
await Promise.all([a, b]);
await flushStoreFileWrites(filePath);
expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({ v: 2 });
});
it("flushStoreFileWrites(path) awaits a specific in-flight write", async () => {
const filePath = join(tempDir!, "store.json");
const write = writeStoreFile(filePath, '{"flush":true}');
await flushStoreFileWrites(filePath);
expect(existsSync(filePath)).toBe(true);
await write;
});
it("flushStoreFileWrites() awaits all in-flight writes", async () => {
const a = join(tempDir!, "a.json");
const b = join(tempDir!, "b.json");
const writes = Promise.all([
writeStoreFile(a, '{"n":1}'),
writeStoreFile(b, '{"n":2}'),
]);
await flushStoreFileWrites();
expect(existsSync(a)).toBe(true);
expect(existsSync(b)).toBe(true);
await writes;
});
it("resolves immediately when nothing is in flight", async () => {
await expect(flushStoreFileWrites()).resolves.toBeUndefined();
await expect(
flushStoreFileWrites(join(tempDir!, "none.json")),
).resolves.toBeUndefined();
});
it("swallows a prior write's rejection so a chained write still runs (.catch branch)", async () => {
// First write targets a path under a *file* (not a dir), so mkdir fails
// and the write rejects. The second write to the same resolved key is
// chained via `(prior ?? Promise.resolve()).catch(() => {})`, so the
// rejection is swallowed and the second write proceeds.
const blocker = join(tempDir!, "blocker");
writeFileSync(blocker, "x", "utf-8");
const badPath = join(blocker, "store.json"); // parent is a file -> ENOTDIR
const first = writeStoreFile(badPath, '{"v":1}');
await expect(first).rejects.toThrow();
// A second write to the same key after the first settled still works once
// the blocker is removed.
rmSync(blocker);
const second = writeStoreFile(badPath, '{"v":2}');
await expect(second).resolves.toBeUndefined();
expect(JSON.parse(readFileSync(badPath, "utf-8"))).toEqual({ v: 2 });
});
it("a chained write swallows the prior in-flight write's rejection", async () => {
// Queue two writes to the same key while the first is still in flight and
// destined to reject (parent is a file). The second's `.catch(() => {})`
// on the chained prior promise must absorb the rejection.
const blocker = join(tempDir!, "blocker2");
writeFileSync(blocker, "x", "utf-8");
const badPath = join(blocker, "store.json");
const first = writeStoreFile(badPath, '{"v":1}');
const second = writeStoreFile(badPath, '{"v":2}');
await expect(first).rejects.toThrow();
// Second also rejects (blocker still present) but does NOT reject with the
// first's error unhandled — the chain swallowed it.
await expect(second).rejects.toThrow();
});
it("leaves a newer write's entry in place when an earlier write settles (finally branch)", async () => {
const filePath = join(tempDir!, "store.json");
// Two overlapping writes share a key. When the first settles, its
// finally must NOT delete the map entry, because the second write
// replaced it. Awaiting the second then flushing exercises both the
// "replaced" finally branch (first) and the "delete" branch (second).
const first = writeStoreFile(filePath, '{"order":1}');
const second = writeStoreFile(filePath, '{"order":2}');
await first;
await second;
// After the last write settles, the pending entry is cleaned up so a
// subsequent flush is a no-op.
await expect(flushStoreFileWrites(filePath)).resolves.toBeUndefined();
expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({ order: 2 });
});
});
describe("deleteStoreFile", () => {
let tempDir: string | null = null;
afterEach(() => {
if (tempDir) {
try {
chmodSync(tempDir, 0o700);
rmSync(tempDir, { recursive: true });
} catch {
// Ignore cleanup errors
}
tempDir = null;
}
});
it("deletes an existing file", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-delete-"));
const filePath = join(tempDir, "store.json");
writeFileSync(filePath, "{}", "utf-8");
expect(existsSync(filePath)).toBe(true);
await deleteStoreFile(filePath);
expect(existsSync(filePath)).toBe(false);
});
it("ignores ENOENT when the file is already gone", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-delete-"));
const filePath = join(tempDir, "missing.json");
await expect(deleteStoreFile(filePath)).resolves.toBeUndefined();
});
it("rethrows a non-ENOENT error (e.g. unlinking a directory)", async () => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-delete-"));
// Unlinking a directory yields EPERM/EISDIR, not ENOENT.
const dirPath = join(tempDir, "subdir");
writeFileSync(join(tempDir, "keep"), "x");
const { mkdirSync } = await import("node:fs");
mkdirSync(dirPath);
await expect(deleteStoreFile(dirPath)).rejects.toThrow();
});
});
describe("serializeStore / parseStore", () => {
it("serializes to pretty-printed JSON", () => {
expect(serializeStore({ a: 1, b: [2, 3] })).toBe(
'{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}',
);
});
it("parses a JSON string round-trip", () => {
const obj = { x: "y", n: 42 };
expect(parseStore(serializeStore(obj))).toEqual(obj);
});
});
});