Skip to content

feat(foreman): MCP client for agents (Phase 1: HTTP transport, context7) - #1014

Merged
Defilan merged 16 commits into
defilantech:mainfrom
Defilan:feat/foreman-mcp-client
Jul 9, 2026
Merged

Defilan merged 16 commits into
defilantech:mainfrom
Defilan:feat/foreman-mcp-client

Conversation

@Defilan

@Defilan Defilan commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

What

Phase 1 of the Foreman MCP (Model Context Protocol) client: coder/reviewer
agents can now call read-only MCP tools (e.g. context7 docs lookup) at run
time. MCP tools appear in the agent's tool loop as ordinary tools named
mcp/<server>/<tool>, flowing through the same dispatch loop and transcript as
native tools. They are gated by their own per-server allowedTools list rather
than the Agent.spec.tools whitelist (which enumerates static native tools and
cannot name dynamically-discovered MCP tools).

New CRD surface (all optional, back-compatible):

kind: Agent
spec:
  mcp:
    enabled: true                 # per-agent opt-in; default off
    callTimeout: 30s
    maxResultBytes: 32768
    servers:
      - name: context7
        transport: http            # Phase 1: HTTP/streamable only
        url: https://mcp.context7.com/mcp
        headers:
          - name: Authorization
            valueFrom: { name: mcp-context7, key: api-key }
        allowedTools: ["*"]

Plus Workload.spec.mcpEnabled / AgenticTask.spec.mcpEnabled (a benchmark
opt-out: run the same coder with MCP off for an A/B control).

Why

Foreman agents hallucinate facts that live outside the model (metric names, API
groups, syntax): #409 invented a vLLM metric, #850 wrote the wrong API group,
#809 fabricated groundable behavior. An in-agent MCP client lets them consult
authoritative sources during a run and stop guessing.

Part of #1013 (this is Phase 1 of that epic; Brave search and native stdio
transport follow in later phases, so the epic stays open).

