ad00654487
forge CreatePullRequest (Gitea; empty base resolves to the repo default branch). Service CreatePR records a pr-created event. HTTP POST /api/repo/pr/create; MCP tool create_pr; <pr-list> New pull request button (head = selected repo current branch, base = default). Live-tested end-to-end: app create_pr opened a real PR (base auto-resolved), listed, then cleaned up; main untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
15 KiB
Go
322 lines
15 KiB
Go
// 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"`
|
|
}
|
|
|
|
// createPRInput describes a pull request to open.
|
|
type createPRInput struct {
|
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
|
Head string `json:"head" jsonschema:"the branch to merge from (must already exist on the remote)"`
|
|
Base string `json:"base" jsonschema:"the branch to merge into; leave empty for the repo's default branch"`
|
|
Title string `json:"title" jsonschema:"the pull request title"`
|
|
Body string `json:"body" jsonschema:"the pull request description (optional)"`
|
|
}
|
|
|
|
// 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)"`
|
|
}
|
|
|
|
// gitCheckoutInput selects a branch to switch to.
|
|
type gitCheckoutInput struct {
|
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
|
Branch string `json:"branch" jsonschema:"the existing branch to switch to"`
|
|
}
|
|
|
|
// createBranchInput names a new branch to create.
|
|
type createBranchInput struct {
|
|
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
|
|
Name string `json:"name" jsonschema:"the new branch name to create and switch to"`
|
|
}
|
|
|
|
// 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
|
|
})
|
|
|
|
// create_pr — open a pull request from a branch.
|
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
|
Name: "create_pr",
|
|
Description: "Open a pull request from head into base (leave base empty for the repo's default branch). The head branch must already exist on the remote — push it first. Path is from list_repos.",
|
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRInput) (*mcpsdk.CallToolResult, forge.PullRequest, error) {
|
|
pr, err := svc.CreatePR(ctx, activity.ActorClaude, in.Path, in.Head, in.Base, in.Title, in.Body)
|
|
if err != nil {
|
|
return nil, forge.PullRequest{}, err
|
|
}
|
|
return nil, pr, 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
|
|
})
|
|
|
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
|
Name: "git_checkout",
|
|
Description: "Switch a repository to an existing branch. Git refuses if uncommitted changes would be overwritten (the error is returned). Path is from list_repos.",
|
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCheckoutInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
|
out, err := svc.GitCheckout(ctx, activity.ActorClaude, in.Path, in.Branch)
|
|
if err != nil {
|
|
return nil, gitActionOutput{}, err
|
|
}
|
|
return nil, gitActionOutput{Output: out}, nil
|
|
})
|
|
|
|
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
|
Name: "create_branch",
|
|
Description: "Create a new branch from the current HEAD and switch to it (git checkout -b). Fails if the branch already exists. Path is from list_repos.",
|
|
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createBranchInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
|
|
out, err := svc.GitCreateBranch(ctx, activity.ActorClaude, in.Path, in.Name)
|
|
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)
|
|
}
|