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..712df66bf8 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -1164,6 +1164,49 @@ 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("keeps the tool selection across an unrelated update such as a stderr line (#2827)", async () => { + h.ctrl.status = "connected"; + h.ctrl.tools = [ + { ...sampleTool, name: "zeta" }, + { ...sampleTool, name: "beta" }, + ]; + const r = await mount(oneStdio()); + await press(r, ["t", TAB, DOWN]); + await expectFrame(r, "▶ zeta"); + // A new stderr array re-derives the server state; the sorted tools must + // keep their identity, or ToolsTab resets the cursor to the top. + h.ctrl.stderrLogs = [stderrLog]; + r.rerender( + , + ); + await tick(); + await tick(); + expect(r.lastFrame() ?? "").toContain("▶ zeta"); + }); + 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..cd88fa4829 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). @@ -697,6 +699,15 @@ function App({ selectedInspectorClient, selectedManagedToolsState, ); + // 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), memoized on that snapshot alone: + // ToolsTab resets its selection when its list's identity changes, so a + // fresh array on an unrelated update (a stderr line) would lose the cursor. + const sortedTools = useMemo( + () => [...managedTools].sort(compareToolsByName), + [managedTools], + ); // Resources, resource templates, prompts from managed state managers const selectedManagedResourcesState = useMemo( @@ -1272,7 +1283,7 @@ function App({ resources: managedResources, resourceTemplates: managedResourceTemplates, prompts: managedPrompts, - tools: managedTools, + tools: sortedTools, stderrLogs: inspectorStderrLogs, // InspectorClient manages this }; }, [ @@ -1286,7 +1297,7 @@ function App({ managedResources, managedResourceTemplates, managedPrompts, - managedTools, + sortedTools, inspectorStderrLogs, ]); 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/public/fonts/Fredoka-OFL.txt b/clients/web/public/fonts/Fredoka-OFL.txt new file mode 100644 index 0000000000..791a98de78 --- /dev/null +++ b/clients/web/public/fonts/Fredoka-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Fredoka Project Authors (https://github.com/hafontia/Fredoka-One) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/clients/web/public/fonts/RobotoMono-OFL.txt b/clients/web/public/fonts/RobotoMono-OFL.txt new file mode 100644 index 0000000000..8e7df338d3 --- /dev/null +++ b/clients/web/public/fonts/RobotoMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. 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})