diff --git a/AGENT.md b/AGENT.md index 7166133..2c818ff 100644 --- a/AGENT.md +++ b/AGENT.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a7261e5..922e280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 99f6c5d..c5197f2 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -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 } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 5ed4a0e..ae22dd8 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -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.