95ae900a58
docs/plans/06-mcp-support.md asks for the host direction — Maven connects OUT
to MCP servers and consumes what they offer. This is the client half: the
protocol, the transports, the connection manager, the config block. Nothing is
wired into a turn yet, and nothing here exposes Maven's own capabilities to an
outside caller.
internal/mcp:
- hand-rolled JSON-RPC 2.0 (the wire format is four fields, and the repo
vendors its deps, so a library would cost more than it saves);
- two transports: a stdio subprocess on this box, and streamable HTTP, which
accepts a plain JSON reply or an SSE frame because servers disagree about
which they send;
- Client: initialize handshake, tools/list, tools/call, resources/list,
resources/read. Text content only — everything downstream is a sentence;
- Manager: lazy dial, per-server failure that never blocks boot or the other
servers, backoff reconnect, Status for a web surface, graceful Close;
- the allowlist encoding: a discovered tool becomes the store row
"vikunja_list_tasks" with cmd ["mcp","vikunja","list_tasks"], scope
"mcp:vikunja". No new column, no migration, and ProposeTool, EnableTool,
the act matcher and the confirm turn all keep working untouched.
Constraints held, in code rather than in prose:
- OFF unless configured, and a server is dark until "enabled": true.
- A url server goes through internal/webfetch, so the SSRF guard, the size
cap, the redirect cap and the per-host rate limit apply. Reaching loopback
needs allow_private on THAT server, and each server gets its own fetcher so
one loopback exemption cannot become a hole for a public endpoint.
- readOnlyHint decides destructive: no hint means "assume it mutates", which
will route the call through the existing confirm turn. Guessing wrong in
that direction only costs a question.
- The catalogue stays small on purpose — allow_tools, and max_tools=12 per
server. The resident model is a 1.7B with a 4096-token context; a tool name
it half-remembers is a wrong act.
- Only the tool name and the router's arguments are sent. There is no API
here through which a note, a fact or the persona block could travel.
webfetch grows Post (JSON-RPC cannot be a GET) and surfaces response headers
for Mcp-Session-Id. It shares Get's guards exactly: a body buys a caller
nothing, a POST to the LAN is refused for the same reason a GET is.
Verified against the real Vikunja MCP server on homesrv
(http://localhost:9100/mcp): handshake, three discovered tools with update_task
correctly NOT read-only, a live list_projects call, a tool excluded by
allow_tools refused, and the same server refused outright once allow_private
was dropped. Tests cover both transports (the stdio one against a real
subprocess), SSE and JSON framing, session echo, reconnect, and the config
validation.
150 lines
3.8 KiB
Go
150 lines
3.8 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The stdio transport is tested against a real subprocess — this test binary,
|
|
// re-executed with MAVEN_MCP_FAKE set, acting as a minimal MCP server. No
|
|
// python, no fixture file, no network.
|
|
func TestMain(m *testing.M) {
|
|
if os.Getenv("MAVEN_MCP_FAKE") != "" {
|
|
fakeStdioServer()
|
|
return
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func fakeStdioServer() {
|
|
h := echoServer()
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
out := bufio.NewWriter(os.Stdout)
|
|
defer out.Flush()
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var req struct {
|
|
ID *int64 `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if json.Unmarshal([]byte(line), &req) != nil {
|
|
continue
|
|
}
|
|
if req.ID == nil {
|
|
// A notification gets no reply, but we emit an unrelated
|
|
// notification so the client's frame-skipping is exercised.
|
|
_, _ = out.WriteString("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\"}\n")
|
|
_ = out.Flush()
|
|
continue
|
|
}
|
|
result, rerr := h(req.Method, req.Params)
|
|
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
|
|
if rerr != nil {
|
|
resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
|
|
} else {
|
|
resp["result"] = result
|
|
}
|
|
raw, _ := json.Marshal(resp)
|
|
_, _ = out.Write(append(raw, '\n'))
|
|
_ = out.Flush()
|
|
if os.Getenv("MAVEN_MCP_FAKE") == "die" && req.Method == "tools/list" {
|
|
return // hang up, so the reconnect path has something to see
|
|
}
|
|
}
|
|
}
|
|
|
|
func stdioManager(t *testing.T, mode string) *Manager {
|
|
t.Helper()
|
|
self, err := os.Executable()
|
|
if err != nil {
|
|
t.Skipf("no executable path: %v", err)
|
|
}
|
|
if _, err := exec.LookPath(self); err != nil && !strings.Contains(self, "/") {
|
|
t.Skip("test binary not executable")
|
|
}
|
|
m, err := NewManager(nil, []ServerConfig{{
|
|
Name: "fake",
|
|
Command: self,
|
|
Env: []string{"MAVEN_MCP_FAKE=" + mode},
|
|
Enabled: true,
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Connect(context.Background())
|
|
return m
|
|
}
|
|
|
|
func TestStdioTransportEndToEnd(t *testing.T) {
|
|
m := stdioManager(t, "1")
|
|
defer m.Close()
|
|
st := m.Status()
|
|
if len(st) != 1 || !st[0].Connected {
|
|
t.Fatalf("status = %+v", st)
|
|
}
|
|
if st[0].Transport != "stdio" {
|
|
t.Fatalf("transport = %q", st[0].Transport)
|
|
}
|
|
if got := len(m.Tools()); got != 2 {
|
|
t.Fatalf("tools = %d", got)
|
|
}
|
|
out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "стдио"})
|
|
if err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
if out != "read_thing:стдио" {
|
|
t.Fatalf("out = %q", out)
|
|
}
|
|
res := m.Resources(context.Background())
|
|
if len(res) != 1 || res[0].URI != "note://one" {
|
|
t.Fatalf("resources = %+v", res)
|
|
}
|
|
body, err := m.ReadResource(context.Background(), "fake", "note://one")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body != "тело ресурса" {
|
|
t.Fatalf("body = %q", body)
|
|
}
|
|
}
|
|
|
|
func TestStdioServerThatDiesIsNotUsable(t *testing.T) {
|
|
m := stdioManager(t, "die")
|
|
defer m.Close()
|
|
// The server hung up after tools/list; the next call must fail cleanly
|
|
// rather than hang or panic.
|
|
if _, err := m.Call(context.Background(), "fake", "read_thing", nil); err == nil {
|
|
t.Fatal("a call into a dead server must error")
|
|
}
|
|
}
|
|
|
|
func TestStdioMissingCommand(t *testing.T) {
|
|
m, err := NewManager(nil, []ServerConfig{{
|
|
Name: "nope", Command: "/nonexistent/mcp-server-that-is-not-there", Enabled: true,
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Connect(context.Background())
|
|
st := m.Status()
|
|
if st[0].Connected || st[0].Err == "" {
|
|
t.Fatalf("a missing binary must be recorded, not fatal: %+v", st)
|
|
}
|
|
if got := len(m.Tools()); got != 0 {
|
|
t.Fatalf("tools = %d", got)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(st[0].Err), "start") {
|
|
t.Logf("err = %q", st[0].Err)
|
|
}
|
|
}
|