mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
The fields projection argument was stripped from every tool's arguments, including write tools like tasks_bulk_update where fields is part of the request body, so bulk updates always failed with ErrBulkTaskFieldsRequired. Fixes #4235
314 lines
9.1 KiB
Go
314 lines
9.1 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
|
|
"code.vikunja.io/api/pkg/log"
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/modules/humabridge"
|
|
apiv2 "code.vikunja.io/api/pkg/routes/api/v2"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
var (
|
|
errToolNotFound = errors.New("mcp: tool not found")
|
|
errScopeDenied = errors.New("mcp: tool not authorized for this token")
|
|
errNoCaller = errors.New("mcp: no caller request in context")
|
|
)
|
|
|
|
func (m *Module) callTool(ctx context.Context, name string, rawArgs json.RawMessage) (any, error) {
|
|
t, ok := m.index[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", errToolNotFound, name)
|
|
}
|
|
// Read the caller before dispatching: the loopback re-enters the group middleware, which stashes its own echo context.
|
|
ec := humabridge.EchoContextFrom(ctx)
|
|
if ec == nil {
|
|
return nil, errNoCaller
|
|
}
|
|
token := tokenFrom(ec)
|
|
if !m.authorized(t, token) {
|
|
return nil, fmt.Errorf("%w: %s", errScopeDenied, name)
|
|
}
|
|
args, err := decodeArgs(t.spec, rawArgs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: invalid arguments for %s: %w", name, err)
|
|
}
|
|
var fields fieldTree
|
|
// Without a projection, fields is a body field of the route (e.g. tasks bulk update).
|
|
if t.spec.projection != noProjection {
|
|
fields, err = takeFields(args)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: invalid arguments for %s: %w", name, err)
|
|
}
|
|
}
|
|
req, err := t.newRequest(ctx, ec, args)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: invalid arguments for %s: %w", name, err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
m.api.Adapter().ServeHTTP(rec, req)
|
|
// The loopback runs as an internal dispatch, for which the auth middleware skips the usage event;
|
|
// a denied leg records none, just as the middleware dispatches nothing when the route check fails.
|
|
if rec.Code < 400 {
|
|
if err := models.RecordAPITokenUse(ctx, token); err != nil {
|
|
log.Errorf("[mcp] could not dispatch api token used event: %s", err)
|
|
}
|
|
}
|
|
result, err := parseResponse(rec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t.spec.projection.apply(fields, result), nil
|
|
}
|
|
func (t *tool) newRequest(ctx context.Context, ec *echo.Context, args map[string]json.RawMessage) (*http.Request, error) {
|
|
caller := ec.Request()
|
|
path := t.op.Path
|
|
query := url.Values{}
|
|
body := map[string]json.RawMessage{}
|
|
format := ""
|
|
for name, raw := range args {
|
|
p, isParam := t.spec.params[name]
|
|
switch {
|
|
// AutoPatch copies headers onto its internal GET and PUT, the query onto neither.
|
|
case name == formatParam && t.op.Method == http.MethodPatch:
|
|
v, err := scalarString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid value for %q: %w", name, err)
|
|
}
|
|
format = v
|
|
case !isParam:
|
|
body[name] = raw
|
|
case p.In == "path":
|
|
v, err := scalarString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid value for %q: %w", name, err)
|
|
}
|
|
path = strings.ReplaceAll(path, "{"+name+"}", url.PathEscape(v))
|
|
default:
|
|
values, err := queryValues(p, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid value for %q: %w", name, err)
|
|
}
|
|
for _, v := range values {
|
|
query.Add(name, v)
|
|
}
|
|
}
|
|
}
|
|
var reader io.Reader
|
|
if t.spec.hasBody {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
}
|
|
target := path
|
|
if len(query) > 0 {
|
|
target += "?" + query.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, t.op.Method, target, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The transport authorised one of possibly several Authorization values; the loopback must not authenticate as a different one.
|
|
if auth, ok := models.APITokenAuthorization(caller.Header); ok {
|
|
req.Header.Set("Authorization", auth)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if reader != nil {
|
|
req.Header.Set("Content-Type", t.contentType)
|
|
}
|
|
if format != "" {
|
|
req.Header.Set(apiv2.RichTextFormatHeader, format)
|
|
}
|
|
// Keep the public origin so generated links and the rate limiter see the real client.
|
|
req.Host = caller.Host
|
|
req.TLS = caller.TLS
|
|
// AutoPatch's inner request carries a path-only URL, so huma falls back to this header for the schema host.
|
|
if forwardedHost := caller.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
|
req.Header.Set("X-Forwarded-Host", forwardedHost)
|
|
} else {
|
|
req.Header.Set("X-Forwarded-Host", caller.Host)
|
|
}
|
|
for _, h := range []string{
|
|
"X-Forwarded-For",
|
|
"X-Real-Ip",
|
|
"User-Agent",
|
|
} {
|
|
if vs := caller.Header.Values(h); len(vs) > 0 {
|
|
req.Header[http.CanonicalHeaderKey(h)] = slices.Clone(vs)
|
|
}
|
|
}
|
|
if id := requestID(ec); id != "" {
|
|
req.Header.Set(echo.HeaderXRequestID, id)
|
|
}
|
|
req.RemoteAddr = caller.RemoteAddr
|
|
return req, nil
|
|
}
|
|
|
|
// echo's RequestID middleware writes the id it generated to the response only.
|
|
func requestID(ec *echo.Context) string {
|
|
if id := ec.Response().Header().Get(echo.HeaderXRequestID); id != "" {
|
|
return id
|
|
}
|
|
return ec.Request().Header.Get(echo.HeaderXRequestID)
|
|
}
|
|
|
|
func scalarString(raw json.RawMessage) (string, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
var v any
|
|
if err := dec.Decode(&v); err != nil {
|
|
return "", err
|
|
}
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x, nil
|
|
case json.Number:
|
|
return x.String(), nil
|
|
case bool:
|
|
if x {
|
|
return "true", nil
|
|
}
|
|
return "false", nil
|
|
}
|
|
return "", errors.New("must be a string, number or boolean")
|
|
}
|
|
func queryValues(p *huma.Param, raw json.RawMessage) ([]string, error) {
|
|
if !bytes.HasPrefix(bytes.TrimSpace(raw), []byte("[")) {
|
|
s, err := scalarString(raw)
|
|
return []string{s}, err
|
|
}
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal(raw, &items); err != nil {
|
|
return nil, err
|
|
}
|
|
parts := make([]string, 0, len(items))
|
|
for _, it := range items {
|
|
s, err := scalarString(it)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, s)
|
|
}
|
|
if p.Explode != nil && *p.Explode {
|
|
return parts, nil
|
|
}
|
|
return []string{strings.Join(parts, ",")}, nil
|
|
}
|
|
func parseResponse(rec *httptest.ResponseRecorder) (any, error) {
|
|
// AutoPatch answers a patch that changes nothing with an empty 304.
|
|
if rec.Code == http.StatusNotModified {
|
|
return map[string]any{
|
|
"ok": true,
|
|
"unchanged": true,
|
|
}, nil
|
|
}
|
|
if rec.Code >= 300 {
|
|
return nil, errors.New(errorText(rec))
|
|
}
|
|
if rec.Body.Len() == 0 {
|
|
return map[string]any{"ok": true}, nil
|
|
}
|
|
dec := json.NewDecoder(rec.Body)
|
|
dec.UseNumber()
|
|
var out any
|
|
if err := dec.Decode(&out); err != nil {
|
|
return nil, fmt.Errorf("mcp: decode v2 response: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
const maxErrorTextRunes = 2000
|
|
|
|
const scopeHint = "the API token lacks a scope required by this call (check route and expand scopes)"
|
|
|
|
func errorText(rec *httptest.ResponseRecorder) string {
|
|
text, code, isProblem := problemText(rec)
|
|
if filterErrorCodes[code] {
|
|
return truncateRunes(text, maxErrorTextRunes) + " — " + filterErrorHint
|
|
}
|
|
if rec.Code != http.StatusUnauthorized {
|
|
return truncateRunes(text, maxErrorTextRunes)
|
|
}
|
|
// The transport already authenticated the token, so a loopback 401 is a missing scope, not a bad token.
|
|
if !isProblem {
|
|
text = "401 Unauthorized"
|
|
}
|
|
return truncateRunes(text, maxErrorTextRunes) + " — " + scopeHint
|
|
}
|
|
func problemText(rec *httptest.ResponseRecorder) (text string, code int, isProblem bool) {
|
|
var problem struct {
|
|
Title string `json:"title"`
|
|
Detail string `json:"detail"`
|
|
Code int `json:"code"`
|
|
Errors []struct {
|
|
Location string `json:"location"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
body := rec.Body.Bytes()
|
|
if err := json.Unmarshal(body, &problem); err != nil || problem.Title == "" {
|
|
return fmt.Sprintf("%d: %s", rec.Code, strings.TrimSpace(string(body))), 0, false
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%d %s", rec.Code, problem.Title)
|
|
if problem.Detail != "" {
|
|
b.WriteString(": " + problem.Detail)
|
|
}
|
|
for i, e := range problem.Errors {
|
|
if i == 0 {
|
|
b.WriteString(" (")
|
|
} else {
|
|
b.WriteString("; ")
|
|
}
|
|
if e.Location != "" {
|
|
b.WriteString(e.Location + ": ")
|
|
}
|
|
b.WriteString(e.Message)
|
|
if i == len(problem.Errors)-1 {
|
|
b.WriteString(")")
|
|
}
|
|
}
|
|
return b.String(), problem.Code, true
|
|
}
|
|
func truncateRunes(s string, limit int) string {
|
|
if len(s) <= limit {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
if len(runes) <= limit {
|
|
return s
|
|
}
|
|
return string(runes[:limit]) + "…"
|
|
}
|