feat: allow standalone MCP Apps to use bound tools and resources (#110515)

* Gateway: enable ticketed MCP App bridge

* Gateway: make MCP App operation switch exhaustive

* refactor(gateway): drop no-op MCP App route change

* fix(gateway): enforce standalone MCP App tool authority

* docs(gateway): clarify standalone MCP App ticket authority

* fix(gateway): require standalone MCP App initialization

* refactor(gateway): keep standalone route load explicit
This commit is contained in:
Jason (Json)
2026-07-18 17:50:37 -06:00
committed by GitHub
parent 8b720729d2
commit 7c070d6df6
6 changed files with 974 additions and 460 deletions
+232
View File
@@ -0,0 +1,232 @@
import {
type CallToolRequest,
CallToolRequestSchema,
type ListResourcesRequest,
ListResourcesRequestSchema,
type ListResourceTemplatesRequest,
ListResourceTemplatesRequestSchema,
type ListToolsRequest,
ListToolsRequestSchema,
type ReadResourceRequest,
ReadResourceRequestSchema,
type Tool,
} from "@modelcontextprotocol/sdk/types.js";
import {
completeDeferredSessionMcpRuntimeRetirement,
peekSessionMcpRuntime,
} from "../agents/agent-bundle-mcp-runtime.js";
import type { McpCatalogTool, SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js";
import {
acquireMcpAppViewRequest,
getMcpAppViewLease,
type McpAppViewLease,
} from "../agents/mcp-ui-resource.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logWarn } from "../logger.js";
import { restoreMcpAppView } from "./mcp-app-reconstruction.js";
export type McpAppActiveView = {
runtime: SessionMcpRuntime;
view: McpAppViewLease;
};
export type McpAppOperation =
| Pick<CallToolRequest, "method" | "params">
| Pick<ListToolsRequest, "method" | "params">
| Pick<ListResourcesRequest, "method" | "params">
| Pick<ListResourceTemplatesRequest, "method" | "params">
| Pick<ReadResourceRequest, "method" | "params">;
function isAppCallableTool(tool: McpCatalogTool): boolean {
return tool.uiVisibility === undefined || tool.uiVisibility.includes("app");
}
function isAppCallableListedTool(tool: Tool): boolean {
const { _meta: metadata } = tool;
const ui =
metadata?.ui && typeof metadata.ui === "object" && !Array.isArray(metadata.ui)
? (metadata.ui as { visibility?: unknown })
: undefined;
const visibility = Array.isArray(ui?.visibility)
? ui.visibility.filter(
(entry): entry is "app" | "model" => entry === "app" || entry === "model",
)
: undefined;
return visibility === undefined || visibility.includes("app");
}
function isAllowedByView(view: McpAppViewLease, toolName: string): boolean {
return view.allowedAppToolNames === undefined || view.allowedAppToolNames.has(toolName);
}
async function requireCallableTool(
runtime: SessionMcpRuntime,
view: McpAppViewLease,
toolName: string,
): Promise<void> {
if (view.readOnly === true) {
throw new Error("MCP App view is read-only");
}
const catalog = await runtime.getCatalog();
const tool = catalog.tools.find(
(entry) => entry.serverName === view.serverName && entry.toolName === toolName,
);
if (!tool || !isAppCallableTool(tool) || !isAllowedByView(view, toolName)) {
throw new Error(`MCP tool "${toolName}" is not app-callable`);
}
}
export async function resolveMcpAppActiveView(params: {
sessionKey: string;
viewId: string;
cfg?: OpenClawConfig;
}): Promise<McpAppActiveView> {
const existingRuntime = peekSessionMcpRuntime({ sessionKey: params.sessionKey });
if (
(existingRuntime && existingRuntime.mcpAppsEnabled !== true) ||
(params.cfg && params.cfg.mcp?.apps?.enabled !== true)
) {
throw new Error("MCP App runtime is unavailable");
}
const existingView = existingRuntime
? getMcpAppViewLease(params.viewId, existingRuntime)
: undefined;
const restored =
existingRuntime?.mcpAppsEnabled === true && existingView
? { runtime: existingRuntime, view: existingView }
: params.cfg
? await restoreMcpAppView({
cfg: params.cfg,
sessionKey: params.sessionKey,
viewId: params.viewId,
})
: undefined;
if (!restored) {
throw new Error("MCP App view expired or is not authorized for this session");
}
return restored;
}
export async function withMcpAppActiveView<T>(
active: McpAppActiveView,
kind: "read" | "tool",
operation: () => Promise<T> | T,
): Promise<T> {
active.runtime.markUsed();
const release = acquireMcpAppViewRequest(active.view, kind);
const releaseRuntimeLease = active.runtime.acquireLease?.();
try {
return await operation();
} finally {
release();
releaseRuntimeLease?.();
await completeDeferredSessionMcpRuntimeRetirement(active.runtime).catch((error: unknown) => {
// A completed app tool call may have side effects. Cleanup failure must
// never turn its successful response into an apparent retryable failure.
logWarn(`mcp-app: deferred runtime cleanup failed: ${formatErrorMessage(error)}`);
});
}
}
export async function executeMcpAppOperation(
active: McpAppActiveView,
operation: McpAppOperation,
): Promise<unknown> {
const { runtime, view } = active;
switch (operation.method) {
case "tools/call":
return await withMcpAppActiveView(active, "tool", async () => {
await requireCallableTool(runtime, view, operation.params.name);
return await runtime.callTool(
view.serverName,
operation.params.name,
operation.params.arguments ?? {},
);
});
case "tools/list":
return await withMcpAppActiveView(active, "read", async () => {
if (!runtime.listTools) {
throw new Error("MCP tools/list is unavailable");
}
const [listed, catalog] = await Promise.all([
runtime.listTools(
view.serverName,
operation.params?.cursor ? { cursor: operation.params.cursor } : undefined,
),
runtime.getCatalog(),
]);
const allowed = new Set(
catalog.tools
.filter(
(tool) =>
tool.serverName === view.serverName &&
isAppCallableTool(tool) &&
isAllowedByView(view, tool.toolName),
)
.map((tool) => tool.toolName),
);
return {
...listed,
tools: listed.tools.filter(
(tool) => allowed.has(tool.name.trim()) && isAppCallableListedTool(tool),
),
};
});
case "resources/list":
return await withMcpAppActiveView(active, "read", async () => {
if (!runtime.listResources) {
throw new Error("MCP resources/list is unavailable");
}
// SessionMcpRuntime aggregates every upstream resources/list page, so
// callers receive the complete list and no nextCursor is exposed.
const resources = await runtime.listResources(view.serverName);
return Array.isArray(resources) ? { resources } : resources;
});
case "resources/templates/list":
return await withMcpAppActiveView(active, "read", async () => {
if (!runtime.listResourceTemplates) {
throw new Error("MCP resources/templates/list is unavailable");
}
return await runtime.listResourceTemplates(
view.serverName,
operation.params?.cursor ? { cursor: operation.params.cursor } : undefined,
);
});
case "resources/read":
return await withMcpAppActiveView(active, "read", async () => {
if (!runtime.readResource) {
throw new Error("MCP resources/read is unavailable");
}
return await runtime.readResource(view.serverName, operation.params.uri);
});
default: {
const unsupported: never = operation;
throw new Error(`Unsupported MCP App operation: ${String(unsupported)}`);
}
}
}
export function parseMcpAppOperation(value: unknown): McpAppOperation | undefined {
const method =
value && typeof value === "object" && !Array.isArray(value)
? (value as { method?: unknown }).method
: undefined;
const schema =
method === "tools/call"
? CallToolRequestSchema
: method === "tools/list"
? ListToolsRequestSchema
: method === "resources/list"
? ListResourcesRequestSchema
: method === "resources/templates/list"
? ListResourceTemplatesRequestSchema
: method === "resources/read"
? ReadResourceRequestSchema
: undefined;
if (!schema) {
return undefined;
}
const parsed = schema.safeParse(value);
return parsed.success ? (parsed.data as McpAppOperation) : undefined;
}
+270 -147
View File
@@ -1,22 +1,27 @@
import type { IncomingMessage } from "node:http";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeMockHttpResponse } from "./test-http-response.js";
const mocks = vi.hoisted(() => ({
completeRetirement: vi.fn(),
getMcpAppViewLease: vi.fn(),
peekSessionMcpRuntime: vi.fn(),
}));
vi.mock("../agents/agent-bundle-mcp-runtime.js", () => ({
completeDeferredSessionMcpRuntimeRetirement: mocks.completeRetirement,
peekSessionMcpRuntime: mocks.peekSessionMcpRuntime,
}));
vi.mock("../agents/mcp-ui-resource.js", () => ({
vi.mock("../agents/mcp-ui-resource.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../agents/mcp-ui-resource.js")>()),
getMcpAppViewLease: mocks.getMcpAppViewLease,
}));
import {
createMcpAppStandaloneTicket,
handleMcpAppStandaloneHttpRequest,
mcpAppStandaloneTesting,
verifyMcpAppStandaloneTicket,
} from "./mcp-app-standalone.js";
@@ -30,218 +35,336 @@ function issueTicket(params: Parameters<typeof createMcpAppStandaloneTicket>[0])
const nowMs = 1_800_000_000_000;
const secret = Buffer.alloc(32, 7);
const runtime = { sessionId: "runtime-session", mcpAppsEnabled: true };
const releaseRuntimeLease = vi.fn();
const runtime = {
sessionId: "runtime-session",
mcpAppsEnabled: true,
markUsed: vi.fn(),
acquireLease: vi.fn(() => releaseRuntimeLease),
getCatalog: vi.fn(async () => ({
tools: [
{ serverName: "demo", toolName: "shared" },
{ serverName: "demo", toolName: "app-only", uiVisibility: ["app"] },
{ serverName: "demo", toolName: "model-only", uiVisibility: ["model"] },
{ serverName: "other", toolName: "cross-only", uiVisibility: ["app"] },
],
})),
callTool: vi.fn(async (serverName: string, toolName: string) => ({
content: [{ type: "text", text: `${serverName}:${toolName}` }],
})),
listTools: vi.fn(async () => ({
tools: [
{ name: "shared", inputSchema: { type: "object" } },
{ name: "app-only", inputSchema: { type: "object" }, _meta: { ui: { visibility: ["app"] } } },
{
name: "model-only",
inputSchema: { type: "object" },
_meta: { ui: { visibility: ["model"] } },
},
],
})),
listResources: vi.fn(async () => [{ uri: "ui://demo/state", name: "state" }]),
listResourceTemplates: vi.fn(async () => ({ resourceTemplates: [] })),
readResource: vi.fn(async (serverName: string, uri: string) => ({
contents: [{ uri, text: `${serverName}:${uri}` }],
})),
};
const view = {
viewId: "mcp-app-view",
sessionId: runtime.sessionId,
runtime,
serverName: "demo",
toolName: "weather",
uiResourceUri: "ui://demo/app",
html: "<!doctype html><p>private fixture</p>",
csp: { connectDomains: ["https://api.example.com"] },
allowedAppToolNames: new Set(["shared", "app-only"]),
toolInput: { city: "Paris" },
toolResult: { content: [{ type: "text", text: "sunny" }] },
expiresAtMs: nowMs + 10 * 60_000,
requestWindowStartedAtMs: nowMs,
requestCount: 0,
toolCallCount: 0,
activeRequests: 0,
byteSize: 100,
};
function request(params: {
async function request(params: {
url: string;
method?: "GET" | "HEAD" | "POST";
authorization?: string;
clock?: () => number;
now?: number;
body?: unknown;
}) {
const { res, end, setHeader } = makeMockHttpResponse();
const handled = handleMcpAppStandaloneHttpRequest(
{
url: params.url,
method: params.method ?? "GET",
headers: params.authorization ? { authorization: params.authorization } : {},
socket: {},
} as IncomingMessage,
res,
{
gatewayPort: 18_789,
sandboxPort: 18_790,
nowMs: params.now ?? nowMs,
ticketSecret: secret,
const serialized = params.body === undefined ? undefined : JSON.stringify(params.body);
const req = Object.assign(Readable.from(serialized === undefined ? [] : [serialized]), {
url: params.url,
method: params.method ?? "GET",
headers: {
...(params.authorization ? { authorization: params.authorization } : {}),
...(serialized ? { "content-type": "application/json" } : {}),
},
);
socket: {},
}) as IncomingMessage;
const handled = await handleMcpAppStandaloneHttpRequest(req, res, {
gatewayPort: 18_789,
sandboxPort: 18_790,
now: params.clock,
nowMs: params.now ?? nowMs,
ticketSecret: secret,
});
return { handled, res, end, setHeader };
}
describe("MCP App standalone host", () => {
beforeEach(() => {
mocks.peekSessionMcpRuntime.mockReset().mockReturnValue(runtime);
mocks.getMcpAppViewLease.mockReset().mockReturnValue(view);
mcpAppStandaloneTesting.clearTickets();
vi.clearAllMocks();
mocks.completeRetirement.mockResolvedValue(undefined);
Object.assign(view, {
allowedAppToolNames: new Set(["shared", "app-only"]),
readOnly: undefined,
requestWindowStartedAtMs: nowMs,
requestCount: 0,
toolCallCount: 0,
activeRequests: 0,
});
mocks.peekSessionMcpRuntime.mockReturnValue(runtime);
mocks.getMcpAppViewLease.mockReturnValue(view);
});
it("mints a short-lived opaque ticket bound to the runtime session and view", () => {
const issued = issueTicket({
sessionKey: "agent:main:main",
view,
nowMs,
secret,
});
it("mints an opaque ticket bound to the session, runtime, view, and lease", () => {
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
expect(issued.ticket).toMatch(/^v1\.[A-Za-z0-9_-]+\.\d+\.[A-Za-z0-9_-]+$/u);
expect(issued.ticket).not.toContain("agent:main:main");
expect(issued.expiresAtMs).toBe(nowMs + 2 * 60_000);
expect(
issueTicket({
sessionKey: "agent:main:main",
view,
nowMs: nowMs + 1,
secret,
}),
).toEqual(issued);
const refreshed = issueTicket({
sessionKey: "agent:main:main",
view,
nowMs: issued.expiresAtMs - 10_000,
secret,
});
expect(refreshed.ticket).not.toBe(issued.ticket);
expect(refreshed.expiresAtMs).toBeGreaterThan(issued.expiresAtMs);
expect(issueTicket({ sessionKey: "agent:main:main", view, nowMs: nowMs + 1, secret })).toEqual(
issued,
);
expect(
verifyMcpAppStandaloneTicket(issued.ticket, {
nowMs: issued.expiresAtMs - 10_000,
sessionKey: "agent:main:main",
sessionId: runtime.sessionId,
viewId: view.viewId,
nowMs,
secret,
}),
).toBeDefined();
for (const expected of [
{ sessionKey: "agent:other:main" },
{ sessionId: "other-runtime" },
{ viewId: "mcp-app-other" },
]) {
expect(
verifyMcpAppStandaloneTicket(issued.ticket, { ...expected, nowMs, secret }),
).toBeUndefined();
}
expect(
verifyMcpAppStandaloneTicket(issued.ticket, {
sessionKey: "agent:main:main",
sessionId: runtime.sessionId,
viewId: view.viewId,
nowMs,
secret,
}),
).toMatchObject({ sessionKey: "agent:main:main", viewId: view.viewId });
expect(
verifyMcpAppStandaloneTicket(issued.ticket, {
sessionKey: "agent:other:main",
sessionId: runtime.sessionId,
viewId: view.viewId,
nowMs,
secret,
}),
verifyMcpAppStandaloneTicket(`${issued.ticket.slice(0, -1)}x`, { nowMs, secret }),
).toBeUndefined();
expect(
verifyMcpAppStandaloneTicket(issued.ticket, {
sessionKey: "agent:main:main",
sessionId: "other-runtime",
viewId: view.viewId,
nowMs,
secret,
}),
).toBeUndefined();
expect(
verifyMcpAppStandaloneTicket(issued.ticket, {
sessionKey: "agent:main:main",
sessionId: runtime.sessionId,
viewId: "mcp-app-other",
nowMs,
secret,
}),
).toBeUndefined();
expect(
verifyMcpAppStandaloneTicket(`${issued.ticket.slice(0, -1)}x`, {
nowMs,
secret,
}),
).toBeUndefined();
expect(
verifyMcpAppStandaloneTicket(issued.ticket, {
nowMs: issued.expiresAtMs + 1,
secret,
}),
verifyMcpAppStandaloneTicket(issued.ticket, { nowMs: issued.expiresAtMs + 1, secret }),
).toBeUndefined();
});
it("never lets a ticket outlive the view lease", () => {
const shortLived = issueTicket({
sessionKey: "agent:main:main",
view: { ...view, expiresAtMs: nowMs + 1_000 },
nowMs,
secret,
});
expect(shortLived.expiresAtMs).toBe(nowMs + 1_000);
expect(
issueTicket({
sessionKey: "agent:main:main",
view: { ...view, expiresAtMs: nowMs + 1_000 },
nowMs: nowMs + 500,
secret,
}),
).toEqual(shortLived);
it("bounds ticket lifetime and omits issuance at capacity", () => {
const shortView = { ...view, expiresAtMs: nowMs + 1_000 };
expect(issueTicket({ sessionKey: "short", view: shortView, nowMs, secret }).expiresAtMs).toBe(
nowMs + 1_000,
);
mcpAppStandaloneTesting.clearTickets();
for (let index = 0; index < 256; index += 1) {
expect(
createMcpAppStandaloneTicket({
sessionKey: `agent:${index}`,
view: { ...view, viewId: `mcp-app-${index}` },
nowMs,
secret,
}),
).toBeDefined();
}
expect(
createMcpAppStandaloneTicket({
sessionKey: "agent:main:main",
view: { ...view, expiresAtMs: nowMs },
sessionKey: "agent:overflow",
view: { ...view, viewId: "mcp-app-overflow" },
nowMs,
secret,
}),
).toBeUndefined();
});
it("serves a static shell without embedding tickets or per-view data", () => {
const result = request({ url: "/__openclaw__/mcp-app" });
it("serves a hash-protected static shell without per-view data", async () => {
const result = await request({ url: "/__openclaw__/mcp-app" });
expect(result.handled).toBe(true);
expect(result.res.statusCode).toBe(200);
const body = String(result.end.mock.calls[0]?.[0]);
expect(body).toContain("location.hash");
expect(body).not.toContain("serverTools");
expect(body).not.toContain("serverResources");
expect(body).toContain("event.origin");
expect(body).toContain("if (!initializeAccepted)");
expect(body).not.toContain('postMessage(message, "*")');
expect(body).not.toContain(view.html);
expect(body).not.toContain("agent:main:main");
expect(result.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store");
expect(result.setHeader).toHaveBeenCalledWith(
"Content-Security-Policy",
expect.stringContaining("connect-src 'self'"),
expect.stringMatching(/script-src 'sha256-[^']+';.*connect-src 'self'/u),
);
});
it("returns view data only for a valid ticket backed by the same live view", () => {
const issued = issueTicket({
sessionKey: "agent:main:main",
view,
nowMs,
secret,
});
it("returns capabilities only for handlers installed on the live view", async () => {
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
const route = "/__openclaw__/mcp-app/view";
expect(request({ url: route }).res.statusCode).toBe(401);
expect(request({ url: `${route}?ticket=${issued.ticket}` }).res.statusCode).toBe(401);
expect(
request({ url: route, authorization: `MCP-App ${issued.ticket.slice(0, -1)}x` }).res
.statusCode,
).toBe(401);
const accepted = request({ url: route, authorization: `MCP-App ${issued.ticket}` });
expect((await request({ url: route })).res.statusCode).toBe(401);
expect((await request({ url: `${route}?ticket=${issued.ticket}` })).res.statusCode).toBe(401);
const accepted = await request({ url: route, authorization: `MCP-App ${issued.ticket}` });
expect(accepted.res.statusCode).toBe(200);
const payload = JSON.parse(String(accepted.end.mock.calls[0]?.[0]));
expect(payload).toMatchObject({
expect(JSON.parse(String(accepted.end.mock.calls[0]?.[0]))).toMatchObject({
html: view.html,
toolInput: view.toolInput,
toolResult: view.toolResult,
sandboxPort: 18_790,
serverTools: true,
serverResources: true,
});
expect(payload.sandboxUrl).toMatch(/^\/mcp-app-sandbox\?csp=/u);
expect(accepted.setHeader).toHaveBeenCalledWith("Vary", "Authorization");
// Reloads within the ticket and view lease are appropriate reuse.
expect(request({ url: route, authorization: `MCP-App ${issued.ticket}` }).res.statusCode).toBe(
200,
);
expect(
(await request({ url: route, authorization: `MCP-App ${issued.ticket}` })).res.statusCode,
).toBe(200);
mocks.getMcpAppViewLease.mockReturnValue({ ...view, viewId: "mcp-app-replaced" });
expect(request({ url: route, authorization: `MCP-App ${issued.ticket}` }).res.statusCode).toBe(
401,
);
expect(
(await request({ url: route, authorization: `MCP-App ${issued.ticket}` })).res.statusCode,
).toBe(401);
});
it("keeps the standalone surface read-only and path-scoped", () => {
expect(request({ url: "/__openclaw__/mcp-app", method: "POST" }).res.statusCode).toBe(404);
expect(request({ url: "/__openclaw__/mcp-app/other" }).handled).toBe(false);
it("executes only owning-server app-visible allowed tools and resources", async () => {
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
const invoke = (body: unknown) =>
request({
url: "/__openclaw__/mcp-app/view",
method: "POST",
authorization: `MCP-App ${issued.ticket}`,
body,
});
const tool = await invoke({
method: "tools/call",
params: { name: "app-only", arguments: {} },
});
expect(tool.res.statusCode).toBe(200);
expect(runtime.callTool).toHaveBeenCalledWith("demo", "app-only", {});
const resource = await invoke({ method: "resources/read", params: { uri: "ui://demo/state" } });
expect(resource.res.statusCode).toBe(200);
expect(runtime.readResource).toHaveBeenCalledWith("demo", "ui://demo/state");
for (const name of ["model-only", "not-allowed", "cross-only"]) {
expect(
(await invoke({ method: "tools/call", params: { name, arguments: {} } })).res.statusCode,
).toBe(403);
}
expect(runtime.callTool).toHaveBeenCalledTimes(1);
expect(releaseRuntimeLease).toHaveBeenCalled();
expect(mocks.completeRetirement).toHaveBeenCalledWith(runtime);
});
it("keeps reconstructed views read-only while preserving resource reads", async () => {
Object.assign(view, { readOnly: true });
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
const invoke = (body: unknown) =>
request({
url: "/__openclaw__/mcp-app/view",
method: "POST",
authorization: `MCP-App ${issued.ticket}`,
body,
});
expect(
(await invoke({ method: "tools/call", params: { name: "app-only", arguments: {} } })).res
.statusCode,
).toBe(403);
expect(
(await invoke({ method: "resources/read", params: { uri: "ui://demo/state" } })).res
.statusCode,
).toBe(200);
});
it("does not accept standalone tool operations without explicit run authority", async () => {
Object.assign(view, { allowedAppToolNames: undefined });
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
const invoke = (body: unknown) =>
request({
url: "/__openclaw__/mcp-app/view",
method: "POST",
authorization: `MCP-App ${issued.ticket}`,
body,
});
expect((await invoke({ method: "tools/list", params: {} })).res.statusCode).toBe(403);
expect(
(await invoke({ method: "tools/call", params: { name: "app-only", arguments: {} } })).res
.statusCode,
).toBe(403);
expect(
(await invoke({ method: "resources/read", params: { uri: "ui://demo/state" } })).res
.statusCode,
).toBe(200);
expect(runtime.callTool).not.toHaveBeenCalled();
});
it("revalidates expiry and enforces request concurrency through the ticket boundary", async () => {
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
const invoke = (now: number) =>
request({
url: "/__openclaw__/mcp-app/view",
method: "POST",
authorization: `MCP-App ${issued.ticket}`,
now,
body: { method: "resources/list", params: {} },
});
view.activeRequests = 4;
expect(
(
await request({
url: "/__openclaw__/mcp-app/view",
authorization: `MCP-App ${issued.ticket}`,
now: nowMs,
})
).res.statusCode,
).toBe(429);
expect((await invoke(nowMs)).res.statusCode).toBe(403);
view.activeRequests = 0;
expect((await invoke(issued.expiresAtMs + 1)).res.statusCode).toBe(401);
const clock = vi
.fn<() => number>()
.mockReturnValueOnce(nowMs)
.mockReturnValueOnce(issued.expiresAtMs + 1);
expect(
(
await request({
url: "/__openclaw__/mcp-app/view",
method: "POST",
authorization: `MCP-App ${issued.ticket}`,
clock,
body: { method: "resources/list", params: {} },
})
).res.statusCode,
).toBe(401);
expect(clock).toHaveBeenCalledTimes(2);
});
it("is path-scoped and rejects malformed operations", async () => {
const issued = issueTicket({ sessionKey: "agent:main:main", view, nowMs, secret });
expect((await request({ url: "/__openclaw__/mcp-app", method: "POST" })).res.statusCode).toBe(
404,
);
expect((await request({ url: "/__openclaw__/mcp-app/other" })).handled).toBe(false);
expect(
(
await request({
url: "/__openclaw__/mcp-app/view",
method: "POST",
authorization: `MCP-App ${issued.ticket}`,
body: { method: "gateway.call", params: {} },
})
).res.statusCode,
).toBe(400);
});
});
+348 -97
View File
@@ -1,9 +1,17 @@
import { createHmac, randomBytes } from "node:crypto";
import { createHash, createHmac, randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { peekSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js";
import { buildMcpAppSandboxPath, resolveMcpAppSandboxPort } from "../agents/mcp-app-sandbox.js";
import { getMcpAppViewLease, type McpAppViewLease } from "../agents/mcp-ui-resource.js";
import { formatErrorMessage } from "../infra/errors.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { readJsonBodyOrError, sendJson } from "./http-common.js";
import {
executeMcpAppOperation,
type McpAppActiveView,
parseMcpAppOperation,
withMcpAppActiveView,
} from "./mcp-app-operations.js";
const MCP_APP_STANDALONE_PATH = "/__openclaw__/mcp-app";
const MCP_APP_STANDALONE_VIEW_PATH = `${MCP_APP_STANDALONE_PATH}/view`;
@@ -12,6 +20,7 @@ const MCP_APP_STANDALONE_TICKET_TTL_MS = 2 * 60_000;
const MCP_APP_STANDALONE_TICKET_MIN_REMAINING_MS = 15_000;
const MCP_APP_STANDALONE_TICKET_MAX_ENTRIES = 256;
const MCP_APP_STABLE_PROTOCOL_VERSION = "2026-01-26";
const MCP_APP_OPERATION_MAX_BODY_BYTES = 256 * 1024;
const ticketSecret = randomBytes(32);
type StandaloneTicketBinding = {
@@ -26,6 +35,10 @@ type StandaloneTicket = { ticket: string; url: string; expiresAtMs: number };
const ticketBindings = new Map<string, StandaloneTicketBinding>();
export const mcpAppStandaloneTesting = {
clearTickets: () => ticketBindings.clear(),
};
function pruneTicketBindings(nowMs: number): void {
for (const [nonce, binding] of ticketBindings) {
if (binding.expiresAtMs <= nowMs) {
@@ -149,11 +162,11 @@ export function verifyMcpAppStandaloneTicket(
return binding;
}
function resolveTicketView(
function resolveTicketActiveView(
value: string,
nowMs: number,
secret: Buffer,
): McpAppViewLease | undefined {
): McpAppActiveView | undefined {
const binding = verifyMcpAppStandaloneTicket(value, { nowMs, secret });
if (!binding) {
return undefined;
@@ -172,7 +185,7 @@ function resolveTicketView(
) {
return undefined;
}
return view;
return { runtime, view };
}
function ticketFromRequest(req: IncomingMessage): string | undefined {
@@ -184,48 +197,112 @@ function ticketFromRequest(req: IncomingMessage): string | undefined {
return value || undefined;
}
function supportsStandaloneToolOperations(
view: Pick<McpAppViewLease, "allowedAppToolNames" | "readOnly">,
): boolean {
// The ticket is the short-lived grant. Tool authority still requires the
// originating run's explicit allowlist and is revalidated on every request.
return view.allowedAppToolNames !== undefined && view.readOnly !== true;
}
function sendText(res: ServerResponse, statusCode: number, body: string): void {
res.statusCode = statusCode;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(body);
}
function standaloneHostHtml(): string {
return `<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>OpenClaw MCP App</title>
<style>html,body{height:100%;margin:0;background:#fff;color:#111;font:14px system-ui,sans-serif}main{height:100%}iframe{display:block;width:100%;height:600px;border:0}.error{padding:16px;color:#b91c1c}</style>
<main id="host" aria-live="polite"></main>
<script>
(() => {
"use strict";
const host = document.getElementById("host");
const ticket = location.hash.startsWith("#") ? location.hash.slice(1) : "";
let frame;
let payload;
function runStandaloneMcpAppHost(config: { protocolVersion: string; viewPath: string }): void {
type StandaloneElement = { className: string; textContent: string };
type StandaloneFrame = StandaloneElement & {
contentWindow?: { postMessage(message: unknown, targetOrigin: string): void };
referrerPolicy: string;
remove(): void;
setAttribute(name: string, value: string): void;
src: string;
style: { height: string };
title: string;
};
type StandaloneMessageEvent = { data: unknown; origin: string; source: unknown };
const browser = globalThis as unknown as {
addEventListener(type: string, listener: (event: StandaloneMessageEvent) => void): void;
document: {
createElement(name: "iframe"): StandaloneFrame;
createElement(name: "p"): StandaloneElement;
getElementById(id: string): { replaceChildren(...children: unknown[]): void } | null;
};
innerWidth: number;
location: { hash: string; origin: string };
matchMedia(query: string): { matches: boolean };
navigator: { language: string };
};
type JsonRpcId = number | string;
type JsonRpcMessage = {
jsonrpc: "2.0";
id?: JsonRpcId;
method?: string;
params?: unknown;
result?: unknown;
error?: { code: number; message: string };
};
type ViewPayload = {
sandboxUrl: string;
sandboxPort: number;
sandboxOrigin?: string;
html: string;
csp?: Record<string, unknown>;
toolInput: unknown;
toolResult: unknown;
serverTools?: boolean;
serverResources?: boolean;
};
const host = browser.document.getElementById("host");
const ticket = browser.location.hash.startsWith("#") ? browser.location.hash.slice(1) : "";
let frame: StandaloneFrame | undefined;
let payload: ViewPayload | undefined;
let initializeAccepted = false;
let initialized = false;
let requestId = 0;
let teardownId;
const fail = (message) => {
host.replaceChildren(Object.assign(document.createElement("p"), { className: "error", textContent: message }));
let sandboxOrigin: string | undefined;
let teardownId: JsonRpcId | undefined;
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const fail = (message: string) => {
frame?.remove();
frame = undefined;
sandboxOrigin = undefined;
host?.replaceChildren(
Object.assign(browser.document.createElement("p"), {
className: "error",
textContent: message,
}),
);
};
const post = (message) => frame?.contentWindow?.postMessage(message, "*");
const notify = (method, params = {}) => post({ jsonrpc: "2.0", method, params });
const respond = (id, result) => post({ jsonrpc: "2.0", id, result });
const reject = (id, method) => post({
jsonrpc: "2.0",
id,
error: { code: -32601, message: "Method not available in read-only host: " + method },
});
const post = (message: JsonRpcMessage) => {
if (sandboxOrigin) {
frame?.contentWindow?.postMessage(message, sandboxOrigin);
}
};
const notify = (method: string, params: unknown = {}) => post({ jsonrpc: "2.0", method, params });
const respond = (id: JsonRpcId, result: unknown) => post({ jsonrpc: "2.0", id, result });
const reject = (id: JsonRpcId, code: number, message: string) =>
post({ jsonrpc: "2.0", id, error: { code, message } });
const removeFrame = () => {
frame?.remove();
frame = undefined;
sandboxOrigin = undefined;
teardownId = undefined;
};
const resolveSandboxUrl = (view) => {
const base = view.sandboxOrigin ? new URL(view.sandboxOrigin) : new URL(location.origin);
if (!view.sandboxOrigin) base.port = String(view.sandboxPort);
const resolveSandboxUrl = (view: ViewPayload) => {
const base = view.sandboxOrigin
? new URL(view.sandboxOrigin)
: new URL(browser.location.origin);
if (!view.sandboxOrigin) {
base.port = String(view.sandboxPort);
}
base.pathname = "/";
base.search = "";
base.hash = "";
@@ -233,26 +310,88 @@ function standaloneHostHtml(): string {
if (
!["http:", "https:"].includes(resolved.protocol) ||
resolved.origin !== base.origin ||
resolved.origin === location.origin ||
resolved.origin === browser.location.origin ||
resolved.pathname !== "/mcp-app-sandbox"
) throw new Error("MCP App sandbox URL is invalid");
return resolved.href;
) {
throw new Error("MCP App sandbox URL is invalid");
}
return resolved;
};
const request = async (method: string, params: unknown): Promise<unknown> => {
const response = await fetch(config.viewPath, {
method: "POST",
headers: {
Authorization: `MCP-App ${ticket}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ method, params }),
cache: "no-store",
credentials: "omit",
});
const body = (await response.json().catch(() => undefined)) as
| { ok?: boolean; result?: unknown; error?: string }
| undefined;
if (response.status === 401) {
fail("MCP App ticket was rejected");
throw new Error("MCP App ticket was rejected");
}
if (!response.ok || body?.ok !== true) {
throw new Error(body?.error || "MCP App operation was rejected");
}
return body.result;
};
const operationHandlers = new Map<string, (params: unknown) => Promise<unknown>>();
const installOperationHandlers = (view: ViewPayload) => {
if (view.serverTools === true) {
operationHandlers.set("tools/call", (params) => request("tools/call", params));
operationHandlers.set("tools/list", (params) => request("tools/list", params));
}
if (view.serverResources === true) {
operationHandlers.set("resources/list", (params) => request("resources/list", params));
operationHandlers.set("resources/templates/list", (params) =>
request("resources/templates/list", params),
);
operationHandlers.set("resources/read", (params) => request("resources/read", params));
}
};
const deliverInitialState = () => {
if (initialized) return;
if (initialized || !payload) {
return;
}
initialized = true;
notify("ui/notifications/tool-input", {
arguments: payload.toolInput && typeof payload.toolInput === "object" && !Array.isArray(payload.toolInput)
? payload.toolInput
: {},
arguments: asRecord(payload.toolInput) ?? {},
});
notify("ui/notifications/tool-result", payload.toolResult);
};
window.addEventListener("message", (event) => {
if (event.source !== frame?.contentWindow || !event.data || event.data.jsonrpc !== "2.0") return;
const message = event.data;
const isValidInitialize = (params: unknown) => {
const record = asRecord(params);
const appInfo = asRecord(record?.appInfo);
return (
typeof record?.protocolVersion === "string" &&
typeof appInfo?.name === "string" &&
typeof appInfo?.version === "string" &&
asRecord(record?.appCapabilities) !== undefined
);
};
browser.addEventListener("message", (event) => {
const message = asRecord(event.data) as JsonRpcMessage | undefined;
if (
event.source !== frame?.contentWindow ||
event.origin !== sandboxOrigin ||
message?.jsonrpc !== "2.0" ||
(message.id !== undefined && typeof message.id !== "string" && typeof message.id !== "number")
) {
return;
}
if (message.method === "ui/notifications/sandbox-proxy-ready") {
notify("ui/notifications/sandbox-resource-ready", { html: payload.html, csp: payload.csp });
if (payload) {
notify("ui/notifications/sandbox-resource-ready", {
html: payload.html,
csp: payload.csp,
});
}
return;
}
if (message.method === "ping" && message.id !== undefined) {
@@ -260,16 +399,25 @@ function standaloneHostHtml(): string {
return;
}
if (message.method === "ui/initialize" && message.id !== undefined) {
if (!payload || !isValidInitialize(message.params)) {
reject(message.id, -32602, "Invalid MCP App initialization");
return;
}
initializeAccepted = true;
respond(message.id, {
protocolVersion: ${JSON.stringify(MCP_APP_STABLE_PROTOCOL_VERSION)},
hostInfo: { name: "OpenClaw read-only host", version: "1.0.0" },
hostCapabilities: { sandbox: { csp: payload.csp ?? {} } },
protocolVersion: config.protocolVersion,
hostInfo: { name: "OpenClaw standalone host", version: "1.0.0" },
hostCapabilities: {
sandbox: { csp: payload.csp ?? {} },
...(payload.serverTools === true ? { serverTools: {} } : {}),
...(payload.serverResources === true ? { serverResources: {} } : {}),
},
hostContext: {
theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
theme: browser.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
displayMode: "inline",
availableDisplayModes: ["inline"],
containerDimensions: { width: Math.max(1, innerWidth), height: 600 },
locale: navigator.language,
containerDimensions: { width: Math.max(1, browser.innerWidth), height: 600 },
locale: browser.navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
platform: "web",
},
@@ -277,13 +425,17 @@ function standaloneHostHtml(): string {
return;
}
if (message.method === "ui/notifications/initialized") {
// A view cannot unlock server operations by skipping the validated handshake.
if (!initializeAccepted) {
return;
}
deliverInitialState();
return;
}
if (message.method === "ui/notifications/size-changed") {
const height = message.params?.height;
if (typeof height === "number" && Number.isFinite(height)) {
frame.style.height = Math.min(1200, Math.max(160, Math.round(height))) + "px";
const height = asRecord(message.params)?.height;
if (frame && typeof height === "number" && Number.isFinite(height)) {
frame.style.height = `${Math.min(1200, Math.max(160, Math.round(height)))}px`;
}
return;
}
@@ -291,38 +443,87 @@ function standaloneHostHtml(): string {
const id = ++requestId;
teardownId = id;
post({ jsonrpc: "2.0", id, method: "ui/resource-teardown", params: {} });
setTimeout(() => { if (teardownId === id) removeFrame(); }, 1_000);
setTimeout(() => {
if (teardownId === id) {
removeFrame();
}
}, 1_000);
return;
}
if (teardownId !== undefined && message.id === teardownId && message.method === undefined) {
removeFrame();
return;
}
if (message.id !== undefined && typeof message.method === "string") reject(message.id, message.method);
if (message.id === undefined || typeof message.method !== "string") {
return;
}
const handler = operationHandlers.get(message.method);
if (!handler) {
reject(message.id, -32601, `Method not available in standalone host: ${message.method}`);
return;
}
if (!initialized) {
reject(message.id, -32002, "MCP App initialization is incomplete");
return;
}
void handler(message.params ?? {})
.then((result) => respond(message.id as JsonRpcId, result))
.catch((error: unknown) =>
reject(
message.id as JsonRpcId,
-32000,
error instanceof Error ? error.message : "MCP App operation failed",
),
);
});
window.addEventListener("pagehide", () => {
if (frame?.contentWindow) post({ jsonrpc: "2.0", id: ++requestId, method: "ui/resource-teardown", params: {} });
browser.addEventListener("pagehide", () => {
if (frame?.contentWindow) {
post({ jsonrpc: "2.0", id: ++requestId, method: "ui/resource-teardown", params: {} });
}
});
if (!ticket) {
fail("MCP App ticket is missing");
return;
}
fetch(${JSON.stringify(MCP_APP_STANDALONE_VIEW_PATH)}, {
headers: { Authorization: "MCP-App " + ticket },
void fetch(config.viewPath, {
headers: { Authorization: `MCP-App ${ticket}` },
cache: "no-store",
credentials: "omit",
}).then(async (response) => {
if (!response.ok) throw new Error("MCP App ticket was rejected");
payload = await response.json();
frame = document.createElement("iframe");
frame.title = "MCP App";
frame.referrerPolicy = "origin";
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
frame.src = resolveSandboxUrl(payload);
host.replaceChildren(frame);
}).catch((error) => fail(error instanceof Error ? error.message : String(error)));
})();
</script>`;
})
.then(async (response) => {
if (!response.ok) {
throw new Error("MCP App ticket was rejected");
}
payload = (await response.json()) as ViewPayload;
installOperationHandlers(payload);
const sandboxUrl = resolveSandboxUrl(payload);
sandboxOrigin = sandboxUrl.origin;
frame = browser.document.createElement("iframe");
frame.title = "MCP App";
frame.referrerPolicy = "origin";
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
frame.src = sandboxUrl.href;
host?.replaceChildren(frame);
})
.catch((error: unknown) => fail(error instanceof Error ? error.message : String(error)));
}
function standaloneHostHtml(): { html: string; scriptHash: string } {
const clientSource = `(${runStandaloneMcpAppHost.toString()})(${JSON.stringify({
protocolVersion: MCP_APP_STABLE_PROTOCOL_VERSION,
viewPath: MCP_APP_STANDALONE_VIEW_PATH,
})});`;
const escapedSource = clientSource.replaceAll("</script", "<\\/script");
return {
html: `<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>OpenClaw MCP App</title>
<style>html,body{height:100%;margin:0;background:#fff;color:#111;font:14px system-ui,sans-serif}main{height:100%}iframe{display:block;width:100%;height:600px;border:0}.error{padding:16px;color:#b91c1c}</style>
<main id="host" aria-live="polite"></main>
<script>${escapedSource}</script>`,
scriptHash: createHash("sha256").update(escapedSource).digest("base64"),
};
}
function resolveShellSandboxOrigin(params: {
@@ -340,17 +541,18 @@ function resolveShellSandboxOrigin(params: {
return base.origin;
}
export function handleMcpAppStandaloneHttpRequest(
export async function handleMcpAppStandaloneHttpRequest(
req: IncomingMessage,
res: ServerResponse,
options: {
gatewayPort?: number;
sandboxPort?: number;
sandboxOrigin?: string;
now?: () => number;
nowMs?: number;
ticketSecret?: Buffer;
} = {},
): boolean {
): Promise<boolean> {
let url: URL;
try {
url = new URL(req.url ?? "/", "http://localhost");
@@ -360,7 +562,11 @@ export function handleMcpAppStandaloneHttpRequest(
if (url.pathname !== MCP_APP_STANDALONE_PATH && url.pathname !== MCP_APP_STANDALONE_VIEW_PATH) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
if (
req.method !== "GET" &&
req.method !== "HEAD" &&
!(url.pathname === MCP_APP_STANDALONE_VIEW_PATH && req.method === "POST")
) {
sendText(res, 404, "Not Found");
return true;
}
@@ -387,43 +593,88 @@ export function handleMcpAppStandaloneHttpRequest(
sandboxOrigin: options.sandboxOrigin,
sandboxPort,
});
const shell = standaloneHostHtml();
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader(
"Content-Security-Policy",
`default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-src ${frameOrigin}; base-uri 'none'; form-action 'none'; object-src 'none'`,
`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'`,
);
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
res.end(req.method === "HEAD" ? undefined : standaloneHostHtml());
res.end(req.method === "HEAD" ? undefined : shell.html);
return true;
}
res.setHeader("Vary", "Authorization");
const ticket = ticketFromRequest(req);
const view = ticket
? resolveTicketView(ticket, options.nowMs ?? Date.now(), options.ticketSecret ?? ticketSecret)
: undefined;
if (!view) {
const now = options.now ?? (() => options.nowMs ?? Date.now());
const nowMs = now();
const secret = options.ticketSecret ?? ticketSecret;
const active = ticket ? resolveTicketActiveView(ticket, nowMs, secret) : undefined;
if (!active) {
res.setHeader("WWW-Authenticate", "MCP-App");
sendText(res, 401, "Unauthorized");
return true;
}
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,
}),
);
return true;
if (req.method === "POST") {
const body = await readJsonBodyOrError(req, res, MCP_APP_OPERATION_MAX_BODY_BYTES);
if (body === undefined) {
return true;
}
const operation = parseMcpAppOperation(body);
if (!operation) {
sendJson(res, 400, { ok: false, error: "Invalid MCP App operation" });
return true;
}
// Body parsing may consume meaningful ticket lifetime. Revalidate the
// authoritative runtime and view immediately before privileged work.
const current = ticket ? resolveTicketActiveView(ticket, now(), secret) : undefined;
if (!current) {
res.setHeader("WWW-Authenticate", "MCP-App");
sendJson(res, 401, { ok: false, error: "Unauthorized" });
return true;
}
if (
(operation.method === "tools/call" || operation.method === "tools/list") &&
!supportsStandaloneToolOperations(current.view)
) {
sendJson(res, 403, { ok: false, error: "MCP App tool bridge is unavailable" });
return true;
}
try {
sendJson(res, 200, { ok: true, result: await executeMcpAppOperation(current, operation) });
} catch (error) {
sendJson(res, 403, { ok: false, error: formatErrorMessage(error) });
}
return true;
}
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,
}),
);
return true;
});
} catch (error) {
sendJson(res, 429, { ok: false, error: formatErrorMessage(error) });
return true;
}
}
+5 -3
View File
@@ -807,11 +807,13 @@ export function createGatewayHttpServer(opts: {
if (configSnapshot.mcp?.apps?.enabled === true && isMcpAppStandalonePath(scopedRequestPath)) {
requestStages.push({
name: "mcp-app-standalone",
run: async () =>
(await getMcpAppStandaloneModule()).handleMcpAppStandaloneHttpRequest(req, res, {
run: async () => {
const standalone = await getMcpAppStandaloneModule();
return await standalone.handleMcpAppStandaloneHttpRequest(req, res, {
sandboxPort: configSnapshot.mcp?.apps?.sandboxPort,
sandboxOrigin: configSnapshot.mcp?.apps?.sandboxOrigin,
}),
});
},
});
}
// Plugin routes run before the general Control UI SPA catch-all so
+73 -189
View File
@@ -1,20 +1,13 @@
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import {
completeDeferredSessionMcpRuntimeRetirement,
peekSessionMcpRuntime,
} from "../../agents/agent-bundle-mcp-runtime.js";
import type { McpCatalogTool, SessionMcpRuntime } from "../../agents/agent-bundle-mcp-types.js";
import { buildMcpAppSandboxPath } from "../../agents/mcp-app-sandbox.js";
import {
acquireMcpAppViewRequest,
getMcpAppViewLease,
type McpAppViewLease,
} from "../../agents/mcp-ui-resource.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { logWarn } from "../../logger.js";
import { restoreMcpAppView } from "../mcp-app-reconstruction.js";
import {
executeMcpAppOperation,
type McpAppOperation,
resolveMcpAppActiveView,
withMcpAppActiveView,
} from "../mcp-app-operations.js";
import { createMcpAppStandaloneTicket } from "../mcp-app-standalone.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -31,94 +24,15 @@ function optionalCursor(params: Record<string, unknown>): { cursor?: string } |
return typeof cursor === "string" && cursor.trim() ? { cursor: cursor.trim() } : undefined;
}
function isAppCallableTool(tool: McpCatalogTool): boolean {
return tool.uiVisibility === undefined || tool.uiVisibility.includes("app");
}
function isAppCallableListedTool(tool: Tool): boolean {
const { _meta: metadata } = tool;
const ui =
metadata?.ui && typeof metadata.ui === "object" && !Array.isArray(metadata.ui)
? (metadata.ui as { visibility?: unknown })
: undefined;
const visibility = Array.isArray(ui?.visibility)
? ui.visibility.filter(
(entry): entry is "app" | "model" => entry === "app" || entry === "model",
)
: undefined;
return visibility === undefined || visibility.includes("app");
}
function isAllowedByView(view: McpAppViewLease, toolName: string): boolean {
return view.allowedAppToolNames === undefined || view.allowedAppToolNames.has(toolName);
}
async function requireActiveView(
async function runOperation(
params: Record<string, unknown>,
cfg?: OpenClawConfig,
): Promise<{
runtime: SessionMcpRuntime;
view: McpAppViewLease;
}> {
const sessionKey = requireString(params, "sessionKey");
const viewId = requireString(params, "viewId");
const existingRuntime = peekSessionMcpRuntime({ sessionKey });
if (
(existingRuntime && existingRuntime.mcpAppsEnabled !== true) ||
(cfg && cfg.mcp?.apps?.enabled !== true)
) {
throw new Error("MCP App runtime is unavailable");
}
const existingView = existingRuntime ? getMcpAppViewLease(viewId, existingRuntime) : undefined;
const restored =
existingRuntime?.mcpAppsEnabled === true && existingView
? { runtime: existingRuntime, view: existingView }
: cfg
? await restoreMcpAppView({ cfg, sessionKey, viewId })
: undefined;
if (!restored) {
throw new Error("MCP App view expired or is not authorized for this session");
}
const { runtime, view } = restored;
runtime.markUsed();
return { runtime, view };
}
async function withActiveView<T>(
params: Record<string, unknown>,
kind: "read" | "tool",
operation: (active: { runtime: SessionMcpRuntime; view: McpAppViewLease }) => Promise<T> | T,
cfg?: OpenClawConfig,
): Promise<T> {
const active = await requireActiveView(params, cfg);
const release = acquireMcpAppViewRequest(active.view, kind);
const releaseRuntimeLease = active.runtime.acquireLease?.();
try {
return await operation(active);
} finally {
release();
releaseRuntimeLease?.();
await completeDeferredSessionMcpRuntimeRetirement(active.runtime).catch((error: unknown) => {
// A completed app tool call may have side effects. Cleanup failure must
// never turn its successful response into an apparent retryable failure.
logWarn(`mcp-app: deferred runtime cleanup failed: ${formatErrorMessage(error)}`);
});
}
}
async function requireCallableTool(
runtime: SessionMcpRuntime,
view: McpAppViewLease,
toolName: string,
): Promise<McpCatalogTool> {
const catalog = await runtime.getCatalog();
const tool = catalog.tools.find(
(entry) => entry.serverName === view.serverName && entry.toolName === toolName,
);
if (!tool || !isAppCallableTool(tool) || !isAllowedByView(view, toolName)) {
throw new Error(`MCP tool "${toolName}" is not app-callable`);
}
return tool;
operation: McpAppOperation,
): Promise<unknown> {
const active = await resolveMcpAppActiveView({
sessionKey: requireString(params, "sessionKey"),
viewId: requireString(params, "viewId"),
});
return await executeMcpAppOperation(active, operation);
}
async function handle(
@@ -134,59 +48,60 @@ async function handle(
export const mcpAppHandlers: GatewayRequestHandlers = {
"mcp.app.view": async ({ respond, params, context }) => {
await handle(
respond,
async () =>
await withActiveView(
params,
"read",
({ view }) => {
const sandboxPort = context.getMcpAppSandboxPort?.();
if (sandboxPort === undefined) {
throw new Error("MCP App sandbox listener is unavailable; restart the Gateway");
}
const configuredOrigin = context.getRuntimeConfig().mcp?.apps?.sandboxOrigin;
let standalone: ReturnType<typeof createMcpAppStandaloneTicket> = undefined;
try {
standalone = createMcpAppStandaloneTicket({
sessionKey: requireString(params, "sessionKey"),
view,
});
} catch (error) {
// Standalone links are additive; issuance must never break the
// existing authenticated Control UI view payload.
logWarn(`mcp-app: standalone ticket unavailable: ${formatErrorMessage(error)}`);
}
return {
sandboxUrl: buildMcpAppSandboxPath(view.csp),
sandboxPort,
...(configuredOrigin ? { sandboxOrigin: new URL(configuredOrigin).origin } : {}),
html: view.html,
...(view.csp ? { csp: view.csp } : {}),
toolInput: view.toolInput,
toolResult: view.toolResult,
...(standalone
? {
standaloneUrl: standalone.url,
standaloneExpiresAtMs: standalone.expiresAtMs,
}
: {}),
// Reconstruction marks views read-only; fresh runs may legitimately grant zero App tools.
messageSupported: view.allowedAppToolNames !== undefined && view.readOnly !== true,
};
},
context.getRuntimeConfig(),
),
);
await handle(respond, async () => {
const active = await resolveMcpAppActiveView({
sessionKey: requireString(params, "sessionKey"),
viewId: requireString(params, "viewId"),
cfg: context.getRuntimeConfig(),
});
return await withMcpAppActiveView(active, "read", () => {
const { view } = active;
const sandboxPort = context.getMcpAppSandboxPort?.();
if (sandboxPort === undefined) {
throw new Error("MCP App sandbox listener is unavailable; restart the Gateway");
}
const configuredOrigin = context.getRuntimeConfig().mcp?.apps?.sandboxOrigin;
let standalone: ReturnType<typeof createMcpAppStandaloneTicket> = undefined;
try {
standalone = createMcpAppStandaloneTicket({
sessionKey: requireString(params, "sessionKey"),
view,
});
} catch (error) {
// Standalone links are additive; issuance must never break the
// existing authenticated Control UI view payload.
logWarn(`mcp-app: standalone ticket unavailable: ${formatErrorMessage(error)}`);
}
return {
sandboxUrl: buildMcpAppSandboxPath(view.csp),
sandboxPort,
...(configuredOrigin ? { sandboxOrigin: new URL(configuredOrigin).origin } : {}),
html: view.html,
...(view.csp ? { csp: view.csp } : {}),
toolInput: view.toolInput,
toolResult: view.toolResult,
...(standalone
? {
standaloneUrl: standalone.url,
standaloneExpiresAtMs: standalone.expiresAtMs,
}
: {}),
// Reconstruction marks views read-only; fresh runs may legitimately grant zero App tools.
messageSupported: view.allowedAppToolNames !== undefined && view.readOnly !== true,
};
});
});
},
"mcp.app.callTool": async ({ respond, params }) => {
await handle(
respond,
async () =>
await withActiveView(params, "tool", async ({ runtime, view }) => {
const toolName = requireString(params, "toolName");
await requireCallableTool(runtime, view, toolName);
return await runtime.callTool(view.serverName, toolName, params.arguments ?? {});
await runOperation(params, {
method: "tools/call",
params: {
name: requireString(params, "toolName"),
arguments: (params.arguments ?? {}) as Record<string, unknown>,
},
}),
);
},
@@ -194,43 +109,16 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
await handle(
respond,
async () =>
await withActiveView(params, "read", async ({ runtime, view }) => {
if (!runtime.listTools) {
throw new Error("MCP tools/list is unavailable");
}
const [listed, catalog] = await Promise.all([
runtime.listTools(view.serverName, optionalCursor(params)),
runtime.getCatalog(),
]);
const allowed = new Set(
catalog.tools
.filter(
(tool) =>
tool.serverName === view.serverName &&
isAppCallableTool(tool) &&
isAllowedByView(view, tool.toolName),
)
.map((tool) => tool.toolName),
);
return {
...listed,
tools: listed.tools.filter(
(tool) => allowed.has(tool.name.trim()) && isAppCallableListedTool(tool),
),
};
}),
await runOperation(params, { method: "tools/list", params: optionalCursor(params) ?? {} }),
);
},
"mcp.app.listResources": async ({ respond, params }) => {
await handle(
respond,
async () =>
await withActiveView(params, "read", async ({ runtime, view }) => {
if (!runtime.listResources) {
throw new Error("MCP resources/list is unavailable");
}
const resources = await runtime.listResources(view.serverName);
return Array.isArray(resources) ? { resources } : resources;
await runOperation(params, {
method: "resources/list",
params: optionalCursor(params) ?? {},
}),
);
},
@@ -238,11 +126,9 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
await handle(
respond,
async () =>
await withActiveView(params, "read", async ({ runtime, view }) => {
if (!runtime.listResourceTemplates) {
throw new Error("MCP resources/templates/list is unavailable");
}
return await runtime.listResourceTemplates(view.serverName, optionalCursor(params));
await runOperation(params, {
method: "resources/templates/list",
params: optionalCursor(params) ?? {},
}),
);
},
@@ -250,11 +136,9 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
await handle(
respond,
async () =>
await withActiveView(params, "read", async ({ runtime, view }) => {
if (!runtime.readResource) {
throw new Error("MCP resources/read is unavailable");
}
return await runtime.readResource(view.serverName, requireString(params, "uri"));
await runOperation(params, {
method: "resources/read",
params: { uri: requireString(params, "uri") },
}),
);
},
+46 -24
View File
@@ -227,7 +227,12 @@ window.mcpConformanceGatewayBrowserClient = GatewayBrowserClient;
</script>`,
});
});
await page.goto(`${controlUiServer.baseUrl}mcp-conformance`);
await page.goto(`${controlUiServer.baseUrl}mcp-conformance`, { waitUntil: "domcontentloaded" });
await page.waitForFunction(
() => Reflect.get(window, "mcpConformanceGatewayBrowserClient") !== undefined,
undefined,
{ timeout: 30_000 },
);
await page.evaluate(
async (params) => {
const GatewayBrowserClient = Reflect.get(
@@ -247,14 +252,17 @@ window.mcpConformanceGatewayBrowserClient = GatewayBrowserClient;
url: params.gatewayUrl,
token: params.authValue,
onHello: () => resolveHello(),
onClose: (info: { reason: string }) =>
rejectHello(new Error(`Gateway connection closed: ${info.reason}`)),
onClose: (info: { code: number; reason: string; error?: unknown; willRetry: boolean }) => {
if (!info.willRetry) {
rejectHello(new Error(`Gateway connection closed: ${JSON.stringify(info)}`));
}
},
});
client.start();
await Promise.race([
connected,
new Promise((_, reject) => {
setTimeout(() => reject(new Error("Gateway connection timed out")), 10_000);
setTimeout(() => reject(new Error("Gateway connection timed out")), 60_000);
}),
]);
const view = document.createElement("mcp-app-view");
@@ -263,6 +271,7 @@ window.mcpConformanceGatewayBrowserClient = GatewayBrowserClient;
snapshot: { client },
connection: { gatewayUrl: params.gatewayUrl },
},
theme: { subscribe: () => () => undefined },
});
view.sessionKey = params.sessionKey;
view.viewId = params.viewId;
@@ -420,7 +429,7 @@ describeConformance("MCP App Control UI and standalone host conformance", () =>
}
}, 120_000);
it("drives the authenticated Control UI bridge and read-only standalone host", async () => {
it("drives the authenticated Control UI and ticketed standalone bridges", async () => {
const controlContext = await browser.newContext({ permissions: ["local-network-access"] });
openContexts.add(controlContext);
const controlPage = await controlContext.newPage();
@@ -464,54 +473,67 @@ describeConformance("MCP App Control UI and standalone host conformance", () =>
const standaloneContext = await browser.newContext({ permissions: ["local-network-access"] });
openContexts.add(standaloneContext);
const authorizationHeaders: string[] = [];
const requestUrls: string[] = [];
const referrers: string[] = [];
const standaloneDiagnostics: string[] = [];
standaloneContext.on("request", (request) => {
requestUrls.push(request.url());
const authorization = request.headers().authorization;
if (authorization) {
authorizationHeaders.push(authorization);
}
const referrer = request.headers().referer;
if (referrer) {
referrers.push(referrer);
}
});
const standalonePage = await standaloneContext.newPage();
standalonePage.on("console", (message) => standaloneDiagnostics.push(message.text()));
const absoluteStandaloneUrl = `http://127.0.0.1:${gatewayPort}${standaloneUrl}`;
const ticket = standaloneUrl.split("#")[1] ?? "";
await standalonePage.goto(absoluteStandaloneUrl);
app = await findAppFrame(standalonePage);
await waitForText(app.locator("#input"), '{"city":"Paris"}');
await waitForTextContaining(app.locator("#result"), "initial-result");
await waitForTextContaining(app.locator("#capabilities"), "serverTools", false);
await waitForTextContaining(app.locator("#capabilities"), "serverResources", false);
await waitForTextContaining(app.locator("#capabilities"), "serverTools");
await waitForTextContaining(app.locator("#capabilities"), "serverResources");
await waitForText(app.locator("#ping"), "{}");
await waitForText(app.locator("#isolation"), "isolated");
await app.locator("#call-app").click();
await waitForTextContaining(app.locator("#app-tool"), "denied:");
await waitForTextContaining(app.locator("#app-tool"), "companion-called");
await app.locator("#call-model").click();
await waitForTextContaining(app.locator("#model-tool"), "denied:");
await app.locator("#read-resource").click();
await waitForTextContaining(app.locator("#resource"), "denied:");
expect(authorizationHeaders).toHaveLength(1);
expect(authorizationHeaders[0]).toMatch(/^MCP-App v1\./u);
expect(authorizationHeaders).not.toContain(`Bearer ${authValue}`);
await waitForTextContaining(app.locator("#resource"), "resource-ok");
expect(authorizationHeaders.length).toBeGreaterThanOrEqual(3);
expect(authorizationHeaders.every((value) => value.startsWith("MCP-App v1."))).toBe(true);
expect(authorizationHeaders.some((value) => value === `Bearer ${authValue}`)).toBe(false);
expect(ticket).not.toBe("");
expect(requestUrls.some((value) => value.includes(ticket))).toBe(false);
expect(referrers.some((value) => value.includes(ticket))).toBe(false);
expect(standaloneDiagnostics.some((value) => value.includes(ticket))).toBe(false);
await app.locator("#request-teardown").click();
await expect.poll(() => standalonePage.frames().length).toBe(1);
await standalonePage.reload();
app = await findAppFrame(standalonePage);
await waitForTextContaining(app.locator("#result"), "initial-result");
await app.locator("#call-app").click();
await waitForTextContaining(app.locator("#app-tool"), "companion-called");
const tampered = `${absoluteStandaloneUrl.slice(0, -1)}${absoluteStandaloneUrl.endsWith("a") ? "b" : "a"}`;
await standalonePage.goto(tampered);
await standalonePage.reload();
await waitForText(standalonePage.locator(".error"), "MCP App ticket was rejected");
const tamperedPage = await standaloneContext.newPage();
await tamperedPage.goto(tampered);
await tamperedPage.reload();
await waitForText(tamperedPage.locator(".error"), "MCP App ticket was rejected");
await tamperedPage.close();
const lease = getMcpAppViewLease(viewId, runtime);
if (!lease) {
throw new Error("MCP App view lease missing");
}
const expiresAtMs = Date.now() + 2_000;
lease.expiresAtMs = expiresAtMs;
const expiring = await requestStandaloneUrl(controlPage);
const expiringUrl = `http://127.0.0.1:${gatewayPort}${expiring}`;
await new Promise((resolve) => {
setTimeout(resolve, Math.max(0, expiresAtMs - Date.now() + 50));
});
await standalonePage.goto(expiringUrl);
await standalonePage.reload();
lease.expiresAtMs = Date.now() - 1;
await app.locator("#call-app").click();
await waitForText(standalonePage.locator(".error"), "MCP App ticket was rejected");
}, 90_000);
});