feat: Komodo MCP server — 7 tools, full API coverage

- komodo_list, komodo_get, komodo_create, komodo_update, komodo_delete,
  komodo_execute, komodo_logs with resource_type routing
- JWT auth with token caching and auto-refresh on 401
- HTTP/SSE transport on port 9800
- TypeScript, @modelcontextprotocol/sdk, zod schemas
- README.md with setup instructions
- references/api.md with full endpoint mapping
This commit is contained in:
2026-09-07 20:36:39 +00:00
parent 15dce68efe
commit 5206ee612d
14 changed files with 2971 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { KomodoClient } from "./komodo-client.js";
import { listInputSchema, handleList } from "./tools/list.js";
import { getInputSchema, handleGet } from "./tools/get.js";
import { createInputSchema, handleCreate } from "./tools/create.js";
import { updateInputSchema, handleUpdate } from "./tools/update.js";
import { deleteInputSchema, handleDelete } from "./tools/delete.js";
import { executeInputSchema, handleExecute } from "./tools/execute.js";
import { logsInputSchema, handleLogs } from "./tools/logs.js";
const PORT = parseInt(process.env.PORT || "9800", 10);
const client = new KomodoClient();
const transports: Record<string, StreamableHTTPServerTransport> = {};
function createServerInstance(): McpServer {
const server = new McpServer({
name: "komodo-mcp-server",
version: "1.0.0",
});
server.registerTool("komodo_list", {
description:
"List or search Komodo resources (stacks, builds, servers, procedures, deployments, alerters, image_registry_accounts, sync_resources, users, tags, executions, access_requests). Returns a JSON array of matching resources.",
inputSchema: listInputSchema,
}, async (args) => handleList(args, client));
server.registerTool("komodo_get", {
description:
"Get a single Komodo resource by ID or name. Returns the full resource object as JSON.",
inputSchema: getInputSchema,
}, async (args) => handleGet(args, client));
server.registerTool("komodo_create", {
description:
"Create a new Komodo resource. Provide the resource_type and a params object with the fields required by the corresponding Komodo Create* endpoint.",
inputSchema: createInputSchema,
}, async (args) => handleCreate(args, client));
server.registerTool("komodo_update", {
description:
"Update an existing Komodo resource by ID. Provide the resource_type, id, and params with the fields to update.",
inputSchema: updateInputSchema,
}, async (args) => handleUpdate(args, client));
server.registerTool("komodo_delete", {
description:
"Delete a Komodo resource by ID or name.",
inputSchema: deleteInputSchema,
}, async (args) => handleDelete(args, client));
server.registerTool("komodo_execute", {
description:
"Execute a Komodo operation (build, deploy, procedure run, server refresh, stack lifecycle actions, etc.). Provide the operation name, an optional resource id, and optional params.",
inputSchema: executeInputSchema,
}, async (args) => handleExecute(args, client));
server.registerTool("komodo_logs", {
description:
"Get logs for a Komodo deployment or stack. Returns log lines as text.",
inputSchema: logsInputSchema,
}, async (args) => handleLogs(args, client));
return server;
}
function parseBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks).toString("utf-8");
try {
resolve(body ? JSON.parse(body) : null);
} catch (err) {
reject(err);
}
});
req.on("error", reject);
});
}
const httpServer = createServer(async (req, res) => {
// CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, mcp-session-id",
);
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
// Health check
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", name: "komodo-mcp-server" }));
return;
}
// MCP endpoint
if (req.url === "/mcp" || req.url?.startsWith("/mcp?")) {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (req.method === "POST") {
const body = await parseBody(req);
// Check if this is an initialize request (no existing session)
const isInitialize =
!sessionId ||
!transports[sessionId] ||
(body && typeof body === "object" && "method" in body && body.method === "initialize");
if (isInitialize && (!sessionId || !transports[sessionId])) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
transports[sid] = transport;
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) delete transports[sid];
};
const server = createServerInstance();
await server.connect(transport);
await transport.handleRequest(req, res, body);
return;
}
if (sessionId && transports[sessionId]) {
const transport = transports[sessionId];
await transport.handleRequest(req, res, body);
return;
}
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: { code: -32000, message: "Bad Request: No valid session" },
id: null,
}),
);
return;
}
if (req.method === "GET" && sessionId && transports[sessionId]) {
await transports[sessionId].handleRequest(req, res);
return;
}
if (req.method === "DELETE" && sessionId && transports[sessionId]) {
await transports[sessionId].handleRequest(req, res);
return;
}
res.writeHead(405, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
jsonrpc: "2.0",
error: { code: -32000, message: "Method not allowed" },
id: null,
}),
);
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
httpServer.listen(PORT, "0.0.0.0", () => {
console.log(`Komodo MCP Server listening on http://0.0.0.0:${PORT}/mcp`);
console.log(`Health check: http://0.0.0.0:${PORT}/health`);
console.log(`Komodo URL: ${process.env.KOMODO_BASE_URL || process.env.KOMODO_URL || "http://10.10.2.114:9120"}`);
});
process.on("SIGINT", async () => {
console.log("\nShutting down...");
for (const sid of Object.keys(transports)) {
try {
await transports[sid].close();
delete transports[sid];
} catch {}
}
httpServer.close();
process.exit(0);
});
+79
View File
@@ -0,0 +1,79 @@
export class KomodoClient {
private baseUrl: string;
private username: string;
private password: string;
private token: string | null = null;
constructor() {
this.baseUrl = process.env.KOMODO_BASE_URL || process.env.KOMODO_URL || "http://10.10.2.114:9120";
this.username = process.env.KOMODO_USERNAME || "";
this.password = process.env.KOMODO_PASSWORD || "";
}
private async ensureAuth(): Promise<void> {
if (this.token) return;
await this.login();
}
private async login(): Promise<void> {
const res = await fetch(`${this.baseUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: this.username,
password: this.password,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Komodo login failed (${res.status}): ${text}`);
}
this.token = await res.text();
}
async rpc(
route: "read" | "write" | "execute",
requestName: string,
params: Record<string, unknown> = {},
): Promise<unknown> {
await this.ensureAuth();
const url = `${this.baseUrl}/${route}/${requestName}`;
const attempt = async (token: string): Promise<Response> => {
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(params),
});
};
let res = await attempt(this.token!);
// Auto-refresh on 401
if (res.status === 401) {
this.token = null;
await this.login();
res = await attempt(this.token!);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Komodo RPC ${route}/${requestName} failed (${res.status}): ${text}`,
);
}
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
import { ResourceType, CREATE_REQUEST_MAP } from "../types.js";
export const createInputSchema = {
resource_type: ResourceType.describe(
"Resource type to create (stack, build, server, procedure, deployment, alerter, image_registry_account, sync_resource, tag)",
),
params: z
.record(z.string(), z.unknown())
.optional()
.describe(
"Resource-specific parameters passed directly to the Komodo Create* endpoint",
),
};
export async function handleCreate(
args: {
resource_type: z.infer<typeof ResourceType>;
params?: Record<string, unknown>;
},
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { resource_type, params } = args;
const requestName = CREATE_REQUEST_MAP[resource_type];
if (!requestName) {
throw new Error(
`No CREATE endpoint available for resource type: ${resource_type}`,
);
}
const result = await client.rpc("write", requestName, params ?? {});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
import { ResourceType, DELETE_REQUEST_MAP } from "../types.js";
export const deleteInputSchema = {
resource_type: ResourceType.describe(
"Resource type to delete (stack, build, server, procedure, deployment, alerter, sync_resource, tag)",
),
id: z.string().describe("Resource ID or name to delete"),
};
export async function handleDelete(
args: { resource_type: z.infer<typeof ResourceType>; id: string },
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { resource_type, id } = args;
const requestName = DELETE_REQUEST_MAP[resource_type];
if (!requestName) {
throw new Error(
`No DELETE endpoint available for resource type: ${resource_type}`,
);
}
const result = await client.rpc("write", requestName, { id });
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
+44
View File
@@ -0,0 +1,44 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
import { ExecuteOperation, EXECUTE_REQUEST_MAP } from "../types.js";
export const executeInputSchema = {
operation: ExecuteOperation.describe(
"Execute operation (run_build, deploy_stack, deploy_stack_service, run_procedure, run_procedure_stage, run_deployment_action, run_deployment_sync, run_resource_sync, run_stack_refresh_cache, run_stack_refresh_content, run_stack_pull, run_stack_pull_deployment, run_stack_stop, run_stack_restart, run_stack_pause, run_stack_unpause, run_stack_remove_orphan_containers, run_stack_toggle_service_dependencies, run_stack_auto_update, run_stack_commit, run_server_refresh, run_server_refresh_containers, run_server_update_periphery, run_server_prune_images, run_server_prune_containers, run_server_prune_networks, run_server_stats, run_server_run_command, run_server_scripts, run_server_copy, run_server_move, run_deployment_execute, run_deployment_redeploy, run_deployment_destroy, run_deployment_stop, run_deployment_logs, run_sync_deployment, get_mcp_token)",
),
id: z.string().optional().describe("Resource ID or name for the operation"),
params: z
.record(z.string(), z.unknown())
.optional()
.describe("Additional parameters for the operation"),
};
export async function handleExecute(
args: {
operation: z.infer<typeof ExecuteOperation>;
id?: string;
params?: Record<string, unknown>;
},
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { operation, id, params } = args;
const mapping = EXECUTE_REQUEST_MAP[operation];
if (!mapping) {
throw new Error(`Unknown execute operation: ${operation}`);
}
const requestParams: Record<string, unknown> = { ...params };
if (id) requestParams.id = id;
const result = await client.rpc(mapping.route, mapping.name, requestParams);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
import { ResourceType, GET_REQUEST_MAP } from "../types.js";
export const getInputSchema = {
resource_type: ResourceType.describe(
"Resource type to get (stack, build, server, procedure, deployment, alerter, image_registry_account, sync_resource, user, tag, execution, access_request)",
),
id: z.string().describe("Resource ID or name"),
};
export async function handleGet(
args: { resource_type: z.infer<typeof ResourceType>; id: string },
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { resource_type, id } = args;
const requestName = GET_REQUEST_MAP[resource_type];
if (!requestName) {
throw new Error(
`No GET endpoint available for resource type: ${resource_type}`,
);
}
const result = await client.rpc("read", requestName, { id });
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
import {
ResourceType,
LIST_REQUEST_MAP,
SEARCH_REQUEST_MAP,
} from "../types.js";
export const listInputSchema = {
resource_type: ResourceType.describe(
"Resource type to list (stack, build, server, procedure, deployment, alerter, image_registry_account, sync_resource, user, tag, execution, access_request)",
),
search: z
.string()
.optional()
.describe("Optional search/filter query — uses the Search* endpoint instead of List*"),
project: z
.string()
.optional()
.describe("Filter by project name or ID"),
};
export async function handleList(
args: { resource_type: z.infer<typeof ResourceType>; search?: string; project?: string },
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { resource_type, search, project } = args;
const params: Record<string, unknown> = {};
if (search) params.filter = search;
if (project) params.project = project;
let route: "read" | "write" | "execute" = "read";
let requestName: string;
if (search && SEARCH_REQUEST_MAP[resource_type]) {
requestName = SEARCH_REQUEST_MAP[resource_type];
} else {
requestName = LIST_REQUEST_MAP[resource_type];
}
const result = await client.rpc(route, requestName, params);
// Komodo list endpoints return bare arrays
const items = Array.isArray(result) ? result : [result];
return {
content: [
{
type: "text",
text: JSON.stringify(items, null, 2),
},
],
};
}
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
export const logsInputSchema = {
resource_type: z
.enum(["deployment", "stack"])
.describe("Resource type to get logs for"),
id: z.string().describe("Resource ID or name"),
tail: z
.number()
.optional()
.describe("Number of recent log lines to return"),
};
export async function handleLogs(
args: {
resource_type: "deployment" | "stack";
id: string;
tail?: number;
},
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { resource_type, id, tail } = args;
const params: Record<string, unknown> = { id };
if (tail !== undefined) params.tail = tail;
let result: unknown;
if (resource_type === "deployment") {
result = await client.rpc("read", "GetDeploymentLogs", params);
} else {
// stack — GetStack returns full stack state including logs
result = await client.rpc("read", "GetStack", { id });
}
return {
content: [
{
type: "text",
text:
typeof result === "string"
? result
: JSON.stringify(result, null, 2),
},
],
};
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod";
import { KomodoClient } from "../komodo-client.js";
import { ResourceType, UPDATE_REQUEST_MAP } from "../types.js";
export const updateInputSchema = {
resource_type: ResourceType.describe(
"Resource type to update (stack, build, server, procedure, deployment, alerter, image_registry_account, sync_resource, user, tag)",
),
id: z.string().describe("Resource ID or name to update"),
params: z
.record(z.string(), z.unknown())
.optional()
.describe(
"Update fields passed directly to the Komodo Update* endpoint",
),
};
export async function handleUpdate(
args: {
resource_type: z.infer<typeof ResourceType>;
id: string;
params?: Record<string, unknown>;
},
client: KomodoClient,
): Promise<{ content: { type: "text"; text: string }[] }> {
const { resource_type, id, params } = args;
const requestName = UPDATE_REQUEST_MAP[resource_type];
if (!requestName) {
throw new Error(
`No UPDATE endpoint available for resource type: ${resource_type}`,
);
}
const result = await client.rpc("write", requestName, { id, ...params });
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
+233
View File
@@ -0,0 +1,233 @@
import { z } from "zod";
export const ResourceType = z.enum([
"stack",
"build",
"server",
"procedure",
"deployment",
"alerter",
"image_registry_account",
"sync_resource",
"user",
"tag",
"execution",
"access_request",
]);
export type ResourceType = z.infer<typeof ResourceType>;
export const ExecuteOperation = z.enum([
"run_build",
"deploy_stack",
"deploy_stack_service",
"run_procedure",
"run_procedure_stage",
"run_deployment_action",
"run_deployment_sync",
"run_resource_sync",
"run_stack_refresh_cache",
"run_stack_refresh_content",
"run_stack_pull",
"run_stack_pull_deployment",
"run_stack_stop",
"run_stack_restart",
"run_stack_pause",
"run_stack_unpause",
"run_stack_remove_orphan_containers",
"run_stack_toggle_service_dependencies",
"run_stack_auto_update",
"run_stack_commit",
"run_server_refresh",
"run_server_refresh_containers",
"run_server_update_periphery",
"run_server_prune_images",
"run_server_prune_containers",
"run_server_prune_networks",
"run_server_stats",
"run_server_run_command",
"run_server_scripts",
"run_server_copy",
"run_server_move",
"run_deployment_execute",
"run_deployment_redeploy",
"run_deployment_destroy",
"run_deployment_stop",
"run_deployment_logs",
"run_sync_deployment",
"get_mcp_token",
]);
export type ExecuteOperation = z.infer<typeof ExecuteOperation>;
// Maps resource_type to the list/search RPC request name
export const LIST_REQUEST_MAP: Record<ResourceType, string> = {
stack: "ListStacks",
build: "ListBuilds",
server: "ListServers",
procedure: "ListProcedures",
deployment: "ListDeployments",
alerter: "ListAlerters",
image_registry_account: "ListImageRegistryAccounts",
sync_resource: "ListSyncResources",
user: "ListUsers",
tag: "ListTags",
execution: "ListExecutions",
access_request: "ListAccessRequests",
};
// Maps resource_type to the search RPC request name
export const SEARCH_REQUEST_MAP: Record<ResourceType, string> = {
stack: "SearchStacks",
build: "SearchBuilds",
server: "SearchServers",
procedure: "SearchProcedures",
deployment: "SearchDeployments",
alerter: "SearchAlerters",
image_registry_account: "SearchImageRegistryAccounts",
sync_resource: "SearchSyncResources",
user: "ListUsers",
tag: "ListTags",
execution: "ListExecutions",
access_request: "ListAccessRequests",
};
// Maps resource_type to the get RPC request name
export const GET_REQUEST_MAP: Record<ResourceType, string> = {
stack: "GetStack",
build: "GetBuild",
server: "GetServer",
procedure: "GetProcedure",
deployment: "GetDeployment",
alerter: "GetAlerter",
image_registry_account: "GetImageRegistryAccount",
sync_resource: "GetSyncResource",
user: "GetUser",
tag: "GetTag",
execution: "GetExecution",
access_request: "GetAccessRequest",
};
// Maps resource_type to the create RPC request name
export const CREATE_REQUEST_MAP: Partial<Record<ResourceType, string>> = {
stack: "CreateStack",
build: "CreateBuild",
server: "CreateServer",
procedure: "CreateProcedure",
deployment: "CreateDeployment",
alerter: "CreateAlerter",
image_registry_account: "CreateImageRegistryAccount",
sync_resource: "CreateSyncResource",
tag: "AddTag",
};
// Maps resource_type to the update RPC request name
export const UPDATE_REQUEST_MAP: Partial<Record<ResourceType, string>> = {
stack: "UpdateStack",
build: "UpdateBuild",
server: "UpdateServer",
procedure: "UpdateProcedure",
deployment: "UpdateDeployment",
alerter: "UpdateAlerter",
image_registry_account: "UpdateImageRegistryAccount",
sync_resource: "UpdateSyncResource",
user: "UpdateUser",
tag: "UpdateTag",
};
// Maps resource_type to the delete RPC request name
export const DELETE_REQUEST_MAP: Partial<Record<ResourceType, string>> = {
stack: "DeleteStack",
build: "DeleteBuild",
server: "DeleteServer",
procedure: "DeleteProcedure",
deployment: "DeleteDeployment",
alerter: "DeleteAlerter",
sync_resource: "DeleteSyncResource",
tag: "DeleteTag",
};
// Maps execute operation to the RPC request name and the route
export const EXECUTE_REQUEST_MAP: Record<
ExecuteOperation,
{ route: "execute" | "read" | "write"; name: string }
> = {
run_build: { route: "execute", name: "RunBuild" },
deploy_stack: { route: "execute", name: "DeployStack" },
deploy_stack_service: { route: "execute", name: "DeployStackService" },
run_procedure: { route: "execute", name: "RunProcedure" },
run_procedure_stage: { route: "execute", name: "RunProcedureStage" },
run_deployment_action: { route: "execute", name: "RunDeploymentAction" },
run_deployment_sync: { route: "execute", name: "RunDeploymentSync" },
run_resource_sync: { route: "execute", name: "RunResourceSync" },
run_stack_refresh_cache: {
route: "execute",
name: "RunStackRefreshCache",
},
run_stack_refresh_content: {
route: "execute",
name: "RunStackRefreshContent",
},
run_stack_pull: { route: "execute", name: "RunStackPull" },
run_stack_pull_deployment: {
route: "execute",
name: "RunStackPullDeployment",
},
run_stack_stop: { route: "execute", name: "RunStackStop" },
run_stack_restart: { route: "execute", name: "RunStackRestart" },
run_stack_pause: { route: "execute", name: "RunStackPause" },
run_stack_unpause: { route: "execute", name: "RunStackUnpause" },
run_stack_remove_orphan_containers: {
route: "execute",
name: "RunStackRemoveOrphanContainers",
},
run_stack_toggle_service_dependencies: {
route: "execute",
name: "RunStackToggleServiceDependencies",
},
run_stack_auto_update: { route: "execute", name: "RunStackAutoUpdate" },
run_stack_commit: { route: "execute", name: "RunStackCommit" },
run_server_refresh: { route: "execute", name: "RunServerRefresh" },
run_server_refresh_containers: {
route: "execute",
name: "RunServerRefreshContainers",
},
run_server_update_periphery: {
route: "execute",
name: "RunServerUpdatePeriphery",
},
run_server_prune_images: {
route: "execute",
name: "RunServerPruneImages",
},
run_server_prune_containers: {
route: "execute",
name: "RunServerPruneContainers",
},
run_server_prune_networks: {
route: "execute",
name: "RunServerPruneNetworks",
},
run_server_stats: { route: "execute", name: "RunServerStats" },
run_server_run_command: {
route: "execute",
name: "RunServerRunCommand",
},
run_server_scripts: { route: "execute", name: "RunServerScripts" },
run_server_copy: { route: "execute", name: "RunServerCopy" },
run_server_move: { route: "execute", name: "RunServerMove" },
run_deployment_execute: {
route: "execute",
name: "RunDeploymentExecute",
},
run_deployment_redeploy: {
route: "execute",
name: "RunDeploymentRedeploy",
},
run_deployment_destroy: {
route: "execute",
name: "RunDeploymentDestroy",
},
run_deployment_stop: { route: "execute", name: "RunDeploymentStop" },
run_deployment_logs: { route: "execute", name: "RunDeploymentLogs" },
run_sync_deployment: { route: "execute", name: "RunSyncDeployment" },
get_mcp_token: { route: "execute", name: "GetMcpToken" },
};