package mcp import ( "context" "github.com/google/uuid" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yan1h/agent-coding-workflow/internal/changerequest" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // registerChangeRequestTools registers the change-request (PR) tools. // // Gate boundary (spec decision): create_pull_request / merge_pull_request / // get_pr_status / list_pull_requests are exposed, but review approval is NOT an // MCP tool — it is HTTP/web-only so an agent cannot self-approve its own PR and // bypass the merge gate. merge_pull_request calls ChangeReqs.Merge, which // hard-blocks unless review_verdict=='approved' (surfaced as a FailedPrecondition // MCP error). Operators may further exclude create/merge from agent system-token // scopes via the token whitelist. func registerChangeRequestTools(srv *mcpsdk.Server, deps ServerDeps) { registerCreatePullRequest(srv, deps) registerGetPRStatus(srv, deps) registerMergePullRequest(srv, deps) registerListPullRequests(srv, deps) } // ===== shared DTO ===== type changeRequestDetail struct { ID string `json:"id"` ProjectID string `json:"project_id"` WorkspaceID string `json:"workspace_id"` Number int `json:"number"` Title string `json:"title"` SourceBranch string `json:"source_branch"` TargetBranch string `json:"target_branch"` State string `json:"state"` ReviewVerdict string `json:"review_verdict"` CIState string `json:"ci_state"` ExternalID int64 `json:"external_id,omitempty"` ExternalURL string `json:"external_url,omitempty"` MergeSHA string `json:"merge_commit_sha,omitempty"` } func changeRequestDetailFrom(cr *changerequest.ChangeRequest) changeRequestDetail { d := changeRequestDetail{ ID: cr.ID.String(), ProjectID: cr.ProjectID.String(), WorkspaceID: cr.WorkspaceID.String(), Number: cr.Number, Title: cr.Title, SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch, State: string(cr.State), ReviewVerdict: string(cr.ReviewVerdict), CIState: string(cr.CIState), ExternalURL: cr.ExternalURL, MergeSHA: cr.MergeCommitSHA, } if cr.ExternalID != nil { d.ExternalID = *cr.ExternalID } return d } // resolvePRByNumber scopes the workspace (CheckToolFromCtx-style project gate) // and finds the change request whose per-project number matches within that // workspace. Returns NotFound when no such CR exists in scope. func resolvePRByNumber(ctx context.Context, deps ServerDeps, workspaceID string, number int) (*changerequest.ChangeRequest, error) { c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, err } w, _, err := scopedWorkspace(ctx, deps, c, workspaceID) if err != nil { return nil, err } list, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID) if err != nil { return nil, err } for _, cr := range list { if cr.Number == number { return cr, nil } } return nil, errs.New(errs.CodeNotFound, "change request not found in workspace") } // ===== create_pull_request ===== type createPRIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` SourceBranch string `json:"source_branch" jsonschema:"branch to merge from (non-empty)"` TargetBranch string `json:"target_branch,omitempty" jsonschema:"branch to merge into (defaults to workspace default branch)"` Title string `json:"title" jsonschema:"PR title (non-empty)"` Description string `json:"description,omitempty"` RequirementID string `json:"requirement_id,omitempty" jsonschema:"optional linked requirement UUID"` IssueID string `json:"issue_id,omitempty" jsonschema:"optional linked issue UUID"` } type changeRequestOut struct { OK bool `json:"ok"` ChangeRequest changeRequestDetail `json:"change_request"` } func registerCreatePullRequest(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "create_pull_request", Description: "Open a pull request on the git host from a workspace branch and persist it as a change request. The PR must be approved by a human (web only) before it can be merged."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) { if err := CheckToolFromCtx(ctx, "create_pull_request"); err != nil { return nil, changeRequestOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, changeRequestOut{}, err } w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, changeRequestOut{}, err } input := changerequest.CreateInput{ SourceBranch: in.SourceBranch, TargetBranch: in.TargetBranch, Title: in.Title, Description: in.Description, } if in.RequirementID != "" { id, perr := uuid.Parse(in.RequirementID) if perr != nil { return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "requirement_id parse") } input.RequirementID = &id } if in.IssueID != "" { id, perr := uuid.Parse(in.IssueID) if perr != nil { return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "issue_id parse") } input.IssueID = &id } cr, err := deps.ChangeReqs.Create(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID, input) if err != nil { return nil, changeRequestOut{}, err } return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil }) } // ===== get_pr_status ===== type prNumberIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` Number int `json:"number" jsonschema:"per-project change-request number"` } func registerGetPRStatus(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "get_pr_status", Description: "Get a change request by number, refreshing its CI/merge state from the git host."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in prNumberIn) (*mcpsdk.CallToolResult, changeRequestOut, error) { if err := CheckToolFromCtx(ctx, "get_pr_status"); err != nil { return nil, changeRequestOut{}, err } cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number) if err != nil { return nil, changeRequestOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, changeRequestOut{}, err } cr, err = deps.ChangeReqs.SyncStatus(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID) if err != nil { return nil, changeRequestOut{}, err } return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil }) } // ===== merge_pull_request (HARD-GATED) ===== type mergePRIn struct { WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` Number int `json:"number" jsonschema:"per-project change-request number"` Method string `json:"method,omitempty" jsonschema:"merge method: merge|squash|rebase"` } func registerMergePullRequest(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "merge_pull_request", Description: "Merge a change request on the git host. HARD-BLOCKED unless review_verdict=='approved' (approval is human/web-only); returns a failed_precondition error otherwise."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) { if err := CheckToolFromCtx(ctx, "merge_pull_request"); err != nil { return nil, changeRequestOut{}, err } cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number) if err != nil { return nil, changeRequestOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, changeRequestOut{}, err } cr, err = deps.ChangeReqs.Merge(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID, in.Method) if err != nil { return nil, changeRequestOut{}, err } return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil }) } // ===== list_pull_requests ===== type listPRsOut struct { ChangeRequests []changeRequestDetail `json:"change_requests"` } func registerListPullRequests(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "list_pull_requests", Description: "List change requests (PRs) of a workspace."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, listPRsOut, error) { if err := CheckToolFromCtx(ctx, "list_pull_requests"); err != nil { return nil, listPRsOut{}, err } c, err := deps.Caller.Resolve(ctx) if err != nil { return nil, listPRsOut{}, err } w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) if err != nil { return nil, listPRsOut{}, err } out, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID) if err != nil { return nil, listPRsOut{}, err } res := listPRsOut{ChangeRequests: make([]changeRequestDetail, 0, len(out))} for _, cr := range out { res.ChangeRequests = append(res.ChangeRequests, changeRequestDetailFrom(cr)) } return nil, res, nil }) }