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