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; } }