Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
22c682f
feat: consume the pinned chat context copy in prompt generation
kylecarbs Jun 17, 2026
b2f7f64
fix(coderd/x/chatd): address review on the pinned context reader
kylecarbs Jun 17, 2026
22df16f
refactor(coderd/x/chatd): extract turn context dispatch for test cove…
kylecarbs Jun 17, 2026
8fa6c3c
docs(coderd/x/chatd): correct context comments from round 2 review
kylecarbs Jun 17, 2026
0e4ab54
Merge remote-tracking branch 'origin/main' into kylecarbs/chat-contex…
kylecarbs Jun 17, 2026
a0d6535
refactor(coderd/x/chatd): select pinned context by presence not exper…
kylecarbs Jun 17, 2026
4735aa4
test(coderd/x/chatd): cover empty skill-name and instruction-content …
kylecarbs Jun 17, 2026
2d8c30c
refactor(coderd/x/chatd): inline agentWorkingDir at its call sites
kylecarbs Jun 17, 2026
cf94945
feat(coderd/chatd): surface pinned context, drift, and a context diff
kylecarbs Jun 17, 2026
0c295e5
feat(cli): add `coder exp chat context show` and `refresh`
kylecarbs Jun 17, 2026
aa5e95d
fix(site): drop empty-name entries from the context popover
kylecarbs Jun 17, 2026
4739405
feat(cli,agentsocket): manage workspace context sources over the agen…
kylecarbs Jun 17, 2026
92fd5ad
fix(cli,chatd,site): accept relative context paths and surface MCP re…
kylecarbs Jun 17, 2026
2c83aff
feat(agent): produce MCP server resources in the workspace-context sn…
kylecarbs Jun 17, 2026
f4ca2ac
feat(chatd,site): surface MCP server tools in the chat context UI
kylecarbs Jun 18, 2026
d9bbee6
feat(chatd,site): surface per-resource error states in the chat context
kylecarbs Jun 18, 2026
5d8fd63
feat(agent): surface failed MCP connects and invalid .mcp.json as con…
kylecarbs Jun 18, 2026
9cd6523
fix(coderd): hydrate context resources in the refresh-context response
kylecarbs Jun 18, 2026
38d1244
fix(agent): connect MCP servers from context-source .mcp.json files
kylecarbs Jun 18, 2026
94ac11c
fix(cli,coderd): authenticate in-workspace chat context refresh with …
kylecarbs Jun 18, 2026
a96951b
fix(agent): exclude MCP resources from the chat-context drift hash
kylecarbs Jun 18, 2026
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
94 changes: 92 additions & 2 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,16 @@ func (a *agent) init() {
Clock: a.clock,
WorkingDir: workingDirFn,
InitialSources: initialContextSources(a.contextConfig, workingDirFn),
// Surface live MCP servers (their tools, and any that
// failed to connect) in the snapshot by reading the MCP
// manager's per-server health on every resolve.
MCP: mcpContextProvider{cachedServers: a.mcpManager.CachedServers},
})
// Re-resolve the context snapshot whenever the MCP tool set
// changes (e.g. a .mcp.json edit reconnects servers) so MCP
// server resources track the live tools. Wired after both
// managers exist; the MCP manager fires this outside its lock.
a.mcpManager.SetOnToolsChanged(a.contextManager.Trigger)
a.contextAPI = agentcontext.NewAPI(a.contextManager)
a.reconnectingPTYServer = reconnectingpty.NewServer(
a.logger.Named("reconnecting-pty"),
Expand Down Expand Up @@ -554,6 +563,7 @@ func (a *agent) initSocketServer() {
server, err := agentsocket.NewServer(
a.logger.Named("socket"),
agentsocket.WithPath(a.socketPath),
agentsocket.WithContextManager(a.contextManager),
)
if err != nil {
a.logger.Error(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath))
Expand Down Expand Up @@ -1545,8 +1555,18 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
// 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))
// Keep the MCP manager's connected servers in sync
// with the .mcp.json files the context resolver
// discovers: the manifest working directory plus any
// context sources added at runtime. A .mcp.json that
// lives in an added source (not the working dir)
// would otherwise never be connected. Sources can be
// added after startup, so this watches for the agent's
// lifetime in its own tracked goroutine.
if mcpErr := a.trackGoroutine(func() {
a.syncMCPServersFromContext(a.gracefulCtx)
}); mcpErr != nil {
a.logger.Warn(ctx, "failed to start workspace MCP context sync", slog.Error(mcpErr))
}
})
if err != nil {
Expand All @@ -1557,6 +1577,76 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
}
}

