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.
365 lines
8.6 KiB
Go
365 lines
8.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"codeberg.org/goern/forgejo-mcp/v2/operation"
|
|
flagPkg "codeberg.org/goern/forgejo-mcp/v2/pkg/flag"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
)
|
|
|
|
// cliMode is set to true when --cli is detected in os.Args.
|
|
var cliMode bool
|
|
|
|
// hasCLIFlag checks os.Args for --cli before flag.Parse() runs.
|
|
func hasCLIFlag() bool {
|
|
for _, arg := range os.Args[1:] {
|
|
if arg == "--cli" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// toolDomains maps tool names to their domain for grouped listing.
|
|
// Built by registering tools per domain and tracking which names appear.
|
|
var toolDomains = map[string]string{}
|
|
|
|
// registerToolsWithDomains registers all tools and builds the domain mapping.
|
|
func registerToolsWithDomains(s *server.MCPServer) {
|
|
beforeNames := toolNames(s)
|
|
|
|
type domainReg struct {
|
|
name string
|
|
fn func(*server.MCPServer)
|
|
}
|
|
domains := []domainReg{
|
|
{"user", operation.RegisterUserTool},
|
|
{"repo", operation.RegisterRepoTool},
|
|
{"issue", operation.RegisterIssueTool},
|
|
{"pull", operation.RegisterPullTool},
|
|
{"pull", operation.RegisterPullReviewTool},
|
|
{"search", operation.RegisterSearchTool},
|
|
{"version", operation.RegisterVersionTool},
|
|
{"actions", operation.RegisterActionsTool},
|
|
{"org", operation.RegisterOrgTool},
|
|
{"tracking", operation.RegisterTrackingTool},
|
|
{"attachment", operation.RegisterAttachmentTool},
|
|
{"release", operation.RegisterReleaseTool},
|
|
{"branch-protection", operation.RegisterBranchProtectionTool},
|
|
}
|
|
|
|
for _, d := range domains {
|
|
d.fn(s)
|
|
afterNames := toolNames(s)
|
|
for _, name := range afterNames {
|
|
if !contains(beforeNames, name) {
|
|
toolDomains[name] = d.name
|
|
}
|
|
}
|
|
beforeNames = afterNames
|
|
}
|
|
}
|
|
|
|
func toolNames(s *server.MCPServer) []string {
|
|
tools := s.ListTools()
|
|
names := make([]string, 0, len(tools))
|
|
for name := range tools {
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func contains(ss []string, s string) bool {
|
|
for _, v := range ss {
|
|
if v == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RunCLI is the entry point for --cli mode.
|
|
func RunCLI(version string) {
|
|
// Parse CLI-specific flags using a separate FlagSet.
|
|
fs := flag.NewFlagSet("cli", flag.ExitOnError)
|
|
argsFlag := fs.String("args", "", "JSON arguments for tool invocation")
|
|
outputFlag := fs.String("output", "", "Output format: json or text")
|
|
helpFlag := fs.Bool("help", false, "Show tool parameter help")
|
|
|
|
// Find the positional command (first non-flag arg after --cli).
|
|
// os.Args has been filtered by init() to remove --cli and preceding flags.
|
|
cliArgs := cliArgsToParse()
|
|
if len(cliArgs) == 0 {
|
|
fmt.Fprintln(os.Stderr, "Usage: forgejo-mcp --cli <command> [options]")
|
|
fmt.Fprintln(os.Stderr, "Commands: list, <tool-name>")
|
|
fmt.Fprintln(os.Stderr, "Options: --args '{json}', --output=json|text, --help")
|
|
os.Exit(1)
|
|
}
|
|
|
|
command := cliArgs[0]
|
|
_ = fs.Parse(cliArgs[1:])
|
|
|
|
// Build the MCPServer and register tools with domain tracking.
|
|
flagPkg.Version = version
|
|
mcpSrv := server.NewMCPServer("Forgejo MCP Server", version, server.WithLogging())
|
|
registerToolsWithDomains(mcpSrv)
|
|
|
|
switch command {
|
|
case "list":
|
|
outputMode := *outputFlag
|
|
if outputMode == "" {
|
|
outputMode = "text"
|
|
}
|
|
if err := cliList(mcpSrv, outputMode); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
default:
|
|
if *helpFlag {
|
|
if err := cliHelp(mcpSrv, command); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
outputMode := *outputFlag
|
|
if outputMode == "" {
|
|
outputMode = "json"
|
|
}
|
|
|
|
argsJSON, err := resolveArgs(*argsFlag)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error reading arguments: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := cliExec(mcpSrv, command, argsJSON, outputMode); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// cliArgsToParse extracts the args after --cli from os.Args.
|
|
func cliArgsToParse() []string {
|
|
for i, arg := range os.Args {
|
|
if arg == "--cli" {
|
|
return os.Args[i+1:]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveArgs returns JSON args from --args flag or stdin pipe.
|
|
// --args takes precedence. If neither provided, returns "{}".
|
|
func resolveArgs(argsFlag string) (string, error) {
|
|
if argsFlag != "" {
|
|
return argsFlag, nil
|
|
}
|
|
|
|
// Check if stdin is a pipe (not a terminal).
|
|
stat, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return "{}", nil
|
|
}
|
|
if (stat.Mode() & os.ModeCharDevice) == 0 {
|
|
data, err := io.ReadAll(os.Stdin)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading stdin: %w", err)
|
|
}
|
|
if len(data) > 0 {
|
|
return string(data), nil
|
|
}
|
|
}
|
|
|
|
return "{}", nil
|
|
}
|
|
|
|
// cliList prints all registered tools.
|
|
func cliList(s *server.MCPServer, outputMode string) error {
|
|
tools := s.ListTools()
|
|
if tools == nil {
|
|
fmt.Println("No tools registered.")
|
|
return nil
|
|
}
|
|
|
|
type toolInfo struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Domain string `json:"domain"`
|
|
}
|
|
|
|
// Build sorted list.
|
|
var infos []toolInfo
|
|
for name, st := range tools {
|
|
domain := toolDomains[name]
|
|
if domain == "" {
|
|
domain = "other"
|
|
}
|
|
infos = append(infos, toolInfo{
|
|
Name: name,
|
|
Description: st.Tool.Description,
|
|
Domain: domain,
|
|
})
|
|
}
|
|
sort.Slice(infos, func(i, j int) bool {
|
|
if infos[i].Domain != infos[j].Domain {
|
|
return infos[i].Domain < infos[j].Domain
|
|
}
|
|
return infos[i].Name < infos[j].Name
|
|
})
|
|
|
|
if outputMode == "json" {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(infos)
|
|
}
|
|
|
|
// Text mode: grouped by domain.
|
|
grouped := map[string][]toolInfo{}
|
|
domainOrder := []string{}
|
|
for _, info := range infos {
|
|
if _, exists := grouped[info.Domain]; !exists {
|
|
domainOrder = append(domainOrder, info.Domain)
|
|
}
|
|
grouped[info.Domain] = append(grouped[info.Domain], info)
|
|
}
|
|
|
|
for _, domain := range domainOrder {
|
|
fmt.Printf("\n%s:\n", strings.ToUpper(domain))
|
|
for _, info := range grouped[domain] {
|
|
fmt.Printf(" %-40s %s\n", info.Name, info.Description)
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
return nil
|
|
}
|
|
|
|
// cliHelp prints the parameter schema for a tool.
|
|
func cliHelp(s *server.MCPServer, toolName string) error {
|
|
st := s.GetTool(toolName)
|
|
if st == nil {
|
|
return fmt.Errorf("unknown tool: %s", toolName)
|
|
}
|
|
|
|
fmt.Printf("Tool: %s\n", st.Tool.Name)
|
|
if st.Tool.Description != "" {
|
|
fmt.Printf("Description: %s\n", st.Tool.Description)
|
|
}
|
|
fmt.Println()
|
|
|
|
props := st.Tool.InputSchema.Properties
|
|
required := st.Tool.InputSchema.Required
|
|
|
|
if len(props) == 0 {
|
|
fmt.Println("No parameters.")
|
|
return nil
|
|
}
|
|
|
|
fmt.Println("Parameters:")
|
|
// Sort parameter names for consistent output.
|
|
names := make([]string, 0, len(props))
|
|
for name := range props {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
requiredSet := map[string]bool{}
|
|
for _, r := range required {
|
|
requiredSet[r] = true
|
|
}
|
|
|
|
for _, name := range names {
|
|
prop := props[name]
|
|
reqStr := "optional"
|
|
if requiredSet[name] {
|
|
reqStr = "required"
|
|
}
|
|
|
|
// Property is stored as map[string]any.
|
|
propMap, ok := prop.(map[string]any)
|
|
if !ok {
|
|
fmt.Printf(" %-20s (%s)\n", name, reqStr)
|
|
continue
|
|
}
|
|
|
|
typStr, _ := propMap["type"].(string)
|
|
desc, _ := propMap["description"].(string)
|
|
|
|
fmt.Printf(" %-20s %-10s %-10s %s\n", name, typStr, reqStr, desc)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cliExec invokes a tool handler and prints the result.
|
|
func cliExec(s *server.MCPServer, toolName, argsJSON, outputMode string) error {
|
|
st := s.GetTool(toolName)
|
|
if st == nil {
|
|
return fmt.Errorf("unknown tool: %s\nRun 'forgejo-mcp --cli list' to see available tools", toolName)
|
|
}
|
|
|
|
// Parse JSON arguments.
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
|
return fmt.Errorf("invalid JSON arguments: %w", err)
|
|
}
|
|
|
|
// Construct CallToolRequest.
|
|
req := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: toolName,
|
|
Arguments: args,
|
|
},
|
|
}
|
|
|
|
// Call the handler.
|
|
result, err := st.Handler(context.Background(), req)
|
|
if err != nil {
|
|
return fmt.Errorf("tool execution failed: %w", err)
|
|
}
|
|
|
|
// Check IsError flag.
|
|
if result.IsError {
|
|
if outputMode == "json" {
|
|
enc := json.NewEncoder(os.Stderr)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(result.Content)
|
|
} else {
|
|
for _, c := range result.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
fmt.Fprintln(os.Stderr, tc.Text)
|
|
}
|
|
}
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Output result.
|
|
if outputMode == "json" {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(result.Content)
|
|
}
|
|
|
|
// Text mode: print text content line by line.
|
|
for _, c := range result.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
fmt.Println(tc.Text)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|