woodpecker-mcp/internal/tools/helpers.go
2026-08-08 01:37:47 +03:00

140 lines
4.1 KiB
Go

package tools
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"math"
"net/url"
"strconv"
"github.com/mark3labs/mcp-go/mcp"
"gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker"
)
// decodedArguments returns the tool call arguments as a map. When the raw
// JSON payload is available it is decoded with json.Number so 64-bit integer
// arguments (repo IDs, pipeline numbers) never lose precision through float64.
func decodedArguments(req mcp.CallToolRequest) map[string]any {
if len(req.Params.RawArguments) > 0 {
var args map[string]any
dec := json.NewDecoder(bytes.NewReader(req.Params.RawArguments))
dec.UseNumber()
if dec.Decode(&args) == nil {
return args
}
}
return req.GetArguments()
}
// argString returns the string value for key, or "" when absent.
func argString(args map[string]any, key string) string {
v, _ := args[key].(string)
return v
}
// argInt64 returns the integer value for key, honoring both the float64
// values produced by standard JSON decoding and json.Number values produced
// by decodedArguments. ok is false when the key is absent or not an integer.
func argInt64(args map[string]any, key string) (int64, bool) {
v, ok := args[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case json.Number:
i, err := n.Int64()
return i, err == nil
case float64:
if n != math.Trunc(n) {
return 0, false
}
return int64(n), true
default:
return 0, false
}
}
// argBool returns the boolean value for key; ok is false when absent.
func argBool(args map[string]any, key string) (bool, bool) {
v, ok := args[key].(bool)
return v, ok
}
// encodePath escapes a path segment the same way encodeURIComponent does for
// identifiers that must not contain slashes (e.g. "owner/repo").
func encodePath(segment string) string {
return url.QueryEscape(segment)
}
// render marshals v to a JSON string. Marshalling never fails for the plain
// output structs used by the tools; the defensive branch guards against
// misuse.
func render(v any) string {
b, err := json.Marshal(v)
if err != nil {
return `{"error":"failed to serialize response"}`
}
return string(b)
}
// renderError produces the client-facing error payload for a failed tool
// call: the status-aware context JSON, wrapped for the MCP result.
func renderError(err error, context string) *mcp.CallToolResult {
var apiErr *woodpecker.APIError
if !errors.As(err, &apiErr) {
apiErr = &woodpecker.APIError{Status: 500, Message: err.Error()}
}
return mcp.NewToolResultError(apiErr.ToContext(context))
}
// decodeBase64 decodes s as base64 UTF-8. Invalid input is returned as-is,
// matching the lenient Buffer.from(data, "base64") behaviour of the original
// client.
func decodeBase64(s string) string {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return s
}
return string(b)
}
// readOnlyAnnotations marks a tool as read-only and idempotent. All
// woodpecker-mcp tools only perform GET requests, so clients may cache
// results and skip destructive-action confirmations. mcp-go defaults to
// destructive/open-world, so every field is set explicitly.
func readOnlyAnnotations() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithDestructiveHintAnnotation(false),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithOpenWorldHintAnnotation(false),
}
}
// newReadOnlyTool builds a tool whose options carry the read-only
// annotations in addition to opts.
func newReadOnlyTool(name string, opts ...mcp.ToolOption) mcp.Tool {
return mcp.NewTool(name, append(opts, readOnlyAnnotations()...)...)
}
// parseIntParam validates and returns a positive integer parameter.
// When absent it returns fallback; when present but invalid it reports why.
func parseIntParam(args map[string]any, key string, fallback int64, min, max int64) (int64, error) {
v, ok := argInt64(args, key)
if !ok {
return fallback, nil
}
if v < min || v > max {
return 0, errors.New(key + " must be between " + strconv.FormatInt(min, 10) + " and " + strconv.FormatInt(max, 10))
}
return v, nil
}
// formatInt64 is a tiny helper to keep query building readable.
func formatInt64(v int64) string {
return strconv.FormatInt(v, 10)
}