UNPKG

@gorizond/mcp-rancher-multi

Version:

MCP server for multiple Rancher Manager backends with Fleet GitOps support

333 lines (332 loc) 12.9 kB
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; } }