Files
2026-06-22 08:55:57 +08:00

235 lines
9.1 KiB
Go

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
})
}