Skip to content
Merged
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
22 changes: 12 additions & 10 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ func (a *agent) init() {
}
return ""
}, a.contextConfig)
a.mcpAPI = agentmcp.NewAPI(a.logger.Named("mcp"), a.mcpManager, a.contextConfigAPI.MCPConfigFiles)
a.mcpAPI = agentmcp.NewAPI(a.mcpManager)

// agentcontext.Manager is the new consolidated resolver,
// watcher, and pusher. It coexists with contextConfigAPI
Expand All @@ -513,16 +513,19 @@ func (a *agent) init() {
Clock: a.clock,
WorkingDir: workingDirFn,
InitialSources: initialContextSources(a.contextConfig, workingDirFn),
// The manager runs its own self-contained MCP runner: it
// connects to the .mcp.json servers it discovers, lists
// their tools, and pushes them to coderd as KindMCPServer
// resources. This is independent of a.mcpManager, which
// serves the agent's MCP HTTP API; the two MCP paths share
// no state during the rollout.
MCPExecer: a.execer,
MCPUpdateEnv: a.updateCommandEnv,
// The manager surfaces MCP servers and their tools as
// KindMCPServer resources by reading the shared MCP engine's
// catalog (a.mcpManager). That engine owns the single set of
// MCP server connections used for both discovery and tool-call
// execution, so each declared server is launched once.
MCPCatalog: func() []agentcontext.MCPServerStatus {
return mcpCatalogToContext(a.mcpManager.Catalog())
},
})
a.contextAPI = agentcontext.NewAPI(a.contextManager)
// Re-resolve and re-push KindMCPServer resources whenever the MCP
// engine's catalog changes (startup connect, .mcp.json edits).
a.mcpManager.SetOnReload(a.contextManager.Trigger)
a.reconnectingPTYServer = reconnectingpty.NewServer(
a.logger.Named("reconnecting-pty"),
a.sshServer,
Expand Down Expand Up @@ -1553,7 +1556,6 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
// lifecycle transition to avoid delaying Ready.
// This runs inside the tracked goroutine so it
// is properly awaited on shutdown.
a.mcpManager.MarkStartupSettled()
if mcpErr := a.mcpManager.Reload(a.gracefulCtx, a.contextConfigAPI.MCPConfigFiles()); mcpErr != nil {
a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(mcpErr))
}
Expand Down
16 changes: 8 additions & 8 deletions agent/agentcontext/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@
// to coderd without coupling this package to any particular
// drpc client version.
//
// Live MCP server tool lists are produced by this package's own
// self-contained MCP runner: it connects to the MCP servers declared in
// the .mcp.json files the resolver discovers, lists their tools, and
// surfaces them as KindMCPServer resources so MCP servers and their
// tools are pushed to coderd alongside instruction files and skills.
// This runs independently of agent/x/agentmcp, which owns the agent's
// MCP HTTP proxy; the two MCP paths share no state and both continue to
// operate unchanged during the rollout.
// Live MCP server tool lists come from the shared MCP engine in
// agent/x/agentmcp, which owns the single set of MCP server connections
// used for both tool discovery and tool-call execution. This package
// reads that engine's catalog through the injected MCPCatalog option and
// surfaces the servers and their tools as KindMCPServer resources, so
// MCP servers are pushed to coderd alongside instruction files and
// skills. The engine notifies this package through the Manager's Trigger
// when its catalog changes, driving a re-resolve and re-push.
package agentcontext
53 changes: 17 additions & 36 deletions agent/agentcontext/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"golang.org/x/xerrors"

"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/quartz"
)

