Add MCP servers (Gitea, K8s, Postgres, filesystem, git) to harness agents
Some checks failed
CI / lint-and-test (push) Successful in 36s
Deploy Production / deploy (push) Failing after 40s
CI / build (push) Failing after 59s

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).
This commit is contained in:
Julia McGhee
2026-03-21 20:55:19 +00:00
parent a5ef56b052
commit 620fbc6b83
9 changed files with 192 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<string, string>;
}
interface ClaudeProjectSettings {
permissions: {
allow: string[];
deny: string[];
};
mcpServers: Record<string, McpServerDef>;
}
async function buildMcpServers(workDir: string): Promise<Record<string, McpServerDef>> {
const servers: Record<string, McpServerDef> = {};
// ── 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<void> {
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",
);
}