diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 881b120..ba7d8c7 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -43,6 +43,7 @@ export const ACTIONS = [ "restart", "activate", "run", + "resolve", "help", "schema", "context", diff --git a/packages/core/src/executors/servers/index.ts b/packages/core/src/executors/servers/index.ts index 63ef323..b3ce7ad 100644 --- a/packages/core/src/executors/servers/index.ts +++ b/packages/core/src/executors/servers/index.ts @@ -3,6 +3,7 @@ export { deleteServer } from "./delete.ts"; export { getServer } from "./get.ts"; export { listServers } from "./list.ts"; export { rebootServer } from "./reboot.ts"; +export { resolveServers } from "./resolve.ts"; export type { CreateServerOptions, DeleteServerOptions, @@ -10,3 +11,4 @@ export type { ListServersOptions, RebootServerOptions, } from "./types.ts"; +export type { ResolveServersOptions, ResolveMatch, ResolveResult } from "./resolve.ts"; diff --git a/packages/core/src/executors/servers/resolve.test.ts b/packages/core/src/executors/servers/resolve.test.ts new file mode 100644 index 0000000..235185a --- /dev/null +++ b/packages/core/src/executors/servers/resolve.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { createTestExecutorContext } from "../../context.ts"; +import { resolveServers } from "./resolve.ts"; + +const mockServers = [ + { id: 1, name: "prod-web-1" }, + { id: 2, name: "prod-web-2" }, + { id: 3, name: "staging-web-1" }, +]; + +function createCtx() { + return createTestExecutorContext({ + client: { + get: async () => ({ servers: mockServers }), + } as never, + }); +} + +describe("resolveServers", () => { + it("should return partial matches", async () => { + const result = await resolveServers({ query: "prod" }, createCtx()); + expect(result.data.query).toBe("prod"); + expect(result.data.total).toBe(2); + expect(result.data.matches).toHaveLength(2); + expect(result.data.matches[0]!.name).toBe("prod-web-1"); + expect(result.data.matches[1]!.name).toBe("prod-web-2"); + }); + + it("should return exact match as single result", async () => { + const result = await resolveServers({ query: "prod-web-1" }, createCtx()); + expect(result.data.total).toBe(1); + expect(result.data.matches).toHaveLength(1); + expect(result.data.matches[0]!.id).toBe(1); + expect(result.data.matches[0]!.name).toBe("prod-web-1"); + }); + + it("should return empty for no matches", async () => { + const result = await resolveServers({ query: "nonexistent" }, createCtx()); + expect(result.data.total).toBe(0); + expect(result.data.matches).toHaveLength(0); + }); + + it("should be case insensitive", async () => { + const result = await resolveServers({ query: "PROD" }, createCtx()); + expect(result.data.total).toBe(2); + expect(result.data.matches[0]!.name).toBe("prod-web-1"); + }); + + it("should return partial matches when multiple exact-like names exist", async () => { + const ctxWithDupes = createTestExecutorContext({ + client: { + get: async () => ({ + servers: [ + { id: 1, name: "prod-web-1" }, + { id: 2, name: "prod-web-1" }, + ], + }), + } as never, + }); + // Two exact matches → fall through to partial match + const result = await resolveServers({ query: "prod-web-1" }, ctxWithDupes); + expect(result.data.total).toBe(2); + expect(result.data.matches).toHaveLength(2); + }); +}); diff --git a/packages/core/src/executors/servers/resolve.ts b/packages/core/src/executors/servers/resolve.ts new file mode 100644 index 0000000..8983755 --- /dev/null +++ b/packages/core/src/executors/servers/resolve.ts @@ -0,0 +1,54 @@ +import type { ServersResponse } from "@studiometa/forge-api"; +import type { ExecutorContext, ExecutorResult } from "../../context.ts"; + +export interface ResolveServersOptions { + query: string; +} + +export interface ResolveMatch { + id: number; + name: string; +} + +export interface ResolveResult { + query: string; + matches: ResolveMatch[]; + total: number; +} + +/** + * Resolve servers by name — partial, case-insensitive match. + * + * If exactly one server matches the query exactly, it is returned as a single result. + * Otherwise all partial matches are returned. + */ +export async function resolveServers( + options: ResolveServersOptions, + ctx: ExecutorContext, +): Promise> { + const response = await ctx.client.get("/servers"); + const servers = response.servers; + const lower = options.query.toLowerCase(); + + // Exact match first + const exact = servers.filter((s) => s.name.toLowerCase() === lower); + if (exact.length === 1) { + return { + data: { + query: options.query, + matches: [{ id: exact[0]!.id, name: exact[0]!.name }], + total: 1, + }, + }; + } + + // Partial match + const partial = servers.filter((s) => s.name.toLowerCase().includes(lower)); + return { + data: { + query: options.query, + matches: partial.map((s) => ({ id: s.id, name: s.name })), + total: partial.length, + }, + }; +} diff --git a/packages/core/src/executors/sites/index.ts b/packages/core/src/executors/sites/index.ts index 55c7694..fd69fca 100644 --- a/packages/core/src/executors/sites/index.ts +++ b/packages/core/src/executors/sites/index.ts @@ -2,9 +2,11 @@ export { createSite } from "./create.ts"; export { deleteSite } from "./delete.ts"; export { getSite } from "./get.ts"; export { listSites } from "./list.ts"; +export { resolveSites } from "./resolve.ts"; export type { CreateSiteOptions, DeleteSiteOptions, GetSiteOptions, ListSitesOptions, } from "./types.ts"; +export type { ResolveSitesOptions, ResolveSiteMatch, ResolveSiteResult } from "./resolve.ts"; diff --git a/packages/core/src/executors/sites/resolve.test.ts b/packages/core/src/executors/sites/resolve.test.ts new file mode 100644 index 0000000..aa439d1 --- /dev/null +++ b/packages/core/src/executors/sites/resolve.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { createTestExecutorContext } from "../../context.ts"; +import { resolveSites } from "./resolve.ts"; + +const mockSites = [ + { id: 1, name: "example.com" }, + { id: 2, name: "api.example.com" }, + { id: 3, name: "staging.myapp.io" }, +]; + +function createCtx() { + return createTestExecutorContext({ + client: { + get: async () => ({ sites: mockSites }), + } as never, + }); +} + +describe("resolveSites", () => { + it("should return partial matches", async () => { + const result = await resolveSites({ server_id: "123", query: "example" }, createCtx()); + expect(result.data.query).toBe("example"); + expect(result.data.total).toBe(2); + expect(result.data.matches).toHaveLength(2); + expect(result.data.matches[0]!.name).toBe("example.com"); + expect(result.data.matches[1]!.name).toBe("api.example.com"); + }); + + it("should return exact match as single result", async () => { + const result = await resolveSites({ server_id: "123", query: "example.com" }, createCtx()); + expect(result.data.total).toBe(1); + expect(result.data.matches).toHaveLength(1); + expect(result.data.matches[0]!.id).toBe(1); + expect(result.data.matches[0]!.name).toBe("example.com"); + }); + + it("should return empty for no matches", async () => { + const result = await resolveSites({ server_id: "123", query: "nonexistent" }, createCtx()); + expect(result.data.total).toBe(0); + expect(result.data.matches).toHaveLength(0); + }); + + it("should be case insensitive", async () => { + const result = await resolveSites({ server_id: "123", query: "EXAMPLE" }, createCtx()); + expect(result.data.total).toBe(2); + }); + + it("should use server_id in the API path", async () => { + let capturedPath = ""; + const ctx = createTestExecutorContext({ + client: { + get: async (path: string) => { + capturedPath = path; + return { sites: [] }; + }, + } as never, + }); + await resolveSites({ server_id: "456", query: "test" }, ctx); + expect(capturedPath).toBe("/servers/456/sites"); + }); + + it("should return partial matches when multiple exact-like names exist", async () => { + const ctxWithDupes = createTestExecutorContext({ + client: { + get: async () => ({ + sites: [ + { id: 1, name: "example.com" }, + { id: 2, name: "example.com" }, + ], + }), + } as never, + }); + // Two exact matches → fall through to partial match + const result = await resolveSites({ server_id: "123", query: "example.com" }, ctxWithDupes); + expect(result.data.total).toBe(2); + expect(result.data.matches).toHaveLength(2); + }); +}); diff --git a/packages/core/src/executors/sites/resolve.ts b/packages/core/src/executors/sites/resolve.ts new file mode 100644 index 0000000..99dc515 --- /dev/null +++ b/packages/core/src/executors/sites/resolve.ts @@ -0,0 +1,55 @@ +import type { SitesResponse } from "@studiometa/forge-api"; +import type { ExecutorContext, ExecutorResult } from "../../context.ts"; + +export interface ResolveSitesOptions { + server_id: string; + query: string; +} + +export interface ResolveSiteMatch { + id: number; + name: string; +} + +export interface ResolveSiteResult { + query: string; + matches: ResolveSiteMatch[]; + total: number; +} + +/** + * Resolve sites by domain name — partial, case-insensitive match. + * + * If exactly one site matches the query exactly, it is returned as a single result. + * Otherwise all partial matches are returned. + */ +export async function resolveSites( + options: ResolveSitesOptions, + ctx: ExecutorContext, +): Promise> { + const response = await ctx.client.get(`/servers/${options.server_id}/sites`); + const sites = response.sites; + const lower = options.query.toLowerCase(); + + // Exact match first + const exact = sites.filter((s) => s.name.toLowerCase() === lower); + if (exact.length === 1) { + return { + data: { + query: options.query, + matches: [{ id: exact[0]!.id, name: exact[0]!.name }], + total: 1, + }, + }; + } + + // Partial match + const partial = sites.filter((s) => s.name.toLowerCase().includes(lower)); + return { + data: { + query: options.query, + matches: partial.map((s) => ({ id: s.id, name: s.name })), + total: partial.length, + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9a76fc9..15cfae2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ export { getServer, listServers, rebootServer, + resolveServers, } from "./executors/servers/index.ts"; export type { CreateServerOptions, @@ -25,15 +26,27 @@ export type { GetServerOptions, ListServersOptions, RebootServerOptions, + ResolveServersOptions, + ResolveMatch, + ResolveResult, } from "./executors/servers/index.ts"; // Sites -export { createSite, deleteSite, getSite, listSites } from "./executors/sites/index.ts"; +export { + createSite, + deleteSite, + getSite, + listSites, + resolveSites, +} from "./executors/sites/index.ts"; export type { CreateSiteOptions, DeleteSiteOptions, GetSiteOptions, ListSitesOptions, + ResolveSitesOptions, + ResolveSiteMatch, + ResolveSiteResult, } from "./executors/sites/index.ts"; // Deployments diff --git a/packages/mcp/src/handlers/help.ts b/packages/mcp/src/handlers/help.ts index 73f520f..816ee43 100644 --- a/packages/mcp/src/handlers/help.ts +++ b/packages/mcp/src/handlers/help.ts @@ -30,6 +30,7 @@ const RESOURCE_HELP: Record = { 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", + resolve: "Find servers by name (partial, case-insensitive match)", }, fields: { id: "Server ID", @@ -54,6 +55,10 @@ const RESOURCE_HELP: Record = { description: "Get full server context (server + all sub-resources)", params: { resource: "servers", action: "context", id: "123" }, }, + { + description: "Find servers by name", + params: { resource: "servers", action: "resolve", query: "prod" }, + }, ], }, @@ -67,6 +72,7 @@ const RESOURCE_HELP: Record = { 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", + resolve: "Find sites by domain name (partial, case-insensitive match, requires server_id)", }, fields: { id: "Site ID", @@ -101,6 +107,10 @@ const RESOURCE_HELP: Record = { description: "Get full site context (site + deployments + certificates + rules)", params: { resource: "sites", action: "context", server_id: "123", id: "456" }, }, + { + description: "Find sites by domain name", + params: { resource: "sites", action: "resolve", server_id: "123", query: "example" }, + }, ], }, diff --git a/packages/mcp/src/handlers/schema.ts b/packages/mcp/src/handlers/schema.ts index 9faedf1..4684810 100644 --- a/packages/mcp/src/handlers/schema.ts +++ b/packages/mcp/src/handlers/schema.ts @@ -23,7 +23,7 @@ interface ResourceSchemaData { const RESOURCE_SCHEMAS: Record = { servers: { - actions: ["list", "get", "create", "delete", "reboot", "context"], + actions: ["list", "get", "create", "delete", "reboot", "resolve", "context"], scope: "global", required: { get: ["id"], @@ -31,6 +31,7 @@ const RESOURCE_SCHEMAS: Record = { delete: ["id"], reboot: ["id"], context: ["id"], + resolve: ["query"], }, create: { provider: { required: true, type: "string — hetzner, ocean2, aws, etc." }, @@ -44,7 +45,7 @@ const RESOURCE_SCHEMAS: Record = { }, sites: { - actions: ["list", "get", "create", "delete", "context"], + actions: ["list", "get", "create", "delete", "resolve", "context"], scope: "server", required: { list: ["server_id"], @@ -52,6 +53,7 @@ const RESOURCE_SCHEMAS: Record = { create: ["server_id", "domain", "project_type"], delete: ["server_id", "id"], context: ["server_id", "id"], + resolve: ["server_id", "query"], }, 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 f515ed7..864a361 100644 --- a/packages/mcp/src/handlers/servers.test.ts +++ b/packages/mcp/src/handlers/servers.test.ts @@ -175,4 +175,58 @@ describe("handleServers", () => { expect(data.sites).toBeDefined(); expect(data.databases).toBeDefined(); }); + + it("should resolve servers by name (partial match)", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async () => ({ + servers: [ + { id: 1, name: "prod-web-1" }, + { id: 2, name: "prod-web-2" }, + { id: 3, name: "staging-web-1" }, + ], + }), + } as never, + }, + compact: true, + }; + const result = await handleServers( + "resolve", + { resource: "servers", action: "resolve", query: "prod" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toContain("prod-web-1"); + expect(result.content[0]!.text).toContain("prod-web-2"); + expect(result.content[0]!.text).toContain("2 server(s)"); + }); + + it("should resolve servers — no matches", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async () => ({ servers: [{ id: 1, name: "staging-web-1" }] }), + } as never, + }, + compact: true, + }; + const result = await handleServers( + "resolve", + { resource: "servers", action: "resolve", query: "prod" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toContain('No servers matching "prod"'); + }); + + it("should require query for resolve", async () => { + const result = await handleServers( + "resolve", + { resource: "servers", action: "resolve" }, + createMockContext(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("query"); + }); }); diff --git a/packages/mcp/src/handlers/servers.ts b/packages/mcp/src/handlers/servers.ts index 6105ac6..fb16637 100644 --- a/packages/mcp/src/handlers/servers.ts +++ b/packages/mcp/src/handlers/servers.ts @@ -4,9 +4,11 @@ import { getServer, listServers, rebootServer, + resolveServers, } from "@studiometa/forge-core"; import type { ForgeServer } from "@studiometa/forge-api"; +import type { ResolveResult } from "@studiometa/forge-core"; import { formatServer, formatServerList } from "../formatters.ts"; import { getServerHints } from "../hints.ts"; @@ -16,12 +18,13 @@ import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; const _handleServers = createResourceHandler({ resource: "servers", - actions: ["list", "get", "create", "delete", "reboot"], + actions: ["list", "get", "create", "delete", "reboot", "resolve"], requiredFields: { get: ["id"], create: ["provider", "name", "type", "region"], delete: ["id"], reboot: ["id"], + resolve: ["query"], }, executors: { list: listServers, @@ -29,6 +32,7 @@ const _handleServers = createResourceHandler({ create: createServer, delete: deleteServer, reboot: rebootServer, + resolve: resolveServers, }, hints: (_data, id) => getServerHints(id), mapOptions: (action, args) => { @@ -49,6 +53,8 @@ const _handleServers = createResourceHandler({ size: args.size ?? "", region: args.region, }; + case "resolve": + return { query: args.query }; default: return {}; } @@ -65,6 +71,12 @@ const _handleServers = createResourceHandler({ return `Server ${args.id} deleted.`; case "reboot": return `Server ${args.id} reboot initiated.`; + case "resolve": { + const result = data as ResolveResult; + if (result.total === 0) return `No servers matching "${result.query}".`; + const lines = result.matches.map((m) => `• ${m.name} (ID: ${m.id})`); + return `${result.total} server(s) matching "${result.query}":\n${lines.join("\n")}`; + } /* v8 ignore next */ default: return "Done."; diff --git a/packages/mcp/src/handlers/sites.test.ts b/packages/mcp/src/handlers/sites.test.ts index b70be2e..2c0e392 100644 --- a/packages/mcp/src/handlers/sites.test.ts +++ b/packages/mcp/src/handlers/sites.test.ts @@ -150,4 +150,58 @@ describe("handleSites", () => { expect(data.deployments).toBeDefined(); expect(data.certificates).toBeDefined(); }); + + it("should resolve sites by domain name (partial match)", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async () => ({ + sites: [ + { id: 1, name: "example.com" }, + { id: 2, name: "api.example.com" }, + { id: 3, name: "staging.myapp.io" }, + ], + }), + } as never, + }, + compact: true, + }; + const result = await handleSites( + "resolve", + { resource: "sites", action: "resolve", server_id: "123", query: "example" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toContain("example.com"); + expect(result.content[0]!.text).toContain("api.example.com"); + expect(result.content[0]!.text).toContain("2 site(s)"); + }); + + it("should resolve sites — no matches", async () => { + const ctx: HandlerContext = { + executorContext: { + client: { + get: async () => ({ sites: [{ id: 1, name: "staging.myapp.io" }] }), + } as never, + }, + compact: true, + }; + const result = await handleSites( + "resolve", + { resource: "sites", action: "resolve", server_id: "123", query: "example" }, + ctx, + ); + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toContain('No sites matching "example"'); + }); + + it("should require server_id and query for resolve", async () => { + const result = await handleSites( + "resolve", + { resource: "sites", action: "resolve", server_id: "1" }, + createMockContext(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("query"); + }); }); diff --git a/packages/mcp/src/handlers/sites.ts b/packages/mcp/src/handlers/sites.ts index 0571a13..e34f2d1 100644 --- a/packages/mcp/src/handlers/sites.ts +++ b/packages/mcp/src/handlers/sites.ts @@ -1,6 +1,7 @@ -import { createSite, deleteSite, getSite, listSites } from "@studiometa/forge-core"; +import { createSite, deleteSite, getSite, listSites, resolveSites } from "@studiometa/forge-core"; import type { ForgeSite } from "@studiometa/forge-api"; +import type { ResolveSiteResult } from "@studiometa/forge-core"; import { formatSite, formatSiteList } from "../formatters.ts"; import { getSiteHints } from "../hints.ts"; @@ -10,18 +11,20 @@ import type { CommonArgs, HandlerContext, ToolResult } from "./types.ts"; const _handleSites = createResourceHandler({ resource: "sites", - actions: ["list", "get", "create", "delete"], + actions: ["list", "get", "create", "delete", "resolve"], requiredFields: { list: ["server_id"], get: ["server_id", "id"], create: ["server_id", "domain"], delete: ["server_id", "id"], + resolve: ["server_id", "query"], }, executors: { list: listSites, get: getSite, create: createSite, delete: deleteSite, + resolve: resolveSites, }, hints: (data, id) => { const site = data as ForgeSite; @@ -42,6 +45,8 @@ const _handleSites = createResourceHandler({ }; case "delete": return { server_id: args.server_id, site_id: args.id }; + case "resolve": + return { server_id: args.server_id, query: args.query }; /* v8 ignore next */ default: return {}; @@ -57,6 +62,13 @@ const _handleSites = createResourceHandler({ return formatSite(data as ForgeSite); case "delete": return `Site ${args.id} deleted from server ${args.server_id}.`; + case "resolve": { + const result = data as ResolveSiteResult; + if (result.total === 0) + return `No sites matching "${result.query}" on server ${args.server_id}.`; + const lines = result.matches.map((m) => `• ${m.name} (ID: ${m.id})`); + return `${result.total} site(s) matching "${result.query}" on server ${args.server_id}:\n${lines.join("\n")}`; + } /* v8 ignore next */ default: return "Done."; diff --git a/packages/mcp/src/tools.test.ts b/packages/mcp/src/tools.test.ts index b663a2f..1d0fc38 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, context", () => { - expect([...READ_ACTIONS]).toEqual(["list", "get", "help", "schema", "context"]); + it("should contain list, get, resolve, help, schema, context", () => { + expect([...READ_ACTIONS]).toEqual(["list", "get", "resolve", "help", "schema", "context"]); }); }); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 11b3350..c84c8d8 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", "context"] as const; +export const READ_ACTIONS = ["list", "get", "resolve", "help", "schema", "context"] as const; /** * Write actions — operations that modify server state. @@ -92,6 +92,11 @@ const SHARED_INPUT_PROPERTIES = { type: "boolean" as const, description: "Compact output (default: true for list, false for get)", }, + query: { + type: "string" as const, + description: + "Search query for resolve action (matches by name, case-insensitive partial match)", + }, }; /** @@ -112,6 +117,7 @@ const FORGE_READ_TOOL: Tool = { "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.", + "resolve: find resources by name (partial, case-insensitive) — provide query field.", ].join("\n"), annotations: { title: "Laravel Forge",