feat(mcp): add resource registry and dispatcher

Define the Op bitmask, the Resource struct, the package-level Register
function, and the Dispatch entry point that future tasks will use to
expose CRUD resources over MCP. No resources are registered yet.

Op carries the CRUD-op identity, knows its api-token permission string
(matching apiTokenRoutes exactly), and knows its tool-name suffix.
Resource.Inputs maps each enabled op to a pointer-to-zero of the wrapper
type the dispatcher will allocate and unmarshal into. Register validates
the resource shape and populates a tool-name lookup table so the
dispatcher never has to string-parse names like task_comments_read_all.

Dispatch threads the user from ctx, allocates a fresh wrapper, unmarshals
arguments, asks the wrapper to copy itself onto a fresh model via the
inputAdapter seam (which Task 4 will populate with real implementations),
and forwards to the corresponding handler.Do* function. The Do* calls go
through a swappable crudFuncs struct so the unit tests can verify
dispatch routing without standing up the database.
This commit is contained in:
kolaente 2026-05-26 23:20:04 +02:00
parent 3ec2d89543
commit a0116749d1
4 changed files with 937 additions and 0 deletions

View File

@ -0,0 +1,193 @@
// 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. It returns
// either the result the SDK should serialize (a model on read_one/update,
// the slice from ReadAll on read_all, or the model on create) or an error.
//
// Errors fall into two categories:
// - ErrToolNotFound / ErrNoUserInContext / 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 in Task 5 does the wrapping.
// - Errors returned by handler.Do* (model-layer permission denials,
// validation failures, etc.) are propagated as-is. The tool handler
// in Task 5 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)
}
u := UserFromContext(ctx)
if u == nil {
return nil, ErrNoUserInContext
}
// 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)
}
}
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: %w", toolName, 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", ref.op, toolName)
}
// 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
}

View File

