Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ const (
EnvProcOOMScore = "CODER_PROC_OOM_SCORE"
)

// mcpFirstSyncTimeout bounds how long the startup goroutine waits for
// the first MCP catalog sync before releasing the context gate. The
// reload keeps running in the background on timeout, and late-settling
// servers still re-push via the catalog-change callback.
const mcpFirstSyncTimeout = 30 * time.Second

var ErrAgentClosing = xerrors.New("agent is closing")

type Options struct {
Expand Down Expand Up @@ -1552,19 +1558,24 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
a.metrics.startupScriptSeconds.WithLabelValues(label).Set(dur)
a.scriptRunner.StartCron()

// Startup finished (success or terminal failure): release
// the context gate. MCP servers connect below and
// re-trigger a push once up, so we don't block readiness
// on them.
a.contextManager.SetReady()

// Connect to workspace MCP servers after the
// lifecycle transition to avoid delaying Ready.
// This runs inside the tracked goroutine so it
// is properly awaited on shutdown.
if mcpErr := a.mcpManager.Reload(a.gracefulCtx, a.contextConfigAPI.MCPConfigFiles()); mcpErr != nil {
// Connect to workspace MCP servers before releasing the
// context gate so the first context push already contains
// the MCP catalog. The wait is bounded: if the catalog has
// not settled within mcpFirstSyncTimeout, the gate is
// released anyway while the reload keeps running in the
// background. Lifecycle Ready was reported above and is
// not delayed by this wait. This runs inside the tracked
// goroutine so it is properly awaited on shutdown.
if mcpErr := a.mcpManager.ReloadWithTimeout(a.gracefulCtx, a.contextConfigAPI.MCPConfigFiles(), mcpFirstSyncTimeout); mcpErr != nil {
a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(mcpErr))
}

// Startup finished (success or terminal failure): release
// the context gate so the first context snapshot resolves
// and is pushed. Servers that settle after the bounded
// wait above still re-trigger a push via the MCP manager's
// catalog-change callback.
a.contextManager.SetReady()
})
if err != nil {
return xerrors.Errorf("track conn goroutine: %w", err)
Expand Down
147 changes: 147 additions & 0 deletions agent/agent_context_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package agent_test

import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -68,3 +71,147 @@ func TestAgent_ContextStatePushed(t *testing.T) {
assert.False(t, p.GetInitial(), "only the first push must be Initial")
}
}

// TestAgent_ContextStateFirstPushIncludesMCP verifies the context gate
// waits for the MCP catalog to settle before the first push: the
// initial PushContextState (Initial=true) already contains the
// configured MCP server resource with its tools instead of relying on
// a follow-up push after the servers connect.
func TestAgent_ContextStateFirstPushIncludesMCP(t *testing.T) {
t.Parallel()

if os.Getenv("TEST_MCP_FAKE_SERVER") == "1" {
// Child process: act as a minimal MCP server over stdio.
runFakeMCPStdioServer()
return
}

dir := t.TempDir()

// Point the default .mcp.json at a fake MCP server using the
// test binary re-exec pattern: the agent spawns this test
// binary, which detects TEST_MCP_FAKE_SERVER and serves MCP
// over stdio instead of running tests.
testBin, err := os.Executable()
require.NoError(t, err)
mcpConfig := map[string]any{
"mcpServers": map[string]any{
"srv": map[string]any{
"command": testBin,
"args": []string{"-test.run=^TestAgent_ContextStateFirstPushIncludesMCP$"},
"env": map[string]string{"TEST_MCP_FAKE_SERVER": "1"},
},
},
}
data, err := json.Marshal(mcpConfig)
require.NoError(t, err)
require.NoError(t,
os.WriteFile(filepath.Join(dir, ".mcp.json"), data, 0o600))

//nolint:dogsled // setupAgent returns a wide tuple; we only care about the client.
_, client, _, _, _ := setupAgent(t,
agentsdk.Manifest{Directory: dir},
0,
func(_ *agenttest.Client, opts *agent.Options) {
opts.ContextConfig = agentcontextconfig.Config{}
},
)

var pushes []*agentproto.PushContextStateRequest
require.Eventually(t, func() bool {
pushes = client.ContextStatePushes()
return len(pushes) > 0
}, testutil.WaitLong, testutil.IntervalFast,
"expected a context snapshot push after startup; got %d pushes", len(pushes))

first := pushes[0]
require.True(t, first.GetInitial(), "first push must carry Initial=true")

// The gate held until the MCP catalog settled, so the first
// push must already contain the server and its tool list.
var srv *agentproto.MCPServerBody
for _, r := range first.GetResources() {
if body := r.GetMcpServer(); body != nil && body.GetServerName() == "srv" {
srv = body
}
}
require.NotNil(t, srv,
"first push must already contain the MCP server resource")
require.Len(t, srv.GetTools(), 1,
"first push must already contain the server's tools")
assert.Equal(t, "echo", srv.GetTools()[0].GetName())
}

