diff --git a/src/gateway/mcp-app-standalone.test.ts b/src/gateway/mcp-app-standalone.test.ts
index 7b4e72af7b91..7301e1580ca9 100644
--- a/src/gateway/mcp-app-standalone.test.ts
+++ b/src/gateway/mcp-app-standalone.test.ts
@@ -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 = "
caf\u00e9 \ud83e\udd9e
";
+ 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((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((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]);
diff --git a/src/gateway/mcp-app-standalone.ts b/src/gateway/mcp-app-standalone.ts
index 3734c0d71b49..f2eec734b62d 100644
--- a/src/gateway/mcp-app-standalone.ts
+++ b/src/gateway/mcp-app-standalone.ts
@@ -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;
}
}