feat(mcp): add find_action/do_action catalog for long-tail resources

Catalog-tier resources stay out of tools/list; agents discover them via
find_action (scope-filtered, schemas on demand) and invoke them through
do_action, which funnels into the same Dispatch path — schema validation
and the per-call scope re-check apply identically.

Wave 1: task labels, task relations (subtasks), team members, project
user/team shares, project views. Deliberately absent: api tokens,
webhooks, link shares, buckets and positions (v1 token scopes don't map
onto (group, op) permissions), saved filters (nested filter object).

Adds IdentityFields for records not addressed by their id (team members
go by team + username) and treats readOnly+param fields as arguments
(REST reads them from the URL; MCP has no URL).
This commit is contained in:
kolaente
2026-08-28 23:54:56 +02:00
parent 28ee6a2a58
commit 09bf29acd5
6 changed files with 420 additions and 6 deletions
+1 -1
View File
@@ -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
+173
View File
@@ -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 <https://www.gnu.org/licenses/>.
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},
})
}
+6
View File
@@ -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
}
+57 -2
View File
@@ -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
+33 -3
View File
@@ -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
}
+150
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)
}