You've already forked agentic-coding-workflow
235 lines
9.1 KiB
Go
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
|
|
})
|
|
}
|