mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
Each call is replayed as an internal HTTP request with the caller's API token, so route scopes, expand scopes and the audit trail are the real ones.
298 lines
8.5 KiB
Go
298 lines
8.5 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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
return parseResponse(rec)
|
|
}
|
|
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, isProblem := problemText(rec)
|
|
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, isProblem bool) {
|
|
var problem struct {
|
|
Title string `json:"title"`
|
|
Detail string `json:"detail"`
|
|
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))), 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(), 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]) + "…"
|
|
}
|