Skip to content

feat(engine): surface connected MCP server instructions - #243

Open
andybons wants to merge 4 commits into
mainfrom
feat/mcp-server-instructions
Open

feat(engine): surface connected MCP server instructions#243
andybons wants to merge 4 commits into
mainfrom
feat/mcp-server-instructions

Conversation

@andybons

@andybons andybons commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The problem

A native harness session never saw an MCP server's own usage guidance. engine/mcp.go kept the tool list from ListAllTools and discarded the InitializeResult, so InitializeResult.Instructions — the spec's channel for "how to use this server" text that no tool description carries — reached the model on the delegated claude-code lane only, which gets it from the CLI's own --mcp-config handling. The same box behaved differently depending on which lane served it, and a server's guidance had nowhere to live but the operator's append_system_prompt.

Both opencode and Claude Code surface server instructions; harness's native loop was the outlier, and inconsistent with harness's own claude-code lane.

The design

mcp.Client now keeps Instructions alongside the ServerInfo, protocol version, and capabilities it already kept — one field and one accessor, no signature changes. MCPManager.Instructions reports it per connected server with that server's namespaced tool names, mirroring Status (it never triggers a connect, and a server that set no text contributes nothing). renderMCPInstructions builds one system segment:

<mcp_instructions>
<server name="boxes-orchestration" tools="mcp__boxes-orchestration__spawn_box, ...">
Fleet orchestration over every box in the fleet.
</server>
</mcp_instructions>

Placement is a cache decision, and it is the whole design.

  • It goes in the system array, before the deferred-MCP catalog. This is the stable half of the MCP context; mcpCatalogSegment changes when tool search selects a deferred tool, so it stays last.
  • Session.mcpInstructionsSegment renders once and freezes. A system-array change invalidates the system and messages caches together (the tools cache survives), so re-rendering the block whenever a server's connection state changed would re-process the entire conversation on that turn. Liveness already has a channel that costs nothing to change — the ambient degraded-server block in mcp_status.go.
  • The cost of freezing is one case, documented at the call site: a server whose first attempt failed and whose background retry later succeeds contributes its tools but not its instructions for that session.

This is deliberately not opencode's connected-only-per-turn filter — that is the one part of their design that puts volatile state in the cached prefix. It matches how vercel-labs/fx splits the same problem (static context in the cached prefix, transient context in an uncached overlay).

Server text is untrusted: the renderer defangs the block's own markup, so a server cannot forge a sibling <server> element under another server's name — the same defense renderTaskNotifications applies to a child's result text. Entries are sorted in the renderer as well as in the manager, because a prompt-cache prefix must not depend on a caller's ordering.

Contract change

append_system_prompt said "environment facts… do not use it for tool instructions." With servers now stating their own usage, the rule widens to platform-owned facts and the platform policy that depends on them, keeping two hard exclusions: never project instructions (that is AGENTS.md), never tool shape. Updated in config.Config, engine.Config, docs/engine-request-cycle.md, and docs/deploy-modal.md, which also gain the byte-stability rule these segments always needed.

Verification

Unit coverage for rendering, the four "nothing to say" cases, markup neutralization, and the freeze (a server appearing and another dropping between turns leaves the segment byte-identical). An end-to-end test over the real HTTP transport proves the wire field is actually read — red-verified by dropping the assignment in mcp.Client.Initialize. A placement test drives two real prompts and proves the segment reaches the system array, in the right position, byte-identical on turn 2.

go test -race ./... green (22 packages), go vet clean.

Note for meetneptune/boxes

internal/api/mcp_orchestration.go's orchestrationInstructions currently reaches claude-code boxes only. Once this ships and the harness floor moves, the GitHub-events and fleet segments can move out of that repo's append_system_prompt and into the server's own instructions, where they are present only when the server is attached.

@andybons
andybons requested review from dpup and a lite review from Copilot and removed request for dpup September 2, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new instructions renderer and its tests currently allow (and don’t correctly exercise) empty/whitespace instruction entries, and MCPManager.Instructions() trims text despite documenting it as verbatim.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR surfaces MCP server–provided initialize.instructions text to the engine’s native session lane by storing it in mcp.Client, exposing it via MCPManager.Instructions(), and rendering a frozen <mcp_instructions> system segment early in the cached system prefix (ahead of the deferred MCP catalog) to keep prompt-cache bytes stable across turns.

