feat: add scoped history and artifacts
Generic Container CI/CD / test-build-publish (push) Failing after 8m25s
Agent CI/CD / build-test-publish-and-deploy (push) Failing after 8m25s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent bc4e9834e5
commit 7d47a2df86
48 changed files with 2632 additions and 78 deletions
+50
View File
@@ -0,0 +1,50 @@
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) }] };
}
+8 -1
View File
@@ -8,6 +8,7 @@ import { localQueryDescriptions, localQueryInputs } from "./local-query-tools.js
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
import { localEvidenceResult } from "./evidence-result.js";
import { nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
import { saveMcpArtifact } from "./artifact-files.js";
export type LocalQueryName = typeof LOCAL_QUERY_TOOLS[number];
const productNames = new Set<string>(PRODUCT_MCP_TOOLS);
@@ -16,6 +17,7 @@ export function createQueryMcpHandler(options: {
internalToken: string; productClient: Pick<McpQueryClient, "query"> | null; productTools: Tool[];
resolveReader: (id: string) => Promise<QueryOwner | null>;
executeLocal: (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown>;
artifactWorkspace?: (context: RuntimeSessionContext) => Promise<string>;
}) {
const definitions: Tool[] = [...options.productTools, ...LOCAL_QUERY_TOOLS.map((name) => ({ name, description: localQueryDescriptions[name],
inputSchema: z.toJSONSchema(localQueryInputs[name], { io: "input" }) as Tool["inputSchema"], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: name === "geocode" } }))];
@@ -38,7 +40,12 @@ export function createQueryMcpHandler(options: {
// JSON-RPC IDs may restart on a native MCP reconnect. Use a host
// request key, stable across this client's auth/transport retries,
// so a later query cannot collide with a saved evidence receipt.
return localEvidenceResult(await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal));
const result = await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal);
if (["export_project_model", "export_sensor_placement"].includes(definition.name) && !result.isError) {
if (!options.artifactWorkspace) return rejected("Export workspace unavailable");
return await saveMcpArtifact(result, owner.context, await options.artifactWorkspace(owner.context));
}
return localEvidenceResult(result);
}
const value = await options.executeLocal(definition.name as LocalQueryName, args, owner.context, signal);
signal.throwIfAborted();