diff --git a/pkg/modules/mcp/dispatcher.go b/pkg/modules/mcp/dispatcher.go
new file mode 100644
index 000000000..463d1be0e
--- /dev/null
+++ b/pkg/modules/mcp/dispatcher.go
@@ -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 .
+
+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
+}
diff --git a/pkg/modules/mcp/dispatcher_test.go b/pkg/modules/mcp/dispatcher_test.go
new file mode 100644
index 000000000..e66898a05
--- /dev/null
+++ b/pkg/modules/mcp/dispatcher_test.go
@@ -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 .
+
+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)
+}
diff --git a/pkg/modules/mcp/registry.go b/pkg/modules/mcp/registry.go
new file mode 100644
index 000000000..88cf863fe
--- /dev/null
+++ b/pkg/modules/mcp/registry.go
@@ -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 .
+
+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 _; 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
+}
diff --git a/pkg/modules/mcp/registry_test.go b/pkg/modules/mcp/registry_test.go
new file mode 100644
index 000000000..2fc6c1b68
--- /dev/null
+++ b/pkg/modules/mcp/registry_test.go
@@ -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 .
+
+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())
+}