Changes:

  • Persist MCP InitializeResult.Instructions in mcp.Client and expose it via an accessor.
  • Add MCPManager.Instructions() and a new engine/mcp_instructions.go renderer that emits a stable per-connected-server instructions system segment.
  • Update config/docs guidance for append_system_prompt (platform-owned, byte-stable segments) and add unit/e2e coverage for instruction surfacing and system placement.
File summaries
File Description
mcp/client.go Stores server initialize.instructions on successful initialize and exposes it via Client.Instructions().
engine/mcp.go Adds MCPServerInstructions and MCPManager.Instructions() to snapshot connected servers’ instruction text and tool names.
engine/mcp_test.go Extends the fake MCP HTTP server to optionally return initialize.instructions.
engine/mcp_instructions.go New renderer and session memoization for a frozen <mcp_instructions> system segment.
engine/mcp_instructions_test.go Tests for rendering, neutralization, freezing behavior, live transport reading, and system-array placement stability.
engine/engine.go Inserts the frozen MCP instructions segment into the system array before the deferred MCP catalog.
engine/AGENTS.md Documents the “system array byte-stable within a session” rule and references the new frozen segment pattern.
docs/engine-request-cycle.md Updates append_system_prompt contract and documents the connected MCP instructions segment behavior/placement.
docs/deploy-modal.md Aligns deploy guidance with the updated append_system_prompt contract and byte-stability rule.
config/config.go Updates AppendSystemPrompt documentation to reflect platform-owned facts/policy and byte-stability constraints.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread engine/mcp.go Outdated
Comment thread engine/mcp_instructions.go
Comment thread engine/mcp_instructions_test.go
andybons added a commit that referenced this pull request Sep 2, 2026
Three findings from the Copilot review on #243.

renderMCPInstructions documents "" when no connected server set any
instructions, but it rendered a <server> element for any entry it was
handed, blank Text included. MCPManager.Instructions filters those, so no
shipped caller hit it -- but this function takes the narrow
mcpInstructionsReader precisely so another registry can implement it, and
an unfiltered entry produced a block that says nothing at full
prompt-prefix cost. It now drops a blank entry and returns "" when none
survives, which makes the documented contract the function's own property
rather than a caller's.

MCPServerInstructions.Text was documented as InitializeResult.Instructions
verbatim while Instructions stored the TRIMMED value. Trimming is a
DISPLAY concern owned by the renderer, so Instructions now keeps the
server's own bytes and trims only for the blank check. The renderer trims
once, where the display form is decided. No rendered output changes.

TestRenderMCPInstructionsAbsentCases had a case named "connected servers
set no instructions" that passed entries: nil -- byte-identical to the
"no servers connected" case above it, so the behavior it named was never
exercised. It is replaced by two cases that pass a connected entry with
empty and with whitespace-only Text.

Red-verified: dropping the blank-entry filter fails both new cases with
an empty <server> element. go test -race ./engine/ passes.
@andybons
andybons requested a lite review from Copilot September 2, 2026 23:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Untrusted MCP server instructions are currently unbounded in the cached system-prompt prefix, which can cause excessive token/cost and context exhaustion; the segment should apply a size cap with a truncation marker.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread engine/mcp_instructions.go
A native session never saw an MCP server's own usage guidance. engine/
mcp.go kept the tool list from ListAllTools and threw away the
InitializeResult, so InitializeResult.Instructions -- the spec's channel
for "how to use this server" text that no tool description carries --
reached the model on the delegated claude-code lane only, which gets it
from the CLI's own --mcp-config handling. The same box behaved
differently depending on which lane served it, and a server's guidance
had no home but the operator's append_system_prompt.

mcp.Client now keeps Instructions alongside the ServerInfo, protocol
version, and capabilities it already kept. MCPManager.Instructions
reports it per CONNECTED server with that server's namespaced tool names,
mirroring Status. renderMCPInstructions builds one system segment:

  <mcp_instructions>
  <server name="..." tools="mcp__...__x, ...">text</server>
  </mcp_instructions>

Placement is a cache decision, and it is the whole design. The segment
sits in the SYSTEM array, before the deferred-MCP catalog: this is the
stable half of the MCP context, and the catalog changes when tool search
selects a tool. Session.mcpInstructionsSegment renders once and freezes.
A system-array change invalidates the system and messages caches
together, so re-rendering when a server's state changes re-processes the
whole conversation. Live state keeps its own channel that costs nothing
to change (mcpStatusSegment). The cost of freezing is one case: a server
whose first attempt failed and whose retry later succeeds contributes
tools but not instructions for that session.

