openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
342 lines (341 loc) • 15.2 kB
JavaScript
import { a as resolveGlobalSingleton } from "./global-singleton-0m-JziJW.mjs";
import { Fn as object, Jn as string, Nt as array, Pn as number, Yt as discriminatedUnion, xn as literal } from "./schemas-BOYIvvln.mjs";
import { n as registerMcpFormResourceOwner, r as requireMcpFormQuestion } from "./mcp-form-resource-context-DThxNgu9.mjs";
import { t as captureAgentQuestionAnswerAuthority } from "./host-private-capabilities-D9XrALSb.mjs";
import { c as releaseMcpAppView, r as fetchMcpAppView } from "./mcp-ui-resource-BFfH0TzA.mjs";
import { a as readGatewayRequestMutationAuthority } from "./session-mutation-guards-Cw1VPolZ.mjs";
import { t as createWorkspaceUploadBatch } from "./workspace-fs-zorsuwri.mjs";
import { n as compileStructuredInputForm, o as snapshotStructuredInput } from "./structured-input-B76WDvka.mjs";
import { l as resolveMcpAppRequesterId, n as callMcpAppToolWithElicitation } from "./mcp-app-operations-DR8z7nxr.mjs";
import { a as captureGatewayClientUploadCommitGuard } from "./upload-policy-B7cW3IFl.mjs";
import { t as canSelectQuestion } from "./question-access-Bxw3nBhY.mjs";
import { a as withPreparedQuestionSessions, t as prepareQuestionAuthorization } from "./question-session-access-CvJdQwkO.mjs";
import { r as readArtifactBase64Payload } from "./artifacts-base64-CgvdhGQA.mjs";
import { t as retainSessionScopedRead } from "./session-scoped-read-T8lBdRDm.mjs";
import { t as resolveLocalSessionWorkspaceRoot } from "./sessions-files-huv1OyNP.mjs";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js";
//#region src/gateway/mcp-app-form-resources.ts
const forms = resolveGlobalSingleton(Symbol.for("openclaw.mcpFormResourceBindings"), () => /* @__PURE__ */ new WeakMap());
const MAX_UPLOAD_BYTES = 5242880;
const routingSchema = object({
sessionKey: string().min(1),
agentId: string().min(1).optional(),
viewId: string().min(1).max(128),
requestId: string().min(1).max(256),
questionId: string().min(1).max(256)
});
const actionSchema = discriminatedUnion("action", [object({
action: literal("preview"),
optionIndex: number().int().min(0).max(63)
}).strict(), object({
action: literal("upload"),
files: array(object({
name: string().min(1).max(255),
mimeType: string().max(256),
content: string().max(Math.ceil(MAX_UPLOAD_BYTES / 3) * 4 + 4),
relativePath: string().min(1).max(2048).optional()
}).strict()).min(1).max(64)
}).strict()]);
async function createMcpAppFormResourceContext(params) {
params.signal.throwIfAborted();
params.origin.assertCurrent();
const snapshot = snapshotStructuredInput(params.snapshot, { richForm: true });
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) throw new Error("Invalid rich form snapshot");
const questionAuthority = captureAgentQuestionAnswerAuthority(params.origin.sessionKey);
if (params.origin.requesterId !== void 0 && questionAuthority?.requesterProfileId !== void 0 && params.origin.requesterId !== questionAuthority.requesterProfileId) throw new Error("MCP form requester does not match its question creator");
const origin = {
...params.origin,
requesterId: params.origin.requesterId ?? questionAuthority?.requesterProfileId
};
const viewId = "mcp-form-" + randomUUID();
let active = true;
let unregister = () => {};
const uploaded = /* @__PURE__ */ new Map();
const previewViews = /* @__PURE__ */ new Set();
const assertCurrent = () => {
params.signal.throwIfAborted();
if (!active) throw new Error("MCP form origin was retired");
origin.assertCurrent();
questionAuthority?.assertActive();
origin.runtime.assertOwnerCurrent?.();
};
const dispose = () => {
if (!active) return;
active = false;
unregister();
params.signal.removeEventListener("abort", dispose);
for (const id of previewViews) releaseMcpAppView(id, origin.runtime);
previewViews.clear();
uploaded.clear();
owner.pending.clear();
forms.delete(owner);
};
const context = {
viewId,
uploads: params.uploadResources !== void 0,
previews: origin.runtime.readResource !== void 0,
isUploadedResource: (questionId, uri) => {
try {
assertCurrent();
return uploaded.get(questionId)?.has(uri) === true;
} catch {
return false;
}
}
};
const compiled = compileStructuredInputForm({
schema: snapshot.requestedSchema,
message: typeof snapshot.message === "string" ? snapshot.message : void 0,
fallbackMessage: "MCP server needs input",
options: {
protocolName: "OpenAI",
allowRichForms: true,
resourceContext: context,
allowEmptyForm: true,
minimumChoiceCount: 1,
allowEnumNames: true
}
});
if (compiled.kind !== "ready" || compiled.plan.kind !== "form") throw new Error(compiled.kind === "unsupported" ? compiled.message : "Invalid resource form");
const questions = new Map(compiled.plan.fields.map((field) => [field.question.id, field.question]));
if (!origin.prepareToolCall && [...questions.values()].some((question) => question.options?.some((option) => option.preview?.type === "mcp_app_tool"))) throw new Error("Form App previews require an originating tool authority hook");
const owner = {
sessionKey: origin.sessionKey,
agentId: origin.agentId,
assertCurrent,
questions,
pending: /* @__PURE__ */ new Map(),
dispose
};
forms.set(owner, {
origin,
uploaded,
uploadResources: params.uploadResources,
previewViews,
owner,
signal: params.signal,
operations: 0,
activeOperations: 0,
uploadedBytes: 0,
uploadedFiles: 0
});
unregister = registerMcpFormResourceOwner(viewId, owner);
params.signal.addEventListener("abort", dispose, { once: true });
if (params.signal.aborted) {
dispose();
params.signal.throwIfAborted();
}
return {
context,
dispose
};
}
function acceptsFile(file, accepts) {
if (!accepts?.length) return true;
const name = file.name.toLowerCase();
const mime = file.mimeType.toLowerCase().split(";", 1)[0];
return accepts.some((rule) => {
const accept = rule.toLowerCase();
return accept.startsWith(".") ? name.endsWith(accept) : accept.endsWith("/*") ? mime.startsWith(accept.slice(0, -1)) : mime === accept;
});
}
/** UI selectors choose only a live owner record; they never supply targets or restrictions. */
async function executeMcpAppFormResource(options) {
const { sessionKey, agentId, viewId, requestId, questionId, ...action } = options.params;
const routing = routingSchema.parse({
sessionKey,
agentId,
viewId,
requestId,
questionId
});
const parsed = {
...routing,
...actionSchema.parse(action)
};
const manager = options.context.questionManager;
if (!manager || !canSelectQuestion(manager, parsed.requestId, options.client)) throw new Error("Question was not found");
const record = manager.get(parsed.requestId);
const observation = record ? manager.observe(record.id, record) : null;
const question = record?.questions.find((field) => field.questionId === parsed.questionId);
if (!record || record.status !== "pending" || !observation || !question || !record.agentId || record.sessionKey !== parsed.sessionKey || parsed.agentId && parsed.agentId !== record.agentId || question.resource?.viewId !== parsed.viewId) throw new Error("Question resource is not pending for this session");
const owner = requireMcpFormQuestion({
...routing,
agentId: record.agentId,
question,
record
});
const form = forms.get(owner);
if (!form || resolveMcpAppRequesterId(options.client) !== form.origin.requesterId) throw new Error("Question resource is not owned by this requester");
if (form.operations >= 120 || form.activeOperations >= 4) throw new Error("MCP form resource request limit reached");
form.operations += 1;
const caller = readGatewayRequestMutationAuthority(options);
const read = retainSessionScopedRead(options, record.sessionKey, record.agentId);
const authorized = prepareQuestionAuthorization(options, observation, record.id, parsed.action === "upload" ? "mutate" : "read");
const assertCurrent = () => {
caller.assertCurrent();
options.sessionMutationAuthorization?.assertCurrent();
read?.assertCurrent();
owner.assertCurrent();
if (options.context.getRuntimeConfig().mcp?.apps?.enabled !== true) throw new Error("MCP App runtime is unavailable");
if (manager.get(record.id) !== record || !observation.isCurrent() || record.status !== "pending" || resolveMcpAppRequesterId(options.client) !== form.origin.requesterId || owner.pending.get(record.id)?.record !== record) throw new Error("Question resource authority expired");
};
form.activeOperations += 1;
try {
await withPreparedQuestionSessions(options, [authorized.target], ([prepared]) => {
const error = authorized.authorize(prepared);
if (error) throw new Error(error.message);
}, { assertCurrent });
assertCurrent();
if (parsed.action === "preview") return await preview(form, options, question, parsed.optionIndex, assertCurrent);
const restrictions = question.resource?.userOptions;
if (!restrictions || !form.uploadResources) throw new Error("This question does not permit uploads");
const assertUpload = captureGatewayClientUploadCommitGuard({
method: "mcp.app.formResource",
requestParams: options.params,
client: options.client,
context: options.context
});
assertUpload?.();
let bytes = 0;
const files = parsed.files.map((file) => {
if (!acceptsFile(file, restrictions.accept)) throw new Error("File does not match this question’s upload restrictions");
const encoded = readArtifactBase64Payload(file.content, { includeData: true });
if (!encoded?.data && file.content !== "") throw new Error("Invalid upload encoding");
bytes += encoded?.sizeBytes ?? 0;
if (bytes > MAX_UPLOAD_BYTES) throw new Error("Form upload exceeds the byte limit");
return {
name: file.name,
mimeType: file.mimeType,
data: Buffer.from(encoded?.data ?? "", "base64"),
relativePath: file.relativePath || void 0
};
});
if (form.uploadedBytes + bytes > MAX_UPLOAD_BYTES || form.uploadedFiles + files.length > 64) throw new Error("MCP form upload budget exceeded");
form.uploadedBytes += bytes;
form.uploadedFiles += files.length;
const resources = await form.uploadResources({
options,
kind: restrictions.kind,
files,
assertCurrent: () => {
assertCurrent();
assertUpload?.();
}
});
assertCurrent();
assertUpload?.();
if (!resources.length || resources.length > 64 || resources.some((resource) => !resource.name || !URL.canParse(resource.uri))) throw new Error("Upload provider returned invalid resources");
const admitted = form.uploaded.get(question.questionId) ?? /* @__PURE__ */ new Set();
for (const resource of resources) admitted.add(resource.uri);
form.uploaded.set(question.questionId, admitted);
return { resources };
} finally {
form.activeOperations -= 1;
read?.release();
}
}
async function preview(form, options, question, index, assertCurrent) {
const target = question.options[index]?.preview;
if (!target) throw new Error("This option has no preview");
const { origin } = form;
if (target.type === "resource_link") {
if (!origin.runtime.readResource) throw new Error("Originating server cannot read resources");
const result = ReadResourceResultSchema.parse(await origin.runtime.readResource(origin.serverName, target.uri));
assertCurrent();
if (JSON.stringify(result.contents).length > MAX_UPLOAD_BYTES * 2) throw new Error("Resource preview exceeds the byte limit");
return { preview: { contents: result.contents.map((content) => "text" in content ? {
mimeType: content.mimeType || void 0,
text: content.text
} : {
mimeType: content.mimeType || void 0,
blob: content.blob
}) } };
}
const catalog = await origin.runtime.getCatalog();
assertCurrent();
const tool = catalog.tools.find((entry) => entry.serverName === origin.serverName && entry.toolName === target.name);
if (!tool?.uiResourceUri || tool.excludedFromOpenClawCatalog || tool.deniedBySession || !origin.prepareToolCall) throw new Error("Preview App tool is unavailable");
const input = target.arguments ?? {};
const assertPreparedPolicy = await origin.prepareToolCall({
options,
toolName: target.name,
input,
assertCurrent,
signal: form.signal
});
const assertExecutionCurrent = () => {
assertCurrent();
if (assertPreparedPolicy) assertPreparedPolicy();
};
assertExecutionCurrent();
const result = await callMcpAppToolWithElicitation({
options,
origin,
toolName: target.name,
input,
assertCurrent: assertExecutionCurrent,
signal: form.signal,
uploadResources: form.uploadResources
});
assertExecutionCurrent();
if (result.isError) throw new Error("Preview App tool failed");
const descriptor = await fetchMcpAppView({
runtime: origin.runtime,
agentId: origin.agentId,
serverName: origin.serverName,
toolName: target.name,
uiResourceUri: tool.uiResourceUri,
toolInput: input,
toolResult: result,
requesterId: origin.requesterId,
allowedAppToolNames: new Set(catalog.tools.filter((entry) => entry.serverName === origin.serverName && !entry.excludedFromOpenClawCatalog && !entry.deniedBySession && (entry.uiVisibility === void 0 || entry.uiVisibility.includes("app"))).map((entry) => entry.toolName)),
prepareToolCall: origin.prepareToolCall,
uploadResources: form.uploadResources,
authorizeAppInteraction: () => {
form.owner.assertCurrent();
return true;
}
});
if (!descriptor) throw new Error("Preview App resource is unavailable");
form.previewViews.add(descriptor.viewId);
assertCurrent();
return { preview: { viewId: descriptor.viewId } };
}
/** Only a host adapter that proved this originating server can read the local workspace may opt in. */
function createMcpAppWorkspaceUploadProvider(params) {
return async (request) => {
const assertCurrent = () => {
params.assertCurrent();
request.assertCurrent();
if (resolveLocalSessionWorkspaceRoot(params) !== params.workspaceDir) throw new Error("Form upload workspace authority changed");
};
const files = request.files.map((file) => {
if (file.name.includes("/") || file.name.includes("\\") || file.name === "." || file.name === "..") throw new Error("Invalid upload filename");
const relativePath = request.kind === "directory" ? file.relativePath : file.name;
if (!relativePath || request.kind === "directory" && relativePath.split("/").length < 2 || request.kind === "file" && file.relativePath) throw new Error("Upload does not match the field’s file/directory mode");
return {
relativePath,
data: file.data
};
});
const saved = await createWorkspaceUploadBatch({
rootDir: params.workspaceDir,
files,
assertCurrent
});
if (request.kind === "file") return saved.paths.map((filePath, index) => ({
uri: pathToFileURL(filePath).href,
name: request.files[index].name
}));
return [...new Set(files.map((file) => file.relativePath.split("/")[0]))].map((name) => ({
uri: pathToFileURL(path.join(saved.rootDir, name)).href,
name
}));
};
}
//#endregion
export { createMcpAppFormResourceContext, createMcpAppWorkspaceUploadProvider, executeMcpAppFormResource };