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 @@ -43,6 +43,7 @@ export const ACTIONS = [
"restart",
"activate",
"run",
"resolve",
"help",
"schema",
"context",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/executors/servers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ 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,
GetServerOptions,
ListServersOptions,
RebootServerOptions,
} from "./types.ts";
export type { ResolveServersOptions, ResolveMatch, ResolveResult } from "./resolve.ts";
66 changes: 66 additions & 0 deletions packages/core/src/executors/servers/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
54 changes: 54 additions & 0 deletions packages/core/src/executors/servers/resolve.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutorResult<ResolveResult>> {
const response = await ctx.client.get<ServersResponse>("/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,
},
};
}
2 changes: 2 additions & 0 deletions packages/core/src/executors/sites/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
79 changes: 79 additions & 0 deletions packages/core/src/executors/sites/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
55 changes: 55 additions & 0 deletions packages/core/src/executors/sites/resolve.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutorResult<ResolveSiteResult>> {
const response = await ctx.client.get<SitesResponse>(`/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,
},
};
}
15 changes: 14 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,35 @@ export {
getServer,
listServers,
rebootServer,
resolveServers,
} from "./executors/servers/index.ts";
export type {
CreateServerOptions,
DeleteServerOptions,
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
Expand Down
10 changes: 10 additions & 0 deletions packages/mcp/src/handlers/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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",
Expand All @@ -54,6 +55,10 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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" },
},
],
},

Expand All @@ -67,6 +72,7 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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",
Expand Down Expand Up @@ -101,6 +107,10 @@ const RESOURCE_HELP: Record<string, ResourceHelp> = {
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" },
},
],
},

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,14 +23,15 @@ interface ResourceSchemaData {

const RESOURCE_SCHEMAS: Record<string, ResourceSchemaData> = {
servers: {
actions: ["list", "get", "create", "delete", "reboot", "context"],
actions: ["list", "get", "create", "delete", "reboot", "resolve", "context"],
scope: "global",
required: {
get: ["id"],
create: ["provider", "type", "region", "name"],
delete: ["id"],
reboot: ["id"],
context: ["id"],
resolve: ["query"],
},
create: {
provider: { required: true, type: "string — hetzner, ocean2, aws, etc." },
Expand All @@ -44,14 +45,15 @@ const RESOURCE_SCHEMAS: Record<string, ResourceSchemaData> = {
},

sites: {
actions: ["list", "get", "create", "delete", "context"],
actions: ["list", "get", "create", "delete", "resolve", "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"],
resolve: ["server_id", "query"],
},
create: {
domain: { required: true, type: "string — e.g. example.com" },
Expand Down
Loading