// syncMCPServersFromContext keeps the MCP manager's connected servers
// in sync with the .mcp.json files the context resolver discovers. The
// resolver scans the manifest working directory, built-in roots, and any
// context sources added at runtime, so a .mcp.json contributed by an
// added source (rather than living in the working directory) is
// connected too. It reloads on every context-snapshot change, deduping
// by the discovered path set so unrelated snapshot churn (e.g. MCP tool
// updates, which also bump the snapshot) does not cause reload churn.
func (a *agent) syncMCPServersFromContext(ctx context.Context) {
changes, unsubscribe := a.contextManager.SubscribeChanges()
defer unsubscribe()

// The statically configured MCP paths (manifest working directory
// plus CODER_AGENT_EXP_MCP_CONFIG_FILES) are stable for the agent's
// lifetime once startup has settled, so resolve them once.
configured := a.contextConfigAPI.MCPConfigFiles()

var last []string
reload := func() {
paths := mcpConfigPaths(configured, a.contextManager.Snapshot())
if slices.Equal(paths, last) {
return
}
last = paths
if err := a.mcpManager.Reload(ctx, paths); err != nil {
a.logger.Warn(ctx, "failed to reload workspace MCP servers", slog.Error(err))
}
}

// Pick up any sources discovered before we subscribed.
reload()
for {
select {
case <-ctx.Done():
return
case <-changes:
reload()
}
}
}

// mcpConfigPaths unions the statically configured MCP config files with
// the .mcp.json files the resolver discovered in the snapshot (working
// directory, built-in roots, and runtime-managed context sources) and
// returns a sorted, deduplicated list. The union keeps legacy
// manifest-directory discovery working while also connecting servers
// declared in .mcp.json files contributed by added context sources.
func mcpConfigPaths(configured []string, snap agentcontext.Snapshot) []string {
seen := make(map[string]struct{}, len(configured)+len(snap.Resources))
add := func(p string) {
if p != "" {
seen[p] = struct{}{}
}
}
for _, p := range configured {
add(p)
}
for _, r := range snap.Resources {
if r.Kind == agentcontext.KindMCPConfig {
add(r.Source)
}
}
paths := make([]string, 0, len(seen))
for p := range seen {
paths = append(paths, p)
}
slices.Sort(paths)
return paths
}

func (a *agent) createDevcontainer(
ctx context.Context,
aAPI proto.DRPCAgentClient28,
Expand Down
11 changes: 11 additions & 0 deletions agent/agentcontext/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ type ManagerOptions struct {
// Tests use this to inject MCP providers and tighten
// caps.
Resolver *Resolver
// MCP, when non-nil, supplies live MCP server resources to
// the resolver. It is applied to the resolver (default or
// injected via Resolver) so MCP servers participate in
// every snapshot. An MCP set directly on an injected
// Resolver takes precedence.
MCP MCPProvider
// Debounce overrides the watcher's debounce window.
Debounce time.Duration
}
Expand Down Expand Up @@ -118,6 +124,11 @@ func NewManager(opts ManagerOptions) *Manager {
if resolver == nil {
resolver = &Resolver{}
}
// Apply the MCP provider to whichever resolver is used, unless
// an injected resolver already set one (which takes precedence).
if opts.MCP != nil && resolver.MCP == nil {
resolver.MCP = opts.MCP
}

m := &Manager{
logger: opts.Logger,
Expand Down
31 changes: 31 additions & 0 deletions agent/agentcontext/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,34 @@ func TestManager_SubscribeBroadcastOnChange(t *testing.T) {
t.Fatal("expected subscriber to be notified")
}
}

// TestManager_MCPProviderOptionAppliesToSnapshot verifies that an MCP
// provider supplied via ManagerOptions.MCP contributes KindMCPServer
// resources (with their tools) to the resolved snapshot.
func TestManager_MCPProviderOptionAppliesToSnapshot(t *testing.T) {
t.Parallel()
dir := t.TempDir()

m := newTestManager(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return dir },
MCP: &fakeMCPProvider{resources: []agentcontext.Resource{{
ID: "mcp_server:fs",
Kind: agentcontext.KindMCPServer,
Source: "fs",
Name: "fs",
Status: agentcontext.StatusOK,
Tools: []agentcontext.MCPTool{{Name: "fs__read", Description: "Read"}},
}}},
})

snap := m.Snapshot()
var found bool
for _, r := range snap.Resources {
if r.Kind == agentcontext.KindMCPServer && r.Source == "fs" {
found = true
require.Len(t, r.Tools, 1)
require.Equal(t, "fs__read", r.Tools[0].Name)
}
}
require.True(t, found, "expected MCP server resource in snapshot")
}
76 changes: 73 additions & 3 deletions agent/agentcontext/resolve.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package agentcontext

