From b57b55f3e11400a6d2d7670d36865c6edf935a2b Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 21 Apr 2026 18:00:41 +0300 Subject: [PATCH] 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 --- docs/mcp.md | 22 ++ server/src/mcp/mcp.service.ts | 256 ++++++++++++++++ server/src/scenario/scenario.service.ts | 88 ++++++ server/test/mcp.controller.spec.ts | 390 ++++++++++++++++++++++++ 4 files changed, 756 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index a4e6971..069b7dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -50,7 +50,29 @@ The server currently registers the following tools. | `export_scenario` | Export scenario payload | | `import_scenario` | Import scenario payload | +## Scenario Files + +| Tool | Description | +|---|---| +| `upload_scenario_file` | Upload a file to a scenario; accepts optional `expiresAt` (ISO 8601 date string) | +| `list_scenario_files` | List scenario files (paginated) | +| `get_scenario_file_content` | Retrieve file content as base64 with metadata | + +## Run Artifact Files + +| Tool | Description | +|---|---| +| `list_run_files` | List files (artifacts) created during a scenario run (paginated) | +| `get_run_file_content` | Retrieve run artifact content as base64 with metadata; run artifacts are read-only and inherit expiry from the run artifact subsystem | + +## Binary Content Handling + +File tools transport binary payloads as base64-encoded strings in a `contentBase64` field with an accompanying `encoding: "base64"` marker in the response. When retrieving file content via `get_scenario_file_content` or `get_run_file_content`, decode the base64 to recover the original bytes. + +Scenario files uploaded via `upload_scenario_file` must have their content pre-encoded as base64. Run artifacts are created implicitly through scenario execution (via `context.downloadFile()` during step execution) and cannot be uploaded via MCP. + ## Notes - Tool IDs and entity IDs are UUIDs. - `create_scenario_step` schema still accepts legacy `type`/`sessionName` fields for compatibility. Current scheduler executes `execCode` plus optional `validateCode` and does not branch by step type. + diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index 87fc826..0c32094 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -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( diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index c1e5376..d93c6f1 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import * as fs from "fs/promises"; import * as path from "path"; import { Like, Repository } from "typeorm"; import { @@ -768,6 +769,47 @@ export class ScenarioService { res.sendFile(fullPath); } + /** + * Get scenario file content as a buffer (for MCP use). + * Returns metadata plus raw bytes without streaming to Response. + */ + async getScenarioFileContentAsBuffer( + scenarioId: string, + fileId: string, + ): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> { + // Verify scenario exists + await this.findOne(scenarioId); + + // Check if file is linked to this scenario + const link = await this.scenarioFileRepo.findOne({ + where: { scenarioId, fileId }, + relations: ["file"], + }); + + if (!link) { + throw new NotFoundException(`File ${fileId} not found in scenario ${scenarioId}`); + } + + const file = link.file; + const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath)); + + // Read file content as buffer + const contentBuffer = await fs.readFile(fullPath); + + return { + file: { + id: file.id, + name: file.originalName, + mimeType: file.mimeType, + size: file.size, + sha256: file.sha256, + expiresAt: file.expiresAt, + createdAt: file.createdAt, + }, + contentBuffer, + }; + } + async listRunFiles( scenarioId: string, runId: string, @@ -833,4 +875,50 @@ export class ScenarioService { res.sendFile(fullPath); } + + /** + * Get run artifact file content as a buffer (for MCP use). + * Returns metadata plus raw bytes without streaming to Response. + */ + async getRunFileContentAsBuffer( + scenarioId: string, + runId: string, + fileId: string, + ): Promise<{ file: { id: string; name: string; mimeType: string; size: number; sha256: string; expiresAt: Date | null; createdAt: Date }; contentBuffer: Buffer }> { + // Verify scenario and run exist + await this.findOne(scenarioId); + const run = await this.runRepo.findOneBy({ id: runId, scenarioId }); + if (!run) { + throw new NotFoundException(`Run ${runId} not found in scenario ${scenarioId}`); + } + + // Check if file is linked to this run + const link = await this.scenarioRunFileRepo.findOne({ + where: { runId, fileId }, + relations: ["file"], + }); + + if (!link) { + throw new NotFoundException(`File ${fileId} not found in run ${runId}`); + } + + const file = link.file; + const fullPath = path.resolve(this.fileStorageService.getAbsolutePath(file.filePath)); + + // Read file content as buffer + const contentBuffer = await fs.readFile(fullPath); + + return { + file: { + id: file.id, + name: file.originalName, + mimeType: file.mimeType, + size: file.size, + sha256: file.sha256, + expiresAt: file.expiresAt, + createdAt: file.createdAt, + }, + contentBuffer, + }; + } } diff --git a/server/test/mcp.controller.spec.ts b/server/test/mcp.controller.spec.ts index bf6b675..1ae7b24 100644 --- a/server/test/mcp.controller.spec.ts +++ b/server/test/mcp.controller.spec.ts @@ -1,6 +1,14 @@ import { INestApplication } from "@nestjs/common"; +import { getRepositoryToken } from "@nestjs/typeorm"; import request from "supertest"; +import * as http from "http"; +import { Repository } from "typeorm"; import { buildTestApp } from "./app.harness"; +import { ScenarioEntity } from "../src/scenario/scenario.entity"; +import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity"; +import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity"; +import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity"; +import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service"; /** * MCP controller integration tests. @@ -16,15 +24,65 @@ import { buildTestApp } from "./app.harness"; */ describe("McpController", () => { let app: INestApplication; + let testServer: http.Server; + let testServerUrl: string; + let scheduler: ScenarioSchedulerService; + let scenarioRepo: Repository; + let stepRepo: Repository; + let runRepo: Repository; + let runStepRepo: Repository; beforeAll(async () => { app = await buildTestApp(); + scheduler = app.get(ScenarioSchedulerService); + scenarioRepo = app.get>( + getRepositoryToken(ScenarioEntity), + ); + stepRepo = app.get>( + getRepositoryToken(ScenarioStepEntity), + ); + runRepo = app.get>( + getRepositoryToken(ScenarioRunEntity), + ); + runStepRepo = app.get>( + getRepositoryToken(ScenarioRunStepEntity), + ); + + // Start a test HTTP server for downloadFile tests + testServer = await createTestServer(); + testServerUrl = `http://localhost:${(testServer.address() as any).port}`; }); afterAll(async () => { + if (testServer) { + testServer.close(); + } await app.close(); }); + // ── helpers ──────────────────────────────────────────────────────────────── + + function createTestServer(): Promise { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + if (req.url === "/test-file.bin") { + res.writeHead(200, { "Content-Type": "application/octet-stream" }); + res.end(Buffer.from("test downloaded file content")); + } else if (req.url === "/test.pdf") { + res.writeHead(200, { "Content-Type": "application/pdf" }); + res.end(Buffer.from("fake pdf content")); + } else { + res.writeHead(404); + res.end(); + } + }); + + server.listen(0, "localhost", () => { + resolve(server); + }); + }); + } + /** Parse the JSON-RPC payload from an SSE response body. */ function parseSse(text: string): Record { const match = text.match(/^data:\s*(.+)$/m); @@ -47,6 +105,49 @@ describe("McpController", () => { return { status: res.status, rpc: parseSse(res.text) }; } + async function createScenario(name: string) { + return scenarioRepo.save(scenarioRepo.create({ name })); + } + + async function createStep(scenarioId: string, execCode: string, order = 0) { + return stepRepo.save( + stepRepo.create({ + scenarioId, + order, + execCode, + }), + ); + } + + async function createRun(scenarioId: string) { + return runRepo.save(runRepo.create({ scenarioId, status: "pending" })); + } + + async function createRunStep(runId: string, scenarioStepId: string, order = 0) { + return runStepRepo.save( + runStepRepo.create({ + runId, + scenarioStepId, + order, + status: "pending", + }), + ); + } + + async function waitForRunCompletion(runId: string, maxAttempts = 50) { + for (let index = 0; index < maxAttempts; index += 1) { + await scheduler.pickUpPendingRuns(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const run = await runRepo.findOneBy({ id: runId }); + if (run && (run.status === "pass" || run.status === "fail")) { + return run; + } + } + + throw new Error(`Run ${runId} did not complete within timeout`); + } + // ── Connectivity ─────────────────────────────────────────────────────────── describe("POST /mcp — connectivity", () => { @@ -285,6 +386,295 @@ describe("McpController", () => { }); }); + // ── Scenario File Tools ──────────────────────────────────────────────────── + + describe("upload_scenario_file", () => { + it("uploads a small text file via MCP and returns metadata", async () => { + const scRpc = ( + await mcpCall("create_scenario", { name: "mcp-file-upload-test" }) + ).rpc; + const sc = JSON.parse( + (scRpc.result as { content: { text: string }[] }).content[0].text, + ) as { id: string }; + + const content = "Hello, MCP!"; + const contentBase64 = Buffer.from(content).toString("base64"); + + const { status, rpc } = await mcpCall("upload_scenario_file", { + scenarioId: sc.id, + name: "test.txt", + contentBase64, + mimeType: "text/plain", + }); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + const uploaded = JSON.parse(result.content[0].text) as { + id: string; + name: string; + mimeType: string; + size: number; + }; + expect(uploaded.name).toBe("test.txt"); + expect(uploaded.mimeType).toBe("text/plain"); + expect(uploaded.size).toBe(content.length); + expect(typeof uploaded.id).toBe("string"); + }); + + it("returns MCP error for invalid base64", async () => { + const scRpc = ( + await mcpCall("create_scenario", { + name: "mcp-file-bad-base64-test", + }) + ).rpc; + const sc = JSON.parse( + (scRpc.result as { content: { text: string }[] }).content[0].text, + ) as { id: string }; + + const { status, rpc } = await mcpCall("upload_scenario_file", { + scenarioId: sc.id, + name: "test.txt", + contentBase64: "!@#$%^&*()", + }); + expect(status).toBe(200); + const result = rpc.result as { isError: boolean }; + expect(result.isError).toBe(true); + }); + }); + + describe("list_scenario_files", () => { + it("returns uploaded files with metadata", async () => { + const scRpc = ( + await mcpCall("create_scenario", { name: "mcp-file-list-test" }) + ).rpc; + const sc = JSON.parse( + (scRpc.result as { content: { text: string }[] }).content[0].text, + ) as { id: string }; + + // Upload a file + const content = "Test content"; + const contentBase64 = Buffer.from(content).toString("base64"); + await mcpCall("upload_scenario_file", { + scenarioId: sc.id, + name: "test.txt", + contentBase64, + }); + + // List files + const { status, rpc } = await mcpCall("list_scenario_files", { + scenarioId: sc.id, + }); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + const list = JSON.parse(result.content[0].text) as { + items: Array<{ id: string; name: string }>; + total: number; + }; + expect(list.items.length).toBeGreaterThan(0); + expect(list.items[0].name).toBe("test.txt"); + expect(typeof list.total).toBe("number"); + }); + + it("returns MCP error for non-existent scenario", async () => { + const { status, rpc } = await mcpCall("list_scenario_files", { + scenarioId: "00000000-0000-0000-0000-000000000000", + }); + expect(status).toBe(200); + const result = rpc.result as { isError: boolean }; + expect(result.isError).toBe(true); + }); + }); + + describe("get_scenario_file_content", () => { + it("retrieves file content as base64 with metadata", async () => { + const scRpc = ( + await mcpCall("create_scenario", { name: "mcp-file-content-test" }) + ).rpc; + const sc = JSON.parse( + (scRpc.result as { content: { text: string }[] }).content[0].text, + ) as { id: string }; + + // Upload a file + const originalContent = "Hello, this is test content!"; + const contentBase64 = Buffer.from(originalContent).toString("base64"); + const uploadRpc = ( + await mcpCall("upload_scenario_file", { + scenarioId: sc.id, + name: "content-test.txt", + contentBase64, + mimeType: "text/plain", + }) + ).rpc; + const uploaded = JSON.parse( + (uploadRpc.result as { content: { text: string }[] }).content[0].text, + ) as { id: string }; + + // Retrieve content + const { status, rpc } = await mcpCall("get_scenario_file_content", { + scenarioId: sc.id, + fileId: uploaded.id, + }); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + const response = JSON.parse(result.content[0].text) as { + file: { id: string; name: string; size: number }; + contentBase64: string; + encoding: string; + }; + + // Verify roundtrip + const retrievedContent = Buffer.from(response.contentBase64, "base64").toString( + "utf8", + ); + expect(retrievedContent).toBe(originalContent); + expect(response.file.name).toBe("content-test.txt"); + expect(response.file.size).toBe(originalContent.length); + expect(response.encoding).toBe("base64"); + }); + + it("returns MCP error for unknown file ID", async () => { + const scRpc = ( + await mcpCall("create_scenario", { + name: "mcp-file-unknown-id-test", + }) + ).rpc; + const sc = JSON.parse( + (scRpc.result as { content: { text: string }[] }).content[0].text, + ) as { id: string }; + + const { status, rpc } = await mcpCall("get_scenario_file_content", { + scenarioId: sc.id, + fileId: "00000000-0000-0000-0000-000000000000", + }); + expect(status).toBe(200); + const result = rpc.result as { isError: boolean }; + expect(result.isError).toBe(true); + }); + }); + + // ── Run Artifact Tools ───────────────────────────────────────────────────── + + async function createRunArtifactFixture(name: string) { + const scenario = await createScenario(name); + const step = await createStep( + scenario.id, + `return await context.downloadFile('${testServerUrl}/test-file.bin', { filename: 'artifact.bin' });`, + ); + const run = await createRun(scenario.id); + await createRunStep(run.id, step.id); + + const completedRun = await waitForRunCompletion(run.id); + expect(completedRun.status).toBe("pass"); + + return { sc: scenario, run: completedRun }; + } + + describe("list_run_files", () => { + it("returns artifacts created during a run with expiry metadata", async () => { + const { sc, run } = await createRunArtifactFixture("mcp-run-files-test"); + + const { status, rpc } = await mcpCall("list_run_files", { + scenarioId: sc.id, + runId: run.id, + }); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + const list = JSON.parse(result.content[0].text) as { + items: Array<{ + id: string; + name: string; + mimeType: string; + expiresAt: string | null; + }>; + total: number; + }; + + expect(list.total).toBe(1); + expect(list.items).toHaveLength(1); + expect(list.items[0].name).toBe("artifact.bin"); + expect(list.items[0].mimeType).toBe("application/octet-stream"); + expect(list.items[0].expiresAt).not.toBeNull(); + }); + + it("returns MCP error for non-existent scenario", async () => { + const { status, rpc } = await mcpCall("list_run_files", { + scenarioId: "00000000-0000-0000-0000-000000000000", + runId: "00000000-0000-0000-0000-000000000000", + }); + expect(status).toBe(200); + const result = rpc.result as { isError: boolean }; + expect(result.isError).toBe(true); + }); + }); + + describe("get_run_file_content", () => { + it("returns run artifact bytes as base64 for a valid file", async () => { + const { sc, run } = await createRunArtifactFixture("mcp-run-file-content-test"); + + const listRpc = ( + await mcpCall("list_run_files", { + scenarioId: sc.id, + runId: run.id, + }) + ).rpc; + const list = JSON.parse( + (listRpc.result as { content: { text: string }[] }).content[0].text, + ) as { + items: Array<{ id: string; name: string; mimeType: string }>; + }; + const file = list.items[0]; + + const { status, rpc } = await mcpCall("get_run_file_content", { + scenarioId: sc.id, + runId: run.id, + fileId: file.id, + }); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + const response = JSON.parse(result.content[0].text) as { + file: { id: string; name: string; mimeType: string; expiresAt: string | null }; + contentBase64: string; + encoding: string; + }; + + expect(response.file.id).toBe(file.id); + expect(response.file.name).toBe("artifact.bin"); + expect(response.file.mimeType).toBe("application/octet-stream"); + expect(response.file.expiresAt).not.toBeNull(); + expect(response.encoding).toBe("base64"); + expect(Buffer.from(response.contentBase64, "base64").toString("utf8")).toBe( + "test downloaded file content", + ); + }); + + it("returns MCP error for mismatched run and file ids", async () => { + const { sc: firstScenario, run: firstRun } = await createRunArtifactFixture( + "mcp-run-file-mismatch-a", + ); + const { sc: secondScenario, run: secondRun } = await createRunArtifactFixture( + "mcp-run-file-mismatch-b", + ); + + const firstListRpc = ( + await mcpCall("list_run_files", { + scenarioId: firstScenario.id, + runId: firstRun.id, + }) + ).rpc; + const firstList = JSON.parse( + (firstListRpc.result as { content: { text: string }[] }).content[0].text, + ) as { items: Array<{ id: string }> }; + + const { status, rpc } = await mcpCall("get_run_file_content", { + scenarioId: secondScenario.id, + runId: secondRun.id, + fileId: firstList.items[0].id, + }); + expect(status).toBe(200); + const result = rpc.result as { isError: boolean }; + expect(result.isError).toBe(true); + }); + }); + // ── Snippet CRUD tools ──────────────────────────────────────────────────── describe("list_snippets", () => {