Skip to content

Commit 6f480f3

Browse files
committed
docs+test: CONFIG.md mcp_servers, E2E tests, README/CLI update
- CONFIG.md: add MCP server configuration section with examples - README.md: update MCP description to mention both server and client - CLI.md: update kode mcp description for client direction - cmd/kode/mcp_e2e_test.go: 5 E2E tests gated by KODE_E2E=true - DiscoverTools, CallTool, ToolAdapter, LoadMCPToolsIntegration, ServerError — all spawn real MCP server subprocess - 17 unit tests already exist in internal/mcpclient
1 parent 9def375 commit 6f480f3

4 files changed

Lines changed: 268 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ kode repl
140140
| [Sub-Agents](docs/SUBAGENTS.md) | Task decomposition, delegation tool, subagent protocol |
141141
| [Web UI](docs/WEBUI.md) | `kode serve`, WebSocket protocol, `@` resource resolution |
142142
| [Skills](docs/CLI.md#skills) | Trigger-matched skills, learning, import, curation |
143-
| [MCP](docs/MCP.md) | MCP server over stdio — use kode tools from Claude Code |
143+
| [MCP](docs/MCP.md) | Serve tools to Claude Code + connect to external MCP servers |
144144
| [Development](docs/DEVELOPMENT.md) | Building, testing, contributing, project structure |
145145

146146
---

cmd/kode/mcp_e2e_test.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/BackendStack21/kode/internal/mcpclient"
10+
)
11+
12+
// ── E2E: MCP Client with a real MCP server subprocess ──────────────────
13+
//
14+
// These tests start a real MCP server subprocess, discover tools, call
15+
// them, and verify the full ToolAdapter round-trip. Gated by KODE_E2E=true
16+
// since they spawn external processes.
17+
18+
// fakeMCPPath returns the path to the pre-compiled fake MCP server binary.
19+
// The test runs inside cmd/kode/, so we go up two levels to reach the repo root.
20+
func fakeMCPPath(t *testing.T) string {
21+
t.Helper()
22+
return filepath.Join("..", "..", "internal", "mcpclient", "testdata", "fakeserver")
23+
}
24+
25+
func skipIfNoMCPE2E(t *testing.T) {
26+
t.Helper()
27+
if os.Getenv("KODE_E2E") == "" {
28+
t.Skip("KODE_E2E not set — skipping MCP E2E test")
29+
}
30+
}
31+
32+
func TestMCPClientE2E_DiscoverTools(t *testing.T) {
33+
skipIfNoMCPE2E(t)
34+
35+
client, err := mcpclient.New("e2e-test", mcpclient.ServerConfig{
36+
Command: fakeMCPPath(t),
37+
Env: map[string]string{
38+
"FAKE_TOOLS": `[{"name":"fetch","description":"Fetch a URL"},{"name":"search","description":"Search the web"}]`,
39+
},
40+
})
41+
if err != nil {
42+
t.Fatalf("New: %v", err)
43+
}
44+
defer client.Close()
45+
46+
tools, err := client.Discover(context.Background())
47+
if err != nil {
48+
t.Fatalf("Discover: %v", err)
49+
}
50+
if len(tools) != 2 {
51+
t.Fatalf("expected 2 tools, got %d", len(tools))
52+
}
53+
if tools[0].Name != "fetch" {
54+
t.Errorf("tool[0].Name = %q, want %q", tools[0].Name, "fetch")
55+
}
56+
if tools[1].Name != "search" {
57+
t.Errorf("tool[1].Name = %q, want %q", tools[1].Name, "search")
58+
}
59+
}
60+
61+
func TestMCPClientE2E_CallTool(t *testing.T) {
62+
skipIfNoMCPE2E(t)
63+
64+
client, err := mcpclient.New("e2e-call", mcpclient.ServerConfig{
65+
Command: fakeMCPPath(t),
66+
Env: map[string]string{"FAKE_TOOLS": `[{"name":"echo","description":"Echo"}]`},
67+
})
68+
if err != nil {
69+
t.Fatalf("New: %v", err)
70+
}
71+
defer client.Close()
72+
73+
if _, err := client.Discover(context.Background()); err != nil {
74+
t.Fatalf("Discover: %v", err)
75+
}
76+
77+
result, err := client.CallTool(context.Background(), "echo", `{"text":"hello"}`)
78+
if err != nil {
79+
t.Fatalf("CallTool: %v", err)
80+
}
81+
if result != "ok" {
82+
t.Errorf("result = %q, want %q", result, "ok")
83+
}
84+
}
85+
86+
func TestMCPClientE2E_ToolAdapter(t *testing.T) {
87+
skipIfNoMCPE2E(t)
88+
89+
client, err := mcpclient.New("e2e-adapter", mcpclient.ServerConfig{
90+
Command: fakeMCPPath(t),
91+
Env: map[string]string{"FAKE_TOOLS": `[{"name":"my_tool","description":"Test tool"}]`},
92+
})
93+
if err != nil {
94+
t.Fatalf("New: %v", err)
95+
}
96+
defer client.Close()
97+
98+
if _, err := client.Discover(context.Background()); err != nil {
99+
t.Fatalf("Discover: %v", err)
100+
}
101+
102+
// Verify ToolAdapter implements kode.Tool-compatible interface
103+
adapter := &mcpclient.ToolAdapter{
104+
Client: client,
105+
ToolName: "my_tool",
106+
Desc: "Test tool",
107+
ParamSchema: map[string]any{
108+
"type": "object",
109+
"properties": map[string]any{
110+
"input": map[string]any{"type": "string"},
111+
},
112+
},
113+
}
114+
115+
if adapter.Name() != "e2e-adapter__my_tool" {
116+
t.Errorf("Name = %q, want %q", adapter.Name(), "e2e-adapter__my_tool")
117+
}
118+
119+
// Verify Schema returns proper JSON
120+
schema, ok := adapter.Schema().(map[string]any)
121+
if !ok {
122+
t.Fatal("Schema() did not return map")
123+
}
124+
if schema["type"] != "object" {
125+
t.Errorf("schema type = %v, want 'object'", schema["type"])
126+
}
127+
128+
// Verify Call works
129+
result, err := adapter.Call(`{"input":"world"}`)
130+
if err != nil {
131+
t.Fatalf("Call: %v", err)
132+
}
133+
if result != "ok" {
134+
t.Errorf("Call result = %q, want %q", result, "ok")
135+
}
136+
}
137+
138+
func TestMCPClientE2E_LoadMCPToolsIntegration(t *testing.T) {
139+
skipIfNoMCPE2E(t)
140+
141+
// Test the full loadMCPTools → ToolAdapter flow that main.go uses
142+
servers := map[string]mcpclient.ServerConfig{
143+
"test-server": {
144+
Command: fakeMCPPath(t),
145+
Env: map[string]string{
146+
"FAKE_TOOLS": `[{"name":"fetch","description":"Fetch tool"},{"name":"query","description":"Query tool"}]`,
147+
},
148+
},
149+
}
150+
151+
var tools []interface {
152+
Name() string
153+
Description() string
154+
Schema() any
155+
Call(args string) (string, error)
156+
}
157+
158+
// We can't call loadMCPTools directly because it takes []kode.Tool,
159+
// so we test at the mcpclient level instead
160+
client, err := mcpclient.New("test-server", servers["test-server"])
161+
if err != nil {
162+
t.Fatalf("New: %v", err)
163+
}
164+
defer client.Close()
165+
166+
defs, err := client.Discover(context.Background())
167+
if err != nil {
168+
t.Fatalf("Discover: %v", err)
169+
}
170+
171+
// Create ToolAdapters (same as loadMCPTools does)
172+
for _, def := range defs {
173+
tools = append(tools, &mcpclient.ToolAdapter{
174+
Client: client,
175+
ToolName: def.Name,
176+
Desc: def.Description,
177+
ParamSchema: def.InputSchema,
178+
})
179+
}
180+
181+
if len(tools) != 2 {
182+
t.Fatalf("expected 2 tools, got %d", len(tools))
183+
}
184+
if tools[0].Name() != "test-server__fetch" {
185+
t.Errorf("tools[0].Name() = %q, want %q", tools[0].Name(), "test-server__fetch")
186+
}
187+
if tools[1].Name() != "test-server__query" {
188+
t.Errorf("tools[1].Name() = %q, want %q", tools[1].Name(), "test-server__query")
189+
}
190+
191+
// Verify we can call both tools
192+
result, err := tools[0].Call(`{}`)
193+
if err != nil {
194+
t.Fatalf("Call fetch: %v", err)
195+
}
196+
if result != "ok" {
197+
t.Errorf("fetch result = %q, want %q", result, "ok")
198+
}
199+
200+
result, err = tools[1].Call(`{}`)
201+
if err != nil {
202+
t.Fatalf("Call query: %v", err)
203+
}
204+
if result != "ok" {
205+
t.Errorf("query result = %q, want %q", result, "ok")
206+
}
207+
}
208+
209+
func TestMCPClientE2E_ServerError(t *testing.T) {
210+
skipIfNoMCPE2E(t)
211+
212+
client, err := mcpclient.New("e2e-error", mcpclient.ServerConfig{
213+
Command: fakeMCPPath(t),
214+
Env: map[string]string{
215+
"FAKE_TOOLS": `[{"name":"borked","description":"Broken tool"}]`,
216+
"FAKE_ERROR_ON_CALL": "internal failure",
217+
},
218+
})
219+
if err != nil {
220+
t.Fatalf("New: %v", err)
221+
}
222+
defer client.Close()
223+
224+
if _, err := client.Discover(context.Background()); err != nil {
225+
t.Fatalf("Discover: %v", err)
226+
}
227+
228+
_, err = client.CallTool(context.Background(), "borked", `{}`)
229+
if err == nil {
230+
t.Fatal("expected error for error-returning tool")
231+
}
232+
}

