// Package mcp exposes GitManager's capabilities to Claude as an MCP server over // Streamable HTTP (AGENT.md §8.1). The tool handlers are THIN ADAPTERS over the // shared service layer (§1.7) — no Git/forge logic lives here. Read tools only // for now; acting/destructive tools arrive with the service methods that back // them, carrying the §1.4 confirmation contract. package mcp import ( "context" "fmt" "net/http" "time" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "gitmanager/internal/activity" "gitmanager/internal/forge" "gitmanager/internal/repos" "gitmanager/internal/service" ) // getRepoInput is the argument schema for the get_repo tool. type getRepoInput struct { Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"` } // listReposOutput wraps the repository list. MCP structured output must be a JSON // object, so the SDK-inferred outputSchema has to be type "object" — returning a // bare slice yields type "array", which Claude Desktop rejects at tools/list. type listReposOutput struct { Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"` } // setActiveProjectInput is the argument schema for set_active_project. type setActiveProjectInput struct { Path string `json:"path" jsonschema:"absolute path of the repository to make active, exactly as returned by list_repos"` } // activeProjectOutput reports the active project path (object, per the rule above). type activeProjectOutput struct { Path string `json:"path" jsonschema:"absolute path of the active project, empty when none is set"` } // activityOutput wraps the activity feed (object, per the rule above). type activityOutput struct { Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"` } // pendingSwitchOutput reports whether the user has asked Claude to switch projects. type pendingSwitchOutput struct { Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"` Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"` Note string `json:"note,omitempty" jsonschema:"an optional note from the user"` RequestedAt time.Time `json:"requestedAt,omitempty"` } // ackSwitchInput is the argument schema for ack_switch. type ackSwitchInput struct { Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"` } // ackSwitchOutput reports the completed switch. type ackSwitchOutput struct { Switched bool `json:"switched"` Target string `json:"target,omitempty" jsonschema:"the repository now active"` } // repoPathInput selects a repository by path (from list_repos). type repoPathInput struct { Path string `json:"path" jsonschema:"absolute path of the repository, exactly as returned by list_repos"` } // prListOutput wraps the pull requests (object, per the schema rule). type prListOutput struct { PRs []forge.PullRequest `json:"prs" jsonschema:"open pull requests for the repository"` } // mergePRInput selects the PR to merge and clean up. type mergePRInput struct { Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"` 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{ Name: "gitmanager", Title: "GitManager", Version: version, Description: "Discover and inspect the user's local Git repositories.", }, nil) // list_repos — no arguments (empty struct = object schema with no properties). mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "list_repos", Description: "List every Git repository GitManager has discovered, each with its current branch, dirty/clean state, ahead/behind counts, and remote names.", }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listReposOutput, error) { return nil, listReposOutput{Repos: svc.ListRepos()}, nil }) // get_repo — details for one already-discovered repository. mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "get_repo", Description: "Get details for one repository: its local branches (with upstreams), recent commits, and remote URLs. The path must be one returned by list_repos.", }, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRepoInput) (*mcpsdk.CallToolResult, repos.Detail, error) { detail, ok := svc.RepoDetail(ctx, in.Path) if !ok { return nil, repos.Detail{}, fmt.Errorf("unknown repository %q — call list_repos for valid paths", in.Path) } return nil, detail, nil }) // get_active_project — the repo/task currently in focus (§8.2). mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "get_active_project", Description: "Get the active project — the repository the user is currently focused on. Check this to stay in sync with the user; path is empty when none is set.", }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activeProjectOutput, error) { return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil }) // set_active_project — Claude switches the focus to another repo. mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "set_active_project", Description: "Set the active project to the given repository path (from list_repos). Use this when switching which repository you are working in so the app and user stay in sync.", }, func(_ context.Context, _ *mcpsdk.CallToolRequest, in setActiveProjectInput) (*mcpsdk.CallToolResult, activeProjectOutput, error) { if _, _, err := svc.SetActiveProject(activity.ActorClaude, in.Path); err != nil { return nil, activeProjectOutput{}, fmt.Errorf("%w — call list_repos for valid paths", err) } return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil }) // get_activity — recent user + Claude actions, so Claude can catch up. mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "get_activity", Description: "Get the recent activity feed (user and Claude actions, oldest first): repo selections, active-project changes, and more as features land. Use it to see what the user has done since you last looked.", }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activityOutput, error) { return nil, activityOutput{Events: svc.Activity(50)}, nil }) // get_pending_switch — has the user asked you to switch projects? (§8.3) mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "get_pending_switch", Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.", }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) { p, ok := svc.PendingSwitch() if !ok { return nil, pendingSwitchOutput{Pending: false}, nil } return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil }) // ack_switch — complete a pending handoff and tell the user where you left off. mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "ack_switch", Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.", }, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) { p, ok := svc.AckSwitch(in.Summary) if !ok { return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge") } return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil }) // list_prs — open pull requests for a repo (§8.4). mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "list_prs", Description: "List the open pull requests for a repository (needs a configured forge such as Gitea). The path must be one returned by list_repos.", }, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, prListOutput, error) { prs, err := svc.ForgePRs(ctx, in.Path) if err != nil { return nil, prListOutput{}, err } return nil, prListOutput{PRs: prs}, nil }) // merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch. mcpsdk.AddTool(s, &mcpsdk.Tool{ Name: "merge_and_cleanup_pr", Description: "Merge a pull request (squash) AND delete its source branch — the \"Merge & clean up\" action. This is irreversible: confirm the exact PR number and repository with the user BEFORE calling. Merged history remains on the host; only the branch is removed.", }, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRInput) (*mcpsdk.CallToolResult, forge.MergeResult, error) { res, err := svc.MergeAndCleanup(ctx, activity.ActorClaude, in.Path, in.Number) if err != nil { return nil, forge.MergeResult{}, err } 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 } // Handler serves the MCP server over Streamable HTTP. Mount it at /mcp. Like the // rest of the app it is localhost-bound and unauthenticated (§8.1) — the same // server instance backs every session. func Handler(s *mcpsdk.Server) http.Handler { return mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return s }, nil) }