Server text is untrusted. The renderer defangs the block's own markup, so
a server cannot forge a sibling <server> element under another server's
name -- the defense renderTaskNotifications already applies to a child's
result text. Entries are sorted in the renderer as well as the manager: a
prompt-cache prefix must not depend on a caller's ordering.

The contract for append_system_prompt widens to match, in config.Config,
engine.Config, and both docs: platform-owned facts AND the platform
policy that depends on them, never project instructions, never tool shape
-- a server now states its own usage itself. Both places also gain the
byte-stability rule these segments always needed.

Verification: unit coverage for rendering, the four absent cases, markup
neutralization, and the freeze; an end-to-end test over the real HTTP
transport proves the wire field is read (red-verified by dropping the
assignment in mcp.Client.Initialize); a placement test proves the segment
reaches the system array and is byte-identical on turn 2 after a server
drops off. go test -race ./... green.
Three findings from the Copilot review on #243.

renderMCPInstructions documents "" when no connected server set any
instructions, but it rendered a <server> element for any entry it was
handed, blank Text included. MCPManager.Instructions filters those, so no
shipped caller hit it -- but this function takes the narrow
mcpInstructionsReader precisely so another registry can implement it, and
an unfiltered entry produced a block that says nothing at full
prompt-prefix cost. It now drops a blank entry and returns "" when none
survives, which makes the documented contract the function's own property
rather than a caller's.

MCPServerInstructions.Text was documented as InitializeResult.Instructions
verbatim while Instructions stored the TRIMMED value. Trimming is a
DISPLAY concern owned by the renderer, so Instructions now keeps the
server's own bytes and trims only for the blank check. The renderer trims
once, where the display form is decided. No rendered output changes.

TestRenderMCPInstructionsAbsentCases had a case named "connected servers
set no instructions" that passed entries: nil -- byte-identical to the
"no servers connected" case above it, so the behavior it named was never
exercised. It is replaced by two cases that pass a connected entry with
empty and with whitespace-only Text.

Red-verified: dropping the blank-entry filter fails both new cases with
an empty <server> element. go test -race ./engine/ passes.
CI runs against the merge with main, and main has since added
TestEverySessionFieldIsClassifiedForSnapshotting. This branch adds
Session.mcpInstrSeg and Session.mcpInstrLoaded, so the merge failed that
guard even though both commits pass on their own: the test requires every
Session field to be named in snapshottedSessionFields or in
snapshotExcludedSessionFields with a reason.

Both are excluded. They are a lazy render-once cache of the connected-MCP-
server instructions block, rendered on first use from the live registry
rather than replayed from the journal -- the same pattern the sibling
instrLoaded/instrSeg/instrErr/instrPath entries are already excluded for.
Snapshotting them would persist a rendering of registry state a reload
re-derives anyway.

Also rebases onto main, which is how the failure surfaced: the branch was
six commits behind and the guard did not exist when its first CI run
passed.

Verification: go test -race ./engine/ and go build/vet ./... pass.
@andybons
andybons force-pushed the feat/mcp-server-instructions branch from 27eb5f4 to b991511 Compare September 2, 2026 23:26
A Copilot finding on #243. A server's instructions text is UNTRUSTED --
it arrives from whatever process the server config points at -- and it was
spliced into the block unbounded.

That matters more here than it would in a message. This block is a SYSTEM
segment, written to the prompt cache once and re-read for every turn of
the session, so a verbose or hostile server does not cost one large
message: it inflates the cached prefix for the whole session, and far
enough out it crowds the context window the conversation needs. The text
is now capped at mcpInstructionsPerServerCap runes through capRunes, which
appends taskLogTruncationMarker, so a model reading truncated guidance can
tell that it is truncated.

The cap is PER SERVER because the two inputs have different trust: the
text is remote and unbounded, while the SET of servers is operator
configuration in this box's own harness.json. A per-block cap would let
one verbose server silently swallow a later server's guidance; per-server
bounds the total at cap x servers, which the operator already controls.
4000 matches taskNotificationResultCap, the same bound this engine already
applies to a child's untrusted Result text, and it sits well above what
real servers send.

Red-verified: removing the capRunes call fails
TestRenderMCPInstructionsCapsServerText, which also asserts a server
inside the cap is passed through unmarked and untrimmed.
go test -race ./engine/ passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are cohesive, include defensive handling for untrusted server text (neutralization + caps), and are backed by targeted unit and end-to-end tests covering the new behavior and cache-stability guarantees.

Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants