From 620fbc6b839a28bde64da3ae665cfa5651d9cb2f Mon Sep 17 00:00:00 2001 From: Julia McGhee Date: Sat, 21 Mar 2026 20:55:19 +0000 Subject: [PATCH] Add MCP servers (Gitea, K8s, Postgres, filesystem, git) to harness agents Wire 5 MCP servers into Claude Code agents spawned by the harness: - Gitea MCP for repo/issue/PR management on self-hosted Gitea - Kubernetes MCP with read-only RBAC for cluster inspection - Postgres MCP with read-only user for database queries - Filesystem and Git MCP scoped to task worktrees Generates .claude/settings.json in each worktree before agent spawn. Gracefully skips for Codex/OpenCode runtimes (no MCP support). Also fixes node-pty build failure by using local Node.js headers instead of downloading from unofficial-builds.nodejs.org (ECONNRESET). --- apps/harness/Dockerfile | 16 ++++ apps/harness/k8s/base/deployment.yaml | 13 +++ .../base/harness-mcp-credentials-sealed.yaml | 14 +++ apps/harness/k8s/base/kustomization.yaml | 2 + apps/harness/k8s/base/rbac.yaml | 35 +++++++ apps/harness/src/lib/boot.ts | 12 +++ apps/harness/src/lib/credentials.ts | 4 +- apps/harness/src/lib/executor.ts | 4 + apps/harness/src/lib/mcp-config.ts | 93 +++++++++++++++++++ 9 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 apps/harness/k8s/base/harness-mcp-credentials-sealed.yaml create mode 100644 apps/harness/k8s/base/rbac.yaml create mode 100644 apps/harness/src/lib/mcp-config.ts diff --git a/apps/harness/Dockerfile b/apps/harness/Dockerfile index 6e2aa2f..e00c7fd 100644 --- a/apps/harness/Dockerfile +++ b/apps/harness/Dockerfile @@ -1,3 +1,6 @@ +FROM golang:1.22-alpine AS gitea-mcp-builder +RUN go install gitea.com/gitea/gitea-mcp@latest + FROM node:20-alpine AS base RUN corepack enable && corepack prepare pnpm@latest --activate @@ -5,6 +8,9 @@ FROM base AS deps RUN apk add --no-cache libc6-compat python3 make gcc g++ linux-headers WORKDIR /app +# Use local Node.js headers for node-gyp (avoids flaky unofficial-builds.nodejs.org downloads) +ENV npm_config_nodedir=/usr/local + # Copy workspace root config + relevant package.jsons for install COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ COPY packages/db/package.json ./packages/db/package.json @@ -31,6 +37,16 @@ RUN npm install -g @anthropic-ai/claude-code @openai/codex RUN curl -fsSL https://opencode.ai/install | sh || \ echo "WARN: opencode install failed, skipping" +# MCP servers: Gitea (Go binary from builder stage) +COPY --from=gitea-mcp-builder /root/go/bin/gitea-mcp /usr/local/bin/gitea-mcp + +# MCP servers: Node.js packages (pre-installed to avoid npx cold starts) +RUN npm install -g \ + @modelcontextprotocol/server-postgres \ + @modelcontextprotocol/server-filesystem \ + @modelcontextprotocol/server-git \ + @manusa/kubernetes-mcp-server + RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/apps/harness/k8s/base/deployment.yaml b/apps/harness/k8s/base/deployment.yaml index 4d075ff..6006b73 100644 --- a/apps/harness/k8s/base/deployment.yaml +++ b/apps/harness/k8s/base/deployment.yaml @@ -14,6 +14,7 @@ spec: labels: app: harness spec: + serviceAccountName: harness imagePullSecrets: - name: gitea-pull-secret containers: @@ -33,6 +34,18 @@ spec: secretKeyRef: name: harness-db-credentials key: database-url + - name: GITEA_TOKEN + valueFrom: + secretKeyRef: + name: harness-mcp-credentials + key: gitea-token + - name: GITEA_URL + value: "http://gitea.platform.svc:3000" + - name: HARNESS_PG_MCP_URL + valueFrom: + secretKeyRef: + name: harness-mcp-credentials + key: postgres-mcp-url volumeMounts: - name: workspace mountPath: /data/harness diff --git a/apps/harness/k8s/base/harness-mcp-credentials-sealed.yaml b/apps/harness/k8s/base/harness-mcp-credentials-sealed.yaml new file mode 100644 index 0000000..9340bc2 --- /dev/null +++ b/apps/harness/k8s/base/harness-mcp-credentials-sealed.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: harness-mcp-credentials + namespace: apps +spec: + encryptedData: + gitea-token: AgBOX7zAufZ4pGGL+dFLTbSlFHkoOhx+n+ER6ULV3Pq8BJEfZwJHBnJptBzpwOZwfLEEOTbY12pVzvEG6VNxzX7+WgGnPetqAyOaJ11SLgnDmpQahDximrO8iRsJMQgvGxouIfzprx57MxGoG5iSyYl+Y8eqzKN/Mz/cg01zySd3k+R2YWrFog1hY/NDx1iUmnahk7vgMSEMeTPJDgPgyLD7wQg0VJEZLQ6zejgNfAox1jDXhQTDJooKIsMYix/vjFvouzwx/dL2FEDa9UjLiHt6OJr8k5ZjMf1rUEJUdAWGFVYzXeN19FZXlpd/+roFSc0D1uwEpHoAjLK8poO3XSGDzBQyV0N4ilmDP32nM6WAud57+rT1iDZ5ZWsXDvsAYoUIENZblcnJsNg2s0GZS48yII55E15mNXgFvmzB233rlSMsSlnbwr9AiSKjudoftawGCRNRF8gtP+Jr7tO0gi0admB0IwqAU3GJnKBhnf83/mRAcT9yaIWqJp5vd3w+J+QcPrhnwfadnUJj+YyBdx1bPpBqJ5tnSory7p+UntcwIJHY5bf2UOAW+vxezW3r3ilAXbKsq3cQ3kTrHm4D9j7TACx8/FXEgO8TTC+BoNAvY9Hgcogx/d39JKJYoxa+618+P6hyHEC/+dxc8wPGLL61d3lTuHdckoN15Oc/YS5MymEEeoAzRCo8AhxJtZhYtYBhSoffLuzoBLQ8xz8xwa1xeCnZ+7nQUuFGebfR4oj2LkOBr3fAT6Pe + postgres-mcp-url: AgCQfK/hPK+l7GRfQkp0/HrlecMUvNg6OzYl/b6biIe6un5aljNJRipuZkuvbmgbJV/SYuUUILZ53IH0J1zPG4HKKiuZuCgtVWpRukAQPAY75pimDDx/1Cz08Vyb+EznMaCdyIxkgKEHfgu5ferVX+zJNMwvrW7mkrD6YSKpUoEtAGFoLfhGG/S+rqE2KP6+H0P8P8TNQUaqgeFM+aGps95FfmfEB1CTD/dfMEeiutHZF3NYMheVbFgcMqhe2OdEaF1ThAFVwNS1IlpewQ5xZNaVu/nncPn9MNx1MLrYEXD1dK8uCkVYho90jsSwpxRyjt8y21NJr1B8tsFi+JGOCL9lwXUdLTYm9jaq+5MjhzaPCx7QViD494TZyOHmoRq8N8Amuurvhd4DGbhhGAlcShHeXUWn8ViGAWBD9QvRQzghTQV2dfkx9xIaIXImG1yrbiXniF7x9XpHcns0tt9HmDw8yTnuBT7ftx0gyOY6bK3WdhZMNObvSYqTHiXfvLgjKqc0A/+Eta2YYfamJp/Oa7HFeawmcmshI+aAAW3ufT5OWZWe9SJ17YuVeVhgrakb/dHfvU1qjlzn3jzoWqf0YMYvV3GGQN+l1ej8aQm9We8VHAo4+dYqfbHT2yH8NuTyxDeVT4YiungDew/uzs2YMAiqKVSLUeJQN2ChjjIhNlE1c0941vG5/jyVSgVAlFAsOA51ayzZApvgBl3VAeadmGEUtx/wFm8JIer3lmh3VQlP/9tEoJ44idqObgHcaVeQ1BLYp9HpFlGQzbsttnBgm8tqZ8A5AFV73BHNu+JU/eDWm9VCVHObE1z3 + template: + metadata: + name: harness-mcp-credentials + namespace: apps diff --git a/apps/harness/k8s/base/kustomization.yaml b/apps/harness/k8s/base/kustomization.yaml index d239df6..d58fbe3 100644 --- a/apps/harness/k8s/base/kustomization.yaml +++ b/apps/harness/k8s/base/kustomization.yaml @@ -3,6 +3,8 @@ kind: Kustomization resources: - deployment.yaml - service.yaml + - rbac.yaml - harness-claude-credentials-sealed.yaml - harness-opencode-credentials-sealed.yaml - harness-db-credentials-sealed.yaml + - harness-mcp-credentials-sealed.yaml diff --git a/apps/harness/k8s/base/rbac.yaml b/apps/harness/k8s/base/rbac.yaml new file mode 100644 index 0000000..808a258 --- /dev/null +++ b/apps/harness/k8s/base/rbac.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: harness +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: harness-k8s-reader +rules: + - apiGroups: [""] + resources: ["pods", "pods/log", "services", "configmaps", "namespaces", "events"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments", "statefulsets", "daemonsets", "replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: harness-k8s-reader +subjects: + - kind: ServiceAccount + name: harness + namespace: apps +roleRef: + kind: ClusterRole + name: harness-k8s-reader + apiGroup: rbac.authorization.k8s.io diff --git a/apps/harness/src/lib/boot.ts b/apps/harness/src/lib/boot.ts index 3285e39..0a2b12a 100644 --- a/apps/harness/src/lib/boot.ts +++ b/apps/harness/src/lib/boot.ts @@ -82,6 +82,7 @@ async function loadCredentialsFromEnv() { ["GH_TOKEN", "github", "GitHub (env)"], ["GITLAB_TOKEN", "gitlab", "GitLab (env)"], ["GITEA_TOKEN", "gitea", "Gitea (env)"], + ["HARNESS_PG_MCP_URL", "postgres-mcp", "Postgres MCP (env)"], ]; for (const [envVar, provider, label] of envMap) { @@ -91,6 +92,17 @@ async function loadCredentialsFromEnv() { if (existing.length > 0) continue; await upsertCredential({ id: `env-${provider}`, provider, label, token }); } + + // Set Gitea baseUrl from GITEA_URL if the credential exists + const giteaUrl = process.env.GITEA_URL; + if (giteaUrl) { + const giteaCreds = await getRawCredentialsByProvider("gitea"); + for (const cred of giteaCreds) { + if (!cred.baseUrl) { + await upsertCredential({ ...cred, baseUrl: giteaUrl }); + } + } + } } async function loadClaudeCredentials() { diff --git a/apps/harness/src/lib/credentials.ts b/apps/harness/src/lib/credentials.ts index e1b8032..fbb4c42 100644 --- a/apps/harness/src/lib/credentials.ts +++ b/apps/harness/src/lib/credentials.ts @@ -4,10 +4,12 @@ import { credentials as credentialsTable } from "@homelab/db"; export type Provider = | "github" | "gitlab" | "gitea" - | "anthropic" | "openai" | "openrouter" | "google" | "opencode-zen" | "opencode-go"; + | "anthropic" | "openai" | "openrouter" | "google" | "opencode-zen" | "opencode-go" + | "postgres-mcp"; export const GIT_PROVIDERS: Provider[] = ["github", "gitlab", "gitea"]; export const AI_PROVIDERS: Provider[] = ["anthropic", "openai", "openrouter", "google", "opencode-zen", "opencode-go"]; +export const INFRA_PROVIDERS: Provider[] = ["postgres-mcp"]; export interface Credential { id: string; diff --git a/apps/harness/src/lib/executor.ts b/apps/harness/src/lib/executor.ts index 3011b14..006ea18 100644 --- a/apps/harness/src/lib/executor.ts +++ b/apps/harness/src/lib/executor.ts @@ -1,6 +1,7 @@ import { spawn, ChildProcess } from "node:child_process"; import { getAgentConfig, buildAgentCommand, AGENT_RUNTIMES } from "./agents"; import { buildAgentEnv } from "./agent-env"; +import { generateMcpConfig } from "./mcp-config"; import { ExecutionResult } from "./types"; const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes @@ -44,6 +45,9 @@ export async function executeAgent(opts: { const env = await buildAgentEnv(config); + // Write .claude/settings.json with MCP servers for claude-code agents + await generateMcpConfig(opts.workDir, config.runtime); + const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const startTime = Date.now(); diff --git a/apps/harness/src/lib/mcp-config.ts b/apps/harness/src/lib/mcp-config.ts new file mode 100644 index 0000000..1104402 --- /dev/null +++ b/apps/harness/src/lib/mcp-config.ts @@ -0,0 +1,93 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getRawCredentialsByProvider } from "./credentials"; +import type { AgentRuntime } from "./agents"; + +const DEFAULT_GITEA_URL = "http://gitea.platform.svc:3000"; + +interface McpServerDef { + command: string; + args: string[]; + env?: Record; +} + +interface ClaudeProjectSettings { + permissions: { + allow: string[]; + deny: string[]; + }; + mcpServers: Record; +} + +async function buildMcpServers(workDir: string): Promise> { + const servers: Record = {}; + + // ── Gitea: only if credential exists ── + const giteaCreds = await getRawCredentialsByProvider("gitea"); + if (giteaCreds.length > 0) { + const cred = giteaCreds[0]; + servers.gitea = { + command: "gitea-mcp", + args: ["stdio"], + env: { + GITEA_TOKEN: cred.token, + GITEA_URL: cred.baseUrl || DEFAULT_GITEA_URL, + }, + }; + } + + // ── Kubernetes: always (uses in-cluster SA token) ── + servers.kubernetes = { + command: "kubernetes-mcp-server", + args: [], + }; + + // ── Postgres: only if credential exists ── + const pgCreds = await getRawCredentialsByProvider("postgres-mcp"); + if (pgCreds.length > 0) { + servers.postgres = { + command: "mcp-server-postgres", + args: [pgCreds[0].token], // token holds the connection string + }; + } + + // ── Filesystem: always (scoped to worktree) ── + servers.filesystem = { + command: "mcp-server-filesystem", + args: [workDir], + }; + + // ── Git: always (scoped to worktree) ── + servers.git = { + command: "mcp-server-git", + args: ["--repository", workDir], + }; + + return servers; +} + +/** + * Generate .claude/settings.json in the worktree with MCP server configs. + * Only applies to claude-code runtime; other runtimes are a no-op. + */ +export async function generateMcpConfig(workDir: string, runtime: AgentRuntime): Promise { + if (runtime !== "claude-code") return; + + const mcpServers = await buildMcpServers(workDir); + const serverNames = Object.keys(mcpServers); + + const settings: ClaudeProjectSettings = { + permissions: { + allow: serverNames.map((name) => `mcp__${name}__*`), + deny: [], + }, + mcpServers, + }; + + const claudeDir = join(workDir, ".claude"); + await mkdir(claudeDir, { recursive: true }); + await writeFile( + join(claudeDir, "settings.json"), + JSON.stringify(settings, null, 2) + "\n", + ); +}