Expand Down Expand Up @@ -39,18 +38,13 @@ type ManagerOptions struct {
// Tests use this to inject MCP resources (via
// Resolver.MCPResources) and tighten caps.
Resolver *Resolver
// MCPExecer, when non-nil, enables the self-contained MCP
// runner: the Manager connects to the MCP servers declared
// in the .mcp.json files it discovers, lists their tools,
// and surfaces them as KindMCPServer resources in every
// snapshot. The runner uses this Execer to launch stdio MCP
// servers. It is ignored when the resolver already has an
// MCP provider (e.g. a test injecting one via Resolver).
MCPExecer agentexec.Execer
// MCPUpdateEnv optionally enriches the environment handed to
// stdio MCP servers (typically the agent's per-command env).
// Used only when MCPExecer is set; may be nil.
MCPUpdateEnv func([]string) ([]string, error)
// MCPCatalog, when non-nil, supplies the per-server MCP snapshot
// the Manager surfaces as KindMCPServer resources on every
// resolve. The agent injects the shared MCP engine's catalog here
// so discovery and execution use one set of server connections.
// It is ignored when the resolver already has an MCP provider
// (e.g. a test injecting one via Resolver).
MCPCatalog func() []MCPServerStatus
// Debounce overrides the watcher's debounce window.
Debounce time.Duration
}
Expand All @@ -73,11 +67,7 @@ type Manager struct {
workingDir func() string
allowedRoots []string
resolver *Resolver
// mcpRunner, when non-nil, owns the agent's self-contained
// MCP connection lifecycle and feeds the resolver's MCP
// provider. runMCPSync (started by Run) drives its reloads.
mcpRunner *mcpRunner
debounce time.Duration
debounce time.Duration

mu sync.Mutex
sources []Source
Expand Down Expand Up @@ -152,17 +142,16 @@ func NewManager(opts ManagerOptions) *Manager {
runStartedCh: make(chan struct{}),
}

// Enable the self-contained MCP runner unless the resolver
// already has a provider (tests inject one via Resolver). The
// runner connects to the .mcp.json servers the resolver
// discovers and surfaces their tools as KindMCPServer
// resources; runMCPSync (started in Run) drives its reloads.
// The provider must be wired before the eager first resolve
// below so the seam is present from the first snapshot.
if resolver.MCPResources == nil && opts.MCPExecer != nil {
m.mcpRunner = newMCPRunner(m.logger.Named("mcp"), opts.MCPExecer, opts.MCPUpdateEnv, m.Trigger)
// Surface the shared MCP engine's catalog as KindMCPServer
// resources unless the resolver already has a provider (tests
// inject one via Resolver). The engine owns the connection
// lifecycle and notifies this Manager via Trigger when its
// catalog changes (see agent wiring). The provider must be wired
// before the eager first resolve below so the seam is present
// from the first snapshot.
if resolver.MCPResources == nil && opts.MCPCatalog != nil {
resolver.MCPResources = func() []Resource {
return buildMCPServerResources(m.mcpRunner.Servers())
return buildMCPServerResources(opts.MCPCatalog())
}
}

Expand Down Expand Up @@ -236,14 +225,6 @@ func (m *Manager) Run(ctx context.Context) error {

defer watcher.Close()

// Drive MCP server reloads from discovered .mcp.json files for
// the lifetime of Run. Started here (not in NewManager) so it
// runs alongside the trigger loop that consumes its re-resolve
// signals.
if m.mcpRunner != nil {
go m.runMCPSync(ctx)
}

for {
select {
case <-ctx.Done():
Expand Down
7 changes: 0 additions & 7 deletions agent/agentcontext/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,6 @@ import (
// Claude config files into snapshots and breaks every
// Len(Resources, N) assertion.
func TestMain(m *testing.M) {
// The MCP runner re-execs this test binary as a fake stdio MCP
// server (TEST_MCP_FAKE_SERVER=1). Serve and exit before any test
// setup runs.
if maybeServeFakeMCPServer() {
os.Exit(0)
}

home, err := os.MkdirTemp("", "agentcontext-test-home-")
if err != nil {
panic(err)
Expand Down
77 changes: 77 additions & 0 deletions agent/agentcontext/mcpcatalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package agentcontext_test

import (
"sync"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/agent/agentcontext"
"github.com/coder/coder/v2/testutil"
)

// TestManager_MCPCatalogSurfacesResources verifies the injected MCP
// catalog is surfaced as KindMCPServer resources, and that a catalog
// change picked up on the next Trigger re-resolves the snapshot. In
// production the shared MCP engine wires SetOnReload to the Manager's
// Trigger so a reload re-publishes the updated tools.
func TestManager_MCPCatalogSurfacesResources(t *testing.T) {
t.Parallel()
dir := t.TempDir()

var mu sync.Mutex
servers := []agentcontext.MCPServerStatus{{
Name: "srv",
Connected: true,
Tools: []agentcontext.MCPTool{{Name: "echo", Description: "echoes input"}},
}}

m := newTestManager(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return dir },
MCPCatalog: func() []agentcontext.MCPServerStatus {
mu.Lock()
defer mu.Unlock()
return append([]agentcontext.MCPServerStatus(nil), servers...)
},
})

// The eager first snapshot already reflects the injected catalog.
got := findMCPServerResource(m.Snapshot(), "srv")
require.NotNil(t, got)
require.Equal(t, agentcontext.StatusOK, got.Status)
require.Len(t, got.Tools, 1)
require.Equal(t, "echo", got.Tools[0].Name)

ctx := testutil.Context(t, testutil.WaitLong)
go func() { _ = m.Run(ctx) }()

// A catalog change re-resolves on the next Trigger.
mu.Lock()
servers = []agentcontext.MCPServerStatus{{
Name: "srv",
Connected: true,
Tools: []agentcontext.MCPTool{
{Name: "echo"},
{Name: "ping"},
},
}}
mu.Unlock()
m.Trigger()

require.Eventually(t, func() bool {
got := findMCPServerResource(m.Snapshot(), "srv")
return got != nil && len(got.Tools) == 2
}, testutil.WaitShort, testutil.IntervalMedium,
"catalog change should re-resolve into the snapshot")
}

// findMCPServerResource returns the KindMCPServer resource for the named
// server, or nil if absent.
func findMCPServerResource(snap agentcontext.Snapshot, name string) *agentcontext.Resource {
for i := range snap.Resources {
if r := snap.Resources[i]; r.Kind == agentcontext.KindMCPServer && r.Source == name {
return &snap.Resources[i]
}
}
return nil
}
Loading
Loading