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) }