Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 4 changed files with 107 additions and 11 deletions.
| @@ -55,7 +55,7 @@ would break your Claude Code login. | ||
| 55 | 55 | Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, |
| 56 | 56 | `-resume latest`, `-no-stream`, `-no-cache`, `-no-auto-compact`, `-v`, `-yolo`, |
| 57 | 57 | `-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`. | |
| 59 | 59 | |
| 60 | 60 | ### Flags and thresholds |
| 61 | 61 | |
| @@ -214,6 +214,13 @@ compaction stops being optional, and it can be turned off altogether: see | ||
| 214 | 214 | | `esc` | interrupt the turn | |
| 215 | 215 | | `ctrl+c` | interrupt the turn; again when idle to exit | |
| 216 | 216 | |
| 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 | + | |
| 217 | 224 | Commands: `/allowed`, `/clear`, `/close`, `/compact`, `/effort`, `/exit`, |
| 218 | 225 | `/help`, `/image`, `/model`, `/mouse`, `/new`, `/resume`, `/settings`, `/setup`, |
| 219 | 226 | `/skills`, `/worktree`, and one per discovered skill. |
| @@ -952,6 +959,7 @@ and any key skips the one playing. | ||
| 952 | 959 | | `ask_user` | never | Puts a multiple-choice question to you and waits — see [questions](#questions) | |
| 953 | 960 | | `web_search` | prompts | Only with `web_search: custom`; runs `ddgs` on this machine | |
| 954 | 961 | | `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 | | |
| 955 | 963 | |
| 956 | 964 | ## Diagnostics |
| 957 | 965 | |
| @@ -1155,6 +1163,36 @@ set is for. The query and the URL are the detail on the prompt, so what is | ||
| 1155 | 1163 | leaving is on screen before it leaves. "Always this tool" is the answer most |
| 1156 | 1164 | people give once. |
| 1157 | 1165 | |
| 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 | + | |
| 1158 | 1196 | ## Hooks |
| 1159 | 1197 | |
| 1160 | 1198 | 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 | ||
| 1409 | 1447 | agent from adding one — the config lives in `~/.config/codey/`, outside any |
| 1410 | 1448 | working root you would sensibly point Codey at — but the flip side is that |
| 1411 | 1449 | 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. | |
| 1413 | 1453 | |
| 1414 | 1454 | **A sandbox.** This is the layer that actually contains a mistake, and the |
| 1415 | 1455 | Codey cannot provide it for you. The honest recipe on Linux: |
| @@ -1446,6 +1486,7 @@ internal/tools the tool registry and the tools | ||
| 1446 | 1486 | internal/diag language servers and linters, and what they say about an edit |
| 1447 | 1487 | internal/skills discovery and parsing of SKILL.md, Codey's own and Claude Code's |
| 1448 | 1488 | internal/websearch the search command, the URL jail, HTML to text |
| 1489 | + internal/mcp MCP client: starts the configured servers, offers their tools | |
| 1449 | 1490 | internal/config saved defaults: model, effort, approvals, web search, theme, diagnostics |
| 1450 | 1491 | internal/approval allow / deny / ask, with shell analysis |
| 1451 | 1492 | internal/ask questions the agent puts to you mid-task, and the tool for it |
| @@ -23,6 +23,7 @@ import ( | ||
| 23 | 23 | "codey/internal/herdr" |
| 24 | 24 | "codey/internal/hooks" |
| 25 | 25 | "codey/internal/llm" |
| 26 | + "codey/internal/mcp" | |
| 26 | 27 | "codey/internal/metrics" |
| 27 | 28 | "codey/internal/serve" |
| 28 | 29 | "codey/internal/skills" |
| @@ -91,6 +92,7 @@ func run() error { | ||
| 91 | 92 | noClaudeCodeAuth := flag.Bool("no-claude-code-auth", false, "do not fall back to the OAuth login in ~/"+claudecode.Filename) |
| 92 | 93 | noHooks := flag.Bool("no-hooks", false, "do not run the hooks in the config, and do not report agent state to herdr") |
| 93 | 94 | 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") | |
| 94 | 96 | |
| 95 | 97 | // Read the line before the flag package does. Three invocations belong to |
| 96 | 98 | // a program driving Codey rather than to a person — see agentcmd.go — and |
| @@ -354,6 +356,23 @@ func run() error { | ||
| 354 | 356 | procs := tools.NewProcesses() |
| 355 | 357 | defer procs.Close() |
| 356 | 358 | |
| 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 | + | |
| 357 | 376 | // The working plan. It is the session's, not any one agent's: a subagent |
| 358 | 377 | // rewriting the parent's list would be two hands on the same page. |
| 359 | 378 | todos := tools.NewTodos() |
| @@ -401,6 +420,7 @@ func run() error { | ||
| 401 | 420 | // — so the fetch half goes in on its own. |
| 402 | 421 | reg.Register(tools.NewWebFetch()) |
| 403 | 422 | } |
| 423 | + reg.Register(mcpTools...) | |
| 404 | 424 | return reg |
| 405 | 425 | } |
| 406 | 426 | } |
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "fmt" |
| 9 | 9 | "io" |
| 10 | 10 | "os" |
| 11 | + "path/filepath" | |
| 11 | 12 | "strings" |
| 12 | 13 | "sync" |
| 13 | 14 | "testing" |
| @@ -613,31 +614,58 @@ func TestMain(m *testing.M) { | ||
| 613 | 614 | |
| 614 | 615 | // serveOnStdio is the child's half of TestConnect*: a well-behaved server on |
| 615 | 616 | // 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. | |
| 617 | 618 | func serveOnStdio(mode string) { |
| 618 | 619 | if mode == "hang" { |
| 619 | 620 | _, _ = io.Copy(io.Discard, os.Stdin) |
| 620 | 621 | return |
| 621 | 622 | } |
| 622 | 623 | 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 | + } | |
| 623 | 631 | f.loop(standard(protocolVersion, []map[string]any{{"name": "hello", "description": "Greets."}}, func(f *fakeServer, msg incoming) { |
| 624 | 632 | f.respond(msg.ID, textResult("hello from pid "+fmt.Sprint(os.Getpid()), false)) |
| 625 | 633 | })) |
| 626 | 634 | } |
| 627 | 635 | |
| 628 | 636 | func TestConnectWithNoServersIsANoOp(t *testing.T) { |
| 629 | - ts, closer, errs := Connect(context.Background(), config.Config{}) | |
| 637 | + ts, closer, errs := Connect(context.Background(), "", config.Config{}) | |
| 630 | 638 | if len(ts) != 0 || len(errs) != 0 || closer == nil { |
| 631 | 639 | t.Fatalf("Connect = %v, %v, %v; want nothing, a closer, no errors", ts, closer, errs) |
| 632 | 640 | } |
| 633 | 641 | _ = closer.Close() |
| 634 | 642 | } |
| 635 | 643 | |
| 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 | + | |
| 636 | 664 | func TestConnectRunsARealServerAndClosesIt(t *testing.T) { |
| 637 | 665 | cfg := config.Config{MCPServers: map[string]config.MCPServer{ |
| 638 | 666 | "self": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "serve"}}, |
| 639 | 667 | }} |
| 640 | - ts, closer, errs := Connect(ctxWith(t, 10*time.Second), cfg) | |
| 668 | + ts, closer, errs := Connect(ctxWith(t, 10*time.Second), "", cfg) | |
| 641 | 669 | if len(errs) != 0 { |
| 642 | 670 | t.Fatalf("Connect errors: %v", errs) |
| 643 | 671 | } |
| @@ -668,7 +696,7 @@ func TestConnectKillsAServerThatNeverAnswers(t *testing.T) { | ||
| 668 | 696 | "mute": {Command: os.Args[0], Env: map[string]string{fakeServerEnv: "hang"}}, |
| 669 | 697 | }} |
| 670 | 698 | // 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) | |
| 672 | 700 | if len(ts) != 0 { |
| 673 | 701 | t.Errorf("tools = %v from a server that never answered", ts) |
| 674 | 702 | } |
| @@ -683,7 +711,7 @@ func TestConnectReportsAServerThatIsNotInstalled(t *testing.T) { | ||
| 683 | 711 | "ghost": {Command: "codey-no-such-mcp-server-3f9a"}, |
| 684 | 712 | "blank": {}, |
| 685 | 713 | }} |
| 686 | - ts, closer, errs := Connect(ctxWith(t, 5*time.Second), cfg) | |
| 714 | + ts, closer, errs := Connect(ctxWith(t, 5*time.Second), "", cfg) | |
| 687 | 715 | if len(ts) != 0 { |
| 688 | 716 | t.Errorf("tools = %v, want none", ts) |
| 689 | 717 | } |
| @@ -703,7 +731,7 @@ func TestConnectCarriesStderrOfAServerThatExitsAtOnce(t *testing.T) { | ||
| 703 | 731 | cfg := config.Config{MCPServers: map[string]config.MCPServer{ |
| 704 | 732 | "crash": {Command: "sh", Args: []string{"-c", "echo 'missing API key' >&2; exit 3"}}, |
| 705 | 733 | }} |
| 706 | - ts, closer, errs := Connect(ctxWith(t, 5*time.Second), cfg) | |
| 734 | + ts, closer, errs := Connect(ctxWith(t, 5*time.Second), "", cfg) | |
| 707 | 735 | if len(ts) != 0 || len(errs) != 1 { |
| 708 | 736 | t.Fatalf("Connect = %v, %v; want no tools and one error", ts, errs) |
| 709 | 737 | } |
| @@ -45,7 +45,13 @@ const stderrTail = 4 << 10 | ||
| 45 | 45 | // installed, will not start, or speaks a protocol this client does not is |
| 46 | 46 | // reported and its tools are simply not there. The closer is always |
| 47 | 47 | // 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) { | |
| 49 | 55 | names := make([]string, 0, len(cfg.MCPServers)) |
| 50 | 56 | for name := range cfg.MCPServers { |
| 51 | 57 | names = append(names, name) |
| @@ -58,7 +64,7 @@ func Connect(ctx context.Context, cfg config.Config) ([]tools.Tool, io.Closer, [ | ||
| 58 | 64 | wg.Add(1) |
| 59 | 65 | go func() { |
| 60 | 66 | defer wg.Done() |
| 61 | - c, infos, err := start(ctx, name, cfg.MCPServers[name]) | |
| 67 | + c, infos, err := start(ctx, dir, name, cfg.MCPServers[name]) | |
| 62 | 68 | results[i] = started{name: name, c: c, tools: infos, err: err} |
| 63 | 69 | }() |
| 64 | 70 | } |
| @@ -126,12 +132,13 @@ func assemble(results []started) ([]tools.Tool, *servers, []error) { | ||
| 126 | 132 | // start runs one server through to a listed set of tools. The client is |
| 127 | 133 | // returned even on failure so the caller can close it; a nil client means |
| 128 | 134 | // 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) { | |
| 130 | 136 | command := strings.TrimSpace(spec.Command) |
| 131 | 137 | if command == "" { |
| 132 | 138 | return nil, nil, fmt.Errorf("no command configured") |
| 133 | 139 | } |
| 134 | 140 | cmd := exec.Command(command, spec.Args...) |
| 141 | + cmd.Dir = dir | |
| 135 | 142 | cmd.Env = append(os.Environ(), envList(spec.Env)...) |
| 136 | 143 | // A new process group, so a server started through npx or uvx can be |
| 137 | 144 | // killed whole. Killing only the wrapper leaves the real server holding |