How

  • New package pkg/foreman/agent/mcp: a client wrapper over the official
    github.com/modelcontextprotocol/go-sdk (v1.6.1, HTTP/streamable transport) —
    the SDK is imported by exactly one file; a tool adapter turning discovered MCP
    tools into tools.Tools; a Connect entry point (dial → discover → filter by
    allowedTools → adapt); and a Register/BuildServers wiring helper.
  • Import-cycle-driven placement: mcp imports agent (for
    agent.ToolResult), so agent cannot import mcp. Registration therefore
    lives in the wiring layer cmd/foreman-agent/main.go (makeRegistryFactory),
    and only a workloadMCPEnabled bool + ctx are threaded through the
    RegistryFactory seam. The registry is assembled by filtering the native
    tools against Agent.spec.tools first, then adding the MCP tools onto the
    filtered set — so MCP tools bypass the native whitelist (they are gated by
    their own per-server allowedTools) and reach the model even when an agent's
    spec.tools lists only native tools.
  • Best-effort by construction: an unreachable server, a failed secret
    resolution, or an erroring/oversized tool result is logged and skipped or
    returned as a soft tool error — it never fails an AgenticTask run.
  • Safety: results are UTF-8-safe truncated to maxResultBytes; each call is
    bounded by callTimeout (and the dial handshake is bounded independently);
    auth headers come only from Secrets and are refused across cross-host
    redirects (so a redirect can't leak the token); MCP results are treated as
    untrusted model-facing data. Audit is the existing run transcript (MCP calls
    appear as normal mcp/<server>/<tool> tool calls).
  • Lifecycle: MCP sessions are closed when the per-run context is done.

Built and reviewed task-by-task (11 commits) with a whole-branch review pass;
the one substantive finding (an inert callTimeout) is fixed in the final
commit. Fleet A/B validation on #409/#850 is tracked as the next step under
#1013.

Assisted-by: Claude Code (Foreman's authoring harness; generated the
implementation and tests task-by-task under my direction — I reviewed every
task, adjudicated the review findings, and ran make test / make lint
locally before submitting).

Checklist

@Defilan Defilan added enhancement New feature or request area/foreman Foreman: the agentic fleet orchestrator add-on labels Jul 8, 2026
@codecov

codecov Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

@joryirving

Copy link
Copy Markdown
Collaborator

Code review

Found 1 issue:

  1. truncateUTF8 over-truncates and drops a whole rune whenever the byte cap lands on the last byte of a complete multibyte rune. The comment claims it cuts "to at most max bytes without splitting a multibyte rune," but it walks back past the full rune and then strips one more byte.

The first loop treats every trailing continuation byte as something to remove, but a continuation byte (0x80–0xBF) is also the last byte of a complete rune. So when s[:max] ends exactly on the final byte of a rune that fully fits, that rune is discarded, and the follow-up DecodeLastRuneInString then sees the orphaned leading byte and strips again. Verified against the implementation:

  • truncateUTF8("ééé", 4) → "é" (2 bytes); correct is "éé" (4 bytes, fits exactly)
  • truncateUTF8("ééé", 2) → "" (0 bytes); correct is "é" (2 bytes, fits exactly)
  • truncateUTF8("𐍈𐍈", 4) → ""; correct is "𐍈" (4 bytes, fits exactly)

The existing test only exercises MaxResultBytes: 11 (lands mid-rune), so the boundary case never triggers it. MCP results routinely contain multibyte UTF-8 (code comments, non-ASCII text, emoji), and truncation at the default MaxResultBytes is common, so this silently loses data in practice rather than just in theory.

A minimal fix: after cut := s[:max], if utf8.Valid(cut) is already true, return cut unchanged — only walk back to the previous RuneStart when cut is actually invalid UTF-8. Worth adding a table-driven test case where the cap lands on a complete rune boundary.

func truncateUTF8(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
cut := s[:max]
for len(cut) > 0 && !utf8.RuneStart(cut[len(cut)-1]) {
cut = cut[:len(cut)-1]
}
// cut now ends either on a complete rune or (if the very start of a
// multibyte rune was itself the byte we trimmed to) an incomplete
// leading byte; utf8.RuneStart only confirms the byte *begins* a
// rune, so verify the full rune actually fits before keeping it.
if len(cut) > 0 {
r, size := utf8.DecodeLastRuneInString(cut)
if r == utf8.RuneError && size == 1 {
cut = cut[:len(cut)-1]
}
}
return cut
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Defilan added a commit to Defilan/LLMKube that referenced this pull request Jul 9, 2026
truncateUTF8 walked back past a complete multibyte rune whenever the byte
cap landed on that rune's final byte: a continuation byte is also the last
byte of a fully-fitting rune, so the trailing-continuation-byte loop
stripped the whole rune and DecodeLastRuneInString then stripped one more.
It silently lost data on any MCP result that truncated on a rune boundary
(common: code comments, non-ASCII text, emoji), e.g. truncateUTF8("eee", 4)
returned "e" instead of "ee" for 2-byte runes.

Short-circuit when the cap already lands on a rune boundary
(utf8.ValidString) and keep the slice as-is; only walk back and drop the
trailing rune when the cap actually split one. Add a table-driven test
covering exact-boundary and split cases (reported by Jory on defilantech#1014).

Signed-off-by: Christopher Maher <chris@mahercode.io>
Defilan added 16 commits July 8, 2026 21:03
Signed-off-by: Christopher Maher <chris@mahercode.io>
Signed-off-by: Christopher Maher <chris@mahercode.io>
Signed-off-by: Christopher Maher <chris@mahercode.io>
Signed-off-by: Christopher Maher <chris@mahercode.io>
Signed-off-by: Christopher Maher <chris@mahercode.io>
…alidity

Signed-off-by: Christopher Maher <chris@mahercode.io>
…n-failure)

Wires ServerConfig -> dial -> listTools -> allowed-filter -> newTools into
a single entry point the Foreman wiring layer can call with a list of MCP
server configs. A server that fails to dial or list its tools is logged
at warning and skipped rather than failing the whole call; the returned
error is reserved for the one programmer error (nil ctx). The returned
closer joins best-effort Close() errors across every successfully
opened session.

Signed-off-by: Christopher Maher <chris@mahercode.io>
Signed-off-by: Christopher Maher <chris@mahercode.io>
Signed-off-by: Christopher Maher <chris@mahercode.io>
…y factory

Signed-off-by: Christopher Maher <chris@mahercode.io>
…hmark-gated)

Signed-off-by: Christopher Maher <chris@mahercode.io>
mcpTool.Execute passed the raw run ctx straight to callTool, so the
CRD-configured Options.CallTimeout was parsed and defaulted but never
enforced: a stalled MCP server could block a single tool call for the
duration of the whole run. Wrap the call in context.WithTimeout using
the already-defaulted CallTimeout.

Also bound client.go's dial handshake (Connect) with a 15s timeout so
a server that accepts a TCP connection but never completes the MCP
handshake can't hang registry build. Confirmed safe by reading the
go-sdk source: ListTools/CallTool each take a fresh per-call ctx, and
the connect ctx handed to the connection's read loop is wrapped in the
SDK's internal notDone{} so its cancellation can't affect later calls.

Signed-off-by: Christopher Maher <chris@mahercode.io>
…-host header leak

The Agent.spec.tools whitelist Filter is an allow-list intersection, and
MCP tool names (mcp/<server>/<tool>) are dynamic, so no real agent's
whitelist could ever name one -- Filter silently dropped every MCP tool
for every agent that set spec.tools, making the feature inert. Native
tools are now filtered first, then MCP tools are appended to the
already-filtered registry via a new Registry.Add, so they bypass the
whitelist by construction (they already carry their own per-server
allowedTools gate).

Also fixes three smaller issues an external review of the MCP client
turned up: the dial http.Client now refuses cross-host redirects so a
3xx response can never replay the secret-sourced auth header to a
different host; mcpTool.Execute now captures the tool-level isError flag
instead of discarding it, so a failed MCP call is distinguishable from a
successful one in both the record and the model-visible output; and
Register no longer hands back a nil closer when Connect hits its nil-ctx
programmer-error path.

Signed-off-by: Christopher Maher <chris@mahercode.io>
…not foreman-system

BuildServers resolved the MCP header secret from foremanNamespace (the
operator's namespace, default foreman-system), but the Agent CR and its
secrets live together in the agent's own namespace (e.g. default), exactly
like provider auth (apiKeySecretRef) resolves. Off-cluster metal agents run
with foreman-namespace=foreman-system, so an mcp-context7 secret in default
was never found: BuildServers silently skipped the server and the coder got
zero MCP tools. Resolve from ag.Namespace instead.

Signed-off-by: Christopher Maher <chris@mahercode.io>
Emit an Info line for every run showing mcpConfigPresent / mcpEnabled /
workloadMCPEnabled, plus a result line with serversConfigured / toolsAdded.
Diagnostic for the freeform-registers-MCP / issue-fix-silent gap: one run of
each kind will show which gate input differs.

Signed-off-by: Christopher Maher <chris@mahercode.io>
truncateUTF8 walked back past a complete multibyte rune whenever the byte
cap landed on that rune's final byte: a continuation byte is also the last
byte of a fully-fitting rune, so the trailing-continuation-byte loop
stripped the whole rune and DecodeLastRuneInString then stripped one more.
It silently lost data on any MCP result that truncated on a rune boundary
(common: code comments, non-ASCII text, emoji), e.g. truncateUTF8("eee", 4)
returned "e" instead of "ee" for 2-byte runes.

Short-circuit when the cap already lands on a rune boundary
(utf8.ValidString) and keep the slice as-is; only walk back and drop the
trailing rune when the cap actually split one. Add a table-driven test
covering exact-boundary and split cases (reported by Jory on defilantech#1014).

Signed-off-by: Christopher Maher <chris@mahercode.io>
@Defilan
Defilan force-pushed the feat/foreman-mcp-client branch from 613ab33 to 60eab17 Compare July 9, 2026 04:03
@Defilan
Defilan merged commit d34343f into defilantech:main Jul 9, 2026
24 checks passed
@github-actions github-actions Bot mentioned this pull request Jul 9, 2026
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jul 10, 2026
…1 ➔ 0.9.3) (#1489)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/defilantech/charts/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.1` → `0.9.3` |

---

### Release Notes

<details>
<summary>defilantech/LLMKube (ghcr.io/defilantech/charts/llmkube)</summary>

### [`v0.9.3`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#093-2026-07-09)

[Compare Source](defilantech/LLMKube@v0.9.2...v0.9.3)

##### Bug Fixes

- **controller:** drop invalid --enable-metrics flag from vLLM runtime ([#&#8203;1031](defilantech/LLMKube#1031)) ([9b9450d](defilantech/LLMKube@9b9450d))

### [`v0.9.2`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#092-2026-07-09)

[Compare Source](defilantech/LLMKube@v0.9.1...v0.9.2)

##### Features

- **foreman:** coder grounding rail (flag doc-contradicting metric writes) ([#&#8203;1017](defilantech/LLMKube#1017)) ([aa969ed](defilantech/LLMKube@aa969ed))
- **foreman:** distinct ALREADY-RESOLVED coder outcome ([#&#8203;970](defilantech/LLMKube#970)) ([#&#8203;1011](defilantech/LLMKube#1011)) ([8e041e0](defilantech/LLMKube@8e041e0))
- **foreman:** flag a GO that changes no functional production code ([#&#8203;1024](defilantech/LLMKube#1024)) ([3e1a6fb](defilantech/LLMKube@3e1a6fb))
- **foreman:** MCP client for agents (Phase 1: HTTP transport, context7) ([#&#8203;1014](defilantech/LLMKube#1014)) ([d34343f](defilantech/LLMKube@d34343f))
- **foreman:** semantic issueAsk verification instead of verbatim substring ([#&#8203;1020](defilantech/LLMKube#1020)) ([85d92ee](defilantech/LLMKube@85d92ee))
- **foreman:** str\_replace escalates to write\_file after repeated failures ([#&#8203;1026](defilantech/LLMKube#1026)) ([de1b8cb](defilantech/LLMKube@de1b8cb))

##### Bug Fixes

- **foreman:** exempt REJECT from grounded-finding demotion ([#&#8203;1008](defilantech/LLMKube#1008)) ([18098cb](defilantech/LLMKube@18098cb))
- **foreman:** gate Job downloads the go.mod toolchain (GOTOOLCHAIN=auto) ([#&#8203;1019](defilantech/LLMKube#1019)) ([5f4c51c](defilantech/LLMKube@5f4c51c))
- **foreman:** guard file-less findings + resolve bare/absolute paths in grounding rail ([#&#8203;1009](defilantech/LLMKube#1009)) ([304a2b8](defilantech/LLMKube@304a2b8))
- **foreman:** reap audit ConfigMaps older than retention ([#&#8203;990](defilantech/LLMKube#990)) ([#&#8203;1027](defilantech/LLMKube#1027)) ([08d06d6](defilantech/LLMKube@08d06d6))
- **foreman:** reviewer diffs against the upstream base, not stale local fork main ([#&#8203;1006](defilantech/LLMKube#1006)) ([6ef7278](defilantech/LLMKube@6ef7278))
- **metrics:** remove dead metrics + phantom PrometheusRule error-rate rule ([#&#8203;1010](defilantech/LLMKube#1010)) ([7c92f3f](defilantech/LLMKube@7c92f3f))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/1489
doonga pushed a commit to greyrock-labs/home-ops that referenced this pull request Jul 10, 2026
…mkube (0.9.1 ➔ 0.9.3) (#497)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/home-operations/charts-mirror/llmkube](https://github.com/defilantech/LLMKube) | patch | `0.9.1` → `0.9.3` |

---

### Release Notes

<details>
<summary>defilantech/LLMKube (ghcr.io/home-operations/charts-mirror/llmkube)</summary>

### [`v0.9.3`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#093-2026-07-09)

[Compare Source](defilantech/LLMKube@v0.9.2...v0.9.3)

##### Bug Fixes

- **controller:** drop invalid --enable-metrics flag from vLLM runtime ([#&#8203;1031](defilantech/LLMKube#1031)) ([9b9450d](defilantech/LLMKube@9b9450d))

### [`v0.9.2`](https://github.com/defilantech/LLMKube/blob/HEAD/CHANGELOG.md#092-2026-07-09)

[Compare Source](defilantech/LLMKube@v0.9.1...v0.9.2)

##### Features

- **foreman:** coder grounding rail (flag doc-contradicting metric writes) ([#&#8203;1017](defilantech/LLMKube#1017)) ([aa969ed](defilantech/LLMKube@aa969ed))
- **foreman:** distinct ALREADY-RESOLVED coder outcome ([#&#8203;970](defilantech/LLMKube#970)) ([#&#8203;1011](defilantech/LLMKube#1011)) ([8e041e0](defilantech/LLMKube@8e041e0))
- **foreman:** flag a GO that changes no functional production code ([#&#8203;1024](defilantech/LLMKube#1024)) ([3e1a6fb](defilantech/LLMKube@3e1a6fb))
- **foreman:** MCP client for agents (Phase 1: HTTP transport, context7) ([#&#8203;1014](defilantech/LLMKube#1014)) ([d34343f](defilantech/LLMKube@d34343f))
- **foreman:** semantic issueAsk verification instead of verbatim substring ([#&#8203;1020](defilantech/LLMKube#1020)) ([85d92ee](defilantech/LLMKube@85d92ee))
- **foreman:** str\_replace escalates to write\_file after repeated failures ([#&#8203;1026](defilantech/LLMKube#1026)) ([de1b8cb](defilantech/LLMKube@de1b8cb))

##### Bug Fixes

- **foreman:** exempt REJECT from grounded-finding demotion ([#&#8203;1008](defilantech/LLMKube#1008)) ([18098cb](defilantech/LLMKube@18098cb))
- **foreman:** gate Job downloads the go.mod toolchain (GOTOOLCHAIN=auto) ([#&#8203;1019](defilantech/LLMKube#1019)) ([5f4c51c](defilantech/LLMKube@5f4c51c))
- **foreman:** guard file-less findings + resolve bare/absolute paths in grounding rail ([#&#8203;1009](defilantech/LLMKube#1009)) ([304a2b8](defilantech/LLMKube@304a2b8))
- **foreman:** reap audit ConfigMaps older than retention ([#&#8203;990](defilantech/LLMKube#990)) ([#&#8203;1027](defilantech/LLMKube#1027)) ([08d06d6](defilantech/LLMKube@08d06d6))
- **foreman:** reviewer diffs against the upstream base, not stale local fork main ([#&#8203;1006](defilantech/LLMKube#1006)) ([6ef7278](defilantech/LLMKube@6ef7278))
- **metrics:** remove dead metrics + phantom PrometheusRule error-rate rule ([#&#8203;1010](defilantech/LLMKube#1010)) ([7c92f3f](defilantech/LLMKube@7c92f3f))

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yNTIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjI1Mi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.greyrock.io/greyrock-labs/home-ops/pulls/497
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/foreman Foreman: the agentic fleet orchestrator add-on enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants