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) +}