chore(web): move App.tsx's module-level block into files (#2127)
Phase 0 of #2126. Lines 239-668 of clients/web/src/App.tsx were already at module scope — helpers, formatters, constants, and the toast subcomponents, none of which close over component state — so the whole block moves verbatim into files. App.tsx drops from 5,303 to 4,892 lines with no behavior change. Placement follows the lib-vs-utils rule in AGENTS.md: pure computation to utils/, anything touching the environment to lib/. EMPTY_SETTINGS and the two loose toast ids deliberately do NOT land in a new top-level src/constants.ts, which the issue suggested: the web coverage include is a whitelist naming components/hooks/theme/lib/utils/server, so a module there would have fallen out of the >=90 gate silently. They go under utils/ instead. ReAuthBannerBar is in the block but not in the issue's table — it was added after the issue was written (#2108). It moves to components/groups/ReAuthBanner/, beside the banner it wraps. Every new module is at 100% on all four coverage dimensions. Two v8 ignores, each justified in place: getAuthToken's typeof-window guard (a happy-dom inherent path) and replayProtocolRequest's provably-dead default case. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ca12561f41
commit
b6ed5072f4
4 changed files with 1232 additions and 453 deletions
115
clients/web/src/lib/authToken.test.ts
Normal file
115
clients/web/src/lib/authToken.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
API_SERVER_ENV_VARS,
|
||||
INSPECTOR_API_TOKEN_GLOBAL,
|
||||
} from "@inspector/core/mcp/remote/constants.js";
|
||||
import { OAUTH_CALLBACK_PATH } from "../utils/oauthFlow";
|
||||
import { getAuthToken, redirectUrlProvider } from "./authToken";
|
||||
|
||||
const KEY = API_SERVER_ENV_VARS.AUTH_TOKEN;
|
||||
|
||||
// happy-dom won't let `window.location` be reassigned, so drive the query
|
||||
// string through the History API instead.
|
||||
function setSearch(search: string): void {
|
||||
window.history.replaceState(null, "", `/${search}`);
|
||||
}
|
||||
|
||||
// happy-dom hands back a fresh Storage wrapper per `window.sessionStorage`
|
||||
// access, so a spy on the instance never sees the call. Replace the property
|
||||
// itself to exercise the "storage is unavailable" branches (privacy mode, a
|
||||
// sandboxed iframe).
|
||||
const realSessionStorage = window.sessionStorage;
|
||||
function stubSessionStorage(overrides: Partial<Storage>): void {
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: { ...realSessionStorage, ...overrides },
|
||||
});
|
||||
}
|
||||
function restoreSessionStorage(): void {
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: realSessionStorage,
|
||||
});
|
||||
}
|
||||
|
||||
describe("redirectUrlProvider", () => {
|
||||
it("points at the backend's OAuth callback on the current origin", () => {
|
||||
expect(redirectUrlProvider.getRedirectUrl()).toBe(
|
||||
`${window.location.origin}${OAUTH_CALLBACK_PATH}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthToken", () => {
|
||||
beforeEach(() => {
|
||||
setSearch("");
|
||||
window.sessionStorage.clear();
|
||||
delete (window as unknown as Record<string, unknown>)[
|
||||
INSPECTOR_API_TOKEN_GLOBAL
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
restoreSessionStorage();
|
||||
setSearch("");
|
||||
window.sessionStorage.clear();
|
||||
delete (window as unknown as Record<string, unknown>)[
|
||||
INSPECTOR_API_TOKEN_GLOBAL
|
||||
];
|
||||
});
|
||||
|
||||
it("prefers the injected global and persists it", () => {
|
||||
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
|
||||
"from-global";
|
||||
setSearch(`?${KEY}=from-url`);
|
||||
window.sessionStorage.setItem(KEY, "from-storage");
|
||||
|
||||
expect(getAuthToken()).toBe("from-global");
|
||||
expect(window.sessionStorage.getItem(KEY)).toBe("from-global");
|
||||
});
|
||||
|
||||
it("ignores a non-string or empty global and falls through to the URL", () => {
|
||||
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
|
||||
"";
|
||||
setSearch(`?${KEY}=from-url`);
|
||||
|
||||
expect(getAuthToken()).toBe("from-url");
|
||||
|
||||
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
|
||||
123;
|
||||
expect(getAuthToken()).toBe("from-url");
|
||||
expect(window.sessionStorage.getItem(KEY)).toBe("from-url");
|
||||
});
|
||||
|
||||
it("falls back to sessionStorage when neither source is present", () => {
|
||||
window.sessionStorage.setItem(KEY, "from-storage");
|
||||
expect(getAuthToken()).toBe("from-storage");
|
||||
});
|
||||
|
||||
it("returns undefined when nothing carries a token", () => {
|
||||
expect(getAuthToken()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still resolves when sessionStorage writes throw", () => {
|
||||
const setItem = vi.fn(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
stubSessionStorage({ setItem });
|
||||
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
|
||||
"from-global";
|
||||
|
||||
expect(getAuthToken()).toBe("from-global");
|
||||
expect(setItem).toHaveBeenCalledWith(KEY, "from-global");
|
||||
});
|
||||
|
||||
it("returns undefined when sessionStorage reads throw", () => {
|
||||
const getItem = vi.fn(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
stubSessionStorage({ getItem });
|
||||
|
||||
expect(getAuthToken()).toBeUndefined();
|
||||
expect(getItem).toHaveBeenCalledWith(KEY);
|
||||
});
|
||||
});
|
||||
61
clients/web/src/lib/authToken.ts
Normal file
61
clients/web/src/lib/authToken.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
|
||||
import { toRecord } from "@inspector/core/json/jsonUtils.js";
|
||||
import {
|
||||
API_SERVER_ENV_VARS,
|
||||
INSPECTOR_API_TOKEN_GLOBAL,
|
||||
} from "@inspector/core/mcp/remote/constants.js";
|
||||
import { OAUTH_CALLBACK_PATH } from "../utils/oauthFlow";
|
||||
|
||||
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
|
||||
// handler. The InspectorClient only consults this when the active server
|
||||
// requires OAuth; for stdio MCP servers it's never used. Created once and
|
||||
// reused so `BrowserOAuthClientProvider` doesn't re-instantiate per render.
|
||||
export const redirectUrlProvider: RedirectUrlProvider = {
|
||||
getRedirectUrl: () => `${window.location.origin}${OAUTH_CALLBACK_PATH}`,
|
||||
};
|
||||
|
||||
// Recover the backend's auth token. Every browser request to /api/* needs it
|
||||
// in the `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401.
|
||||
// Three sources, in priority order:
|
||||
// 1. `window.__INSPECTOR_API_TOKEN__` — injected into index.html by the
|
||||
// backend on every page load (dev Vite plugin + prod Hono server). This
|
||||
// is the robust path: it survives a bare-URL reload, a bookmark, or a
|
||||
// cleared sessionStorage, none of which carry the query string.
|
||||
// 2. `?MCP_INSPECTOR_API_TOKEN=…` — the URL the launcher banner prints. Kept
|
||||
// as a fallback for pasted full URLs and older integrations.
|
||||
// 3. sessionStorage — backstop for SPA navigations / OAuth round-trips that
|
||||
// land without either of the above.
|
||||
// Both the injected global and the URL value are persisted to sessionStorage
|
||||
// so a later navigation that drops them (e.g. a deep-link load that wasn't
|
||||
// injected, or an iframe) still authenticates from the backstop.
|
||||
export function getAuthToken(): string | undefined {
|
||||
/* v8 ignore next -- happy-dom always defines `window`; this is the SSR/no-DOM guard */
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN;
|
||||
// Best-effort persistence — sessionStorage may be unavailable (privacy
|
||||
// mode, iframe sandboxing, etc.); the resolved value still works for the
|
||||
// current page load regardless.
|
||||
const persist = (token: string): void => {
|
||||
try {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, token);
|
||||
} catch {
|
||||
// ignore — see note above
|
||||
}
|
||||
};
|
||||
const fromGlobal = toRecord(window)[INSPECTOR_API_TOKEN_GLOBAL];
|
||||
if (typeof fromGlobal === "string" && fromGlobal) {
|
||||
persist(fromGlobal);
|
||||
return fromGlobal;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN);
|
||||
if (fromUrl) {
|
||||
persist(fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
try {
|
||||
return window.sessionStorage.getItem(STORAGE_KEY) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
172
clients/web/src/lib/protocolReplay.test.ts
Normal file
172
clients/web/src/lib/protocolReplay.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import { messagesToLogEntries, replayProtocolRequest } from "./protocolReplay";
|
||||
|
||||
describe("messagesToLogEntries", () => {
|
||||
const notification = (method: string, params: unknown): MessageEntry =>
|
||||
({
|
||||
direction: "notification",
|
||||
timestamp: 1000,
|
||||
message: { jsonrpc: "2.0", method, params },
|
||||
}) as unknown as MessageEntry;
|
||||
|
||||
it("keeps only notifications/message and carries the timestamp", () => {
|
||||
const entries = messagesToLogEntries([
|
||||
notification("notifications/message", { level: "info", data: "hello" }),
|
||||
notification("notifications/progress", { progress: 1 }),
|
||||
]);
|
||||
expect(entries).toEqual([
|
||||
{ receivedAt: 1000, params: { level: "info", data: "hello" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips requests and responses", () => {
|
||||
const request = {
|
||||
direction: "request",
|
||||
timestamp: 1,
|
||||
message: { jsonrpc: "2.0", id: 1, method: "notifications/message" },
|
||||
} as unknown as MessageEntry;
|
||||
const response = {
|
||||
direction: "response",
|
||||
timestamp: 2,
|
||||
message: { jsonrpc: "2.0", id: 1, result: {} },
|
||||
} as unknown as MessageEntry;
|
||||
expect(messagesToLogEntries([request, response])).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a notification carrying no method", () => {
|
||||
const malformed = {
|
||||
direction: "notification",
|
||||
timestamp: 3,
|
||||
message: { jsonrpc: "2.0", result: {} },
|
||||
} as unknown as MessageEntry;
|
||||
expect(messagesToLogEntries([malformed])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replayProtocolRequest", () => {
|
||||
const tool = { name: "echo", inputSchema: { type: "object" } } as Tool;
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
callTool: vi.fn().mockResolvedValue({}),
|
||||
getPrompt: vi.fn().mockResolvedValue({}),
|
||||
readResource: vi.fn().mockResolvedValue({}),
|
||||
listTools: vi.fn().mockResolvedValue({}),
|
||||
listPrompts: vi.fn().mockResolvedValue({}),
|
||||
listResources: vi.fn().mockResolvedValue({}),
|
||||
listResourceTemplates: vi.fn().mockResolvedValue({}),
|
||||
listRequestorTasks: vi.fn().mockResolvedValue({}),
|
||||
ping: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
const replay = (
|
||||
client: ReturnType<typeof makeClient>,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
tools: Tool[] = [tool],
|
||||
) =>
|
||||
replayProtocolRequest(
|
||||
client as unknown as InspectorClient,
|
||||
method,
|
||||
params,
|
||||
tools,
|
||||
);
|
||||
|
||||
it("refuses a method outside the shared replayable set", async () => {
|
||||
const client = makeClient();
|
||||
await expect(replay(client, "initialize")).resolves.toBe(
|
||||
'Replay isn\'t supported for "initialize".',
|
||||
);
|
||||
});
|
||||
|
||||
it("replays tools/call with the recorded arguments", async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
replay(client, "tools/call", { name: "echo", arguments: { a: 1 } }),
|
||||
).resolves.toBeNull();
|
||||
expect(client.callTool).toHaveBeenCalledWith(tool, { a: 1 });
|
||||
});
|
||||
|
||||
it("defaults tools/call arguments to an empty object", async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
replay(client, "tools/call", { name: "echo" }),
|
||||
).resolves.toBeNull();
|
||||
expect(client.callTool).toHaveBeenCalledWith(tool, {});
|
||||
});
|
||||
|
||||
it("explains when the tool is gone, and when the name is missing", async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
replay(client, "tools/call", { name: "vanished" }),
|
||||
).resolves.toBe('Tool "vanished" is no longer available to replay.');
|
||||
await expect(replay(client, "tools/call", {})).resolves.toBe(
|
||||
'Tool "?" is no longer available to replay.',
|
||||
);
|
||||
expect(client.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replays prompts/get, and explains a missing name", async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
replay(client, "prompts/get", { name: "p", arguments: { x: 1 } }),
|
||||
).resolves.toBeNull();
|
||||
expect(client.getPrompt).toHaveBeenCalledWith("p", { x: 1 });
|
||||
|
||||
await expect(
|
||||
replay(client, "prompts/get", { name: "p" }),
|
||||
).resolves.toBeNull();
|
||||
expect(client.getPrompt).toHaveBeenLastCalledWith("p", {});
|
||||
|
||||
await expect(replay(client, "prompts/get", {})).resolves.toBe(
|
||||
"Prompt name is missing; cannot replay.",
|
||||
);
|
||||
});
|
||||
|
||||
it("replays resources/read, and explains a missing uri", async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
replay(client, "resources/read", { uri: "file:///a" }),
|
||||
).resolves.toBeNull();
|
||||
expect(client.readResource).toHaveBeenCalledWith("file:///a");
|
||||
|
||||
await expect(replay(client, "resources/read", {})).resolves.toBe(
|
||||
"Resource URI is missing; cannot replay.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tools/list", "listTools"],
|
||||
["prompts/list", "listPrompts"],
|
||||
["resources/list", "listResources"],
|
||||
["resources/templates/list", "listResourceTemplates"],
|
||||
["tasks/list", "listRequestorTasks"],
|
||||
] as const)("replays %s on the recorded page", async (method, fn) => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
replay(client, method, { cursor: "page-2" }),
|
||||
).resolves.toBeNull();
|
||||
expect(client[fn]).toHaveBeenCalledWith("page-2");
|
||||
});
|
||||
|
||||
it("passes no cursor when the entry carried none", async () => {
|
||||
const client = makeClient();
|
||||
await expect(replay(client, "tools/list", undefined)).resolves.toBeNull();
|
||||
expect(client.listTools).toHaveBeenCalledWith(undefined);
|
||||
|
||||
await expect(
|
||||
replay(client, "tools/list", { cursor: 7 }),
|
||||
).resolves.toBeNull();
|
||||
expect(client.listTools).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("replays ping", async () => {
|
||||
const client = makeClient();
|
||||
await expect(replay(client, "ping")).resolves.toBeNull();
|
||||
expect(client.ping).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
108
clients/web/src/lib/protocolReplay.ts
Normal file
108
clients/web/src/lib/protocolReplay.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type {
|
||||
LoggingMessageNotification,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import type { LogEntryData } from "../components/elements/LogEntry/LogEntry";
|
||||
import { isReplayableProtocolMethod } from "../components/groups/protocolUtils.js";
|
||||
|
||||
// Derive `LogEntryData[]` from the MessageLog by filtering for the
|
||||
// `notifications/message` notifications the server emits in response to
|
||||
// `logging/setLevel`. The Logs screen renders these; we transform here
|
||||
// rather than in the screen so the view stays prop-driven.
|
||||
export function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] {
|
||||
const out: LogEntryData[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.direction !== "notification") continue;
|
||||
// MessageEntry.message is a JSONRPC union; notifications have `method`
|
||||
// but not `id`. Narrow with an `in` check, then confirm the method.
|
||||
if (!("method" in m.message)) continue;
|
||||
if (m.message.method !== "notifications/message") continue;
|
||||
// The method check pins this to a logging notification; its `params` are
|
||||
// only generically typed on the JSONRPC union, so cast just that value.
|
||||
const params = m.message.params as LoggingMessageNotification["params"];
|
||||
out.push({
|
||||
receivedAt: m.timestamp,
|
||||
params,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Re-issue the original request behind a Protocol entry. The call goes through
|
||||
// InspectorClient → tracked transport → message log, so the replayed
|
||||
// request+response surface as a fresh Protocol entry (protocol-local) — it
|
||||
// intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a
|
||||
// human-readable reason when the entry can't be replayed (unsupported method,
|
||||
// or a tool that's no longer present), or null on a dispatched replay.
|
||||
export async function replayProtocolRequest(
|
||||
client: InspectorClient,
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
tools: Tool[],
|
||||
): Promise<string | null> {
|
||||
// Gate on the shared replayable-method set (the same one ProtocolEntry uses to
|
||||
// show/hide the Replay button) so the two can't drift.
|
||||
if (!isReplayableProtocolMethod(method)) {
|
||||
return `Replay isn't supported for "${method}".`;
|
||||
}
|
||||
// Pagination cursor carried by the */list requests; replaying the same page
|
||||
// reproduces the original call.
|
||||
const cursor = typeof params?.cursor === "string" ? params.cursor : undefined;
|
||||
switch (method) {
|
||||
case "tools/call": {
|
||||
const name = typeof params?.name === "string" ? params.name : undefined;
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
if (!tool) {
|
||||
return `Tool "${name ?? "?"}" is no longer available to replay.`;
|
||||
}
|
||||
await client.callTool(
|
||||
tool,
|
||||
(params?.arguments ?? {}) as Record<string, JsonValue>,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
case "prompts/get": {
|
||||
const name = typeof params?.name === "string" ? params.name : undefined;
|
||||
if (!name) return "Prompt name is missing; cannot replay.";
|
||||
await client.getPrompt(
|
||||
name,
|
||||
(params?.arguments ?? {}) as Record<string, JsonValue>,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
case "resources/read": {
|
||||
const uri = typeof params?.uri === "string" ? params.uri : undefined;
|
||||
if (!uri) return "Resource URI is missing; cannot replay.";
|
||||
await client.readResource(uri);
|
||||
return null;
|
||||
}
|
||||
case "tools/list":
|
||||
await client.listTools(cursor);
|
||||
return null;
|
||||
case "prompts/list":
|
||||
await client.listPrompts(cursor);
|
||||
return null;
|
||||
case "resources/list":
|
||||
await client.listResources(cursor);
|
||||
return null;
|
||||
case "resources/templates/list":
|
||||
await client.listResourceTemplates(cursor);
|
||||
return null;
|
||||
case "tasks/list":
|
||||
await client.listRequestorTasks(cursor);
|
||||
return null;
|
||||
case "ping":
|
||||
await client.ping();
|
||||
return null;
|
||||
/* v8 ignore start -- unreachable: the guard above admits exactly the nine
|
||||
methods this switch enumerates. Kept so a method added to
|
||||
REPLAYABLE_PROTOCOL_METHODS without a case here reports a reason rather
|
||||
than silently resolving as a dispatched replay. */
|
||||
default:
|
||||
return `Replay isn't supported for "${method}".`;
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue