@gorizond/mcp-rancher-multi
Version:
MCP server for multiple Rancher Manager backends with Fleet GitOps support
333 lines (332 loc) • 12.9 kB
JavaScript
import { pickFields } from "./formatters.js";
import { resolveToken, stripKeys, stripMetadataManagedFields, } from "./utils.js";
const MAX_ERROR_BODY = 4000;
function truncateBody(body, maxLen = MAX_ERROR_BODY) {
if (!body)
return "";
if (body.length <= maxLen)
return body;
return `${body.slice(0, maxLen)}\n...truncated (${body.length - maxLen} more chars)`;
}
function normalizePath(path) {
return path.startsWith("/") ? path : `/${path}`;
}
function ensureLimit(path, limit) {
if (!limit)
return normalizePath(path);
const url = new URL(normalizePath(path), "http://dummy");
if (!url.searchParams.has("limit")) {
url.searchParams.set("limit", String(limit));
}
return `${url.pathname}${url.search}`;
}
const DEFAULT_CLUSTER_FIELDS = ["id", "name"];
function isAbsoluteUrl(pathOrUrl) {
return /^https?:\/\//i.test(pathOrUrl);
}
function ensureLimitUrl(url, limit) {
if (!limit)
return url;
const u = new URL(url);
if (!u.searchParams.has("limit")) {
u.searchParams.set("limit", String(limit));
}
return u.toString();
}
function stripContinueParam(path) {
const url = new URL(normalizePath(path), "http://dummy");
url.searchParams.delete("continue");
return `${url.pathname}${url.search}`;
}
function withContinue(path, token) {
if (!token)
return normalizePath(path);
const url = new URL(normalizePath(path), "http://dummy");
url.searchParams.set("continue", token);
return `${url.pathname}${url.search}`;
}
// Minimal Rancher client (v3 + k8s proxy)
export class RancherClient {
baseUrl;
token;
insecure;
caCertPemBase64;
constructor(cfg) {
this.baseUrl = cfg.baseUrl.replace(/\/$/, "");
this.token = resolveToken(cfg.token);
this.insecure = !!cfg.insecureSkipTlsVerify;
this.caCertPemBase64 = cfg.caCertPemBase64;
}
toAbsoluteRancherUrl(pathOrUrl) {
if (isAbsoluteUrl(pathOrUrl))
return pathOrUrl;
const clean = normalizePath(pathOrUrl);
return `${this.baseUrl}${clean}`;
}
headers(extra) {
const h = {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
...extra,
};
return h;
}
async requestJSON(url, init) {
const res = await fetch(url, {
...init,
headers: this.headers(init?.headers),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText} — ${url}
${truncateBody(text)}`);
}
return (await res.json());
}
async listClusters(options = {}) {
const { summary = true, summaryFields, stripKeys: keysToStrip = [], limit, autoContinue = false, maxPages, maxItems, continueToken, } = options;
const startPath = continueToken || "/v3/clusters";
const initialUrl = ensureLimitUrl(this.toAbsoluteRancherUrl(startPath), limit);
if (autoContinue) {
return this.paginateClusterList(initialUrl, {
summary,
summaryFields,
stripKeys: keysToStrip,
maxItems,
maxPages,
});
}
const res = await this.requestJSON(initialUrl);
const page = this.sanitizeClusterPage(res, {
summary,
summaryFields,
stripKeys: keysToStrip,
});
return page.data;
}
async listNodes(clusterId) {
const p = clusterId ? `?clusterId=${encodeURIComponent(clusterId)}` : "";
const url = `${this.baseUrl}/v3/nodes${p}`;
const res = await this.requestJSON(url);
return res.data;
}
async getCluster(id, options = {}) {
const { summary = false, stripKeys: keysToStrip = [] } = options;
const url = `${this.baseUrl}/v3/clusters/${encodeURIComponent(id)}`;
const res = await this.requestJSON(url);
return this.sanitizeCluster(res, { summary, stripKeys: keysToStrip });
}
async listProjects(clusterId) {
const url = `${this.baseUrl}/v3/projects?clusterId=${encodeURIComponent(clusterId)}`;
const res = await this.requestJSON(url);
return res.data;
}
async generateKubeconfig(clusterId) {
const url = `${this.baseUrl}/v3/clusters/${encodeURIComponent(clusterId)}?action=generateKubeconfig`;
const res = await this.requestJSON(url, {
method: "POST",
headers: { "content-type": "application/json" },
});
return res.config;
}
async k8s(clusterId, k8sPath, init) {
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,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`K8s proxy HTTP ${res.status} ${res.statusText} — ${url}
${truncateBody(text)}`);
}
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json"))
return res.json();
return res.text();
}
async k8sRaw(options) {
const { clusterId, path, method = "GET", body, contentType = "application/json", accept, limit, autoContinue = false, maxPages, maxItems, stripManagedFields = true, stripKeys: keysToStrip = [], } = options;
const headers = {
Accept: accept || "application/json",
};
const methodUpper = method.toUpperCase();
const hasBody = body !== undefined && body !== null && body !== "";
if (methodUpper !== "GET" || hasBody) {
headers["content-type"] = contentType || "application/json";
}
const init = { method: methodUpper, headers };
if (hasBody)
init.body = body;
const pathWithLimit = ensureLimit(path, limit);
if (methodUpper === "GET" && autoContinue) {
return this.paginateK8sList(clusterId, pathWithLimit, init, {
maxPages,
maxItems,
stripManagedFields,
stripKeys: keysToStrip,
});
}
const res = await this.k8s(clusterId, pathWithLimit, init);
return this.sanitizeResult(res, {
stripManagedFields,
stripKeys: keysToStrip,
});
}
async paginateK8sList(clusterId, path, init, opts) {
const { maxPages, maxItems, stripManagedFields = true, stripKeys: keysToStrip = [], } = opts;
const basePath = stripContinueParam(path);
let nextPath = path;
let pages = 0;
let items = [];
let firstPage = null;
let lastMetadata = null;
let nextContinue;
while (true) {
const page = await this.k8s(clusterId, nextPath, init);
if (!page || typeof page !== "object" || !("items" in page)) {
return this.sanitizeResult(page, {
stripManagedFields,
stripKeys: keysToStrip,
});
}
if (!firstPage)
firstPage = page;
pages += 1;
const pageItems = Array.isArray(page.items)
? page.items
: [];
const remaining = typeof maxItems === "number"
? Math.max(maxItems - items.length, 0)
: undefined;
const toAdd = typeof remaining === "number"
? pageItems.slice(0, remaining)
: pageItems;
items.push(...toAdd);
lastMetadata = page.metadata;
nextContinue = page?.metadata?.continue;
const hitMaxItems = typeof maxItems === "number" && items.length >= maxItems;
const hitMaxPages = typeof maxPages === "number" && pages >= maxPages;
if (!nextContinue || hitMaxItems || hitMaxPages || toAdd.length === 0)
break;
nextPath = withContinue(basePath, nextContinue);
}
const result = {
...(firstPage && typeof firstPage === "object" ? firstPage : {}),
items,
metadata: {
...(lastMetadata && typeof lastMetadata === "object"
? lastMetadata
: {}),
continue: nextContinue,
},
pageInfo: {
pages,
itemsCollected: items.length,
maxPages: maxPages ?? null,
maxItems: maxItems ?? null,
},
};
return this.sanitizeResult(result, {
stripManagedFields,
stripKeys: keysToStrip,
});
}
sanitizeClusterPage(res, opts) {
const items = Array.isArray(res?.data) ? res.data : [];
const sanitized = items.map((c) => this.sanitizeCluster({ ...(c || {}) }, opts));
return { data: sanitized, pagination: res?.pagination };
}
async paginateClusterList(url, opts) {
let nextUrl = url;
let pages = 0;
let items = [];
let lastPagination;
let nextToken;
while (nextUrl) {
const res = await this.requestJSON(nextUrl);
const page = this.sanitizeClusterPage(res, opts);
const remaining = typeof opts.maxItems === "number"
? Math.max(opts.maxItems - items.length, 0)
: undefined;
const toAdd = typeof remaining === "number"
? page.data.slice(0, remaining)
: page.data;
items.push(...toAdd);
pages += 1;
lastPagination = page.pagination;
nextToken = page.pagination?.next;
const hitMaxItems = typeof opts.maxItems === "number" && items.length >= opts.maxItems;
const hitMaxPages = typeof opts.maxPages === "number" && pages >= opts.maxPages;
const exhausted = !nextToken || hitMaxItems || hitMaxPages || toAdd.length === 0;
if (exhausted)
break;
nextUrl = nextToken
? isAbsoluteUrl(nextToken)
? nextToken
: this.toAbsoluteRancherUrl(nextToken)
: undefined;
}
return {
data: items,
pagination: lastPagination
? { ...lastPagination, next: nextToken }
: { next: nextToken },
pageInfo: {
pages,
itemsCollected: items.length,
maxPages: opts.maxPages ?? null,
maxItems: opts.maxItems ?? null,
},
};
}
sanitizeResult(value, opts) {
const { stripManagedFields = true, stripKeys: keysToStrip = [] } = opts;
if (stripManagedFields)
stripMetadataManagedFields(value);
if (keysToStrip.length)
stripKeys(value, keysToStrip);
return value;
}
sanitizeCluster(cluster, opts) {
const { summary = false, summaryFields, stripKeys: keysToStrip = [], } = opts;
if (keysToStrip.length)
stripKeys(cluster, keysToStrip);
if (!summary)
return cluster;
const workspace = cluster?.fleetWorkspaceName ||
cluster?.annotations?.["fleet.cattle.io/workspace-name"] ||
cluster?.labels?.["fleet.cattle.io/workspace-name"] ||
cluster?.annotations?.["management.cattle.io/cluster-template-workspace-name"];
const fleetStatus = cluster?.status?.fleet || {
agent: cluster?.annotations?.["fleet.cattle.io/agent-state"],
clusterNamespace: cluster?.annotations?.["fleet.cattle.io/cluster-namespace"],
};
const summaryValue = {
id: cluster?.id,
name: cluster?.name,
state: cluster?.state,
provider: cluster?.provider || cluster?.driver,
created: cluster?.created,
workspace,
fleet: fleetStatus,
kubeVersion: cluster?.kubernetesVersion,
agentImage: cluster?.agentImage,
ready: cluster?.ready,
transitioning: cluster?.transitioning,
transitioningMessage: cluster?.transitioningMessage,
};
const fieldsToPick = summaryFields && summaryFields.length
? summaryFields
: DEFAULT_CLUSTER_FIELDS;
return pickFields(summaryValue, fieldsToPick);
}
async listNamespaces(clusterId) {
const out = await this.k8s(clusterId, "/api/v1/namespaces");
return out?.items ?? out;
}
}