import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -15,6 +17,8 @@ import (
"strconv"
"strings"

"golang.org/x/xerrors"

"github.com/coder/coder/v2/codersdk/workspacesdk"
)

Expand Down Expand Up @@ -164,7 +168,10 @@ func (r *Resolver) ResolveContext(ctx context.Context, roots []ScanRoot) Snapsho
payloadBytes += uint64(len(r.Payload))
}

hash := ComputeAggregateHash(resources)
// 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 UI.
hash := ComputeAggregateHash(driftResources(resources))

snap := Snapshot{
Resources: resources,
Expand Down Expand Up @@ -472,9 +479,46 @@ func (r *Resolver) readMCPConfig(scanRoot, path string, info fs.FileInfo, userSo
return res
}
res.ContentHash = sha256.Sum256(data)
// A .mcp.json with broken JSON yields no MCP servers at all; the
// agentmcp manager logs and skips it, so the failure is otherwise
// invisible. Flag structural problems here as StatusInvalid so the
// chat context surfaces them as an issue rather than silently
// dropping every server in the file.
if err := validateMCPConfig(data); err != nil {
res.Status = StatusInvalid
res.Error = err.Error()
}
return res
}

// validateMCPConfig performs lightweight structural validation of a
// .mcp.json document so syntactically broken files surface as
// StatusInvalid instead of silently producing no MCP servers. It is
// deliberately self-contained and does not import the agentmcp
// package: it only checks that the document is valid JSON shaped like
// {"mcpServers": {<name>: {...}}}. Individual server fields
// (command/url/env/...) are not validated here; the MCP manager owns
// that when it connects. An absent or empty mcpServers map is valid.
func validateMCPConfig(data []byte) error {
var shape struct {
MCPServers map[string]json.RawMessage `json:"mcpServers"`
}
if err := json.Unmarshal(data, &shape); err != nil {
return err
}
// Each server entry must be a JSON object; a scalar or array
// entry is a structural error the MCP manager would reject.
// The top-level Unmarshal above already rejects malformed JSON,
// so a well-formed value starting with '{' is a complete object.
for name, raw := range shape.MCPServers {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || trimmed[0] != '{' {
return xerrors.Errorf("server %q must be a JSON object", name)
}
}
return nil
}

// readFileResource is the shared plumbing for kinds whose only
// difference is the enum stamped on the Resource: build the
// Resource header, enforce the per-resource size cap, read the
Expand Down Expand Up @@ -938,8 +982,12 @@ type Snapshot struct {
Version uint64
// AggregateHash is sha256 over a canonical encoding of
// (ID, Kind, Source, ContentHash, Status) for every
// resource. Identical inputs always produce identical
// hashes; see ComputeAggregateHash.
// 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
// ComputeAggregateHash.
AggregateHash [32]byte
// Resources is sorted by ID for deterministic encoding.
Resources []Resource
Expand All @@ -952,6 +1000,28 @@ 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 (coderd's ListMCPTools path),
// 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