Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ inspector/
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions)
│ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab)
│ ├── cli/ # CLI client
Expand Down Expand Up @@ -66,6 +67,16 @@ inspector/

* The InspectorClient from v1.5/main will be merged into v2/main, and wired up to the new web Inspector. The TUI and CLI will follow. Eventually when everything works on v2/main we will replace main with v2/main, eliminating the legacy implementations.

## Web backend auth token

The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The browser recovers that token from three sources, in priority order (see `App.tsx` `getAuthToken()`):

1. `window.__INSPECTOR_API_TOKEN__` — injected into `index.html` on every page load by the backend (the dev Vite plugin via `transformIndexHtml`, the prod Hono server on the `/` route), both routed through `clients/web/server/inject-auth-token.ts`. This is what makes a bare-URL reload, a bookmark, or a cleared `sessionStorage` keep working.
2. `?MCP_INSPECTOR_API_TOKEN=…` query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs.
3. `sessionStorage` — backstop for navigations that land without either of the above.

Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`.

## Maintenance Rules

### Keep documentation files up to date
Expand Down
51 changes: 51 additions & 0 deletions clients/web/server/inject-auth-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Embed the remote API auth token into the served `index.html` so the browser
* doesn't depend on the `?MCP_INSPECTOR_API_TOKEN=…` query string surviving
* navigation, bookmarks, or a hand-typed reload at the bare URL. The dev Vite
* plugin (`vite-hono-plugin.ts`) and the prod Hono server (`server.ts`) both
* funnel through this single helper so the injected shape stays identical
* across both backends. `App.tsx`'s `getAuthToken()` reads the embedded global
* ahead of the URL / sessionStorage fallbacks.
*/

import { INSPECTOR_API_TOKEN_GLOBAL } from "../../../core/mcp/remote/constants.ts";

/**
* Serialize the token as a JS string literal safe to drop inside an inline
* `<script>`. `JSON.stringify` handles quotes / backslashes / control chars;
* the extra `<` → `<` escape closes the one hole that matters in an HTML
* context — a token containing the literal `</script>` would otherwise close
* the tag early. The token is normally a hex string, but it can be a
* user-supplied value (`MCP_INSPECTOR_API_TOKEN` env / `--auth-token`), so we
* don't assume it's benign.
*/
function serializeTokenForScript(token: string): string {
return JSON.stringify(token).replace(/</g, "\\u003c");
}

/**
* Return `html` with a `<script>window.__INSPECTOR_API_TOKEN__ = "…"</script>`
* tag injected. The script is placed just before `</head>` when present, else
* just before `</body>`, else prepended — in every case it runs before the app
* bundle (which lives further down the document) so the global is set by the
* time `getAuthToken()` reads it.
*
* An empty `token` (auth disabled via `DANGEROUSLY_OMIT_AUTH`) is a no-op: the
* page is returned untouched and no global is defined, matching the banner's
* "no token in the URL" behavior.
*/
export function injectAuthToken(html: string, token: string): string {
if (!token) return html;
const script = `<script>window.${INSPECTOR_API_TOKEN_GLOBAL} = ${serializeTokenForScript(
token,
)};</script>`;
const headClose = html.indexOf("</head>");
if (headClose !== -1) {
return html.slice(0, headClose) + script + html.slice(headClose);
}
const bodyClose = html.indexOf("</body>");
if (bodyClose !== -1) {
return html.slice(0, bodyClose) + script + html.slice(bodyClose);
}
return script + html;
}
50 changes: 35 additions & 15 deletions clients/web/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import open from "open";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import type { Context } from "hono";
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
import { createSandboxController } from "./sandbox-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
import {
webServerConfigToInitialPayload,
Expand Down Expand Up @@ -60,29 +62,47 @@ export async function startHonoServer(
return apiApp.fetch(c.req.raw);
});

// Serve index.html with the API token injected so a reload at any bare URL
// (no `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates against /api/*.
// No-op when auth is dangerously omitted (empty token). The dev Vite plugin
// applies the same injection via `transformIndexHtml`. `Cache-Control:
// no-store` keeps a browser/proxy from serving a page that carries a stale
// token after a server restart regenerates it (randomBytes per start).
const serveIndexHtml = (c: Context) => {
const indexPath = join(rootPath, "index.html");
const html = readFileSync(indexPath, "utf-8");
c.header("Cache-Control", "no-store");
return c.html(injectAuthToken(html, resolvedAuthToken));
};

app.get("/", async (c) => {
try {
const indexPath = join(rootPath, "index.html");
const html = readFileSync(indexPath, "utf-8");
return c.html(html);
return serveIndexHtml(c);
} catch (error) {
console.error("Error serving index.html:", error);
return c.notFound();
}
});

app.use(
"/*",
serveStatic({
root: rootPath,
rewriteRequestPath: (path) => {
if (!path.includes(".") && !path.startsWith("/api")) {
return "/index.html";
}
return path;
},
}),
);
// Real static assets (paths with a file extension). Missing files fall
// through to the SPA fallback below via `next()`.
app.use("/*", serveStatic({ root: rootPath }));

// SPA deep-link fallback: any non-/api route that didn't resolve to a static
// asset (e.g. `/oauth/callback`, the OAuth landing URL) serves the *injected*
// 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")) {
return c.notFound();
}
try {
return serveIndexHtml(c);
} catch (error) {
console.error("Error serving index.html:", error);
return c.notFound();
}
});

const httpServer = serve(
{
Expand Down
17 changes: 17 additions & 0 deletions clients/web/server/vite-hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,28 @@ import open from "open";
// spurious "could not resolve" warnings during build.
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
import { createSandboxController } from "./sandbox-controller.js";
import { injectAuthToken } from "./inject-auth-token.js";
import type { WebServerConfig } from "./web-server-config.js";
import {
webServerConfigToInitialPayload,
printServerBanner,
} from "./web-server-config.js";

export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
// Resolved once `configureServer` runs (createRemoteApp generates a token
// when none is supplied). Captured here so the `transformIndexHtml` hook —
// which fires per index.html request, after `configureServer` — can embed it
// into the served page. Stays "" when auth is dangerously omitted or under
// Vitest (where `configureServer` returns early), making injection a no-op.
let resolvedAuthToken = "";
return {
name: "hono-api-middleware",
// Embed the API token into the dev-served index.html so a reload at the
// bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates. The
// prod server applies the same injection in `server.ts`.
transformIndexHtml(html) {
return injectAuthToken(html, resolvedAuthToken);
},
// `apply: 'serve'` keeps the plugin out of `vite build`, but Vitest still
// instantiates a Vite server in middleware mode (no HTTP server) for
// transforms and invokes `configureServer` regardless. Returning early
Expand Down Expand Up @@ -67,6 +80,10 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
initialConfig: webServerConfigToInitialPayload(config),
});

// Expose the resolved token to `transformIndexHtml`. Left empty when
// auth is dangerously omitted so the page carries no token global.
resolvedAuthToken = config.dangerouslyOmitAuth ? "" : resolvedToken;

// Chain the API close (mcp.json watcher) and the sandbox into the
// Vite server's close so dev-server restarts release both resources.
const originalClose = server.close.bind(server);
Expand Down
50 changes: 36 additions & 14 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import type {
ServerEntry,
ServerType,
} from "@inspector/core/mcp/types.js";
import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js";
import {
API_SERVER_ENV_VARS,
INSPECTOR_API_TOKEN_GLOBAL,
} from "@inspector/core/mcp/remote/constants.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js";
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
Expand Down Expand Up @@ -65,25 +68,44 @@ const redirectUrlProvider: RedirectUrlProvider = {
getRedirectUrl: () => `${window.location.origin}/oauth/callback`,
};

// Pull the dev-backend's auth token off the URL the launcher banner prints.
// `npm run dev` opens `http://localhost:6274?MCP_INSPECTOR_API_TOKEN=…`;
// every browser request to /api/* needs the same token in the
// `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401.
// Persist to sessionStorage so SPA navigations / OAuth round-trips don't
// drop the token from the URL bar.
// 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.
function getAuthToken(): string | undefined {
if (typeof window === "undefined") return undefined;
const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN;
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN);
if (fromUrl) {
// 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, fromUrl);
window.sessionStorage.setItem(STORAGE_KEY, token);
} catch {
// Best-effort persistence — sessionStorage may be unavailable
// (privacy mode, iframe sandboxing, etc.); the URL value still
// works for the current page load.
// ignore — see note above
}
};
const fromGlobal = (window as unknown as Record<string, unknown>)[
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 {
Expand Down
67 changes: 67 additions & 0 deletions clients/web/src/test/integration/server/inject-auth-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { injectAuthToken } from "../../../../server/inject-auth-token.js";
import { INSPECTOR_API_TOKEN_GLOBAL } from "../../../../../../core/mcp/remote/constants.js";

const TOKEN = "deadbeefcafef00d";
const scriptFor = (token: string) =>
`window.${INSPECTOR_API_TOKEN_GLOBAL} = ${JSON.stringify(token)};`;

describe("injectAuthToken", () => {
it("injects the token global just before </head>", () => {
const html = "<html><head><title>X</title></head><body></body></html>";
const out = injectAuthToken(html, TOKEN);
expect(out).toContain(scriptFor(TOKEN));
// The script must land inside <head>, ahead of the closing tag.
const scriptIdx = out.indexOf(scriptFor(TOKEN));
const headCloseIdx = out.indexOf("</head>");
expect(scriptIdx).toBeLessThan(headCloseIdx);
expect(scriptIdx).toBeGreaterThan(out.indexOf("<head>"));
});

it("falls back to before </body> when there is no </head>", () => {
const html = "<html><body><div id='root'></div></body></html>";
const out = injectAuthToken(html, TOKEN);
const scriptIdx = out.indexOf(scriptFor(TOKEN));
expect(scriptIdx).toBeGreaterThan(-1);
expect(scriptIdx).toBeLessThan(out.indexOf("</body>"));
});

it("prepends when there is neither </head> nor </body>", () => {
const html = "<div id='root'></div>";
const out = injectAuthToken(html, TOKEN);
expect(out.startsWith(`<script>${scriptFor(TOKEN)}</script>`)).toBe(true);
expect(out.endsWith(html)).toBe(true);
});

it("returns the html untouched for an empty token (auth disabled)", () => {
const html = "<html><head></head><body></body></html>";
expect(injectAuthToken(html, "")).toBe(html);
});

it("escapes a token containing </script> so the tag can't close early", () => {
const evil = "abc</script><script>alert(1)</script>";
const out = injectAuthToken("<head></head>", evil);
// The raw, unescaped sequence must not survive into the output.
expect(out).not.toContain("</script><script>alert(1)");
// The `<` of the embedded literal is escaped to its JS unicode form.
expect(out).toContain("\\u003c/script>");
// Exactly one opening + one closing script tag (our wrapper) remain.
expect(out.match(/<script>/g)).toHaveLength(1);
expect(out.match(/<\/script>/g)).toHaveLength(1);
});

it("round-trips the token value through JSON so the browser reads it back", () => {
const html = "<head></head>";
const out = injectAuthToken(html, TOKEN);
// Recover the JSON literal the browser would evaluate and confirm it
// parses back to the original token.
const match = out.match(
new RegExp(`window\\.${INSPECTOR_API_TOKEN_GLOBAL} = (.+?);</script>`),
);
expect(match).not.toBeNull();
const parsed = JSON.parse(
(match as RegExpMatchArray)[1].replace(/\\u003c/g, "<"),
);
expect(parsed).toBe(TOKEN);
});
});
Loading
Loading