fix(web): guard the app-origin port and the trusted-origin grant

Copilot review round 3 on #2100.

- MCP_APP_ORIGIN_PORT could equal CLIENT_PORT or MCP_SANDBOX_PORT. The
  app-origin listener starts BEFORE the web server binds, so that is not a
  race it loses — it wins the port and the Inspector dies with EADDRINUSE.
  `CLIENT_PORT=6278` was a valid config before this feature existed. It now
  resolves to a dynamic port with a warning: of the three listeners this is
  the only one that can move, and refusing to boot would cost the user the
  whole Inspector over a feature they may not be using.
- The proxy's same-origin refusal covered only its own origin. An app served
  from the INSPECTOR's origin is no safer: same-origin with the top document
  means direct access to window.top, its DOM and its storage, and the
  cross-origin frame in between buys nothing. Both trusted origins are now
  refused before allow-same-origin is granted.
- Replaced the `as unknown as number` on `server.address()` with a real
  narrowing of the string/null members of its return type.
- MCP_APP_ORIGIN_PORT added to the v1→v2 migration guide's env table, its
  Docker recipe, and a troubleshooting entry for the symptom this actually
  produces (the app renders, but its own backend rejects `Origin: null`).
- clients/web/README.md now states which failure is outside the fallback
  guarantee — a published document the browser cannot reach — and why there
  is no honest signal to fall back on. Docs and code no longer disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-24 00:35:21 -04:00 • committed by GitHub
commit bb3ba91b8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -314,6 +314,42 @@ describe("buildWebServerConfigFromEnv", () => {
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
});
it.each([
["the web server", "CLIENT_PORT", "the web server (CLIENT_PORT)"],
[
"the sandbox",
"MCP_SANDBOX_PORT",
"the MCP Apps sandbox (MCP_SANDBOX_PORT)",
],
])(
"yields a dynamic app-origin port when it collides with %s",
(_label, envVar, expectedReason) => {
// The app-origin listener starts BEFORE the web server binds, so a
// configured collision is not a race it loses — it WINS the port and the
// Inspector then dies with EADDRINUSE. `CLIENT_PORT=6278` was a valid
// config before this feature existed.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
process.env[envVar] = "6500";
process.env.MCP_APP_ORIGIN_PORT = "6500";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(0);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(expectedReason),
);
} finally {
warnSpy.mockRestore();
}
},
);
it("leaves an explicit dynamic app-origin port alone", () => {
// 0 means "OS-assigned" for both, so two zeros are not a collision.
process.env.CLIENT_PORT = "6274";
process.env.MCP_SANDBOX_PORT = "0";
process.env.MCP_APP_ORIGIN_PORT = "0";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(0);
});
it("sets MCP_STORAGE_DIR when present", () => {
process.env.MCP_STORAGE_DIR = "/tmp/storage";
expect(buildWebServerConfigFromEnv().storageDir).toBe("/tmp/storage");