diff --git a/packages/core/src/constants.test.ts b/packages/core/src/constants.test.ts index cb6d9ad..e12c607 100644 --- a/packages/core/src/constants.test.ts +++ b/packages/core/src/constants.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import { ACTIONS, RESOURCES } from "./constants.ts"; describe("constants", () => { - it("should export RESOURCES array with all 20 implemented resources", () => { - expect(RESOURCES).toHaveLength(20); + it("should export RESOURCES array with all 21 implemented resources", () => { + expect(RESOURCES).toHaveLength(21); expect(RESOURCES).toContain("servers"); expect(RESOURCES).toContain("sites"); expect(RESOURCES).toContain("deployments"); @@ -25,6 +25,7 @@ describe("constants", () => { expect(RESOURCES).toContain("commands"); expect(RESOURCES).toContain("scheduled-jobs"); expect(RESOURCES).toContain("user"); + expect(RESOURCES).toContain("batch"); }); it("should export ACTIONS array", () => { diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 176a8c7..25ea10e 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -23,6 +23,7 @@ export const RESOURCES = [ "commands", "scheduled-jobs", "user", + "batch", ] as const; export type Resource = (typeof RESOURCES)[number]; diff --git a/packages/mcp/src/handlers/batch.test.ts b/packages/mcp/src/handlers/batch.test.ts new file mode 100644 index 0000000..296d409 --- /dev/null +++ b/packages/mcp/src/handlers/batch.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vitest"; + +import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; +import { handleBatch } from "./batch.ts"; +import { errorResult, jsonResult } from "./utils.ts"; + +const mockRouteToHandler = async ( + resource: string, + action: string, + _args: CommonArgs, + _ctx: HandlerContext, +): Promise => { + if (resource === "fail") throw new Error("Mock failure"); + return jsonResult({ resource, action, mock: true }); +}; + +const mockCtx: HandlerContext = { + executorContext: { client: {} as never }, + compact: true, +}; + +describe("handleBatch", () => { + it("should return error for unknown action", async () => { + const result = await handleBatch("list", {} as CommonArgs, mockCtx, mockRouteToHandler); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('"list"'); + expect(result.content[0]!.text).toContain("run"); + }); + + it("should return error when operations is missing", async () => { + const result = await handleBatch( + "run", + { resource: "batch", action: "run" } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("operations"); + }); + + it("should return error when operations is not an array", async () => { + const result = await handleBatch( + "run", + { resource: "batch", action: "run", operations: "not-an-array" } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('"operations" must be an array'); + }); + + it("should return error when operations exceeds max (11 ops)", async () => { + const operations = Array.from({ length: 11 }, () => ({ + resource: "servers", + action: "list", + })); + const result = await handleBatch( + "run", + { resource: "batch", action: "run", operations } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("Too many operations"); + expect(result.content[0]!.text).toContain("11"); + expect(result.content[0]!.text).toContain("10"); + }); + + it("should succeed with empty operations array", async () => { + const result = await handleBatch( + "run", + { resource: "batch", action: "run", operations: [] } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.total).toBe(0); + expect(data._batch.succeeded).toBe(0); + expect(data._batch.failed).toBe(0); + expect(data.results).toEqual([]); + }); + + it("should return error when operation is not an object", async () => { + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [null], + } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("must be an object"); + }); + + it("should return error when operation is missing resource field", async () => { + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [{ action: "list" }], + } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('"resource"'); + expect(result.content[0]!.text).toContain("index 0"); + }); + + it("should return error when operation is missing action field", async () => { + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [{ resource: "servers" }], + } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('"action"'); + expect(result.content[0]!.text).toContain("index 0"); + }); + + it("should reject write action in an operation", async () => { + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [{ resource: "servers", action: "create" }], + } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain('"create"'); + expect(result.content[0]!.text).toContain("read actions"); + }); + + it("should execute multiple operations successfully", async () => { + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [ + { resource: "servers", action: "list" }, + { resource: "sites", action: "list", server_id: "123" }, + { resource: "databases", action: "list", server_id: "123" }, + ], + } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.total).toBe(3); + expect(data._batch.succeeded).toBe(3); + expect(data._batch.failed).toBe(0); + expect(data.results).toHaveLength(3); + expect(data.results[0].resource).toBe("servers"); + expect(data.results[0].action).toBe("list"); + expect(data.results[0].index).toBe(0); + expect(data.results[0].data).toBeDefined(); + expect(data.results[1].resource).toBe("sites"); + }); + + it("should isolate partial failure (one op throws, one succeeds)", async () => { + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [ + { resource: "servers", action: "list" }, + { resource: "fail", action: "list" }, + ], + } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.total).toBe(2); + expect(data._batch.succeeded).toBe(1); + expect(data._batch.failed).toBe(1); + expect(data.results[0].data).toBeDefined(); + expect(data.results[0].error).toBeUndefined(); + expect(data.results[1].error).toContain("Mock failure"); + expect(data.results[1].data).toBeUndefined(); + }); + + it("should include correct _batch summary counts", async () => { + const mockWithError = async ( + resource: string, + action: string, + _args: CommonArgs, + _ctx: HandlerContext, + ): Promise => { + if (resource === "fail") throw new Error("Mock failure"); + if (resource === "err") return errorResult("Returned error"); + return jsonResult({ resource, action, mock: true }); + }; + + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [ + { resource: "servers", action: "list" }, + { resource: "fail", action: "list" }, + { resource: "err", action: "list" }, + { resource: "sites", action: "list", server_id: "1" }, + ], + } as CommonArgs, + mockCtx, + mockWithError, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.total).toBe(4); + expect(data._batch.succeeded).toBe(2); + expect(data._batch.failed).toBe(2); + }); + + it("should handle operation result without structuredContent", async () => { + const mockNoStructured = async (): Promise => ({ + content: [{ type: "text", text: "plain text result" }], + }); + + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [{ resource: "servers", action: "list" }], + } as CommonArgs, + mockCtx, + mockNoStructured, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.succeeded).toBe(1); + expect(data.results[0].data).toBe("plain text result"); + }); + + it("should handle error result without structuredContent", async () => { + const mockErrorNoStructured = async (): Promise => ({ + content: [{ type: "text", text: "Error: something broke" }], + isError: true, + }); + + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [{ resource: "servers", action: "list" }], + } as CommonArgs, + mockCtx, + mockErrorNoStructured, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.failed).toBe(1); + expect(data.results[0].error).toBe("Error: something broke"); + }); + + it("should handle non-Error rejection reason", async () => { + const mockStringReject = async (): Promise => { + throw "string rejection"; + }; + + const result = await handleBatch( + "run", + { + resource: "batch", + action: "run", + operations: [{ resource: "servers", action: "list" }], + } as CommonArgs, + mockCtx, + mockStringReject, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.failed).toBe(1); + expect(data.results[0].error).toBe("string rejection"); + }); + + it("should allow exactly 10 operations (at the limit)", async () => { + const operations = Array.from({ length: 10 }, () => ({ + resource: "servers", + action: "list", + })); + const result = await handleBatch( + "run", + { resource: "batch", action: "run", operations } as CommonArgs, + mockCtx, + mockRouteToHandler, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.total).toBe(10); + expect(data._batch.succeeded).toBe(10); + }); +}); diff --git a/packages/mcp/src/handlers/batch.ts b/packages/mcp/src/handlers/batch.ts new file mode 100644 index 0000000..4728d21 --- /dev/null +++ b/packages/mcp/src/handlers/batch.ts @@ -0,0 +1,133 @@ +/** + * Batch handler — executes multiple read operations in a single MCP call. + * + * Reduces round-trips for AI agents by running operations in parallel + * with Promise.allSettled, isolating per-operation failures. + */ + +import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; + +import { isReadAction } from "../tools.ts"; +import { errorResult, jsonResult } from "./utils.ts"; + +type RouteHandler = ( + resource: string, + action: string, + args: CommonArgs, + ctx: HandlerContext, +) => Promise; + +const MAX_OPERATIONS = 10; + +/** + * Handle batch action — executes multiple read operations in parallel. + */ +export async function handleBatch( + action: string, + args: CommonArgs, + ctx: HandlerContext, + routeToHandler: RouteHandler, +): Promise { + if (action !== "run") { + return errorResult(`Unknown action "${action}" for batch resource. Only "run" is supported.`); + } + + const { operations } = args; + + if (operations === undefined || operations === null) { + return errorResult('Missing required field: "operations". Provide an array of operations.'); + } + + if (!Array.isArray(operations)) { + return errorResult('"operations" must be an array of operation objects.'); + } + + if (operations.length > MAX_OPERATIONS) { + return errorResult( + `Too many operations: ${operations.length}. Maximum is ${MAX_OPERATIONS} per batch.`, + ); + } + + // Validate each operation + for (let i = 0; i < operations.length; i++) { + const op = operations[i] as Record; + if (!op || typeof op !== "object") { + return errorResult(`Operation at index ${i} must be an object.`); + } + if (!op["resource"] || typeof op["resource"] !== "string") { + return errorResult(`Operation at index ${i} is missing required field "resource".`); + } + if (!op["action"] || typeof op["action"] !== "string") { + return errorResult(`Operation at index ${i} is missing required field "action".`); + } + if (!isReadAction(op["action"] as string)) { + return errorResult( + `Operation at index ${i} has invalid action "${op["action"]}". Only read actions are allowed in batch: list, get, help, schema.`, + ); + } + } + + // Execute all operations in parallel + const settled = await Promise.allSettled( + operations.map((op) => { + const { resource, action: opAction, ...rest } = op as Record; + return routeToHandler( + resource as string, + opAction as string, + { resource: resource as string, action: opAction as string, ...rest } as CommonArgs, + ctx, + ); + }), + ); + + // Aggregate results + let succeeded = 0; + let failed = 0; + + const results = settled.map((outcome, index) => { + const op = operations[index] as Record; + const resource = op["resource"] as string; + const opAction = op["action"] as string; + + if (outcome.status === "fulfilled") { + const toolResult = outcome.value; + if (toolResult.isError) { + failed++; + return { + index, + resource, + action: opAction, + error: + toolResult.structuredContent?.["error"] ?? + /* v8 ignore next */ + toolResult.content[0]?.text ?? + "Unknown error", + }; + } + succeeded++; + return { + index, + resource, + action: opAction, + data: toolResult.structuredContent?.["result"] ?? toolResult.content[0]?.text, + }; + } else { + failed++; + return { + index, + resource, + action: opAction, + error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason), + }; + } + }); + + return jsonResult({ + _batch: { + total: operations.length, + succeeded, + failed, + }, + results, + }); +} diff --git a/packages/mcp/src/handlers/e2e.test.ts b/packages/mcp/src/handlers/e2e.test.ts index 950d36c..b0b8c17 100644 --- a/packages/mcp/src/handlers/e2e.test.ts +++ b/packages/mcp/src/handlers/e2e.test.ts @@ -507,4 +507,39 @@ describe("E2E: executeToolWithCredentials", () => { ); expect(result.isError).toBeUndefined(); }); + + it("should reject batch on forge_write tool", async () => { + const result = await executeToolWithCredentials( + "forge_write", + { + resource: "batch", + action: "run", + operations: [{ resource: "servers", action: "list" }], + }, + creds, + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("read-only"); + expect(result.content[0]!.text).toContain('"forge" tool'); + }); + + it("should execute batch operations end-to-end", async () => { + const result = await executeToolWithCredentials( + "forge", + { + resource: "batch", + action: "run", + operations: [ + { resource: "servers", action: "list" }, + { resource: "sites", action: "list", server_id: "1" }, + ], + }, + creds, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data._batch.total).toBe(2); + expect(data._batch.succeeded).toBe(2); + expect(data._batch.failed).toBe(0); + }); }); diff --git a/packages/mcp/src/handlers/help.ts b/packages/mcp/src/handlers/help.ts index 78e3ee6..0e43a33 100644 --- a/packages/mcp/src/handlers/help.ts +++ b/packages/mcp/src/handlers/help.ts @@ -619,6 +619,31 @@ const RESOURCE_HELP: Record = { ], }, + batch: { + description: + "Execute multiple read operations in a single call — reduces round-trips for AI agents", + scope: "global (no parent ID needed)", + actions: { + run: "Execute a batch of read operations in parallel (max 10)", + }, + fields: { + operations: "Array of operations [{resource, action, ...params}] (max 10)", + }, + examples: [ + { + description: "Batch: list servers + list sites on a server", + params: { + resource: "batch", + action: "run", + operations: [ + { resource: "servers", action: "list" }, + { resource: "sites", action: "list", server_id: "123" }, + ], + }, + }, + ], + }, + recipes: { description: "Manage and run server recipes — reusable bash scripts executed on one or more servers", diff --git a/packages/mcp/src/handlers/index.ts b/packages/mcp/src/handlers/index.ts index 4a6f417..a77b336 100644 --- a/packages/mcp/src/handlers/index.ts +++ b/packages/mcp/src/handlers/index.ts @@ -15,6 +15,7 @@ import { createAuditLogger, RESOURCES } from "@studiometa/forge-core"; import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; import { handleBackups } from "./backups.ts"; +import { handleBatch } from "./batch.ts"; import { handleCertificates } from "./certificates.ts"; import { handleCommands } from "./commands.ts"; import { handleDaemons } from "./daemons.ts"; @@ -104,6 +105,8 @@ function routeToHandler( return handleScheduledJobs(action, args, ctx); case "user": return handleUser(action, args, ctx); + case "batch": + return handleBatch(action, args, ctx, routeToHandler); /* v8 ignore next 6 -- all valid resources are handled above; unreachable in practice */ default: return Promise.resolve( @@ -131,6 +134,30 @@ export async function executeToolWithCredentials( return errorResult('Missing required fields: "resource" and "action".'); } + // Batch resource is read-only even though it uses the "run" action name. + // Handle it early, before the write-action guard, to avoid misdirection. + if (resource === "batch") { + // Validate batch is only called from the forge (read) tool + if (name !== "forge") { + return errorResult( + 'The "batch" resource is read-only and must be used with the "forge" tool, not "forge_write".', + ); + } + // Validate resource is known before creating context + const client = new HttpClient({ token: credentials.apiToken }); + const executorContext: ExecutorContext = { client }; + const handlerContext: HandlerContext = { + executorContext, + compact: compact ?? true, + }; + return routeToHandler( + resource, + action, + { resource, action, ...rest } as CommonArgs, + handlerContext, + ); + } + // Validate action matches the tool if (name === "forge" && isWriteAction(action)) { return errorResult( diff --git a/packages/mcp/src/handlers/schema.ts b/packages/mcp/src/handlers/schema.ts index 0f9d9da..03ec53f 100644 --- a/packages/mcp/src/handlers/schema.ts +++ b/packages/mcp/src/handlers/schema.ts @@ -314,6 +314,14 @@ const RESOURCE_SCHEMAS: Record = { required: {}, }, + batch: { + actions: ["run"], + scope: "global", + required: { + run: ["operations"], + }, + }, + recipes: { actions: ["list", "get", "create", "delete", "run"], scope: "global", diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index edb8b80..e4f64d8 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -110,6 +110,7 @@ const FORGE_READ_TOOL: Tool = { "Discovery: action=help with any resource for filters and examples.", "Server operations require id. Site operations require server_id.", "Deployment operations require server_id and site_id.", + "Batch: use resource=batch action=run with an operations array to execute multiple reads in one call.", ].join("\n"), annotations: { title: "Laravel Forge", @@ -127,6 +128,19 @@ const FORGE_READ_TOOL: Tool = { enum: [...READ_ACTIONS], description: 'Read action to perform. Use "help" for resource documentation.', }, + operations: { + type: "array" as const, + description: + "Array of operations for batch execution (max 10). Each operation needs resource, action, and any additional params.", + items: { + type: "object" as const, + properties: { + resource: { type: "string" as const }, + action: { type: "string" as const }, + }, + required: ["resource", "action"], + }, + }, }, required: ["resource", "action"], },