Skip to content

Commit cd4e386

Browse files
authored
[feat] Add MCP server for AI tool integration (#45) (#79)
* [feat] Add MCP server infrastructure (#45) Add mcp-go dependency, HandlerContext for DI, JSON response types, server registration, and `rimba mcp` cobra command with skipConfig. * [feat] Add read-only MCP tools: list, status, conflict-check, exec (#45) Implement tool handlers that delegate to existing operations, git, and resolver packages. Includes shared helpers: resolveMainBranch, marshalResult. * [feat] Add mutating MCP tools: add, remove, merge, sync, clean (#45) Implement tool handlers for worktree creation, removal, branch merging, upstream sync, and cleanup operations with unit tests. * [test] Add shared test helpers and E2E tests for MCP server (#45) Add mock runner, callTool/unmarshalJSON helpers, and E2E tests using MCP JSON-RPC protocol over stdin/stdout for all major tool operations. * [test] Increase MCP unit test coverage to 99.2% (#45) Add comprehensive unit tests for all MCP tool handlers to exceed the 97% coverage threshold. Extract test constants and helpers to satisfy goconst, perfsprint, and gocyclo lint rules.
1 parent 81ce37c commit cd4e386

26 files changed

Lines changed: 6228 additions & 0 deletions

cmd/mcp.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cmd
2+
3+
import (
4+
"github.com/lugassawan/rimba/internal/config"
5+
"github.com/lugassawan/rimba/internal/git"
6+
mcppkg "github.com/lugassawan/rimba/internal/mcp"
7+
"github.com/mark3labs/mcp-go/server"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func init() {
12+
rootCmd.AddCommand(mcpCmd)
13+
}
14+
15+
var mcpCmd = &cobra.Command{
16+
Use: "mcp",
17+
Short: "Start MCP server for AI tool integration",
18+
Long: `Starts a Model Context Protocol (MCP) server over stdio.
19+
20+
AI tools like Claude Code and Cursor can connect to this server to discover
21+
and invoke rimba commands with structured parameters and typed responses.
22+
23+
To configure in Claude Code, add to .claude/settings.json:
24+
{"mcpServers": {"rimba": {"command": "rimba", "args": ["mcp"]}}}`,
25+
Annotations: map[string]string{"skipConfig": "true"},
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
r := newRunner()
28+
29+
repoRoot, err := git.MainRepoRoot(r)
30+
if err != nil {
31+
return err
32+
}
33+
34+
// Config is optional — some tools work without it.
35+
cfg, _ := config.Resolve(repoRoot)
36+
37+
hctx := &mcppkg.HandlerContext{
38+
Runner: r,
39+
Config: cfg,
40+
RepoRoot: repoRoot,
41+
Version: version,
42+
}
43+
44+
s := mcppkg.NewServer(hctx)
45+
return server.ServeStdio(s)
46+
},
47+
}

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ module github.com/lugassawan/rimba
33
go 1.25.7
44

55
require (
6+
github.com/mark3labs/mcp-go v0.44.0
67
github.com/pelletier/go-toml/v2 v2.2.4
78
github.com/spf13/cobra v1.10.2
89
)
910

1011
require (
12+
github.com/bahlo/generic-list-go v0.2.0 // indirect
13+
github.com/buger/jsonparser v1.1.1 // indirect
14+
github.com/google/uuid v1.6.0 // indirect
1115
github.com/inconshreveable/mousetrap v1.1.0 // indirect
16+
github.com/invopop/jsonschema v0.13.0 // indirect
17+
github.com/mailru/easyjson v0.7.7 // indirect
18+
github.com/spf13/cast v1.7.1 // indirect
1219
github.com/spf13/pflag v1.0.9 // indirect
20+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
21+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
22+
gopkg.in/yaml.v3 v3.0.1 // indirect
1323
)

go.sum

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,50 @@
1+
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
2+
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
4+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
15
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
6+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
9+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
10+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
11+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
12+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
13+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
214
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
315
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
16+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
17+
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
18+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
19+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
20+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
21+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
22+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
23+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
24+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
25+
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
26+
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
427
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
528
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
29+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
30+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
31+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
32+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
633
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
34+
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
35+
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
736
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
837
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
938
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
1039
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
40+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
41+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
42+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
43+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
44+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
45+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
1146
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
47+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1248
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
49+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
50+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/mcp/context.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package mcp
2+
3+
import (
4+
"github.com/lugassawan/rimba/internal/config"
5+
"github.com/lugassawan/rimba/internal/git"
6+
)
7+
8+
// HandlerContext holds shared dependencies for MCP tool handlers.
9+
// Created once in cmd/mcp.go, captured by handler closures.
10+
type HandlerContext struct {
11+
Runner git.Runner
12+
Config *config.Config // may be nil if not in a rimba-initialized repo
13+
RepoRoot string
14+
Version string
15+
}
16+
17+
// requireConfig returns the config or an error if not available.
18+
func (h *HandlerContext) requireConfig() (*config.Config, error) {
19+
if h.Config == nil {
20+
return nil, errConfigRequired
21+
}
22+
return h.Config, nil
23+
}

internal/mcp/server.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package mcp
2+
3+
import (
4+
"errors"
5+
6+
"github.com/mark3labs/mcp-go/server"
7+
)
8+
9+
var errConfigRequired = errors.New("rimba is not initialized in this repository; run `rimba init` first")
10+
11+
// NewServer creates an MCP server with all rimba tools registered.
12+
func NewServer(hctx *HandlerContext) *server.MCPServer {
13+
s := server.NewMCPServer("rimba", hctx.Version,
14+
server.WithToolCapabilities(false),
15+
)
16+
17+
registerListTool(s, hctx)
18+
registerAddTool(s, hctx)
19+
registerRemoveTool(s, hctx)
20+
registerStatusTool(s, hctx)
21+
registerExecTool(s, hctx)
22+
registerConflictCheckTool(s, hctx)
23+
registerMergeTool(s, hctx)
24+
registerSyncTool(s, hctx)
25+
registerCleanTool(s, hctx)
26+
27+
return s
28+
}

internal/mcp/server_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
9+
"github.com/lugassawan/rimba/internal/config"
10+
mcplib "github.com/mark3labs/mcp-go/mcp"
11+
)
12+
13+
const (
14+
gitSymbolicRef = "symbolic-ref"
15+
refsOriginMain = "refs/remotes/origin/main"
16+
gitWorktree = "worktree"
17+
gitRevParse = "rev-parse"
18+
gitRevList = "rev-list"
19+
gitStatus = "status"
20+
gitList = "list"
21+
revListEven = "0\t0"
22+
dirtyOutput = " M dirty.go\n"
23+
)
24+
25+
// mockRunner implements git.Runner for unit tests.
26+
type mockRunner struct {
27+
run func(args ...string) (string, error)
28+
runInDir func(dir string, args ...string) (string, error)
29+
}
30+
31+
func (m *mockRunner) Run(args ...string) (string, error) {
32+
if m.run == nil {
33+
return "", nil
34+
}
35+
return m.run(args...)
36+
}
37+
38+
func (m *mockRunner) RunInDir(dir string, args ...string) (string, error) {
39+
if m.runInDir == nil {
40+
return "", nil
41+
}
42+
return m.runInDir(dir, args...)
43+
}
44+
45+
// testConfig returns a minimal config suitable for testing.
46+
func testConfig() *config.Config {
47+
return &config.Config{
48+
WorktreeDir: ".worktrees",
49+
DefaultSource: "main",
50+
CopyFiles: []string{".editorconfig"},
51+
}
52+
}
53+
54+
// testContext returns a HandlerContext with a mock runner and test config.
55+
func testContext(r *mockRunner) *HandlerContext {
56+
return &HandlerContext{
57+
Runner: r,
58+
Config: testConfig(),
59+
RepoRoot: "/repo",
60+
Version: "test",
61+
}
62+
}
63+
64+
// callTool is a test helper that invokes a tool handler with the given arguments.
65+
func callTool(t *testing.T, handler func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error), args map[string]any) *mcplib.CallToolResult {
66+
t.Helper()
67+
req := mcplib.CallToolRequest{}
68+
req.Params.Arguments = args
69+
result, err := handler(context.Background(), req)
70+
if err != nil {
71+
t.Fatalf("handler returned protocol error: %v", err)
72+
}
73+
return result
74+
}
75+
76+
// resultJSON extracts the JSON text from a tool result.
77+
func resultJSON(t *testing.T, result *mcplib.CallToolResult) string {
78+
t.Helper()
79+
if result.IsError {
80+
t.Fatalf("expected success result, got error: %v", result.Content)
81+
}
82+
tc, ok := mcplib.AsTextContent(result.Content[0])
83+
if !ok {
84+
t.Fatal("result content is not TextContent")
85+
}
86+
return tc.Text
87+
}
88+
89+
// resultError extracts the error text from a tool result.
90+
func resultError(t *testing.T, result *mcplib.CallToolResult) string {
91+
t.Helper()
92+
if !result.IsError {
93+
t.Fatal("expected error result, got success")
94+
}
95+
tc, ok := mcplib.AsTextContent(result.Content[0])
96+
if !ok {
97+
t.Fatal("error content is not TextContent")
98+
}
99+
return tc.Text
100+
}
101+
102+
// unmarshalJSON is a helper to unmarshal JSON from a tool result into a target type.
103+
func unmarshalJSON[T any](t *testing.T, result *mcplib.CallToolResult) T {
104+
t.Helper()
105+
jsonStr := resultJSON(t, result)
106+
var v T
107+
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
108+
t.Fatalf("failed to unmarshal: %v\njson: %s", err, jsonStr)
109+
}
110+
return v
111+
}
112+
113+
func TestNewServer(t *testing.T) {
114+
hctx := testContext(&mockRunner{})
115+
s := NewServer(hctx)
116+
117+
tools := s.ListTools()
118+
expectedTools := []string{"list", "add", "remove", "status", "exec", "conflict-check", "merge", "sync", "clean"}
119+
for _, name := range expectedTools {
120+
if _, exists := tools[name]; !exists {
121+
t.Errorf("expected tool %q to be registered", name)
122+
}
123+
}
124+
125+
if len(tools) != len(expectedTools) {
126+
t.Errorf("expected %d tools, got %d", len(expectedTools), len(tools))
127+
}
128+
}
129+
130+
// worktreePorcelain generates git worktree --porcelain output.
131+
func worktreePorcelain(entries ...struct{ path, branch string }) string {
132+
lines := make([]string, 0, 4*len(entries))
133+
for _, e := range entries {
134+
lines = append(lines,
135+
"worktree "+e.path,
136+
"HEAD abc123",
137+
"branch refs/heads/"+e.branch,
138+
"",
139+
)
140+
}
141+
return strings.Join(lines, "\n")
142+
}

0 commit comments

Comments
 (0)