Slice 7: expose git commands as MCP tools

Add git_fetch/git_pull/git_push/git_commit/git_discard_changes MCP tools as thin adapters over the service (actor=claude), completing 1.7 symmetry so Claude can run the same commands as the right-click menu. git_discard_changes is flagged destructive (confirm first, 1.4). Extended the MCP test with a git_commit round-trip; synced AGENT.md 8.1 tool list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 09:15:23 -04:00
parent 6ef1c195e3
commit c6d3b5fae8
4 changed files with 99 additions and 2 deletions
+3 -2
View File
@@ -365,8 +365,9 @@ obeys the safety rules (§1.4).
the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`.
- Act: `git_status/checkout/commit/push/pull/create_branch`,
`create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`.
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`,
`git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`,
`ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.)
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
structured output must be a JSON **object** (`type: "object"`). A handler that
+13
View File
@@ -188,3 +188,16 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
`cmd/server/main.go`, `components/repo-menu` (new), `components/repo-list`,
`web/templates/{index,help}.html`.
- **Next:** expose these git ops as MCP tools so Claude can run them too.
## 2026-09-20 — Slice 7: git commands as MCP tools (§1.7 symmetry)
- **What:** Added MCP tools `git_fetch`, `git_pull`, `git_push`, `git_commit`,
and `git_discard_changes` — thin adapters over the existing service methods
(actor=claude), so Claude can run the same commands as the right-click menu.
`git_discard_changes`'s description flags it destructive and tells Claude to
confirm first (§1.4). Extended the MCP test with a `git_commit` round-trip.
Synced AGENT.md §8.1's tool list to the actual names.
- **Why:** Complete the §1.7 symmetry — every capability reachable from both the
GUI and Claude.
- **Affects:** `internal/mcp` (+test), `AGENT.md` (§8.1).
- **Note:** the new tools appear in Claude Desktop only after its next restart
(tool list cached per connection); network ops still need container git creds.
+68
View File
@@ -81,6 +81,17 @@ type mergePRInput struct {
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
}
// gitCommitInput is the argument schema for git_commit.
type gitCommitInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Message string `json:"message" jsonschema:"the commit message"`
}
// gitActionOutput carries a git command's output (object, per the schema rule).
type gitActionOutput struct {
Output string `json:"output" jsonschema:"the git command output (may be empty)"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -185,6 +196,63 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, res, nil
})
// --- Git commands (the same ops as the right-click menu, §6/§1.7) --------
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_fetch",
Description: "Fetch updates from the remote for a repository (does not change the working tree). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitFetch(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_pull",
Description: "Pull the latest changes (fetch + merge) into a repository's current branch. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitPull(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_push",
Description: "Push the current branch to its upstream (plain push, never forced). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitPush(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_commit",
Description: "Stage all changes and commit them with a message. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCommitInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCommit(ctx, activity.ActorClaude, in.Path, in.Message)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_discard_changes",
Description: "DESTRUCTIVE: discard ALL uncommitted changes to tracked files (git reset --hard HEAD). This cannot be undone — confirm the exact repository with the user BEFORE calling. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitDiscard(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
return s
}
+15
View File
@@ -201,6 +201,21 @@ func TestMCPRoundTrip(t *testing.T) {
if !res.IsError {
t.Fatalf("expected IsError acking with no pending switch")
}
// --- git_commit via MCP (adapter wiring) --------------------------------
if err := os.WriteFile(filepath.Join(repoPath, "extra.txt"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "git_commit",
Arguments: map[string]any{"path": repoPath, "message": "add extra via mcp"},
})
if err != nil {
t.Fatalf("git_commit: %v", err)
}
if res.IsError {
t.Fatalf("git_commit tool error: %+v", res.Content)
}
}
// decodeResult unmarshals the JSON text content of a tool result into v.