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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user