140 lines
4.1 KiB
Go
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)
|
|
}
|