From 68eb76ad0cfc1adb22be064296aba39932a33289 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 27 Feb 2026 15:38:52 +0100 Subject: [PATCH 1/2] Add context action for rich single-call resource fetching Add a context action to servers and sites resources that fetches all related sub-resources in parallel via Promise.all, returning a comprehensive snapshot in a single MCP call. - Server context: server + sites + databases + db users + daemons + firewall rules + scheduled jobs - Site context: site + deployments (last 5) + certificates + redirect rules + security rules Closes #69 Co-authored-by: Claude --- packages/core/src/constants.ts | 1 + packages/mcp/src/handlers/context.test.ts | 134 ++++++++++++++++++++++ packages/mcp/src/handlers/context.ts | 90 +++++++++++++++ packages/mcp/src/handlers/help.ts | 12 ++ packages/mcp/src/handlers/schema.ts | 6 +- packages/mcp/src/handlers/servers.test.ts | 31 +++++ packages/mcp/src/handlers/servers.ts | 19 ++- packages/mcp/src/handlers/sites.test.ts | 40 +++++++ packages/mcp/src/handlers/sites.ts | 19 ++- packages/mcp/src/tools.test.ts | 4 +- packages/mcp/src/tools.ts | 3 +- 11 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 packages/mcp/src/handlers/context.test.ts create mode 100644 packages/mcp/src/handlers/context.ts diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 176a8c7..ed02b37 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -44,6 +44,7 @@ export const ACTIONS = [ "run", "help", "schema", + "context", ] as const; export type Action = (typeof ACTIONS)[number]; diff --git a/packages/mcp/src/handlers/context.test.ts b/packages/mcp/src/handlers/context.test.ts new file mode 100644 index 0000000..c92fabf --- /dev/null +++ b/packages/mcp/src/handlers/context.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import type { HandlerContext } from "./types.ts"; +import { handleServerContext, handleSiteContext } from "./context.ts"; + +function createMockContext(): HandlerContext { + return { + executorContext: { + client: { + get: async (url: string) => { + // Server sub-resources + if (url.match(/\/servers\/\d+\/sites$/)) return { sites: [{ id: 1, name: "app.com" }] }; + if (url.match(/\/servers\/\d+\/databases$/)) + return { databases: [{ id: 1, name: "mydb" }] }; + if (url.match(/\/servers\/\d+\/database-users$/)) + return { users: [{ id: 1, name: "forge" }] }; + if (url.match(/\/servers\/\d+\/daemons$/)) return { daemons: [] }; + if (url.match(/\/servers\/\d+\/firewall-rules$/)) return { rules: [] }; + if (url.match(/\/servers\/\d+\/jobs$/)) return { jobs: [] }; + // Site sub-resources (must come before server get) + if (url.match(/\/servers\/\d+\/sites\/\d+\/deployments$/)) { + return { + deployments: Array.from({ length: 8 }, (_, i) => ({ id: i + 1 })), + }; + } + if (url.match(/\/servers\/\d+\/sites\/\d+\/certificates$/)) return { certificates: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/redirect-rules$/)) + return { redirect_rules: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/security-rules$/)) + return { security_rules: [] }; + // Site get (must come before server get) + if (url.match(/\/servers\/\d+\/sites\/\d+$/)) + return { site: { id: 1, name: "app.com", server_id: 1 } }; + // Server get + if (url.match(/\/servers\/\d+$/)) return { server: { id: 1, name: "web-1" } }; + return {}; + }, + } as never, + }, + compact: false, + }; +} + +describe("handleServerContext", () => { + it("returns server plus all sub-resources", async () => { + const result = await handleServerContext( + { resource: "servers", action: "context", id: "1" }, + createMockContext(), + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.server).toBeDefined(); + expect(data.server.name).toBe("web-1"); + expect(data.sites).toBeDefined(); + expect(Array.isArray(data.sites)).toBe(true); + expect(data.databases).toBeDefined(); + expect(data.database_users).toBeDefined(); + expect(data.daemons).toBeDefined(); + expect(data.firewall_rules).toBeDefined(); + expect(data.scheduled_jobs).toBeDefined(); + }); + + it("returns error when id is missing", async () => { + const result = await handleServerContext( + { resource: "servers", action: "context" }, + createMockContext(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("id"); + }); + + it("includes structured content on success", async () => { + const result = await handleServerContext( + { resource: "servers", action: "context", id: "42" }, + createMockContext(), + ); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent?.success).toBe(true); + }); +}); + +describe("handleSiteContext", () => { + it("returns site plus all sub-resources", async () => { + const result = await handleSiteContext( + { resource: "sites", action: "context", server_id: "1", id: "2" }, + createMockContext(), + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.site).toBeDefined(); + expect(data.site.name).toBe("app.com"); + expect(data.deployments).toBeDefined(); + expect(data.certificates).toBeDefined(); + expect(data.redirect_rules).toBeDefined(); + expect(data.security_rules).toBeDefined(); + }); + + it("limits deployments to last 5", async () => { + const result = await handleSiteContext( + { resource: "sites", action: "context", server_id: "1", id: "2" }, + createMockContext(), + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.deployments).toHaveLength(5); + }); + + it("returns error when server_id is missing", async () => { + const result = await handleSiteContext( + { resource: "sites", action: "context", id: "2" }, + createMockContext(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("server_id"); + }); + + it("returns error when id is missing", async () => { + const result = await handleSiteContext( + { resource: "sites", action: "context", server_id: "1" }, + createMockContext(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("id"); + }); + + it("includes structured content on success", async () => { + const result = await handleSiteContext( + { resource: "sites", action: "context", server_id: "1", id: "2" }, + createMockContext(), + ); + expect(result.structuredContent).toBeDefined(); + expect(result.structuredContent?.success).toBe(true); + }); +}); diff --git a/packages/mcp/src/handlers/context.ts b/packages/mcp/src/handlers/context.ts new file mode 100644 index 0000000..360b9d1 --- /dev/null +++ b/packages/mcp/src/handlers/context.ts @@ -0,0 +1,90 @@ +import { + getServer, + getSite, + listCertificates, + listDaemons, + listDatabaseUsers, + listDatabases, + listDeployments, + listFirewallRules, + listRedirectRules, + listScheduledJobs, + listSecurityRules, + listSites, +} from "@studiometa/forge-core"; + +import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; +import { errorResult, jsonResult } from "./utils.ts"; + +/** + * Handle server context action — fetches server details plus all sub-resources + * (sites, databases, database users, daemons, firewall rules, scheduled jobs) + * in parallel in a single call. + */ +export async function handleServerContext( + args: CommonArgs, + ctx: HandlerContext, +): Promise { + const serverId = args.id; + if (!serverId) return errorResult("Missing required field: id"); + + const execCtx = ctx.executorContext; + + const [server, sites, databases, dbUsers, daemons, firewallRules, scheduledJobs] = + await Promise.all([ + getServer({ server_id: serverId }, execCtx), + listSites({ server_id: serverId }, execCtx), + listDatabases({ server_id: serverId }, execCtx), + listDatabaseUsers({ server_id: serverId }, execCtx), + listDaemons({ server_id: serverId }, execCtx), + listFirewallRules({ server_id: serverId }, execCtx), + listScheduledJobs({ server_id: serverId }, execCtx), + ]); + + return jsonResult({ + server: server.data, + sites: sites.data, + databases: databases.data, + database_users: dbUsers.data, + daemons: daemons.data, + firewall_rules: firewallRules.data, + scheduled_jobs: scheduledJobs.data, + }); +} + +/** + * Handle site context action — fetches site details plus all sub-resources + * (recent deployments, certificates, redirect rules, security rules) + * in parallel in a single call. Deployments are limited to the last 5. + */ +export async function handleSiteContext( + args: CommonArgs, + ctx: HandlerContext, +): Promise { + const serverId = args.server_id; + const siteId = args.id; + if (!serverId) return errorResult("Missing required field: server_id"); + if (!siteId) return errorResult("Missing required field: id"); + + const execCtx = ctx.executorContext; + + const [site, deployments, certificates, redirectRules, securityRules] = await Promise.all([ + getSite({ server_id: serverId, site_id: siteId }, execCtx), + listDeployments({ server_id: serverId, site_id: siteId }, execCtx), + listCertificates({ server_id: serverId, site_id: siteId }, execCtx), + listRedirectRules({ server_id: serverId, site_id: siteId }, execCtx), + listSecurityRules({ server_id: serverId, site_id: siteId }, execCtx), + ]); + + const recentDeployments = Array.isArray(deployments.data) + ? deployments.data.slice(0, 5) + : deployments.data; + + return jsonResult({ + site: site.data, + deployments: recentDeployments, + certificates: certificates.data, + redirect_rules: redirectRules.data, + security_rules: securityRules.data, + }); +} diff --git a/packages/mcp/src/handlers/help.ts b/packages/mcp/src/handlers/help.ts index 78e3ee6..f7ef874 100644 --- a/packages/mcp/src/handlers/help.ts +++ b/packages/mcp/src/handlers/help.ts @@ -28,6 +28,8 @@ const RESOURCE_HELP: Record = { create: "Provision a new server (requires provider, type, region, name)", delete: "Delete a server by ID (irreversible)", reboot: "Reboot a server by ID", + context: + "Get full server context: server details + all sub-resources (sites, databases, database users, daemons, firewall rules, scheduled jobs) in one call", }, fields: { id: "Server ID", @@ -48,6 +50,10 @@ const RESOURCE_HELP: Record = { description: "Reboot a server", params: { resource: "servers", action: "reboot", id: "123" }, }, + { + description: "Get full server context (server + all sub-resources)", + params: { resource: "servers", action: "context", id: "123" }, + }, ], }, @@ -59,6 +65,8 @@ const RESOURCE_HELP: Record = { get: "Get a single site by ID", create: "Create a new site (requires domain, project_type)", delete: "Delete a site by ID", + context: + "Get full site context: site details + recent deployments (last 5) + certificates + redirect rules + security rules in one call", }, fields: { id: "Site ID", @@ -89,6 +97,10 @@ const RESOURCE_HELP: Record = { directory: "/public", }, }, + { + description: "Get full site context (site + deployments + certificates + rules)", + params: { resource: "sites", action: "context", server_id: "123", id: "456" }, + }, ], }, diff --git a/packages/mcp/src/handlers/schema.ts b/packages/mcp/src/handlers/schema.ts index 0f9d9da..2207aed 100644 --- a/packages/mcp/src/handlers/schema.ts +++ b/packages/mcp/src/handlers/schema.ts @@ -23,13 +23,14 @@ interface ResourceSchemaData { const RESOURCE_SCHEMAS: Record = { servers: { - actions: ["list", "get", "create", "delete", "reboot"], + actions: ["list", "get", "create", "delete", "reboot", "context"], scope: "global", required: { get: ["id"], create: ["provider", "type", "region", "name"], delete: ["id"], reboot: ["id"], + context: ["id"], }, create: { provider: { required: true, type: "string — hetzner, ocean2, aws, etc." }, @@ -43,13 +44,14 @@ const RESOURCE_SCHEMAS: Record = { }, sites: { - actions: ["list", "get", "create", "delete"], + actions: ["list", "get", "create", "delete", "context"], scope: "server", required: { list: ["server_id"], get: ["server_id", "id"], create: ["server_id", "domain", "project_type"], delete: ["server_id", "id"], + context: ["server_id", "id"], }, create: { domain: { required: true, type: "string — e.g. example.com" }, diff --git a/packages/mcp/src/handlers/servers.test.ts b/packages/mcp/src/handlers/servers.test.ts index e7b93b2..f515ed7 100644 --- a/packages/mcp/src/handlers/servers.test.ts +++ b/packages/mcp/src/handlers/servers.test.ts @@ -144,4 +144,35 @@ describe("handleServers", () => { expect(parsed._hints).toBeDefined(); expect(parsed._hints.related_resources).toBeDefined(); }); + + it("should handle context action", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async (url: string) => { + if (url.match(/\/servers\/\d+\/sites$/)) return { sites: [] }; + if (url.match(/\/servers\/\d+\/databases$/)) return { databases: [] }; + if (url.match(/\/servers\/\d+\/database-users$/)) return { users: [] }; + if (url.match(/\/servers\/\d+\/daemons$/)) return { daemons: [] }; + if (url.match(/\/servers\/\d+\/firewall-rules$/)) return { rules: [] }; + if (url.match(/\/servers\/\d+\/scheduled-jobs$/)) return { jobs: [] }; + if (url.match(/\/servers\/\d+$/)) + return { server: { id: 1, name: "web-1", is_ready: true } }; + return {}; + }, + } as never, + }, + compact: false, + }; + const result = await handleServers( + "context", + { resource: "servers", action: "context", id: "1" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.server).toBeDefined(); + expect(data.sites).toBeDefined(); + expect(data.databases).toBeDefined(); + }); }); diff --git a/packages/mcp/src/handlers/servers.ts b/packages/mcp/src/handlers/servers.ts index 4951446..6105ac6 100644 --- a/packages/mcp/src/handlers/servers.ts +++ b/packages/mcp/src/handlers/servers.ts @@ -10,9 +10,11 @@ import type { ForgeServer } from "@studiometa/forge-api"; import { formatServer, formatServerList } from "../formatters.ts"; import { getServerHints } from "../hints.ts"; +import { handleServerContext } from "./context.ts"; import { createResourceHandler } from "./factory.ts"; +import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; -export const handleServers = createResourceHandler({ +const _handleServers = createResourceHandler({ resource: "servers", actions: ["list", "get", "create", "delete", "reboot"], requiredFields: { @@ -69,3 +71,18 @@ export const handleServers = createResourceHandler({ } }, }); + +/** + * Handle servers resource actions, intercepting the `context` action + * for rich single-call resource fetching. + */ +export async function handleServers( + action: string, + args: CommonArgs, + ctx: HandlerContext, +): Promise { + if (action === "context") { + return handleServerContext(args, ctx); + } + return _handleServers(action, args, ctx); +} diff --git a/packages/mcp/src/handlers/sites.test.ts b/packages/mcp/src/handlers/sites.test.ts index 40e6ff7..b70be2e 100644 --- a/packages/mcp/src/handlers/sites.test.ts +++ b/packages/mcp/src/handlers/sites.test.ts @@ -110,4 +110,44 @@ describe("handleSites", () => { expect(parsed._hints).toBeDefined(); expect(parsed._hints.related_resources).toBeDefined(); }); + + it("should handle context action", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async (url: string) => { + if (url.match(/\/servers\/\d+\/sites\/\d+\/deployments$/)) + return { deployments: [{ id: 1 }, { id: 2 }] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/certificates$/)) return { certificates: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/redirect-rules$/)) + return { redirect_rules: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/security-rules$/)) + return { security_rules: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+$/)) + return { + site: { + id: 1, + name: "example.com", + server_id: 1, + project_type: "php", + status: "installed", + }, + }; + return {}; + }, + } as never, + }, + compact: false, + }; + const result = await handleSites( + "context", + { resource: "sites", action: "context", server_id: "1", id: "1" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.site).toBeDefined(); + expect(data.deployments).toBeDefined(); + expect(data.certificates).toBeDefined(); + }); }); diff --git a/packages/mcp/src/handlers/sites.ts b/packages/mcp/src/handlers/sites.ts index 613da41..0571a13 100644 --- a/packages/mcp/src/handlers/sites.ts +++ b/packages/mcp/src/handlers/sites.ts @@ -4,9 +4,11 @@ import type { ForgeSite } from "@studiometa/forge-api"; import { formatSite, formatSiteList } from "../formatters.ts"; import { getSiteHints } from "../hints.ts"; +import { handleSiteContext } from "./context.ts"; import { createResourceHandler } from "./factory.ts"; +import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; -export const handleSites = createResourceHandler({ +const _handleSites = createResourceHandler({ resource: "sites", actions: ["list", "get", "create", "delete"], requiredFields: { @@ -61,3 +63,18 @@ export const handleSites = createResourceHandler({ } }, }); + +/** + * Handle sites resource actions, intercepting the `context` action + * for rich single-call resource fetching. + */ +export async function handleSites( + action: string, + args: CommonArgs, + ctx: HandlerContext, +): Promise { + if (action === "context") { + return handleSiteContext(args, ctx); + } + return _handleSites(action, args, ctx); +} diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index b108db0..b663a2f 100644 --- a/packages/mcp/src/tools.test.ts +++ b/packages/mcp/src/tools.test.ts @@ -122,8 +122,8 @@ describe("TOOLS", () => { }); describe("READ_ACTIONS", () => { - it("should contain list, get, help, schema", () => { - expect([...READ_ACTIONS]).toEqual(["list", "get", "help", "schema"]); + it("should contain list, get, help, schema, context", () => { + expect([...READ_ACTIONS]).toEqual(["list", "get", "help", "schema", "context"]); }); }); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index edb8b80..b4b9ca0 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -5,7 +5,7 @@ import { RESOURCES } from "@studiometa/forge-core"; /** * Read-only actions — safe operations that don't modify server state. */ -export const READ_ACTIONS = ["list", "get", "help", "schema"] as const; +export const READ_ACTIONS = ["list", "get", "help", "schema", "context"] as const; /** * Write actions — operations that modify server state. @@ -108,6 +108,7 @@ const FORGE_READ_TOOL: Tool = { `Resources: ${RESOURCES.join(", ")}.`, `Actions: ${[...READ_ACTIONS].join(", ")}.`, "Discovery: action=help with any resource for filters and examples.", + "Context: action=context on servers or sites fetches all sub-resources in one call.", "Server operations require id. Site operations require server_id.", "Deployment operations require server_id and site_id.", ].join("\n"), From 6e58afdd1e44c0a51b7c9a28b3ebac2474daca03 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 27 Feb 2026 16:45:46 +0100 Subject: [PATCH 2/2] Improve context test coverage for codecov Add test for non-array deployments data to cover the else branch in handleSiteContext. Co-authored-by: Claude --- packages/mcp/src/handlers/context.test.ts | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/mcp/src/handlers/context.test.ts b/packages/mcp/src/handlers/context.test.ts index c92fabf..12e52c9 100644 --- a/packages/mcp/src/handlers/context.test.ts +++ b/packages/mcp/src/handlers/context.test.ts @@ -131,4 +131,34 @@ describe("handleSiteContext", () => { expect(result.structuredContent).toBeDefined(); expect(result.structuredContent?.success).toBe(true); }); + + it("handles non-array deployments data gracefully", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async (url: string) => { + if (url.match(/\/servers\/\d+\/sites\/\d+\/deployments$/)) { + return { deployments: "none" }; // non-array + } + if (url.match(/\/servers\/\d+\/sites\/\d+\/certificates$/)) return { certificates: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/redirect-rules$/)) + return { redirect_rules: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+\/security-rules$/)) + return { security_rules: [] }; + if (url.match(/\/servers\/\d+\/sites\/\d+$/)) + return { site: { id: 1, name: "app.com" } }; + return {}; + }, + } as never, + }, + compact: false, + }; + const result = await handleSiteContext( + { resource: "sites", action: "context", server_id: "1", id: "2" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + const data = JSON.parse(result.content[0]!.text); + expect(data.deployments).toBe("none"); + }); });