From 8cbae95ec455f271aa2ce4a6f87450f7b71d6b77 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 27 Feb 2026 15:39:03 +0100 Subject: [PATCH 1/2] Add resolve action for name-based resource lookup in MCP Add a resolve action to servers and sites that accepts a name/query and returns matching resources via partial case-insensitive search. Exact match returns single result, partial returns all candidates. Extracted matching logic into forge-core executors so both CLI and MCP can share the resolution logic. Closes #70 Co-authored-by: Claude --- packages/core/src/constants.ts | 1 + packages/core/src/executors/servers/index.ts | 2 + .../src/executors/servers/resolve.test.ts | 66 ++++++++++++++++ .../core/src/executors/servers/resolve.ts | 54 +++++++++++++ packages/core/src/executors/sites/index.ts | 2 + .../core/src/executors/sites/resolve.test.ts | 79 +++++++++++++++++++ packages/core/src/executors/sites/resolve.ts | 55 +++++++++++++ packages/core/src/index.ts | 15 +++- packages/mcp/src/handlers/help.ts | 10 +++ packages/mcp/src/handlers/schema.ts | 6 +- packages/mcp/src/handlers/servers.test.ts | 54 +++++++++++++ packages/mcp/src/handlers/servers.ts | 14 +++- packages/mcp/src/handlers/sites.test.ts | 54 +++++++++++++ packages/mcp/src/handlers/sites.ts | 16 +++- packages/mcp/src/tools.test.ts | 4 +- packages/mcp/src/tools.ts | 8 +- 16 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/executors/servers/resolve.test.ts create mode 100644 packages/core/src/executors/servers/resolve.ts create mode 100644 packages/core/src/executors/sites/resolve.test.ts create mode 100644 packages/core/src/executors/sites/resolve.ts diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 176a8c7..b902302 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -42,6 +42,7 @@ export const ACTIONS = [ "restart", "activate", "run", + "resolve", "help", "schema", ] as const; 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 78e3ee6..9e7a694 100644 --- a/packages/mcp/src/handlers/help.ts +++ b/packages/mcp/src/handlers/help.ts @@ -28,6 +28,7 @@ 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", + resolve: "Find servers by name (partial, case-insensitive match)", }, fields: { id: "Server ID", @@ -48,6 +49,10 @@ const RESOURCE_HELP: Record = { description: "Reboot a server", params: { resource: "servers", action: "reboot", id: "123" }, }, + { + description: "Find servers by name", + params: { resource: "servers", action: "resolve", query: "prod" }, + }, ], }, @@ -59,6 +64,7 @@ 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", + resolve: "Find sites by domain name (partial, case-insensitive match, requires server_id)", }, fields: { id: "Site ID", @@ -89,6 +95,10 @@ const RESOURCE_HELP: Record = { directory: "/public", }, }, + { + 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 0f9d9da..c9c215e 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", "resolve"], scope: "global", required: { get: ["id"], create: ["provider", "type", "region", "name"], delete: ["id"], reboot: ["id"], + resolve: ["query"], }, 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", "resolve"], scope: "server", required: { list: ["server_id"], get: ["server_id", "id"], create: ["server_id", "domain", "project_type"], delete: ["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 e7b93b2..772e376 100644 --- a/packages/mcp/src/handlers/servers.test.ts +++ b/packages/mcp/src/handlers/servers.test.ts @@ -144,4 +144,58 @@ describe("handleServers", () => { expect(parsed._hints).toBeDefined(); expect(parsed._hints.related_resources).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 4951446..ca5fd6a 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"; @@ -14,12 +16,13 @@ import { createResourceHandler } from "./factory.ts"; export 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, @@ -27,6 +30,7 @@ export const handleServers = createResourceHandler({ create: createServer, delete: deleteServer, reboot: rebootServer, + resolve: resolveServers, }, hints: (_data, id) => getServerHints(id), mapOptions: (action, args) => { @@ -47,6 +51,8 @@ export const handleServers = createResourceHandler({ size: args.size ?? "", region: args.region, }; + case "resolve": + return { query: args.query }; default: return {}; } @@ -63,6 +69,12 @@ export 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 40e6ff7..0ac1608 100644 --- a/packages/mcp/src/handlers/sites.test.ts +++ b/packages/mcp/src/handlers/sites.test.ts @@ -110,4 +110,58 @@ describe("handleSites", () => { expect(parsed._hints).toBeDefined(); expect(parsed._hints.related_resources).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 613da41..bd3440b 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"; @@ -8,18 +9,20 @@ import { createResourceHandler } from "./factory.ts"; export 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; @@ -40,6 +43,8 @@ export 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 {}; @@ -55,6 +60,13 @@ export 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 b108db0..12e8442 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, resolve, help, schema", () => { + expect([...READ_ACTIONS]).toEqual(["list", "get", "resolve", "help", "schema"]); }); }); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index edb8b80..c3f91ac 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", "resolve", "help", "schema"] 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)", + }, }; /** @@ -110,6 +115,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.", + "resolve: find resources by name (partial, case-insensitive) — provide query field.", ].join("\n"), annotations: { title: "Laravel Forge", From 56c2063e91efd64354b33574f3c192c8a549aa6b Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 27 Feb 2026 16:13:15 +0100 Subject: [PATCH 2/2] Extract matchByName() utility and refactor resolve logic Extract shared case-insensitive name matching into a reusable matchByName() utility in forge-core. Refactor both core resolve executors and CLI resolve functions to use it, eliminating duplicated matching logic. Closes #74 Co-authored-by: Claude --- packages/cli/src/utils/resolve.test.ts | 12 ++- packages/cli/src/utils/resolve.ts | 16 +--- .../core/src/executors/servers/resolve.ts | 21 ++--- packages/core/src/executors/sites/resolve.ts | 21 ++--- packages/core/src/index.ts | 4 + packages/core/src/utils/name-matcher.test.ts | 88 +++++++++++++++++++ packages/core/src/utils/name-matcher.ts | 29 ++++++ 7 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 packages/core/src/utils/name-matcher.test.ts create mode 100644 packages/core/src/utils/name-matcher.ts diff --git a/packages/cli/src/utils/resolve.test.ts b/packages/cli/src/utils/resolve.test.ts index f5425d3..41071c9 100644 --- a/packages/cli/src/utils/resolve.test.ts +++ b/packages/cli/src/utils/resolve.test.ts @@ -5,10 +5,14 @@ import type { ForgeServer, ForgeSite } from "@studiometa/forge-api"; import { resolveServerId, resolveSiteId } from "./resolve.ts"; import { ValidationError } from "../errors.ts"; -vi.mock("@studiometa/forge-core", () => ({ - listServers: vi.fn(), - listSites: vi.fn(), -})); +vi.mock("@studiometa/forge-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + listServers: vi.fn(), + listSites: vi.fn(), + matchByName: actual.matchByName, + }; +}); const mockServer = (id: number, name: string): ForgeServer => ({ diff --git a/packages/cli/src/utils/resolve.ts b/packages/cli/src/utils/resolve.ts index f823c87..194d6a2 100644 --- a/packages/cli/src/utils/resolve.ts +++ b/packages/cli/src/utils/resolve.ts @@ -4,7 +4,7 @@ * Accepts numeric IDs (used as-is) or plain strings (resolved by name/partial match). */ -import { listServers, listSites } from "@studiometa/forge-core"; +import { listServers, listSites, matchByName } from "@studiometa/forge-core"; import type { ExecutorContext } from "@studiometa/forge-core"; import type { ForgeServer, ForgeSite } from "@studiometa/forge-api"; import { ValidationError } from "../errors.ts"; @@ -25,17 +25,12 @@ export async function resolveServerId(value: string, execCtx: ExecutorContext): const result = await listServers({}, execCtx); const servers = result.data as ForgeServer[]; - const lower = value.toLowerCase(); + const { exact, partial } = matchByName(servers, value, (s) => s.name); - // Exact match by name - const exact = servers.filter((s) => s.name.toLowerCase() === lower); if (exact.length === 1) { return String(exact[0].id); } - // Partial match - const partial = servers.filter((s) => s.name.toLowerCase().includes(lower)); - if (partial.length === 0) { const available = servers.map((s) => ` ${s.name} (${s.id})`).join("\n"); throw new ValidationError(`No server found matching "${value}"`, "server", [ @@ -75,17 +70,12 @@ export async function resolveSiteId( const result = await listSites({ server_id: serverId }, execCtx); const sites = result.data as ForgeSite[]; - const lower = value.toLowerCase(); + const { exact, partial } = matchByName(sites, value, (s) => s.name); - // Exact match by domain - const exact = sites.filter((s) => s.name.toLowerCase() === lower); if (exact.length === 1) { return String(exact[0].id); } - // Partial match - const partial = sites.filter((s) => s.name.toLowerCase().includes(lower)); - if (partial.length === 0) { const available = sites.map((s) => ` ${s.name} (${s.id})`).join("\n"); throw new ValidationError(`No site found matching "${value}" on server ${serverId}`, "site", [ diff --git a/packages/core/src/executors/servers/resolve.ts b/packages/core/src/executors/servers/resolve.ts index 8983755..4d0b53b 100644 --- a/packages/core/src/executors/servers/resolve.ts +++ b/packages/core/src/executors/servers/resolve.ts @@ -1,5 +1,6 @@ import type { ServersResponse } from "@studiometa/forge-api"; import type { ExecutorContext, ExecutorResult } from "../../context.ts"; +import { matchByName } from "../../utils/name-matcher.ts"; export interface ResolveServersOptions { query: string; @@ -28,27 +29,15 @@ export async function resolveServers( ): 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, - }, - }; - } + const match = matchByName(servers, options.query, (s) => s.name); + const matches = match.exact.length === 1 ? match.exact : match.partial; - // 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, + matches: matches.map((s) => ({ id: s.id, name: s.name })), + total: matches.length, }, }; } diff --git a/packages/core/src/executors/sites/resolve.ts b/packages/core/src/executors/sites/resolve.ts index 99dc515..6e1477c 100644 --- a/packages/core/src/executors/sites/resolve.ts +++ b/packages/core/src/executors/sites/resolve.ts @@ -1,5 +1,6 @@ import type { SitesResponse } from "@studiometa/forge-api"; import type { ExecutorContext, ExecutorResult } from "../../context.ts"; +import { matchByName } from "../../utils/name-matcher.ts"; export interface ResolveSitesOptions { server_id: string; @@ -29,27 +30,15 @@ export async function resolveSites( ): 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, - }, - }; - } + const match = matchByName(sites, options.query, (s) => s.name); + const matches = match.exact.length === 1 ? match.exact : match.partial; - // 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, + matches: matches.map((s) => ({ id: s.id, name: s.name })), + total: matches.length, }, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15cfae2..2e29d01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,10 @@ export { ACTIONS, RESOURCES } from "./constants.ts"; export type { Action, Resource } from "./constants.ts"; +// Utilities +export { matchByName } from "./utils/name-matcher.ts"; +export type { NameMatch } from "./utils/name-matcher.ts"; + // Audit logging export { createAuditLogger, sanitizeArgs, getAuditLogPath } from "./logger.ts"; export type { AuditLogger, AuditLogEntry } from "./logger.ts"; diff --git a/packages/core/src/utils/name-matcher.test.ts b/packages/core/src/utils/name-matcher.test.ts new file mode 100644 index 0000000..ed690d7 --- /dev/null +++ b/packages/core/src/utils/name-matcher.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { matchByName } from "./name-matcher.ts"; + +interface Item { + id: number; + name: string; +} + +const items: Item[] = [ + { id: 1, name: "prod-web-1" }, + { id: 2, name: "prod-web-2" }, + { id: 3, name: "staging-web-1" }, + { id: 4, name: "production" }, +]; + +const getName = (item: Item) => item.name; + +describe("matchByName", () => { + it("returns exact match in exact array", () => { + const { exact } = matchByName(items, "production", getName); + expect(exact).toHaveLength(1); + expect(exact[0]!.id).toBe(4); + }); + + it("returns partial matches in partial array", () => { + const { partial } = matchByName(items, "prod", getName); + expect(partial).toHaveLength(3); // prod-web-1, prod-web-2, production + }); + + it("includes exact matches in partial array", () => { + const { exact, partial } = matchByName(items, "production", getName); + expect(exact).toHaveLength(1); + expect(partial).toContainEqual(exact[0]); + }); + + it("is case insensitive for exact match", () => { + const { exact } = matchByName(items, "PRODUCTION", getName); + expect(exact).toHaveLength(1); + expect(exact[0]!.name).toBe("production"); + }); + + it("is case insensitive for partial match", () => { + const { partial } = matchByName(items, "PROD", getName); + expect(partial).toHaveLength(3); + }); + + it("matches all items with empty query", () => { + const { exact, partial } = matchByName(items, "", getName); + // Every name includes "" and equals "" is false → exact empty, partial all + expect(exact).toHaveLength(0); + expect(partial).toHaveLength(items.length); + }); + + it("returns empty arrays when no matches", () => { + const { exact, partial } = matchByName(items, "nonexistent", getName); + expect(exact).toHaveLength(0); + expect(partial).toHaveLength(0); + }); + + it("works with a custom getName function", () => { + const data = [ + { id: 1, label: "Alpha" }, + { id: 2, label: "Beta" }, + { id: 3, label: "alpha-extra" }, + ]; + const { exact, partial } = matchByName(data, "alpha", (d) => d.label); + expect(exact).toHaveLength(1); + expect(exact[0]!.id).toBe(1); + expect(partial).toHaveLength(2); + }); + + it("returns empty arrays for empty items array", () => { + const { exact, partial } = matchByName([], "prod", getName); + expect(exact).toHaveLength(0); + expect(partial).toHaveLength(0); + }); + + it("returns multiple exact matches when names are duplicated", () => { + const dupes: Item[] = [ + { id: 1, name: "prod" }, + { id: 2, name: "prod" }, + ]; + const { exact, partial } = matchByName(dupes, "prod", getName); + expect(exact).toHaveLength(2); + expect(partial).toHaveLength(2); + }); +}); diff --git a/packages/core/src/utils/name-matcher.ts b/packages/core/src/utils/name-matcher.ts new file mode 100644 index 0000000..729d74d --- /dev/null +++ b/packages/core/src/utils/name-matcher.ts @@ -0,0 +1,29 @@ +/** + * Result of a name matching operation. + * Provides both exact and partial matches for flexible consumption. + */ +export interface NameMatch { + /** Items whose name matches the query exactly (case-insensitive). */ + exact: T[]; + /** Items whose name contains the query (case-insensitive). Includes exact matches. */ + partial: T[]; +} + +/** + * Match items by name using case-insensitive exact and partial matching. + * + * @param items - The items to search through. + * @param query - The search query. + * @param getName - Function to extract the name from an item. + * @returns Object with exact and partial match arrays. + */ +export function matchByName( + items: T[], + query: string, + getName: (item: T) => string, +): NameMatch { + const lower = query.toLowerCase(); + const exact = items.filter((item) => getName(item).toLowerCase() === lower); + const partial = items.filter((item) => getName(item).toLowerCase().includes(lower)); + return { exact, partial }; +}