feat: agent-friendly status & logs tools, build log resolution, builder inspect

- 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
This commit is contained in:
2026-09-09 00:33:25 +00:00
parent fe00ed430b
commit 0cc0745c86
7 changed files with 581 additions and 5 deletions
+16 -2
View File
@@ -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;
}
+206
View File
@@ -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<string, string> = {
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<string, string> = {
builder: "builder",
action: "action",
tag: "tag",
variable: "name",
user_group: "user_group",
sync_resource: "resource_sync",
};
export interface DetectionResult {
type: ResourceType;
resource: Record<string, unknown>;
}
/**
* 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<DetectionResult | null> {
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<string, unknown>;
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<string, unknown> | undefined;
const config = resource?.config as Record<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>> | 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<string, unknown>)?.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<string, unknown> | 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");
}
+4
View File
@@ -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) {
+52 -2
View File
@@ -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<string, unknown>;
const info = build?.info as Record<string, unknown> | undefined;
const config = build?.config as Record<string, unknown> | 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<string, unknown>;
const builderConfig = builder?.config as Record<string, unknown> | 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<string, unknown>;
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<Record<string, unknown>>;
const targetId = serverIds[0];
const server = servers.find((s) => {
const id = s._id as Record<string, unknown> | 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<string, unknown>;
// Url builder uses a Periphery address directly — use it as server reference
serverName = params.address as string | undefined;
}
if (serverName) {
const logParams: Record<string, unknown> = {
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}`);
+221
View File
@@ -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<string, string> = {
deployment: "GetDeploymentLog",
stack: "GetStackLog",
};
export async function handleResourceLogs(
args: {
resource: string;
type?: z.infer<typeof ResourceType>;
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<string, unknown> = {};
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<string, unknown> | null;
if (match?.server) {
const params: Record<string, unknown> = {
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<string, unknown>,
tail?: number,
): Promise<{ content: { type: "text"; text: string }[] }> {
const config = build?.config as Record<string, unknown> | undefined;
const info = build?.info as Record<string, unknown> | 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<string, unknown>;
const builderConfig = builder?.config as Record<string, unknown> | 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<string, unknown>;
const serverIds = params.server_ids as string[] | undefined;
if (serverIds && serverIds.length > 0) {
const servers = (await client.rpc("read", "ListServers", {})) as Array<Record<string, unknown>>;
const targetId = serverIds[0];
const server = servers.find((s) => {
const id = s._id as Record<string, unknown> | 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<string, unknown>;
serverName = params.address as string | undefined;
}
if (serverName) {
const logParams: Record<string, unknown> = {
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") }],
};
}
+80
View File
@@ -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<typeof ResourceType>;
},
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<string, string> = {
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<string, unknown>;
// 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,
}],
};
}
+2 -1
View File
@@ -352,7 +352,8 @@ export const INSPECT_REQUEST_MAP: Record<string, string> = {
swarm_secret: "InspectSwarmSecret",
swarm_service: "InspectSwarmService",
swarm_stack: "InspectSwarmStack",
swarm_task: "InspectSwarmTask"
swarm_task: "InspectSwarmTask",
builder: "GetBuilder"
};
export const SEARCH_LOG_REQUEST_MAP: Record<string, string> = {