From a0116749d18dec5454ac24e495ce83d218b4cbea Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 26 May 2026 23:20:04 +0200 Subject: [PATCH] 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. --- pkg/modules/mcp/dispatcher.go | 193 +++++++++++++++++ pkg/modules/mcp/dispatcher_test.go | 319 +++++++++++++++++++++++++++++ pkg/modules/mcp/registry.go | 200 ++++++++++++++++++ pkg/modules/mcp/registry_test.go | 225 ++++++++++++++++++++ 4 files changed, 937 insertions(+) create mode 100644 pkg/modules/mcp/dispatcher.go create mode 100644 pkg/modules/mcp/dispatcher_test.go create mode 100644 pkg/modules/mcp/registry.go create mode 100644 pkg/modules/mcp/registry_test.go 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()) +}