Files
kolaente d335888c89 fix(mcp): forward fields body argument on tools without projection
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
2026-10-23 14:51:17 +02:00

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]) + "…"
}