237 lines
9.1 KiB
Go
237 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
|
|
"code.vikunja.io/api/pkg/web"
|
|
"code.vikunja.io/api/pkg/web/handler"
|
|
)
|
|
|
|
// ErrToolNotFound is returned when Dispatch is called for a tool name that
|
|
// has not been registered. Callers should map this to an MCP tool result
|
|
// with IsError=true (per the SDK convention for missing tools), not to a
|
|
// JSON-RPC protocol error.
|
|
var ErrToolNotFound = errors.New("mcp: tool not found")
|
|
|
|
// ErrNoUserInContext is returned when Dispatch is invoked without a user
|
|
// in ctx. Task 2's entry handler always sets one, so hitting this means
|
|
// either a programming bug or someone calling Dispatch outside the HTTP
|
|
// pipeline.
|
|
var ErrNoUserInContext = errors.New("mcp: no user in context")
|
|
|
|
// inputAdapter is the Task 3/Task 4 seam. Each per-op input wrapper struct
|
|
// (defined in inputs.go, added by Task 4) implements ApplyTo, which copies
|
|
// the wrapper's fields onto a fresh handler.CObject. The dispatcher
|
|
// allocates a wrapper from Resource.Inputs[op] via reflection,
|
|
// json.Unmarshals tool arguments into it, then calls ApplyTo on the model
|
|
// returned by Resource.EmptyStruct().
|
|
//
|
|
// Defining the interface here (rather than in inputs.go) keeps the
|
|
// dispatcher buildable in Task 3 before any wrappers exist; the
|
|
// dispatcher tests provide their own ApplyTo implementation to exercise
|
|
// the code path.
|
|
type inputAdapter interface {
|
|
ApplyTo(dst handler.CObject) error
|
|
}
|
|
|
|
// readAllInput is the optional interface a wrapper for OpReadAll may
|
|
// implement to expose pagination fields to the dispatcher. Wrappers that
|
|
// don't implement it get search="", page=0, perPage=0 (the same defaults
|
|
// the REST layer applies when callers omit the query parameters).
|
|
type readAllInput interface {
|
|
ReadAllParams() (search string, page int, perPage int)
|
|
}
|
|
|
|
// crudFuncs are the framework-agnostic Do* entry points the dispatcher
|
|
// invokes. The package-level defaults point at handler.Do*; tests swap
|
|
// them out so they can run without a database connection (handler.Do*
|
|
// opens an xorm session, which is fine in integration tests but not in
|
|
// the dispatcher unit tests that exercise routing logic only).
|
|
type crudFuncs struct {
|
|
doCreate func(context.Context, handler.CObject, web.Auth) error
|
|
doReadOne func(context.Context, handler.CObject, web.Auth) (int, error)
|
|
doReadAll func(context.Context, handler.CObject, web.Auth, string, int, int) (any, int, int64, error)
|
|
doUpdate func(context.Context, handler.CObject, web.Auth) error
|
|
doDelete func(context.Context, handler.CObject, web.Auth) error
|
|
}
|
|
|
|
var defaultCRUD = crudFuncs{
|
|
doCreate: handler.DoCreate,
|
|
doReadOne: handler.DoReadOne,
|
|
doReadAll: handler.DoReadAll,
|
|
doUpdate: handler.DoUpdate,
|
|
doDelete: handler.DoDelete,
|
|
}
|
|
|
|
// crud is the live set of Do* functions Dispatch uses. Tests swap it out
|
|
// via withCRUD and restore it on teardown.
|
|
var crud = defaultCRUD
|
|
|
|
// Dispatch is the single entry point for every tools/call when the caller
|
|
// only has raw JSON arguments (e.g. unit tests, or future non-SDK call
|
|
// sites). It unmarshals the arguments into the wrapper registered for the
|
|
// tool and delegates to DispatchTyped, which is also the path the
|
|
// AddTool-generated handlers take (they pass an already-typed wrapper to
|
|
// skip the unmarshal round-trip the SDK has already performed against the
|
|
// input schema).
|
|
//
|
|
// Errors fall into three categories:
|
|
// - ErrToolNotFound / ErrNoUserInContext / ErrScopeDenied /
|
|
// JSON-unmarshal errors are dispatcher-level failures the caller should
|
|
// translate into an IsError=true tool result. We return them as errors
|
|
// here (rather than constructing a *mcp.CallToolResult) so the
|
|
// dispatcher stays SDK-agnostic; the thin AddTool handler does the
|
|
// wrapping.
|
|
// - Errors returned by handler.Do* (model-layer permission denials,
|
|
// validation failures, etc.) are propagated as-is. The tool handler
|
|
// wraps them with SetError per the SDK's convention that domain
|
|
// failures be reported as tool results, not protocol errors.
|
|
func Dispatch(ctx context.Context, toolName string, rawArgs json.RawMessage) (any, error) {
|
|
ref, ok := lookupTool(toolName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
|
|
}
|
|
|
|
// Scope check first — never allocate a wrapper or touch model state
|
|
// for a tool the caller isn't authorized to invoke. This guards
|
|
// against the (rare) case where the per-session tool registration in
|
|
// newServer registered a tool the current request's token doesn't
|
|
// have a scope for: the SDK caches the *Server across requests, but
|
|
// the API token is per-HTTP-request.
|
|
if !tokenAuthorizes(TokenFromContext(ctx), ref.resource.Name, ref.op) {
|
|
return nil, fmt.Errorf("%w: %s", ErrScopeDenied, toolName)
|
|
}
|
|
|
|
// Allocate a fresh wrapper for this call so concurrent dispatches
|
|
// don't share state through the prototype stored in r.Inputs.
|
|
wrapperProto, ok := ref.resource.Inputs[ref.op]
|
|
if !ok {
|
|
return nil, fmt.Errorf("mcp: resource %q has no input wrapper for op %s", ref.resource.Name, ref.op.ToolSuffix())
|
|
}
|
|
wrapper, err := allocateWrapper(wrapperProto)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(rawArgs) > 0 {
|
|
if err := json.Unmarshal(rawArgs, wrapper); err != nil {
|
|
return nil, fmt.Errorf("mcp: invalid arguments for %s: %w", toolName, err)
|
|
}
|
|
}
|
|
|
|
return dispatchPrepared(ctx, ref, wrapper)
|
|
}
|
|
|
|
// DispatchTyped is the dispatcher entry point for callers that already have
|
|
// a typed wrapper value (e.g. AddTool handlers, where the SDK has already
|
|
// unmarshalled and validated args against the input schema). It skips the
|
|
// JSON round-trip that Dispatch performs.
|
|
//
|
|
// The wrapper must implement inputAdapter (and optionally readAllInput for
|
|
// pagination). Every wrapper registered in inputs.go meets that contract.
|
|
func DispatchTyped(ctx context.Context, toolName string, wrapper any) (any, error) {
|
|
ref, ok := lookupTool(toolName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
|
|
}
|
|
// Scope check mirrors Dispatch — see the comment there for why this
|
|
// is necessary even when newServer already filtered the tool set.
|
|
if !tokenAuthorizes(TokenFromContext(ctx), ref.resource.Name, ref.op) {
|
|
return nil, fmt.Errorf("%w: %s", ErrScopeDenied, toolName)
|
|
}
|
|
return dispatchPrepared(ctx, ref, wrapper)
|
|
}
|
|
|
|
// dispatchPrepared runs the shared post-allocation pipeline: pull the user
|
|
// from ctx, copy the wrapper onto a fresh model via inputAdapter, then call
|
|
// the right handler.Do* per op. Both Dispatch (raw JSON path) and
|
|
// DispatchTyped (AddTool path) funnel through here.
|
|
func dispatchPrepared(ctx context.Context, ref toolRef, wrapper any) (any, error) {
|
|
u := UserFromContext(ctx)
|
|
if u == nil {
|
|
return nil, ErrNoUserInContext
|
|
}
|
|
|
|
model := ref.resource.EmptyStruct()
|
|
if adapter, ok := wrapper.(inputAdapter); ok {
|
|
if err := adapter.ApplyTo(model); err != nil {
|
|
return nil, fmt.Errorf("mcp: copy input for %s_%s: %w", ref.resource.Name, ref.op.ToolSuffix(), err)
|
|
}
|
|
}
|
|
|
|
switch ref.op {
|
|
case OpCreate:
|
|
if err := crud.doCreate(ctx, model, u); err != nil {
|
|
return nil, err
|
|
}
|
|
return model, nil
|
|
|
|
case OpReadOne:
|
|
if _, err := crud.doReadOne(ctx, model, u); err != nil {
|
|
return nil, err
|
|
}
|
|
return model, nil
|
|
|
|
case OpReadAll:
|
|
search, page, perPage := "", 0, 0
|
|
if ra, ok := wrapper.(readAllInput); ok {
|
|
search, page, perPage = ra.ReadAllParams()
|
|
}
|
|
result, _, _, err := crud.doReadAll(ctx, model, u, search, page, perPage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
|
|
case OpUpdate:
|
|
if err := crud.doUpdate(ctx, model, u); err != nil {
|
|
return nil, err
|
|
}
|
|
return model, nil
|
|
|
|
case OpDelete:
|
|
if err := crud.doDelete(ctx, model, u); err != nil {
|
|
return nil, err
|
|
}
|
|
return model, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("mcp: unsupported op %d for tool %s_%s", ref.op, ref.resource.Name, ref.op.ToolSuffix())
|
|
}
|
|
|
|
// allocateWrapper returns a fresh pointer of the same concrete type as the
|
|
// prototype stored in Resource.Inputs. Resource.Inputs is conventionally a
|
|
// pointer-to-zero (e.g. &ProjectCreateInput{}); allocateWrapper takes its
|
|
// reflect.Type, allocates a fresh value, and hands back a pointer suitable
|
|
// for json.Unmarshal.
|
|
func allocateWrapper(proto any) (any, error) {
|
|
if proto == nil {
|
|
return nil, errors.New("mcp: nil input prototype")
|
|
}
|
|
t := reflect.TypeOf(proto)
|
|
if t.Kind() != reflect.Pointer {
|
|
return nil, fmt.Errorf("mcp: input prototype must be a pointer, got %s", t.Kind())
|
|
}
|
|
return reflect.New(t.Elem()).Interface(), nil
|
|
}
|