@ -0,0 +1,319 @@
// 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"
"testing"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"code.vikunja.io/api/pkg/web/handler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
// stubCObject is a test double for handler.CObject that records which method
// was invoked by the dispatcher. Each instance must be checked individually,
// because handler.Do* runs against a fresh EmptyStruct() per call.
type stubCObject struct {
ID int64 `json:"id"`
Title string
// called records the most recent CRUD method invoked on this instance.
called string
// returnErr is returned from the next CRUD method invoked. Permission
// checks always allow access; failure scenarios are exercised by the
// model layer in the integration tests.
returnErr error
}
func (s *stubCObject) CanRead(_ *xorm.Session, _ web.Auth) (bool, int, error) {
return true, 0, nil
}
func (s *stubCObject) CanDelete(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
func (s *stubCObject) CanUpdate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
func (s *stubCObject) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil }
func (s *stubCObject) Create(_ *xorm.Session, _ web.Auth) error {
s.called = "Create"
return s.returnErr
}
func (s *stubCObject) ReadOne(_ *xorm.Session, _ web.Auth) error {
s.called = "ReadOne"
return s.returnErr
}
func (s *stubCObject) ReadAll(_ *xorm.Session, _ web.Auth, search string, page, perPage int) (any, int, int64, error) {
s.called = "ReadAll"
return []string{search}, page, int64(perPage), s.returnErr
}
func (s *stubCObject) Update(_ *xorm.Session, _ web.Auth) error {
s.called = "Update"
return s.returnErr
}
func (s *stubCObject) Delete(_ *xorm.Session, _ web.Auth) error {
s.called = "Delete"
return s.returnErr
}
// stubTracker tracks the *last* instance handed out by EmptyStruct so the
// test can inspect which method was invoked after the dispatcher has run.
type stubTracker struct {
last *stubCObject
nextErr error
}
func (s *stubTracker) empty() handler.CObject {
o := &stubCObject{returnErr: s.nextErr}
s.last = o
return o
}
// stubInput is the wrapper type used by the dispatcher tests for every op.
// In the real registry each op has its own wrapper type; for testing the
// dispatcher we only need something that unmarshal+ApplyTo work against.
type stubInput struct {
ID int64 `json:"id"`
Title string `json:"title"`
Search string `json:"search,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
}
// ApplyTo copies wrapper fields onto the model. This is the seam Task 4 will
// fill in for real resources; for now the dispatcher tests provide their own
// implementation via the inputAdapter interface so we can verify dispatch
// without depending on the (still-absent) per-resource adapter.
func (i *stubInput) ApplyTo(dst handler.CObject) error {
s, ok := dst.(*stubCObject)
if !ok {
return errors.New("stubInput: unexpected target type")
}
s.ID = i.ID
s.Title = i.Title
return nil
}
// ReadAllParams exposes the pagination fields to the dispatcher. The real
// wrappers in Task 4 follow the same shape; the dispatcher reads these
// without depending on the concrete struct.
func (i *stubInput) ReadAllParams() (string, int, int) {
return i.Search, i.Page, i.PerPage
}
func newAuthedCtx(t *testing.T) context.Context {
t.Helper()
u := &user.User{ID: 42}
return WithUser(context.Background(), u)
}
// installStubCRUD swaps the dispatcher's Do* function set with test doubles
// that drive the model's CRUD methods directly (no xorm session). It
// returns a teardown that restores the original handler.Do* set. Tests
// that need to verify dispatch routing without standing up the DB should
// call this at the top.
func installStubCRUD(t *testing.T) {
t.Helper()
saved := crud
crud = crudFuncs{
doCreate: func(_ context.Context, obj handler.CObject, a web.Auth) error {
return obj.Create(nil, a)
},
doReadOne: func(_ context.Context, obj handler.CObject, a web.Auth) (int, error) {
return 0, obj.ReadOne(nil, a)
},
doReadAll: func(_ context.Context, obj handler.CObject, a web.Auth, search string, page, perPage int) (any, int, int64, error) {
return obj.ReadAll(nil, a, search, page, perPage)
},
doUpdate: func(_ context.Context, obj handler.CObject, a web.Auth) error {
return obj.Update(nil, a)
},
doDelete: func(_ context.Context, obj handler.CObject, a web.Auth) error {
return obj.Delete(nil, a)
},
}
t.Cleanup(func() { crud = saved })
}
func TestDispatchToolNotFound(t *testing.T) {
resetRegistry(t)
_, err := Dispatch(newAuthedCtx(t), "missing_tool", json.RawMessage(`{}`))
require.Error(t, err)
assert.ErrorIs(t, err, ErrToolNotFound)
}
func TestDispatchNoUser(t *testing.T) {
resetRegistry(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
_, err := Dispatch(context.Background(), "stubs_read_one", json.RawMessage(`{"id":1}`))
require.Error(t, err)
assert.ErrorIs(t, err, ErrNoUserInContext)
}
func TestDispatchCallsCreate(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpCreate,
Inputs: map[Op]any{OpCreate: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_create", json.RawMessage(`{"title":"hello"}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "Create", tracker.last.called)
assert.Equal(t, "hello", tracker.last.Title)
}
func TestDispatchCallsReadOne(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
out, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{"id":7}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "ReadOne", tracker.last.called)
assert.Equal(t, int64(7), tracker.last.ID)
// ReadOne returns the (now-populated) model directly.
assert.Same(t, tracker.last, out)
}
func TestDispatchCallsReadAll(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadAll,
Inputs: map[Op]any{OpReadAll: &stubInput{}},
}))
out, err := Dispatch(newAuthedCtx(t), "stubs_read_all", json.RawMessage(`{"search":"foo","page":2,"per_page":50}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "ReadAll", tracker.last.called)
// The stub's ReadAll echoes the search/page/per_page so we can confirm
// the dispatcher threaded the wrapper's pagination fields through.
assert.Equal(t, []string{"foo"}, out)
}
func TestDispatchCallsUpdate(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpUpdate,
Inputs: map[Op]any{OpUpdate: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_update", json.RawMessage(`{"id":3,"title":"new"}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "Update", tracker.last.called)
assert.Equal(t, int64(3), tracker.last.ID)
assert.Equal(t, "new", tracker.last.Title)
}
func TestDispatchCallsDelete(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpDelete,
Inputs: map[Op]any{OpDelete: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_delete", json.RawMessage(`{"id":9}`))
require.NoError(t, err)
require.NotNil(t, tracker.last)
assert.Equal(t, "Delete", tracker.last.called)
assert.Equal(t, int64(9), tracker.last.ID)
}
func TestDispatchModelErrorPropagates(t *testing.T) {
resetRegistry(t)
installStubCRUD(t)
wantErr := errors.New("simulated model error")
tracker := &stubTracker{nextErr: wantErr}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{"id":1}`))
require.Error(t, err)
assert.ErrorIs(t, err, wantErr)
}
func TestDispatchInvalidJSON(t *testing.T) {
resetRegistry(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
_, err := Dispatch(newAuthedCtx(t), "stubs_read_one", json.RawMessage(`{not json`))
require.Error(t, err)
}
func TestDispatchUnsupportedOpForResource(t *testing.T) {
resetRegistry(t)
tracker := &stubTracker{}
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: tracker.empty,
Ops: OpReadOne, // only read_one is registered
Inputs: map[Op]any{OpReadOne: &stubInput{}},
}))
// stubs_create was never registered, so it must be tool-not-found.
_, err := Dispatch(newAuthedCtx(t), "stubs_create", json.RawMessage(`{}`))
require.Error(t, err)
assert.ErrorIs(t, err, ErrToolNotFound)
}

200
pkg/modules/mcp/registry.go Normal file
View File

@ -0,0 +1,200 @@
// 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 (
"errors"
"fmt"
"sync"
"code.vikunja.io/api/pkg/web/handler"
)
// Op is a bitmask of the CRUD operations a resource exposes. Bitmask was
// chosen because resources rarely need anything beyond a simple
// allow/disallow per op and OR-ing flags reads cleanly at the registration
// site (e.g. OpCreate | OpReadOne | OpReadAll). No other corner of the
// codebase uses bitmasks; this is local to the MCP registry.
type Op uint8
const (
OpCreate Op = 1 << iota
OpReadOne
OpReadAll
OpUpdate
OpDelete
)
// AllOps returns the ops in registration-and-iteration order. Keeping the
// list in one place ensures the registry, dispatcher, and any future
// tools/list filter walk the same five.
func AllOps() []Op {
return []Op{OpCreate, OpReadOne, OpReadAll, OpUpdate, OpDelete}
}
// Permission returns the API-token permission string for this op. The
// strings must match the permission names that pkg/models/api_routes.go
// stores under apiTokenRoutes[group][...]; CanDoAPIRoute in the REST layer
// and the (future) MCP per-tool scope filter both look up by these exact
// strings.
func (o Op) Permission() string {
switch o {
case OpCreate:
return "create"
case OpReadOne:
return "read_one"
case OpReadAll:
return "read_all"
case OpUpdate:
return "update"
case OpDelete:
return "delete"
}
return ""
}
// ToolSuffix returns the snake_case suffix used to form a tool name. Tool
// names are <resource.Name>_<op-suffix>; the suffix is identical to the
// permission string today but kept separate so the two can evolve
// independently if MCP and the REST scope system diverge.
func (o Op) ToolSuffix() string {
return o.Permission()
}
// Resource describes a CRUD-able model exposed over MCP. Mirrors the
// handler.WebHandler{EmptyStruct: ...} shape used in pkg/routes/routes.go.
//
// Inputs maps each enabled op to a pointer-to-zero of the wrapper struct
// the dispatcher should unmarshal tool arguments into. The wrapper carries
// json:/jsonschema: tags consumed by the SDK's AddTool for input-schema
// generation, and implements the inputAdapter seam below so the dispatcher
// can copy wrapper -> fresh model before invoking handler.Do*.
//
// The wrapper structs themselves live in inputs.go (introduced in Task 4).
// Task 3 only carries them through the registry.
type Resource struct {
// Name matches the API-token scope group exactly (e.g. "projects",
// "task_comments"). It is also the prefix of every tool name this
// resource produces.
Name string
// Description is used as the prefix of each generated tool's
// description text.
Description string
// EmptyStruct returns a fresh, zero-valued model instance for each
// dispatched call. Mirrors handler.WebHandler.EmptyStruct.
EmptyStruct func() handler.CObject
// Ops is the bitmask of CRUD operations this resource supports.
Ops Op
// Inputs holds the per-op wrapper type. The dispatcher allocates a
// fresh value with reflection (via reflect.TypeOf(v).Elem()), JSON-
// unmarshals the call arguments into it, and then asks the wrapper to
// copy itself onto a fresh model via the inputAdapter interface.
Inputs map[Op]any
}
// toolRef points a tool name back at its resource + op. Built once at
// registration time so the dispatcher never has to parse tool names.
type toolRef struct {
resource *Resource
op Op
}
var (
registryMu sync.RWMutex
resources []*Resource
toolIndex = map[string]toolRef{}
)
// ErrDuplicateResource is returned when Register is called twice with the
// same Name.
var ErrDuplicateResource = errors.New("mcp: resource already registered")
// Register adds a resource to the package-level registry. It validates the
// shape (non-empty name, EmptyStruct present, an Inputs entry for each op
// in the Ops bitmask) and populates the tool-name lookup table so the
// dispatcher never has to string-parse tool names like
// "task_comments_read_all".
func Register(r Resource) error {
if r.Name == "" {
return errors.New("mcp: resource Name must not be empty")
}
if r.EmptyStruct == nil {
return fmt.Errorf("mcp: resource %q has no EmptyStruct", r.Name)
}
registryMu.Lock()
defer registryMu.Unlock()
if _, exists := findResourceLocked(r.Name); exists {
return fmt.Errorf("%w: %s", ErrDuplicateResource, r.Name)
}
// Make sure every enabled op has an input wrapper, otherwise the
// dispatcher would crash later with a less useful error.
for _, op := range AllOps() {
if r.Ops&op == 0 {
continue
}
if _, has := r.Inputs[op]; !has {
return fmt.Errorf("mcp: resource %q is missing input for op %s", r.Name, op.ToolSuffix())
}
}
stored := r
resources = append(resources, &stored)
for _, op := range AllOps() {
if stored.Ops&op == 0 {
continue
}
toolName := stored.Name + "_" + op.ToolSuffix()
toolIndex[toolName] = toolRef{resource: &stored, op: op}
}
return nil
}
// lookupResource returns the registered resource with the given name.
// Intended for tests and internal callers; external code should resolve
// via tool name.
func lookupResource(name string) (*Resource, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
return findResourceLocked(name)
}
func findResourceLocked(name string) (*Resource, bool) {
for _, r := range resources {
if r.Name == name {
return r, true
}
}
return nil, false
}
// lookupTool returns the (resource, op) pair the given tool name was
// registered for.
func lookupTool(toolName string) (toolRef, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
ref, ok := toolIndex[toolName]
return ref, ok
}

View File

@ -0,0 +1,225 @@
// 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 (
"testing"
"code.vikunja.io/api/pkg/web/handler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// resetRegistry clears the package-level registry so each test starts from
// a clean slate. Tests that mutate the registry should call this at the top.
func resetRegistry(t *testing.T) {
t.Helper()
registryMu.Lock()
defer registryMu.Unlock()
resources = nil
toolIndex = map[string]toolRef{}
}
func TestOpPermission(t *testing.T) {
cases := map[Op]string{
OpCreate: "create",
OpReadOne: "read_one",
OpReadAll: "read_all",
OpUpdate: "update",
OpDelete: "delete",
}
for op, want := range cases {
assert.Equalf(t, want, op.Permission(), "Permission() for op %d", op)
}
}
func TestOpToolSuffix(t *testing.T) {
cases := map[Op]string{
OpCreate: "create",
OpReadOne: "read_one",
OpReadAll: "read_all",
OpUpdate: "update",
OpDelete: "delete",
}
for op, want := range cases {
assert.Equalf(t, want, op.ToolSuffix(), "ToolSuffix() for op %d", op)
}
}
func TestOpUnknownPermission(t *testing.T) {
// Combined bitmasks and zero values have no defined permission string.
assert.Empty(t, Op(0).Permission())
assert.Empty(t, (OpCreate | OpReadOne).Permission())
}
func TestRegisterAppends(t *testing.T) {
resetRegistry(t)
r := Resource{
Name: "stubs",
Description: "test resource",
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpCreate | OpReadOne,
Inputs: map[Op]any{
OpCreate: &struct{}{},
OpReadOne: &struct{}{},
},
}
require.NoError(t, Register(r))
got, ok := lookupResource("stubs")
require.True(t, ok)
assert.Equal(t, "stubs", got.Name)
}
func TestRegisterDuplicateName(t *testing.T) {
resetRegistry(t)
r := Resource{
Name: "stubs",
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &struct{}{}},
}
require.NoError(t, Register(r))
err := Register(r)
require.Error(t, err)
assert.Contains(t, err.Error(), "already registered")
}
func TestRegisterMissingInputForOp(t *testing.T) {
resetRegistry(t)
r := Resource{
Name: "stubs",
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpCreate | OpReadOne,
// Missing input wrapper for OpReadOne.
Inputs: map[Op]any{OpCreate: &struct{}{}},
}
err := Register(r)
require.Error(t, err)
assert.Contains(t, err.Error(), "input")
}
func TestRegisterEmptyName(t *testing.T) {
resetRegistry(t)
err := Register(Resource{
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &struct{}{}},
})
require.Error(t, err)
}
func TestRegisterRequiresEmptyStruct(t *testing.T) {
resetRegistry(t)
err := Register(Resource{
Name: "stubs",
Ops: OpReadOne,
Inputs: map[Op]any{OpReadOne: &struct{}{}},
})
require.Error(t, err)
}
func TestToolNameResolver(t *testing.T) {
resetRegistry(t)
require.NoError(t, Register(Resource{
Name: "projects",
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpCreate | OpReadOne | OpReadAll | OpUpdate | OpDelete,
Inputs: map[Op]any{
OpCreate: &struct{}{},
OpReadOne: &struct{}{},
OpReadAll: &struct{}{},
OpUpdate: &struct{}{},
OpDelete: &struct{}{},
},
}))
require.NoError(t, Register(Resource{
Name: "task_comments",
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpReadAll,
Inputs: map[Op]any{OpReadAll: &struct{}{}},
}))
tests := []struct {
toolName string
resource string
op Op
}{
{"projects_create", "projects", OpCreate},
{"projects_read_one", "projects", OpReadOne},
{"projects_read_all", "projects", OpReadAll},
{"projects_update", "projects", OpUpdate},
{"projects_delete", "projects", OpDelete},
{"task_comments_read_all", "task_comments", OpReadAll},
}
for _, tc := range tests {
ref, ok := lookupTool(tc.toolName)
require.Truef(t, ok, "tool %s should be resolvable", tc.toolName)
assert.Equal(t, tc.resource, ref.resource.Name, "tool %s", tc.toolName)
assert.Equal(t, tc.op, ref.op, "tool %s", tc.toolName)
}
_, ok := lookupTool("nonexistent_tool")
assert.False(t, ok)
// `task_comments_read_all` must resolve to (task_comments, read_all),
// not to (task, comments_read_all) or any naive underscore split.
ref, ok := lookupTool("task_comments_read_all")
require.True(t, ok)
assert.Equal(t, "task_comments", ref.resource.Name)
assert.Equal(t, OpReadAll, ref.op)
}
func TestRegisterOnlyExposesEnabledOps(t *testing.T) {
resetRegistry(t)
require.NoError(t, Register(Resource{
Name: "stubs",
EmptyStruct: func() handler.CObject { return &stubCObject{} },
Ops: OpReadOne | OpReadAll,
Inputs: map[Op]any{
OpReadOne: &struct{}{},
OpReadAll: &struct{}{},
},
}))
_, ok := lookupTool("stubs_read_one")
assert.True(t, ok)
_, ok = lookupTool("stubs_read_all")
assert.True(t, ok)
// Ops that weren't enabled in the bitmask must not appear.
_, ok = lookupTool("stubs_create")
assert.False(t, ok)
_, ok = lookupTool("stubs_delete")
assert.False(t, ok)
}
func TestAllOps(t *testing.T) {
// AllOps must enumerate exactly the five supported ops so the registry
// and the dispatcher walk the same list.
want := []Op{OpCreate, OpReadOne, OpReadAll, OpUpdate, OpDelete}
assert.Equal(t, want, AllOps())
}