@gorizond/mcp-rancher-multi
Version:
MCP server for multiple Rancher Manager backends with Fleet GitOps support
490 lines (489 loc) • 20.2 kB
JavaScript
// mcp-rancher-multi — MCP server for Rancher (multi-server) + Fleet GitOps tools
// License: MIT
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { summarizeFleetBundleDeployment, summarizeFleetGitRepo, } from "./formatters.js";
import { loadEnvFiles, loadConfigFromEnv, resolveToken, obfuscateConfig, stripMetadataManagedFields, } from "./utils.js";
import { RancherClient } from "./rancher-client.js";
// ---- Load .env files first ----
loadEnvFiles();
// ---- Load configuration from environment ----
const STORE = loadConfigFromEnv();
// ---- MCP server ----
const server = new McpServer({ name: "mcp-rancher-multi", version: "0.3.0" });
const toJsonText = (data, strip = true) => JSON.stringify(strip ? stripMetadataManagedFields(data) : data, null, 2);
const buildPath = (path, params) => {
const url = new URL(path.startsWith("/") ? `http://dummy${path}` : `http://dummy/${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "")
url.searchParams.set(key, value);
}
return `${url.pathname}${url.search}`;
};
function getClient(serverId) {
const cfg = STORE[serverId];
if (!cfg)
throw new Error(`Rancher server '${serverId}' not found`);
return new RancherClient(cfg);
}
// ---- Tools: manage Rancher servers ----
server.registerTool("rancher_servers_list", {
title: "List registered Rancher servers",
description: "Returns known servers from local store",
inputSchema: z.object({}).shape,
}, async () => ({
content: [
{ type: "text", text: toJsonText(Object.values(obfuscateConfig(STORE))) },
],
}));
server.registerTool("rancher_servers_add", {
title: "Add/Update Rancher server (runtime only)",
description: "Register a Rancher Manager for current session (not persisted)",
inputSchema: z.object({
id: z.string(),
baseUrl: z.string().url(),
token: z.string(),
name: z.string().optional(),
insecureSkipTlsVerify: z.boolean().optional(),
caCertPemBase64: z.string().optional(),
}).shape,
}, async (args) => {
const cfg = { ...args };
STORE[cfg.id] = cfg;
return {
content: [
{
type: "text",
text: toJsonText(obfuscateConfig({ [cfg.id]: cfg })[cfg.id]),
},
],
};
});
server.registerTool("rancher_servers_remove", {
title: "Remove Rancher server (runtime only)",
description: "Deletes a server from current session (not persisted)",
inputSchema: z.object({ id: z.string() }).shape,
}, async ({ id }) => {
if (!STORE[id])
throw new Error(`Server '${id}' not found`);
const removed = STORE[id];
delete STORE[id];
return {
content: [
{
type: "text",
text: toJsonText(obfuscateConfig({ [id]: removed })[id]),
},
],
};
});
// ---- Tools: clusters / nodes / projects ----
server.registerTool("rancher_clusters_list", {
title: "List clusters",
description: "Return clusters from selected Rancher server",
inputSchema: z.object({
serverId: z.string(),
summary: z.boolean().default(true),
summaryFields: z.array(z.string()).optional(),
stripKeys: z.array(z.string()).optional(),
limit: z.number().int().positive().optional(),
autoContinue: z.boolean().default(false),
maxPages: z.number().int().positive().optional(),
maxItems: z.number().int().positive().optional(),
continueToken: z.string().optional(),
}).shape,
}, async ({ serverId, summary, summaryFields, stripKeys, limit, autoContinue, maxPages, maxItems, continueToken, }) => {
const client = getClient(serverId);
const data = await client.listClusters({
summary,
summaryFields,
stripKeys,
limit,
autoContinue,
maxPages,
maxItems,
continueToken,
});
return { content: [{ type: "text", text: toJsonText(data, true) }] };
});
server.registerTool("rancher_cluster_get", {
title: "Get cluster",
description: "Return a single cluster by id",
inputSchema: z.object({
serverId: z.string(),
clusterId: z.string(),
summary: z.boolean().default(false),
stripKeys: z.array(z.string()).optional(),
}).shape,
}, async ({ serverId, clusterId, summary, stripKeys, }) => {
const client = getClient(serverId);
const data = await client.getCluster(clusterId, { summary, stripKeys });
return { content: [{ type: "text", text: toJsonText(data) }] };
});
server.registerTool("rancher_clusters_kubeconfig", {
title: "Generate kubeconfig for a cluster",
description: "POST /v3/clusters/{id}?action=generateKubeconfig",
inputSchema: z.object({ serverId: z.string(), clusterId: z.string() })
.shape,
}, async ({ serverId, clusterId }) => {
const client = getClient(serverId);
const kubeconfig = await client.generateKubeconfig(clusterId);
return { content: [{ type: "text", text: kubeconfig }] };
});
server.registerTool("rancher_nodes_list", {
title: "List nodes",
description: "Return nodes (v3/nodes)",
inputSchema: z.object({
serverId: z.string(),
clusterId: z.string().optional(),
}).shape,
}, async ({ serverId, clusterId }) => {
const client = getClient(serverId);
const data = await client.listNodes(clusterId);
return { content: [{ type: "text", text: toJsonText(data) }] };
});
server.registerTool("rancher_projects_list", {
title: "List projects",
description: "Return projects in cluster (v3/projects)",
inputSchema: z.object({ serverId: z.string(), clusterId: z.string() })
.shape,
}, async ({ serverId, clusterId }) => {
const client = getClient(serverId);
const data = await client.listProjects(clusterId);
return { content: [{ type: "text", text: toJsonText(data) }] };
});
// ---- Tools: Kubernetes via Rancher proxy ----
server.registerTool("k8s_namespaces_list", {
title: "K8s: list namespaces",
description: "GET /api/v1/namespaces via Rancher proxy",
inputSchema: z.object({ serverId: z.string(), clusterId: z.string() })
.shape,
}, async ({ serverId, clusterId }) => {
const client = getClient(serverId);
const data = await client.listNamespaces(clusterId);
return { content: [{ type: "text", text: toJsonText(data) }] };
});
server.registerTool("k8s_raw", {
title: "K8s: raw HTTP via Rancher proxy",
description: "Arbitrary request to /api or /apis (DANGEROUS) — use carefully",
inputSchema: z.object({
serverId: z.string(),
clusterId: z.string(),
path: z.string().describe("E.g. /api/v1/pods?limit=50"),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
body: z.string().optional(),
contentType: z.string().optional().default("application/json"),
accept: z.string().optional(),
limit: z.number().int().positive().optional(),
autoContinue: z.boolean().default(false),
maxPages: z.number().int().positive().optional(),
maxItems: z.number().int().positive().optional(),
stripManagedFields: z.boolean().default(true),
stripKeys: z.array(z.string()).optional(),
}).shape,
}, async ({ serverId, clusterId, path: p, method, body, contentType, accept, limit, autoContinue, maxPages, maxItems, stripManagedFields, stripKeys, }) => {
const client = getClient(serverId);
const res = await client.k8sRaw({
clusterId,
path: p,
method,
body,
contentType,
accept,
limit,
autoContinue,
maxPages,
maxItems,
stripManagedFields,
stripKeys,
});
const text = typeof res === "string" ? res : toJsonText(res, stripManagedFields);
return { content: [{ type: "text", text }] };
});
// ---- Tools: Health & kubeconfig merge ----
server.registerTool("rancher_health", {
title: "Rancher health",
description: "Check /v3 endpoint",
inputSchema: z.object({ serverId: z.string() }).shape,
}, async ({ serverId }) => {
const cfg = STORE[serverId];
if (!cfg)
throw new Error(`Server '${serverId}' not found`);
const res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}/v3`, {
headers: { Authorization: `Bearer ${resolveToken(cfg.token)}` },
});
return {
content: [{ type: "text", text: `HTTP ${res.status} ${res.statusText}` }],
};
});
server.registerTool("rancher_kubeconfigs_merge", {
title: "Kubeconfig: merge multiple clusters",
description: "Concatenate generated kubeconfigs for a list of clusterIds",
inputSchema: z.object({
serverId: z.string(),
clusterIds: z.array(z.string()).nonempty(),
}).shape,
}, async ({ serverId, clusterIds, }) => {
const client = getClient(serverId);
const parts = await Promise.all(clusterIds.map((id) => client.generateKubeconfig(id)));
const merged = parts
.map((cfg, i) => `# --- kubeconfig #${i + 1} for ${clusterIds[i]} ---\n${cfg}\n`)
.join("\n");
return { content: [{ type: "text", text: merged }] };
});
// ---- Tools: Fleet (GitOps) on Rancher local cluster by default ----
async function fleetApi(serverId, path, init, clusterId = "local", rawOptions) {
const client = getClient(serverId);
const clean = path.startsWith("/") ? path : `/${path}`;
if (rawOptions) {
return client.k8sRaw({
clusterId,
path: clean,
method: rawOptions.method || "GET",
body: rawOptions.body,
contentType: rawOptions.contentType,
accept: rawOptions.accept,
limit: rawOptions.limit,
autoContinue: rawOptions.autoContinue,
maxPages: rawOptions.maxPages,
maxItems: rawOptions.maxItems,
stripManagedFields: rawOptions.stripManagedFields,
stripKeys: rawOptions.stripKeys,
});
}
return client.k8s(clusterId, clean, init);
}
server.registerTool("fleet_gitrepos_list", {
title: "Fleet: list GitRepos",
description: "GET /apis/fleet.cattle.io/v1alpha1/namespaces/{ns}/gitrepos",
inputSchema: z.object({
serverId: z.string(),
namespace: z.string().default("fleet-default"),
clusterId: z.string().default("local"),
limit: z.number().int().positive().optional(),
autoContinue: z.boolean().default(true),
maxPages: z.number().int().positive().optional(),
maxItems: z.number().int().positive().optional(),
accept: z.string().optional(),
stripManagedFields: z.boolean().default(true),
stripKeys: z.array(z.string()).optional(),
continueToken: z.string().optional(),
summary: z.boolean().default(true),
summaryFields: z.array(z.string()).optional(),
}).shape,
}, async ({ serverId, namespace, clusterId, limit, autoContinue, maxPages, maxItems, accept, stripManagedFields, stripKeys, continueToken, summary, summaryFields, }) => {
const path = buildPath(`/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos`, { continue: continueToken });
const data = await fleetApi(serverId, path, undefined, clusterId, {
method: "GET",
limit,
autoContinue,
maxPages,
maxItems,
accept,
stripManagedFields,
stripKeys,
});
const output = summary && typeof data === "object"
? {
items: Array.isArray(data?.items)
? data.items.map((repo) => summarizeFleetGitRepo(repo, summaryFields))
: [],
metadata: data?.metadata,
pageInfo: data?.pageInfo,
}
: data;
return {
content: [{ type: "text", text: toJsonText(output, stripManagedFields) }],
};
});
server.registerTool("fleet_gitrepos_get", {
title: "Fleet: get GitRepo",
description: "GET /apis/fleet.cattle.io/v1alpha1/namespaces/{ns}/gitrepos/{name}",
inputSchema: z.object({
serverId: z.string(),
namespace: z.string(),
name: z.string(),
clusterId: z.string().default("local"),
}).shape,
}, async ({ serverId, namespace, name, clusterId }) => {
const data = await fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos/${name}`, undefined, clusterId);
return { content: [{ type: "text", text: toJsonText(data) }] };
});
server.registerTool("fleet_gitrepos_create", {
title: "Fleet: create GitRepo",
description: "POST a GitRepo manifest (JSON)",
inputSchema: z.object({
serverId: z.string(),
clusterId: z.string().default("local"),
namespace: z.string().default("fleet-default"),
body: z.string().describe("GitRepo JSON manifest"),
}).shape,
}, async ({ serverId, clusterId, namespace, body }) => {
const res = await fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos`, {
method: "POST",
headers: { "content-type": "application/json" },
body,
}, clusterId);
return { content: [{ type: "text", text: toJsonText(res) }] };
});
server.registerTool("fleet_gitrepos_apply", {
title: "Fleet: apply GitRepo (Server-Side Apply)",
description: "PATCH application/apply-patch+yaml to GitRepo (idempotent)",
inputSchema: z.object({
serverId: z.string(),
namespace: z.string().default("fleet-default"),
name: z.string(),
manifestYaml: z
.string()
.describe("Full GitRepo manifest: apiVersion/kind/metadata/spec"),
fieldManager: z.string().default("mcp-rancher-multi"),
clusterId: z.string().default("local"),
}).shape,
}, async ({ serverId, namespace, name, manifestYaml, fieldManager, clusterId, }) => {
const params = new URLSearchParams({ fieldManager, force: "true" });
const res = await fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos/${name}?${params.toString()}`, {
method: "PATCH",
headers: { "content-type": "application/apply-patch+yaml" },
body: manifestYaml,
}, clusterId);
const text = typeof res === "string" ? res : toJsonText(res);
return { content: [{ type: "text", text }] };
});
server.registerTool("fleet_gitrepos_redeploy", {
title: "Fleet: force redeploy GitRepo",
description: "PATCH merge-patch: set metadata.annotations['fleet.cattle.io/redeployHash']",
inputSchema: z.object({
serverId: z.string(),
namespace: z.string().default("fleet-default"),
name: z.string(),
clusterId: z.string().default("local"),
}).shape,
}, async ({ serverId, namespace, name, clusterId }) => {
const hash = Math.random().toString(36).slice(2);
const body = JSON.stringify({
metadata: { annotations: { "fleet.cattle.io/redeployHash": hash } },
});
const res = await fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos/${name}`, {
method: "PATCH",
headers: { "content-type": "application/merge-patch+json" },
body,
}, clusterId);
return { content: [{ type: "text", text: toJsonText(res) }] };
});
server.registerTool("fleet_bdeploys_list", {
title: "Fleet: list BundleDeployments",
description: "GET /apis/fleet.cattle.io/v1alpha1/bundledeployments (optional labelSelector)",
inputSchema: z.object({
serverId: z.string(),
labelSelector: z.string().optional(),
clusterId: z.string().default("local"),
limit: z.number().int().positive().optional(),
autoContinue: z.boolean().default(true),
maxPages: z.number().int().positive().optional(),
maxItems: z.number().int().positive().optional(),
accept: z.string().optional(),
stripManagedFields: z.boolean().default(true),
stripKeys: z.array(z.string()).optional(),
continueToken: z.string().optional(),
summary: z.boolean().default(true),
summaryFields: z.array(z.string()).optional(),
}).shape,
}, async ({ serverId, labelSelector, clusterId, limit, autoContinue, maxPages, maxItems, accept, stripManagedFields, stripKeys, continueToken, summary, summaryFields, }) => {
const path = buildPath(`/apis/fleet.cattle.io/v1alpha1/bundledeployments`, {
labelSelector,
continue: continueToken,
});
const data = await fleetApi(serverId, path, undefined, clusterId, {
method: "GET",
limit,
autoContinue,
maxPages,
maxItems,
accept,
stripManagedFields,
stripKeys,
});
const output = summary && typeof data === "object"
? {
items: Array.isArray(data?.items)
? data.items.map((bd) => summarizeFleetBundleDeployment(bd, summaryFields))
: [],
metadata: data?.metadata,
pageInfo: data?.pageInfo,
}
: data;
return {
content: [{ type: "text", text: toJsonText(output, stripManagedFields) }],
};
});
server.registerTool("fleet_status_summary", {
title: "Fleet: status summary",
description: "Aggregate Ready/NonReady from BundleDeployments and link to GitRepos",
inputSchema: z.object({
serverId: z.string(),
namespace: z.string().default("fleet-default"),
clusterId: z.string().default("local"),
limit: z.number().int().positive().optional(),
autoContinue: z.boolean().default(true),
maxPages: z.number().int().positive().optional(),
maxItems: z.number().int().positive().optional(),
accept: z.string().optional(),
stripManagedFields: z.boolean().default(true),
stripKeys: z.array(z.string()).optional(),
continueGitRepos: z.string().optional(),
continueBundleDeployments: z.string().optional(),
}).shape,
}, async ({ serverId, namespace, clusterId, limit, autoContinue, maxPages, maxItems, accept, stripManagedFields, stripKeys, continueGitRepos, continueBundleDeployments, }) => {
const [repos, bds] = await Promise.all([
fleetApi(serverId, buildPath(`/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos`, { continue: continueGitRepos }), undefined, clusterId, {
method: "GET",
limit,
autoContinue,
maxPages,
maxItems,
accept,
stripManagedFields,
stripKeys,
}),
fleetApi(serverId, buildPath(`/apis/fleet.cattle.io/v1alpha1/bundledeployments`, {
continue: continueBundleDeployments,
}), undefined, clusterId, {
method: "GET",
limit,
autoContinue,
maxPages,
maxItems,
accept,
stripManagedFields,
stripKeys,
}),
]);
const out = {
gitrepos: repos?.items?.map((r) => ({
name: r?.metadata?.name,
namespace: r?.metadata?.namespace,
repo: r?.spec?.repo,
branch: r?.spec?.branch,
paths: r?.spec?.paths,
paused: r?.spec?.paused || false,
conditions: r?.status?.conditions || [],
})) || [],
bundleDeployments: bds?.items?.map((bd) => ({
name: bd?.metadata?.name,
namespace: bd?.metadata?.namespace,
summary: bd?.status?.summary,
ready: bd?.status?.ready,
nonReady: bd?.status?.nonReady,
desiredReady: bd?.status?.desiredReady,
display: bd?.status?.display,
})) || [],
};
return {
content: [{ type: "text", text: toJsonText(out, stripManagedFields) }],
};
});
// ---- Start server ----
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[mcp-rancher-multi] started. Store: ${JSON.stringify(obfuscateConfig(STORE), null, 2)}`);