feat(dashboard): pinned MCP apps — board rendering, lease re-mint, durable tool grants (#111524)

* feat(boards): mint pinned MCP app views

* feat(ui): render pinned MCP apps on dashboards

* fix(dashboard): reconcile MCP app rebase

* fix(dashboard): align generated CI contracts

* test(gateway): record board.widget.appView release train

* fix(dashboard): revalidate pinned MCP app views
This commit is contained in:
Peter Steinberger
2026-07-19 19:51:26 -07:00
committed by GitHub
parent ddf57c083f
commit 64c82812a5
49 changed files with 2498 additions and 83 deletions
+1
View File
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- **Control UI user profiles:** let trusted-proxy users manage their own display name and avatar, resolve attributed chat and presence identities through uploaded avatars or a private cached Gravatar proxy, and keep other users' profiles admin-only.
- **Trusted-proxy browser pairing:** optionally auto-approve new Control UI and WebChat devices from allowlisted proxy identities with non-admin scope caps, while keeping existing-device upgrades manual.
- **Channel plugin ingress monitors:** add a shared plugin SDK monitor for durable admission, polling, pruning, claim identity validation, adoption handoff, and shutdown, and migrate IRC, Synology Chat, and Google Chat to the shared lifecycle.
- **Dashboard MCP apps:** pin originating-session MCP app views as living dashboard widgets, renew their sandboxed view leases, and keep tool interactivity behind revision-bound grants with graceful stale-state recovery.
- **External gateway supervision:** add `OPENCLAW_SUPERVISOR_MODE=external` for lifecycle owners such as OCM, preserving verified restart and deferral behavior without exposing native service authority, blocking native service mutation and self-update, and providing a versioned atomic restart-handoff consume contract. Thanks @shakkernerd.
- **ClickClack guided setup:** configure ClickClack from `openclaw onboard` or `openclaw channels add clickclack` with URL, token, and workspace prompts, default-account env fallback, nonfatal live connection validation, and gateway-aware next steps that connect automatically when OpenClaw is already running. Thanks @shakkernerd.
- **ClickClack command menus:** publish each bot's native OpenClaw commands to ClickClack composer autocomplete at gateway startup, with per-account opt-out and nonfatal compatibility handling for older tokens and servers. Thanks @shakkernerd.
@@ -235,6 +235,7 @@ enum class GatewayMethod(
BoardUpdate("board.update"),
BoardWidgetPut("board.widget.put"),
BoardWidgetGrant("board.widget.grant"),
BoardWidgetAppView("board.widget.appView"),
BoardEvent("board.event"),
AuditList("audit.list"),
AuditActivityList("audit.activity.list"),
@@ -244,6 +244,7 @@ public struct BoardWidget: Codable, Sendable {
public let position: Int
public let grantstate: AnyCodable
public let revision: Int
public let instanceid: String?
public let declaredsummary: [String]?
public let frameurl: String?
@@ -257,6 +258,7 @@ public struct BoardWidget: Codable, Sendable {
position: Int,
grantstate: AnyCodable,
revision: Int,
instanceid: String? = nil,
declaredsummary: [String]? = nil,
frameurl: String? = nil)
{
@@ -269,6 +271,7 @@ public struct BoardWidget: Codable, Sendable {
self.position = position
self.grantstate = grantstate
self.revision = revision
self.instanceid = instanceid
self.declaredsummary = declaredsummary
self.frameurl = frameurl
}
@@ -283,6 +286,7 @@ public struct BoardWidget: Codable, Sendable {
case position
case grantstate = "grantState"
case revision
case instanceid = "instanceId"
case declaredsummary = "declaredSummary"
case frameurl = "frameUrl"
}
@@ -510,6 +514,40 @@ public struct BoardMcpAppDescriptor: Codable, Sendable {
}
}
public struct BoardMcpAppPinDescriptor: Codable, Sendable {
public let viewid: String
public let servername: String
public let toolname: String
public let uiresourceuri: String
public let originsessionkey: String
public let toolcallid: String
public init(
viewid: String,
servername: String,
toolname: String,
uiresourceuri: String,
originsessionkey: String,
toolcallid: String)
{
self.viewid = viewid
self.servername = servername
self.toolname = toolname
self.uiresourceuri = uiresourceuri
self.originsessionkey = originsessionkey
self.toolcallid = toolcallid
}
private enum CodingKeys: String, CodingKey {
case viewid = "viewId"
case servername = "serverName"
case toolname = "toolName"
case uiresourceuri = "uiResourceUri"
case originsessionkey = "originSessionKey"
case toolcallid = "toolCallId"
}
}
public struct BoardWidgetHtmlContent: Codable, Sendable {
public let kind: String
public let html: String
@@ -546,6 +584,24 @@ public struct BoardWidgetMcpAppContent: Codable, Sendable {
}
}
public struct BoardWidgetMcpAppPutContent: Codable, Sendable {
public let kind: String
public let descriptor: BoardMcpAppPinDescriptor
public init(
kind: String,
descriptor: BoardMcpAppPinDescriptor)
{
self.kind = kind
self.descriptor = descriptor
}
private enum CodingKeys: String, CodingKey {
case kind
case descriptor
}
}
public struct BoardCanvasDocumentSource: Codable, Sendable {
public let kind: String
public let docid: String
@@ -635,17 +691,20 @@ public struct BoardWidgetGrantParams: Codable, Sendable {
public let name: String
public let decision: AnyCodable
public let revision: Int
public let instanceid: String?
public init(
sessionkey: String,
name: String,
decision: AnyCodable,
revision: Int)
revision: Int,
instanceid: String? = nil)
{
self.sessionkey = sessionkey
self.name = name
self.decision = decision
self.revision = revision
self.instanceid = instanceid
}
private enum CodingKeys: String, CodingKey {
@@ -653,6 +712,51 @@ public struct BoardWidgetGrantParams: Codable, Sendable {
case name
case decision
case revision
case instanceid = "instanceId"
}
}
public struct BoardWidgetAppViewParams: Codable, Sendable {
public let sessionkey: String
public let name: String
public let revision: Int
public let instanceid: String?
public init(
sessionkey: String,
name: String,
revision: Int,
instanceid: String? = nil)
{
self.sessionkey = sessionkey
self.name = name
self.revision = revision
self.instanceid = instanceid
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case name
case revision
case instanceid = "instanceId"
}
}
public struct BoardWidgetAppViewResult: Codable, Sendable {
public let viewid: String
public let expiresatms: Int
public init(
viewid: String,
expiresatms: Int)
{
self.viewid = viewid
self.expiresatms = expiresatms
}
private enum CodingKeys: String, CodingKey {
case viewid = "viewId"
case expiresatms = "expiresAtMs"
}
}
@@ -1150,7 +1254,7 @@ public struct ErrorShape: Codable, Sendable {
}
}
public struct GatewayErrorDetails: Codable, Sendable {
public struct MissingScopeErrorDetails: Codable, Sendable {
public let code: String
public let missingscope: String
public let requiredscopes: [String]
@@ -1172,6 +1276,20 @@ public struct GatewayErrorDetails: Codable, Sendable {
}
}
public struct McpAppViewExpiredErrorDetails: Codable, Sendable {
public let code: String
public init(
code: String)
{
self.code = code
}
private enum CodingKeys: String, CodingKey {
case code
}
}
public struct GatewaySuspendTaskBlocker: Codable, Sendable {
public let taskid: String
public let status: String
@@ -15578,7 +15696,7 @@ public enum BoardWidgetContent: Codable, Sendable {
public enum BoardWidgetPutContent: Codable, Sendable {
case html(BoardWidgetHtmlContent)
case mcpApp(BoardWidgetMcpAppContent)
case mcpApp(BoardWidgetMcpAppPutContent)
case canvasDoc(BoardCanvasDocumentSource)
private enum CodingKeys: String, CodingKey {
@@ -15590,7 +15708,7 @@ public enum BoardWidgetPutContent: Codable, Sendable {
let discriminator = try container.decode(String.self, forKey: .discriminator)
switch discriminator {
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder))
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppPutContent(from: decoder))
case "canvas-doc": self = try .canvasDoc(BoardCanvasDocumentSource(from: decoder))
default:
throw DecodingError.dataCorruptedError(
@@ -15641,6 +15759,64 @@ public enum BoardCommand: Codable, Sendable {
}
}
public enum GatewayErrorDetails: Codable, Sendable {
case missingScope(MissingScopeErrorDetails)
case mcpAppViewExpired(McpAppViewExpiredErrorDetails)
public init(code: String, missingscope: String, requiredscopes: [String]) {
self = .missingScope(
MissingScopeErrorDetails(
code: code,
missingscope: missingscope,
requiredscopes: requiredscopes
)
)
}
public var code: String {
switch self {
case .missingScope(let value): value.code
case .mcpAppViewExpired(let value): value.code
}
}
public var missingscope: String {
if case .missingScope(let value) = self { return value.missingscope }
return ""
}
public var requiredscopes: [String] {
if case .missingScope(let value) = self { return value.requiredscopes }
return []
}
private enum CodingKeys: String, CodingKey {
case discriminator = "code"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let discriminator = try container.decode(String.self, forKey: .discriminator)
switch discriminator {
case "MISSING_SCOPE": self = try .missingScope(MissingScopeErrorDetails(from: decoder))
case "MCP_APP_VIEW_EXPIRED": self = try .mcpAppViewExpired(McpAppViewExpiredErrorDetails(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
in: container,
debugDescription: "Unknown GatewayErrorDetails discriminator value"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .missingScope(let value): try value.encode(to: encoder)
case .mcpAppViewExpired(let value): try value.encode(to: encoder)
}
}
}
public enum GatewaySuspendPrepareResult: Codable, Sendable {
case busy(GatewaySuspendPrepareBusyResult)
case ready(GatewaySuspendPrepareReadyResult)
@@ -22,6 +22,7 @@ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
/** Stable discriminants for structured method-level authorization failures. */
export const GatewayErrorDetailCodes = {
MISSING_SCOPE: "MISSING_SCOPE",
MCP_APP_VIEW_EXPIRED: "MCP_APP_VIEW_EXPIRED",
} as const;
/** Missing operator-scope details shared by WebSocket and HTTP responses. */
@@ -31,8 +32,12 @@ export type MissingScopeErrorDetails = {
requiredScopes: string[];
};
export type McpAppViewExpiredErrorDetails = {
code: typeof GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED;
};
/** Structured details emitted by method-level authorization failures. */
export type GatewayErrorDetails = MissingScopeErrorDetails;
export type GatewayErrorDetails = MissingScopeErrorDetails | McpAppViewExpiredErrorDetails;
type GatewayErrorLike = {
code?: unknown;
@@ -69,6 +74,11 @@ export function readMissingScopeErrorDetails(details: unknown): MissingScopeErro
};
}
export function isMcpAppViewExpiredError(error: unknown): boolean {
const record = asRecord(error);
return asRecord(record?.details)?.code === GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED;
}
/**
* Reads a method-level missing-scope failure, preferring structured details.
* The message fallback keeps clients compatible with gateways predating structured details.
+12 -2
View File
@@ -1,6 +1,10 @@
export * from "./clawhub-trust-error-details.js";
export * from "./system-agent-error-details.js";
export { readMissingScopeError, readMissingScopeErrorDetails } from "./gateway-error-details.js";
export {
isMcpAppViewExpiredError,
readMissingScopeError,
readMissingScopeErrorDetails,
} from "./gateway-error-details.js";
export * from "./session-icon.js";
export * from "./terminal-validators.js";
export {
@@ -15,7 +19,11 @@ export type { ProtocolValidator } from "./protocol-validator.js";
export * from "./schema/worker-inference.js";
export * from "./schema/skill-history.js";
export * from "./schema/ui-command.js";
export type { GatewayErrorDetails, MissingScopeErrorDetails } from "./schema/error-codes.js";
export type {
GatewayErrorDetails,
McpAppViewExpiredErrorDetails,
MissingScopeErrorDetails,
} from "./schema/error-codes.js";
export * from "./schema/board.js";
export * from "./migration-api.js";
export type * from "./public-session-catalog.js";
@@ -24,6 +32,7 @@ import {
BoardGetParamsSchema,
BoardUpdateParamsSchema,
BoardWidgetContentSchema,
BoardWidgetAppViewParamsSchema,
BoardWidgetGrantParamsSchema,
BoardWidgetPutParamsSchema,
AgentEventSchema,
@@ -671,6 +680,7 @@ export const validateWorktreesListParams = lazyCompile(WorktreesListParamsSchema
export const validateBoardGetParams = lazyCompile(BoardGetParamsSchema);
export const validateBoardUpdateParams = lazyCompile(BoardUpdateParamsSchema);
export const validateBoardWidgetContent = lazyCompile(BoardWidgetContentSchema);
export const validateBoardWidgetAppViewParams = lazyCompile(BoardWidgetAppViewParamsSchema);
export const validateBoardWidgetPutParams = lazyCompile(BoardWidgetPutParamsSchema);
export const validateBoardWidgetGrantParams = lazyCompile(BoardWidgetGrantParamsSchema);
export const validateBoardEventParams = lazyCompile(BoardEventParamsSchema);
@@ -2,6 +2,8 @@ import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
BoardSnapshotSchema,
BoardWidgetAppViewParamsSchema,
BoardWidgetAppViewResultSchema,
BoardWidgetGrantParamsSchema,
BoardWidgetPutParamsSchema,
} from "./board.js";
@@ -62,6 +64,12 @@ describe("BoardSnapshotSchema", () => {
};
expect(Value.Check(BoardSnapshotSchema, snapshot)).toBe(true);
expect(
Value.Check(BoardSnapshotSchema, {
...snapshot,
widgets: [{ ...widget, instanceId: "widget-instance" }],
}),
).toBe(true);
});
});
@@ -75,6 +83,59 @@ describe("BoardWidgetPutParamsSchema", () => {
}),
).toBe(true);
});
it("requires an active source view for MCP App pins", () => {
const pin = {
sessionKey: "agent:main:main",
name: "weather-app",
content: {
kind: "mcp-app",
descriptor: {
viewId: "mcp-app-source",
serverName: "weather",
toolName: "show",
uiResourceUri: "ui://weather/app",
originSessionKey: "agent:main:main",
toolCallId: "call-1",
},
},
};
expect(Value.Check(BoardWidgetPutParamsSchema, pin)).toBe(true);
expect(
Value.Check(BoardWidgetPutParamsSchema, {
...pin,
content: {
...pin.content,
descriptor: { ...pin.content.descriptor, viewId: undefined },
},
}),
).toBe(false);
});
});
describe("BoardWidgetAppView schemas", () => {
it("binds lease requests to a board widget and returns its expiry", () => {
expect(
Value.Check(BoardWidgetAppViewParamsSchema, {
sessionKey: "agent:main:main",
name: "weather-app",
revision: 3,
instanceId: "widget-instance",
}),
).toBe(true);
expect(
Value.Check(BoardWidgetAppViewParamsSchema, {
sessionKey: "agent:main:main",
name: "weather-app",
}),
).toBe(false);
expect(
Value.Check(BoardWidgetAppViewResultSchema, {
viewId: "mcp-app-fresh",
expiresAtMs: 1_800_000,
}),
).toBe(true);
});
});
describe("BoardWidgetGrantParamsSchema", () => {
@@ -85,6 +146,7 @@ describe("BoardWidgetGrantParamsSchema", () => {
name: "status",
decision: "granted",
revision: 1,
instanceId: "widget-instance",
}),
).toBe(true);
expect(
+31 -1
View File
@@ -44,6 +44,7 @@ export const BoardWidgetSchema = closedObject({
Type.Literal("rejected"),
]),
revision: Type.Integer({ minimum: 1 }),
instanceId: Type.Optional(NonEmptyString),
declaredSummary: Type.Optional(Type.Array(Type.String())),
frameUrl: Type.Optional(Type.String()),
});
@@ -124,6 +125,16 @@ export const BoardMcpAppDescriptorSchema = closedObject({
});
export type BoardMcpAppDescriptor = Static<typeof BoardMcpAppDescriptorSchema>;
export const BoardMcpAppPinDescriptorSchema = closedObject({
viewId: NonEmptyString,
serverName: NonEmptyString,
toolName: NonEmptyString,
uiResourceUri: NonEmptyString,
originSessionKey: NonEmptyString,
toolCallId: NonEmptyString,
});
export type BoardMcpAppPinDescriptor = Static<typeof BoardMcpAppPinDescriptorSchema>;
export const BoardWidgetHtmlContentSchema = closedObject({
kind: Type.Literal("html"),
html: Type.String({ maxLength: 262_144 }),
@@ -132,6 +143,10 @@ export const BoardWidgetMcpAppContentSchema = closedObject({
kind: Type.Literal("mcp-app"),
descriptor: BoardMcpAppDescriptorSchema,
});
export const BoardWidgetMcpAppPutContentSchema = closedObject({
kind: Type.Literal("mcp-app"),
descriptor: BoardMcpAppPinDescriptorSchema,
});
export const BoardWidgetContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
@@ -146,7 +161,7 @@ export type BoardCanvasDocumentSource = Static<typeof BoardCanvasDocumentSourceS
export const BoardWidgetPutContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardWidgetMcpAppPutContentSchema,
BoardCanvasDocumentSourceSchema,
]);
export type BoardWidgetPutContent = Static<typeof BoardWidgetPutContentSchema>;
@@ -181,9 +196,24 @@ export const BoardWidgetGrantParamsSchema = closedObject({
name: BoardWidgetNameSchema,
decision: Type.Union([Type.Literal("granted"), Type.Literal("rejected")]),
revision: Type.Integer({ minimum: 1 }),
instanceId: Type.Optional(NonEmptyString),
});
export type BoardWidgetGrantParams = Static<typeof BoardWidgetGrantParamsSchema>;
export const BoardWidgetAppViewParamsSchema = closedObject({
sessionKey: NonEmptyString,
name: BoardWidgetNameSchema,
revision: Type.Integer({ minimum: 1 }),
instanceId: Type.Optional(NonEmptyString),
});
export type BoardWidgetAppViewParams = Static<typeof BoardWidgetAppViewParamsSchema>;
export const BoardWidgetAppViewResultSchema = closedObject({
viewId: NonEmptyString,
expiresAtMs: Type.Integer({ minimum: 0 }),
});
export type BoardWidgetAppViewResult = Static<typeof BoardWidgetAppViewResultSchema>;
export const BoardEventParamsSchema = closedObject({
sessionKey: NonEmptyString,
widget: BoardWidgetNameSchema,
@@ -4,6 +4,8 @@ import {
ErrorCodes,
GatewayErrorDetailCodes,
GatewayErrorDetailsSchema,
isMcpAppViewExpiredError,
McpAppViewExpiredErrorDetailsSchema,
MissingScopeErrorDetailsSchema,
missingScopeErrorShape,
readMissingScopeError,
@@ -25,6 +27,14 @@ describe("gateway error details", () => {
);
});
it("identifies MCP App lease expiry without message parsing", () => {
const details = { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED };
expect(Value.Check(McpAppViewExpiredErrorDetailsSchema, details)).toBe(true);
expect(Value.Check(GatewayErrorDetailsSchema, details)).toBe(true);
expect(isMcpAppViewExpiredError({ details })).toBe(true);
expect(isMcpAppViewExpiredError(new Error("upstream token expired"))).toBe(false);
});
it("builds a distinct forbidden missing-scope response", () => {
expect(
missingScopeErrorShape({
@@ -15,7 +15,9 @@ export {
GatewayErrorDetailCodes,
type ErrorCode,
type GatewayErrorDetails,
type McpAppViewExpiredErrorDetails,
type MissingScopeErrorDetails,
isMcpAppViewExpiredError,
readMissingScopeError,
readMissingScopeErrorDetails,
} from "../gateway-error-details.js";
@@ -27,8 +29,15 @@ export const MissingScopeErrorDetailsSchema = closedObject({
requiredScopes: Type.Array(NonEmptyString, { minItems: 1 }),
});
export const McpAppViewExpiredErrorDetailsSchema = closedObject({
code: Type.Literal(GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED),
});
/** Structured details emitted by method-level authorization failures. */
export const GatewayErrorDetailsSchema = MissingScopeErrorDetailsSchema;
export const GatewayErrorDetailsSchema = Type.Union([
MissingScopeErrorDetailsSchema,
McpAppViewExpiredErrorDetailsSchema,
]);
/** Builds the canonical gateway error payload while preserving optional retry metadata. */
export function errorShape(
@@ -160,6 +160,7 @@ import {
BoardFocusTabCommandSchema,
BoardGetParamsSchema,
BoardMcpAppDescriptorSchema,
BoardMcpAppPinDescriptorSchema,
BoardOpSchema,
BoardSetChatDockCommandSchema,
BoardSnapshotSchema,
@@ -170,9 +171,12 @@ import {
BoardTabUpdateOpSchema,
BoardUpdateParamsSchema,
BoardWidgetContentSchema,
BoardWidgetAppViewParamsSchema,
BoardWidgetAppViewResultSchema,
BoardWidgetGrantParamsSchema,
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardWidgetMcpAppPutContentSchema,
BoardWidgetMoveOpSchema,
BoardWidgetPutContentSchema,
BoardWidgetPutParamsSchema,
@@ -280,7 +284,11 @@ import {
WorkerEnvironmentStateSchema,
WorkerTunnelStatusSchema,
} from "./environments.js";
import { GatewayErrorDetailsSchema } from "./error-codes.js";
import {
GatewayErrorDetailsSchema,
McpAppViewExpiredErrorDetailsSchema,
MissingScopeErrorDetailsSchema,
} from "./error-codes.js";
import {
ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
@@ -600,8 +608,10 @@ export const ProtocolSchemas = {
BoardWidgetRemoveOp: BoardWidgetRemoveOpSchema,
BoardOp: BoardOpSchema,
BoardMcpAppDescriptor: BoardMcpAppDescriptorSchema,
BoardMcpAppPinDescriptor: BoardMcpAppPinDescriptorSchema,
BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema,
BoardWidgetMcpAppPutContent: BoardWidgetMcpAppPutContentSchema,
BoardCanvasDocumentSource: BoardCanvasDocumentSourceSchema,
BoardWidgetContent: BoardWidgetContentSchema,
BoardWidgetPutContent: BoardWidgetPutContentSchema,
@@ -609,6 +619,8 @@ export const ProtocolSchemas = {
BoardUpdateParams: BoardUpdateParamsSchema,
BoardWidgetPutParams: BoardWidgetPutParamsSchema,
BoardWidgetGrantParams: BoardWidgetGrantParamsSchema,
BoardWidgetAppViewParams: BoardWidgetAppViewParamsSchema,
BoardWidgetAppViewResult: BoardWidgetAppViewResultSchema,
BoardEventParams: BoardEventParamsSchema,
BoardChangedEvent: BoardChangedEventSchema,
BoardFocusTabCommand: BoardFocusTabCommandSchema,
@@ -628,6 +640,8 @@ export const ProtocolSchemas = {
StateVersion: StateVersionSchema,
Snapshot: SnapshotSchema,
ErrorShape: ErrorShapeSchema,
MissingScopeErrorDetails: MissingScopeErrorDetailsSchema,
McpAppViewExpiredErrorDetails: McpAppViewExpiredErrorDetailsSchema,
GatewayErrorDetails: GatewayErrorDetailsSchema,
GatewaySuspendTaskBlocker: GatewaySuspendTaskBlockerSchema,
GatewaySuspendBlocker: GatewaySuspendBlockerSchema,
+38
View File
@@ -568,6 +568,43 @@ function swiftUnionCaseName(value: boolean | number | string | null, fallback: s
return safeName(String(value));
}
function emitDiscriminatedUnionCompatibility(name: string): string[] {
if (name !== "GatewayErrorDetails") {
return [];
}
// GatewayErrorDetails shipped as the missing-scope struct before it gained an expiry variant.
// Keep its initializer and property access source-compatible while the enum carries both cases.
return [
" public init(code: String, missingscope: String, requiredscopes: [String]) {",
" self = .missingScope(",
" MissingScopeErrorDetails(",
" code: code,",
" missingscope: missingscope,",
" requiredscopes: requiredscopes",
" )",
" )",
" }",
"",
" public var code: String {",
" switch self {",
" case .missingScope(let value): value.code",
" case .mcpAppViewExpired(let value): value.code",
" }",
" }",
"",
" public var missingscope: String {",
" if case .missingScope(let value) = self { return value.missingscope }",
' return ""',
" }",
"",
" public var requiredscopes: [String] {",
" if case .missingScope(let value) = self { return value.requiredscopes }",
" return []",
" }",
"",
];
}
function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | undefined {
const branches = schema.oneOf ?? schema.anyOf;
if (!branches || branches.length < 2) {
@@ -627,6 +664,7 @@ function emitDiscriminatedUnion(name: string, schema: JsonSchema): string | unde
`public enum ${name}: Codable, Sendable {`,
...resolvedCases.map((entry) => ` case ${entry.caseName}(${entry.branchName})`),
"",
...emitDiscriminatedUnionCompatibility(name),
" private enum CodingKeys: String, CodingKey {",
` case discriminator = "${discriminator}"`,
" }",
@@ -454,6 +454,7 @@ export async function materializeBundleMcpToolsForRun(params: {
(agentResult.details as Record<string, unknown>).mcpAppPreview = buildMcpAppCanvasPayload(
{
...view,
...(params.runtime.sessionKey ? { originSessionKey: params.runtime.sessionKey } : {}),
...(result["_meta"] !== undefined ? { resultMetaState: "unavailable" as const } : {}),
},
);
@@ -157,6 +157,7 @@ describe("createBundleMcpToolRuntime", () => {
_meta: { "ui/state": { selected: true } },
},
});
sessionRuntime.sessionKey = "agent:main:main";
sessionRuntime.mcpAppsEnabled = true;
sessionRuntime.readResource = async () => ({
contents: [
@@ -183,6 +184,7 @@ describe("createBundleMcpToolRuntime", () => {
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
originSessionKey: "agent:main:main",
resultMetaState: "unavailable",
},
},
+3
View File
@@ -62,6 +62,7 @@ describe("MCP App UI resources", () => {
},
],
}));
const authorizeAppToolCall = vi.fn(async () => true);
const result = await fetchMcpAppView({
runtime: sessionRuntime,
serverName: "demo",
@@ -69,6 +70,7 @@ describe("MCP App UI resources", () => {
uiResourceUri: "ui://demo/app",
toolInput: { city: "Paris" },
toolResult: { content: [{ type: "text", text: "ok" }] },
authorizeAppToolCall,
});
expect(result?.viewId).toMatch(/^mcp-app-/u);
@@ -76,6 +78,7 @@ describe("MCP App UI resources", () => {
html: "<html>demo</html>",
toolInput: { city: "Paris" },
permissions: { geolocation: {} },
authorizeAppToolCall,
});
expect(
getMcpAppViewLease(
+7
View File
@@ -26,10 +26,12 @@ export type McpAppViewLease = {
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId?: string;
html: string;
csp?: McpAppCsp;
permissions?: McpAppPermissions;
allowedAppToolNames?: ReadonlySet<string>;
authorizeAppToolCall?: () => boolean | Promise<boolean>;
readOnly?: true;
toolInput: unknown;
toolResult: CallToolResult;
@@ -222,6 +224,7 @@ export async function fetchMcpAppView(params: {
toolInput: unknown;
toolResult: CallToolResult;
allowedAppToolNames?: ReadonlySet<string>;
authorizeAppToolCall?: () => boolean | Promise<boolean>;
readOnly?: true;
viewId?: string;
}): Promise<
@@ -276,12 +279,14 @@ export async function fetchMcpAppView(params: {
serverName: params.serverName,
toolName: params.toolName,
uiResourceUri: params.uiResourceUri,
...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
html,
...(csp ? { csp } : {}),
...(permissions ? { permissions } : {}),
...(params.allowedAppToolNames
? { allowedAppToolNames: new Set(params.allowedAppToolNames) }
: {}),
...(params.authorizeAppToolCall ? { authorizeAppToolCall: params.authorizeAppToolCall } : {}),
...(params.readOnly ? { readOnly: true as const } : {}),
toolInput: params.toolInput,
toolResult: params.toolResult,
@@ -362,6 +367,7 @@ export function buildMcpAppCanvasPayload(view: {
toolName: string;
uiResourceUri: string;
toolCallId?: string;
originSessionKey?: string;
resultMetaState?: "unavailable";
}) {
assertBoundedViewDescriptor(view);
@@ -380,6 +386,7 @@ export function buildMcpAppCanvasPayload(view: {
toolName: view.toolName,
uiResourceUri: view.uiResourceUri,
...(view.toolCallId ? { toolCallId: view.toolCallId } : {}),
...(view.originSessionKey ? { originSessionKey: view.originSessionKey } : {}),
...(view.resultMetaState ? { resultMetaState: view.resultMetaState } : {}),
},
};
+1
View File
@@ -47,6 +47,7 @@ function cloneWidget(widget: BoardWidget): BoardWidget {
position: widget.position,
grantState: widget.grantState,
revision: widget.revision,
...(widget.instanceId !== undefined ? { instanceId: widget.instanceId } : {}),
...(widget.declaredSummary !== undefined
? { declaredSummary: [...widget.declaredSummary] }
: {}),
+85
View File
@@ -143,6 +143,11 @@ describe.each([
},
revision: 1,
});
expect(store.readWidgetMcpApp("agent:main:board", "app")).toMatchObject({
revision: 1,
grantGeneration: expect.stringMatching(/^[a-f0-9]{32}$/u),
});
expect(store.getSnapshot("agent:main:board").widgets[1]?.instanceId).toMatch(/^[a-f0-9]{32}$/u);
});
it("preserves grants only for equal or narrower declarations", () => {
@@ -196,6 +201,86 @@ describe.each([
expect(wider.widgets[0]).toMatchObject({ revision: 4, grantState: "pending" });
});
it("requires a fresh grant when an MCP app widget changes servers", () => {
const store = createStore();
const descriptor = {
serverName: "server-a",
toolName: "weather",
uiResourceUri: "ui://weather",
originSessionKey: "agent:main:board",
toolCallId: "call-a",
};
const first = store.putWidget({
sessionKey: "agent:main:board",
name: "weather",
content: { kind: "mcp-app", descriptor },
declared: { tools: ["refresh"] },
});
store.grant("agent:main:board", "weather", "granted", 1, first.widgets[0]?.instanceId);
const differentServer = store.putWidget({
sessionKey: "agent:main:board",
name: "weather",
content: {
kind: "mcp-app",
descriptor: { ...descriptor, serverName: "server-b", toolCallId: "call-b" },
},
declared: { tools: ["refresh"] },
});
expect(differentServer.widgets[0]).toMatchObject({ revision: 2, grantState: "pending" });
store.grant(
"agent:main:board",
"weather",
"granted",
2,
differentServer.widgets[0]?.instanceId,
);
const sameServer = store.putWidget({
sessionKey: "agent:main:board",
name: "weather",
content: {
kind: "mcp-app",
descriptor: { ...descriptor, serverName: "server-b", toolCallId: "call-c" },
},
declared: { tools: ["refresh"] },
});
expect(sameServer.widgets[0]).toMatchObject({ revision: 3, grantState: "granted" });
});
it("rejects a delayed MCP App grant after remove and same-name replacement", () => {
const store = createStore();
const putApp = (serverName: string) =>
store.putWidget({
sessionKey: "agent:main:board",
name: "app",
content: {
kind: "mcp-app",
descriptor: {
serverName,
toolName: "tool",
uiResourceUri: `ui://${serverName}`,
originSessionKey: "agent:main:board",
toolCallId: `call-${serverName}`,
},
},
declared: { tools: ["refresh"] },
});
const original = putApp("server-a");
store.applyOps("agent:main:board", [{ kind: "widget_remove", name: "app" }]);
const replacement = putApp("server-b");
expect(replacement.widgets[0]).toMatchObject({ revision: 1, grantState: "pending" });
expect(replacement.widgets[0]?.instanceId).not.toBe(original.widgets[0]?.instanceId);
expect(() =>
store.grant("agent:main:board", "app", "granted", 1, original.widgets[0]?.instanceId),
).toThrow("instance changed");
expect(
store.grant("agent:main:board", "app", "granted", 1, replacement.widgets[0]?.instanceId)
.widgets[0],
).toMatchObject({ grantState: "granted" });
});
it("rejects stale grant revisions before accepting the current one", () => {
const store = createStore();
store.putWidget({
+96 -9
View File
@@ -26,7 +26,14 @@ type BoardWidgetMcpAppDocument = {
descriptor: BoardMcpAppDescriptor;
revision: number;
};
type StoredBoardWidgetMcpAppDocument = BoardWidgetMcpAppDocument & {
grantState: "none" | "pending" | "granted" | "rejected";
declaredTools: string[];
grantGeneration?: string;
};
export type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDocument;
export type BoardWidgetMcpAppRead = StoredBoardWidgetMcpAppDocument;
type StoredBoardWidgetDocument = BoardWidgetHtmlDocument | StoredBoardWidgetMcpAppDocument;
export interface BoardStore {
getSnapshot(sessionKey: string): BoardSnapshot;
@@ -37,14 +44,16 @@ export interface BoardStore {
name: string,
decision: "granted" | "rejected",
revision: number,
instanceId?: string,
): BoardSnapshot;
readWidgetHtml(sessionKey: string, name: string): BoardWidgetDocument | undefined;
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppRead | undefined;
listSessionsWithBoards(): string[];
}
type StoredBoard = {
snapshot: BoardSnapshot;
documents: Map<string, BoardWidgetDocument>;
documents: Map<string, StoredBoardWidgetDocument>;
};
const BOARD_MAX_WIDGETS = 48;
@@ -72,17 +81,25 @@ function createBoardWidgetDocument(
content: BoardWidgetContent,
revision: number,
grantState: BoardWidgetHtmlDocument["grantState"],
): BoardWidgetDocument {
declaredTools: readonly string[],
instanceId: string,
): StoredBoardWidgetDocument {
if (content.kind === "html") {
return {
html: content.html,
revision,
sha256: createHash("sha256").update(content.html).digest("hex"),
viewGeneration: randomBytes(16).toString("hex"),
viewGeneration: instanceId,
grantState,
};
}
return { descriptor: { ...content.descriptor }, revision };
return {
descriptor: { ...content.descriptor },
revision,
grantState,
declaredTools: [...declaredTools],
grantGeneration: instanceId,
};
}
export function createBoardDeclaredSummary(
@@ -95,9 +112,30 @@ export function createBoardDeclaredSummary(
return lines.length > 0 ? lines : undefined;
}
type BoardWidgetGrantScope = { kind: "html" } | { kind: "mcp-app"; serverName: string };
function grantScopeForContent(content: BoardWidgetContent): BoardWidgetGrantScope {
return content.kind === "html"
? { kind: "html" }
: { kind: "mcp-app", serverName: content.descriptor.serverName };
}
export function boardWidgetGrantScopeMatches(
previous: BoardWidgetGrantScope,
content: BoardWidgetContent,
): boolean {
const next = grantScopeForContent(content);
if (previous.kind === "html" || next.kind === "html") {
return previous.kind === next.kind;
}
return previous.serverName === next.serverName;
}
export function createBoardWidgetPutSnapshot(
prior: BoardSnapshot,
params: BoardWidgetMaterializedPutParams,
grantScopeMatches = true,
instanceId?: string,
): BoardSnapshot {
if (
params.content.kind === "html" &&
@@ -129,6 +167,7 @@ export function createBoardWidgetPutSnapshot(
// A grant follows new bytes only when every declared capability was already approved.
// Any widening must return to pending before the widget can be served.
const preservesGrant =
grantScopeMatches &&
existing?.grantState === "granted" &&
(declaredSummary ?? []).every((entry) => existing.declaredSummary?.includes(entry));
layout = insertBoardWidget(
@@ -147,6 +186,7 @@ export function createBoardWidgetPutSnapshot(
position: existing?.position ?? layout.widgets.length,
grantState: preservesGrant ? "granted" : declaredSummary ? "pending" : "none",
revision: widgetRevision,
...(instanceId ? { instanceId } : {}),
...(declaredSummary ? { declaredSummary } : {}),
},
{
@@ -170,6 +210,7 @@ export function createBoardGrantSnapshot(
name: string,
decision: "granted" | "rejected",
revision: number,
instanceId?: string,
): BoardSnapshot {
const widget = current.widgets.find((candidate) => candidate.name === name);
if (!widget) {
@@ -181,6 +222,12 @@ export function createBoardGrantSnapshot(
`board widget revision changed: ${name} is revision ${widget.revision}, not ${revision}`,
);
}
if (
widget.contentKind === "mcp-app" &&
(!widget.instanceId || widget.instanceId !== instanceId)
) {
throw new BoardValidationError("conflict", `board widget instance changed: ${name}`);
}
if (widget.grantState !== "pending") {
throw new BoardValidationError(
"invalid_operation",
@@ -229,13 +276,29 @@ export class InMemoryBoardStore implements BoardStore {
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot {
const current = this.boards.get(params.sessionKey);
const prior = current?.snapshot ?? emptyBoardSnapshot(params.sessionKey);
const snapshot = createBoardWidgetPutSnapshot(prior, params);
const existingDocument = current?.documents.get(params.name);
const grantScopeMatches = existingDocument
? boardWidgetGrantScopeMatches(
"html" in existingDocument
? { kind: "html" }
: { kind: "mcp-app", serverName: existingDocument.descriptor.serverName },
params.content,
)
: true;
const instanceId = randomBytes(16).toString("hex");
const snapshot = createBoardWidgetPutSnapshot(prior, params, grantScopeMatches, instanceId);
const documents = new Map(current?.documents ?? []);
const widgetRevision = snapshot.widgets.find((widget) => widget.name === params.name)!.revision;
const widget = snapshot.widgets.find((candidate) => candidate.name === params.name)!;
documents.set(
params.name,
createBoardWidgetDocument(params.content, widgetRevision, widget.grantState),
createBoardWidgetDocument(
params.content,
widgetRevision,
widget.grantState,
params.declared?.tools ?? [],
instanceId,
),
);
this.boards.set(params.sessionKey, { snapshot, documents });
return cloneBoardSnapshot(snapshot);
@@ -246,14 +309,21 @@ export class InMemoryBoardStore implements BoardStore {
name: string,
decision: "granted" | "rejected",
revision: number,
instanceId?: string,
): BoardSnapshot {
const current = this.boards.get(sessionKey);
if (!current) {
throw new BoardValidationError("not_found", `board widget not found: ${name}`);
}
const snapshot = createBoardGrantSnapshot(current.snapshot, name, decision, revision);
const snapshot = createBoardGrantSnapshot(
current.snapshot,
name,
decision,
revision,
instanceId,
);
const document = current.documents.get(name);
if (document && "html" in document) {
if (document) {
document.grantState = decision;
}
this.boards.set(sessionKey, { snapshot, documents: current.documents });
@@ -267,7 +337,24 @@ export class InMemoryBoardStore implements BoardStore {
}
return "html" in document
? { ...document }
: { descriptor: { ...document.descriptor }, revision: document.revision };
: {
descriptor: { ...document.descriptor },
revision: document.revision,
};
}
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppRead | undefined {
const document = this.boards.get(sessionKey)?.documents.get(name);
if (!document || "html" in document) {
return undefined;
}
return {
descriptor: { ...document.descriptor },
revision: document.revision,
grantState: document.grantState,
declaredTools: [...document.declaredTools],
...(document.grantGeneration ? { grantGeneration: document.grantGeneration } : {}),
};
}
listSessionsWithBoards(): string[] {
+94 -7
View File
@@ -30,12 +30,14 @@ import {
} from "../state/openclaw-agent-db.js";
import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js";
import {
boardWidgetGrantScopeMatches,
cloneBoardSnapshot,
createBoardDeclaredSummary,
createBoardGrantSnapshot,
createBoardWidgetPutSnapshot,
type BoardStore,
type BoardWidgetDocument,
type BoardWidgetMcpAppRead,
} from "./board-store.js";
type BoardDatabase = Pick<
@@ -99,17 +101,31 @@ type SqliteBoardStoreOptions = {
env?: NodeJS.ProcessEnv;
};
function parseManifest(value: string): BoardWidgetMaterializedPutParams["declared"] {
const parsed = JSON.parse(value) as { netOrigins?: unknown; tools?: unknown };
type StoredBoardWidgetManifest = NonNullable<BoardWidgetMaterializedPutParams["declared"]> & {
mcpAppGrantGeneration?: string;
};
function parseManifest(value: string): StoredBoardWidgetManifest {
const parsed = JSON.parse(value) as {
mcpAppGrantGeneration?: unknown;
netOrigins?: unknown;
tools?: unknown;
};
const netOrigins = Array.isArray(parsed.netOrigins)
? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string")
: undefined;
const tools = Array.isArray(parsed.tools)
? parsed.tools.filter((entry): entry is string => typeof entry === "string")
: undefined;
const mcpAppGrantGeneration =
typeof parsed.mcpAppGrantGeneration === "string" &&
/^[a-f0-9]{32}$/u.test(parsed.mcpAppGrantGeneration)
? parsed.mcpAppGrantGeneration
: undefined;
return {
...(netOrigins?.length ? { netOrigins } : {}),
...(tools?.length ? { tools } : {}),
...(mcpAppGrantGeneration ? { mcpAppGrantGeneration } : {}),
};
}
@@ -127,7 +143,10 @@ function rowToTab(row: SelectedBoardTabRow): BoardTab {
}
function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
const declaredSummary = createBoardDeclaredSummary(parseManifest(row.manifest));
const manifest = parseManifest(row.manifest);
const declaredSummary = createBoardDeclaredSummary(manifest);
const instanceId =
row.content_kind === "mcp-app" ? manifest.mcpAppGrantGeneration : row.view_generation;
return {
name: row.name,
tabId: row.tab_id,
@@ -138,6 +157,7 @@ function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
position: row.position,
grantState: row.grant_state as BoardWidget["grantState"],
revision: row.revision,
...(instanceId ? { instanceId } : {}),
...(declaredSummary ? { declaredSummary } : {}),
};
}
@@ -293,7 +313,10 @@ function contentFields(
viewGeneration: string,
now: number,
) {
const manifest = JSON.stringify(params.declared ?? {});
const manifest = JSON.stringify({
...params.declared,
...(params.content.kind === "mcp-app" ? { mcpAppGrantGeneration: viewGeneration } : {}),
});
if (params.content.kind === "html") {
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
return {
@@ -449,9 +472,26 @@ export class SqliteBoardStore implements BoardStore {
);
}
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams);
const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
const existing = previous.widgetRows.find((row) => row.name === canonicalParams.name);
const grantScopeMatches = existing
? existing.content_kind === "html"
? boardWidgetGrantScopeMatches({ kind: "html" }, canonicalParams.content)
: existing.descriptor_json !== null &&
boardWidgetGrantScopeMatches(
{
kind: "mcp-app",
serverName: parseDescriptor(existing.descriptor_json).serverName,
},
canonicalParams.content,
)
: true;
const next = createBoardWidgetPutSnapshot(
previous.snapshot,
canonicalParams,
grantScopeMatches,
viewGeneration,
);
const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
const now = Date.now();
upsertTabs(transactionDatabase, previous, next);
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
@@ -502,6 +542,7 @@ export class SqliteBoardStore implements BoardStore {
name: string,
decision: "granted" | "rejected",
revision: number,
instanceId?: string,
): BoardSnapshot {
const { database, resolved } = this.prepareWrite(sessionKey);
return runOpenClawAgentWriteTransaction(
@@ -513,7 +554,13 @@ export class SqliteBoardStore implements BoardStore {
);
}
const previous = readStoredBoard(transactionDatabase, resolved.sessionKey);
const next = createBoardGrantSnapshot(previous.snapshot, name, decision, revision);
const next = createBoardGrantSnapshot(
previous.snapshot,
name,
decision,
revision,
instanceId,
);
upsertTabs(transactionDatabase, previous, next);
const row = previous.widgetRows.find((candidate) => candidate.name === name)!;
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
@@ -587,6 +634,46 @@ export class SqliteBoardStore implements BoardStore {
return result.found ? result.value : undefined;
}
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppRead | undefined {
const resolved = this.resolve(sessionKey);
const result = withOpenClawAgentDatabaseReadOnly(
(database) => {
if (!hasSession(database, resolved.sessionKey) || !boardTablesPresent(database)) {
return undefined;
}
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
const row = executeSqliteQuerySync(
database.db,
db
.selectFrom("board_widgets")
.select(["content_kind", "descriptor_json", "revision", "grant_state", "manifest"])
.where("session_key", "=", resolved.sessionKey)
.where("name", "=", name)
.limit(1),
).rows[0];
if (!row || row.content_kind !== "mcp-app" || row.descriptor_json === null) {
return undefined;
}
const manifest = parseManifest(row.manifest);
return {
descriptor: parseDescriptor(row.descriptor_json),
revision: row.revision,
grantState: row.grant_state as BoardWidget["grantState"],
declaredTools: manifest.tools ?? [],
...(manifest.mcpAppGrantGeneration
? { grantGeneration: manifest.mcpAppGrantGeneration }
: {}),
};
},
{
agentId: resolved.agentId,
...(resolved.path ? { path: resolved.path } : {}),
env: this.options.env,
},
);
return result.found ? result.value : undefined;
}
listSessionsWithBoards(): string[] {
const sessionKeys = new Set<string>();
const agentIds = new Set(
+2
View File
@@ -35,6 +35,7 @@ describe("extractCanvasFromText", () => {
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
originSessionKey: "agent:main:main",
resultMetaState: "unavailable",
},
},
@@ -47,6 +48,7 @@ describe("extractCanvasFromText", () => {
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
originSessionKey: "agent:main:main",
resultMetaState: "unavailable",
},
});
+3
View File
@@ -15,6 +15,7 @@ type McpAppPreviewDescriptor = {
toolName?: string;
uiResourceUri?: string;
toolCallId?: string;
originSessionKey?: string;
resultMetaState?: "unavailable";
};
@@ -68,6 +69,7 @@ function coerceMcpAppDescriptor(
const toolName = getRecordStringField(record, "toolName");
const uiResourceUri = getRecordStringField(record, "uiResourceUri");
const toolCallId = getRecordStringField(record, "toolCallId");
const originSessionKey = getRecordStringField(record, "originSessionKey");
const resultMetaState = record?.resultMetaState === "unavailable" ? "unavailable" : undefined;
const hasCompleteDescriptor = Boolean(
serverName &&
@@ -86,6 +88,7 @@ function coerceMcpAppDescriptor(
toolName,
uiResourceUri,
toolCallId,
...(originSessionKey && originSessionKey.length <= 512 ? { originSessionKey } : {}),
...(resultMetaState ? { resultMetaState } : {}),
}
: { viewId };
+32 -1
View File
@@ -31,6 +31,13 @@ export type McpAppActiveView = {
view: McpAppViewLease;
};
export class McpAppViewExpiredError extends Error {
constructor() {
super("MCP App view expired or is not authorized for this session");
this.name = "McpAppViewExpiredError";
}
}
export type McpAppOperation =
| Pick<CallToolRequest, "method" | "params">
| Pick<ListToolsRequest, "method" | "params">
@@ -60,6 +67,29 @@ function isAllowedByView(view: McpAppViewLease, toolName: string): boolean {
return view.allowedAppToolNames === undefined || view.allowedAppToolNames.has(toolName);
}
export async function requireMcpAppViewAuthorization(view: McpAppViewLease): Promise<void> {
if (view.authorizeAppToolCall && !(await view.authorizeAppToolCall())) {
throw new Error("MCP App widget grant is no longer active");
}
}
export async function resolveMcpAppAllowedToolNames(active: McpAppActiveView): Promise<string[]> {
if (active.view.readOnly === true) {
return [];
}
const catalog = await active.runtime.getCatalog();
return catalog.tools
.filter(
(tool) =>
tool.serverName === active.view.serverName &&
isAppCallableTool(tool) &&
isAllowedByView(active.view, tool.toolName),
)
.map((tool) => tool.toolName)
.filter((toolName, index, all) => all.indexOf(toolName) === index)
.toSorted();
}
async function requireCallableTool(
runtime: SessionMcpRuntime,
view: McpAppViewLease,
@@ -68,6 +98,7 @@ async function requireCallableTool(
if (view.readOnly === true) {
throw new Error("MCP App view is read-only");
}
await requireMcpAppViewAuthorization(view);
const catalog = await runtime.getCatalog();
const tool = catalog.tools.find(
(entry) => entry.serverName === view.serverName && entry.toolName === toolName,
@@ -103,7 +134,7 @@ export async function resolveMcpAppActiveView(params: {
})
: undefined;
if (!restored) {
throw new Error("MCP App view expired or is not authorized for this session");
throw new McpAppViewExpiredError();
}
return restored;
}
+53 -2
View File
@@ -32,7 +32,7 @@ vi.mock("./session-utils.js", () => ({
loadSessionEntry: mocks.loadSessionEntry,
}));
import { restoreMcpAppView } from "./mcp-app-reconstruction.js";
import { mintMcpAppViewFromTranscript, restoreMcpAppView } from "./mcp-app-reconstruction.js";
const runtime = { mcpAppsEnabled: true };
const view = { id: "view-lease" };
@@ -50,7 +50,9 @@ beforeEach(() => {
storePath: "/tmp/sessions.json",
});
mocks.getOrCreateSessionMcpRuntime.mockResolvedValue(runtime);
mocks.fetchMcpAppView.mockResolvedValue(undefined);
mocks.fetchMcpAppView.mockImplementation(async (params: { viewId?: string }) => ({
viewId: params.viewId ?? "mcp-app-fresh",
}));
mocks.getMcpAppViewLease.mockReturnValue(view);
});
@@ -128,6 +130,55 @@ describe("MCP App transcript reconstruction", () => {
});
});
it("mints a fresh board lease from the stable transcript descriptor", async () => {
mocks.visitSessionMessagesAsync.mockImplementation(
async (_scope: unknown, visit: (message: unknown) => void) => {
for (const message of [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-1",
name: "demo__show",
arguments: { city: "Paris" },
},
],
},
toolResult("mcp-app-original", "call-1"),
]) {
visit(message);
}
},
);
const authorizeAppToolCall = vi.fn(async () => true);
await expect(
mintMcpAppViewFromTranscript({
cfg: {},
sessionKey: "agent:main:main",
descriptor: {
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
originSessionKey: "agent:main:main",
toolCallId: "call-1",
},
allowedAppToolNames: new Set(["refresh"]),
authorizeAppToolCall,
readOnly: false,
}),
).resolves.toEqual({ runtime, view });
expect(mocks.fetchMcpAppView).toHaveBeenCalledWith(
expect.objectContaining({
allowedAppToolNames: new Set(["refresh"]),
authorizeAppToolCall,
toolCallId: "call-1",
}),
);
expect(mocks.fetchMcpAppView.mock.calls.at(-1)?.[0]).not.toHaveProperty("viewId");
});
it("rejects client-selected descriptors that do not match transcript ownership", async () => {
await expect(
restoreFromMessages(
+72 -14
View File
@@ -1,4 +1,5 @@
import { type CallToolResult, ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js";
import type { BoardMcpAppDescriptor } from "../../packages/gateway-protocol/src/index.js";
import { getOrCreateSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js";
import type { SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
@@ -23,6 +24,8 @@ type McpAppDescriptor = {
resultMetaState?: "unavailable";
};
type TranscriptLookup = { viewId: string } | { descriptor: BoardMcpAppDescriptor };
type ReconstructionData = {
descriptor: McpAppDescriptor;
toolInput: unknown;
@@ -130,7 +133,26 @@ function readCallToolResult(message: Record<string, unknown>, details: Record<st
} as CallToolResult;
}
function readTranscriptResult(value: unknown, viewId: string): TranscriptResultRead | undefined {
function matchesLookup(
rawDescriptor: Record<string, unknown> | undefined,
lookup: TranscriptLookup,
): boolean {
if ("viewId" in lookup) {
return readString(rawDescriptor, "viewId") === lookup.viewId;
}
const descriptor = lookup.descriptor;
return (
readString(rawDescriptor, "serverName") === descriptor.serverName &&
readString(rawDescriptor, "toolName") === descriptor.toolName &&
readString(rawDescriptor, "uiResourceUri") === descriptor.uiResourceUri &&
readString(rawDescriptor, "toolCallId") === descriptor.toolCallId
);
}
function readTranscriptResult(
value: unknown,
lookup: TranscriptLookup,
): TranscriptResultRead | undefined {
const message = asRecord(value);
if (!message || readString(message, "role")?.toLowerCase() !== "toolresult") {
return undefined;
@@ -141,7 +163,7 @@ function readTranscriptResult(value: unknown, viewId: string): TranscriptResultR
}
const preview = asRecord(details.mcpAppPreview);
const rawDescriptor = asRecord(preview?.mcpApp);
if (readString(rawDescriptor, "viewId") !== viewId) {
if (!matchesLookup(rawDescriptor, lookup)) {
return undefined;
}
const descriptor = readDescriptor(rawDescriptor);
@@ -166,13 +188,13 @@ function readTranscriptResult(value: unknown, viewId: string): TranscriptResultR
/** Searches the full active transcript without retaining its messages in memory. */
async function findMcpAppReconstructionDataByVisit(
visitTranscript: TranscriptVisit,
viewId: string,
lookup: TranscriptLookup,
): Promise<ReconstructionData | undefined> {
let resultRead: TranscriptResultRead | undefined;
let resultIndex = -1;
let messageIndex = 0;
await visitTranscript((message) => {
const read = readTranscriptResult(message, viewId);
const read = readTranscriptResult(message, lookup);
if (read) {
resultRead = read;
resultIndex = messageIndex;
@@ -222,14 +244,15 @@ function getRestoreInFlight(): Map<string, Promise<ReconstructionResult | undefi
return created;
}
async function restoreMcpAppViewOnce(params: {
async function reconstructMcpAppView(params: {
cfg: OpenClawConfig;
sessionKey: string;
viewId: string;
lookup: TranscriptLookup;
allowedAppToolNames: ReadonlySet<string>;
authorizeAppToolCall?: () => boolean | Promise<boolean>;
readOnly: boolean;
viewId?: string;
}): Promise<ReconstructionResult | undefined> {
if (!params.viewId.startsWith("mcp-app-") || params.viewId.length > 128) {
return undefined;
}
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const loaded = loadSessionEntry(params.sessionKey, { agentId });
const sessionId = loaded.entry?.sessionId;
@@ -249,7 +272,7 @@ async function restoreMcpAppViewOnce(params: {
reason: "MCP App restart reconstruction",
cache: "reuse",
});
}, params.viewId);
}, params.lookup);
if (!data) {
return undefined;
}
@@ -263,7 +286,7 @@ async function restoreMcpAppViewOnce(params: {
if (runtime.mcpAppsEnabled !== true) {
return undefined;
}
await fetchMcpAppView({
const fetched = await fetchMcpAppView({
runtime,
serverName: data.descriptor.serverName,
toolName: data.descriptor.toolName,
@@ -271,14 +294,49 @@ async function restoreMcpAppViewOnce(params: {
toolCallId: data.descriptor.toolCallId,
toolInput: data.toolInput,
toolResult: data.toolResult,
viewId: data.descriptor.viewId,
...(params.viewId ? { viewId: params.viewId } : {}),
allowedAppToolNames: params.allowedAppToolNames,
...(params.authorizeAppToolCall ? { authorizeAppToolCall: params.authorizeAppToolCall } : {}),
...(params.readOnly ? { readOnly: true as const } : {}),
});
const view = fetched ? getMcpAppViewLease(fetched.viewId, runtime) : undefined;
return view ? { runtime, view } : undefined;
}
async function restoreMcpAppViewOnce(params: {
cfg: OpenClawConfig;
sessionKey: string;
viewId: string;
}): Promise<ReconstructionResult | undefined> {
if (!params.viewId.startsWith("mcp-app-") || params.viewId.length > 128) {
return undefined;
}
return await reconstructMcpAppView({
...params,
lookup: { viewId: params.viewId },
// A reconstructed preview can render and read its owning server resources,
// but cannot call tools without a fresh run carrying current effective policy.
allowedAppToolNames: new Set(),
readOnly: true,
});
const view = getMcpAppViewLease(params.viewId, runtime);
return view ? { runtime, view } : undefined;
}
export async function mintMcpAppViewFromTranscript(params: {
cfg: OpenClawConfig;
sessionKey: string;
descriptor: BoardMcpAppDescriptor;
allowedAppToolNames: ReadonlySet<string>;
authorizeAppToolCall?: () => boolean | Promise<boolean>;
readOnly: boolean;
}): Promise<ReconstructionResult | undefined> {
return await reconstructMcpAppView({
cfg: params.cfg,
sessionKey: params.sessionKey,
lookup: { descriptor: params.descriptor },
allowedAppToolNames: params.allowedAppToolNames,
...(params.authorizeAppToolCall ? { authorizeAppToolCall: params.authorizeAppToolCall } : {}),
readOnly: params.readOnly,
});
}
export async function restoreMcpAppView(params: {
@@ -27,6 +27,7 @@ const CURRENT_TRAIN_METHODS = [
"agents.workspace.get",
"audit.list",
"audit.activity.list",
"board.widget.appView",
"tts.speak",
"environments.list",
"environments.status",
+1
View File
@@ -132,6 +132,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "board.update", scope: "operator.write", since: "<=2026.7" },
{ name: "board.widget.put", scope: "operator.write", since: "<=2026.7" },
{ name: "board.widget.grant", scope: "operator.approvals", since: "<=2026.7" },
{ name: "board.widget.appView", scope: "operator.read", since: "2026.7" },
{ name: "board.event", scope: "operator.write", since: "<=2026.7" },
{ name: "audit.list", scope: "operator.read", since: "2026.7" },
{ name: "audit.activity.list", scope: "operator.read", since: "2026.7" },
+8 -1
View File
@@ -393,7 +393,14 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
loadHandlers: loadUiCommandHandlers,
}),
...createLazyCoreHandlers({
methods: ["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"],
methods: [
"board.get",
"board.update",
"board.widget.put",
"board.widget.grant",
"board.widget.appView",
"board.event",
],
loadHandlers: loadBoardHandlers,
}),
...createLazyCoreHandlers({
+423 -9
View File
@@ -26,10 +26,44 @@ vi.mock("./sessions.runtime.js", () => ({
})),
}));
function createHarness(readCanvasHtml?: Parameters<typeof createBoardHandlers>[2]) {
const store = new InMemoryBoardStore();
type BoardMcpAppDependencies = NonNullable<Parameters<typeof createBoardHandlers>[3]>;
function createMcpAppDependencies(): BoardMcpAppDependencies {
let lease = 0;
const runtime = { getCatalog: vi.fn() };
return {
resolveActiveView: vi.fn(async ({ viewId }: { viewId: string }) => ({
runtime,
view: {
viewId,
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
toolCallId: "call",
},
})),
resolveAllowedToolNames: vi.fn(async () => ["server.refresh", "server.search"]),
mintFromTranscript: vi.fn(async ({ readOnly }: { readOnly: boolean }) => {
lease += 1;
return {
runtime,
view: {
viewId: `mcp-app-board-${lease}`,
expiresAtMs: 10_000 + lease,
...(readOnly ? { readOnly: true as const } : {}),
},
};
}),
} as unknown as BoardMcpAppDependencies;
}
function createHarness(
readCanvasHtml?: Parameters<typeof createBoardHandlers>[2],
mcpApp: BoardMcpAppDependencies = createMcpAppDependencies(),
store: InMemoryBoardStore = new InMemoryBoardStore(),
) {
const broadcast = vi.fn();
const handlers = createBoardHandlers(store, undefined, readCanvasHtml);
const handlers = createBoardHandlers(store, undefined, readCanvasHtml, mcpApp);
const invoke = async (method: string, params: Record<string, unknown>) => {
const respond = vi.fn<RespondFn>();
await handlers[method]!({
@@ -38,11 +72,14 @@ function createHarness(readCanvasHtml?: Parameters<typeof createBoardHandlers>[2
client: null,
isWebchatConnect: () => false,
respond,
context: { broadcast } as unknown as GatewayRequestContext,
context: {
broadcast,
getRuntimeConfig: () => ({ mcp: { apps: { enabled: true } } }),
} as unknown as GatewayRequestContext,
});
return respond;
};
return { store, broadcast, invoke };
return { store, broadcast, invoke, mcpApp };
}
describe("board gateway methods", () => {
@@ -61,15 +98,21 @@ describe("board gateway methods", () => {
it("registers every contract method with its required scope", () => {
expect(
Object.fromEntries(
["board.get", "board.update", "board.widget.put", "board.widget.grant", "board.event"].map(
(method) => [method, resolveCoreOperatorGatewayMethodScope(method)],
),
[
"board.get",
"board.update",
"board.widget.put",
"board.widget.grant",
"board.widget.appView",
"board.event",
].map((method) => [method, resolveCoreOperatorGatewayMethodScope(method)]),
),
).toEqual({
"board.get": "operator.read",
"board.update": "operator.write",
"board.widget.put": "operator.write",
"board.widget.grant": "operator.approvals",
"board.widget.appView": "operator.read",
"board.event": "operator.write",
});
});
@@ -109,8 +152,9 @@ describe("board gateway methods", () => {
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "origin",
originSessionKey: "agent:main:main",
toolCallId: "call",
viewId: "mcp-app-source",
},
},
});
@@ -234,6 +278,376 @@ describe("board gateway methods", () => {
});
});
it("captures MCP App tools at pin time and mints grant-bound fresh leases", async () => {
const { invoke, mcpApp, store } = createHarness();
const descriptor = {
viewId: "mcp-app-source",
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "agent:main:main",
toolCallId: "call",
};
const put = await invoke("board.widget.put", {
sessionKey: "agent:main:main",
name: "server-app",
content: { kind: "mcp-app", descriptor },
});
expect(put).toHaveBeenCalledWith(
true,
expect.objectContaining({
widgets: [
expect.objectContaining({
name: "server-app",
grantState: "pending",
declaredSummary: ["Tool access: server.refresh", "Tool access: server.search"],
}),
],
}),
);
expect(mcpApp.resolveActiveView).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey: "agent:main:main", viewId: "mcp-app-source" }),
);
const originalGrantGeneration = store.readWidgetMcpApp(
"agent:main:main",
"server-app",
)?.grantGeneration;
expect(originalGrantGeneration).toMatch(/^[a-f0-9]{32}$/u);
const readOnly = await invoke("board.widget.appView", {
sessionKey: "agent:main:main",
name: "server-app",
revision: 1,
instanceId: originalGrantGeneration,
});
expect(readOnly).toHaveBeenCalledWith(true, {
viewId: "mcp-app-board-1",
expiresAtMs: 10_001,
});
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
allowedAppToolNames: new Set(),
readOnly: true,
}),
);
await invoke("board.widget.grant", {
sessionKey: "agent:main:main",
name: "server-app",
decision: "granted",
revision: 1,
instanceId: originalGrantGeneration,
});
const interactive = await invoke("board.widget.appView", {
sessionKey: "agent:main:main",
name: "server-app",
revision: 1,
instanceId: originalGrantGeneration,
});
expect(interactive).toHaveBeenCalledWith(true, {
viewId: "mcp-app-board-2",
expiresAtMs: 10_002,
});
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
expect.objectContaining({
allowedAppToolNames: new Set(["server.refresh", "server.search"]),
readOnly: false,
}),
);
const authorizeAppToolCall = vi
.mocked(mcpApp.mintFromTranscript)
.mock.calls.at(-1)?.[0].authorizeAppToolCall;
if (!authorizeAppToolCall) {
throw new Error("interactive board lease must carry a grant check");
}
expect(await authorizeAppToolCall()).toBe(true);
await invoke("board.update", {
sessionKey: "agent:main:main",
ops: [{ kind: "widget_remove", name: "server-app" }],
});
expect(await authorizeAppToolCall()).toBe(false);
await invoke("board.widget.put", {
sessionKey: "agent:main:main",
name: "server-app",
content: { kind: "mcp-app", descriptor },
});
const replacementGrantGeneration = store.readWidgetMcpApp(
"agent:main:main",
"server-app",
)?.grantGeneration;
const staleGrant = await invoke("board.widget.grant", {
sessionKey: "agent:main:main",
name: "server-app",
decision: "granted",
revision: 1,
instanceId: originalGrantGeneration,
});
expect(staleGrant.mock.calls[0]?.[0]).toBe(false);
await invoke("board.widget.grant", {
sessionKey: "agent:main:main",
name: "server-app",
decision: "granted",
revision: 1,
instanceId: replacementGrantGeneration,
});
expect(replacementGrantGeneration).not.toBe(originalGrantGeneration);
expect(await authorizeAppToolCall()).toBe(false);
});
it("keeps validated zero-tool MCP Apps message-capable without granting tools", async () => {
const mcpApp = createMcpAppDependencies();
vi.mocked(mcpApp.resolveAllowedToolNames).mockResolvedValue([]);
const { invoke, store } = createHarness(undefined, mcpApp);
await invoke("board.widget.put", {
sessionKey: "agent:main:main",
name: "message-app",
content: {
kind: "mcp-app",
descriptor: {
viewId: "mcp-app-source",
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "agent:main:main",
toolCallId: "call",
},
},
});
const widget = store.getSnapshot("agent:main:main").widgets[0]!;
expect(widget.grantState).toBe("none");
await invoke("board.widget.appView", {
sessionKey: "agent:main:main",
name: "message-app",
revision: widget.revision,
instanceId: widget.instanceId,
});
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
expect.objectContaining({ allowedAppToolNames: new Set(), readOnly: false }),
);
expect(vi.mocked(mcpApp.mintFromTranscript).mock.calls.at(-1)?.[0]).not.toHaveProperty(
"authorizeAppToolCall",
);
});
it("canonicalizes equivalent board and MCP App origin session keys", async () => {
class AliasBoardStore extends InMemoryBoardStore {
private legacyOrigin = false;
private canonical(sessionKey: string): string {
return sessionKey === "main-alias" ? "agent:main:main" : sessionKey;
}
useLegacyOrigin(): void {
this.legacyOrigin = true;
}
override getSnapshot(sessionKey: string) {
return super.getSnapshot(this.canonical(sessionKey));
}
override putWidget(params: Parameters<InMemoryBoardStore["putWidget"]>[0]) {
return super.putWidget({ ...params, sessionKey: this.canonical(params.sessionKey) });
}
override readWidgetMcpApp(sessionKey: string, name: string) {
const current = super.readWidgetMcpApp(this.canonical(sessionKey), name);
return current && this.legacyOrigin
? {
...current,
descriptor: { ...current.descriptor, originSessionKey: "main-alias" },
}
: current;
}
}
const store = new AliasBoardStore();
const mcpApp = createMcpAppDependencies();
const { invoke } = createHarness(undefined, mcpApp, store);
await invoke("board.widget.put", {
sessionKey: "main-alias",
name: "aliased-app",
content: {
kind: "mcp-app",
descriptor: {
viewId: "mcp-app-source",
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "main-alias",
toolCallId: "call",
},
},
});
expect(mcpApp.resolveActiveView).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey: "agent:main:main" }),
);
expect(store.readWidgetMcpApp("agent:main:main", "aliased-app")?.descriptor).toMatchObject({
originSessionKey: "agent:main:main",
});
store.useLegacyOrigin();
const widget = store.getSnapshot("agent:main:main").widgets[0]!;
await invoke("board.widget.appView", {
sessionKey: "agent:main:main",
name: "aliased-app",
revision: widget.revision,
instanceId: widget.instanceId,
});
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
descriptor: expect.objectContaining({ originSessionKey: "agent:main:main" }),
}),
);
});
it("keeps pre-validation MCP App rows read-only even if their legacy grant says granted", async () => {
class LegacyBoardStore extends InMemoryBoardStore {
override readWidgetMcpApp(sessionKey: string, name: string) {
const current = super.readWidgetMcpApp(sessionKey, name);
if (!current) {
return undefined;
}
const { grantGeneration: _grantGeneration, ...legacy } = current;
return legacy;
}
}
const mcpApp = createMcpAppDependencies();
const store = new LegacyBoardStore();
const { invoke } = createHarness(undefined, mcpApp, store);
await invoke("board.widget.put", {
sessionKey: "agent:main:main",
name: "legacy-app",
content: {
kind: "mcp-app",
descriptor: {
viewId: "mcp-app-source",
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "agent:main:main",
toolCallId: "call",
},
},
});
await invoke("board.widget.grant", {
sessionKey: "agent:main:main",
name: "legacy-app",
decision: "granted",
revision: 1,
instanceId: store.getSnapshot("agent:main:main").widgets[0]?.instanceId,
});
await invoke("board.widget.appView", {
sessionKey: "agent:main:main",
name: "legacy-app",
revision: 1,
instanceId: store.getSnapshot("agent:main:main").widgets[0]?.instanceId,
});
expect(mcpApp.mintFromTranscript).toHaveBeenLastCalledWith(
expect.objectContaining({
allowedAppToolNames: new Set(),
readOnly: true,
}),
);
expect(vi.mocked(mcpApp.mintFromTranscript).mock.calls.at(-1)?.[0]).not.toHaveProperty(
"authorizeAppToolCall",
);
});
it("rejects pre-validation MCP App rows that point at another session", async () => {
const store = new InMemoryBoardStore();
store.putWidget({
sessionKey: "agent:main:board",
name: "legacy-cross-session",
content: {
kind: "mcp-app",
descriptor: {
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "agent:main:origin",
toolCallId: "call",
},
},
});
const mcpApp = createMcpAppDependencies();
const { invoke } = createHarness(undefined, mcpApp, store);
const response = await invoke("board.widget.appView", {
sessionKey: "agent:main:board",
name: "legacy-cross-session",
revision: 1,
instanceId: store.getSnapshot("agent:main:board").widgets[0]?.instanceId,
});
expect(response).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
expect(mcpApp.mintFromTranscript).not.toHaveBeenCalled();
});
it("rejects app-view requests for a replaced widget revision", async () => {
const { invoke, mcpApp } = createHarness();
await invoke("board.widget.put", {
sessionKey: "agent:main:main",
name: "server-app",
content: {
kind: "mcp-app",
descriptor: {
viewId: "mcp-app-source",
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "agent:main:main",
toolCallId: "call",
},
},
});
const response = await invoke("board.widget.appView", {
sessionKey: "agent:main:main",
name: "server-app",
revision: 2,
});
expect(response.mock.calls[0]?.[0]).toBe(false);
expect(mcpApp.mintFromTranscript).not.toHaveBeenCalled();
});
it("rejects cross-session MCP App pins before resolving the source view", async () => {
const { invoke, mcpApp } = createHarness();
const response = await invoke("board.widget.put", {
sessionKey: "agent:main:other",
name: "server-app",
content: {
kind: "mcp-app",
descriptor: {
viewId: "mcp-app-source",
serverName: "server",
toolName: "tool",
uiResourceUri: "ui://resource",
originSessionKey: "agent:main:main",
toolCallId: "call",
},
},
});
expect(response).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
expect(mcpApp.resolveActiveView).not.toHaveBeenCalled();
});
it("materializes canvas document sources before storing and broadcasting", async () => {
const readCanvasDocument = vi.fn(async () => ({
html: "<!doctype html><p>same wrapped bytes</p>",
+130 -1
View File
@@ -3,6 +3,7 @@ import {
errorShape,
formatValidationErrors,
type BoardEventParams,
type BoardWidgetAppViewParams,
type BoardUpdateParams,
type BoardWidgetGrantParams,
type BoardWidgetMaterializedPutParams,
@@ -11,6 +12,7 @@ import {
validateBoardGetParams,
validateBoardUpdateParams,
validateBoardWidgetContent,
validateBoardWidgetAppViewParams,
validateBoardWidgetGrantParams,
validateBoardWidgetPutParams,
} from "../../../packages/gateway-protocol/src/index.js";
@@ -20,10 +22,23 @@ import type { BoardStore } from "../../boards/board-store.js";
import { readCanvasDocumentHtmlSource } from "../../canvas/documents.js";
import { boardStore } from "../board-store.js";
import { buildBoardWidgetFrameUrl, createBoardViewTicket } from "../board-view-ticket.js";
import { resolveMcpAppActiveView, resolveMcpAppAllowedToolNames } from "../mcp-app-operations.js";
import { mintMcpAppViewFromTranscript } from "../mcp-app-reconstruction.js";
import type { GatewayRequestHandlers } from "./types.js";
type NoticeAppender = typeof appendBoardEventNotice;
type CanvasDocumentReader = typeof readCanvasDocumentHtmlSource;
type McpAppDependencies = {
resolveActiveView: typeof resolveMcpAppActiveView;
resolveAllowedToolNames: typeof resolveMcpAppAllowedToolNames;
mintFromTranscript: typeof mintMcpAppViewFromTranscript;
};
const defaultMcpAppDependencies: McpAppDependencies = {
resolveActiveView: resolveMcpAppActiveView,
resolveAllowedToolNames: resolveMcpAppAllowedToolNames,
mintFromTranscript: mintMcpAppViewFromTranscript,
};
function invalidParams(
method: string,
@@ -55,6 +70,7 @@ export function createBoardHandlers(
store: BoardStore,
appendNotice: NoticeAppender = appendBoardEventNotice,
readCanvasDocument: CanvasDocumentReader = readCanvasDocumentHtmlSource,
mcpApp: McpAppDependencies = defaultMcpAppDependencies,
): GatewayRequestHandlers {
return {
"board.get": ({ params, respond }) => {
@@ -111,7 +127,10 @@ export function createBoardHandlers(
}
try {
const requestParams = params as BoardWidgetPutParams;
const boardSessionKey = store.getSnapshot(requestParams.sessionKey).sessionKey;
const { declared: requestDeclared, ...requestWithoutDeclared } = requestParams;
let content: BoardWidgetMaterializedPutParams["content"];
let declared = requestDeclared;
if (requestParams.content.kind === "canvas-doc") {
const document = await readCanvasDocument(requestParams.content.docId);
if (document.cspSandbox !== "scripts") {
@@ -121,6 +140,44 @@ export function createBoardHandlers(
);
}
content = { kind: "html", html: document.html };
} else if (requestParams.content.kind === "mcp-app") {
const descriptor = requestParams.content.descriptor;
const originSessionKey = store.getSnapshot(descriptor.originSessionKey).sessionKey;
if (originSessionKey !== boardSessionKey) {
throw new BoardValidationError(
"invalid_operation",
"MCP App widgets can only be pinned to their originating session board",
);
}
const active = await mcpApp.resolveActiveView({
sessionKey: originSessionKey,
viewId: descriptor.viewId,
cfg: context.getRuntimeConfig(),
});
const { view } = active;
if (
view.serverName !== descriptor.serverName ||
view.toolName !== descriptor.toolName ||
view.uiResourceUri !== descriptor.uiResourceUri ||
view.toolCallId !== descriptor.toolCallId
) {
throw new BoardValidationError(
"invalid_operation",
"MCP App pin descriptor does not match the active view",
);
}
const allowedTools = await mcpApp.resolveAllowedToolNames(active);
content = {
kind: "mcp-app",
descriptor: {
serverName: descriptor.serverName,
toolName: descriptor.toolName,
uiResourceUri: descriptor.uiResourceUri,
originSessionKey,
toolCallId: descriptor.toolCallId,
},
};
declared = allowedTools.length > 0 ? { tools: allowedTools } : undefined;
} else {
content = requestParams.content;
}
@@ -128,7 +185,12 @@ export function createBoardHandlers(
invalidParams("board.widget.put content", validateBoardWidgetContent.errors, respond);
return;
}
const boardParams: BoardWidgetMaterializedPutParams = { ...requestParams, content };
const boardParams: BoardWidgetMaterializedPutParams = {
...requestWithoutDeclared,
sessionKey: boardSessionKey,
content,
...(declared ? { declared } : {}),
};
const snapshot = store.putWidget(boardParams);
context.broadcast("board.changed", {
sessionKey: snapshot.sessionKey,
@@ -152,6 +214,7 @@ export function createBoardHandlers(
boardParams.name,
boardParams.decision,
boardParams.revision,
boardParams.instanceId,
);
context.broadcast("board.changed", {
sessionKey: snapshot.sessionKey,
@@ -162,6 +225,72 @@ export function createBoardHandlers(
respondBoardError(error, respond);
}
},
"board.widget.appView": async ({ params, respond, context }) => {
if (!validateBoardWidgetAppViewParams(params)) {
invalidParams("board.widget.appView", validateBoardWidgetAppViewParams.errors, respond);
return;
}
try {
const boardParams = params as BoardWidgetAppViewParams;
const snapshot = store.getSnapshot(boardParams.sessionKey);
const widget = snapshot.widgets.find((candidate) => candidate.name === boardParams.name);
const document = store.readWidgetMcpApp(snapshot.sessionKey, boardParams.name);
if (
!widget ||
widget.contentKind !== "mcp-app" ||
!document ||
document.revision !== widget.revision ||
document.revision !== boardParams.revision ||
widget.instanceId !== boardParams.instanceId
) {
throw new BoardValidationError(
"not_found",
`board MCP App widget not found: ${boardParams.name}`,
);
}
const originSessionKey = store.getSnapshot(document.descriptor.originSessionKey).sessionKey;
if (originSessionKey !== snapshot.sessionKey) {
throw new BoardValidationError(
"invalid_operation",
"Pinned MCP App source does not belong to this board session",
);
}
// Pins created before server-side source validation have no generation and stay read-only.
const sourceValidated =
Boolean(document.grantGeneration) && document.grantGeneration === widget.instanceId;
const requiresToolGrant = document.declaredTools.length > 0;
const interactive =
sourceValidated && (!requiresToolGrant || document.grantState === "granted");
const minted = await mcpApp.mintFromTranscript({
cfg: context.getRuntimeConfig(),
sessionKey: originSessionKey,
descriptor: { ...document.descriptor, originSessionKey },
allowedAppToolNames: new Set(interactive ? document.declaredTools : []),
...(interactive && requiresToolGrant
? {
authorizeAppToolCall: () => {
const current = store.readWidgetMcpApp(snapshot.sessionKey, boardParams.name);
return (
current?.revision === document.revision &&
current.grantState === "granted" &&
current.grantGeneration === document.grantGeneration
);
},
}
: {}),
readOnly: !interactive,
});
if (!minted) {
throw new Error("Pinned MCP App source is no longer available");
}
respond(true, {
viewId: minted.view.viewId,
expiresAtMs: minted.view.expiresAtMs,
});
} catch (error) {
respondBoardError(error, respond);
}
},
"board.event": ({ params, respond }) => {
if (!validateBoardEventParams(params)) {
invalidParams("board.event", validateBoardEventParams.errors, respond);
@@ -1,5 +1,6 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayErrorDetailCodes } from "../../../packages/gateway-protocol/src/index.js";
const mocks = vi.hoisted(() => ({
completeDeferredSessionMcpRuntimeRetirement: vi.fn(),
@@ -27,6 +28,7 @@ vi.mock("../mcp-app-standalone.js", () => ({
createMcpAppStandaloneTicket: mocks.createMcpAppStandaloneTicket,
}));
import { resolveMcpAppAllowedToolNames } from "../mcp-app-operations.js";
import { mcpAppHandlers } from "./mcp-app.js";
const view = {
@@ -37,6 +39,7 @@ const view = {
uiResourceUri: "ui://demo/app",
html: "<html>demo</html>",
allowedAppToolNames: new Set(["shared", "app-only"]) as ReadonlySet<string> | undefined,
authorizeAppToolCall: undefined as (() => boolean | Promise<boolean>) | undefined,
readOnly: undefined as boolean | undefined,
toolInput: { city: "Paris" },
toolResult: { content: [{ type: "text", text: "ok" }] },
@@ -110,6 +113,7 @@ describe("MCP App gateway bridge", () => {
view.toolCallCount = 0;
view.activeRequests = 0;
view.allowedAppToolNames = new Set(["shared", "app-only"]);
view.authorizeAppToolCall = undefined;
view.readOnly = undefined;
mocks.getMcpAppViewLease.mockReset().mockReturnValue(view);
mocks.completeDeferredSessionMcpRuntimeRetirement.mockReset().mockResolvedValue(false);
@@ -278,6 +282,20 @@ describe("MCP App gateway bridge", () => {
expect(activeRuntime.pendingMcpAppModelContext).toBeUndefined();
});
it("rechecks a board widget grant before updating model context", async () => {
view.authorizeAppToolCall = vi.fn(async () => false);
const respond = await invoke("mcp.app.updateModelContext", {
sessionKey: "agent:main:main",
viewId: "cv_app",
content: [{ type: "text", text: "blocked" }],
});
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(view.authorizeAppToolCall).toHaveBeenCalledOnce();
const activeRuntime = mocks.peekSessionMcpRuntime.mock.results[0]?.value;
expect(activeRuntime.pendingMcpAppModelContext).toBeUndefined();
});
it("filters model-only tools from app discovery and execution", async () => {
const params = { sessionKey: "agent:main:main", viewId: "cv_app" };
const listed = await invoke("mcp.app.listTools", params);
@@ -303,6 +321,37 @@ describe("MCP App gateway bridge", () => {
expect(denied.mock.calls[0]?.[0]).toBe(false);
});
it("rechecks a board widget grant before every App tool call", async () => {
view.authorizeAppToolCall = vi.fn(async () => false);
const denied = await invoke("mcp.app.callTool", {
sessionKey: "agent:main:main",
viewId: "cv_app",
toolName: "shared",
});
expect(denied.mock.calls[0]?.[0]).toBe(false);
expect(view.authorizeAppToolCall).toHaveBeenCalledOnce();
expect(mocks.peekSessionMcpRuntime.mock.results[0]?.value.callTool).not.toHaveBeenCalled();
});
it("captures only app-visible tools allowed by the originating view", async () => {
const activeRuntime = runtime();
const activeView = {
...view,
allowedAppToolNames: new Set(["app-only", "model-only"]),
};
await expect(
resolveMcpAppAllowedToolNames({ runtime: activeRuntime as never, view: activeView as never }),
).resolves.toEqual(["app-only"]);
await expect(
resolveMcpAppAllowedToolNames({
runtime: activeRuntime as never,
view: { ...activeView, readOnly: true } as never,
}),
).resolves.toEqual([]);
});
it("rejects views that are not backed by the transcript", async () => {
mocks.getMcpAppViewLease.mockReturnValue(undefined);
const respond = await invoke("mcp.app.view", {
@@ -310,6 +359,9 @@ describe("MCP App gateway bridge", () => {
viewId: "expired",
});
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(respond.mock.calls[0]?.[2]).toMatchObject({
details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED },
});
expect(mocks.restoreMcpAppView).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
+20 -3
View File
@@ -1,11 +1,17 @@
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import {
ErrorCodes,
errorShape,
GatewayErrorDetailCodes,
} from "../../../packages/gateway-protocol/src/index.js";
import { updateMcpAppModelContext } from "../../agents/mcp-app-model-context.js";
import { buildMcpAppSandboxPath } from "../../agents/mcp-app-sandbox.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { logWarn } from "../../logger.js";
import {
executeMcpAppOperation,
McpAppViewExpiredError,
type McpAppOperation,
requireMcpAppViewAuthorization,
resolveMcpAppActiveView,
withMcpAppActiveView,
} from "../mcp-app-operations.js";
@@ -43,7 +49,17 @@ async function handle(
try {
respond(true, await operation());
} catch (error) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
formatErrorMessage(error),
error instanceof McpAppViewExpiredError
? { details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED } }
: undefined,
),
);
}
}
@@ -103,10 +119,11 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
sessionKey: requireString(params, "sessionKey"),
viewId: requireString(params, "viewId"),
});
return await withMcpAppActiveView(active, "read", () => {
return await withMcpAppActiveView(active, "read", async () => {
if (active.view.readOnly === true || active.view.allowedAppToolNames === undefined) {
throw new Error("MCP App view is not authorized to update model context");
}
await requireMcpAppViewAuthorization(active.view);
updateMcpAppModelContext(active.runtime, active.view, params);
return {};
});
+184
View File
@@ -78,6 +78,17 @@ function deferred(): {
return { promise, resolve, reject };
}
function deferredValue<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function settleCells(view: OpenClawBoardView): Promise<OpenClawBoardWidgetCell[]> {
await view.updateComplete;
const cells = [...view.querySelectorAll("openclaw-board-widget-cell")];
@@ -108,6 +119,7 @@ async function mount(
afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("openclaw-board-view", () => {
@@ -410,6 +422,178 @@ describe("openclaw-board-view", () => {
await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "rejected"));
});
it("renders MCP App widgets through the bridge element while approval is pending", async () => {
if (!customElements.get("mcp-app-view")) {
customElements.define("mcp-app-view", class extends HTMLElement {});
}
const widgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-board",
expiresAtMs: Date.now() + 60_000,
}));
const refreshWidgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-renewed",
expiresAtMs: Date.now() + 60_000,
}));
const source = snapshot({
sessionKey: "agent:main:main",
widgets: [boardWidget({ contentKind: "mcp-app", grantState: "pending" })],
});
const view = await mount({
snapshot: source,
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
});
await vi.waitFor(() => expect(view.querySelector("mcp-app-view")).not.toBeNull());
const app = view.querySelector<HTMLElement & { sessionKey: string; viewId: string }>(
"mcp-app-view",
);
expect(app?.sessionKey).toBe("agent:main:main");
expect(app?.viewId).toBe("mcp-app-board");
expect(view.querySelector('[data-test-id="board-pending"]')).not.toBeNull();
expect(view.querySelector("iframe")).toBeNull();
app?.dispatchEvent(
new CustomEvent("openclaw-mcp-app-view-expired", { bubbles: true, composed: true }),
);
await vi.waitFor(() =>
expect(view.querySelector<HTMLElement & { viewId: string }>("mcp-app-view")?.viewId).toBe(
"mcp-app-renewed",
),
);
expect(refreshWidgetAppView).toHaveBeenCalledWith("alpha", 1);
});
it("renews a mounted MCP App lease before it expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
if (!customElements.get("mcp-app-view")) {
customElements.define("mcp-app-view", class extends HTMLElement {});
}
const widgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-board",
expiresAtMs: 11_000,
}));
const refreshWidgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-renewed",
expiresAtMs: 71_000,
}));
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
const view = await mount({
snapshot: source,
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
});
await vi.advanceTimersByTimeAsync(0);
await settleCells(view);
await vi.advanceTimersByTimeAsync(4_999);
expect(refreshWidgetAppView).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await settleCells(view);
expect(refreshWidgetAppView).toHaveBeenCalledWith("alpha", 1);
expect(view.querySelector<HTMLElement & { viewId: string }>("mcp-app-view")?.viewId).toBe(
"mcp-app-renewed",
);
view.remove();
await vi.advanceTimersByTimeAsync(60_000);
expect(refreshWidgetAppView).toHaveBeenCalledTimes(1);
});
it("does not loop automatic renewal for a lease already inside the refresh window", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const widgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-near-expiry",
expiresAtMs: 5_000,
}));
const refreshWidgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-renewed",
expiresAtMs: 5_000,
}));
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
const view = await mount({
snapshot: source,
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
});
await vi.advanceTimersByTimeAsync(60_000);
expect(refreshWidgetAppView).not.toHaveBeenCalled();
view.remove();
});
it("does not schedule renewal when an in-flight MCP App load resolves after disconnect", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const pending = deferredValue<{
status: "ready";
viewId: string;
expiresAtMs: number;
}>();
const widgetAppView = vi.fn(() => pending.promise);
const refreshWidgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-renewed",
expiresAtMs: 71_000,
}));
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
const view = await mount({
snapshot: source,
callbacks: callbacks({ widgetAppView, refreshWidgetAppView }),
});
expect(widgetAppView).toHaveBeenCalledWith("alpha", 1);
view.remove();
pending.resolve({ status: "ready", viewId: "late-view", expiresAtMs: 11_000 });
await vi.advanceTimersByTimeAsync(60_000);
expect(refreshWidgetAppView).not.toHaveBeenCalled();
});
it("shows stale MCP Apps with retry and remove without breaking the board", async () => {
if (!customElements.get("mcp-app-view")) {
customElements.define("mcp-app-view", class extends HTMLElement {});
}
const widgetAppView = vi.fn(async () => ({
status: "stale" as const,
error: "origin transcript pruned",
}));
const refreshWidgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "mcp-app-retried",
expiresAtMs: Date.now() + 60_000,
}));
const applyOps = vi.fn(async () => undefined);
const source = snapshot({ widgets: [boardWidget({ contentKind: "mcp-app" })] });
const view = await mount({
snapshot: source,
callbacks: callbacks({ applyOps, widgetAppView, refreshWidgetAppView }),
});
await vi.waitFor(() =>
expect(view.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull(),
);
const buttons = view.querySelectorAll<HTMLButtonElement>(
'[data-test-id="board-mcp-app-stale"] button',
);
expect([...buttons].map((button) => button.textContent?.trim())).toEqual(["Retry", "Remove"]);
buttons[1]?.click();
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_remove", name: "alpha" }]),
);
buttons[0]?.click();
await vi.waitFor(() =>
expect(view.querySelector<HTMLElement & { viewId: string }>("mcp-app-view")?.viewId).toBe(
"mcp-app-retried",
),
);
expect(refreshWidgetAppView).toHaveBeenCalledWith("alpha", 1);
});
it("renders declared approval details instead of the generic copy", async () => {
const source = snapshot({
widgets: [
+11 -1
View File
@@ -207,6 +207,16 @@ class OpenClawBoardView extends OpenClawLightDomElement {
frameLoadFailed: async (name) => {
await this.callbacks?.frameLoadFailed?.(name);
},
widgetAppView: async (name, revision) =>
(await this.callbacks?.widgetAppView?.(name, revision)) ?? {
status: "stale",
error: "MCP App view unavailable",
},
refreshWidgetAppView: async (name, revision) =>
(await this.callbacks?.refreshWidgetAppView?.(name, revision)) ?? {
status: "stale",
error: "MCP App view unavailable",
},
};
private beginGesture(
@@ -563,10 +573,10 @@ class OpenClawBoardView extends OpenClawLightDomElement {
.widget=${widget}
.rect=${rect}
.tabs=${tabs}
.sessionKey=${sessionKey}
.widgetFrameUrl=${this.widgetFrameUrl}
.callbacks=${this.cellCallbacks}
.sessions=${this.sessions}
.sessionKey=${sessionKey}
.dragging=${widget.name === this.gestureName}
.focusTabIndex=${widget.name === focusName ? 0 : -1}
.positionInSet=${(logicalPosition.get(widget.name) ?? 0) + 1}
+185 -2
View File
@@ -1,9 +1,11 @@
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewaySessionRow } from "../../api/types.ts";
import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts";
import { t } from "../../i18n/index.ts";
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
import { toCssPlacement } from "../../lib/board/grid.ts";
import type { BoardWidgetAppViewState } from "../../lib/board/provider.ts";
import type { BoardTab } from "../../lib/board/types.ts";
import type {
BoardGrantDecision,
@@ -21,6 +23,8 @@ const BOARD_SIZE_PRESETS = {
xl: { w: 12, h: 8 },
} as const;
const MAX_FRAME_REFRESH_ATTEMPTS = 3;
const APP_VIEW_REFRESH_LEAD_MS = 5_000;
const loadMcpAppView = () => import("../mcp-app-view-registration.ts");
export type BoardWidgetCellCallbacks = {
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;
@@ -33,16 +37,18 @@ export type BoardWidgetCellCallbacks = {
focus: (widget: BoardViewWidget, direction: BoardGridDirection) => void;
focusChanged: (name: string) => void;
frameLoadFailed: (name: string) => Promise<void>;
widgetAppView: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
refreshWidgetAppView: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
};
class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@property({ attribute: false }) widget?: BoardViewWidget;
@property({ attribute: false }) rect?: BoardGridRect;
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks;
@property({ attribute: false }) sessions: readonly GatewaySessionRow[] = [];
@property({ type: String }) sessionKey = "";
@property({ type: Boolean }) dragging = false;
@property({ type: Number }) focusTabIndex = -1;
@property({ type: Number }) positionInSet = 1;
@@ -52,10 +58,24 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@state() private actionError = "";
@state() private actionPending = false;
@state() private frameError = "";
@state() private appViewState?: BoardWidgetAppViewState;
@state() private appViewLoading = false;
private frameFailureKey = "";
private frameRefreshAttempts = 0;
private frameProbeGeneration = 0;
private lastFrameUrl = "";
private appViewKey = "";
private appViewGeneration = 0;
private appViewRenewalTimer?: number;
override connectedCallback(): void {
super.connectedCallback();
const widget = this.widget;
if (widget?.contentKind === "mcp-app" && this.callbacks && !this.appViewKey) {
this.appViewKey = this.currentAppViewKey(widget);
void this.loadAppView(widget, this.callbacks, false);
}
}
override willUpdate(changed: PropertyValues<this>): void {
const previousWidget = changed.get("widget");
@@ -75,6 +95,105 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
}
}
const widget = this.widget;
if (!this.isConnected || !widget || widget.contentKind !== "mcp-app" || !this.callbacks) {
this.cancelAppViewRenewal();
this.appViewGeneration += 1;
this.appViewKey = "";
this.appViewState = undefined;
this.appViewLoading = false;
return;
}
const key = this.currentAppViewKey(widget);
if (key !== this.appViewKey) {
this.cancelAppViewRenewal();
this.appViewGeneration += 1;
this.appViewLoading = false;
this.appViewKey = key;
void this.loadAppView(widget, this.callbacks, false);
}
}
override disconnectedCallback(): void {
this.cancelAppViewRenewal();
this.appViewGeneration += 1;
this.appViewKey = "";
this.appViewState = undefined;
this.appViewLoading = false;
super.disconnectedCallback();
}
private async loadAppView(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
force: boolean,
): Promise<void> {
if (this.appViewLoading) {
return;
}
const key = this.currentAppViewKey(widget);
const generation = ++this.appViewGeneration;
this.cancelAppViewRenewal();
this.appViewLoading = true;
if (force) {
this.appViewState = undefined;
}
const appView = await (force
? callbacks.refreshWidgetAppView(widget.name, widget.revision)
: callbacks.widgetAppView(widget.name, widget.revision));
const current = this.widget;
if (
this.isConnected &&
generation === this.appViewGeneration &&
this.appViewKey === key &&
current?.name === widget.name &&
current.revision === widget.revision
) {
this.appViewState = appView;
this.appViewLoading = false;
this.scheduleAppViewRenewal(widget, callbacks, appView);
}
}
private currentAppViewKey(widget: BoardViewWidget): string {
return `${this.sessionKey}\0${widget.name}\0${widget.revision}\0${widget.instanceId ?? ""}\0${widget.grantState}`;
}
private cancelAppViewRenewal(): void {
if (this.appViewRenewalTimer !== undefined) {
window.clearTimeout(this.appViewRenewalTimer);
this.appViewRenewalTimer = undefined;
}
}
private scheduleAppViewRenewal(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
appView: BoardWidgetAppViewState,
): void {
this.cancelAppViewRenewal();
if (appView.status !== "ready") {
return;
}
const key = this.appViewKey;
const delayMs = appView.expiresAtMs - Date.now() - APP_VIEW_REFRESH_LEAD_MS;
if (delayMs <= 0) {
// A near-expiry response or clock skew must not create a tight remint loop.
// The bridge's structured expiry event remains the renewal fallback.
return;
}
this.appViewRenewalTimer = window.setTimeout(() => {
this.appViewRenewalTimer = undefined;
const current = this.widget;
if (
this.isConnected &&
this.appViewKey === key &&
current?.name === widget.name &&
current.revision === widget.revision
) {
void this.loadAppView(current, callbacks, true);
}
}, delayMs);
}
private resetFrameFailures(): void {
@@ -323,7 +442,70 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
`;
}
private renderStaleMcpApp(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
return html`
<div class="board-widget__stale" data-test-id="board-mcp-app-stale">
<strong>${t("board.widget.appStaleTitle")}</strong>
<span>${t("board.widget.appStaleDetail")}</span>
<div class="board-widget__grant-actions">
<button
class="btn btn--small btn--primary"
type="button"
?disabled=${this.appViewLoading}
@click=${() => void this.loadAppView(widget, callbacks, true)}
>
${t("board.widget.retry")}
</button>
<button
class="btn btn--small"
type="button"
?disabled=${this.busy || this.actionPending}
@click=${() => void this.runAction(() => callbacks.remove(widget))}
>
${t("board.widget.remove")}
</button>
</div>
</div>
`;
}
private renderMcpApp(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
void ensureCustomElementDefined("mcp-app-view", loadMcpAppView).catch(() => undefined);
const appView = this.appViewState;
const accessNotice =
widget.grantState === "pending"
? this.renderPending(widget, callbacks)
: widget.grantState === "rejected"
? this.renderRejected(widget, callbacks)
: nothing;
const view =
!appView || this.appViewLoading
? html`<div class="board-widget__app-loading" data-test-id="board-mcp-app-loading">
${t("board.widget.appLoading")}
</div>`
: appView.status === "stale"
? this.renderStaleMcpApp(widget, callbacks)
: html`<mcp-app-view
class="board-widget__mcp-app-view"
.sessionKey=${this.sessionKey}
.viewId=${appView.viewId}
.height=${Math.max(160, (this.rect?.h ?? 4) * 56 - 38)}
.title=${widget.title || widget.name}
@openclaw-mcp-app-view-expired=${() => void this.loadAppView(widget, callbacks, true)}
></mcp-app-view>`;
return html`<div class="board-widget__mcp-app">${accessNotice}${view}</div>`;
}
private renderBody(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
if (widget.contentKind === "mcp-app") {
return this.renderMcpApp(widget, callbacks);
}
if (widget.grantState === "pending") {
return this.renderPending(widget, callbacks);
}
@@ -425,6 +607,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
this.actionError !== "" ||
widget.grantState === "pending" ||
widget.grantState === "rejected";
const contentScrollable = bodyScrollable || widget.contentKind === "mcp-app";
return html`
<section
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""}`}
@@ -461,7 +644,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
${readOnly ? nothing : this.renderMenu(widget, callbacks)}
</header>
<div
class=${`board-widget__body ${bodyScrollable ? "board-widget__body--scrollable" : ""}`}
class=${`board-widget__body ${contentScrollable ? "board-widget__body--scrollable" : ""}`}
>
${body}
${this.actionError && widget.grantState !== "pending"
+1
View File
@@ -8,6 +8,7 @@ export type McpAppHostSandboxCsp = NonNullable<
/** Bubbling event handled by the owning chat pane through its normal send path. */
export const WIDGET_PROMPT_EVENT = "openclaw-widget-prompt";
export type WidgetPromptEventDetail = { text: string };
export const MCP_APP_VIEW_EXPIRED_EVENT = "openclaw-mcp-app-view-expired";
const WIDGET_PROMPT_MAX_CHARS = 4_000;
const WIDGET_PROMPT_RATE_WINDOW_MS = 60_000;
+61 -1
View File
@@ -1,6 +1,11 @@
import { GatewayErrorDetailCodes } from "@openclaw/gateway-protocol";
import { afterEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../i18n/index.ts";
import { WIDGET_PROMPT_EVENT, type WidgetPromptEventDetail } from "./mcp-app-security.ts";
import {
MCP_APP_VIEW_EXPIRED_EVENT,
WIDGET_PROMPT_EVENT,
type WidgetPromptEventDetail,
} from "./mcp-app-security.ts";
const bridgeMocks = vi.hoisted(() => ({
instances: [] as Array<Record<string, unknown>>,
@@ -236,6 +241,54 @@ describe("mcp-app-view localization", () => {
expect(received).toHaveLength(9);
});
it("signals its board owner when the view lease has expired", async () => {
const request = vi.fn(async () => {
throw Object.assign(new Error("MCP App view expired"), {
details: { code: GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED },
});
});
const view = document.createElement(MCP_APP_VIEW_ELEMENT_NAME) as McpAppViewElement;
Reflect.set(view, "context", {
gateway: {
snapshot: { client: { request } },
connection: { gatewayUrl: "ws://gateway.example:8443/openclaw" },
},
});
view.sessionKey = "agent:main:main";
view.viewId = "mcp-app-expired";
const expired = vi.fn();
view.addEventListener(MCP_APP_VIEW_EXPIRED_EVENT, expired);
document.body.append(view);
await expect.poll(() => expired).toHaveBeenCalledOnce();
await expect
.poll(() => view.shadowRoot?.querySelector(".error")?.textContent)
.toContain("MCP App view expired");
});
it("does not renew the view for unrelated upstream expiry errors", async () => {
const request = vi.fn(async () => {
throw new Error("upstream token expired");
});
const view = document.createElement(MCP_APP_VIEW_ELEMENT_NAME) as McpAppViewElement;
Reflect.set(view, "context", {
gateway: {
snapshot: { client: { request } },
connection: { gatewayUrl: "ws://gateway.example:8443/openclaw" },
},
});
view.sessionKey = "agent:main:main";
view.viewId = "mcp-app-upstream-expired";
const expired = vi.fn();
view.addEventListener(MCP_APP_VIEW_EXPIRED_EVENT, expired);
document.body.append(view);
await expect
.poll(() => view.shadowRoot?.querySelector(".error")?.textContent)
.toContain("upstream token expired");
expect(expired).not.toHaveBeenCalled();
});
it("does not advertise or install message support for read-only views", async () => {
const { bridge } = await mountBridge(`view-read-only-${crypto.randomUUID()}`, false);
expect(bridge.capabilities).not.toHaveProperty("message");
@@ -301,6 +354,13 @@ describe("mcp-app-view localization", () => {
expect.objectContaining({ containerDimensions: { width: 720, height: 600 } }),
);
view.height = 480;
await view.updateComplete;
expect(view.shadowRoot?.querySelector("iframe")?.style.height).toBe("480px");
expect(bridge.setHostContext).toHaveBeenLastCalledWith(
expect.objectContaining({ containerDimensions: { width: 720, height: 480 } }),
);
view.remove();
await expect.poll(() => disconnect).toHaveBeenCalledOnce();
expect(unsubscribe).toHaveBeenCalledOnce();
+28 -10
View File
@@ -6,7 +6,8 @@ import {
ListToolsRequestSchema,
type ListToolsResult,
} from "@modelcontextprotocol/sdk/types.js";
import { LitElement, css, html, nothing } from "lit";
import { isMcpAppViewExpiredError } from "@openclaw/gateway-protocol";
import { LitElement, css, html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { applicationContext, type ApplicationContext } from "../app/context.ts";
@@ -15,6 +16,7 @@ import { openExternalUrlSafe } from "../lib/open-external-url.ts";
import {
buildMcpAppHostCapabilities,
dispatchWidgetPrompt,
MCP_APP_VIEW_EXPIRED_EVENT,
resolveMcpAppSandboxUrl,
type McpAppHostSandboxCsp,
} from "./mcp-app-security.ts";
@@ -39,6 +41,7 @@ type ScheduleFallback = (callback: () => void, delayMs: number) => number;
type McpAppResources = {
bridge: OpenClawAppBridge | null;
cleanups: Set<() => void>;
frameHeight: number;
iframe: HTMLIFrameElement;
transport: { close(): Promise<void> } | null;
};
@@ -151,9 +154,14 @@ export class McpAppView extends LitElement {
super.disconnectedCallback();
}
override updated() {
override updated(changedProperties: PropertyValues<this>) {
if (this.resources) {
this.resources.iframe.title = this.title || t("mcpApp.title");
if (changedProperties.has("height")) {
this.resources.frameHeight = this.height;
this.resources.iframe.style.height = `${this.height}px`;
this.resources.bridge?.setHostContext(hostContext(this.mount.value, this.height));
}
}
const nextKey = `${this.sessionKey}\0${this.viewId}`;
const nextClient = this.context?.gateway.snapshot.client ?? null;
@@ -169,11 +177,20 @@ export class McpAppView extends LitElement {
if (!client || !this.sessionKey || !this.viewId) {
throw new Error("MCP App gateway unavailable");
}
return await client.request(method, {
sessionKey: this.sessionKey,
viewId: this.viewId,
...params,
});
try {
return await client.request(method, {
sessionKey: this.sessionKey,
viewId: this.viewId,
...params,
});
} catch (error) {
if (isMcpAppViewExpiredError(error)) {
this.dispatchEvent(
new CustomEvent(MCP_APP_VIEW_EXPIRED_EVENT, { bubbles: true, composed: true }),
);
}
throw error;
}
}
private addResourceCleanup(resources: McpAppResources, cleanup: () => void): () => void {
@@ -271,6 +288,7 @@ export class McpAppView extends LitElement {
const resources: McpAppResources = {
bridge: null,
cleanups: new Set(),
frameHeight: this.height,
iframe,
transport: null,
};
@@ -308,7 +326,6 @@ export class McpAppView extends LitElement {
return;
}
let frameHeight = this.height;
const bridge = new OpenClawAppBridge(
null,
{ name: "OpenClaw", version: "1.0.0" },
@@ -374,7 +391,7 @@ export class McpAppView extends LitElement {
bridge.onsizechange = ({ height }) => {
if (height !== undefined) {
const nextHeight = Math.min(1200, Math.max(160, Math.round(height)));
frameHeight = nextHeight;
resources.frameHeight = nextHeight;
iframe.style.height = `${nextHeight}px`;
bridge.setHostContext(hostContext(mount, nextHeight));
}
@@ -411,7 +428,8 @@ export class McpAppView extends LitElement {
if (generation !== this.setupGeneration) {
return;
}
const updateHostContext = () => bridge.setHostContext(hostContext(mount, frameHeight));
const updateHostContext = () =>
bridge.setHostContext(hostContext(mount, resources.frameHeight));
const hostContextCleanup = this.context?.theme.subscribe(updateHostContext);
if (hostContextCleanup) {
this.addResourceCleanup(resources, hostContextCleanup);
+4
View File
@@ -2612,6 +2612,10 @@ export const en: TranslationMap = {
reject: "Reject",
rejected: "Access rejected",
rejectedDetail: "This widget stays inactive until it is removed or replaced.",
appLoading: "Restoring app…",
appStaleTitle: "This pinned app is stale",
appStaleDetail: "Its server, resource, or originating transcript is no longer available.",
retry: "Retry",
frameResolverMissing: "Widget content is unavailable.",
frameAuthorizationFailed: "Widget authorization failed after repeated refresh attempts.",
errorTitle: "This widget could not load",
+63
View File
@@ -0,0 +1,63 @@
import type { BoardWidget, BoardWidgetAppViewResult } from "@openclaw/gateway-protocol";
import type { BoardWidgetAppViewState } from "./view-types.ts";
type AppViewRequest = () => Promise<BoardWidgetAppViewResult>;
export class BoardMcpAppViewCache {
private readonly entries = new Map<
string,
Promise<BoardWidgetAppViewState> | BoardWidgetAppViewState
>();
clear(): void {
this.entries.clear();
}
prune(widgets: readonly BoardWidget[]): void {
const validKeys = new Set(
widgets
.filter((widget) => widget.contentKind === "mcp-app")
.map((widget) => this.key(widget)),
);
for (const key of this.entries.keys()) {
if (!validKeys.has(key)) {
this.entries.delete(key);
}
}
}
async resolve(
widget: BoardWidget,
request: AppViewRequest,
force: boolean,
): Promise<BoardWidgetAppViewState> {
const key = this.key(widget);
if (force) {
this.entries.delete(key);
}
const cached = this.entries.get(key);
if (cached) {
const resolved = await cached;
if (resolved.status !== "ready" || resolved.expiresAtMs > Date.now() + 5_000) {
return resolved;
}
this.entries.delete(key);
}
const pending = request()
.then<BoardWidgetAppViewState>((result) => ({ status: "ready", ...result }))
.catch<BoardWidgetAppViewState>((error: unknown) => ({
status: "stale",
error: error instanceof Error ? error.message : String(error),
}));
this.entries.set(key, pending);
const resolved = await pending;
if (this.entries.get(key) === pending) {
this.entries.set(key, resolved);
}
return resolved;
}
private key(widget: BoardWidget): string {
return `${widget.name}\0${widget.revision}\0${widget.instanceId ?? ""}\0${widget.grantState}`;
}
}
+139
View File
@@ -1,5 +1,6 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
import {
boardExists,
boardProviderForSession,
@@ -820,6 +821,7 @@ describe("board providers", () => {
position: 0,
grantState: "none" as const,
revision: 1,
instanceId: "canvas-instance",
frameUrl: "/frame",
},
],
@@ -864,6 +866,7 @@ describe("board providers", () => {
name: "canvas-cv-1",
decision: "granted",
revision: 1,
instanceId: "canvas-instance",
});
expect(request).toHaveBeenCalledWith("board.widget.put", {
sessionKey: "agent:main:live",
@@ -878,4 +881,140 @@ describe("board providers", () => {
command: { kind: "focus_tab", tabId: "main" },
});
});
it("caches MCP App leases and re-mints them as expiry approaches", async () => {
mockLocation.search = "";
let now = 0;
vi.spyOn(Date, "now").mockImplementation(() => now);
const snapshot = {
sessionKey: "agent:main:app",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "server-app",
tabId: "main",
contentKind: "mcp-app" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 1,
instanceId: "app-instance",
},
],
};
let lease = 0;
const request = vi.fn(async (method: string) => {
if (method === "board.get") {
return snapshot;
}
if (method === "board.widget.appView") {
lease += 1;
return { viewId: `mcp-app-${lease}`, expiresAtMs: lease === 1 ? 10_000 : 20_000 };
}
throw new Error(`unexpected method: ${method}`);
});
const provider = new GatewayBoardProvider("agent:main:app", {
request: request as never,
addEventListener: () => () => {},
});
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
status: "ready",
viewId: "mcp-app-1",
});
expect(request).toHaveBeenCalledWith("board.widget.appView", {
sessionKey: "agent:main:app",
name: "server-app",
revision: 1,
instanceId: "app-instance",
});
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
viewId: "mcp-app-1",
});
now = 6_000;
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
status: "ready",
viewId: "mcp-app-2",
});
expect(request.mock.calls.filter(([method]) => method === "board.widget.appView")).toHaveLength(
2,
);
});
it("does not reuse a cached MCP App lease for a same-name replacement", async () => {
const cache = new BoardMcpAppViewCache();
const widget = {
name: "server-app",
tabId: "main",
contentKind: "mcp-app" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 1,
instanceId: "instance-a",
};
const request = vi
.fn()
.mockResolvedValueOnce({ viewId: "view-a", expiresAtMs: Date.now() + 60_000 })
.mockResolvedValueOnce({ viewId: "view-b", expiresAtMs: Date.now() + 60_000 });
await expect(cache.resolve(widget, request, false)).resolves.toMatchObject({
viewId: "view-a",
});
await expect(
cache.resolve({ ...widget, instanceId: "instance-b" }, request, false),
).resolves.toMatchObject({ viewId: "view-b" });
expect(request).toHaveBeenCalledTimes(2);
});
it("contains MCP App re-mint failures as stale widget state and retries on demand", async () => {
mockLocation.search = "";
const snapshot = {
sessionKey: "agent:main:stale-app",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "server-app",
tabId: "main",
contentKind: "mcp-app" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 1,
instanceId: "stale-app-instance",
},
],
};
let attempts = 0;
const request = vi.fn(async (method: string) => {
if (method === "board.get") {
return snapshot;
}
attempts += 1;
if (attempts === 1) {
throw new Error("origin transcript pruned");
}
return { viewId: "mcp-app-restored", expiresAtMs: Date.now() + 60_000 };
});
const provider = new GatewayBoardProvider("agent:main:stale-app", {
request: request as never,
addEventListener: () => () => {},
});
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
await expect(provider.widgetAppView("server-app", 1)).resolves.toEqual({
status: "stale",
error: "origin transcript pruned",
});
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
status: "ready",
viewId: "mcp-app-restored",
});
});
});
+151 -5
View File
@@ -2,8 +2,10 @@ import type {
BoardChangedEvent,
BoardCommand,
BoardCommandEvent,
BoardMcpAppPinDescriptor,
BoardOp,
BoardSnapshot,
BoardWidgetAppViewResult,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
@@ -11,14 +13,15 @@ import {
buildAgentMainSessionKey,
normalizeSessionKeyForUiComparison,
} from "../sessions/session-key.ts";
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
import { applyMockBoardOp, normalizeMockBoardSnapshot } from "./mock-ops.ts";
import type { BoardWidgetAppViewState } from "./view-types.ts";
export type { BoardCommandEvent };
export type { BoardViewCallbacks } from "./view-types.ts";
export type { BoardViewCallbacks, BoardWidgetAppViewState } from "./view-types.ts";
type BoardGatewayClient = Pick<GatewayBrowserClient, "request" | "addEventListener">;
type BoardPinWidgetInput = {
docId: string;
type BoardPinPlacement = {
title?: string;
name?: string;
tabId?: string;
@@ -26,6 +29,9 @@ type BoardPinWidgetInput = {
after?: string;
};
type BoardPinWidgetInput = BoardPinPlacement & { docId: string };
type BoardPinMcpAppInput = BoardPinPlacement & { descriptor: BoardMcpAppPinDescriptor };
type BoardSnapshotSignal = {
readonly value: BoardSnapshot;
subscribe(listener: () => void): () => void;
@@ -36,13 +42,17 @@ type BoardEventStream = {
};
export type BoardProvider = {
readonly sessionKey: string;
readonly canPinWidgets: boolean;
readonly snapshot$: BoardSnapshotSignal;
applyOps(ops: BoardOp[]): Promise<void>;
grant(name: string, decision: "granted" | "rejected"): Promise<void>;
pinWidget(input: BoardPinWidgetInput): Promise<void>;
pinMcpApp(input: BoardPinMcpAppInput): Promise<void>;
widgetFrameUrl(name: string, revision: number): string;
refreshWidgetFrame(name: string): Promise<void>;
widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState>;
refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState>;
readonly events: BoardEventStream;
};
@@ -64,6 +74,15 @@ export function canvasWidgetNameForDocument(docId: string): string {
return `${prefix}-${hashDocumentId(docId)}`;
}
export function mcpAppWidgetNameForToolCall(toolCallId: string): string {
const name = `mcp-app-${toolCallId.toLowerCase().replace(/[^a-z0-9._-]/gu, "-")}`;
if (name === `mcp-app-${toolCallId}` && name.length <= 64) {
return name;
}
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "mcp-app-widget";
return `${prefix}-${hashDocumentId(toolCallId)}`;
}
class ValueSignal<T> {
private readonly listeners = new Set<() => void>();
@@ -166,7 +185,7 @@ class NullProvider implements BoardProvider {
readonly snapshot$: BoardSnapshotSignal;
readonly events: BoardEventStream = new EventStream<BoardCommandEvent>();
constructor(sessionKey = "") {
constructor(readonly sessionKey = "") {
this.snapshot$ = new ValueSignal(emptySnapshot(sessionKey));
}
@@ -178,11 +197,23 @@ class NullProvider implements BoardProvider {
throw new Error("Session dashboard unavailable");
}
async pinMcpApp(_input: BoardPinMcpAppInput): Promise<void> {
throw new Error("Session dashboard unavailable");
}
widgetFrameUrl(_name: string, _revision: number): string {
return "";
}
async refreshWidgetFrame(_name: string): Promise<void> {}
async widgetAppView(_name: string, _revision: number): Promise<BoardWidgetAppViewState> {
return { status: "stale", error: "Session dashboard unavailable" };
}
async refreshWidgetAppView(_name: string, _revision: number): Promise<BoardWidgetAppViewState> {
return { status: "stale", error: "Session dashboard unavailable" };
}
}
class MockBoardProvider implements BoardProvider {
@@ -255,6 +286,44 @@ class MockBoardProvider implements BoardProvider {
);
}
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
const snapshot = this.snapshotSignal.value;
const name = input.name ?? mcpAppWidgetNameForToolCall(input.descriptor.toolCallId);
const title = boardWidgetTitle(input.title);
const tabId = input.tabId ?? snapshot.tabs[0]?.tabId ?? "main";
const tabs = snapshot.tabs.length
? snapshot.tabs
: [
{
tabId: "main",
title: t("chat.board.defaultTab"),
position: 0,
chatDock: "right" as const,
},
];
const existing = snapshot.widgets.find((widget) => widget.name === name);
const widgets = snapshot.widgets.filter((widget) => widget.name !== name);
widgets.push({
name,
tabId,
...(title ? { title } : {}),
contentKind: "mcp-app",
sizeW: existing?.sizeW ?? 6,
sizeH: existing?.sizeH ?? 4,
position: existing?.position ?? widgets.filter((widget) => widget.tabId === tabId).length,
grantState: "none",
revision: (existing?.revision ?? 0) + 1,
});
this.snapshotSignal.set(
normalizeMockBoardSnapshot({
...snapshot,
revision: snapshot.revision + 1,
tabs,
widgets,
}),
);
}
widgetFrameUrl(name: string, revision: number): string {
return (
this.snapshotSignal.value.widgets.find(
@@ -265,6 +334,14 @@ class MockBoardProvider implements BoardProvider {
async refreshWidgetFrame(_name: string): Promise<void> {}
async widgetAppView(_name: string, _revision: number): Promise<BoardWidgetAppViewState> {
return { status: "stale", error: "MCP App mock view unavailable" };
}
async refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
return await this.widgetAppView(name, revision);
}
emitCommand(command: BoardCommand): void {
this.eventStream.emit({ sessionKey: this.sessionKey, command });
}
@@ -285,6 +362,7 @@ export class GatewayBoardProvider implements BoardProvider {
private stateGeneration = 0;
private connected = false;
private wakeRetryDelay: (() => void) | undefined;
private readonly appViews = new BoardMcpAppViewCache();
constructor(
readonly sessionKey: string,
@@ -319,6 +397,7 @@ export class GatewayBoardProvider implements BoardProvider {
this.clientGeneration += 1;
this.stateGeneration += 1;
this.changedWidgets.clear();
this.appViews.clear();
this.snapshotSignal.set(emptySnapshot(this.sessionKey));
this.subscribe(client);
if (connected) {
@@ -348,6 +427,7 @@ export class GatewayBoardProvider implements BoardProvider {
name,
decision,
revision: widget.revision,
...(widget.instanceId ? { instanceId: widget.instanceId } : {}),
});
}
@@ -375,6 +455,30 @@ export class GatewayBoardProvider implements BoardProvider {
);
}
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
const name = input.name ?? mcpAppWidgetNameForToolCall(input.descriptor.toolCallId);
const title = boardWidgetTitle(input.title);
await this.mutate(
"board.widget.put",
{
sessionKey: this.sessionKey,
name,
...(title ? { title } : {}),
content: { kind: "mcp-app", descriptor: input.descriptor },
...(input.tabId || input.size || input.after
? {
placement: {
...(input.tabId ? { tabId: input.tabId } : {}),
...(input.size ? { size: input.size } : {}),
...(input.after ? { after: input.after } : {}),
},
}
: {}),
},
name,
);
}
widgetFrameUrl(name: string, revision: number): string {
return (
this.snapshotSignal.value.widgets.find(
@@ -387,6 +491,42 @@ export class GatewayBoardProvider implements BoardProvider {
return this.requestRefresh(name);
}
async widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
return await this.resolveWidgetAppView(name, revision, false);
}
async refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState> {
return await this.resolveWidgetAppView(name, revision, true);
}
private async resolveWidgetAppView(
name: string,
revision: number,
force: boolean,
): Promise<BoardWidgetAppViewState> {
const widget = this.snapshotSignal.value.widgets.find(
(candidate) =>
candidate.name === name &&
candidate.revision === revision &&
candidate.contentKind === "mcp-app",
);
if (!widget) {
return { status: "stale", error: "Dashboard MCP App widget unavailable" };
}
const client = this.client;
return await this.appViews.resolve(
widget,
async () =>
await client.request<BoardWidgetAppViewResult>("board.widget.appView", {
sessionKey: this.sessionKey,
name,
revision,
...(widget.instanceId ? { instanceId: widget.instanceId } : {}),
}),
force,
);
}
private subscribe(client: BoardGatewayClient): void {
this.unsubscribe = client.addEventListener((event) => {
if (event.event === "board.changed") {
@@ -530,12 +670,14 @@ export class GatewayBoardProvider implements BoardProvider {
previous &&
!changedWidgets.has(widget.name) &&
previous.revision === widget.revision &&
previous.instanceId === widget.instanceId &&
previous.frameUrl
) {
return { ...widget, frameUrl: previous.frameUrl };
}
return widget;
});
this.appViews.prune(widgets);
this.snapshotSignal.set({ ...snapshot, widgets });
}
}
@@ -561,11 +703,15 @@ function isMockBoardSession(sessionKey: string): boolean {
return /^agent:[^:]+:[^:]+$/u.test(sessionKey);
}
function boardProviderCacheKey(sessionKey: string): string {
export function normalizeBoardSessionKeyForComparison(sessionKey: string): string {
const normalized = normalizeSessionKeyForUiComparison(sessionKey);
return normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized;
}
function boardProviderCacheKey(sessionKey: string): string {
return normalizeBoardSessionKeyForComparison(sessionKey);
}
export function boardProviderForSession(
sessionKey: string,
client?: BoardGatewayClient | null,
+5
View File
@@ -1,6 +1,9 @@
import type { BoardOp, BoardSnapshot, BoardWidget } from "@openclaw/gateway-protocol";
export type BoardGrantDecision = "granted" | "rejected";
export type BoardWidgetAppViewState =
| { status: "ready"; viewId: string; expiresAtMs: number }
| { status: "stale"; error: string };
/** Native Control UI card, derived from session state rather than the board store. */
type BoardStoredWidget = BoardWidget & {
@@ -22,6 +25,8 @@ export type BoardViewCallbacks = {
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;
selectTab: (tabId: string) => void;
frameLoadFailed?: (name: string) => Promise<void>;
widgetAppView?: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
refreshWidgetAppView?: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
};
export type BoardWidgetFrameUrl = (name: string, revision: number) => string;
+1
View File
@@ -177,6 +177,7 @@ export type ToolCard = {
toolName?: string;
uiResourceUri?: string;
toolCallId?: string;
originSessionKey?: string;
};
};
};
+3
View File
@@ -146,6 +146,9 @@ function coerceCanvasPreview(
? { uiResourceUri: mcpApp.uiResourceUri }
: {}),
...(typeof mcpApp.toolCallId === "string" ? { toolCallId: mcpApp.toolCallId } : {}),
...(typeof mcpApp.originSessionKey === "string"
? { originSessionKey: mcpApp.originSessionKey }
: {}),
},
}
: {}),
+3
View File
@@ -3363,6 +3363,9 @@ class ChatPane extends OpenClawLightDomElement {
this.persistBoardSessionView({ face: "dashboard", activeTabId: tabId });
},
frameLoadFailed: (name) => board.provider.refreshWidgetFrame(name),
widgetAppView: (name, revision) => board.provider.widgetAppView(name, revision),
refreshWidgetAppView: (name, revision) =>
board.provider.refreshWidgetAppView(name, revision),
} satisfies BoardViewCallbacks,
widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision),
onDockChange: (dock) => this.handleBoardDockChange(dock),
@@ -0,0 +1,36 @@
import type { BoardMcpAppPinDescriptor } from "@openclaw/gateway-protocol";
import { normalizeBoardSessionKeyForComparison } from "../../../lib/board/provider.ts";
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
export function buildMcpAppPinDescriptor(
preview: ToolPreview,
boardSessionKey: string,
): BoardMcpAppPinDescriptor | undefined {
const descriptor = preview.mcpApp;
const viewId = descriptor?.viewId?.trim();
const serverName = descriptor?.serverName?.trim();
const toolName = descriptor?.toolName?.trim();
const uiResourceUri = descriptor?.uiResourceUri?.trim();
const toolCallId = descriptor?.toolCallId?.trim();
const originSessionKey = descriptor?.originSessionKey?.trim();
if (
!viewId ||
!serverName ||
!toolName ||
!uiResourceUri ||
!toolCallId ||
!originSessionKey ||
normalizeBoardSessionKeyForComparison(originSessionKey) !==
normalizeBoardSessionKeyForComparison(boardSessionKey)
) {
return undefined;
}
return {
viewId,
serverName,
toolName,
uiResourceUri,
toolCallId,
originSessionKey,
};
}
@@ -3,6 +3,7 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import { buildMcpAppPinDescriptor } from "./widget-card-mcp-app.ts";
import { renderToolPreview } from "./widget-card.ts";
describe("widget-card", () => {
@@ -65,6 +66,7 @@ describe("widget-card", () => {
subscribe: () => () => {},
};
const provider = {
sessionKey: "agent:main:main",
canPinWidgets: true,
pinWidget,
snapshot$: snapshotSignal,
@@ -204,4 +206,76 @@ describe("widget-card", () => {
);
expect(app.querySelector("[data-pin-widget]")).toBeNull();
});
it("pins complete MCP App descriptors only to their originating board", async () => {
const pinMcpApp = vi.fn(async () => undefined);
const provider = {
sessionKey: "agent:main:main",
canPinWidgets: true,
pinMcpApp,
snapshot$: {
value: {
sessionKey: "agent:main:main",
revision: 0,
tabs: [],
widgets: [],
},
subscribe: () => () => {},
},
} as unknown as BoardProvider;
const preview = {
kind: "canvas" as const,
surface: "assistant_message" as const,
render: "url" as const,
title: "Weather",
mcpApp: {
viewId: "mcp-app-source",
serverName: "weather",
toolName: "show",
uiResourceUri: "ui://weather/app",
toolCallId: "call-1",
originSessionKey: "agent:main:main",
},
};
expect(buildMcpAppPinDescriptor(preview, "agent:main:main")).toEqual({
viewId: "mcp-app-source",
serverName: "weather",
toolName: "show",
uiResourceUri: "ui://weather/app",
toolCallId: "call-1",
originSessionKey: "agent:main:main",
});
expect(buildMcpAppPinDescriptor(preview, "agent:main:other")).toBeUndefined();
expect(buildMcpAppPinDescriptor(preview, "main")).toEqual(
expect.objectContaining({ originSessionKey: "agent:main:main" }),
);
const origin = document.createElement("div");
render(
renderToolPreview(preview, "chat_message", {
boardProvider: provider,
sessionKey: "agent:main:main",
}),
origin,
);
origin.querySelector<HTMLButtonElement>("[data-pin-widget]")?.click();
await vi.waitFor(() =>
expect(pinMcpApp).toHaveBeenCalledWith({
descriptor: expect.objectContaining({ viewId: "mcp-app-source", toolCallId: "call-1" }),
name: "mcp-app-call-1",
title: "Weather",
}),
);
const otherProvider = { ...provider, sessionKey: "agent:main:other" } as BoardProvider;
const other = document.createElement("div");
render(
renderToolPreview(preview, "chat_message", {
boardProvider: otherProvider,
sessionKey: "agent:main:main",
}),
other,
);
expect(other.querySelector("[data-pin-widget]")).toBeNull();
});
});
+51 -7
View File
@@ -1,3 +1,4 @@
import type { BoardMcpAppPinDescriptor } from "@openclaw/gateway-protocol";
import { html, nothing } from "lit";
import { keyed } from "lit/directives/keyed.js";
import { ensureCustomElementDefined } from "../../../app/lazy-custom-element.ts";
@@ -9,7 +10,11 @@ import {
} from "../../../components/mcp-app-security.ts";
import "../../../components/web-awesome.ts";
import { t } from "../../../i18n/index.ts";
import { canvasWidgetNameForDocument, type BoardProvider } from "../../../lib/board/provider.ts";
import {
canvasWidgetNameForDocument,
mcpAppWidgetNameForToolCall,
type BoardProvider,
} from "../../../lib/board/provider.ts";
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
import {
isInternalCanvasEntryUrl,
@@ -19,6 +24,7 @@ import {
} from "../../../lib/chat/tool-display.ts";
import { showToast } from "../../../lib/toast.ts";
import type { SidebarContent } from "./chat-sidebar.ts";
import { buildMcpAppPinDescriptor } from "./widget-card-mcp-app.ts";
import { exportWidget } from "./widget-export.ts";
import { installWidgetThemeObserver, postWidgetTheme } from "./widget-theme.ts";
@@ -62,6 +68,34 @@ async function pinCanvasWidget(
}
}
async function pinMcpAppWidget(
event: Event,
preview: ToolPreview,
provider: BoardProvider,
name: string,
descriptor: BoardMcpAppPinDescriptor,
): Promise<void> {
const button = event.currentTarget;
if (!(button instanceof HTMLButtonElement)) {
return;
}
button.disabled = true;
button.textContent = t("chat.toolCards.pinToDashboardPending");
try {
await provider.pinMcpApp({
descriptor,
name,
...(preview.title?.trim() ? { title: preview.title.trim() } : {}),
});
button.textContent = t("chat.toolCards.pinnedToDashboard");
button.dataset.pinned = "true";
} catch (error) {
button.disabled = false;
button.textContent = t("chat.toolCards.pinToDashboard");
button.title = error instanceof Error ? error.message : String(error);
}
}
function canvasWidgetName(preview: ToolPreview): string | undefined {
if (preview.boardWidgetName) {
return preview.boardWidgetName;
@@ -427,17 +461,25 @@ function renderWidgetCard(
}
const label = preview.title?.trim() || t("chat.toolCards.canvas");
const contentKind = preview.mcpApp ? "mcp-app" : "canvas-html";
const pinName = canvasWidgetName(preview);
const mcpDescriptor = options?.boardProvider
? buildMcpAppPinDescriptor(preview, options.boardProvider.sessionKey)
: undefined;
const pinName = preview.mcpApp
? mcpDescriptor
? mcpAppWidgetNameForToolCall(mcpDescriptor.toolCallId)
: undefined
: canvasWidgetName(preview);
const pinned = Boolean(
pinName &&
options?.boardProvider?.snapshot$.value.widgets.some((widget) => widget.name === pinName),
);
const pinAction =
contentKind === "canvas-html" &&
preview.sandbox === "scripts" &&
options?.boardProvider?.canPinWidgets &&
isManagedCanvasDocumentPreview(preview) &&
pinName
pinName &&
((contentKind === "canvas-html" &&
preview.sandbox === "scripts" &&
isManagedCanvasDocumentPreview(preview)) ||
(contentKind === "mcp-app" && mcpDescriptor))
? html`<button
class="chat-tool-card__widget-action"
type="button"
@@ -449,7 +491,9 @@ function renderWidgetCard(
pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard",
)}
@click=${(event: Event) =>
void pinCanvasWidget(event, preview, options.boardProvider!, pinName)}
contentKind === "mcp-app" && mcpDescriptor
? void pinMcpAppWidget(event, preview, options.boardProvider!, pinName, mcpDescriptor)
: void pinCanvasWidget(event, preview, options.boardProvider!, pinName)}
>
${t(pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard")}
</button>`
+41
View File
@@ -383,6 +383,47 @@ openclaw-board-widget-cell {
}
}
.board-widget__mcp-app {
display: grid;
min-height: 100%;
}
.board-widget__mcp-app > .board-widget__grant {
border-bottom: 1px solid var(--board-line);
height: auto;
min-height: 0;
padding: 12px;
}
.board-widget__mcp-app-view {
display: block;
min-height: 160px;
width: 100%;
}
.board-widget__app-loading,
.board-widget__stale {
align-content: center;
color: var(--muted, #8a919e);
display: grid;
font-size: 11px;
gap: 8px;
justify-items: center;
min-height: 160px;
padding: 16px;
text-align: center;
}
.board-widget__stale strong {
color: var(--text, #d7dae0);
font-size: 13px;
}
.board-widget__stale > span {
line-height: 1.45;
max-width: 34ch;
}
.board-widget__resize-handle {
bottom: 1px;
cursor: nwse-resize;