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:
+22
@@ -50,7 +50,29 @@ The server currently registers the following tools.
|
|||||||
| `export_scenario` | Export scenario payload |
|
| `export_scenario` | Export scenario payload |
|
||||||
| `import_scenario` | Import 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
|
## Notes
|
||||||
|
|
||||||
- Tool IDs and entity IDs are UUIDs.
|
- 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.
|
- `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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
// ── Snippets ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import * as fs from "fs/promises";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { Like, Repository } from "typeorm";
|
import { Like, Repository } from "typeorm";
|
||||||
import {
|
import {
|
||||||
@@ -768,6 +769,47 @@ export class ScenarioService {
|
|||||||
res.sendFile(fullPath);
|
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(
|
async listRunFiles(
|
||||||
scenarioId: string,
|
scenarioId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -833,4 +875,50 @@ export class ScenarioService {
|
|||||||
|
|
||||||
res.sendFile(fullPath);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { INestApplication } from "@nestjs/common";
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { getRepositoryToken } from "@nestjs/typeorm";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
import * as http from "http";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
import { buildTestApp } from "./app.harness";
|
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.
|
* MCP controller integration tests.
|
||||||
@@ -16,15 +24,65 @@ import { buildTestApp } from "./app.harness";
|
|||||||
*/
|
*/
|
||||||
describe("McpController", () => {
|
describe("McpController", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
let testServer: http.Server;
|
||||||
|
let testServerUrl: string;
|
||||||
|
let scheduler: ScenarioSchedulerService;
|
||||||
|
let scenarioRepo: Repository<ScenarioEntity>;
|
||||||
|
let stepRepo: Repository<ScenarioStepEntity>;
|
||||||
|
let runRepo: Repository<ScenarioRunEntity>;
|
||||||
|
let runStepRepo: Repository<ScenarioRunStepEntity>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await buildTestApp();
|
app = await buildTestApp();
|
||||||
|
scheduler = app.get(ScenarioSchedulerService);
|
||||||
|
scenarioRepo = app.get<Repository<ScenarioEntity>>(
|
||||||
|
getRepositoryToken(ScenarioEntity),
|
||||||
|
);
|
||||||
|
stepRepo = app.get<Repository<ScenarioStepEntity>>(
|
||||||
|
getRepositoryToken(ScenarioStepEntity),
|
||||||
|
);
|
||||||
|
runRepo = app.get<Repository<ScenarioRunEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunEntity),
|
||||||
|
);
|
||||||
|
runStepRepo = app.get<Repository<ScenarioRunStepEntity>>(
|
||||||
|
getRepositoryToken(ScenarioRunStepEntity),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start a test HTTP server for downloadFile tests
|
||||||
|
testServer = await createTestServer();
|
||||||
|
testServerUrl = `http://localhost:${(testServer.address() as any).port}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
if (testServer) {
|
||||||
|
testServer.close();
|
||||||
|
}
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createTestServer(): Promise<http.Server> {
|
||||||
|
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. */
|
/** Parse the JSON-RPC payload from an SSE response body. */
|
||||||
function parseSse(text: string): Record<string, unknown> {
|
function parseSse(text: string): Record<string, unknown> {
|
||||||
const match = text.match(/^data:\s*(.+)$/m);
|
const match = text.match(/^data:\s*(.+)$/m);
|
||||||
@@ -47,6 +105,49 @@ describe("McpController", () => {
|
|||||||
return { status: res.status, rpc: parseSse(res.text) };
|
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 ───────────────────────────────────────────────────────────
|
// ── Connectivity ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("POST /mcp — 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 ────────────────────────────────────────────────────
|
// ── Snippet CRUD tools ────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("list_snippets", () => {
|
describe("list_snippets", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user