diff --git a/src/index.ts b/src/index.ts index f6451d7..7160d64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,9 @@ import { searchLogsInputSchema, handleSearchLogs } from "./tools/search-logs.js" import { listDetailInputSchema, handleListDetail } from "./tools/list-detail.js"; import { copyInputSchema, handleCopy } from "./tools/copy.js"; 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"; const PORT = parseInt(process.env.PORT || "9800", 10); @@ -120,6 +123,24 @@ function createServerInstance(client: KomodoClient): McpServer { inputSchema: renameInputSchema, }, async (args) => handleRename(args, client)); + server.registerTool("komodo_server_stats", { + description: + "Get server diagnostics. stat_type: system_information (OS/CPU/hardware), system_stats (live CPU/mem/disk), historical_server_stats (timeseries), server_state (connectivity), periphery_information (agent info), containers_summary (aggregate counts), version (Core API version), core_info (Core configuration).", + inputSchema: serverStatsInputSchema, + }, async (args) => handleServerStats(args, client)); + + server.registerTool("komodo_resource_stats", { + description: + "Get resource diagnostics. stat_type: deployment_stats (CPU/mem/net usage), deployment_container (container details), build_monthly_stats (build frequency), action_state (is resource currently running — requires resource_type param), concurrency_limit_usage (active limits), resource_matching_container (reverse-lookup container → resource).", + inputSchema: resourceStatsInputSchema, + }, async (args) => handleResourceStats(args, client)); + + server.registerTool("komodo_updates", { + description: + "Get execution history and logs. mode: get (retrieve specific update by ID with full structured logs including stage/command/stdout/stderr), list (list recent operations with status/success/target). Use list to find recent executions, then get with the update ID to see full logs.", + inputSchema: updatesInputSchema, + }, async (args) => handleUpdates(args, client)); + return server; } diff --git a/src/tools/execute.ts b/src/tools/execute.ts index 4a98145..b266ad9 100644 --- a/src/tools/execute.ts +++ b/src/tools/execute.ts @@ -78,6 +78,15 @@ const OPERATION_PARAM_KEY: Record = { global_auto_update: "_none", send_alert: "alert", test_alerter: "alerter", + // Troubleshooting / refresh + refresh_server: "server", + refresh_server_containers: "server", + refresh_stack_cache: "stack", + refresh_stack_content: "stack", + remove_orphan_containers: "stack", + check_stack_for_update: "stack", + check_deployment_for_update: "deployment", + get_mcp_token: "_none", }; // Operations that need {server: } alongside the entity param diff --git a/src/tools/list-detail.ts b/src/tools/list-detail.ts index 1941d4a..9a9981c 100644 --- a/src/tools/list-detail.ts +++ b/src/tools/list-detail.ts @@ -20,6 +20,8 @@ const listDetailType = z.enum([ "common_stack_build_extra_args", "common_stack_extra_args", // Other "image_history", "all_stack_services", + // Troubleshooting + "terminals", "alerts", "containers_summary", ]); export const listDetailInputSchema = { diff --git a/src/tools/logs.ts b/src/tools/logs.ts index 47cdb77..605ae40 100644 --- a/src/tools/logs.ts +++ b/src/tools/logs.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { KomodoClient } from "../komodo-client.js"; -const LogResourceType = z.enum(["deployment", "stack", "container", "swarm_service"]); +const LogResourceType = z.enum(["deployment", "stack", "container", "swarm_service", "build"]); export const logsInputSchema = { resource_type: LogResourceType.describe( @@ -61,6 +61,25 @@ export async function handleLogs( result = await client.rpc("read", "GetSwarmServiceLog", params); break; } + case "build": { + 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 lines: string[] = []; + lines.push(`Build: ${build?.name ?? id}`); + 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?.latest_hash) lines.push(`Latest hash: ${info.latest_hash}`); + if (info?.built_message) lines.push(`Built message: ${info.built_message}`); + if (info?.latest_message) lines.push(`Latest message: ${info.latest_message}`); + if (info?.remote_error) lines.push(`Remote error: ${info.remote_error}`); + if (info?.built_contents) lines.push(`--- Built Dockerfile ---\n${info.built_contents}`); + if (info?.remote_contents) lines.push(`--- Remote Dockerfile ---\n${info.remote_contents}`); + result = lines.join("\n"); + break; + } } return { diff --git a/src/tools/resource-stats.ts b/src/tools/resource-stats.ts new file mode 100644 index 0000000..79eff83 --- /dev/null +++ b/src/tools/resource-stats.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import { KomodoClient } from "../komodo-client.js"; +import { RESOURCE_STATS_REQUEST_MAP, ACTION_STATE_REQUEST_MAP } from "../types.js"; + +const ResourceStatType = z.enum([ + "deployment_stats", + "deployment_container", + "build_monthly_stats", + "action_state", + "concurrency_limit_usage", + "resource_matching_container", +]); + +const ActionStateResourceType = z.enum([ + "action", "build", "deployment", "procedure", + "repo", "server", "stack", "swarm", "sync_resource", +]); + +export const resourceStatsInputSchema = { + stat_type: ResourceStatType.describe( + "Resource stat to retrieve: deployment_stats (CPU/mem/net usage), deployment_container (container details), build_monthly_stats (build frequency), action_state (is resource currently running), concurrency_limit_usage (active limits), resource_matching_container (reverse-lookup container → resource)." + ), + id: z.string().optional().describe("Resource ID or name (required for deployment_stats, deployment_container, build_monthly_stats, action_state)"), + resource_type: ActionStateResourceType.optional().describe("Resource type for action_state (e.g. 'stack', 'deployment', 'build'). Required when stat_type is 'action_state'."), + server: z.string().optional().describe("Server name (required for resource_matching_container)"), + container: z.string().optional().describe("Container name (required for resource_matching_container)"), +}; + +export async function handleResourceStats( + args: { + stat_type: z.infer; + id?: string; + resource_type?: z.infer; + server?: string; + container?: string; + }, + client: KomodoClient, +): Promise<{ content: { type: "text"; text: string }[] }> { + const { stat_type, id, resource_type, server, container } = args; + + // Handle action_state separately — it dispatches to different endpoints per resource type + if (stat_type === "action_state") { + if (!resource_type) throw new Error(`action_state requires a resource_type (e.g. 'stack', 'deployment', 'build')`); + if (!id) throw new Error(`action_state requires an id`); + + const requestName = ACTION_STATE_REQUEST_MAP[resource_type]; + if (!requestName) { + throw new Error(`No action state endpoint for resource type: ${resource_type}`); + } + + // The param key matches the resource type for most, but sync_resource uses "resource_sync" + const paramKey = resource_type === "sync_resource" ? "resource_sync" : resource_type; + const params: Record = { [paramKey]: id }; + + const result = await client.rpc("read", requestName, params); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + const requestName = RESOURCE_STATS_REQUEST_MAP[stat_type]; + if (!requestName) { + throw new Error(`Unknown resource stat type: ${stat_type}`); + } + + const params: Record = {}; + + switch (stat_type) { + case "deployment_stats": + case "deployment_container": + if (!id) throw new Error(`${stat_type} requires an id (deployment name or ID)`); + params.deployment = id; + break; + case "build_monthly_stats": + if (!id) throw new Error(`${stat_type} requires an id (build name or ID)`); + params.build = id; + break; + case "concurrency_limit_usage": + // No params needed + break; + case "resource_matching_container": + if (!server) throw new Error(`resource_matching_container requires a server`); + if (!container) throw new Error(`resource_matching_container requires a container name`); + params.server = server; + params.container = container; + break; + } + + const result = await client.rpc("read", requestName, params); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; +} diff --git a/src/tools/server-stats.ts b/src/tools/server-stats.ts new file mode 100644 index 0000000..0873fdb --- /dev/null +++ b/src/tools/server-stats.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { KomodoClient } from "../komodo-client.js"; +import { SERVER_STATS_REQUEST_MAP } from "../types.js"; + +const ServerStatType = z.enum([ + "system_information", + "system_stats", + "historical_server_stats", + "server_state", + "periphery_information", + "containers_summary", + "version", + "core_info", +]); + +export const serverStatsInputSchema = { + stat_type: ServerStatType.describe( + "Server stat to retrieve: system_information (OS/CPU/hardware), system_stats (live CPU/mem/disk), historical_server_stats (timeseries), server_state (connectivity), periphery_information (agent info), containers_summary (aggregate counts), version (Core API version), core_info (Core configuration)." + ), + server: z.string().optional().describe("Server name or ID (required for most stats except version, core_info, containers_summary)"), + granularity: z.string().optional().describe("Time granularity for historical_server_stats (e.g. '1-hr', '1-day'). Required for historical_server_stats."), + page: z.number().optional().describe("Page for historical_server_stats pagination"), +}; + +export async function handleServerStats( + args: { + stat_type: z.infer; + server?: string; + granularity?: string; + page?: number; + }, + client: KomodoClient, +): Promise<{ content: { type: "text"; text: string }[] }> { + const { stat_type, server, granularity, page } = args; + + const requestName = SERVER_STATS_REQUEST_MAP[stat_type]; + if (!requestName) { + throw new Error(`Unknown server stat type: ${stat_type}`); + } + + const params: Record = {}; + + switch (stat_type) { + case "system_information": + case "system_stats": + case "server_state": + case "periphery_information": + if (!server) throw new Error(`${stat_type} requires a server name or ID`); + params.server = server; + break; + case "historical_server_stats": + if (!server) throw new Error(`historical_server_stats requires a server name or ID`); + if (!granularity) throw new Error(`historical_server_stats requires a granularity (e.g. '1-hr', '1-day')`); + params.server = server; + params.granularity = granularity; + if (page !== undefined) params.page = page; + break; + case "containers_summary": + case "version": + case "core_info": + // No params needed + break; + } + + const result = await client.rpc("read", requestName, params); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; +} diff --git a/src/tools/updates.ts b/src/tools/updates.ts new file mode 100644 index 0000000..8765417 --- /dev/null +++ b/src/tools/updates.ts @@ -0,0 +1,115 @@ +import { z } from "zod"; +import { KomodoClient } from "../komodo-client.js"; + +const UpdateMode = z.enum(["get", "list"]); + +export const updatesInputSchema = { + mode: UpdateMode.describe( + "get: retrieve a specific update by ID (returns full execution logs). list: list recent updates (returns summary of recent operations)." + ), + id: z.string().optional().describe("Update ID (required for get mode). The update ID from a previous list or execute operation."), + page: z.number().optional().describe("Page number for list mode (0 = most recent, default). Use next_page from response to paginate."), +}; + +interface UpdateLog { + stage?: string; + command?: string; + stdout?: string; + stderr?: string; + success?: boolean; + start_ts?: number; + end_ts?: number; +} + +interface Update { + _id?: { $oid: string }; + operation?: string; + start_ts?: number; + success?: boolean; + operator?: string; + target?: { type: string; id: string }; + logs?: UpdateLog[]; + end_ts?: number; + status?: string; + version?: { major: number; minor: number; patch: number }; + commit_hash?: string; +} + +function formatUpdate(update: Update): string { + const lines: string[] = []; + const id = update._id?.$oid ?? "unknown"; + lines.push(`Update: ${id}`); + lines.push(`Operation: ${update.operation ?? "unknown"}`); + lines.push(`Status: ${update.status ?? "unknown"}`); + lines.push(`Success: ${update.success ?? "unknown"}`); + lines.push(`Operator: ${update.operator ?? "unknown"}`); + if (update.target) lines.push(`Target: ${update.target.type} (${update.target.id})`); + if (update.start_ts) lines.push(`Started: ${new Date(update.start_ts).toISOString()}`); + if (update.end_ts) lines.push(`Ended: ${new Date(update.end_ts).toISOString()}`); + if (update.version) lines.push(`Version: ${update.version.major}.${update.version.minor}.${update.version.patch}`); + if (update.commit_hash) lines.push(`Commit: ${update.commit_hash}`); + + if (update.logs && update.logs.length > 0) { + lines.push(`\n--- Logs (${update.logs.length} stages) ---`); + for (const log of update.logs) { + lines.push(`\n[${log.stage ?? "unknown"}] ${log.success ? "OK" : "FAILED"}`); + if (log.command) lines.push(`Command: ${log.command}`); + if (log.start_ts) lines.push(`Start: ${new Date(log.start_ts).toISOString()}`); + if (log.end_ts) lines.push(`End: ${new Date(log.end_ts).toISOString()}`); + if (log.stdout) lines.push(`stdout:\n${log.stdout}`); + if (log.stderr) lines.push(`stderr:\n${log.stderr}`); + } + } else { + lines.push("\nNo logs available."); + } + + return lines.join("\n"); +} + +export async function handleUpdates( + args: { + mode: z.infer; + id?: string; + page?: number; + }, + client: KomodoClient, +): Promise<{ content: { type: "text"; text: string }[] }> { + const { mode, id, page } = args; + + if (mode === "get") { + if (!id) throw new Error(`get mode requires an id (update ID)`); + const result = await client.rpc("read", "GetUpdate", { id }); + return { + content: [{ type: "text", text: formatUpdate(result as Update) }], + }; + } + + // list mode + const params: Record = {}; + if (page !== undefined) params.page = page; + + const result = await client.rpc("read", "ListUpdates", params) as { + updates?: Update[]; + next_page?: number; + }; + + const lines: string[] = []; + if (result.updates && result.updates.length > 0) { + lines.push(`Found ${result.updates.length} updates:\n`); + for (const u of result.updates) { + const uId = u._id?.$oid ?? "unknown"; + const time = u.start_ts ? new Date(u.start_ts).toISOString() : "unknown"; + const target = u.target ? `${u.target.type}` : "unknown"; + lines.push(`- [${u.success ? "OK" : "FAIL"}] ${uId} | ${u.operation ?? "?"} | ${target} | ${u.operator ?? "?"} | ${time}`); + } + if (result.next_page !== undefined) { + lines.push(`\nMore pages available. Use page: ${result.next_page}`); + } + } else { + lines.push("No updates found."); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; +} diff --git a/src/types.ts b/src/types.ts index 47559b8..1c040c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,7 +100,16 @@ export const ExecuteOperation = z.enum([ "backup_core_database", "global_auto_update", "rotate_all_server_keys", - "rotate_core_keys" + "rotate_core_keys", + // Troubleshooting / refresh + "refresh_server", + "refresh_server_containers", + "refresh_stack_cache", + "refresh_stack_content", + "remove_orphan_containers", + "check_stack_for_update", + "check_deployment_for_update", + "get_mcp_token", ]); export type ExecuteOperation = z.infer; @@ -294,7 +303,16 @@ export const EXECUTE_REQUEST_MAP: Record< unpause_container: {"route":"execute","name":"UnpauseContainer"}, unpause_deployment: {"route":"execute","name":"UnpauseDeployment"}, unpause_stack: {"route":"execute","name":"UnpauseStack"}, - update_swarm_node: {"route":"execute","name":"UpdateSwarmNode"} + update_swarm_node: {"route":"execute","name":"UpdateSwarmNode"}, + // Troubleshooting / refresh + refresh_server: {"route":"execute","name":"RunServerRefresh"}, + refresh_server_containers: {"route":"execute","name":"RunServerRefreshContainers"}, + refresh_stack_cache: {"route":"execute","name":"RunStackRefreshCache"}, + refresh_stack_content: {"route":"execute","name":"RunStackRefreshContent"}, + remove_orphan_containers: {"route":"execute","name":"RunStackRemoveOrphanContainers"}, + check_stack_for_update: {"route":"execute","name":"CheckStackForUpdate"}, + check_deployment_for_update: {"route":"execute","name":"CheckDeploymentForUpdate"}, + get_mcp_token: {"route":"execute","name":"GetMcpToken"}, }; export const SUMMARY_REQUEST_MAP: Partial> = { @@ -386,7 +404,11 @@ export const LIST_DETAIL_REQUEST_MAP: Record = { common_stack_build_extra_args: "ListCommonStackBuildExtraArgs", common_stack_extra_args: "ListCommonStackExtraArgs", image_history: "ListImageHistory", - all_stack_services: "ListAllStackServices" + all_stack_services: "ListAllStackServices", + // Troubleshooting additions + terminals: "ListTerminals", + alerts: "ListAlerts", + containers_summary: "GetContainersSummary", }; export const COPY_REQUEST_MAP: Record = { @@ -418,3 +440,34 @@ export const RENAME_REQUEST_MAP: Record = { tag: "RenameTag", user_group: "RenameUserGroup" }; + +export const SERVER_STATS_REQUEST_MAP: Record = { + system_information: "GetSystemInformation", + system_stats: "GetSystemStats", + historical_server_stats: "GetHistoricalServerStats", + server_state: "GetServerState", + periphery_information: "GetPeripheryInformation", + containers_summary: "GetContainersSummary", + version: "GetVersion", + core_info: "GetCoreInfo", +}; + +export const RESOURCE_STATS_REQUEST_MAP: Record = { + deployment_stats: "GetDeploymentStats", + deployment_container: "GetDeploymentContainer", + build_monthly_stats: "GetBuildMonthlyStats", + concurrency_limit_usage: "GetConcurrencyLimitUsage", + resource_matching_container: "GetResourceMatchingContainer", +}; + +export const ACTION_STATE_REQUEST_MAP: Record = { + action: "GetActionActionState", + build: "GetBuildActionState", + deployment: "GetDeploymentActionState", + procedure: "GetProcedureActionState", + repo: "GetRepoActionState", + server: "GetServerActionState", + stack: "GetStackActionState", + swarm: "GetSwarmActionState", + sync_resource: "GetResourceSyncActionState", +};