@gorizond/mcp-rancher-multi
Version:
MCP server for multiple Rancher Manager backends with Fleet GitOps support
447 lines (408 loc) • 17 kB
text/typescript
// 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 { loadEnvFiles, loadConfigFromEnv, resolveToken, obfuscateConfig, stripMetadataManagedFields, type RancherServerConfig } from "./utils.js";
// ---- Load .env files first ----
loadEnvFiles();
// ---- Load configuration from environment ----
const STORE = loadConfigFromEnv();
// ---- Minimal Rancher client (v3 + k8s proxy) ----
class RancherClient {
baseUrl: string;
token: string;
insecure: boolean;
caCertPemBase64?: string;
constructor(cfg: RancherServerConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/$/, "");
this.token = resolveToken(cfg.token);
this.insecure = !!cfg.insecureSkipTlsVerify;
this.caCertPemBase64 = cfg.caCertPemBase64;
}
private headers(extra?: Record<string, string>): HeadersInit {
const h: Record<string, string> = {
"Authorization": `Bearer ${this.token}`,
"Accept": "application/json",
...extra,
};
return h;
}
private async requestJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: this.headers(init?.headers as Record<string, string> | undefined),
} as RequestInit);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}
${text}`);
}
return (await res.json()) as T;
}
async listClusters() {
type Cluster = { id: string; name?: string; state?: string; provider?: string; [k: string]: any };
type Result = { data: Cluster[] };
const url = `${this.baseUrl}/v3/clusters`;
const res = await this.requestJSON<Result>(url);
return res.data;
}
async listNodes(clusterId?: string) {
type Node = { id: string; nodeName?: string; clusterId?: string; state?: string; [k: string]: any };
type Result = { data: Node[] };
const p = clusterId ? `?clusterId=${encodeURIComponent(clusterId)}` : "";
const url = `${this.baseUrl}/v3/nodes${p}`;
const res = await this.requestJSON<Result>(url);
return res.data;
}
async listProjects(clusterId: string) {
type Project = { id: string; name?: string; clusterId?: string; [k: string]: any };
type Result = { data: Project[] };
const url = `${this.baseUrl}/v3/projects?clusterId=${encodeURIComponent(clusterId)}`;
const res = await this.requestJSON<Result>(url);
return res.data;
}
async generateKubeconfig(clusterId: string) {
type KubeResp = { config: string };
const url = `${this.baseUrl}/v3/clusters/${encodeURIComponent(clusterId)}?action=generateKubeconfig`;
const res = await this.requestJSON<KubeResp>(url, { method: "POST", headers: { "content-type": "application/json" } });
return res.config;
}
async k8s(clusterId: string, k8sPath: string, init?: RequestInit) {
const pathClean = k8sPath.startsWith("/") ? k8sPath : `/${k8sPath}`;
const url = `${this.baseUrl}/k8s/clusters/${encodeURIComponent(clusterId)}${pathClean}`;
const res = await fetch(url, {
...init,
headers: this.headers({ "Accept": "application/json", ...(init?.headers as any) }),
} as RequestInit);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`K8s proxy HTTP ${res.status} ${res.statusText} — ${url}
${text}`);
}
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) return res.json();
return res.text();
}
async listNamespaces(clusterId: string) {
const out = await this.k8s(clusterId, "/api/v1/namespaces");
return (out as any)?.items ?? out;
}
}
// ---- MCP server ----
const server = new McpServer({ name: "mcp-rancher-multi", version: "0.3.0" });
const toJsonText = (data: any) => JSON.stringify(stripMetadataManagedFields(data), null, 2);
function getClient(serverId: string) {
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: any) => {
const cfg: RancherServerConfig = { ...args } as any;
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 }: { id: string }) => {
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() }).shape
},
async ({ serverId }: { serverId: string }) => {
const client = getClient(serverId);
const data = await client.listClusters();
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 }: { serverId: string; clusterId: string }) => {
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 }: { serverId: string; clusterId?: string }) => {
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 }: { serverId: string; clusterId: string }) => {
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 }: { serverId: string; clusterId: string }) => {
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")
}).shape
},
async ({ serverId, clusterId, path: p, method, body, contentType }: any) => {
const client = getClient(serverId);
const init: RequestInit = { method, headers: { "content-type": contentType || "application/json" } };
if (body) (init as any).body = body;
const res = await client.k8s(clusterId, p, init);
const text = typeof res === "string" ? res : toJsonText(res);
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 }: { serverId: string }) => {
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 }: { serverId: string; clusterIds: string[] }) => {
const client = getClient(serverId);
const parts = await Promise.all(clusterIds.map((id: string) => 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: string, path: string, init?: RequestInit, clusterId = 'local') {
const client = getClient(serverId);
const clean = path.startsWith('/') ? path : `/${path}`;
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') }).shape
},
async ({ serverId, namespace, clusterId }: any) => {
const data = await fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos`, undefined, clusterId);
return { content: [{ type: 'text', text: toJsonText(data) }] };
}
);
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 }: any) => {
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 }: any) => {
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 }: any) => {
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 }: any) => {
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') }).shape
},
async ({ serverId, labelSelector, clusterId }: any) => {
const qs = labelSelector ? `?labelSelector=${encodeURIComponent(labelSelector)}` : '';
const data = await fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/bundledeployments${qs}`, undefined, clusterId);
return { content: [{ type: 'text', text: toJsonText(data) }] };
}
);
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') }).shape
},
async ({ serverId, namespace, clusterId }: any) => {
const [repos, bds] = await Promise.all([
fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/namespaces/${namespace}/gitrepos`, undefined, clusterId),
fleetApi(serverId, `/apis/fleet.cattle.io/v1alpha1/bundledeployments`, undefined, clusterId)
]);
const out = {
gitrepos: (repos as any)?.items?.map((r: any) => ({
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 as any)?.items?.map((bd: any) => ({
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) }] };
}
);
// ---- Start server ----
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[mcp-rancher-multi] started. Store: ${JSON.stringify(obfuscateConfig(STORE), null, 2)}`);