feat(mcp): add file tools for scenarios and runs

- expose scenario file upload, listing, and content retrieval over MCP\n- expose run artifact listing and content retrieval with end-to-end coverage
This commit is contained in:
2026-04-21 18:00:41 +03:00
parent 06c662042b
commit b57b55f3e1
4 changed files with 756 additions and 0 deletions
+256
View File
@@ -1062,6 +1062,262 @@ export class McpService {
},
);
// ── Scenario Files ────────────────────────────────────────────────────────
server.registerTool(
"upload_scenario_file",
{
description:
"Upload a file to a scenario. Content must be base64-encoded. Returns file metadata.",
inputSchema: {
scenarioId: z.uuid().describe("Scenario ID"),
name: z.string().describe("File name"),
contentBase64: z
.string()
.describe("Base64-encoded file content"),
mimeType: z
.string()
.optional()
.describe("MIME type (e.g., text/plain, application/json)"),
expiresAt: z
.string()
.optional()
.describe("ISO 8601 expiration date"),
},
},
async ({ scenarioId, name, contentBase64, mimeType, expiresAt }) => {
try {
// Validate base64 format
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(contentBase64)) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "Invalid base64 encoding",
},
],
};
}
// Decode base64 to buffer
let buffer: Buffer;
try {
buffer = Buffer.from(contentBase64, "base64");
// Verify the base64 can be re-encoded to match the original
if (buffer.toString("base64") !== contentBase64) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "Invalid base64 encoding",
},
],
};
}
} catch {
return {
isError: true,
content: [
{
type: "text" as const,
text: "Invalid base64 encoding",
},
],
};
}
// Create a mock Multer file object for the service
const file = {
buffer,
originalname: name,
mimetype: mimeType || "application/octet-stream",
} as any;
// Call the service method
const result = await this.scenarioService.uploadFile(
scenarioId,
file,
expiresAt,
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text" as const, text: (err as Error).message }],
};
}
},
);
server.registerTool(
"list_scenario_files",
{
description:
"List files uploaded to a scenario (paginated)",
inputSchema: {
scenarioId: z.uuid().describe("Scenario ID"),
limit: z
.number()
.int()
.min(1)
.optional()
.describe("Items per page (default 20)"),
offset: z
.number()
.int()
.min(0)
.optional()
.describe("Skip count (default 0)"),
},
},
async ({ scenarioId, limit, offset }) => {
try {
const result = await this.scenarioService.listScenarioFiles(
scenarioId,
limit,
offset,
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text" as const, text: (err as Error).message }],
};
}
},
);
server.registerTool(
"get_scenario_file_content",
{
description:
"Get a scenario file's content as base64 with metadata",
inputSchema: {
scenarioId: z.uuid().describe("Scenario ID"),
fileId: z.uuid().describe("File ID"),
},
},
async ({ scenarioId, fileId }) => {
try {
const { file, contentBuffer } =
await this.scenarioService.getScenarioFileContentAsBuffer(
scenarioId,
fileId,
);
const contentBase64 = contentBuffer.toString("base64");
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
file,
contentBase64,
encoding: "base64",
}),
},
],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text" as const, text: (err as Error).message }],
};
}
},
);
// ── Run Artifact Files ────────────────────────────────────────────────────
server.registerTool(
"list_run_files",
{
description:
"List files (artifacts) created during a scenario run (paginated)",
inputSchema: {
scenarioId: z.uuid().describe("Scenario ID"),
runId: z.uuid().describe("Run ID"),
limit: z
.number()
.int()
.min(1)
.optional()
.describe("Items per page (default 20)"),
offset: z
.number()
.int()
.min(0)
.optional()
.describe("Skip count (default 0)"),
},
},
async ({ scenarioId, runId, limit, offset }) => {
try {
const result = await this.scenarioService.listRunFiles(
scenarioId,
runId,
limit,
offset,
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text" as const, text: (err as Error).message }],
};
}
},
);
server.registerTool(
"get_run_file_content",
{
description:
"Get a run artifact file's content as base64 with metadata",
inputSchema: {
scenarioId: z.uuid().describe("Scenario ID"),
runId: z.uuid().describe("Run ID"),
fileId: z.uuid().describe("File ID"),
},
},
async ({ scenarioId, runId, fileId }) => {
try {
const { file, contentBuffer } =
await this.scenarioService.getRunFileContentAsBuffer(
scenarioId,
runId,
fileId,
);
const contentBase64 = contentBuffer.toString("base64");
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
file,
contentBase64,
encoding: "base64",
}),
},
],
};
} catch (err) {
return {
isError: true,
content: [{ type: "text" as const, text: (err as Error).message }],
};
}
},
);
// ── Snippets ──────────────────────────────────────────────────────────────
server.registerTool(