docs/CLI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
| `kode serve [--addr :8080] [--open]` | Web UI server with WebSocket streaming, `@` resource completion, session history |
2323
| `kode subagent --goal <string> [flags]` | Run a focused sub-task; outputs JSON on stdout. Spawned by `delegate_tasks` tool |
2424
| `kode init [--global] [--force]` | Create a config file template |
25-
| `kode mcp [--sandbox]` | Start MCP server over stdio — exposes tools for Claude Code |
25+
| `kode mcp [--sandbox]` | Start MCP server (expose tools to Claude Code) or connect to external MCP servers (via `mcp_servers` config) |
2626
| `kode version` | Print version and exit |
2727

2828
## Run flags

docs/CONFIG.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,40 @@
171171
167|| `timeout_seconds` | 120 | Default timeout per sub-agent (overridden by `--timeout`) |
172172
168|| `max_iterations` | 15 | Default max think→act cycles per sub-agent (overridden by `--max-iter`) |
173173
169|
174-
170|This section is optional. Omitted fields inherit sensible defaults.
175-
171|
176-
172|## kode init
174+
This section is optional. Omitted fields inherit sensible defaults.
175+
176+
## MCP server configuration
177+
178+
Connect to **external MCP servers** and expose their tools to the agent.
179+
Any MCP server that works with Claude Code works with kode — same config format.
180+
181+
```json
182+
{
183+
"mcp_servers": {
184+
"playwright": {
185+
"command": "npx",
186+
"args": ["@playwright/mcp"]
187+
},
188+
"fetch": {
189+
"command": "uvx",
190+
"args": ["mcp-server-fetch"]
191+
}
192+
}
193+
}
194+
```
195+
196+
| Field | Description |
197+
|-------|-------------|
198+
| `command` | The executable to run |
199+
| `args` | Optional command-line arguments |
200+
| `env` | Optional environment variable overrides (empty string removes from env) |
201+
202+
Tools are registered as `<server_name>__<tool_name>` (e.g., `playwright__navigate`)
203+
and are available in `kode run`, `kode repl`, `kode continue`, and `kode serve`.
204+
205+
See [docs/MCP.md](docs/MCP.md#kode-as-mcp-client) for detailed instructions.
206+
207+
## kode init
177208
173|
178209
174|Create a config file template:
179210
175|

0 commit comments

Comments
 (0)