diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml
index 6a53da3e3..d0cf786e7 100644
--- a/pkg/db/fixtures/api_tokens.yml
+++ b/pkg/db/fixtures/api_tokens.yml
@@ -113,7 +113,7 @@
token_salt: mCpFullSc9R3
token_hash: 3b530a9f7564d062a526537f06ea8b570e2ac1ca1d69f59b04cd7abdbb9c5804517a639a88613940fb427c71ee4c6e800fc9
token_last_eight: fullp003
- permissions: '{"mcp":["access"],"projects":["create","read_one","read_all","update","delete"],"tasks":["create","read_one","read_all","update","delete"],"labels":["create","read_one","read_all","update","delete"],"teams":["create","read_one","read_all","update","delete"],"tasks_comments":["create","read_one","read_all","update","delete"],"tasks_assignees":["create","read_all","delete"]}'
+ permissions: '{"mcp":["access"],"projects":["create","read_one","read_all","update","delete"],"tasks":["create","read_one","read_all","update","delete"],"labels":["create","read_one","read_all","update","delete"],"teams":["create","read_one","read_all","update","delete"],"tasks_comments":["create","read_one","read_all","update","delete"],"tasks_assignees":["create","read_all","delete"],"tasks_labels":["create","read_all","delete"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 1
created: 2024-01-01 00:00:00
diff --git a/pkg/modules/mcp/catalog.go b/pkg/modules/mcp/catalog.go
new file mode 100644
index 000000000..02ccc111b
--- /dev/null
+++ b/pkg/modules/mcp/catalog.go
@@ -0,0 +1,173 @@
+// 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
+
+// The action catalog: TierCatalog resources don't get first-class tools in
+// tools/list — they're reachable through two meta-tools instead, keeping the
+// per-session tool list (and the tokens it costs an LLM client) small while
+// still exposing the long tail of CRUD resources.
+//
+// - find_action lists the catalog actions the requesting token's scopes
+// authorise. Without arguments it returns a cheap name+description
+// index; naming an action or resource returns full input schemas.
+// - do_action invokes one action by name. It funnels into the same
+// Dispatch path as the typed tools, so schema validation and the
+// per-call scope re-check apply identically.
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "code.vikunja.io/api/pkg/models"
+
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+const (
+ toolFindAction = "find_action"
+ toolDoAction = "do_action"
+)
+
+// actionInfo is one find_action result entry.
+type actionInfo struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InputSchema *jsonschema.Schema `json:"input_schema,omitempty"`
+}
+
+type findActionArgs struct {
+ Action string `json:"action"`
+ Resource string `json:"resource"`
+}
+
+type doActionArgs struct {
+ Action string `json:"action"`
+ Arguments json.RawMessage `json:"arguments"`
+}
+
+func findActionSchema() *jsonschema.Schema {
+ return &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "action": {Type: "string", Description: "Return the full input schema for this single action (e.g. tasks_labels_create)."},
+ "resource": {Type: "string", Description: "Return the full input schemas for every action of this resource (e.g. tasks_labels)."},
+ },
+ AdditionalProperties: falseSchema(),
+ }
+}
+
+func doActionSchema() *jsonschema.Schema {
+ return &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "action": {Type: "string", Description: "The action to invoke, as returned by find_action (e.g. tasks_labels_create)."},
+ "arguments": {Type: "object", Description: "The action's arguments, matching the input_schema find_action returned for it."},
+ },
+ Required: []string{"action"},
+ AdditionalProperties: falseSchema(),
+ }
+}
+
+// installCatalogTools registers the two meta-tools. They're always present
+// for an mcp:access token; a token with no catalog scopes just gets an
+// empty find_action result, and do_action re-checks scopes per call.
+func installCatalogTools(srv *mcp.Server, token *models.APIToken) {
+ srv.AddTool(&mcp.Tool{
+ Name: toolFindAction,
+ Description: "Discover additional Vikunja actions beyond the tools listed here: sharing projects with users or teams, task labels and relations (subtasks), team members, project views and more. " +
+ "Returns the actions your token authorises; pass action or resource to get full input schemas. Invoke them with do_action.",
+ InputSchema: findActionSchema(),
+ }, findActionHandler(token))
+
+ srv.AddTool(&mcp.Tool{
+ Name: toolDoAction,
+ Description: "Invoke an action discovered via find_action. Arguments must match the action's input_schema.",
+ InputSchema: doActionSchema(),
+ }, doActionHandler)
+}
+
+// catalogActions returns the catalog entries the token authorises,
+// optionally filtered to one action or resource, with schemas attached when
+// the filter is specific enough to keep the payload small.
+func catalogActions(token *models.APIToken, action, resource string) []actionInfo {
+ withSchemas := action != "" || resource != ""
+ out := []actionInfo{}
+ for _, r := range snapshotResources() {
+ if r.Tier != TierCatalog || !r.enabled() {
+ continue
+ }
+ if resource != "" && r.Name != resource {
+ continue
+ }
+ for _, op := range AllOps() {
+ if r.Ops&op == 0 || !tokenAuthorizes(token, r.Name, op) {
+ continue
+ }
+ name := r.Name + "_" + op.ToolSuffix()
+ if action != "" && name != action {
+ continue
+ }
+ info := actionInfo{Name: name, Description: r.toolDescription(op)}
+ if withSchemas {
+ info.InputSchema = r.spec(op).schema
+ }
+ out = append(out, info)
+ }
+ }
+ return out
+}
+
+func findActionHandler(token *models.APIToken) mcp.ToolHandler {
+ return func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var args findActionArgs
+ if len(req.Params.Arguments) > 0 {
+ if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
+ return &mcp.CallToolResult{
+ IsError: true,
+ Content: []mcp.Content{&mcp.TextContent{Text: "invalid arguments: " + err.Error()}},
+ }, nil
+ }
+ }
+
+ result := map[string]any{"actions": catalogActions(token, args.Action, args.Resource)}
+ body, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("mcp: marshal find_action result: %w", err)
+ }
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{&mcp.TextContent{Text: string(body)}},
+ StructuredContent: result,
+ }, nil
+ }
+}
+
+func doActionHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var args doActionArgs
+ if err := json.Unmarshal(req.Params.Arguments, &args); err != nil || args.Action == "" {
+ return &mcp.CallToolResult{
+ IsError: true,
+ Content: []mcp.Content{&mcp.TextContent{Text: "do_action requires an \"action\" name; discover actions with find_action"}},
+ }, nil
+ }
+ // Dispatch validates the arguments and re-checks the token's scope, so
+ // do_action can't reach anything a direct tool call couldn't.
+ return rawToolHandler(args.Action)(ctx, &mcp.CallToolRequest{
+ Params: &mcp.CallToolParamsRaw{Name: args.Action, Arguments: args.Arguments},
+ })
+}
diff --git a/pkg/modules/mcp/registry.go b/pkg/modules/mcp/registry.go
index 35d1be5a7..2dc26dde7 100644
--- a/pkg/modules/mcp/registry.go
+++ b/pkg/modules/mcp/registry.go
@@ -132,6 +132,12 @@ type Resource struct {
// tags alone don't say so.
RequiredCreate []string
+ // IdentityFields overrides how read_one/update/delete address a record,
+ // by JSON property name, for models whose row isn't addressed by its id
+ // (team members go by team + username) or that need parent context the
+ // derivation can't infer (views need project_id alongside id).
+ IdentityFields []string
+
specs map[Op]*opSpec
}
diff --git a/pkg/modules/mcp/resources.go b/pkg/modules/mcp/resources.go
index e73083d4d..abee7c972 100644
--- a/pkg/modules/mcp/resources.go
+++ b/pkg/modules/mcp/resources.go
@@ -82,8 +82,9 @@ func allResources() []Resource {
},
Ops: OpCreate | OpReadOne | OpReadAll | OpUpdate | OpDelete,
// "s" duplicates the reserved search argument; view-scoped
- // listing is polymorphic (buckets vs tasks) and stays REST-only.
- Exclude: []string{"s", "project_view_id"},
+ // listing is polymorphic (buckets vs tasks) and stays REST-only;
+ // index is server-assigned despite its readOnly+param tags.
+ Exclude: []string{"s", "project_view_id", "index"},
// Omitting project_id lists tasks across every project the
// caller can see.
OptionalFields: []string{"project_id"},
@@ -117,6 +118,59 @@ func allResources() []Resource {
Model: func() handler.CObject { return &models.TaskAssginee{} },
Ops: OpCreate | OpReadAll | OpDelete,
},
+
+ // Catalog tier — reachable via find_action / do_action only. Ops
+ // mirror each resource's REST surface. Deliberately absent: api
+ // tokens (self-escalation), webhooks (server-side outbound
+ // requests), link shares (public exposure), buckets and task
+ // positions (their v1 token scopes don't map onto (group, op)
+ // permissions), saved filters (nested filter object).
+ {
+ Name: "tasks_labels",
+ Description: "Labels attached to a Vikunja task; create adds a label, delete removes it",
+ Model: func() handler.CObject { return &models.LabelTask{} },
+ Ops: OpCreate | OpReadAll | OpDelete,
+ Tier: TierCatalog,
+ },
+ {
+ Name: "tasks_relations",
+ Description: "Relations between Vikunja tasks (subtask, parenttask, blocking, related, …)",
+ Model: func() handler.CObject { return &models.TaskRelation{} },
+ Ops: OpCreate | OpDelete,
+ Tier: TierCatalog,
+ },
+ {
+ Name: "teams_members",
+ Description: "Members of a Vikunja team, addressed by team id and username",
+ Model: func() handler.CObject { return &models.TeamMember{} },
+ Ops: OpCreate | OpDelete,
+ Tier: TierCatalog,
+ IdentityFields: []string{"username"},
+ },
+ {
+ Name: "projects_users",
+ Description: "Users a Vikunja project is shared with, addressed by project id and username",
+ Model: func() handler.CObject { return &models.ProjectUser{} },
+ Ops: OpCreate | OpReadAll | OpUpdate | OpDelete,
+ Tier: TierCatalog,
+ IdentityFields: []string{"username"},
+ },
+ {
+ Name: "projects_teams",
+ Description: "Teams a Vikunja project is shared with, addressed by project id and team id",
+ Model: func() handler.CObject { return &models.TeamProject{} },
+ Ops: OpCreate | OpReadAll | OpUpdate | OpDelete,
+ Tier: TierCatalog,
+ IdentityFields: []string{"team_id"},
+ },
+ {
+ Name: "projects_views",
+ Description: "Views of a Vikunja project (list, gantt, table, kanban)",
+ Model: func() handler.CObject { return &models.ProjectView{} },
+ Ops: OpCreate | OpReadOne | OpReadAll | OpUpdate | OpDelete,
+ Tier: TierCatalog,
+ IdentityFields: []string{"id", "project_id"},
+ },
}
}
@@ -143,6 +197,7 @@ func installToolsForToken(srv *mcp.Server, token *models.APIToken) {
}, rawToolHandler(name))
}
}
+ installCatalogTools(srv, token)
}
// rawToolHandler adapts Dispatch to the SDK's low-level ToolHandler. Domain
diff --git a/pkg/modules/mcp/schema.go b/pkg/modules/mcp/schema.go
index 69db5d26d..5a17e3b81 100644
--- a/pkg/modules/mcp/schema.go
+++ b/pkg/modules/mcp/schema.go
@@ -100,6 +100,8 @@ func buildOpSpec(modelType reflect.Type, op Op, r *Resource) (*opSpec, error) {
name, hasJSON := jsonName(f)
param := f.Tag.Get("param")
+ identity := func(name string) bool { return slices.Contains(r.IdentityFields, name) }
+
switch {
case f.Name == "ID":
if !hasJSON || excluded("id") {
@@ -108,6 +110,12 @@ func buildOpSpec(modelType reflect.Type, op Op, r *Resource) (*opSpec, error) {
if op != OpReadOne && op != OpUpdate && op != OpDelete {
continue
}
+ // Resources whose rows aren't addressed by their id (e.g. team
+ // members, addressed by team + username) declare IdentityFields
+ // without "id" and the property disappears entirely.
+ if len(r.IdentityFields) > 0 && !identity("id") {
+ continue
+ }
if f.Type.Kind() != reflect.Int64 {
return nil, fmt.Errorf("mcp: %s: ID field must be int64, got %s", modelType, f.Type)
}
@@ -131,7 +139,10 @@ func buildOpSpec(modelType reflect.Type, op Op, r *Resource) (*opSpec, error) {
required = append(required, hidden)
}
- case !hasJSON, f.Tag.Get("readOnly") == "true", excluded(name):
+ // readOnly with a param tag means "REST takes this from the URL,
+ // not the body" (e.g. TaskRelation.TaskID) — MCP has no URL, so it
+ // stays an argument.
+ case !hasJSON, f.Tag.Get("readOnly") == "true" && param == "", excluded(name):
continue
default:
@@ -146,10 +157,13 @@ func buildOpSpec(modelType reflect.Type, op Op, r *Resource) (*opSpec, error) {
req = requiredForCreate(f, name, r)
case OpUpdate:
include = true
+ req = identity(name)
case OpReadOne, OpDelete:
// Models without an exposed id (e.g. TaskAssginee) are
- // identified by their param-tagged fields instead.
- if !hasExposedID && param != "" {
+ // identified by their param-tagged fields instead;
+ // IdentityFields declares the set explicitly when the
+ // derivation can't know it (e.g. views need project_id too).
+ if (!hasExposedID && param != "") || identity(name) {
include, req = true, true
}
case OpReadAll:
@@ -219,6 +233,22 @@ func propSchema(f reflect.StructField) (*jsonschema.Schema, bool) {
default:
return nil, false
}
+ // Named int types with a custom string MarshalJSON declare their wire
+ // type via swaggertype (e.g. ProjectViewKind).
+ if f.Tag.Get("swaggertype") == "string" {
+ s.Type = "string"
+ s.Format = ""
+ }
+ // Both huma-style `enum` and swaggo-style `enums` list allowed values.
+ enum := f.Tag.Get("enum")
+ if enum == "" {
+ enum = f.Tag.Get("enums")
+ }
+ if enum != "" && s.Type == "string" {
+ for _, v := range strings.Split(enum, ",") {
+ s.Enum = append(s.Enum, v)
+ }
+ }
return propWithDoc(s, f), true
}
diff --git a/pkg/webtests/mcp_catalog_test.go b/pkg/webtests/mcp_catalog_test.go
new file mode 100644
index 000000000..3dfe166df
--- /dev/null
+++ b/pkg/webtests/mcp_catalog_test.go
@@ -0,0 +1,150 @@
+// 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 webtests
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// findActions calls find_action and returns the decoded action list.
+func findActions(t *testing.T, c *mcpClient, args map[string]any) []map[string]any {
+ t.Helper()
+ result := c.callTool("find_action", args)
+ require.NotContains(t, result, "isError", "find_action errored: %v", result)
+ var payload struct {
+ Actions []map[string]any `json:"actions"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(toolResultText(t, result)), &payload))
+ return payload.Actions
+}
+
+func TestMCP_Catalog_MetaToolsInToolsList(t *testing.T) {
+ c := newMCPClient(t, mcpFullProjectsToken)
+ resp := c.rpc("tools/list", map[string]any{})
+ names := toolNamesFromList(t, resp)
+
+ assert.True(t, names["find_action"], "find_action missing: %v", names)
+ assert.True(t, names["do_action"], "do_action missing: %v", names)
+ // Catalog actions must not appear as first-class tools.
+ assert.False(t, names["tasks_labels_create"], "catalog actions must stay out of tools/list")
+}
+
+func TestMCP_Catalog_FindActionScopeFiltered(t *testing.T) {
+ // Token 11 has tasks_labels scopes but no other catalog resource.
+ c := newMCPClient(t, mcpFullProjectsToken)
+ actions := findActions(t, c, map[string]any{})
+
+ names := map[string]bool{}
+ for _, a := range actions {
+ names[a["name"].(string)] = true
+ assert.NotContains(t, a, "input_schema", "unfiltered find_action must stay schema-free")
+ }
+ for _, want := range []string{"tasks_labels_create", "tasks_labels_read_all", "tasks_labels_delete"} {
+ assert.True(t, names[want], "missing %s: %v", want, names)
+ }
+ assert.False(t, names["projects_users_create"], "no projects_users scope on token 11")
+ assert.False(t, names["tasks_create"], "typed tools must not appear in the catalog")
+}
+
+func TestMCP_Catalog_FindActionReturnsSchemas(t *testing.T) {
+ c := newMCPClient(t, mcpFullProjectsToken)
+ actions := findActions(t, c, map[string]any{"resource": "tasks_labels"})
+ require.Len(t, actions, 3)
+
+ for _, a := range actions {
+ schema, ok := a["input_schema"].(map[string]any)
+ require.Truef(t, ok, "action %v missing input_schema", a["name"])
+ props, ok := schema["properties"].(map[string]any)
+ require.True(t, ok)
+ assert.Contains(t, props, "task_id")
+ }
+}
+
+func TestMCP_Catalog_FindActionEmptyWithoutScopes(t *testing.T) {
+ c := newMCPClient(t, mcpOnlyToken)
+ actions := findActions(t, c, map[string]any{})
+ assert.Empty(t, actions)
+}
+
+func TestMCP_Catalog_DoActionLabelRoundTrip(t *testing.T) {
+ c := newMCPClient(t, mcpFullProjectsToken)
+
+ // Attach label 1 (owned by user 1) to task 1, then read it back.
+ result := c.callTool("do_action", map[string]any{
+ "action": "tasks_labels_create",
+ "arguments": map[string]any{"task_id": 1, "label_id": 1},
+ })
+ require.NotContains(t, result, "isError", "do_action create errored: %v", result)
+
+ result = c.callTool("do_action", map[string]any{
+ "action": "tasks_labels_read_all",
+ "arguments": map[string]any{"task_id": 1},
+ })
+ require.NotContains(t, result, "isError")
+ var labels []map[string]any
+ require.NoError(t, json.Unmarshal([]byte(toolResultText(t, result)), &labels))
+ ids := map[float64]bool{}
+ for _, l := range labels {
+ ids[l["id"].(float64)] = true
+ }
+ assert.True(t, ids[1], "label 1 should be attached: %v", labels)
+
+ result = c.callTool("do_action", map[string]any{
+ "action": "tasks_labels_delete",
+ "arguments": map[string]any{"task_id": 1, "label_id": 1},
+ })
+ require.NotContains(t, result, "isError", "do_action delete errored: %v", result)
+}
+
+func TestMCP_Catalog_DoActionScopeDenied(t *testing.T) {
+ // Token 11 has no projects_users scope; the per-call re-check inside
+ // Dispatch must reject the action even though it exists.
+ c := newMCPClient(t, mcpFullProjectsToken)
+ result := c.callTool("do_action", map[string]any{
+ "action": "projects_users_create",
+ "arguments": map[string]any{"project_id": 1, "username": "user2"},
+ })
+ isErr, _ := result["isError"].(bool)
+ require.True(t, isErr, "expected scope denial: %v", result)
+ assert.Contains(t, toolResultText(t, result), "not authorized")
+}
+
+func TestMCP_Catalog_DoActionUnknownAction(t *testing.T) {
+ c := newMCPClient(t, mcpFullProjectsToken)
+ result := c.callTool("do_action", map[string]any{
+ "action": "nonexistent_create",
+ "arguments": map[string]any{},
+ })
+ isErr, _ := result["isError"].(bool)
+ require.True(t, isErr, "expected tool-not-found: %v", result)
+}
+
+func TestMCP_Catalog_DoActionValidatesArguments(t *testing.T) {
+ // Missing the required label_id must fail schema validation inside
+ // Dispatch, surfaced as an isError tool result.
+ c := newMCPClient(t, mcpFullProjectsToken)
+ result := c.callTool("do_action", map[string]any{
+ "action": "tasks_labels_create",
+ "arguments": map[string]any{"task_id": 1},
+ })
+ isErr, _ := result["isError"].(bool)
+ require.True(t, isErr, "expected validation error: %v", result)
+}