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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user