mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user