// runFakeMCPStdioServer implements a minimal JSON-RPC / MCP server
// over stdin/stdout, just enough for initialize + tools/list. It
// runs in a re-exec'd copy of the test binary spawned by the agent's
// MCP manager.
func runFakeMCPStdioServer() {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Bytes()

var req struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.Unmarshal(line, &req); err != nil {
continue
}

var resp any
switch req.Method {
case "initialize":
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{
"tools": map[string]any{},
},
"serverInfo": map[string]any{
"name": "fake-server",
"version": "0.0.1",
},
},
}
case "notifications/initialized":
// No response needed for notifications.
continue
case "tools/list":
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]any{
"tools": []map[string]any{
{
"name": "echo",
"description": "echoes input",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
},
}
default:
resp = map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"error": map[string]any{
"code": -32601,
"message": "method not found",
},
}
}

out, err := json.Marshal(resp)
if err != nil {
continue
}
_, _ = fmt.Fprintf(os.Stdout, "%s\n", out)
}
}
54 changes: 54 additions & 0 deletions agent/agentcontext/mcp_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,57 @@ func TestBuildMCPServerResources(t *testing.T) {
require.Equal(t, StatusOK, got[1].Status)
})
}

// TestAggregateHash_MCPServerState verifies that live MCP server state
// participates in the aggregate hash: tool changes and connect-state
// flips change the hash, while identical inputs and tool order
// permutations do not (buildMCPServerResources sorts tools by name
// before hashing).
func TestAggregateHash_MCPServerState(t *testing.T) {
t.Parallel()

hashOf := func(servers []MCPServerStatus) [32]byte {
return ComputeAggregateHash(buildMCPServerResources(servers))
}

connected := []MCPServerStatus{
{Name: "fs", Connected: true, Tools: []MCPTool{
{Name: "read", Description: "Read"},
{Name: "write", Description: "Write"},
}},
}
base := hashOf(connected)

// Identical inputs hash identically.
require.Equal(t, base, hashOf(connected))

// Tool order permutations hash identically because tools are
// sorted by name before hashing.
permuted := []MCPServerStatus{
{Name: "fs", Connected: true, Tools: []MCPTool{
{Name: "write", Description: "Write"},
{Name: "read", Description: "Read"},
}},
}
require.Equal(t, base, hashOf(permuted))

// A tool-list change flips the aggregate hash.
toolAdded := []MCPServerStatus{
{Name: "fs", Connected: true, Tools: []MCPTool{
{Name: "read", Description: "Read"},
{Name: "write", Description: "Write"},
{Name: "stat", Description: "Stat"},
}},
}
require.NotEqual(t, base, hashOf(toolAdded))

// A connected-to-failed flip changes both the resource status and
// its content hash, so the aggregate hash flips too.
failed := []MCPServerStatus{
{Name: "fs", Connected: false, Err: "connection refused"},
}
require.NotEqual(t, base, hashOf(failed))

// A server disappearing entirely flips the aggregate hash.
require.NotEqual(t, base, hashOf(nil))
}
42 changes: 10 additions & 32 deletions agent/agentcontext/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,10 @@ func (r *Resolver) ResolveContext(ctx context.Context, roots []ScanRoot) Snapsho
payloadBytes += uint64(len(r.Payload))
}

// The drift hash covers only pinned prompt content; MCP resources are
// excluded (see driftResources). Snapshot.Resources still carries the
// full set so MCP servers stay visible in the chat-context snapshot.
hash := ComputeAggregateHash(driftResources(resources))
// The aggregate hash covers every resource, including MCP config and
// live MCP server state, so MCP changes (server added or removed, tool
// list changed, connect state changed) surface as chat-context drift.
hash := ComputeAggregateHash(resources)

snap := Snapshot{
Resources: resources,
Expand Down Expand Up @@ -959,12 +959,12 @@ type Snapshot struct {
// loop withholds.
Version uint64
// AggregateHash is sha256 over a canonical encoding of
// (ID, Kind, Source, ContentHash, Status) for every
// drift-relevant resource. MCP resources (KindMCPConfig and
// KindMCPServer) are excluded because they describe live,
// agent-global runtime capabilities discovered at turn time,
// not pinned prompt content; see driftResources. Identical
// inputs always produce identical hashes; see
// (ID, Kind, Source, ContentHash, Status) for every resource in
// the snapshot, including MCP config and live MCP server state.
// coderd pins MCP servers and their tools into chat context, so
// an MCP change (server added or removed, tool list changed,
// connect state changed) surfaces as chat-context drift.
// Identical inputs always produce identical hashes; see
// ComputeAggregateHash.
AggregateHash [32]byte
// Resources is sorted by ID for deterministic encoding.
Expand All @@ -978,28 +978,6 @@ type Snapshot struct {
SnapshotError string
}

// driftResources returns the subset of resources that participate in
// chat-context drift detection. MCP resources (the .mcp.json config and
// connected MCP servers) are deliberately excluded: an agent connects to
// its MCP servers asynchronously after startup, and the chat model
// discovers their tools live at turn time, not from pinned prompt
// content. Hashing them would dirty an already-hydrated chat the moment
// a server finished connecting, even though nothing the user pinned
// changed. Instruction files and skills, whose content is pinned into
// the chat, stay drift-relevant.
func driftResources(resources []Resource) []Resource {
out := make([]Resource, 0, len(resources))
for _, r := range resources {
switch r.Kind {
case KindMCPConfig, KindMCPServer:
continue
default:
out = append(out, r)
}
}
return out
}

// ComputeAggregateHash produces the deterministic snapshot
// aggregate hash for the supplied resources. The caller does
// not need to pre-sort; the function sorts a copy of the slice
Expand Down
Loading
Loading