fix(gateway): preserve standalone MCP app HEAD response metadata (#120905)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
zengLingbiao
2026-08-25 16:59:42 +08:00
committed by GitHub
parent e5cc19185b
commit 6f4b9a28cf
2 changed files with 115 additions and 35 deletions
+80 -1
View File
@@ -1,4 +1,5 @@
import type { IncomingMessage } from "node:http";
import { createServer, type IncomingMessage } from "node:http";
import type { AddressInfo } from "node:net";
import { Readable } from "node:stream";
import { runInNewContext } from "node:vm";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -221,6 +222,84 @@ describe("MCP App standalone host", () => {
);
});
it.each([
{ label: "public shell", path: "/__openclaw__/mcp-app", expectedStatus: 200 },
{
label: "authenticated multibyte view",
path: "/__openclaw__/mcp-app/view",
expectedStatus: 200,
authorized: true,
},
{
label: "unauthorized view",
path: "/__openclaw__/mcp-app/view",
expectedStatus: 401,
},
{
label: "saturated view",
path: "/__openclaw__/mcp-app/view",
expectedStatus: 429,
authorized: true,
saturated: true,
},
])(
"keeps GET and HEAD metadata aligned over HTTP for $label",
async ({ path, expectedStatus, authorized, saturated }) => {
const originalHtml = view.html;
view.html = "<!doctype html><p>caf\u00e9 \ud83e\udd9e</p>";
const ticket = authorized
? issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret }).ticket
: undefined;
view.activeRequests = saturated ? 4 : 0;
const server = createServer((req, res) => {
void handleMcpAppStandaloneHttpRequest(req, res, {
sandboxPort: 18_790,
nowMs,
ticketSecret: secret,
}).catch((error: unknown) => {
res.statusCode = 500;
res.end(String(error));
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
try {
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const headers = ticket ? { Authorization: `MCP-App ${ticket}` } : undefined;
const get = await fetch(`${origin}${path}`, { headers });
const body = Buffer.from(await get.arrayBuffer());
const head = await fetch(`${origin}${path}`, { method: "HEAD", headers });
expect(get.status).toBe(expectedStatus);
expect(head.status).toBe(expectedStatus);
expect(get.headers.get("content-length")).toBe(String(body.byteLength));
expect(head.headers.get("content-length")).toBe(String(body.byteLength));
expect((await head.arrayBuffer()).byteLength).toBe(0);
expect(head.headers.get("cache-control")).toBe("no-store");
if (path.endsWith("/view")) {
expect(head.headers.get("vary")).toBe("Authorization");
}
if (expectedStatus === 401) {
expect(head.headers.get("www-authenticate")).toBe("MCP-App");
}
if (authorized && !saturated) {
expect(body.toString()).toContain("caf\u00e9 \ud83e\udd9e");
expect(JSON.parse(body.toString())).toMatchObject({ operationTimeoutMs: 65_000 });
}
} finally {
view.html = originalHtml;
view.activeRequests = 0;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
},
);
it("executes serialized fetch deadlines with visible outcomes", async () => {
const shell = await request({ url: "/__openclaw__/mcp-app" });
const html = String(shell.end.mock.calls[0]?.[0]);
+35 -34
View File
@@ -6,6 +6,7 @@ import { buildMcpAppSandboxPath, resolveMcpAppSandboxPort } from "../agents/mcp-
import { getMcpAppViewLease, type McpAppViewLease } from "../agents/mcp-ui-resource.js";
import { formatErrorMessage } from "../infra/errors.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { respondPlainText } from "./control-ui-http-utils.js";
import {
classifyMcpAppStandalonePath,
MCP_APP_STANDALONE_PATH,
@@ -210,10 +211,17 @@ function supportsStandaloneToolOperations(
return view.allowedAppToolNames !== undefined && view.readOnly !== true;
}
function sendText(res: ServerResponse, statusCode: number, body: string): void {
function sendJsonRepresentation(
req: IncomingMessage,
res: ServerResponse,
statusCode: number,
body: unknown,
): void {
const serialized = JSON.stringify(body);
res.statusCode = statusCode;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(body);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Content-Length", String(Buffer.byteLength(serialized)));
res.end(req.method === "HEAD" ? undefined : serialized);
}
function runStandaloneMcpAppHost(config: {
@@ -593,20 +601,20 @@ export async function handleMcpAppStandaloneHttpRequest(
req.method !== "HEAD" &&
!(url.pathname === MCP_APP_STANDALONE_VIEW_PATH && req.method === "POST")
) {
sendText(res, 404, "Not Found");
respondPlainText(res, 404, "Not Found");
return true;
}
const gatewayPort = options.gatewayPort ?? req.socket.localPort;
if (!gatewayPort) {
sendText(res, 503, "MCP App host unavailable");
respondPlainText(res, 503, "MCP App host unavailable");
return true;
}
let sandboxPort: number;
try {
sandboxPort = resolveMcpAppSandboxPort(gatewayPort, options.sandboxPort);
} catch {
sendText(res, 503, "MCP App host unavailable");
respondPlainText(res, 503, "MCP App host unavailable");
return true;
}
@@ -622,6 +630,7 @@ export async function handleMcpAppStandaloneHttpRequest(
const shell = standaloneHostHtml();
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Content-Length", String(Buffer.byteLength(shell.html)));
res.setHeader(
"Content-Security-Policy",
`default-src 'none'; script-src 'sha256-${shell.scriptHash}'; style-src 'unsafe-inline'; connect-src 'self'; frame-src ${frameOrigin}; base-uri 'none'; form-action 'none'; object-src 'none'`,
@@ -639,7 +648,7 @@ export async function handleMcpAppStandaloneHttpRequest(
const active = ticket ? resolveTicketActiveView(ticket, nowMs, secret) : undefined;
if (!active) {
res.setHeader("WWW-Authenticate", "MCP-App");
sendText(res, 401, "Unauthorized");
respondPlainText(res, 401, "Unauthorized");
return true;
}
if (req.method === "POST") {
@@ -678,36 +687,28 @@ export async function handleMcpAppStandaloneHttpRequest(
try {
return await withMcpAppActiveView(active, "read", () => {
const { runtime, view } = active;
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
req.method === "HEAD"
? undefined
: JSON.stringify({
sandboxUrl: buildMcpAppSandboxPath(view.csp),
sandboxPort,
...(options.sandboxOrigin
? { sandboxOrigin: new URL(options.sandboxOrigin).origin }
: {}),
html: view.html,
...(view.csp ? { csp: view.csp } : {}),
toolInput: view.toolInput,
toolResult: view.toolResult,
serverTools: supportsStandaloneToolOperations(view),
serverResources: runtime.readResource !== undefined,
...(view.requestTimeoutMs !== undefined
? {
// Keep the browser's outer deadline behind the SDK request
// so a valid near-deadline response can reach the App.
operationTimeoutMs: addTimerTimeoutGraceMs(view.requestTimeoutMs),
}
: {}),
}),
);
sendJsonRepresentation(req, res, 200, {
sandboxUrl: buildMcpAppSandboxPath(view.csp),
sandboxPort,
...(options.sandboxOrigin ? { sandboxOrigin: new URL(options.sandboxOrigin).origin } : {}),
html: view.html,
...(view.csp ? { csp: view.csp } : {}),
toolInput: view.toolInput,
toolResult: view.toolResult,
serverTools: supportsStandaloneToolOperations(view),
serverResources: runtime.readResource !== undefined,
...(view.requestTimeoutMs !== undefined
? {
// Keep the browser's outer deadline behind the SDK request
// so a valid near-deadline response can reach the App.
operationTimeoutMs: addTimerTimeoutGraceMs(view.requestTimeoutMs),
}
: {}),
});
return true;
});
} catch (error) {
sendJson(res, 429, { ok: false, error: formatErrorMessage(error) });
sendJsonRepresentation(req, res, 429, { ok: false, error: formatErrorMessage(error) });
return true;
}
}