51 lines
3.3 KiB
TypeScript
51 lines
3.3 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { open } from "node:fs/promises";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import type { CallToolResult } from "@modelcontextprotocol/client";
|
|
import { config } from "../config.js";
|
|
import { isManagedConversationWorkspace, resolveConversationWorkspace, SANDBOX_UID, SANDBOX_GID } from "../runtime/conversationWorkspace.js";
|
|
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
|
|
|
const formats = new Map([
|
|
["application/zip", ".zip"],
|
|
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"],
|
|
]);
|
|
export const artifactFilename = /^mcp-[a-f0-9-]{36}\.(zip|xlsx)$/;
|
|
|
|
export async function openArtifactDirectory(workspace: string, sessionId: string) {
|
|
const canonical = await resolveConversationWorkspace(workspace, config.CONVERSATION_WORKSPACE_ROOT);
|
|
if (!isManagedConversationWorkspace(canonical, config.CONVERSATION_WORKSPACE_ROOT, sessionId)) throw new Error("Export workspace owner mismatch");
|
|
const root = await open(canonical, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
try { return await open(`/proc/self/fd/${root.fd}/files`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
|
|
finally { await root.close(); }
|
|
}
|
|
|
|
export async function saveMcpArtifact(result: CallToolResult, context: RuntimeSessionContext, workspace: string): Promise<CallToolResult> {
|
|
if (result.isError) return result;
|
|
const output = result.structuredContent as Record<string, unknown> | undefined;
|
|
const data = output?.data as Record<string, unknown> | undefined;
|
|
const file = data?.file as Record<string, unknown> | undefined;
|
|
if (!file) throw new Error("Missing export file");
|
|
const extension = formats.get(String(file.mime_type));
|
|
if (!extension || file.encoding !== "base64" || typeof file.data !== "string" || file.data.length > 7 * 1024 * 1024) throw new Error("Invalid export format");
|
|
const bytes = Buffer.from(file.data, "base64");
|
|
if (!bytes.length || bytes.length > 5 * 1024 * 1024 || bytes.length !== file.size || bytes.toString("base64") !== file.data || createHash("sha256").update(bytes).digest("hex") !== file.sha256) throw new Error("Export integrity check failed");
|
|
const filename = `mcp-${randomUUID()}${extension}`;
|
|
const directory = await openArtifactDirectory(workspace, context.sessionId);
|
|
try {
|
|
const path = `/proc/self/fd/${directory.fd}/${filename}`;
|
|
const destination = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
try {
|
|
await destination.writeFile(bytes);
|
|
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
|
|
}
|
|
finally { await destination.close(); }
|
|
} finally { await directory.close(); }
|
|
const url = `/api/v1/session/${encodeURIComponent(context.clientSessionId)}/files/${filename}`;
|
|
const next = { ...output, data: { file: { name: file.name, mime_type: file.mime_type, size: bytes.length,
|
|
sha256: file.sha256, relative_path: `files/${filename}`, download_url: url,
|
|
markdown: `[下载 ${extension === ".zip" ? "模型文件" : "监测点清单"}](${url})`,
|
|
} } };
|
|
return { ...result, structuredContent: next, content: [{ type: "text", text: JSON.stringify(next) }] };
|
|
}
|