Files
coder/agent/agentcontext/manager.go
T
Kyle Carberry 27ecd17991 refactor: consolidate agent MCP onto a single persistent engine (#26599)
Two MCP code paths both spawned the servers declared in a workspace's
`.mcp.json`: the persistent engine in `agent/x/agentmcp` (which owns
tool-call execution via `CallTool`) and an ephemeral one-shot runner in
`agent/agentcontext` (`mcprunner.go`) that connected, listed tools, and
immediately closed each server purely for discovery. Every declared
server was launched twice, and the discovery path duplicated the
engine's `.mcp.json` parse, transport-build, env-resolve, and connect
logic.

This makes `agent/x/agentmcp` the single persistent MCP engine. The
`agentcontext` manager now reads that engine's per-server catalog
in-process through an injected `MCPCatalog` option and surfaces each
server as a `KindMCPServer` resource. The engine wires `SetOnReload` to
the manager's `Trigger`, so a reload (startup connect or `.mcp.json`
edit) re-resolves and re-pushes the pinned resources. Tool-call
execution is unchanged: it still flows through the engine's `CallTool`
over `POST /api/v0/mcp/call-tool`.

The now-dead HTTP discovery surface is removed: the agent `GET
/api/v0/mcp/tools` route with `agentmcp.API.handleListTools`, and
`workspacesdk.AgentConn.ListMCPTools` with `ListMCPToolsResponse` (mock
regenerated). The change nets roughly `-1370` lines, mostly the deleted
duplicate runner and its tests.

<details>
<summary>Decision log</summary>

The merge of #26585 made pinned `chat_context_resources` the sole source
of workspace context, which surfaced the duplicate spawning. Two options
were considered:

- **Option A + dependency injection (chosen):** keep `agent/x/agentmcp`
as the single persistent engine; `agentcontext` consumes its catalog
in-process and stays the orchestrator/owner at the API boundary (it
still pushes `KindMCPServer` resources). This is low-risk because
`agentcontext` already exposed the `resolver.MCPResources` seam, so the
change just rebinds it from the ephemeral runner to the shared engine.
- **Option B (rejected):** reimplement persistent pooling, reconnect,
singleflight, and race handling inside `agentcontext` and delete
`agentmcp`. Too broad, and it discards the engine's tested lifecycle for
no behavioral gain.

`agentcontext`'s discovery was never what kept servers alive; its runner
closed each server immediately after listing tools. The component
holding persistent connections was always `agentmcp`, which is why
execution already lived there. Consolidating onto it removes the
duplicated stack rather than a whole package: both packages survive with
distinct roles (`agentmcp` is the engine, `agentcontext` is the
orchestrator/owner).

</details>

Coder Agents generated on behalf of @kylecarbs
2026-06-22 22:21:58 -06:00

682 lines
20 KiB
Go

