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).
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
14
apps/harness/k8s/base/harness-mcp-credentials-sealed.yaml
Normal file
14
apps/harness/k8s/base/harness-mcp-credentials-sealed.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
35
apps/harness/k8s/base/rbac.yaml
Normal file
35
apps/harness/k8s/base/rbac.yaml
Normal 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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
93
apps/harness/src/lib/mcp-config.ts
Normal file
93
apps/harness/src/lib/mcp-config.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user