From 8bd1f9beca1b8cc150099aa291b0c06121f31eb5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 28 Oct 2026 23:57:10 -0400 Subject: [PATCH 1/2] fix: nine small defects from the v2.13 review (#2827) 1. Self-host the Fredoka and Roboto Mono woff2 files (clients/web/src/theme/fonts) instead of loading them from fonts.googleapis.com at runtime, in the app and in Storybook. 2. The sandbox listener answers HEAD, ignores a query string on /sandbox, and applies the main server's Host allow-list (plus the host of MCP_SANDBOX_FULL_ADDRESS). 3. The prod SPA fallback excludes only /api and /api/..., so /apixyz gets the SPA. 4. One ad-hoc server naming rule (adHocServerName in core/mcp/serverList.ts): URL host or command basename, used by web and by the shared CLI/TUI/mcpdo resolution, which used to call it "default". 5. The default seed scopes filesystem-server-default to os.tmpdir(), passed in by the Node backend (defaultSeedConfig(tempDir)) since serverList.ts is browser-consumed. 6. Every web screen has a visually hidden h1; connected-screen panel headings move to h2/h3 (visual sizes unchanged) so axe heading-order stays clean. 7. The TUI lists tools alphabetically, as the web sidebar does since #2782, via a shared core/mcp/toolOrder.ts. 8. TUI test modals no longer print their title twice; the roots-editor comment in App.tsx reflects #2432. 9. A custom MCP_INSPECTOR_API_TOKEN shorter than 32 characters prints a startup warning. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/launcher/README.md | 4 +- clients/tui/__tests__/App.test.tsx | 19 ++ clients/tui/__tests__/formHeader.test.ts | 22 ++ clients/tui/__tests__/tui-servers.test.ts | 4 +- clients/tui/src/App.tsx | 13 +- .../tui/src/components/PromptTestModal.tsx | 3 +- .../tui/src/components/ResourceTestModal.tsx | 3 +- clients/tui/src/components/ToolTestModal.tsx | 3 +- clients/tui/src/utils/formHeader.ts | 17 ++ clients/web/.storybook/main.ts | 6 - clients/web/.storybook/preview.tsx | 1 + clients/web/README.md | 2 + clients/web/index.html | 3 - clients/web/server/run-web.ts | 19 +- clients/web/server/sandbox-controller.ts | 65 +++++- clients/web/server/server.ts | 14 +- clients/web/server/vite-hono-plugin.ts | 1 + clients/web/server/web-server-config.ts | 32 +++ .../groups/AppControls/AppControls.tsx | 6 +- .../groups/LogControls/LogControls.tsx | 16 +- .../groups/LogStreamPanel/LogStreamPanel.tsx | 6 +- .../MessageDirectionFilter.tsx | 6 +- .../NetworkControls/NetworkControls.tsx | 12 +- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 6 +- .../PromptArgumentsForm.tsx | 6 +- .../groups/PromptControls/PromptControls.tsx | 6 +- .../PromptMessagesDisplay.tsx | 6 +- .../ProtocolControls/ProtocolControls.tsx | 12 +- .../ProtocolListPanel/ProtocolListPanel.tsx | 12 +- .../ResourceControls/ResourceControls.tsx | 6 +- .../ResourcePreviewPanel.tsx | 6 +- .../ResourceTemplatePanel.tsx | 6 +- .../StructuredOutputPanel.tsx | 6 +- .../groups/TaskControls/TaskControls.tsx | 18 +- .../groups/TaskListPanel/TaskListPanel.tsx | 16 +- .../groups/ToolControls/ToolControls.tsx | 9 +- .../ToolResultPanel/ToolCallErrorPanel.tsx | 8 +- .../ToolResultPanel/ToolResultPanel.tsx | 12 +- .../screens/AppsScreen/AppsScreen.tsx | 6 +- .../screens/ConsoleScreen/ConsoleScreen.tsx | 8 +- .../ServerListScreen/ServerListScreen.tsx | 11 +- .../InspectorView/InspectorView.test.tsx | 27 +++ .../views/InspectorView/InspectorView.tsx | 18 ++ clients/web/src/main.tsx | 1 + .../src/test/core/mcp/node/servers.test.ts | 16 +- .../web/src/test/core/mcp/serverList.test.ts | 39 +++- .../web/src/test/core/mcp/toolOrder.test.ts | 44 ++++ .../src/test/core/react/useServers.test.tsx | 6 +- .../mcp/remote/servers-route.test.ts | 8 +- .../server/sandbox-controller.test.ts | 139 +++++++++++- .../server/server-token-injection.test.ts | 22 +- .../server/web-server-config.test.ts | 94 ++++++++- clients/web/src/theme/fonts/fonts.css | 198 ++++++++++++++++++ clients/web/src/theme/fonts/fonts.test.ts | 35 ++++ .../theme/fonts/fredoka-normal-hebrew.woff2 | Bin 0 -> 8776 bytes .../fonts/fredoka-normal-latin-ext.woff2 | Bin 0 -> 4564 bytes .../theme/fonts/fredoka-normal-latin.woff2 | Bin 0 -> 29704 bytes .../roboto-mono-italic-cyrillic-ext.woff2 | Bin 0 -> 38556 bytes .../fonts/roboto-mono-italic-cyrillic.woff2 | Bin 0 -> 20544 bytes .../fonts/roboto-mono-italic-greek.woff2 | Bin 0 -> 14872 bytes .../fonts/roboto-mono-italic-latin-ext.woff2 | Bin 0 -> 24356 bytes .../fonts/roboto-mono-italic-latin.woff2 | Bin 0 -> 35240 bytes .../fonts/roboto-mono-italic-vietnamese.woff2 | Bin 0 -> 10864 bytes .../roboto-mono-normal-cyrillic-ext.woff2 | Bin 0 -> 35892 bytes .../fonts/roboto-mono-normal-cyrillic.woff2 | Bin 0 -> 18584 bytes .../fonts/roboto-mono-normal-greek.woff2 | Bin 0 -> 14048 bytes .../fonts/roboto-mono-normal-latin-ext.woff2 | Bin 0 -> 22868 bytes .../fonts/roboto-mono-normal-latin.woff2 | Bin 0 -> 32752 bytes .../fonts/roboto-mono-normal-vietnamese.woff2 | Bin 0 -> 10276 bytes clients/web/src/utils/toolUtils.ts | 36 +--- core/mcp/node/servers.ts | 11 +- core/mcp/remote/node/server.ts | 11 +- core/mcp/serverList.ts | 68 ++++-- core/mcp/toolOrder.ts | 46 ++++ docs/environment-variables.md | 2 +- docs/mcp-server-configuration.md | 6 +- 76 files changed, 1068 insertions(+), 196 deletions(-) create mode 100644 clients/tui/__tests__/formHeader.test.ts create mode 100644 clients/tui/src/utils/formHeader.ts create mode 100644 clients/web/src/test/core/mcp/toolOrder.test.ts create mode 100644 clients/web/src/theme/fonts/fonts.css create mode 100644 clients/web/src/theme/fonts/fonts.test.ts create mode 100644 clients/web/src/theme/fonts/fredoka-normal-hebrew.woff2 create mode 100644 clients/web/src/theme/fonts/fredoka-normal-latin-ext.woff2 create mode 100644 clients/web/src/theme/fonts/fredoka-normal-latin.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-italic-cyrillic-ext.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-italic-cyrillic.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-italic-greek.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-italic-latin-ext.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-italic-latin.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-italic-vietnamese.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-normal-cyrillic-ext.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-normal-cyrillic.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-normal-greek.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-normal-latin-ext.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-normal-latin.woff2 create mode 100644 clients/web/src/theme/fonts/roboto-mono-normal-vietnamese.woff2 create mode 100644 core/mcp/toolOrder.ts diff --git a/clients/launcher/README.md b/clients/launcher/README.md index 280f5e75f7..c4707dad72 100644 --- a/clients/launcher/README.md +++ b/clients/launcher/README.md @@ -35,8 +35,8 @@ transport and sets the era that connection negotiates, so a non-legacy era needs no `mcp.json` entry. **Seed contents are web-specific.** When the web backend creates a missing -writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a -`filesystem-server-default` scoped to `/tmp`, the canonical +writable catalog it seeds `defaultSeedConfig` (`core/mcp/serverList.ts`) — a +`filesystem-server-default` scoped to the platform temp dir (`os.tmpdir()`), the canonical `everything-server-default`, and `example-server-default`, the MCP org's remote feature-reference server (Streamable HTTP, no local process and no API key needed) — so a first launch has something to connect to. diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 0d15556b35..102d41b7b6 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -1164,6 +1164,25 @@ describe("App (status, layout, modals)", () => { expect(f).toContain("alpha"); }); + it("lists tools alphabetically by name, as the web sidebar does (#2827)", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [ + { ...sampleTool, name: "zeta" }, + { ...sampleTool, name: "tool_10" }, + { ...sampleTool, name: "Beta" }, + { ...sampleTool, name: "tool_2" }, + ]; + const r = await mount(oneStdio()); + await press(r, ["t"]); + await expectFrame(r, "Tools (4)"); + const f = r.lastFrame() ?? ""; + const order = ["Beta", "tool_2", "tool_10", "zeta"].map((n) => + f.indexOf(n), + ); + expect(order.every((i) => i >= 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); + }); + it("opens the tool test modal with Enter from the list pane", async () => { h.ctrl.status = "connected"; h.ctrl.tools = [sampleTool]; diff --git a/clients/tui/__tests__/formHeader.test.ts b/clients/tui/__tests__/formHeader.test.ts new file mode 100644 index 0000000000..f196390504 --- /dev/null +++ b/clients/tui/__tests__/formHeader.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { untitledForm } from "../src/utils/formHeader.js"; +import { schemaToForm } from "../src/utils/schemaToForm.js"; + +describe("untitledForm (#2827)", () => { + it("drops the title the modal header already shows, keeping the sections", () => { + const form = schemaToForm( + { type: "object", properties: { q: { type: "string" } } }, + "search", + ); + expect(form.title).toBe("Test Tool: search"); + const body = untitledForm(form); + expect(body.title).toBeUndefined(); + expect(body.sections).toBe(form.sections); + }); + + it("does not mutate the form the header reads", () => { + const form = schemaToForm(null, "noParams"); + untitledForm(form); + expect(form.title).toBe("Test Tool: noParams"); + }); +}); diff --git a/clients/tui/__tests__/tui-servers.test.ts b/clients/tui/__tests__/tui-servers.test.ts index 00ace5fccd..cb59652f9a 100644 --- a/clients/tui/__tests__/tui-servers.test.ts +++ b/clients/tui/__tests__/tui-servers.test.ts @@ -75,8 +75,8 @@ describe("loadTuiServers", () => { it("builds a single ad-hoc server from a positional target", async () => { const servers = await loadTuiServers({ target: ["my-server", "--flag"] }); - expect(Object.keys(servers)).toEqual(["default"]); - expect(servers.default?.config).toMatchObject({ + expect(Object.keys(servers)).toEqual(["my-server"]); + expect(servers["my-server"]?.config).toMatchObject({ type: "stdio", command: "my-server", args: ["--flag"], diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 1960a222ca..503f3a15bf 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -44,6 +44,7 @@ import { } from "@inspector/core/mcp/node/index.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; +import { compareToolsByName } from "@inspector/core/mcp/toolOrder.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js"; import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; @@ -429,9 +430,10 @@ function App({ }), ...(savedSettings && { serverSettings: savedSettings }), // Advertise the roots configured for this server in mcp.json, as web - // and the CLI do. The TUI has no roots editor, but a user who set - // roots in the web UI expects them to apply to the same server here - // (#1797). + // and the CLI do (#1797). This is the starting list: the roots + // editor (#2432, `e` on the Info tab) changes it for this session + // through `InspectorClient.setRoots`, but never writes mcp.json, so + // these configured roots return on the next launch. roots: cleanRoots(savedSettings?.roots ?? []), // Per-server protocol era (SEP §7.8) from mcp.json → SDK // versionNegotiation; absent era defaults to legacy (#1626). @@ -1272,7 +1274,10 @@ function App({ resources: managedResources, resourceTemplates: managedResourceTemplates, prompts: managedPrompts, - tools: managedTools, + // Alphabetical by the name each row shows, the order the web Tools + // sidebar uses (#2782, #2827) — not the server's `tools/list` order. + // A copy: the hook's array is the store's snapshot. + tools: [...managedTools].sort(compareToolsByName), stderrLogs: inspectorStderrLogs, // InspectorClient manages this }; }, [ diff --git a/clients/tui/src/components/PromptTestModal.tsx b/clients/tui/src/components/PromptTestModal.tsx index a78eec4927..317623bb31 100644 --- a/clients/tui/src/components/PromptTestModal.tsx +++ b/clients/tui/src/components/PromptTestModal.tsx @@ -7,6 +7,7 @@ import type { Prompt, GetPromptResult } from "@modelcontextprotocol/client"; import { promptArgsToForm } from "../utils/promptArgsToForm.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { redactErrorText, redactedJson } from "../utils/errorText.js"; +import { untitledForm } from "../utils/formHeader.js"; // Helper to extract error message from various error types function getErrorMessage(error: unknown): string { @@ -221,7 +222,7 @@ export function PromptTestModal({ )}
handleFormSubmit(values as Record) } diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index 1bbf305828..e7ebe43a26 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -12,6 +12,7 @@ import { } from "@inspector/core/mcp/uriTemplate.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { redactErrorText, redactedJson } from "../utils/errorText.js"; +import { untitledForm } from "../utils/formHeader.js"; // Helper to extract error message from various error types function getErrorMessage(error: unknown): string { @@ -274,7 +275,7 @@ export function ResourceTestModal({ )} handleFormSubmit(values as Record) } diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index 30f7833e8e..aed74624b1 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -14,6 +14,7 @@ import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { inlineLocalRefs } from "@inspector/core/json/localRefs.js"; import { redactErrorText, redactedJson } from "../utils/errorText.js"; import type { ResultFileFormat } from "@inspector/core/mcp/resultFile.js"; +import { untitledForm } from "../utils/formHeader.js"; import { defaultResultFileName, saveResultToFile, @@ -384,7 +385,7 @@ export function ToolTestModal({ {state === "form" && ( void handleFormSubmit(value as Record) } diff --git a/clients/tui/src/utils/formHeader.ts b/clients/tui/src/utils/formHeader.ts new file mode 100644 index 0000000000..1da9688b31 --- /dev/null +++ b/clients/tui/src/utils/formHeader.ts @@ -0,0 +1,17 @@ +/** + * The form a test modal hands to `ink-form`, minus its title (#2827). + * + * Each test modal (tool, prompt, resource template) prints the form's title in + * its own header, because it has to stay visible in the loading and results + * states too — and `ink-form` prints the same `form.title` again in its own + * header box, so the open form showed "Test Tool: " twice. The modal's + * header is the one that survives every state, so the copy given to the form + * drops it. `title` is optional in `ink-form`'s `FormStructure`; without one + * its header keeps the navigation hint and the section tabs. + */ + +import type { FormStructure } from "ink-form"; + +export function untitledForm(form: FormStructure): FormStructure { + return { ...form, title: undefined }; +} diff --git a/clients/web/.storybook/main.ts b/clients/web/.storybook/main.ts index 69b7bb2204..a4a08b132d 100644 --- a/clients/web/.storybook/main.ts +++ b/clients/web/.storybook/main.ts @@ -10,11 +10,5 @@ const config: StorybookConfig = { "@storybook/addon-onboarding", ], framework: "@storybook/react-vite", - previewHead: (head) => ` - ${head} - - - - `, }; export default config; diff --git a/clients/web/.storybook/preview.tsx b/clients/web/.storybook/preview.tsx index cd2edd3ac3..6efcc5587b 100644 --- a/clients/web/.storybook/preview.tsx +++ b/clients/web/.storybook/preview.tsx @@ -3,6 +3,7 @@ import { MantineProvider, useMantineColorScheme } from "@mantine/core"; import { Notifications } from "@mantine/notifications"; import "@mantine/core/styles.css"; import "@mantine/notifications/styles.css"; +import "../src/theme/fonts/fonts.css"; import "../src/App.css"; import { theme } from "../src/theme/theme"; import { cssVariablesResolver } from "../src/theme/cssVariables"; diff --git a/clients/web/README.md b/clients/web/README.md index 18adee52b7..84aa2dcb65 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -419,6 +419,8 @@ The origin allow-list only sees requests that carry an `Origin` header, and a br - The **port is not compared**, so a remapped port (`-p 127.0.0.1:8080:6274`) or a proxy on `:443` still matches; the name is what a re-pointed page cannot change. A request with no `Host` header is refused. - `GET /healthz` is exempt — it discloses nothing — so a probe that addresses the container by a service name keeps working. +**The MCP Apps sandbox listener (`MCP_SANDBOX_PORT`, default 6275) applies the same rules** to `/sandbox`, in prod and `--dev` alike, and additionally admits the host of `MCP_SANDBOX_FULL_ADDRESS` when one is set; a refused `Host` gets the same 403 (#2827). + `MCP_INSPECTOR_ALLOWED_HOSTS` is read by the prod server only; under `--dev`, Vite's own `server.allowedHosts` check applies instead (see below) — except over TLS, where Vite skips it because the certificate check already refuses a page loaded under another name. **Running the Inspector behind a `*.localhost` proxy.** A common local-dev shape gives each service a friendly name — `my-api.localhost`, `my-app.localhost`, `inspector.localhost` — instead of a set of ports. The Inspector works there, but the origin allow-list does not include those names by default, so set it explicitly: diff --git a/clients/web/index.html b/clients/web/index.html index 6248265253..cf13d2e11a 100644 --- a/clients/web/index.html +++ b/clients/web/index.html @@ -4,9 +4,6 @@ - - - MCP Inspector diff --git a/clients/web/server/run-web.ts b/clients/web/server/run-web.ts index 7df47c9bde..0f1fd64e7b 100644 --- a/clients/web/server/run-web.ts +++ b/clients/web/server/run-web.ts @@ -20,6 +20,7 @@ import { parseProtocolEra, type ServerConfigOptions, } from "../../../core/mcp/node/config.ts"; +import { adHocServerName } from "../../../core/mcp/serverList.ts"; import { buildWebServerConfig, type WebServerConfig, @@ -42,22 +43,6 @@ function ensureStdioCwd(config: MCPServerConfig): MCPServerConfig { return config; } -/** - * Derive a readable catalog id for an ad-hoc server seeded from CLI flags: - * the URL host for HTTP/SSE, or the command basename for stdio. Falls back to - * "server" so the id is always a non-empty `mcpServers` map key. - */ -function deriveSeedServerId(config: MCPServerConfig): string { - if (config.type === "sse" || config.type === "streamable-http") { - try { - return new URL(config.url).host || "server"; - } catch { - return "server"; - } - } - return config.command?.split(/[\\/]/).pop() || "server"; -} - export async function runWeb(argv: string[]): Promise { const program = new Command(); @@ -256,7 +241,7 @@ export async function runWeb(argv: string[]): Promise { entry.protocolEra = opts.protocolEra; } initialServers = { - mcpServers: { [deriveSeedServerId(config)]: entry }, + mcpServers: { [adHocServerName(config)]: entry }, }; writable = false; } else if (hasHeaders) { diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index d55c063b07..fb6bf32572 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -12,6 +12,7 @@ import { isAllInterfacesHost, } from "../../../core/node/hostUrl.ts"; import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js"; +import { ALLOWED_HOSTS_ENV, createHostAllowList } from "./host-allow-list.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -37,6 +38,35 @@ export interface SandboxControllerOptions { * so a failed bind still reports no sandbox rather than a dead URL. */ publicUrl?: string; + /** + * Extra hostnames the listener accepts in `Host` — the main server's + * `MCP_INSPECTOR_ALLOWED_HOSTS` list. The sandbox applies the same `Host` + * allow-list as the main server (#2827): bind host, the hosts of + * `allowedOrigins`, these, plus the host of {@link publicUrl} (the name a + * reverse proxy forwards to this listener). + */ + allowedHosts?: string[]; +} + +/** + * The request paths that serve the proxy page: `/sandbox` or `/sandbox/`, + * ignoring any query string (#2827). A cache-buster or tracking parameter on + * the URL names the same resource, so refusing it 404'd a valid request. + */ +export function isSandboxPath(url: string | undefined): boolean { + const path = (url ?? "").split("?", 1)[0]; + return path === "/sandbox" || path === "/sandbox/"; +} + +/** The hostname of an advertised public URL, or none if it will not parse. */ +function publicUrlHosts(publicUrl: string | undefined): string[] { + if (!publicUrl) return []; + try { + return [new URL(publicUrl).hostname]; + } catch { + // public-address.ts validated it already; an unparsable value admits nothing. + return []; + } } // Loopback fallback. NB: no `http://[::1]:*` — a **bracketed IPv6 literal is not @@ -212,7 +242,22 @@ export function createSandboxController( // `localhost` — a name resolves to one address family and would reintroduce // the #1951 split (web on IPv4, sandbox on IPv6) for any future call site // that omits `host`. Both call sites pass `config.sandboxHost` today. - const { port, host = DEFAULT_BIND_HOST, allowedOrigins, publicUrl } = options; + const { + port, + host = DEFAULT_BIND_HOST, + allowedOrigins, + publicUrl, + allowedHosts, + } = options; + // The same DNS-rebinding defence the main server applies (see + // host-allow-list.ts). The proxy page carries no token, but it is the + // embedder of every MCP App the Inspector renders, so a page under a + // rebinding name has no business loading it either. + const isAllowedHost = createHostAllowList({ + bindHost: host, + allowedOrigins: allowedOrigins ?? [], + extraHosts: [...(allowedHosts ?? []), ...publicUrlHosts(publicUrl)], + }); let server: Server | null = null; let sandboxUrl: string | null = null; // Tracked separately from the URL: an advertised public URL carries the @@ -265,21 +310,29 @@ export function createSandboxController( }; server = createServer((req, res) => { - if ( - req.method !== "GET" || - (req.url !== "/sandbox" && req.url !== "/sandbox/") - ) { + if (!isAllowedHost(req.headers.host)) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end( + `Forbidden: Host "${req.headers.host ?? ""}" is not allowed. To serve the sandbox under this name, add it to ${ALLOWED_HOSTS_ENV} (or to ALLOWED_ORIGINS).`, + ); + return; + } + // HEAD is answered like GET minus the body (RFC 9110 §9.3.2), so a + // probe (`curl -I`) sees the same status and headers a browser does. + const isHead = req.method === "HEAD"; + if ((req.method !== "GET" && !isHead) || !isSandboxPath(req.url)) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found"); return; } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", + "Content-Length": Buffer.byteLength(sandboxHtml), "Cache-Control": "no-store, no-cache, must-revalidate", Pragma: "no-cache", "Content-Security-Policy": SANDBOX_PROXY_CSP, }); - res.end(sandboxHtml); + res.end(isHead ? undefined : sandboxHtml); }); // A taken port used to mean "no sandbox at all", which was tolerable // only because the default was dynamic and therefore never collided. diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 7c3cf4ea45..95fcbbd9f2 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -35,6 +35,15 @@ import type { WebServerHandle } from "./types.js"; export type { WebServerHandle }; +/** + * Whether a request path belongs to the API namespace the SPA fallback must + * not answer: `/api` exactly, or anything under `/api/`. A bare + * `startsWith("/api")` also swallowed `/apixyz` (#2827). + */ +export function isApiPath(path: string): boolean { + return path === "/api" || path.startsWith("/api/"); +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -50,6 +59,7 @@ export async function startHonoServer( host: config.sandboxHost, allowedOrigins: config.allowedOrigins, publicUrl: config.sandboxPublicUrl, + allowedHosts: config.allowedHosts, }); await sandboxController.start(); // The dedicated origin apps declaring `_meta.ui.domain` are served from @@ -152,7 +162,9 @@ export async function startHonoServer( // index.html — not the raw file — so bookmarks and hand-typed reloads at // those paths get the token global too, matching the `/` route. app.get("*", async (c) => { - if (c.req.path.startsWith("/api")) { + // `/api` itself or anything under `/api/` — never a sibling such as + // `/apixyz`, which is an ordinary deep link and gets the SPA (#2827). + if (isApiPath(c.req.path)) { return c.notFound(); } try { diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 079f3fb598..f5677d35bc 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -77,6 +77,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { host: config.sandboxHost, allowedOrigins: config.allowedOrigins, publicUrl: config.sandboxPublicUrl, + allowedHosts: config.allowedHosts, }); await sandboxController.start(); // The dedicated origin apps declaring `_meta.ui.domain` are served from diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 0b2bf229a3..16b048b2ed 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -448,6 +448,35 @@ export function defaultAllowedOrigins( return [webOrigin(scheme, h, port)]; } +/** + * The shortest custom API token accepted without a warning (#2827) — the + * length of a 128-bit secret in hex. The token generated when none is set is + * `randomBytes(32)` in hex (64 characters), so this only ever fires for one an + * operator chose. + */ +export const MIN_RECOMMENDED_AUTH_TOKEN_LENGTH = 32; + +/** + * The startup warning for a custom `MCP_INSPECTOR_API_TOKEN` (or its legacy + * name) shorter than {@link MIN_RECOMMENDED_AUTH_TOKEN_LENGTH}, or `null` when + * there is nothing to say: no custom token (one is generated) or one long + * enough. A warning rather than a refusal — a short token still gates + * `/api/*`, and a deployment that set one deliberately (a test rig, a fixed + * token in a script) keeps working; what it should not do is pass silently, + * since the token is the only thing between a page that can reach the port + * and every MCP server the Inspector can spawn. + */ +export function weakAuthTokenWarning(token: string): string | null { + if (token === "" || token.length >= MIN_RECOMMENDED_AUTH_TOKEN_LENGTH) { + return null; + } + return ( + `Warning: the custom API token (${API_SERVER_ENV_VARS.AUTH_TOKEN}) is ${token.length} character${token.length === 1 ? "" : "s"} long; ` + + `use at least ${MIN_RECOMMENDED_AUTH_TOKEN_LENGTH} random characters (e.g. \`openssl rand -hex 32\`), ` + + `or unset it to have one generated.` + ); +} + /** * Build WebServerConfig from process.env and optional initial MCP server config. * Used by the launcher runner, Vite dev (`buildWebServerConfigFromEnv`), and prod standalone. @@ -491,6 +520,9 @@ export function buildWebServerConfig( (process.env[LEGACY_AUTH_TOKEN_ENV] as string | undefined) ?? ""); + const weakToken = weakAuthTokenWarning(authToken); + if (weakToken) console.warn(weakToken); + const sandboxPort = resolveSandboxPort(); // The app-origin listener starts BEFORE the web server binds (both in prod // `startHonoServer` and in the Vite plugin), so a configured collision is not diff --git a/clients/web/src/components/groups/AppControls/AppControls.tsx b/clients/web/src/components/groups/AppControls/AppControls.tsx index f157cc79d5..3d421ebf9f 100644 --- a/clients/web/src/components/groups/AppControls/AppControls.tsx +++ b/clients/web/src/components/groups/AppControls/AppControls.tsx @@ -12,6 +12,10 @@ import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListCh import { AppListItem } from "../AppListItem/AppListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; +// h2 under the page's visually hidden h1 (#2827), sized as the h4 it was so +// the look is unchanged; sections below it are h3 (axe `heading-order`). +const PanelHeading = Title.withProps({ order: 2, size: "h4" }); + export interface AppControlsProps { tools: Tool[]; selectedName?: string; @@ -55,7 +59,7 @@ export function AppControls({ return ( - MCP Apps ({tools.length}) + MCP Apps ({tools.length})