Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const ACTIONS = [
"run",
"help",
"schema",
"context",
] as const;

export type Action = (typeof ACTIONS)[number];
164 changes: 164 additions & 0 deletions packages/mcp/src/handlers/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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);
});

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");
});
});
90 changes: 90 additions & 0 deletions packages/mcp/src/handlers/context.ts
Original file line number Diff line number Diff line change
@@ -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<ToolResult> {
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<ToolResult> {
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,
});
}
12 changes: 12 additions & 0 deletions packages/mcp/src/handlers/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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",
Expand All @@ -48,6 +50,10 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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" },
},
],
},

Expand All @@ -59,6 +65,8 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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",
Expand Down Expand Up @@ -89,6 +97,10 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
directory: "/public",
},
},
{
description: "Get full site context (site + deployments + certificates + rules)",
params: { resource: "sites", action: "context", server_id: "123", id: "456" },
},
],
},

Expand Down
6 changes: 4 additions & 2 deletions packages/mcp/src/handlers/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ interface ResourceSchemaData {

const RESOURCE_SCHEMAS: Record<string, ResourceSchemaData> = {
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." },
Expand All @@ -43,13 +44,14 @@ const RESOURCE_SCHEMAS: Record<string, ResourceSchemaData> = {
},

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" },
Expand Down
31 changes: 31 additions & 0 deletions packages/mcp/src/handlers/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading