feat(agentic): expose PR tools over MCP

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 23:34:05 +00:00
parent 73243f2d6b
commit 0cb648a117
3 changed files with 298 additions and 0 deletions

View file

@ -37,6 +37,38 @@ func (s *PrepSubsystem) registerCreatePRTool(server *mcp.Server) {
}, s.createPR)
}
// input := agentic.PRGetInput{Org: "core", Repo: "go-io", Number: 42}
type PRGetInput struct {
Org string `json:"org,omitempty"`
Repo string `json:"repo"`
Number int `json:"number"`
}
// out := agentic.PRGetOutput{Success: true, PR: agentic.PRInfo{Repo: "go-io", Number: 42}}
type PRGetOutput struct {
Success bool `json:"success"`
PR PRInfo `json:"pr"`
}
// input := agentic.PRMergeInput{Org: "core", Repo: "go-io", Number: 42, Method: "squash"}
type PRMergeInput struct {
Org string `json:"org,omitempty"`
Repo string `json:"repo"`
Number int `json:"number"`
Method string `json:"method,omitempty"`
}
// out := agentic.PRMergeOutput{Success: true, Repo: "go-io", Number: 42, State: "merged"}
type PRMergeOutput struct {
Success bool `json:"success"`
Org string `json:"org,omitempty"`
Repo string `json:"repo"`
Number int `json:"number"`
Method string `json:"method,omitempty"`
State string `json:"state,omitempty"`
PR PRInfo `json:"pr,omitempty"`
}
func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, input CreatePRInput) (*mcp.CallToolResult, CreatePROutput, error) {
if input.Workspace == "" {
return nil, CreatePROutput{}, core.E("createPR", "workspace is required", nil)
@ -132,6 +164,124 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
}, nil
}
func (s *PrepSubsystem) registerPRTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_pr_get",
Description: "Read a pull request from Forge by repository and pull request number.",
}, s.prGet)
mcp.AddTool(server, &mcp.Tool{
Name: "pr_get",
Description: "Read a pull request from Forge by repository and pull request number.",
}, s.prGet)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_pr_list",
Description: "List pull requests across Forge repos. Filter by org, repo, and state.",
}, s.prList)
mcp.AddTool(server, &mcp.Tool{
Name: "pr_list",
Description: "List pull requests across Forge repos. Filter by org, repo, and state.",
}, s.prList)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_pr_merge",
Description: "Merge a pull request on Forge by repository and pull request number.",
}, s.prMerge)
mcp.AddTool(server, &mcp.Tool{
Name: "pr_merge",
Description: "Merge a pull request on Forge by repository and pull request number.",
}, s.prMerge)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_pr_close",
Description: "Close a pull request on Forge by repository and pull request number.",
}, s.closePR)
mcp.AddTool(server, &mcp.Tool{
Name: "pr_close",
Description: "Close a pull request on Forge by repository and pull request number.",
}, s.closePR)
}
func (s *PrepSubsystem) prGet(ctx context.Context, _ *mcp.CallToolRequest, input PRGetInput) (*mcp.CallToolResult, PRGetOutput, error) {
if s.forgeToken == "" {
return nil, PRGetOutput{}, core.E("prGet", "no Forge token configured", nil)
}
if input.Repo == "" || input.Number <= 0 {
return nil, PRGetOutput{}, core.E("prGet", "repo and number are required", nil)
}
org := input.Org
if org == "" {
org = "core"
}
var pr pullRequestView
err := s.forge.Client().Get(ctx, core.Sprintf("/api/v1/repos/%s/%s/pulls/%d", org, input.Repo, input.Number), &pr)
if err != nil {
return nil, PRGetOutput{}, core.E("prGet", core.Concat("failed to read PR ", core.Sprint(input.Number)), err)
}
return nil, PRGetOutput{
Success: true,
PR: PRInfo{
Repo: input.Repo,
Number: int(pullRequestNumber(pr)),
Title: pr.Title,
State: pr.State,
Author: pullRequestAuthor(pr),
Branch: pr.Head.Ref,
Base: pr.Base.Ref,
Mergeable: pr.Mergeable,
URL: pr.HTMLURL,
},
}, nil
}
func (s *PrepSubsystem) prList(ctx context.Context, _ *mcp.CallToolRequest, input ListPRsInput) (*mcp.CallToolResult, ListPRsOutput, error) {
return s.listPRs(ctx, nil, input)
}
func (s *PrepSubsystem) prMerge(ctx context.Context, _ *mcp.CallToolRequest, input PRMergeInput) (*mcp.CallToolResult, PRMergeOutput, error) {
if s.forgeToken == "" {
return nil, PRMergeOutput{}, core.E("prMerge", "no Forge token configured", nil)
}
if input.Repo == "" || input.Number <= 0 {
return nil, PRMergeOutput{}, core.E("prMerge", "repo and number are required", nil)
}
org := input.Org
if org == "" {
org = "core"
}
method := input.Method
if method == "" {
method = "merge"
}
if err := s.forge.Pulls.Merge(ctx, org, input.Repo, int64(input.Number), method); err != nil {
return nil, PRMergeOutput{}, core.E("prMerge", core.Concat("failed to merge PR ", core.Sprint(input.Number)), err)
}
output := PRMergeOutput{
Success: true,
Org: org,
Repo: input.Repo,
Number: input.Number,
Method: method,
State: "merged",
}
if _, prOutput, err := s.prGet(ctx, nil, PRGetInput{Org: org, Repo: input.Repo, Number: input.Number}); err == nil {
output.PR = prOutput.PR
}
return nil, output, nil
}
func (s *PrepSubsystem) buildPRBody(workspaceStatus *WorkspaceStatus) string {
b := core.NewBuilder()
b.WriteString("## Summary\n\n")

View file

@ -13,6 +13,7 @@ import (
core "dappco.re/go/core"
"dappco.re/go/core/forge"
forge_types "dappco.re/go/core/forge/types"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -260,6 +261,152 @@ func TestPr_ClosePR_Good_Success(t *testing.T) {
assert.Equal(t, "closed", out.State)
}
func TestPr_RegisterPRTools_Good_RegistersPRAliases(t *testing.T) {
server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "0.1.0"}, &mcpsdk.ServerOptions{
Capabilities: &mcpsdk.ServerCapabilities{
Tools: &mcpsdk.ToolCapabilities{ListChanged: true},
},
})
s := &PrepSubsystem{ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{})}
s.registerPRTools(server)
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0.1.0"}, nil)
clientTransport, serverTransport := mcpsdk.NewInMemoryTransports()
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = serverSession.Close() })
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = clientSession.Close() })
result, err := clientSession.ListTools(context.Background(), nil)
require.NoError(t, err)
var toolNames []string
for _, tool := range result.Tools {
toolNames = append(toolNames, tool.Name)
}
assert.Contains(t, toolNames, "agentic_pr_get")
assert.Contains(t, toolNames, "pr_get")
assert.Contains(t, toolNames, "agentic_pr_list")
assert.Contains(t, toolNames, "pr_list")
assert.Contains(t, toolNames, "agentic_pr_merge")
assert.Contains(t, toolNames, "pr_merge")
assert.Contains(t, toolNames, "agentic_pr_close")
assert.Contains(t, toolNames, "pr_close")
}
func TestPr_PRGet_Good_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/api/v1/repos/core/test-repo/pulls/42", r.URL.Path)
_, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{
"number": 42,
"title": "Fix login",
"state": "open",
"mergeable": true,
"html_url": "https://forge.test/core/test-repo/pulls/42",
"head": map[string]any{"ref": "agent/fix-login"},
"base": map[string]any{"ref": "dev"},
"user": map[string]any{"login": "codex"},
})))
}))
t.Cleanup(srv.Close)
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, out, err := s.prGet(context.Background(), nil, PRGetInput{
Repo: "test-repo",
Number: 42,
})
require.NoError(t, err)
assert.True(t, out.Success)
assert.Equal(t, "test-repo", out.PR.Repo)
assert.Equal(t, 42, out.PR.Number)
assert.Equal(t, "Fix login", out.PR.Title)
assert.Equal(t, "open", out.PR.State)
assert.Equal(t, "agent/fix-login", out.PR.Branch)
}
func TestPr_PRGet_Bad_NoToken(t *testing.T) {
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forgeToken: "",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, _, err := s.prGet(context.Background(), nil, PRGetInput{
Repo: "test-repo",
Number: 42,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "no Forge token")
}
func TestPr_PRMerge_Good_Success(t *testing.T) {
mergeCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/merge") {
mergeCalled = true
assert.Equal(t, "/api/v1/repos/core/test-repo/pulls/42/merge", r.URL.Path)
_, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{
"number": 42,
"title": "Fix login",
"state": "closed",
"head": map[string]any{"ref": "agent/fix-login"},
"base": map[string]any{"ref": "dev"},
})))
return
}
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(core.JSONMarshalString(map[string]any{
"number": 42,
"title": "Fix login",
"state": "closed",
"head": map[string]any{"ref": "agent/fix-login"},
"base": map[string]any{"ref": "dev"},
})))
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
s := &PrepSubsystem{
ServiceRuntime: core.NewServiceRuntime(testCore, AgentOptions{}),
forge: forge.NewForge(srv.URL, "test-token"),
forgeURL: srv.URL,
forgeToken: "test-token",
backoff: make(map[string]time.Time),
failCount: make(map[string]int),
}
_, out, err := s.prMerge(context.Background(), nil, PRMergeInput{
Repo: "test-repo",
Number: 42,
Method: "merge",
})
require.NoError(t, err)
assert.True(t, out.Success)
assert.True(t, mergeCalled)
assert.Equal(t, "test-repo", out.Repo)
assert.Equal(t, 42, out.Number)
assert.Equal(t, "merged", out.State)
}
// --- listPRs ---
func TestPr_ListPRs_Bad_NoToken(t *testing.T) {

View file

@ -394,6 +394,7 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) {
s.registerIssueTools(server)
s.registerMessageTools(server)
s.registerSprintTools(server)
s.registerPRTools(server)
s.registerContentTools(server)
s.registerLanguageTools(server)