251 lines
6.1 KiB
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,
|
|
}
|
|
}
|