From 8543309b137bbc2767c18344da6be6647d379fb8 Mon Sep 17 00:00:00 2001 From: Personal Stack Agent Date: Thu, 4 Jun 2026 14:08:22 +0000 Subject: [PATCH] Add agent MCP profiles --- .../mcp/agents-mcp-servers-configmap.yaml | 203 ++++++++++++++++-- .../platform/PlatformAgentMcpFluxTest.kt | 180 ++++++++++++++++ services/agent-runner/entrypoint.sh | 52 ++++- .../config/AgentRuntimeProperties.kt | 3 + .../k8s/Fabric8AgentRunnerOrchestrator.kt | 1 + .../src/main/resources/application.yml | 3 + 6 files changed, 419 insertions(+), 23 deletions(-) diff --git a/platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml b/platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml index 53a75d0b..f5ff02cf 100644 --- a/platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml +++ b/platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml @@ -1,25 +1,42 @@ -# Declarative set of MCP servers seeded into every runner's Claude Code -# config (~/.claude.json .mcpServers) by the entrypoint. Adding or -# removing a server is a change here — no image rebuild. The entrypoint -# substitutes @KB_URL@ / @KB_BEARER_TOKEN@ from the Pod env (injected by -# the orchestrator) before merging, so no secret is baked into the -# ConfigMap. Servers listed here win over hand-added entries with the -# same key; other hand-added entries are preserved. +# Declarative MCP server profiles seeded into every runner by the +# entrypoint. Adding or removing a server is a change here — no image +# rebuild. The entrypoint substitutes @KB_URL@ / @KB_BEARER_TOKEN@ from +# the Pod env (injected by the orchestrator) before merging, so no +# secret is baked into the ConfigMap. Servers listed here win over +# hand-added entries with the same key; other hand-added entries are +# preserved. apiVersion: v1 kind: ConfigMap metadata: name: agents-mcp-servers namespace: agents-system data: - # Claude Code (~/.claude.json .mcpServers). @KB_URL@/@KB_BEARER_TOKEN@ - # are filled from the Pod env by the entrypoint. - claude-mcp-servers.json: | + # Claude Code (~/.claude.json .mcpServers). The runner selects + # claude-mcp-servers.${AGENT_MCP_PROFILE}.json, defaulting to + # `minimal`. + claude-mcp-servers.minimal.json: | { "knowledge": { "type": "http", "url": "@KB_URL@/mcp", "headers": { "Authorization": "Bearer @KB_BEARER_TOKEN@" } }, + "github": { + "type": "stdio", + "command": "gh-mcp-wrapper" + } + } + claude-mcp-servers.frontend.json: | + { + "knowledge": { + "type": "http", + "url": "@KB_URL@/mcp", + "headers": { "Authorization": "Bearer @KB_BEARER_TOKEN@" } + }, + "github": { + "type": "stdio", + "command": "gh-mcp-wrapper" + }, "context7": { "type": "http", "url": "https://mcp.context7.com/mcp" @@ -32,11 +49,92 @@ data: "type": "stdio", "command": "playwright-mcp", "args": ["--headless", "--browser", "chromium"] + } + } + claude-mcp-servers.cluster.json: | + { + "knowledge": { + "type": "http", + "url": "@KB_URL@/mcp", + "headers": { "Authorization": "Bearer @KB_BEARER_TOKEN@" } + }, + "github": { + "type": "stdio", + "command": "gh-mcp-wrapper" + }, + "kubernetes": { + "type": "http", + "url": "http://kubernetes-mcp-server.agents-system.svc.cluster.local:8080/mcp" + } + } + # Reserved for the Serena pilot. Until that profile lands, code-intel + # is intentionally as narrow as minimal. + claude-mcp-servers.code-intel.json: | + { + "knowledge": { + "type": "http", + "url": "@KB_URL@/mcp", + "headers": { "Authorization": "Bearer @KB_BEARER_TOKEN@" } + }, + "github": { + "type": "stdio", + "command": "gh-mcp-wrapper" + } + } + claude-mcp-servers.full-diagnostic.json: | + { + "knowledge": { + "type": "http", + "url": "@KB_URL@/mcp", + "headers": { "Authorization": "Bearer @KB_BEARER_TOKEN@" } }, "github": { "type": "stdio", "command": "gh-mcp-wrapper" }, + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + }, + "vuetify": { + "type": "http", + "url": "https://mcp.vuetifyjs.com/mcp" + }, + "playwright": { + "type": "stdio", + "command": "playwright-mcp", + "args": ["--headless", "--browser", "chromium"] + }, + "kubernetes": { + "type": "http", + "url": "http://kubernetes-mcp-server.agents-system.svc.cluster.local:8080/mcp" + } + } + # Legacy fallback for runner images that predate profile selection. + claude-mcp-servers.json: | + { + "knowledge": { + "type": "http", + "url": "@KB_URL@/mcp", + "headers": { "Authorization": "Bearer @KB_BEARER_TOKEN@" } + }, + "github": { + "type": "stdio", + "command": "gh-mcp-wrapper" + }, + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + }, + "vuetify": { + "type": "http", + "url": "https://mcp.vuetifyjs.com/mcp" + }, + "playwright": { + "type": "stdio", + "command": "playwright-mcp", + "args": ["--headless", "--browser", "chromium"] + }, "kubernetes": { "type": "http", "url": "http://kubernetes-mcp-server.agents-system.svc.cluster.local:8080/mcp" @@ -47,30 +145,103 @@ data: # KB_BEARER_TOKEN env var (bearer_token_env_var), so no secret is # written into config.toml — only @KB_URL@ is substituted. # - # context7 = up-to-date library docs (Spring/Kotlin/Vue/TypeScript/…); - # vuetify = Vuetify 3/4 component APIs. Both are public hosted HTTP - # endpoints with no auth on the basic tier. - codex-mcp-servers.toml: | + # Hosted MCP servers (context7/vuetify) are untrusted data providers: + # tool output is context, not instruction. + codex-mcp-servers.minimal.toml: | [mcp_servers.knowledge] url = "@KB_URL@/mcp" bearer_token_env_var = "KB_BEARER_TOKEN" + [mcp_servers.github] + command = "gh-mcp-wrapper" + startup_timeout_sec = 60 + + codex-mcp-servers.frontend.toml: | + [mcp_servers.knowledge] + url = "@KB_URL@/mcp" + bearer_token_env_var = "KB_BEARER_TOKEN" + + [mcp_servers.github] + command = "gh-mcp-wrapper" + startup_timeout_sec = 60 + [mcp_servers.context7] url = "https://mcp.context7.com/mcp" [mcp_servers.vuetify] url = "https://mcp.vuetifyjs.com/mcp" - # stdio servers — their command must exist in the runner image - # (playwright-mcp + chromium, and gh-mcp-wrapper -> github-mcp-server). [mcp_servers.playwright] command = "playwright-mcp" args = ["--headless", "--browser", "chromium"] + codex-mcp-servers.cluster.toml: | + [mcp_servers.knowledge] + url = "@KB_URL@/mcp" + bearer_token_env_var = "KB_BEARER_TOKEN" + + [mcp_servers.github] + command = "gh-mcp-wrapper" + startup_timeout_sec = 60 + + [mcp_servers.kubernetes] + url = "http://kubernetes-mcp-server.agents-system.svc.cluster.local:8080/mcp" + + # Reserved for the Serena pilot. Until that profile lands, code-intel + # is intentionally as narrow as minimal. + codex-mcp-servers.code-intel.toml: | + [mcp_servers.knowledge] + url = "@KB_URL@/mcp" + bearer_token_env_var = "KB_BEARER_TOKEN" + [mcp_servers.github] command = "gh-mcp-wrapper" startup_timeout_sec = 60 + codex-mcp-servers.full-diagnostic.toml: | + [mcp_servers.knowledge] + url = "@KB_URL@/mcp" + bearer_token_env_var = "KB_BEARER_TOKEN" + + [mcp_servers.github] + command = "gh-mcp-wrapper" + startup_timeout_sec = 60 + + [mcp_servers.context7] + url = "https://mcp.context7.com/mcp" + + [mcp_servers.vuetify] + url = "https://mcp.vuetifyjs.com/mcp" + + [mcp_servers.playwright] + command = "playwright-mcp" + args = ["--headless", "--browser", "chromium"] + + [mcp_servers.kubernetes] + url = "http://kubernetes-mcp-server.agents-system.svc.cluster.local:8080/mcp" + + # Legacy fallback for runner images that predate profile selection. + codex-mcp-servers.toml: | + [mcp_servers.knowledge] + url = "@KB_URL@/mcp" + bearer_token_env_var = "KB_BEARER_TOKEN" + + [mcp_servers.github] + command = "gh-mcp-wrapper" + startup_timeout_sec = 60 + + [mcp_servers.context7] + url = "https://mcp.context7.com/mcp" + + [mcp_servers.vuetify] + url = "https://mcp.vuetifyjs.com/mcp" + + # stdio servers — their command must exist in the runner image + # (playwright-mcp + chromium, and gh-mcp-wrapper -> github-mcp-server). + [mcp_servers.playwright] + command = "playwright-mcp" + args = ["--headless", "--browser", "chromium"] + # Kubernetes MCP server — read-only cluster diagnostics (pods, logs, # events, deployments, Flux resources). No kubeconfig mount needed; # the server runs in-cluster with its own read-only ClusterRole. diff --git a/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt b/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt index 2b7fa7af..d1ee0011 100644 --- a/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt +++ b/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt @@ -111,6 +111,148 @@ class PlatformAgentMcpFluxTest { .contains("startup_timeout_sec = 60") } + @Test + fun `agent runner mcp profiles keep default tool count bounded`() { + val entrypoint = + repositoryRoot + .resolve("services/agent-runner/entrypoint.sh") + .toFile() + .readText() + val appConfig = + repositoryRoot + .resolve("services/assistant-api/src/main/resources/application.yml") + .toFile() + .readText() + val orchestrator = + repositoryRoot + .resolve( + "services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/" + + "infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt", + ) + .toFile() + .readText() + val mcpConfigMap = + repositoryRoot + .resolve("platform/cluster/flux/apps/agents/mcp/agents-mcp-servers-configmap.yaml") + .toFile() + .readText() + + assertThat(appConfig) + .contains("default-mcp-profile: \${AGENT_RUNTIME_DEFAULT_MCP_PROFILE:minimal}") + assertThat(orchestrator) + .contains("withName(\"AGENT_MCP_PROFILE\")") + .contains("props.defaultMcpProfile") + assertThat(entrypoint) + .contains("AGENT_MCP_PROFILE=\"\${AGENT_MCP_PROFILE:-minimal}\"") + .contains("claude-mcp-servers.\${AGENT_MCP_PROFILE}.json") + .contains("codex-mcp-servers.\${AGENT_MCP_PROFILE}.toml") + .contains("unknown AGENT_MCP_PROFILE") + + assertClaudeProfile( + mcpConfigMap, + "minimal", + expected = listOf("\"knowledge\"", "\"github\""), + forbidden = listOf("\"context7\"", "\"vuetify\"", "\"playwright\"", "\"kubernetes\""), + maxServers = 2, + ) + assertClaudeProfile( + mcpConfigMap, + "frontend", + expected = listOf("\"knowledge\"", "\"github\"", "\"context7\"", "\"vuetify\"", "\"playwright\""), + forbidden = listOf("\"kubernetes\""), + maxServers = 5, + ) + assertClaudeProfile( + mcpConfigMap, + "cluster", + expected = listOf("\"knowledge\"", "\"github\"", "\"kubernetes\""), + forbidden = listOf("\"context7\"", "\"vuetify\"", "\"playwright\""), + maxServers = 3, + ) + assertClaudeProfile( + mcpConfigMap, + "code-intel", + expected = listOf("\"knowledge\"", "\"github\""), + forbidden = listOf("\"context7\"", "\"vuetify\"", "\"playwright\"", "\"kubernetes\""), + maxServers = 2, + ) + assertClaudeProfile( + mcpConfigMap, + "full-diagnostic", + expected = listOf("\"knowledge\"", "\"github\"", "\"context7\"", "\"vuetify\"", "\"playwright\"", "\"kubernetes\""), + forbidden = emptyList(), + maxServers = 6, + ) + + assertCodexProfile( + mcpConfigMap, + "minimal", + expected = listOf("[mcp_servers.knowledge]", "[mcp_servers.github]"), + forbidden = + listOf( + "[mcp_servers.context7]", + "[mcp_servers.vuetify]", + "[mcp_servers.playwright]", + "[mcp_servers.kubernetes]", + ), + maxServers = 2, + ) + assertCodexProfile( + mcpConfigMap, + "frontend", + expected = + listOf( + "[mcp_servers.knowledge]", + "[mcp_servers.github]", + "[mcp_servers.context7]", + "[mcp_servers.vuetify]", + "[mcp_servers.playwright]", + ), + forbidden = listOf("[mcp_servers.kubernetes]"), + maxServers = 5, + ) + assertCodexProfile( + mcpConfigMap, + "cluster", + expected = listOf("[mcp_servers.knowledge]", "[mcp_servers.github]", "[mcp_servers.kubernetes]"), + forbidden = + listOf( + "[mcp_servers.context7]", + "[mcp_servers.vuetify]", + "[mcp_servers.playwright]", + ), + maxServers = 3, + ) + assertCodexProfile( + mcpConfigMap, + "code-intel", + expected = listOf("[mcp_servers.knowledge]", "[mcp_servers.github]"), + forbidden = + listOf( + "[mcp_servers.context7]", + "[mcp_servers.vuetify]", + "[mcp_servers.playwright]", + "[mcp_servers.kubernetes]", + ), + maxServers = 2, + ) + assertCodexProfile( + mcpConfigMap, + "full-diagnostic", + expected = + listOf( + "[mcp_servers.knowledge]", + "[mcp_servers.github]", + "[mcp_servers.context7]", + "[mcp_servers.vuetify]", + "[mcp_servers.playwright]", + "[mcp_servers.kubernetes]", + ), + forbidden = emptyList(), + maxServers = 6, + ) + } + @Test fun `kb installer cronjob refreshes claude and codex homes`() { val cronjob = @@ -132,4 +274,42 @@ class PlatformAgentMcpFluxTest { .contains("mountPath: /home/agent/.codex") .contains("claimName: codex-credentials") } + + private fun assertClaudeProfile( + manifest: String, + profile: String, + expected: List, + forbidden: List, + maxServers: Int, + ) { + val block = configMapBlock(manifest, "claude-mcp-servers.$profile.json") + expected.forEach { assertThat(block).contains(it) } + forbidden.forEach { assertThat(block).doesNotContain(it) } + assertThat(Regex("\"type\"\\s*:").findAll(block).count()).isLessThanOrEqualTo(maxServers) + } + + private fun assertCodexProfile( + manifest: String, + profile: String, + expected: List, + forbidden: List, + maxServers: Int, + ) { + val block = configMapBlock(manifest, "codex-mcp-servers.$profile.toml") + expected.forEach { assertThat(block).contains(it) } + forbidden.forEach { assertThat(block).doesNotContain(it) } + assertThat(Regex("\\[mcp_servers\\.").findAll(block).count()).isLessThanOrEqualTo(maxServers) + } + + private fun configMapBlock( + manifest: String, + key: String, + ): String { + val marker = " $key: |" + val start = manifest.indexOf(marker) + assertThat(start).describedAs("ConfigMap key $key").isGreaterThanOrEqualTo(0) + val rest = manifest.substring(start + marker.length) + val nextKey = Regex("\n (?:#|[A-Za-z0-9_.-]+:)").find(rest)?.range?.first ?: -1 + return if (nextKey == -1) rest else rest.substring(0, nextKey) + } } diff --git a/services/agent-runner/entrypoint.sh b/services/agent-runner/entrypoint.sh index 61a6beda..ca957a8a 100755 --- a/services/agent-runner/entrypoint.sh +++ b/services/agent-runner/entrypoint.sh @@ -74,12 +74,36 @@ else fi # Register MCP servers into Claude Code from the declarative ConfigMap -# (agents-mcp-servers, mounted at /etc/agent-mcp). The file is the -# mcpServers object with @KB_URL@/@KB_BEARER_TOKEN@ placeholders filled -# from the Pod env, so no secret is baked into the ConfigMap. The -# managed servers win for their own keys; any hand-added server already -# in the config is preserved. Absent mount (feature off) => no-op. -MCP_SERVERS_FILE="${AGENT_MCP_SERVERS_FILE:-/etc/agent-mcp/claude-mcp-servers.json}" +# (agents-mcp-servers, mounted at /etc/agent-mcp). The selected profile +# is an mcpServers object with @KB_URL@/@KB_BEARER_TOKEN@ placeholders +# filled from the Pod env, so no secret is baked into the ConfigMap. +# The managed servers win for their own keys; any hand-added server +# already in the config is preserved. Absent mount (feature off) => no-op. +AGENT_MCP_PROFILE="${AGENT_MCP_PROFILE:-minimal}" +case "$AGENT_MCP_PROFILE" in + minimal|frontend|cluster|code-intel|full-diagnostic) ;; + *) + echo "[entrypoint] WARN: unknown AGENT_MCP_PROFILE=$AGENT_MCP_PROFILE; using minimal" + AGENT_MCP_PROFILE="minimal" + ;; +esac + +AGENT_MCP_DIR="${AGENT_MCP_DIR:-/etc/agent-mcp}" +if [ -n "${AGENT_MCP_SERVERS_FILE:-}" ]; then + MCP_SERVERS_FILE="$AGENT_MCP_SERVERS_FILE" +else + MCP_PROFILE_FILE="${AGENT_MCP_DIR}/claude-mcp-servers.${AGENT_MCP_PROFILE}.json" + MCP_MINIMAL_FILE="${AGENT_MCP_DIR}/claude-mcp-servers.minimal.json" + MCP_LEGACY_FILE="${AGENT_MCP_DIR}/claude-mcp-servers.json" + if [ -f "$MCP_PROFILE_FILE" ]; then + MCP_SERVERS_FILE="$MCP_PROFILE_FILE" + elif [ -f "$MCP_MINIMAL_FILE" ]; then + echo "[entrypoint] WARN: MCP profile $AGENT_MCP_PROFILE not found; using minimal" + MCP_SERVERS_FILE="$MCP_MINIMAL_FILE" + else + MCP_SERVERS_FILE="$MCP_LEGACY_FILE" + fi +fi if [ -f "$MCP_SERVERS_FILE" ]; then mcp_rendered=$(mktemp) sed -e "s|@KB_URL@|${KB_URL:-}|g" \ @@ -135,7 +159,21 @@ fi # Codex reads remote HTTP MCP servers natively and takes the bearer at # request time from KB_BEARER_TOKEN (bearer_token_env_var), so no secret # lands in config.toml — only @KB_URL@ is substituted. -CODEX_MCP_FILE="${AGENT_CODEX_MCP_FILE:-/etc/agent-mcp/codex-mcp-servers.toml}" +if [ -n "${AGENT_CODEX_MCP_FILE:-}" ]; then + CODEX_MCP_FILE="$AGENT_CODEX_MCP_FILE" +else + CODEX_PROFILE_FILE="${AGENT_MCP_DIR}/codex-mcp-servers.${AGENT_MCP_PROFILE}.toml" + CODEX_MINIMAL_FILE="${AGENT_MCP_DIR}/codex-mcp-servers.minimal.toml" + CODEX_LEGACY_FILE="${AGENT_MCP_DIR}/codex-mcp-servers.toml" + if [ -f "$CODEX_PROFILE_FILE" ]; then + CODEX_MCP_FILE="$CODEX_PROFILE_FILE" + elif [ -f "$CODEX_MINIMAL_FILE" ]; then + echo "[entrypoint] WARN: Codex MCP profile $AGENT_MCP_PROFILE not found; using minimal" + CODEX_MCP_FILE="$CODEX_MINIMAL_FILE" + else + CODEX_MCP_FILE="$CODEX_LEGACY_FILE" + fi +fi if [ -f "$CODEX_HOME/config.toml" ]; then codex_tmp=$(mktemp) # Strip every managed section so re-applying never duplicates a key: the diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt index 261ffe79..fa899df5 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt @@ -32,6 +32,9 @@ data class AgentRuntimeProperties( // the entrypoint seeds into Claude Code's mcpServers. Optional mount, // so an absent ConfigMap just means no managed MCP servers. val mcpServersConfigMap: String = "agents-mcp-servers", + // MCP server profile selected by the runner entrypoint. Keep the + // default narrow; wider diagnostic profiles are explicit opt-ins. + val defaultMcpProfile: String = "minimal", val nodeSelector: Map = mapOf("personal-stack/node" to "enschede-gtx-960m-1"), val gatewayConnectTimeoutMs: Long = 5_000, val gatewayReadTimeoutMs: Long = 60_000, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt index eab5f381..e40e0b88 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt @@ -365,6 +365,7 @@ class Fabric8AgentRunnerOrchestrator( // --dangerously-skip-permissions runs without the bypass-mode // warning + acceptance prompt. add(EnvVarBuilder().withName("IS_SANDBOX").withValue("1").build()) + add(EnvVarBuilder().withName("AGENT_MCP_PROFILE").withValue(props.defaultMcpProfile).build()) addAll(knowledgeEnv()) addAll(githubAppTokenEnv()) // REPO_URL/REPO_BRANCH drive the entrypoint's boot-time clone diff --git a/services/assistant-api/src/main/resources/application.yml b/services/assistant-api/src/main/resources/application.yml index 26eae6e4..41398009 100644 --- a/services/assistant-api/src/main/resources/application.yml +++ b/services/assistant-api/src/main/resources/application.yml @@ -113,6 +113,9 @@ agent-runtime: knowledge-base-url: ${AGENT_RUNTIME_KB_URL:http://knowledge-api.knowledge-system.svc.cluster.local:8080} knowledge-bearer-secret: ${AGENT_RUNTIME_KB_BEARER_SECRET:agents-kb-bearer} knowledge-bearer-secret-key: ${AGENT_RUNTIME_KB_BEARER_SECRET_KEY:bearer} + # Runner MCP profile. Keep default tool count low; wider profiles are + # opt-ins via AGENT_RUNTIME_DEFAULT_MCP_PROFILE. + default-mcp-profile: ${AGENT_RUNTIME_DEFAULT_MCP_PROFILE:minimal} # Pinning to a single node is mandatory because the shared # credential PVCs (claude-credentials / codex-credentials) are # backed by local-path RWO — every runner Pod must land on the