mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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
This commit is contained in:
@@ -23,12 +23,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
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,14 +224,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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package agentcontext_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontext"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// TestManager_MCPServerToolsInSnapshot exercises the MCP runner end to
|
||||
// end against a real subprocess: a .mcp.json in the working directory is
|
||||
// discovered by the resolver, the runner connects the declared stdio
|
||||
// server, lists its tools, and they surface as a KindMCPServer resource
|
||||
// in the manager's snapshot (the same snapshot that is pushed to coderd).
|
||||
func TestManager_MCPServerToolsInSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
writeMCPConfig(t, dir, "fake", map[string]string{"TEST_MCP_FAKE_SERVER": "1"})
|
||||
|
||||
m := newTestManager(t, agentcontext.ManagerOptions{
|
||||
WorkingDir: func() string { return dir },
|
||||
MCPExecer: agentexec.DefaultExecer,
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
go func() { _ = m.Run(ctx) }()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return findMCPServer(m.Snapshot(), "fake") != nil
|
||||
}, testutil.WaitLong, testutil.IntervalMedium,
|
||||
"the connected MCP server's tools should surface in the snapshot")
|
||||
|
||||
got := findMCPServer(m.Snapshot(), "fake")
|
||||
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)
|
||||
require.Equal(t, "echoes input", got.Tools[0].Description)
|
||||
}
|
||||
|
||||
// TestManager_MCPServerHangingCloseDoesNotStall is a regression test for
|
||||
// a server that ignores stdin-close. mcp-go's stdio Close() closes stdin
|
||||
// and then blocks on cmd.Wait(); without a force-kill the runner's
|
||||
// per-server connect (and thus the whole reload) would hang and the
|
||||
// tools would never be published. The runner force-kills the subprocess,
|
||||
// so the tool still surfaces in the snapshot.
|
||||
func TestManager_MCPServerHangingCloseDoesNotStall(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
writeMCPConfig(t, dir, "hang", map[string]string{
|
||||
"TEST_MCP_FAKE_SERVER": "1",
|
||||
"TEST_MCP_HANG_AFTER_LIST": "1",
|
||||
})
|
||||
|
||||
m := newTestManager(t, agentcontext.ManagerOptions{
|
||||
WorkingDir: func() string { return dir },
|
||||
MCPExecer: agentexec.DefaultExecer,
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
go func() { _ = m.Run(ctx) }()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
got := findMCPServer(m.Snapshot(), "hang")
|
||||
return got != nil && got.Status == agentcontext.StatusOK
|
||||
}, testutil.WaitLong, testutil.IntervalMedium,
|
||||
"a hanging MCP server must not stall the reload; its tool should still surface")
|
||||
}
|
||||
|
||||
// findMCPServer returns the KindMCPServer resource for the named server,
|
||||
// or nil if absent.
|
||||
func findMCPServer(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 &r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMCPConfig writes a .mcp.json into dir declaring a single stdio MCP
|
||||
// server that re-execs this test binary into serveFakeMCPServer (via the
|
||||
// TEST_MCP_FAKE_SERVER env, which TestMain handles).
|
||||
func writeMCPConfig(t *testing.T, dir, name string, env map[string]string) {
|
||||
t.Helper()
|
||||
testBin, err := os.Executable()
|
||||
require.NoError(t, err)
|
||||
cfg := map[string]any{
|
||||
"mcpServers": map[string]any{
|
||||
name: map[string]any{
|
||||
"command": testBin,
|
||||
"env": env,
|
||||
},
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".mcp.json"), data, 0o600))
|
||||
}
|
||||
|
||||
// maybeServeFakeMCPServer serves the fake stdio MCP server when
|
||||
// TEST_MCP_FAKE_SERVER=1 and reports whether it handled the process so
|
||||
// the caller (TestMain) can exit. The runner re-execs the test binary
|
||||
// into this, so it must run at the very top of TestMain. When
|
||||
// TEST_MCP_HANG_AFTER_LIST=1 the server blocks after serving instead of
|
||||
// returning, simulating a server that ignores stdin-close so a test can
|
||||
// exercise the runner's force-kill (the process is then killed by the
|
||||
// parent and never returns here).
|
||||
func maybeServeFakeMCPServer() (served bool) {
|
||||
if os.Getenv("TEST_MCP_FAKE_SERVER") != "1" {
|
||||
return false
|
||||
}
|
||||
serveFakeMCPServer()
|
||||
if os.Getenv("TEST_MCP_HANG_AFTER_LIST") == "1" {
|
||||
select {}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serveFakeMCPServer serves a minimal MCP protocol over stdin/stdout: it
|
||||
// answers initialize and advertises a single "echo" tool, then returns
|
||||
// when the client closes stdin (EOF).
|
||||
func serveFakeMCPServer() {
|
||||
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":
|
||||
// Notifications take no response.
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
package agentcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
)
|
||||
|
||||
// mcpConnectTimeout bounds how long the runner waits for a single MCP
|
||||
// server to start its transport, initialize, and report its tools.
|
||||
const mcpConnectTimeout = 30 * time.Second
|
||||
|
||||
// mcpConnectConcurrency bounds how many MCP servers the runner connects
|
||||
// to at once. .mcp.json files rarely declare many servers, but the cap
|
||||
// keeps a pathological config from spawning an unbounded number of
|
||||
// subprocesses simultaneously.
|
||||
const mcpConnectConcurrency = 8
|
||||
|
||||
// mcpServerConfig is a single MCP server declaration parsed from a
|
||||
// .mcp.json file. It is the runner's self-contained equivalent of the
|
||||
// agent/x/agentmcp ServerConfig: agentcontext deliberately does not
|
||||
// import that package so the two MCP paths stay completely separate.
|
||||
type mcpServerConfig struct {
|
||||
Name string
|
||||
Transport string
|
||||
Command string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
URL string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// mcpConfigFile mirrors the on-disk .mcp.json schema.
|
||||
type mcpConfigFile struct {
|
||||
MCPServers map[string]json.RawMessage `json:"mcpServers"`
|
||||
}
|
||||
|
||||
// mcpServerEntry is a single server block inside mcpServers.
|
||||
type mcpServerEntry struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
// parseMCPConfig reads a .mcp.json file at path and returns the declared
|
||||
// MCP servers sorted by name. It returns an empty slice when the
|
||||
// mcpServers key is missing or empty. It is a self-contained copy of the
|
||||
// agent/x/agentmcp parser so agentcontext can discover and start its own
|
||||
// MCP servers without importing that package.
|
||||
func parseMCPConfig(path string) ([]mcpServerConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read mcp config %q: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg mcpConfigFile
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, xerrors.Errorf("parse mcp config %q: %w", path, err)
|
||||
}
|
||||
|
||||
if len(cfg.MCPServers) == 0 {
|
||||
return []mcpServerConfig{}, nil
|
||||
}
|
||||
|
||||
servers := make([]mcpServerConfig, 0, len(cfg.MCPServers))
|
||||
for name, raw := range cfg.MCPServers {
|
||||
var entry mcpServerEntry
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return nil, xerrors.Errorf("parse server %q in %q: %w", name, path, err)
|
||||
}
|
||||
|
||||
tr := inferMCPTransport(entry)
|
||||
if tr == "" {
|
||||
return nil, xerrors.Errorf("server %q in %q has no command or url", name, path)
|
||||
}
|
||||
|
||||
resolveMCPEnvVars(entry.Env)
|
||||
|
||||
servers = append(servers, mcpServerConfig{
|
||||
Name: name,
|
||||
Transport: tr,
|
||||
Command: entry.Command,
|
||||
Args: entry.Args,
|
||||
Env: entry.Env,
|
||||
URL: entry.URL,
|
||||
Headers: entry.Headers,
|
||||
})
|
||||
}
|
||||
|
||||
slices.SortFunc(servers, func(a, b mcpServerConfig) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// inferMCPTransport determines the transport type for a server entry.
|
||||
// An explicit "type" field takes priority; otherwise the presence of
|
||||
// "command" implies stdio and "url" implies http.
|
||||
func inferMCPTransport(e mcpServerEntry) string {
|
||||
if e.Type != "" {
|
||||
return e.Type
|
||||
}
|
||||
if e.Command != "" {
|
||||
return "stdio"
|
||||
}
|
||||
if e.URL != "" {
|
||||
return "http"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveMCPEnvVars expands ${VAR} references in env map values using
|
||||
// the current process environment.
|
||||
func resolveMCPEnvVars(env map[string]string) {
|
||||
for k, v := range env {
|
||||
env[k] = os.Expand(v, os.Getenv)
|
||||
}
|
||||
}
|
||||
|
||||
// mcpRunner connects to the MCP servers declared in the .mcp.json files
|
||||
// the context resolver discovers, lists each server's tools, and caches
|
||||
// a non-blocking per-server snapshot that buildMCPServerResources turns
|
||||
// into KindMCPServer resources. It owns its own connection lifecycle and
|
||||
// does not share state with agent/x/agentmcp: the two MCP paths run
|
||||
// independently during the rollout. Connections are one-shot
|
||||
// (connect, initialize, list tools, close) because the runner only needs
|
||||
// each server's tool list to push to coderd, not a live tool-call proxy.
|
||||
type mcpRunner struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
updateEnv func([]string) ([]string, error)
|
||||
onChange func()
|
||||
|
||||
// reloadMu serializes Reload so a slow reload cannot interleave
|
||||
// with a newer one and publish a stale cache. The sole production
|
||||
// caller (runMCPSync) already calls Reload sequentially; the mutex
|
||||
// is defensive.
|
||||
reloadMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
cache []MCPServerStatus
|
||||
}
|
||||
|
||||
// newMCPRunner constructs a runner. onChange is invoked (outside the
|
||||
// cache lock) after a Reload that changes the per-server snapshot, so
|
||||
// the manager can re-resolve and push the updated KindMCPServer
|
||||
// resources. updateEnv may be nil.
|
||||
func newMCPRunner(logger slog.Logger, execer agentexec.Execer, updateEnv func([]string) ([]string, error), onChange func()) *mcpRunner {
|
||||
return &mcpRunner{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
updateEnv: updateEnv,
|
||||
onChange: onChange,
|
||||
}
|
||||
}
|
||||
|
||||
// Servers returns a deep copy of the current per-server MCP snapshot. It
|
||||
// never blocks on I/O: the resolver calls it on every re-resolve.
|
||||
func (r *mcpRunner) Servers() []MCPServerStatus {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return cloneMCPServers(r.cache)
|
||||
}
|
||||
|
||||
// Reload reparses the supplied .mcp.json paths, connects to every
|
||||
// declared server in parallel, lists its tools, and replaces the cached
|
||||
// snapshot with the fresh result (including per-server failures). It
|
||||
// fires onChange when the snapshot changed. Reload is best-effort: a
|
||||
// server that fails to connect or list tools is recorded as a
|
||||
// disconnected entry rather than aborting the whole reload.
|
||||
func (r *mcpRunner) Reload(ctx context.Context, paths []string) {
|
||||
r.reloadMu.Lock()
|
||||
defer r.reloadMu.Unlock()
|
||||
|
||||
configs := r.parseConfigs(ctx, paths)
|
||||
statuses := r.connectAll(ctx, configs)
|
||||
|
||||
r.mu.Lock()
|
||||
changed := !reflect.DeepEqual(r.cache, statuses)
|
||||
r.cache = statuses
|
||||
r.mu.Unlock()
|
||||
|
||||
if changed && r.onChange != nil {
|
||||
r.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
// parseConfigs parses every path and returns the union of declared
|
||||
// servers, deduplicated by name (first occurrence wins). Missing files
|
||||
// are skipped silently; other parse errors are logged and skipped so one
|
||||
// broken .mcp.json does not drop the servers declared in sibling files.
|
||||
func (r *mcpRunner) parseConfigs(ctx context.Context, paths []string) []mcpServerConfig {
|
||||
var all []mcpServerConfig
|
||||
for _, path := range paths {
|
||||
configs, err := parseMCPConfig(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
r.logger.Warn(ctx, "failed to parse MCP config",
|
||||
slog.F("path", path),
|
||||
slog.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
all = append(all, configs...)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(all))
|
||||
deduped := make([]mcpServerConfig, 0, len(all))
|
||||
for _, cfg := range all {
|
||||
if _, ok := seen[cfg.Name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cfg.Name] = struct{}{}
|
||||
deduped = append(deduped, cfg)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
// connectAll connects to each server in parallel (bounded) and returns
|
||||
// one status per server in name order. Per-server failures are isolated:
|
||||
// a failed connect or list becomes a disconnected status carrying the
|
||||
// error instead of failing the batch.
|
||||
func (r *mcpRunner) connectAll(ctx context.Context, configs []mcpServerConfig) []MCPServerStatus {
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
}
|
||||
statuses := make([]MCPServerStatus, len(configs))
|
||||
var eg errgroup.Group
|
||||
eg.SetLimit(mcpConnectConcurrency)
|
||||
for i, cfg := range configs {
|
||||
eg.Go(func() error {
|
||||
st := MCPServerStatus{Name: cfg.Name}
|
||||
tools, err := r.connectAndList(ctx, cfg)
|
||||
if err != nil {
|
||||
r.logger.Warn(ctx, "failed to connect MCP server",
|
||||
slog.F("server", cfg.Name),
|
||||
slog.Error(err),
|
||||
)
|
||||
st.Err = err.Error()
|
||||
} else {
|
||||
st.Connected = true
|
||||
st.Tools = tools
|
||||
}
|
||||
statuses[i] = st
|
||||
return nil
|
||||
})
|
||||
}
|
||||
_ = eg.Wait()
|
||||
return statuses
|
||||
}
|
||||
|
||||
// connectAndList starts a single MCP server, completes the initialize
|
||||
// handshake, lists its tools, and closes the connection. Tool names are
|
||||
// returned exactly as the server reported them; the resource carries the
|
||||
// server name separately, so any flattening into a single namespace is
|
||||
// left to the control plane.
|
||||
func (r *mcpRunner) connectAndList(ctx context.Context, cfg mcpServerConfig) ([]MCPTool, error) {
|
||||
tr, err := r.createTransport(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create transport for %q: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
c := client.NewClient(tr)
|
||||
|
||||
// Tie the subprocess to cmdCtx. mcp-go's stdio Close() closes stdin
|
||||
// and then blocks on cmd.Wait() with no kill: a server that ignores
|
||||
// stdin-close would stall this reload indefinitely (the deferred
|
||||
// Close runs before connectAndList returns, so it would block the
|
||||
// errgroup and hold the reload lock). Canceling cmdCtx force-kills
|
||||
// the process via exec.CommandContext, so Close's Wait returns. The
|
||||
// deferred cleanup cancels before closing, and runs before the
|
||||
// connectCtx cancel because defers are LIFO.
|
||||
cmdCtx, cmdCancel := context.WithCancel(ctx)
|
||||
connectCtx, cancel := context.WithTimeout(cmdCtx, mcpConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := c.Start(cmdCtx); err != nil {
|
||||
cmdCancel()
|
||||
_ = c.Close()
|
||||
return nil, xerrors.Errorf("start %q: %w", cfg.Name, err)
|
||||
}
|
||||
defer func() {
|
||||
cmdCancel()
|
||||
_ = c.Close()
|
||||
}()
|
||||
|
||||
if _, err := c.Initialize(connectCtx, mcp.InitializeRequest{
|
||||
Params: mcp.InitializeParams{
|
||||
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
||||
ClientInfo: mcp.Implementation{
|
||||
Name: "coder-agent",
|
||||
Version: buildinfo.Version(),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, xerrors.Errorf("initialize %q: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
result, err := c.ListTools(connectCtx, mcp.ListToolsRequest{})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("list tools from %q: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
tools := make([]MCPTool, 0, len(result.Tools))
|
||||
for _, tool := range result.Tools {
|
||||
tools = append(tools, MCPTool{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
InputSchema: toolInputSchema(tool.InputSchema),
|
||||
})
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// createTransport builds the mcp-go transport for a server config.
|
||||
func (r *mcpRunner) createTransport(ctx context.Context, cfg mcpServerConfig) (transport.Interface, error) {
|
||||
switch cfg.Transport {
|
||||
case "stdio":
|
||||
env := r.buildEnv(ctx, cfg.Env)
|
||||
return transport.NewStdioWithOptions(
|
||||
cfg.Command,
|
||||
env,
|
||||
cfg.Args,
|
||||
transport.WithCommandFunc(func(ctx context.Context, command string, cmdEnv []string, args []string) (*exec.Cmd, error) {
|
||||
cmd := r.execer.CommandContext(ctx, command, args...)
|
||||
cmd.Env = cmdEnv
|
||||
return cmd, nil
|
||||
}),
|
||||
), nil
|
||||
case "http", "":
|
||||
return transport.NewStreamableHTTP(cfg.URL, transport.WithHTTPHeaders(cfg.Headers))
|
||||
case "sse":
|
||||
return transport.NewSSE(cfg.URL, transport.WithHeaders(cfg.Headers))
|
||||
default:
|
||||
return nil, xerrors.Errorf("unsupported transport %q", cfg.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// buildEnv enriches the process environment via the agent's updateEnv
|
||||
// callback, then merges explicit overrides from the server config on
|
||||
// top. Note: env enrichment is captured per Reload; an env change alone
|
||||
// (without a .mcp.json change) does not trigger a re-list.
|
||||
func (r *mcpRunner) buildEnv(ctx context.Context, explicit map[string]string) []string {
|
||||
env := usershell.SystemEnvInfo{}.Environ()
|
||||
if r.updateEnv != nil {
|
||||
updated, err := r.updateEnv(env)
|
||||
if err != nil {
|
||||
r.logger.Warn(ctx, "failed to enrich MCP server environment", slog.Error(err))
|
||||
env = usershell.SystemEnvInfo{}.Environ()
|
||||
} else {
|
||||
env = updated
|
||||
}
|
||||
}
|
||||
if len(explicit) == 0 {
|
||||
return env
|
||||
}
|
||||
|
||||
existing := make(map[string]int, len(env))
|
||||
for i, kv := range env {
|
||||
if k, _, ok := strings.Cut(kv, "="); ok {
|
||||
existing[k] = i
|
||||
}
|
||||
}
|
||||
for k, v := range explicit {
|
||||
entry := k + "=" + v
|
||||
if idx, ok := existing[k]; ok {
|
||||
env[idx] = entry
|
||||
} else {
|
||||
env = append(env, entry)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// toolInputSchema converts an mcp-go tool input schema into the
|
||||
// JSON-Schema-shaped map MCPTool carries. Required is converted to
|
||||
// []any (not []string) so the downstream structpb encoding accepts it.
|
||||
// An empty schema yields nil so the tool ships with InputSchema unset.
|
||||
func toolInputSchema(s mcp.ToolInputSchema) map[string]any {
|
||||
out := map[string]any{}
|
||||
if s.Type != "" {
|
||||
out["type"] = s.Type
|
||||
}
|
||||
if len(s.Properties) > 0 {
|
||||
out["properties"] = s.Properties
|
||||
}
|
||||
if len(s.Required) > 0 {
|
||||
required := make([]any, len(s.Required))
|
||||
for i, req := range s.Required {
|
||||
required[i] = req
|
||||
}
|
||||
out["required"] = required
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cloneMCPServers deep-copies a per-server snapshot so callers cannot
|
||||
// mutate the runner's cache. Tool input schemas are treated as immutable
|
||||
// and shared by reference.
|
||||
func cloneMCPServers(in []MCPServerStatus) []MCPServerStatus {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MCPServerStatus, len(in))
|
||||
for i, s := range in {
|
||||
s.Tools = slices.Clone(s.Tools)
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runMCPSync keeps the runner's connected servers in sync with the
|
||||
// .mcp.json files the resolver discovers. It subscribes to snapshot
|
||||
// changes, extracts the set of KindMCPConfig paths (keyed by content
|
||||
// hash so in-place edits are detected), and reloads the runner only when
|
||||
// that set changes. A reload fires the runner's onChange, which
|
||||
// re-resolves and surfaces the updated KindMCPServer resources; that
|
||||
// re-resolve does not change the config set, so it does not loop.
|
||||
func (m *Manager) runMCPSync(ctx context.Context) {
|
||||
changes, unsubscribe := m.SubscribeChanges()
|
||||
defer unsubscribe()
|
||||
|
||||
var lastKey string
|
||||
reload := func() {
|
||||
paths, key := mcpConfigSet(m.Snapshot())
|
||||
if key == lastKey {
|
||||
return
|
||||
}
|
||||
lastKey = key
|
||||
m.mcpRunner.Reload(ctx, paths)
|
||||
}
|
||||
|
||||
// Pick up any .mcp.json discovered before we subscribed.
|
||||
reload()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.closedCh:
|
||||
return
|
||||
case <-changes:
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mcpConfigSet extracts the .mcp.json config files from a snapshot's
|
||||
// KindMCPConfig resources. It returns the sorted unique source paths
|
||||
// plus a key encoding path:contenthash pairs, so callers detect both
|
||||
// path-set changes and in-place content edits. An empty set yields an
|
||||
// empty key.
|
||||
func mcpConfigSet(snap Snapshot) (paths []string, key string) {
|
||||
hashes := make(map[string]string, len(snap.Resources))
|
||||
for _, r := range snap.Resources {
|
||||
if r.Kind != KindMCPConfig || r.Source == "" {
|
||||
continue
|
||||
}
|
||||
hashes[r.Source] = hex.EncodeToString(r.ContentHash[:])
|
||||
}
|
||||
if len(hashes) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
paths = make([]string, 0, len(hashes))
|
||||
for p := range hashes {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
slices.Sort(paths)
|
||||
parts := make([]string, len(paths))
|
||||
for i, p := range paths {
|
||||
parts[i] = p + ":" + hashes[p]
|
||||
}
|
||||
return paths, strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package agentcontext
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseMCPConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
write := func(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".mcp.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("InfersTransportAndSorts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := write(t, `{"mcpServers": {
|
||||
"zebra": {"command": "zebra-bin", "args": ["--flag"]},
|
||||
"alpha": {"url": "https://example.com/mcp"}
|
||||
}}`)
|
||||
got, err := parseMCPConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
// Sorted by name.
|
||||
require.Equal(t, "alpha", got[0].Name)
|
||||
require.Equal(t, "http", got[0].Transport)
|
||||
require.Equal(t, "https://example.com/mcp", got[0].URL)
|
||||
require.Equal(t, "zebra", got[1].Name)
|
||||
require.Equal(t, "stdio", got[1].Transport)
|
||||
require.Equal(t, "zebra-bin", got[1].Command)
|
||||
require.Equal(t, []string{"--flag"}, got[1].Args)
|
||||
})
|
||||
|
||||
t.Run("ExplicitTypeWins", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := write(t, `{"mcpServers": {"s": {"type": "sse", "url": "https://x"}}}`)
|
||||
got, err := parseMCPConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "sse", got[0].Transport)
|
||||
})
|
||||
|
||||
t.Run("EmptyServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := write(t, `{"mcpServers": {}}`)
|
||||
got, err := parseMCPConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("RejectsServerWithoutCommandOrURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := write(t, `{"mcpServers": {"s": {}}}`)
|
||||
_, err := parseMCPConfig(path)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InvalidJSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := write(t, `{not json`)
|
||||
_, err := parseMCPConfig(path)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestParseMCPConfig_ExpandsEnv is a standalone (non-parallel) test
|
||||
// because t.Setenv cannot be used under a parallel parent test.
|
||||
func TestParseMCPConfig_ExpandsEnv(t *testing.T) {
|
||||
t.Setenv("AGENTCONTEXT_MCP_TEST_TOKEN", "secret")
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, ".mcp.json")
|
||||
require.NoError(t, os.WriteFile(path,
|
||||
[]byte(`{"mcpServers": {"s": {"command": "x", "env": {"TOKEN": "${AGENTCONTEXT_MCP_TEST_TOKEN}"}}}}`), 0o600))
|
||||
got, err := parseMCPConfig(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, "secret", got[0].Env["TOKEN"])
|
||||
}
|
||||
|
||||
func TestToolInputSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("FullSchema", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := toolInputSchema(mcp.ToolInputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]any{"q": map[string]any{"type": "string"}},
|
||||
Required: []string{"q"},
|
||||
})
|
||||
require.Equal(t, "object", got["type"])
|
||||
require.Equal(t, map[string]any{"q": map[string]any{"type": "string"}}, got["properties"])
|
||||
// Required is converted to []any so structpb.NewStruct accepts it.
|
||||
require.Equal(t, []any{"q"}, got["required"])
|
||||
})
|
||||
|
||||
t.Run("EmptyYieldsNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Nil(t, toolInputSchema(mcp.ToolInputSchema{}))
|
||||
})
|
||||
|
||||
t.Run("TypeOnly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := toolInputSchema(mcp.ToolInputSchema{Type: "object"})
|
||||
require.Equal(t, map[string]any{"type": "object"}, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMCPConfigSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
paths, key := mcpConfigSet(Snapshot{})
|
||||
require.Empty(t, paths)
|
||||
require.Empty(t, key)
|
||||
})
|
||||
|
||||
t.Run("SortedAndKeyedByContentHash", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
snap := Snapshot{Resources: []Resource{
|
||||
{Kind: KindMCPConfig, Source: "/b/.mcp.json", ContentHash: [32]byte{0x01}},
|
||||
{Kind: KindMCPConfig, Source: "/a/.mcp.json", ContentHash: [32]byte{0x02}},
|
||||
// Non-config and empty-source resources are ignored.
|
||||
{Kind: KindInstructionFile, Source: "/a/AGENTS.md"},
|
||||
{Kind: KindMCPServer, Source: "fs"},
|
||||
{Kind: KindMCPConfig, Source: ""},
|
||||
}}
|
||||
paths, key := mcpConfigSet(snap)
|
||||
require.Equal(t, []string{"/a/.mcp.json", "/b/.mcp.json"}, paths)
|
||||
require.NotEmpty(t, key)
|
||||
|
||||
// An in-place content edit (same path, new hash) changes the key.
|
||||
snap2 := Snapshot{Resources: []Resource{
|
||||
{Kind: KindMCPConfig, Source: "/b/.mcp.json", ContentHash: [32]byte{0x01}},
|
||||
{Kind: KindMCPConfig, Source: "/a/.mcp.json", ContentHash: [32]byte{0x09}},
|
||||
}}
|
||||
_, key2 := mcpConfigSet(snap2)
|
||||
require.NotEqual(t, key, key2)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user