Files
vikunja-mcp/tools.go

251 lines
6.1 KiB
Go

// Vikunja MCP - Developed by acidvegas in Go (https://git.acid.vegas)
// vikunja-mcp/tools.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var (
nameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
pathParamRe = regexp.MustCompile(`\{(\w+)\}`)
)
// sanitizeName turns an OpenAPI operationId into a valid MCP tool name.
func sanitizeName(raw string) string {
name := nameSanitizer.ReplaceAllString(raw, "_")
name = strings.Trim(name, "_")
if len(name) > 64 {
name = name[:64]
}
if name == "" {
return "op"
}
return name
}
type opDescriptor struct {
method string
path string
params []map[string]any
}
// registerTools walks an OpenAPI 2 spec and registers an MCP tool for every
// allowlisted operation. Returns the number of tools registered.
func registerTools(server *mcp.Server, cfg *config, spec map[string]any) int {
paths, _ := spec["paths"].(map[string]any)
count := 0
for path, methodsAny := range paths {
methods, ok := methodsAny.(map[string]any)
if !ok {
continue
}
for method, opAny := range methods {
switch method {
case "get", "post", "put", "delete", "patch":
default:
continue
}
methodU := strings.ToUpper(method)
if !isAllowlisted(methodU, path) {
continue
}
op, ok := opAny.(map[string]any)
if !ok {
continue
}
name := toolNameFor(op, method, path)
desc := toolDescFor(op, methodU, path)
properties, required, params := buildSchema(op, spec, methodU, path)
schema := map[string]any{
"type": "object",
"properties": properties,
"required": required,
}
schemaBytes, err := json.Marshal(schema)
if err != nil {
log.Printf("skip %s %s: schema marshal failed: %v", methodU, path, err)
continue
}
descriptor := opDescriptor{method: methodU, path: path, params: params}
server.AddTool(&mcp.Tool{
Name: name,
Description: desc,
InputSchema: json.RawMessage(schemaBytes),
}, makeHandler(cfg, descriptor))
count++
}
}
return count
}
func toolNameFor(op map[string]any, method, path string) string {
raw, _ := op["operationId"].(string)
if raw == "" {
raw = fmt.Sprintf("%s_%s", method, path)
}
return sanitizeName(raw)
}
func toolDescFor(op map[string]any, methodU, path string) string {
desc, _ := op["summary"].(string)
if desc == "" {
desc, _ = op["description"].(string)
}
if desc == "" {
desc = fmt.Sprintf("%s %s", methodU, path)
}
desc = strings.TrimSpace(desc)
if len(desc) > 1024 {
desc = desc[:1024]
}
return desc
}
func buildSchema(op, spec map[string]any, methodU, path string) (map[string]any, []string, []map[string]any) {
properties := map[string]any{}
required := []string{}
var params []map[string]any
paramsAny, _ := op["parameters"].([]any)
for _, p := range paramsAny {
param, ok := p.(map[string]any)
if !ok {
continue
}
params = append(params, param)
pname, _ := param["name"].(string)
loc, _ := param["in"].(string)
if loc == "body" {
body := resolveBodySchema(param, spec)
if _, has := body["description"]; !has {
body["description"] = fmt.Sprintf("Request body for %s %s", methodU, path)
}
properties[pname] = body
} else {
pType, _ := param["type"].(string)
paramDesc, _ := param["description"].(string)
if paramDesc == "" {
paramDesc = fmt.Sprintf("%s parameter", loc)
}
properties[pname] = map[string]any{
"type": openapiToJSON(pType),
"description": strings.TrimSpace(paramDesc),
}
}
if req, _ := param["required"].(bool); req {
required = append(required, pname)
}
}
return properties, required, params
}
// makeHandler returns a ToolHandler that executes a single Vikunja API call
// using the supplied operation descriptor.
func makeHandler(cfg *config, op opDescriptor) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := map[string]any{}
if len(req.Params.Arguments) > 0 {
_ = json.Unmarshal(req.Params.Arguments, &args)
}
path := op.path
query := map[string]string{}
var body any
for _, param := range op.params {
pname, _ := param["name"].(string)
val, ok := args[pname]
if !ok || val == nil {
continue
}
switch loc, _ := param["in"].(string); loc {
case "path":
path = strings.ReplaceAll(path, "{"+pname+"}", fmt.Sprintf("%v", val))
case "query":
query[pname] = fmt.Sprintf("%v", val)
case "body":
body = val
}
}
if matches := pathParamRe.FindAllStringSubmatch(path, -1); len(matches) > 0 {
missing := make([]string, 0, len(matches))
for _, m := range matches {
missing = append(missing, m[1])
}
msg := "error: missing required path parameters: " + strings.Join(missing, ", ")
log.Printf("%s %s -> %s", op.method, op.path, msg)
return toolErr(msg), nil
}
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return toolErr("error: failed to marshal body: " + err.Error()), nil
}
reqBody = bytes.NewReader(b)
}
httpReq, err := http.NewRequestWithContext(ctx, op.method, cfg.baseURL+path, reqBody)
if err != nil {
return toolErr("error: " + err.Error()), nil
}
if len(query) > 0 {
q := httpReq.URL.Query()
for k, v := range query {
q.Set(k, v)
}
httpReq.URL.RawQuery = q.Encode()
}
httpReq.Header.Set("Authorization", "Bearer "+cfg.token)
httpReq.Header.Set("Accept", "application/json")
if body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
resp, err := cfg.httpClient.Do(httpReq)
if err != nil {
return toolErr("error: " + err.Error()), nil
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
log.Printf("%s %s -> HTTP %d", op.method, path, resp.StatusCode)
}
text := fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, string(respBytes))
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, nil
}
}
func toolErr(msg string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: msg}},
IsError: true,
}
}