From 0cc0745c867405a36e7804303ef21f8757412a57 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 9 Sep 2026 00:33:25 +0000 Subject: [PATCH] feat: agent-friendly status & logs tools, build log resolution, builder inspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add komodo_status: auto-detects resource type by name, shows state + active ops - Add komodo_resource_logs: auto-detects type and fetches appropriate logs - Add shared resource-detect utility for type auto-detection across 11 resource types - Enhance komodo_logs build case: resolves builder→server→container for real logs - Add builder inspect type to komodo_inspect - Fallback to build resource info when container log resolution fails --- src/index.ts | 18 ++- src/resource-detect.ts | 206 ++++++++++++++++++++++++++++++++++ src/tools/inspect.ts | 4 + src/tools/logs.ts | 54 ++++++++- src/tools/resource-logs.ts | 221 +++++++++++++++++++++++++++++++++++++ src/tools/status.ts | 80 ++++++++++++++ src/types.ts | 3 +- 7 files changed, 581 insertions(+), 5 deletions(-) create mode 100644 src/resource-detect.ts create mode 100644 src/tools/resource-logs.ts create mode 100644 src/tools/status.ts diff --git a/src/index.ts b/src/index.ts index 7160d64..8bce822 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,8 @@ import { renameInputSchema, handleRename } from "./tools/rename.js"; import { serverStatsInputSchema, handleServerStats } from "./tools/server-stats.js"; import { resourceStatsInputSchema, handleResourceStats } from "./tools/resource-stats.js"; import { updatesInputSchema, handleUpdates } from "./tools/updates.js"; +import { statusInputSchema, handleStatus } from "./tools/status.js"; +import { resourceLogsInputSchema, handleResourceLogs } from "./tools/resource-logs.js"; const PORT = parseInt(process.env.PORT || "9800", 10); @@ -83,7 +85,7 @@ function createServerInstance(client: KomodoClient): McpServer { server.registerTool("komodo_logs", { description: - "Get logs for a Komodo resource. resource_type: deployment (GetDeploymentLog), stack (GetStackLog), container (GetContainerLog), swarm_service (GetSwarmServiceLog). Returns log lines as text.", + "Get logs for a Komodo resource. resource_type: deployment (GetDeploymentLog), stack (GetStackLog), container (GetContainerLog), swarm_service (GetSwarmServiceLog), build (resolves builder container and fetches real build logs, falls back to build resource info). Returns log lines as text.", inputSchema: logsInputSchema, }, async (args) => handleLogs(args, client)); @@ -95,7 +97,7 @@ function createServerInstance(client: KomodoClient): McpServer { server.registerTool("komodo_inspect", { description: - "Inspect a Docker object. inspect_type: container, image, network, volume, deployment_container, deployment_swarm_service, stack_container, stack_swarm_info, stack_swarm_service, swarm, swarm_config, swarm_node, swarm_secret, swarm_service, swarm_stack, swarm_task. Provide id and optional server name.", + "Inspect a Docker object or resource. inspect_type: container, image, network, volume, deployment_container, deployment_swarm_service, stack_container, stack_swarm_info, stack_swarm_service, swarm, swarm_config, swarm_node, swarm_secret, swarm_service, swarm_stack, swarm_task, builder. Provide id and optional server name.", inputSchema: inspectInputSchema, }, async (args) => handleInspect(args, client)); @@ -141,6 +143,18 @@ function createServerInstance(client: KomodoClient): McpServer { inputSchema: updatesInputSchema, }, async (args) => handleUpdates(args, client)); + server.registerTool("komodo_status", { + description: + "Check the status of any Komodo resource by name or ID. Auto-detects resource type (stack, build, server, deployment, procedure, repo, action, alerter, builder, swarm, sync_resource). Returns state, health, active operations, and relevant details. Optionally pass type to skip auto-detection.", + inputSchema: statusInputSchema, + }, async (args) => handleStatus(args, client)); + + server.registerTool("komodo_resource_logs", { + description: + "Get logs for any Komodo resource by name or ID. Auto-detects resource type and fetches the appropriate logs. For builds, resolves the builder container and fetches real build logs. For deployments/stacks, fetches the container/service logs. Optionally pass type to skip auto-detection.", + inputSchema: resourceLogsInputSchema, + }, async (args) => handleResourceLogs(args, client)); + return server; } diff --git a/src/resource-detect.ts b/src/resource-detect.ts new file mode 100644 index 0000000..b849d14 --- /dev/null +++ b/src/resource-detect.ts @@ -0,0 +1,206 @@ +import { KomodoClient } from "./komodo-client.js"; +import type { ResourceType } from "./types.js"; + +/** + * Resource types we try to detect, in priority order. + * Covers the most common agent-facing resources. + */ +const DETECT_ORDER: ResourceType[] = [ + "stack", + "deployment", + "build", + "server", + "procedure", + "repo", + "action", + "alerter", + "builder", + "swarm", + "sync_resource", +]; + +/** Map resource type → Komodo Get request name */ +const GET_REQUEST: Record = { + stack: "GetStack", + deployment: "GetDeployment", + build: "GetBuild", + server: "GetServer", + procedure: "GetProcedure", + repo: "GetRepo", + action: "GetAction", + alerter: "GetAlerter", + builder: "GetBuilder", + swarm: "GetSwarm", + sync_resource: "GetResourceSync", +}; + +/** Map resource type → param key for Get request (most use "id", some differ) */ +const GET_PARAM_KEY: Record = { + builder: "builder", + action: "action", + tag: "tag", + variable: "name", + user_group: "user_group", + sync_resource: "resource_sync", +}; + +export interface DetectionResult { + type: ResourceType; + resource: Record; +} + +/** + * Auto-detect a resource by trying Get endpoints across types. + * Returns the first match, or null if nothing found. + */ +export async function detectResource( + client: KomodoClient, + nameOrId: string, + hint?: ResourceType, +): Promise { + const typesToTry = hint ? [hint, ...DETECT_ORDER.filter((t) => t !== hint)] : DETECT_ORDER; + + for (const type of typesToTry) { + const requestName = GET_REQUEST[type]; + if (!requestName) continue; + + const paramKey = GET_PARAM_KEY[type] || "id"; + try { + const resource = (await client.rpc("read", requestName, { + [paramKey]: nameOrId, + })) as Record; + + if (resource && (resource.name || resource._id)) { + return { type, resource }; + } + } catch { + // Not found on this type — try next + } + } + + return null; +} + +/** + * Format a human-readable status summary for a resource. + */ +export function formatStatus(result: DetectionResult): string { + const { type, resource } = result; + const name = (resource.name as string) ?? "unknown"; + const info = resource?.info as Record | undefined; + const config = resource?.config as Record | undefined; + + const lines: string[] = []; + lines.push(`Resource: ${name}`); + lines.push(`Type: ${type}`); + + switch (type) { + case "server": { + const state = info?.state ?? "unknown"; + lines.push(`State: ${state}`); + if (info?.err) lines.push(`Error: ${info.err}`); + const stats = info?.stats as Record | undefined; + if (stats) { + lines.push(`CPU: ${stats.cpu_perc ?? "?"}%`); + const memUsed = stats.mem_used_gb as number | undefined; + const memTotal = stats.mem_total_gb as number | undefined; + if (memUsed != null && memTotal != null) { + lines.push(`Memory: ${memUsed.toFixed(1)} / ${memTotal.toFixed(1)} GB (${((memUsed / memTotal) * 100).toFixed(0)}%)`); + } + const diskUsed = stats.disk_used_gb as number | undefined; + const diskTotal = stats.disk_total_gb as number | undefined; + if (diskUsed != null && diskTotal != null) { + lines.push(`Disk: ${diskUsed.toFixed(1)} / ${diskTotal.toFixed(1)} GB (${((diskUsed / diskTotal) * 100).toFixed(0)}%)`); + } + } + break; + } + case "stack": { + const state = info?.state ?? "unknown"; + lines.push(`State: ${state}`); + const status = info?.status as string | undefined; + if (status) lines.push(`Status: ${status}`); + const services = info?.services as Array> | undefined; + if (services && services.length > 0) { + lines.push(`Services (${services.length}):`); + for (const svc of services) { + lines.push(` - ${svc.service ?? "?"}: ${svc.state ?? "unknown"}`); + } + } + break; + } + case "deployment": { + const state = info?.state ?? "unknown"; + lines.push(`State: ${state}`); + const status = info?.status as string | undefined; + if (status) lines.push(`Status: ${status}`); + break; + } + case "build": { + const state = info?.state ?? "unknown"; + lines.push(`State: ${state}`); + if (info?.last_built_at) { + lines.push(`Last built: ${new Date(info.last_built_at as number).toISOString()}`); + } + if (info?.built_hash) lines.push(`Built hash: ${info.built_hash}`); + if (info?.remote_error) lines.push(`Remote error: ${info.remote_error}`); + const builderId = config?.builder_id as string | undefined; + if (builderId) lines.push(`Builder: ${builderId}`); + break; + } + case "procedure": { + const running = info?.running ?? false; + lines.push(`Running: ${running}`); + break; + } + case "repo": { + const state = info?.state ?? "unknown"; + lines.push(`State: ${state}`); + if (info?.last_built_at) { + lines.push(`Last built: ${new Date(info.last_built_at as number).toISOString()}`); + } + break; + } + case "action": { + const running = info?.running ?? false; + lines.push(`Running: ${running}`); + if (info?.last_run_at) { + lines.push(`Last run: ${new Date(info.last_run_at as number).toISOString()}`); + } + break; + } + case "alerter": { + const enabled = (config as Record)?.enabled ?? "unknown"; + lines.push(`Enabled: ${enabled}`); + break; + } + case "builder": { + const builderType = config?.type ?? "unknown"; + lines.push(`Builder type: ${builderType}`); + const params = config?.params as Record | undefined; + if (builderType === "Server" && params?.server_ids) { + lines.push(`Server IDs: ${(params.server_ids as string[]).join(", ")}`); + } else if (builderType === "Url" && params?.address) { + lines.push(`Address: ${params.address}`); + } + break; + } + case "swarm": { + const state = info?.state ?? "unknown"; + lines.push(`State: ${state}`); + break; + } + case "sync_resource": { + const lastStatus = info?.last_sync_status as string | undefined; + lines.push(`Last sync status: ${lastStatus ?? "never"}`); + if (info?.last_sync_ts) { + lines.push(`Last sync: ${new Date(info.last_sync_ts as number).toISOString()}`); + } + break; + } + default: + lines.push(`State: ${JSON.stringify(info?.state ?? "unknown")}`); + } + + return lines.join("\n"); +} diff --git a/src/tools/inspect.ts b/src/tools/inspect.ts index b65eae9..80f741d 100644 --- a/src/tools/inspect.ts +++ b/src/tools/inspect.ts @@ -8,6 +8,7 @@ const InspectType = z.enum([ "stack_container", "stack_swarm_info", "stack_swarm_service", "swarm", "swarm_config", "swarm_node", "swarm_secret", "swarm_service", "swarm_stack", "swarm_task", + "builder", ]); // Types that need {deployment/stack, service} instead of {id} @@ -51,6 +52,9 @@ export async function handleInspect( params.stack = id; if (service) params.service = service; if (server) params.server = server; + } else if (inspect_type === "builder") { + // GetBuilder: {builder} + params.builder = id; } else { const paramKey = server ? INSPECT_PARAM_KEY[inspect_type] : undefined; if (paramKey) { diff --git a/src/tools/logs.ts b/src/tools/logs.ts index 605ae40..e20e1ed 100644 --- a/src/tools/logs.ts +++ b/src/tools/logs.ts @@ -5,7 +5,7 @@ const LogResourceType = z.enum(["deployment", "stack", "container", "swarm_servi export const logsInputSchema = { resource_type: LogResourceType.describe( - "Resource type to get logs for (deployment, stack, container, swarm_service). Returns log lines as text." + "Resource type to get logs for (deployment, stack, container, swarm_service, build). Build resolves the builder container and fetches real logs. Returns log lines as text." ), id: z.string().describe("Resource ID or name"), tail: z @@ -65,10 +65,60 @@ export async function handleLogs( const build = (await client.rpc("read", "GetBuild", { id })) as Record; const info = build?.info as Record | undefined; const config = build?.config as Record | undefined; + const buildName = (build?.name as string) ?? id; + + // Try to resolve the builder and fetch real container logs + const builderId = config?.builder_id as string | undefined; + if (builderId) { + try { + const builder = (await client.rpc("read", "GetBuilder", { builder: builderId })) as Record; + const builderConfig = builder?.config as Record | undefined; + const builderName = (builder?.name as string) ?? builderId; + const builderType = builderConfig?.type as string | undefined; + + let serverName: string | undefined; + + if (builderType === "Server" && builderConfig?.params) { + const params = builderConfig.params as Record; + const serverIds = params.server_ids as string[] | undefined; + if (serverIds && serverIds.length > 0) { + // Resolve first server_id to server name + try { + const servers = (await client.rpc("read", "ListServers", {})) as Array>; + const targetId = serverIds[0]; + const server = servers.find((s) => { + const id = s._id as Record | undefined; + return id?.$oid === targetId || s.name === targetId; + }); + if (server) serverName = server.name as string; + } catch { /* server lookup failed, try without */ } + } + } else if (builderType === "Url" && builderConfig?.params) { + const params = builderConfig.params as Record; + // Url builder uses a Periphery address directly — use it as server reference + serverName = params.address as string | undefined; + } + + if (serverName) { + const logParams: Record = { + server: serverName, + container: builderName, + }; + if (tail !== undefined) logParams.tail = tail; + result = await client.rpc("read", "GetContainerLog", logParams); + break; + } + } catch { + // Builder lookup or container log fetch failed — fall through to build info + } + } + + // Fallback: return build resource info (limited on failure) const lines: string[] = []; - lines.push(`Build: ${build?.name ?? id}`); + lines.push(`Build: ${buildName}`); if (config?.repo) lines.push(`Repo: ${config.repo}`); if (config?.branch) lines.push(`Branch: ${config.branch}`); + if (builderId) lines.push(`Builder: ${builderId}`); if (info?.last_built_at) lines.push(`Last built: ${new Date(info.last_built_at as number).toISOString()}`); if (info?.built_hash) lines.push(`Built hash: ${info.built_hash}`); if (info?.latest_hash) lines.push(`Latest hash: ${info.latest_hash}`); diff --git a/src/tools/resource-logs.ts b/src/tools/resource-logs.ts new file mode 100644 index 0000000..8b41d93 --- /dev/null +++ b/src/tools/resource-logs.ts @@ -0,0 +1,221 @@ +import { z } from "zod"; +import { KomodoClient } from "../komodo-client.js"; +import { ResourceType } from "../types.js"; +import { detectResource } from "../resource-detect.js"; + +export const resourceLogsInputSchema = { + resource: z + .string() + .describe("Name or ID of the resource to get logs for"), + type: ResourceType.optional().describe( + "Resource type hint to skip auto-detection (e.g. 'stack', 'deployment', 'container'). If omitted, auto-detects by trying all types." + ), + tail: z + .number() + .optional() + .describe("Number of recent log lines to return"), + services: z + .array(z.string()) + .optional() + .describe("Service names to include (used for stack logs)"), +}; + +/** Map resource type → log request name */ +const LOG_REQUEST: Record = { + deployment: "GetDeploymentLog", + stack: "GetStackLog", +}; + +export async function handleResourceLogs( + args: { + resource: string; + type?: z.infer; + tail?: number; + services?: string[]; + }, + client: KomodoClient, +): Promise<{ content: { type: "text"; text: string }[] }> { + const { resource, type, tail, services } = args; + + // Handle container type directly (not a Komodo resource — needs server name) + if (type === "container" as string) { + return fetchContainerLogs(client, resource, tail); + } + + // Auto-detect the resource + const detected = await detectResource(client, resource, type); + if (!detected) { + return { + content: [{ + type: "text", + text: `Resource not found: "${resource}". Try a different name or ID, or specify the type parameter.`, + }], + }; + } + + const { type: resType, resource: res } = detected; + const name = (res.name as string) ?? resource; + + // Get logs based on resource type + const logRequest = LOG_REQUEST[resType]; + + if (resType === "build") { + // Build logs need special handling: resolve builder container + return fetchBuildLogs(client, res, tail); + } + + if (!logRequest) { + return { + content: [{ + type: "text", + text: `Logs not available for resource type "${resType}" (${name}). Use komodo_get to view the resource details.`, + }], + }; + } + + // Build params based on resource type + const params: Record = {}; + + switch (resType as string) { + case "deployment": + params.id = name; + break; + case "stack": + params.id = name; + params.services = services ?? []; + break; + } + + if (tail !== undefined) params.tail = tail; + + try { + const result = await client.rpc("read", logRequest, params); + return { + content: [{ + type: "text", + text: typeof result === "string" ? result : JSON.stringify(result, null, 2), + }], + }; + } catch (err) { + return { + content: [{ + type: "text", + text: `Failed to fetch logs for ${resType} "${name}": ${err instanceof Error ? err.message : err}`, + }], + }; + } +} + +async function fetchContainerLogs( + client: KomodoClient, + container: string, + tail?: number, +): Promise<{ content: { type: "text"; text: string }[] }> { + // Container requires server name — try to find it via resource_matching_container + try { + const match = (await client.rpc("read", "GetResourceMatchingContainer", { + container, + })) as Record | null; + + if (match?.server) { + const params: Record = { + server: match.server, + container, + }; + if (tail !== undefined) params.tail = tail; + const result = await client.rpc("read", "GetContainerLog", params); + return { + content: [{ + type: "text", + text: typeof result === "string" ? result : JSON.stringify(result, null, 2), + }], + }; + } + } catch { + // Fall through + } + + return { + content: [{ + type: "text", + text: `Cannot fetch logs for container "${container}": server name is required. Use komodo_logs with resource_type="container" and provide the server parameter.`, + }], + }; +} + +async function fetchBuildLogs( + client: KomodoClient, + build: Record, + tail?: number, +): Promise<{ content: { type: "text"; text: string }[] }> { + const config = build?.config as Record | undefined; + const info = build?.info as Record | undefined; + const buildName = (build?.name as string) ?? "unknown"; + const builderId = config?.builder_id as string | undefined; + + if (!builderId) { + return { + content: [{ + type: "text", + text: `Build "${buildName}" has no builder configured.`, + }], + }; + } + + try { + const builder = (await client.rpc("read", "GetBuilder", { + builder: builderId, + })) as Record; + const builderConfig = builder?.config as Record | undefined; + const builderName = (builder?.name as string) ?? builderId; + const builderType = builderConfig?.type as string | undefined; + + let serverName: string | undefined; + + if (builderType === "Server" && builderConfig?.params) { + const params = builderConfig.params as Record; + const serverIds = params.server_ids as string[] | undefined; + if (serverIds && serverIds.length > 0) { + const servers = (await client.rpc("read", "ListServers", {})) as Array>; + const targetId = serverIds[0]; + const server = servers.find((s) => { + const id = s._id as Record | undefined; + return id?.$oid === targetId || s.name === targetId; + }); + if (server) serverName = server.name as string; + } + } else if (builderType === "Url" && builderConfig?.params) { + const params = builderConfig.params as Record; + serverName = params.address as string | undefined; + } + + if (serverName) { + const logParams: Record = { + server: serverName, + container: builderName, + }; + if (tail !== undefined) logParams.tail = tail; + const result = await client.rpc("read", "GetContainerLog", logParams); + return { + content: [{ + type: "text", + text: typeof result === "string" ? result : JSON.stringify(result, null, 2), + }], + }; + } + } catch { + // Fall through to build info + } + + // Fallback: build resource info + const lines: string[] = []; + lines.push(`Build: ${buildName} (builder container logs unavailable)`); + if (config?.repo) lines.push(`Repo: ${config.repo}`); + if (config?.branch) lines.push(`Branch: ${config.branch}`); + if (info?.last_built_at) lines.push(`Last built: ${new Date(info.last_built_at as number).toISOString()}`); + if (info?.built_hash) lines.push(`Built hash: ${info.built_hash}`); + if (info?.remote_error) lines.push(`Remote error: ${info.remote_error}`); + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/tools/status.ts b/src/tools/status.ts new file mode 100644 index 0000000..52c9315 --- /dev/null +++ b/src/tools/status.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { KomodoClient } from "../komodo-client.js"; +import { ResourceType } from "../types.js"; +import { detectResource, formatStatus } from "../resource-detect.js"; + +export const statusInputSchema = { + resource: z + .string() + .describe("Name or ID of the resource to check"), + type: ResourceType.optional().describe( + "Resource type hint to skip auto-detection (e.g. 'stack', 'build', 'server'). If omitted, auto-detects by trying all types." + ), +}; + +export async function handleStatus( + args: { + resource: string; + type?: z.infer; + }, + client: KomodoClient, +): Promise<{ content: { type: "text"; text: string }[] }> { + const { resource, type } = args; + + const detected = await detectResource(client, resource, type); + if (!detected) { + return { + content: [{ + type: "text", + text: `Resource not found: "${resource}". Try a different name or ID, or specify the type parameter.`, + }], + }; + } + + const statusText = formatStatus(detected); + + // Also fetch action state to show if anything is currently running + const actionStateMap: Record = { + stack: "GetStackActionState", + deployment: "GetDeploymentActionState", + build: "GetBuildActionState", + server: "GetServerActionState", + procedure: "GetProcedureActionState", + repo: "GetRepoActionState", + action: "GetActionActionState", + sync_resource: "GetResourceSyncActionState", + swarm: "GetSwarmActionState", + }; + + const actionRequest = actionStateMap[detected.type]; + let actionStateText = ""; + + if (actionRequest) { + try { + const paramKey = detected.type === "sync_resource" ? "resource_sync" : detected.type; + const state = (await client.rpc("read", actionRequest, { + [paramKey]: resource, + })) as Record; + + // Check if any field is truthy (meaning an operation is in progress) + const activeOps = Object.entries(state).filter( + ([, v]) => v === true || (typeof v === "number" && v > 0) + ); + + if (activeOps.length > 0) { + actionStateText = `\nActive operations: ${activeOps.map(([k]) => k).join(", ")}`; + } else { + actionStateText = "\nNo active operations"; + } + } catch { + // Action state not available — not critical + } + } + + return { + content: [{ + type: "text", + text: statusText + actionStateText, + }], + }; +} diff --git a/src/types.ts b/src/types.ts index 1c040c4..d68e54f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -352,7 +352,8 @@ export const INSPECT_REQUEST_MAP: Record = { swarm_secret: "InspectSwarmSecret", swarm_service: "InspectSwarmService", swarm_stack: "InspectSwarmStack", - swarm_task: "InspectSwarmTask" + swarm_task: "InspectSwarmTask", + builder: "GetBuilder" }; export const SEARCH_LOG_REQUEST_MAP: Record = {