Files
komodo-mcp-server/src/index.ts
T
bot-hermes 5206ee612d 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
2026-09-07 20:36:39 +00:00

198 lines
6.7 KiB
TypeScript

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);
});