forgejo-mcp/operation/operation.go
Christoph Görn 0826172c23
feat: ✨ branch protection management — CRUD tools + forgejo:// resources (uc6)
Implements the branch-protection OpenSpec change (#195). New
operation/branchprotection/ package:

- Tools: list/get/create/edit/delete_branch_protection over
  /repos/{owner}/{repo}/branch_protections.
- list_branch_protections is output-bounded (page/limit; echoes page/limit
  for resumability).
- create/edit round-trip status_check_contexts (comma-separated). edit uses
  PATCH pointer semantics (pkg/ptr.To): only caller-passed fields change;
  unpassed fields serialize as null (leave-unchanged), never false.
- Resources: bounded collection forgejo://repo/{o}/{r}/branch_protections
  (EmbeddedListCap sentinel -> list_branch_protections) and single
  forgejo://repo/{o}/{r}/branch_protection/{rule} (rule may contain slashes).
- ParseBranchProtection(s) added to operation/resource; wired into
  operation.go + cmd/cli.go domain grouping; README tool table updated.
- httptest coverage: list, get (ok/404), create (contexts round-trip;
  missing branch_name -> no call), edit (only-passed-fields null-safety;
  contexts), delete; parser + truncation + error-mapping tests.

Refines the edit scenario in the change spec to match SDK marshaling
(null = leave-unchanged). tasks.md complete.

Closes forgejo-mcp-uc6.
2026-06-02 16:22:24 +02:00

279 lines
7.8 KiB
Go

package operation
import (
"context"
"fmt"
"net/http"
"strings"
"codeberg.org/goern/forgejo-mcp/v2/operation/actions"
"codeberg.org/goern/forgejo-mcp/v2/operation/attachment"
"codeberg.org/goern/forgejo-mcp/v2/operation/branchprotection"
"codeberg.org/goern/forgejo-mcp/v2/operation/issue"
"codeberg.org/goern/forgejo-mcp/v2/operation/org"
"codeberg.org/goern/forgejo-mcp/v2/operation/pull"
"codeberg.org/goern/forgejo-mcp/v2/operation/release"
"codeberg.org/goern/forgejo-mcp/v2/operation/repo"
"codeberg.org/goern/forgejo-mcp/v2/operation/search"
"codeberg.org/goern/forgejo-mcp/v2/operation/tracking"
"codeberg.org/goern/forgejo-mcp/v2/operation/user"
"codeberg.org/goern/forgejo-mcp/v2/operation/version"
"codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
"github.com/mark3labs/mcp-go/server"
)
var (
mcpServer *server.MCPServer
)
func RegisterTool(s *server.MCPServer) {
log.Info("Registering MCP tools")
RegisterUserTool(s)
RegisterRepoTool(s)
RegisterIssueTool(s)
RegisterPullTool(s)
RegisterPullReviewTool(s)
RegisterSearchTool(s)
RegisterVersionTool(s)
RegisterActionsTool(s)
RegisterOrgTool(s)
RegisterTrackingTool(s)
RegisterAttachmentTool(s)
RegisterReleaseTool(s)
RegisterBranchProtectionTool(s)
log.Info("All MCP tools registered successfully")
}
// Per-domain registration functions exposed for CLI domain grouping.
func RegisterUserTool(s *server.MCPServer) {
user.RegisterTool(s)
log.Debug("Registered user tools")
}
func RegisterRepoTool(s *server.MCPServer) {
repo.RegisterTool(s)
log.Debug("Registered repository tools")
}
func RegisterIssueTool(s *server.MCPServer) {
issue.RegisterTool(s)
log.Debug("Registered issue tools")
}
func RegisterPullTool(s *server.MCPServer) {
pull.RegisterTool(s)
log.Debug("Registered pull request tools")
}
func RegisterPullReviewTool(s *server.MCPServer) {
pull.RegisterReviewTools(s)
log.Debug("Registered pull review write tools")
}
func RegisterSearchTool(s *server.MCPServer) {
search.RegisterTool(s)
log.Debug("Registered search tools")
}
func RegisterVersionTool(s *server.MCPServer) {
version.RegisterTool(s)
log.Debug("Registered version tools")
}
func RegisterActionsTool(s *server.MCPServer) {
actions.RegisterTool(s)
log.Debug("Registered actions tools")
}
func RegisterOrgTool(s *server.MCPServer) {
org.RegisterTool(s)
log.Debug("Registered org tools")
}
func RegisterTrackingTool(s *server.MCPServer) {
tracking.RegisterTool(s)
log.Debug("Registered time tracking tools")
}
func RegisterAttachmentTool(s *server.MCPServer) {
attachment.RegisterTool(s)
log.Debug("Registered attachment tools")
}
func RegisterReleaseTool(s *server.MCPServer) {
release.RegisterTool(s)
log.Debug("Registered release tools")
}
func RegisterBranchProtectionTool(s *server.MCPServer) {
branchprotection.RegisterTool(s)
log.Debug("Registered branch protection tools")
}
func extractToken(auth string) string {
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 {
// No scheme prefix. Spec: bare tokens MUST be rejected and treated
// as if no Authorization header were present (no silent identity).
return ""
}
scheme := strings.ToLower(parts[0])
if scheme == "token" || scheme == "bearer" {
return parts[1]
}
return ""
}
func Run(transport, version string) error {
flag.Version = version
mcpServer = newMCPServer(version)
RegisterTool(mcpServer)
RegisterCoreResources(mcpServer)
// Test connection to Forgejo instance before starting the server
log.Info("Testing connection to Forgejo instance",
log.SanitizedURLField("url", flag.URL),
)
if err := testConnection(); err != nil {
log.Error("Failed to connect to Forgejo instance",
log.SanitizedURLField("url", flag.URL),
log.ErrorField(err),
)
return fmt.Errorf("connection test failed: %w", err)
}
log.Info("Successfully connected to Forgejo instance",
log.SanitizedURLField("url", flag.URL),
)
switch transport {
case "stdio":
log.Info("Starting MCP server with stdio transport")
log.Info("MCP server ready for stdio communication")
if err := server.ServeStdio(mcpServer); err != nil {
log.Error("MCP stdio server failed",
log.ErrorField(err),
)
return err
}
log.Info("MCP stdio server shutdown")
case "sse":
sseServer := server.NewSSEServer(mcpServer, server.WithSSEContextFunc(func(ctx context.Context, r *http.Request) context.Context {
if token := extractToken(r.Header.Get("Authorization")); token != "" {
return forgejo.WithToken(ctx, token)
}
return ctx
}))
log.Info("Starting MCP SSE server",
log.IntField("port", flag.SSEPort),
)
log.Info("MCP SSE server ready for connections",
log.IntField("port", flag.SSEPort),
log.StringField("endpoint", fmt.Sprintf("http://localhost:%d", flag.SSEPort)),
)
if err := sseServer.Start(fmt.Sprintf(":%d", flag.SSEPort)); err != nil {
log.Error("Failed to start SSE server",
log.IntField("port", flag.SSEPort),
log.ErrorField(err),
)
return fmt.Errorf("failed to start SSE server: %w", err)
}
log.Info("MCP SSE server shutdown")
case "http":
httpServer := server.NewStreamableHTTPServer(mcpServer, server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
if token := extractToken(r.Header.Get("Authorization")); token != "" {
return forgejo.WithToken(ctx, token)
}
return ctx
}))
log.Info("Starting MCP streamable HTTP server",
log.IntField("port", flag.HTTPPort),
)
log.Info("MCP streamable HTTP server ready for connections",
log.IntField("port", flag.HTTPPort),
log.StringField("endpoint", fmt.Sprintf("http://localhost:%d", flag.HTTPPort)),
)
if err := httpServer.Start(fmt.Sprintf(":%d", flag.HTTPPort)); err != nil {
log.Error("Failed to start streamable HTTP server",
log.IntField("port", flag.HTTPPort),
log.ErrorField(err),
)
return fmt.Errorf("failed to start streamable HTTP server: %w", err)
}
log.Info("MCP streamable HTTP server shutdown")
default:
log.Error("Invalid transport configuration",
log.StringField("transport", transport),
log.StringField("valid_options", "stdio, sse, http"),
)
return fmt.Errorf("invalid transport type: %s. Must be 'stdio', 'sse', or 'http'", transport)
}
return nil
}
func testConnection() error {
return forgejo.VerifyConnection()
}
func RegisterCoreResources(s *server.MCPServer) {
RegisterCommitResource(s)
RegisterIssueResources(s)
RegisterOwnerResource(s)
RegisterPullResources(s)
RegisterRepoResource(s)
RegisterStatusResource(s)
RegisterBranchProtectionResources(s)
log.Debug("Registered core resource templates")
}
func RegisterBranchProtectionResources(s *server.MCPServer) {
branchprotection.RegisterResource(s)
log.Debug("Registered branch protection resource templates")
}
func RegisterPullResources(s *server.MCPServer) {
pull.RegisterPullResources(s)
log.Debug("Registered pull request resource template")
}
func RegisterIssueResources(s *server.MCPServer) {
issue.RegisterIssueResources(s)
log.Debug("Registered issue resource templates")
}
func RegisterCommitResource(s *server.MCPServer) {
repo.RegisterCommitResource(s)
log.Debug("Registered commit resource template")
}
func RegisterOwnerResource(s *server.MCPServer) {
user.RegisterOwnerResource(s)
log.Debug("Registered owner resource template")
}
func RegisterRepoResource(s *server.MCPServer) {
repo.RegisterRepoResource(s)
log.Debug("Registered repo resource template")
}
func RegisterStatusResource(s *server.MCPServer) {
repo.RegisterStatusResource(s)
log.Debug("Registered commit status resource template")
}
func newMCPServer(version string) *server.MCPServer {
return server.NewMCPServer(
"Forgejo MCP Server",
version,
server.WithLogging(),
server.WithResourceCapabilities(false, false),
)
}