package agentcontext
import (
"context"
"strings"
"sync"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/quartz"
)
// ManagerOptions configures a Manager. Zero values get sensible
// defaults.
type ManagerOptions struct {
// Logger receives diagnostic messages. Required.
Logger slog.Logger
// Clock is the time source used for the watcher's
// debounce timer. Optional; defaults to quartz.NewReal().
Clock quartz.Clock
// WorkingDir is evaluated on every resolve, mirroring the
// existing agent convention. The result is used as a
// scan root.
WorkingDir func() string
// InitialSources seeds the Manager's source list at boot
// time. Sources from CODER_AGENT_EXP_*_DIRS env vars or
// startup scripts are layered here.
InitialSources []Source
// AllowedRoots restricts which paths may be added as
// sources at runtime. When empty the package falls back
// to [~, ~/.coder, ~/.claude] plus the working directory.
// Tests override this to exercise the validation logic
// directly; production callers leave it unset.
AllowedRoots []string
// Resolver, when non-nil, replaces the default resolver.
// Tests use this to inject MCP resources (via
// Resolver.MCPResources) and tighten caps.
Resolver *Resolver
// 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
}
// Source is a user-declared scan root added to the agent's
// in-memory list via the HTTP API or boot-time env seeding.
// Identity is the canonical absolute path.
type Source struct {
// Path is the canonical absolute path (symlinks resolved,
// ~ expanded). Empty means the zero value.
Path string
}
// Manager orchestrates source CRUD, resolution, watching, and
// Pusher fan-out. Construct with NewManager; start its lifecycle
// goroutines with Run; tear down with Close.
type Manager struct {
logger slog.Logger
clock quartz.Clock
workingDir func() string
allowedRoots []string
resolver *Resolver
debounce time.Duration
mu sync.Mutex
sources []Source
// sourceIndex maps canonical path -> position in sources
// for O(1) lookups during AddSource / RemoveSource.
sourceIndex map[string]int
// snapshot is the latest result of a resolver pass. It is
// replaced atomically under mu.
snapshot Snapshot
// version monotonically increases per resolve pass.
version uint64
// resolveEpoch increments at the start of every resolver
// pass that drops m.mu around the filesystem walk. Each
// pass captures the epoch it claimed; at publish time it
// compares its captured epoch against the current epoch and
// skips the publish if a newer pass has started, preventing
// an old walk's stale result from overwriting a newer one's
// fresh result at a higher version number.
resolveEpoch uint64
// subscribers receive a non-blocking signal whenever the
// snapshot changes. Subscribers must drain their channel
// promptly; the Manager drops sends to full channels.
subscribers map[chan struct{}]struct{}
// trigger fires when AddSource / RemoveSource / watcher
// observe a change.
trigger chan struct{}
// running tracks Run lifetime.
running bool
closed bool
closedCh chan struct{}
runDoneCh chan struct{}
runStartedCh chan struct{}
watcher *Watcher
}
// NewManager validates options, canonicalizes initial sources,
// performs the first resolver pass synchronously, and returns
// the resulting Manager. Run must be called separately to start
// the watcher and re-resolve goroutine.
func NewManager(opts ManagerOptions) *Manager {
clock := opts.Clock
if clock == nil {
clock = quartz.NewReal()
}
debounce := opts.Debounce
if debounce <= 0 {
debounce = DefaultWatchDebounce
}
resolver := opts.Resolver
if resolver == nil {
resolver = &Resolver{}
}
m := &Manager{
logger: opts.Logger,
clock: clock,
workingDir: opts.WorkingDir,
allowedRoots: append([]string(nil), opts.AllowedRoots...),
resolver: resolver,
debounce: debounce,
sources: make([]Source, 0),
sourceIndex: make(map[string]int),
subscribers: make(map[chan struct{}]struct{}),
trigger: make(chan struct{}, 1),
closedCh: make(chan struct{}),
runDoneCh: make(chan struct{}),
runStartedCh: make(chan struct{}),
}
// 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(opts.MCPCatalog())
}
}
for _, s := range opts.InitialSources {
canonical, err := CanonicalizePath(s.Path)
if err != nil {
// Initial sources may not exist yet at boot
// time; log and skip rather than abort the
// agent.
m.logger.Warn(context.Background(),
"skipping invalid initial source",
slog.F("path", s.Path),
slog.Error(err))
continue
}
if _, ok := m.sourceIndex[canonical]; ok {
continue
}
m.sourceIndex[canonical] = len(m.sources)
m.sources = append(m.sources, Source{Path: canonical})
}
// First snapshot is computed eagerly. The push protocol
// requires a snapshot to be present before the agent signals
// lifecycle = ready, so callers can rely on Snapshot() being
// populated immediately after NewManager returns.
m.resolveLocked()
return m
}
// Run starts the watcher and the re-resolve goroutine. Run
// blocks until ctx is canceled or Close is called. It is safe
// to call Run at most once per Manager.
func (m *Manager) Run(ctx context.Context) error {
m.mu.Lock()
if m.running {
m.mu.Unlock()
return xerrors.New("agentcontext: Manager.Run called more than once")
}
if m.closed {
m.mu.Unlock()
return xerrors.New("agentcontext: Manager already closed")
}
m.running = true
close(m.runStartedCh)
m.mu.Unlock()
// Close any early-exit path so Close does not block on
// runDoneCh after Run already set running=true. The deferred
// close runs even when NewWatcher fails.
defer close(m.runDoneCh)
watcher, err := NewWatcher(WatcherOptions{
Logger: m.logger.Named("watcher"),
Clock: m.clock,
Debounce: m.debounce,
OnChange: m.signal,
})
if err != nil {
// NewWatcher already falls back to degraded mode on
// init failure, so an actual error here is
// exceptional.
return xerrors.Errorf("create watcher: %w", err)
}
m.mu.Lock()
m.watcher = watcher
roots := m.scanRootsLocked()
m.mu.Unlock()
watcher.Sync(ctx, roots)
defer watcher.Close()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-m.closedCh:
return nil
case <-m.trigger:
m.mu.Lock()
roots := m.scanRootsLocked()
m.mu.Unlock()
watcher.Sync(ctx, roots)
m.resolveAndBroadcast(ctx)
}
}
}
// started returns a channel that is closed once Run has
// claimed the running flag. Tests use it to coordinate with
// the watcher loop without polling; a closed channel never
// blocks, so this is safe to call repeatedly.
func (m *Manager) started() <-chan struct{} {
return m.runStartedCh
}
// Close stops the Manager. Close is idempotent; subsequent
// calls block until Run exits.
func (m *Manager) Close() error {
m.mu.Lock()
if m.closed {
running := m.running
m.mu.Unlock()
if running {
<-m.runDoneCh
}
return nil
}
m.closed = true
running := m.running
close(m.closedCh)
m.mu.Unlock()
if running {
<-m.runDoneCh
}
return nil
}
// Sources returns a defensive copy of the current source list.
// The returned slice is safe to mutate.
func (m *Manager) Sources() []Source {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Source, len(m.sources))
copy(out, m.sources)
return out
}
// HasSource reports whether path matches an existing source
// after canonicalization. Returns the canonical path on
// success.
func (m *Manager) HasSource(path string) (canonical string, ok bool) {
c, err := CanonicalizePath(path)
if err != nil {
return "", false
}
m.mu.Lock()
defer m.mu.Unlock()
_, ok = m.sourceIndex[c]
return c, ok
}
// AddSource adds a new source. The path is canonicalized and
// validated against the AllowedRoots set. AddSource is
// idempotent.
func (m *Manager) AddSource(s Source) (Source, error) {
canonical, err := CanonicalizePath(s.Path)
if err != nil {
return Source{}, xerrors.Errorf("canonicalize: %w", err)
}
if err := ValidateSourcePath(canonical, m.effectiveAllowedRoots()); err != nil {
return Source{}, err
}
m.mu.Lock()
if _, ok := m.sourceIndex[canonical]; ok {
out := m.sources[m.sourceIndex[canonical]]
m.mu.Unlock()
return out, nil
}
m.sourceIndex[canonical] = len(m.sources)
m.sources = append(m.sources, Source{Path: canonical})
m.mu.Unlock()
m.signal()
return Source{Path: canonical}, nil
}
// SeedSources canonicalizes and inserts a batch of trusted
// sources without applying AllowedRoots validation. It is the
// late-binding equivalent of ManagerOptions.InitialSources for
// callers that need the working directory to resolve relative
// paths but only learn the working directory after Run has
// started. Paths that fail canonicalization are silently
// skipped, matching the boot-time seeding contract. SeedSources
// is idempotent: previously seeded canonical paths are
// deduplicated via the existing source index.
//
// AddSource is the correct entry point for untrusted HTTP
// callers; this method exists only for the agent's manifest-
// triggered seeding from CODER_AGENT_EXP_*_DIRS, where the
// template author already authorized the paths.
func (m *Manager) SeedSources(sources []Source) {
if len(sources) == 0 {
return
}
m.mu.Lock()
changed := false
for _, s := range sources {
canonical, err := CanonicalizePath(s.Path)
if err != nil {
m.logger.Warn(context.Background(),
"skipping invalid seeded source",
slog.F("path", s.Path),
slog.Error(err))
continue
}
if _, ok := m.sourceIndex[canonical]; ok {
continue
}
m.sourceIndex[canonical] = len(m.sources)
m.sources = append(m.sources, Source{Path: canonical})
changed = true
}
m.mu.Unlock()
if changed {
m.signal()
}
}
// RemoveSource removes the source matching path. Path is
// canonicalized before matching. Returns ErrSourceNotFound when
// no such source exists or when the path cannot be canonicalized.
func (m *Manager) RemoveSource(path string) error {
canonical, err := CanonicalizePath(path)
if err != nil {
// A path that does not canonicalize cannot match any
// existing source. Mirror HasSource semantics by
// reporting not-found rather than leaking the
// canonicalize error to API callers.
return ErrSourceNotFound
}
m.mu.Lock()
idx, ok := m.sourceIndex[canonical]
if !ok {
m.mu.Unlock()
return ErrSourceNotFound
}
// O(n) compaction is fine for the typical handful of
// user-added sources.
m.sources = append(m.sources[:idx], m.sources[idx+1:]...)
delete(m.sourceIndex, canonical)
for i := idx; i < len(m.sources); i++ {
m.sourceIndex[m.sources[i].Path] = i
}
m.mu.Unlock()
m.signal()
return nil
}
// Snapshot returns the latest Snapshot. The returned value is
// safe to share but shares the same Resources slice as the
// internal state; callers must not mutate it.
func (m *Manager) Snapshot() Snapshot {
m.mu.Lock()
defer m.mu.Unlock()
return m.snapshot
}
// SubscribeChanges returns a buffered channel that receives a
// signal whenever the snapshot changes. The unsubscribe
// callback is safe to call from any goroutine and is
// idempotent.
func (m *Manager) SubscribeChanges() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
m.mu.Lock()
m.subscribers[ch] = struct{}{}
m.mu.Unlock()
// OnceFunc returns a closure that runs the underlying
// function at most once. Subsequent invocations are no-ops,
// matching the idempotency contract callers rely on.
unsub := sync.OnceFunc(func() {
m.mu.Lock()
delete(m.subscribers, ch)
m.mu.Unlock()
// Don't close ch: readers may still be in flight.
})
return ch, unsub
}
// Resync forces an immediate re-resolve and returns the new
// Snapshot. Resync is safe to call regardless of whether Run is
// active. Like resolveAndBroadcast, Resync drops the Manager's
// mutex around the resolver pass so concurrent Sources,
// AddSource, RemoveSource, and Snapshot calls do not block on
// filesystem I/O. When the watcher is active, Resync also
// re-arms it so newly added scan roots are observed for
// subsequent edits.
func (m *Manager) Resync(ctx context.Context) (Snapshot, error) {
if ctxErr := ctx.Err(); ctxErr != nil {
return m.Snapshot(), ctxErr
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return m.Snapshot(), ErrManagerClosed
}
roots := m.scanRootsLocked()
resolver := m.resolver
watcher := m.watcher
m.resolveEpoch++
myEpoch := m.resolveEpoch
m.mu.Unlock()
if ctxErr := ctx.Err(); ctxErr != nil {
return m.Snapshot(), ctxErr
}
snap := resolver.ResolveContext(ctx, roots)
if ctxErr := ctx.Err(); ctxErr != nil {
// Cancellation mid-walk yields a partial or empty
// Snapshot whose SnapshotError is set to
// "context canceled". Publishing it would replace
// the live Snapshot with empty resources until the
// next trigger, so bail without touching state.
return m.Snapshot(), ctxErr
}
if snap.SnapshotError == "" && watcher != nil {
if d := watcher.Degraded(); d != "" {
snap.SnapshotError = d
}
}
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return m.Snapshot(), ErrManagerClosed
}
if m.resolveEpoch != myEpoch {
// A newer resolve pass started while this one was
// walking the filesystem. The newer pass's data
// strictly supersedes ours, so skip the publish to
// avoid overwriting a fresher Snapshot at a higher
// version. Return the currently published Snapshot,
// which is at least as fresh as ours. The watcher
// is NOT re-armed: the winning pass already synced
// with the current roots, and replaying our stale
// root set here would drop watches on sources that
// only the newer pass knows about.
published := m.snapshot
m.mu.Unlock()
return published, nil
}
m.version++
snap.Version = m.version
m.snapshot = snap
subs := make([]chan struct{}, 0, len(m.subscribers))
for ch := range m.subscribers {
subs = append(subs, ch)
}
m.mu.Unlock()
if watcher != nil {
watcher.Sync(ctx, roots)
}
// The broadcast is unconditional: Resync waiters that
// triggered the pass without an actual content change
// still need to wake up. Subscribers compare snapshots via
// AggregateHash if they want to filter.
for _, ch := range subs {
select {
case ch <- struct{}{}:
default:
}
}
return snap, nil
}
// signal triggers a re-resolve. Sends are non-blocking; the
// trigger channel has a depth of 1, which coalesces bursts.
func (m *Manager) signal() {
select {
case m.trigger <- struct{}{}:
default:
}
}
// Trigger queues an asynchronous re-resolve. Trigger returns
// immediately; the Run goroutine performs the filesystem walk
// in the background and broadcasts when it finishes. Use
// Trigger when the caller wants the watcher to pick up an
// updated working directory or scan-root set but does not need
// the new Snapshot synchronously. Trigger is a no-op when Run
// has not started or the Manager is closed.
func (m *Manager) Trigger() {
m.signal()
}
// scanRootsLocked returns the list of ScanRoots to feed the
// resolver and watcher. The Manager's mutex must be held.
func (m *Manager) scanRootsLocked() []ScanRoot {
builtinRoots := defaultBuiltinRoots()
out := make([]ScanRoot, 0, 1+len(builtinRoots)+len(m.sources))
if m.workingDir != nil {
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
// The working directory is a single scan root. The
// resolver reads its top-level instruction files and
// .mcp.json plus the fixed skill containers under it;
// it neither descends into subdirectories nor climbs
// to parent directories. Additional directories are
// added explicitly as Sources or via the seeding env
// vars.
out = append(out, ScanRoot{Path: wd})
}
}
for _, r := range builtinRoots {
canonical, err := CanonicalizePath(r)
if err != nil {
continue
}
out = append(out, ScanRoot{Path: canonical})
}
for _, s := range m.sources {
out = append(out, ScanRoot{Path: s.Path, UserSource: s.Path})
}
return out
}
// effectiveAllowedRoots returns the AllowedRoots augmented
// with the current working directory. The working directory is
// evaluated on every call so it picks up the workspace's
// resolved path after the agent's manifest finishes loading.
// When AllowedRoots is empty the package falls back to its
// default policy ([~, ~/.coder, ~/.claude]).
func (m *Manager) effectiveAllowedRoots() []string {
var roots []string
if len(m.allowedRoots) > 0 {
roots = append(roots, m.allowedRoots...)
} else {
roots = append(roots, defaultAllowedRoots()...)
}
if m.workingDir != nil {
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
roots = append(roots, wd)
}
}
return roots
}
// resolveAndBroadcast computes a fresh snapshot and notifies
// every subscriber. The broadcast is unconditional: Resync
// waiters that triggered the pass without an actual content
// change still need to wake up. Subscribers compare snapshots
// via AggregateHash if they want to filter.
func (m *Manager) resolveAndBroadcast(ctx context.Context) {
// Snapshot the inputs under the lock, then release it
// before running the resolver. The resolver walks the
// filesystem, reads files, and hashes them; holding
// m.mu across that would block Sources, AddSource,
// RemoveSource, Snapshot, and SubscribeChanges for the
// duration of the pass.
m.mu.Lock()
roots := m.scanRootsLocked()
resolver := m.resolver
watcher := m.watcher
m.resolveEpoch++
myEpoch := m.resolveEpoch
m.mu.Unlock()
if err := ctx.Err(); err != nil {
return
}
snap := resolver.ResolveContext(ctx, roots)
if err := ctx.Err(); err != nil {
// Cancellation mid-walk yields a partial or empty
// Snapshot. Publishing it would replace the live
// Snapshot with empty resources, so bail without
// touching state. The Run loop's gracefulCtx is
// canceled only at shutdown, but defensive checks
// keep the publish contract uniform with Resync.
return
}
// Surface watcher degradation as a snapshot-level error
// when the resolver did not already emit one.
if snap.SnapshotError == "" && watcher != nil {
if d := watcher.Degraded(); d != "" {
snap.SnapshotError = d
}
}
m.mu.Lock()
if m.resolveEpoch != myEpoch {
// A newer resolve pass started while this one was
// walking the filesystem. Skip the publish so a
// stale-epoch result does not overwrite a fresher
// Snapshot at a higher version number. The newer
// pass will broadcast its own result.
m.mu.Unlock()
return
}
m.version++
snap.Version = m.version
m.snapshot = snap
subs := make([]chan struct{}, 0, len(m.subscribers))
for ch := range m.subscribers {
subs = append(subs, ch)
}
m.mu.Unlock()
for _, ch := range subs {
select {
case ch <- struct{}{}:
default:
}
}
}
// resolveLocked runs the resolver inline while m.mu is held.
// It is used by the synchronous initial resolve in NewManager,
// where there is no concurrent reader. Background re-resolves
// must use resolveAndBroadcast, which drops the lock around
// filesystem I/O.
func (m *Manager) resolveLocked() {
roots := m.scanRootsLocked()
snap := m.resolver.Resolve(roots)
m.version++
snap.Version = m.version
// Surface watcher degradation as a snapshot-level error
// when the resolver did not already emit one.
if snap.SnapshotError == "" && m.watcher != nil {
if d := m.watcher.Degraded(); d != "" {
snap.SnapshotError = d
}
}
m.snapshot = snap
}
// ErrSourceNotFound is returned by RemoveSource when the
// requested path is not in the source list.
var ErrSourceNotFound = xerrors.New("source not found")
// ErrManagerClosed is returned by methods called after Close.
var ErrManagerClosed = xerrors.New("agentcontext: manager closed")