Vectrum
Sign In

mcp: start the configured servers in the working root, and offer their tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 7b05038commit03b5fe4

Showing 4 changed files with 107 additions and 11 deletions.

+43−2README.mdView File
@@ -55,7 +55,7 @@ would break your Claude Code login.
5555 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
5656 `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`,
5757 `-no-tui`, `-no-skills`, `-no-agents`, `-no-web`, `-no-hooks`,
58- `-no-diagnostics`, `-no-claude-code-auth`.
58+ `-no-diagnostics`, `-no-mcp`, `-no-claude-code-auth`.
5959
6060 ### Flags and thresholds
6161
@@ -214,6 +214,13 @@ compaction stops being optional, and it can be turned off altogether: see
214214 | `esc` | interrupt the turn |
215215 | `ctrl+c` | interrupt the turn; again when idle to exit |
216216
217+ Interrupting ends the turn, not the session. A tool still running when you
218+ press `esc` is answered with an error saying it was interrupted, and one that
219+ had already finished keeps its result, so the conversation is never left with
220+ a `tool_use` nothing answered — which the API rejects on every request after
221+ it, the next thing you type or a `-resume` weeks later. A session saved in
222+ that state by an older build is repaired when it is reopened.
223+
217224 Commands: `/allowed`, `/clear`, `/close`, `/compact`, `/effort`, `/exit`,
218225 `/help`, `/image`, `/model`, `/mouse`, `/new`, `/resume`, `/settings`, `/setup`,
219226 `/skills`, `/worktree`, and one per discovered skill.
@@ -952,6 +959,7 @@ and any key skips the one playing.
952959 | `ask_user` | never | Puts a multiple-choice question to you and waits — see [questions](#questions) |
953960 | `web_search` | prompts | Only with `web_search: custom`; runs `ddgs` on this machine |
954961 | `web_fetch` | prompts | With either web search mode; refuses private and loopback addresses |
962+ | `mcp__<server>__<tool>` | prompts | One per tool of each [MCP server](#mcp-servers) in the config |
955963
956964 ## Diagnostics
957965
@@ -1155,6 +1163,36 @@ set is for. The query and the URL are the detail on the prompt, so what is
11551163 leaving is on screen before it leaves. "Always this tool" is the answer most
11561164 people give once.
11571165
1166+ ## MCP servers
1167+
1168+ Codey is an MCP client over stdio. Servers go in the config by hand, in the
1169+ same shape Claude Code and Codex use, so an entry can be carried across from
1170+ either unchanged:
1171+
1172+ ```json
1173+ {
1174+ "mcp_servers": {
1175+ "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] },
1176+ "db": { "command": "my-db-mcp", "env": { "DB_URL": "postgres://localhost/dev" } }
1177+ }
1178+ }
1179+ ```
1180+
1181+ Each server is started once, when the session opens, in the working root, and
1182+ every tool it lists is offered as `mcp__<name>__<tool>` — the name being the
1183+ key you gave it, not whatever the server calls itself. Subagents get the same
1184+ tools from the same processes. Every call prompts like `bash` does, with
1185+ "always this tool" to stop asking. A server that is not installed, does not
1186+ answer within thirty seconds or speaks a protocol version Codey does not is
1187+ reported at startup and left out; the session goes on without it. A server
1188+ that dies later is not restarted, and its tools say so when called.
1189+
1190+ Only tools are used: not resources, prompts or sampling, and no HTTP
1191+ transport. Pictures a tool returns are described to the model rather than
1192+ shown. A server's directory is fixed when it starts, so after `/worktree` it
1193+ is still serving the root the session began in. `-no-mcp` starts none of them
1194+ for one run.
1195+
11581196 ## Hooks
11591197
11601198 Codey publishes its lifecycle — a session opened, a run started, a tool ran, an
@@ -1409,7 +1447,9 @@ own config rather than the agent asking for it. The path jail is what keeps the
14091447 agent from adding one — the config lives in `~/.config/codey/`, outside any
14101448 working root you would sensibly point Codey at — but the flip side is that
14111449 anything able to write that file has arranged to run commands here. `-no-hooks`
1412- is the switch that says no for one run.
1450+ is the switch that says no for one run. MCP servers are the same bargain: each
1451+ is a program started with your environment when the session opens, and only
1452+ its tool calls go through the prompts. `-no-mcp` is theirs.
14131453
14141454 **A sandbox.** This is the layer that actually contains a mistake, and the
14151455 Codey cannot provide it for you. The honest recipe on Linux:
@@ -1446,6 +1486,7 @@ internal/tools the tool registry and the tools
14461486 internal/diag language servers and linters, and what they say about an edit
14471487 internal/skills discovery and parsing of SKILL.md, Codey's own and Claude Code's
14481488 internal/websearch the search command, the URL jail, HTML to text
1489+ internal/mcp MCP client: starts the configured servers, offers their tools
14491490 internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics
14501491 internal/approval allow / deny / ask, with shell analysis
14511492 internal/ask questions the agent puts to you mid-task, and the tool for it
+20−0cmd/codey/main.goView File
@@ -23,6 +23,7 @@ import (
2323 "codey/internal/herdr"
2424 "codey/internal/hooks"
2525 "codey/internal/llm"
26+ "codey/internal/mcp"
2627 "codey/internal/metrics"
2728 "codey/internal/serve"
2829 "codey/internal/skills"
@@ -91,6 +92,7 @@ func run() error {
9192 noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename)
9293 noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr")
9394 noDiagnostics := flag.Bool("no-diagnostics", false, "do not check an edited file with the language server or linter installed for it")
95+ noMCP := flag.Bool("no-mcp", false, "do not start the MCP servers in the config, so their tools are not offered")
9496
9597 // Read the line before the flag package does. Three invocations belong to
9698 // a program driving Codey rather than to a person — see agentcmd.go — and
@@ -354,6 +356,23 @@ func run() error {
354356 procs := tools.NewProcesses()
355357 defer procs.Close()
356358
359+ // The MCP servers the config names, started once for the session and
360+ // shared by every agent in it, subagents included, as Claude Code shares
361+ // them: a server is a process, and one per agent would be four copies of
362+ // the same database connection. They run in the root Codey was started
363+ // in and stay there across /worktree, since a server's working directory
364+ // is fixed when it starts. A server that will not start is reported and
365+ // left out; the session goes on without its tools.
366+ var mcpTools []tools.Tool
367+ if len(cfg.MCPServers) > 0 && !*noMCP {
368+ ts, closer, errs := mcp.Connect(context.Background(), root, cfg)
369+ defer closer.Close()
370+ for _, err := range errs {
371+ notice("%v", err)
372+ }
373+ mcpTools = ts
374+ }
375+
357376 // The working plan. It is the session's, not any one agent's: a subagent
358377 // rewriting the parent's list would be two hands on the same page.
359378 todos := tools.NewTodos()
@@ -401,6 +420,7 @@ func run() error {
401420 // — so the fetch half goes in on its own.
402421 reg.Register(tools.NewWebFetch())
403422 }
423+ reg.Register(mcpTools...)
404424 return reg
405425 }
406426 }
+34−6internal/mcp/client_test.goView File
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "os"
11+ "path/filepath"
1112 "strings"
1213 "sync"
1314 "testing"
@@ -613,31 +614,58 @@ func TestMain(m *testing.M) {
613614
614615 // serveOnStdio is the child's half of TestConnect*: a well-behaved server on
615616 // the real stdin and stdout. In "hang" mode it never answers, for the
616- // timeout-and-kill path.
617+ // timeout-and-kill path; in "cwd" mode its one tool says where it is running.
617618 func serveOnStdio(mode string) {
618619 if mode == "hang" {
619620 _, _ = io.Copy(io.Discard, os.Stdin)
620621 return
621622 }
622623 f := &fakeServer{in: bufio.NewScanner(os.Stdin), out: os.Stdout}
624+ if mode == "cwd" {
625+ f.loop(standard(protocolVersion, []map[string]any{{"name": "where", "description": "Says where."}}, func(f *fakeServer, msg incoming) {
626+ wd, _ := os.Getwd()
627+ f.respond(msg.ID, textResult(wd, false))
628+ }))
629+ return
630+ }
623631 f.loop(standard(protocolVersion, []map[string]any{{"name": "hello", "description": "Greets."}}, func(f *fakeServer, msg incoming) {
624632 f.respond(msg.ID, textResult("hello from pid "+fmt.Sprint(os.Getpid()), false))
625633 }))
626634 }
627635
628636 func TestConnectWithNoServersIsANoOp(t *testing.T) {
629- ts, closer, errs := Connect(context.Background(), config.Config{})
637+ ts, closer, errs := Connect(context.Background(), "", config.Config{})
630638 if len(ts) != 0 || len(errs) != 0 || closer == nil {
631639 t.Fatalf("Connect = %v, %v, %v; want nothing, a closer, no errors", ts, closer, errs)
632640 }
633641 _ = closer.Close()
634642 }
635643
644+ func TestConnectStartsServersInTheGivenDirectory(t *testing.T) {
645+ dir := t.TempDir()
646+ cfg := config.Config{MCPServers: map[string]config.MCPServer{
647+ "self": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "cwd"}},
648+ }}
649+ ts, closer, errs := Connect(ctxWith(t, 10*time.Second), dir, cfg)
650+ defer closer.Close()
651+ if len(errs) != 0 || len(ts) != 1 {
652+ t.Fatalf("Connect = %v, %v; want one tool, no errors", ts, errs)
653+ }
654+ out, err := ts[0].Execute(ctxWith(t, 5*time.Second), nil)
655+ if err != nil {
656+ t.Fatalf("Execute: %v", err)
657+ }
658+ want, _ := filepath.EvalSymlinks(dir)
659+ if got, _ := filepath.EvalSymlinks(out); got != want {
660+ t.Errorf("server ran in %q, want %q", out, dir)
661+ }
662+ }
663+
636664 func TestConnectRunsARealServerAndClosesIt(t *testing.T) {
637665 cfg := config.Config{MCPServers: map[string]config.MCPServer{
638666 "self": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "serve"}},
639667 }}
640- ts, closer, errs := Connect(ctxWith(t, 10*time.Second), cfg)
668+ ts, closer, errs := Connect(ctxWith(t, 10*time.Second), "", cfg)
641669 if len(errs) != 0 {
642670 t.Fatalf("Connect errors: %v", errs)
643671 }
@@ -668,7 +696,7 @@ func TestConnectKillsAServerThatNeverAnswers(t *testing.T) {
668696 "mute": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "hang"}},
669697 }}
670698 // The handshake deadline is the shorter of ours and initTimeout.
671- ts, closer, errs := Connect(ctxWith(t, 200*time.Millisecond), cfg)
699+ ts, closer, errs := Connect(ctxWith(t, 200*time.Millisecond), "", cfg)
672700 if len(ts) != 0 {
673701 t.Errorf("tools = %v from a server that never answered", ts)
674702 }
@@ -683,7 +711,7 @@ func TestConnectReportsAServerThatIsNotInstalled(t *testing.T) {
683711 "ghost": {Command: "codey-no-such-mcp-server-3f9a"},
684712 "blank": {},
685713 }}
686- ts, closer, errs := Connect(ctxWith(t, 5*time.Second), cfg)
714+ ts, closer, errs := Connect(ctxWith(t, 5*time.Second), "", cfg)
687715 if len(ts) != 0 {
688716 t.Errorf("tools = %v, want none", ts)
689717 }
@@ -703,7 +731,7 @@ func TestConnectCarriesStderrOfAServerThatExitsAtOnce(t *testing.T) {
703731 cfg := config.Config{MCPServers: map[string]config.MCPServer{
704732 "crash": {Command: "sh", Args: []string{"-c", "echo 'missing API key' >&2; exit 3"}},
705733 }}
706- ts, closer, errs := Connect(ctxWith(t, 5*time.Second), cfg)
734+ ts, closer, errs := Connect(ctxWith(t, 5*time.Second), "", cfg)
707735 if len(ts) != 0 || len(errs) != 1 {
708736 t.Fatalf("Connect = %v, %v; want no tools and one error", ts, errs)
709737 }
+10−3internal/mcp/connect.goView File
@@ -45,7 +45,13 @@ const stderrTail = 4 << 10
4545 // installed, will not start, or speaks a protocol this client does not is
4646 // reported and its tools are simply not there. The closer is always
4747 // returned, even when nothing started, so the caller has one thing to defer.
48- func Connect(ctx context.Context, cfg config.Config) ([]tools.Tool, io.Closer, []error) {
48+ //
49+ // Every server runs in dir, the working root, as it would under Claude Code:
50+ // a filesystem or git server started with no path of its own serves the
51+ // directory it was started in, and that should be the one the agent works in
52+ // rather than wherever Codey was launched from. An empty dir leaves the
53+ // server in Codey's own.
54+ func Connect(ctx context.Context, dir string, cfg config.Config) ([]tools.Tool, io.Closer, []error) {
4955 names := make([]string, 0, len(cfg.MCPServers))
5056 for name := range cfg.MCPServers {
5157 names = append(names, name)
@@ -58,7 +64,7 @@ func Connect(ctx context.Context, cfg config.Config) ([]tools.Tool, io.Closer, [
5864 wg.Add(1)
5965 go func() {
6066 defer wg.Done()
61- c, infos, err := start(ctx, name, cfg.MCPServers[name])
67+ c, infos, err := start(ctx, dir, name, cfg.MCPServers[name])
6268 results[i] = started{name: name, c: c, tools: infos, err: err}
6369 }()
6470 }
@@ -126,12 +132,13 @@ func assemble(results []started) ([]tools.Tool, *servers, []error) {
126132 // start runs one server through to a listed set of tools. The client is
127133 // returned even on failure so the caller can close it; a nil client means
128134 // the process never started.
129- func start(ctx context.Context, name string, spec config.MCPServer) (*client, []toolInfo, error) {
135+ func start(ctx context.Context, dir, name string, spec config.MCPServer) (*client, []toolInfo, error) {
130136 command := strings.TrimSpace(spec.Command)
131137 if command == "" {
132138 return nil, nil, fmt.Errorf("no command configured")
133139 }
134140 cmd := exec.Command(command, spec.Args...)
141+ cmd.Dir = dir
135142 cmd.Env = append(os.Environ(), envList(spec.Env)...)
136143 // A new process group, so a server started through npx or uvx can be
137144 // killed whole. Killing only the wrapper leaves the real server holding