feat(mcp): expose projects via mcp tools
Wires the projects resource into the MCP server end-to-end. The five project tools (create, read_one, read_all, update, delete) are now visible in tools/list and dispatch through handler.Do* like the REST layer. - Add ProjectCreateInput / ProjectUpdateInput in inputs.go with jsonschema tags covering only the writable fields the model honours (title, description, identifier, hex_color, parent_project_id, position, is_archived, is_favorite); computed fields like Owner and MaxPermission are intentionally absent so the SDK-reflected schema stays narrow. - Add resources.go with a sync.Once-guarded RegisterResources(), and an installTools helper that registers tools per (resource, op) on the *mcp.Server via a generic addTool[In inputAdapter] helper. The handler maps domain failures (permission denials, missing rows, validation) to IsError tool results per the SDK convention. - Add DispatchTyped in dispatcher.go so the AddTool handler can hand a pre-unmarshalled wrapper to the dispatcher without a JSON round-trip. The existing Dispatch (raw JSON path) delegates to a shared dispatchPrepared. - Wire RegisterResources() + installTools() into newServer() so each new MCP session inherits the static tool set. - Add fixture token 11 (mcp:access + projects:*) for the full-scope integration tests; bump TestAPIToken_ReadAll's expected count. - Refresh TestMCP_ToolsListEmpty into TestMCP_ToolsListReturnsRegisteredResources, asserting the five projects_* tools are present (Task 6 will introduce scope-based filtering of this list). - Add pkg/webtests/mcp_projects_test.go covering tools/list, create/read_one/read_all/update/delete happy paths, schema-validation failure on missing required title, permission denial on a forbidden project, and nonexistent-id lookup.
This commit is contained in:
parent
dbf352cc96
commit
e423167ce1
|
|
@ -98,3 +98,13 @@
|
|||
owner_id: 1
|
||||
created: 2024-01-01 00:00:00
|
||||
# token in plaintext is tk_mcp_mixed_scope_token_test_00mcpmixed02
|
||||
- id: 11
|
||||
title: 'mcp access token with full project scopes for user 1'
|
||||
token_salt: mCpFullSc9R3
|
||||
token_hash: 3b530a9f7564d062a526537f06ea8b570e2ac1ca1d69f59b04cd7abdbb9c5804517a639a88613940fb427c71ee4c6e800fc9
|
||||
token_last_eight: fullp003
|
||||
permissions: '{"mcp":["access"],"projects":["create","read_one","read_all","update","delete"]}'
|
||||
expires_at: 2099-01-01 00:00:00
|
||||
owner_id: 1
|
||||
created: 2024-01-01 00:00:00
|
||||
# token in plaintext is tk_mcp_full_projects_token_test_0fullp003
|
||||
|
|
|
|||
|
|
@ -39,13 +39,14 @@ func TestAPIToken_ReadAll(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
tokens, is := result.([]*APIToken)
|
||||
assert.Truef(t, is, "tokens are not of type []*APIToken")
|
||||
assert.Len(t, tokens, 4)
|
||||
assert.Len(t, tokens, 5)
|
||||
assert.Len(t, tokens, count)
|
||||
assert.Equal(t, int64(4), total)
|
||||
assert.Equal(t, int64(5), total)
|
||||
assert.Equal(t, int64(1), tokens[0].ID)
|
||||
assert.Equal(t, int64(2), tokens[1].ID)
|
||||
assert.Equal(t, int64(9), tokens[2].ID)
|
||||
assert.Equal(t, int64(10), tokens[3].ID)
|
||||
assert.Equal(t, int64(11), tokens[4].ID)
|
||||
}
|
||||
|
||||
func TestAPIToken_CanDelete(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -87,31 +87,30 @@ var defaultCRUD = crudFuncs{
|
|||
// 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.
|
||||
// Dispatch is the single entry point for every tools/call when the caller
|
||||
// only has raw JSON arguments (e.g. unit tests, or future non-SDK call
|
||||
// sites). It unmarshals the arguments into the wrapper registered for the
|
||||
// tool and delegates to DispatchTyped, which is also the path the
|
||||
// AddTool-generated handlers take (they pass an already-typed wrapper to
|
||||
// skip the unmarshal round-trip the SDK has already performed against the
|
||||
// input schema).
|
||||
//
|
||||
// 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.
|
||||
// SDK-agnostic; the thin AddTool handler 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.
|
||||
// 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]
|
||||
|
|
@ -129,10 +128,38 @@ func Dispatch(ctx context.Context, toolName string, rawArgs json.RawMessage) (an
|
|||
}
|
||||
}
|
||||
|
||||
return dispatchPrepared(ctx, ref, wrapper)
|
||||
}
|
||||
|
||||
// DispatchTyped is the dispatcher entry point for callers that already have
|
||||
// a typed wrapper value (e.g. AddTool handlers, where the SDK has already
|
||||
// unmarshalled and validated args against the input schema). It skips the
|
||||
// JSON round-trip that Dispatch performs.
|
||||
//
|
||||
// The wrapper must implement inputAdapter (and optionally readAllInput for
|
||||
// pagination). Every wrapper registered in inputs.go meets that contract.
|
||||
func DispatchTyped(ctx context.Context, toolName string, wrapper any) (any, error) {
|
||||
ref, ok := lookupTool(toolName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrToolNotFound, toolName)
|
||||
}
|
||||
return dispatchPrepared(ctx, ref, wrapper)
|
||||
}
|
||||
|
||||
// dispatchPrepared runs the shared post-allocation pipeline: pull the user
|
||||
// from ctx, copy the wrapper onto a fresh model via inputAdapter, then call
|
||||
// the right handler.Do* per op. Both Dispatch (raw JSON path) and
|
||||
// DispatchTyped (AddTool path) funnel through here.
|
||||
func dispatchPrepared(ctx context.Context, ref toolRef, wrapper any) (any, error) {
|
||||
u := UserFromContext(ctx)
|
||||
if u == nil {
|
||||
return nil, ErrNoUserInContext
|
||||
}
|
||||
|
||||
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)
|
||||
return nil, fmt.Errorf("mcp: copy input for %s_%s: %w", ref.resource.Name, ref.op.ToolSuffix(), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,7 +200,7 @@ func Dispatch(ctx context.Context, toolName string, rawArgs json.RawMessage) (an
|
|||
return model, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("mcp: unsupported op %d for tool %s", ref.op, toolName)
|
||||
return nil, fmt.Errorf("mcp: unsupported op %d for tool %s_%s", ref.op, ref.resource.Name, ref.op.ToolSuffix())
|
||||
}
|
||||
|
||||
// allocateWrapper returns a fresh pointer of the same concrete type as the
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import (
|
|||
"reflect"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
)
|
||||
|
||||
|
|
@ -277,3 +278,87 @@ func jsonName(f reflect.StructField) (string, bool) {
|
|||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// ProjectCreateInput is the input wrapper for the `projects_create` tool.
|
||||
//
|
||||
// Only the fields the caller is allowed to set are exposed; computed and
|
||||
// server-managed fields on models.Project (Owner, MaxPermission, Views,
|
||||
// background information, IsFavorite, etc.) are intentionally absent so the
|
||||
// generated JSON Schema stays narrow.
|
||||
//
|
||||
// Title is the only required field — every other field has `omitempty` so
|
||||
// the SDK's reflected JSON Schema marks them optional.
|
||||
type ProjectCreateInput struct {
|
||||
// Title of the project. Required.
|
||||
Title string `json:"title" jsonschema:"the title of the project"`
|
||||
// Optional longer description.
|
||||
Description string `json:"description,omitempty" jsonschema:"longer-form description of the project"`
|
||||
// Optional short identifier (max 10 chars) used as the prefix for task
|
||||
// identifiers within this project.
|
||||
Identifier string `json:"identifier,omitempty" jsonschema:"short identifier used as a prefix for task identifiers, max 10 chars"`
|
||||
// Optional hex color (without the leading #). Six characters, e.g.
|
||||
// "ff0000".
|
||||
HexColor string `json:"hex_color,omitempty" jsonschema:"hex color code for the project without leading hash, e.g. ff0000"`
|
||||
// Optional parent project id. Zero means top-level.
|
||||
ParentProjectID int64 `json:"parent_project_id,omitempty" jsonschema:"id of the parent project, omit or 0 for a top-level project"`
|
||||
// Optional ordering position among siblings.
|
||||
Position float64 `json:"position,omitempty" jsonschema:"ordering position of the project among its siblings"`
|
||||
// Optional archive flag. Defaults to false.
|
||||
IsArchived bool `json:"is_archived,omitempty" jsonschema:"set to true to create the project in an archived state"`
|
||||
// Optional favorite flag for the calling user. Defaults to false.
|
||||
IsFavorite bool `json:"is_favorite,omitempty" jsonschema:"set to true to mark the project as a favorite for the caller"`
|
||||
}
|
||||
|
||||
// ApplyTo copies the wrapper fields onto a fresh *models.Project before
|
||||
// handler.DoCreate runs. CreateProject overwrites Owner / OwnerID from the
|
||||
// authed user, so the wrapper does not (and must not) expose those fields.
|
||||
func (in *ProjectCreateInput) ApplyTo(dst handler.CObject) error {
|
||||
p, ok := dst.(*models.Project)
|
||||
if !ok {
|
||||
return fmt.Errorf("mcp: ProjectCreateInput.ApplyTo: unexpected destination %T", dst)
|
||||
}
|
||||
return copyByJSONTag(in, p)
|
||||
}
|
||||
|
||||
// ProjectUpdateInput is the input wrapper for the `projects_update` tool.
|
||||
//
|
||||
// All writable fields use `omitempty` so callers can supply partial updates;
|
||||
// copyByJSONTag's "skip zero values" policy leaves omitted fields untouched
|
||||
// (matching the REST update handler's PATCH-like behaviour). The one
|
||||
// exception is ID, which is always required to identify the target row.
|
||||
//
|
||||
// Vikunja's Project.Update only persists a fixed list of columns (title,
|
||||
// is_archived, identifier, hex_color, parent_project_id, position, and
|
||||
// description if non-empty); fields outside that list are silently ignored
|
||||
// at the model layer. The wrapper exposes exactly that list.
|
||||
type ProjectUpdateInput struct {
|
||||
// ID of the project to update. Required.
|
||||
ID int64 `json:"id" jsonschema:"id of the project to update"`
|
||||
// New title. Omit to leave unchanged.
|
||||
Title string `json:"title,omitempty" jsonschema:"new title for the project; omit to leave unchanged"`
|
||||
// New description. Omit to leave unchanged.
|
||||
Description string `json:"description,omitempty" jsonschema:"new description; omit to leave unchanged"`
|
||||
// New short identifier. Omit to leave unchanged.
|
||||
Identifier string `json:"identifier,omitempty" jsonschema:"new short identifier (max 10 chars); omit to leave unchanged"`
|
||||
// New hex color (without leading #). Omit to leave unchanged.
|
||||
HexColor string `json:"hex_color,omitempty" jsonschema:"new hex color (without leading #); omit to leave unchanged"`
|
||||
// New parent project id. Omit (or zero) to leave unchanged.
|
||||
ParentProjectID int64 `json:"parent_project_id,omitempty" jsonschema:"new parent project id; omit or 0 to leave unchanged"`
|
||||
// New ordering position. Omit (or zero) to leave unchanged.
|
||||
Position float64 `json:"position,omitempty" jsonschema:"new ordering position among siblings; omit or 0 to leave unchanged"`
|
||||
// Archive state. Omit (or false) to leave un-archived.
|
||||
IsArchived bool `json:"is_archived,omitempty" jsonschema:"set to true to archive, omit or false to leave un-archived"`
|
||||
// Favorite state for the caller. Omit (or false) to leave un-favorited.
|
||||
IsFavorite bool `json:"is_favorite,omitempty" jsonschema:"set to true to favorite for the caller, omit or false to un-favorite"`
|
||||
}
|
||||
|
||||
// ApplyTo copies the wrapper fields onto a fresh *models.Project. ID is
|
||||
// always copied so the model knows which row to update.
|
||||
func (in *ProjectUpdateInput) ApplyTo(dst handler.CObject) error {
|
||||
p, ok := dst.(*models.Project)
|
||||
if !ok {
|
||||
return fmt.Errorf("mcp: ProjectUpdateInput.ApplyTo: unexpected destination %T", dst)
|
||||
}
|
||||
p.ID = in.ID
|
||||
return copyByJSONTag(in, p)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,15 +42,23 @@ import (
|
|||
const routePrefix = "/api/v1/mcp"
|
||||
|
||||
// newServer constructs a fresh *mcp.Server with Vikunja's implementation
|
||||
// metadata. The SDK's NewStreamableHTTPHandler accepts a factory
|
||||
// (getServer) that may return the same server across sessions; we return
|
||||
// a new one per session for now so future per-session state (e.g.
|
||||
// scope-filtered tool sets, see Task 6) has a clean place to live.
|
||||
// metadata and the static set of registered tools. The SDK's
|
||||
// NewStreamableHTTPHandler accepts a factory (getServer) that may return
|
||||
// the same server across sessions; we return a new one per session for now
|
||||
// so future per-session state (e.g. scope-filtered tool sets, see Task 6)
|
||||
// has a clean place to live.
|
||||
//
|
||||
// RegisterResources is idempotent and is called here so production startup
|
||||
// doesn't need to know about a separate init step — the first incoming MCP
|
||||
// request triggers registration on demand.
|
||||
func newServer() *mcp.Server {
|
||||
return mcp.NewServer(&mcp.Implementation{
|
||||
RegisterResources()
|
||||
srv := mcp.NewServer(&mcp.Implementation{
|
||||
Name: "vikunja",
|
||||
Version: version.Version,
|
||||
}, nil)
|
||||
installTools(srv)
|
||||
return srv
|
||||
}
|
||||
|
||||
// streamableHandler is package-level so the SDK can manage its internal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// resources.go owns the central list of MCP-exposed resources. Each entry
|
||||
// declares: the resource name (matches the API-token scope group), the
|
||||
// model's EmptyStruct, the set of supported ops, and the per-op input
|
||||
// wrappers from inputs.go.
|
||||
//
|
||||
// RegisterResources is idempotent and safe to call multiple times — the
|
||||
// registry's duplicate check is converted to a no-op so the function works
|
||||
// both at production startup (via newServer) and in repeated test setups
|
||||
// that reset the registry between cases.
|
||||
//
|
||||
// installTools walks the registry and registers a typed *mcp.Tool on the
|
||||
// given server for every (resource, op) pair. The per-op wrapper type is
|
||||
// hard-coded into a generic addTool helper so the SDK can reflect the input
|
||||
// schema at registration time — there is no way to feed reflect.Type into
|
||||
// the AddTool generics at runtime.
|
||||
|
||||
var registerResourcesOnce sync.Once
|
||||
|
||||
// RegisterResources populates the package-level registry with every
|
||||
// MCP-exposed resource. It runs at most once per process; subsequent calls
|
||||
// are no-ops so tests that pre-populate the registry or call this twice
|
||||
// don't crash on the duplicate-name guard.
|
||||
func RegisterResources() {
|
||||
registerResourcesOnce.Do(func() {
|
||||
if err := registerProjects(); err != nil {
|
||||
panic(fmt.Errorf("mcp: failed to register projects resource: %w", err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func registerProjects() error {
|
||||
return Register(Resource{
|
||||
Name: "projects",
|
||||
Description: "Vikunja projects (containers for tasks)",
|
||||
EmptyStruct: func() handler.CObject { return &models.Project{} },
|
||||
Ops: OpCreate | OpReadOne | OpReadAll | OpUpdate | OpDelete,
|
||||
Inputs: map[Op]any{
|
||||
OpCreate: &ProjectCreateInput{},
|
||||
OpReadOne: &ReadOneInput{},
|
||||
OpReadAll: &ReadAllInput{},
|
||||
OpUpdate: &ProjectUpdateInput{},
|
||||
OpDelete: &DeleteInput{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// installTools walks the registry and binds each enabled (resource, op)
|
||||
// pair to a tool on the given server. Per-op wrapper types are known at
|
||||
// compile time, so a per-resource installer is the cleanest way to keep the
|
||||
// SDK's compile-time type parameter happy while the registry stays
|
||||
// data-driven elsewhere.
|
||||
//
|
||||
// Called from newServer (mcp.go); every fresh MCP session gets the full
|
||||
// tool set. Per-token scope filtering is layered on top in Task 6.
|
||||
func installTools(srv *mcp.Server) {
|
||||
installProjectsTools(srv)
|
||||
}
|
||||
|
||||
func installProjectsTools(srv *mcp.Server) {
|
||||
r, ok := lookupResource("projects")
|
||||
if !ok {
|
||||
// Defensive: RegisterResources must run before installTools.
|
||||
// A missing resource means programmer error, not a runtime
|
||||
// condition the caller can recover from.
|
||||
panic("mcp: projects resource not registered")
|
||||
}
|
||||
|
||||
if r.Ops&OpCreate != 0 {
|
||||
addTool[*ProjectCreateInput](srv, r, OpCreate, "Create a new project")
|
||||
}
|
||||
if r.Ops&OpReadOne != 0 {
|
||||
addTool[*ReadOneInput](srv, r, OpReadOne, "Fetch a single project by id")
|
||||
}
|
||||
if r.Ops&OpReadAll != 0 {
|
||||
addTool[*ReadAllInput](srv, r, OpReadAll, "List the projects the caller has access to")
|
||||
}
|
||||
if r.Ops&OpUpdate != 0 {
|
||||
addTool[*ProjectUpdateInput](srv, r, OpUpdate, "Update an existing project")
|
||||
}
|
||||
if r.Ops&OpDelete != 0 {
|
||||
addTool[*DeleteInput](srv, r, OpDelete, "Delete a project by id")
|
||||
}
|
||||
}
|
||||
|
||||
// addTool registers one MCP tool on the given server. The In type
|
||||
// parameter must be a pointer-to-struct that implements inputAdapter (and
|
||||
// optionally readAllInput); the SDK reflects it at registration time to
|
||||
// build the input schema.
|
||||
//
|
||||
// The handler:
|
||||
//
|
||||
// 1. Calls DispatchTyped with the already-unmarshalled wrapper. The SDK
|
||||
// has already validated the input against the schema by the time the
|
||||
// handler runs (see ToolHandlerFor in the SDK docs), so there is no
|
||||
// reason to re-marshal and re-unmarshal.
|
||||
// 2. Maps any error from the dispatcher to an IsError tool result per the
|
||||
// SDK's convention that domain failures (permission denials, missing
|
||||
// records, validation errors) surface as tool results, not JSON-RPC
|
||||
// protocol errors. ToolHandlerFor would do this automatically if we
|
||||
// returned the error, but we also want to populate Content with the
|
||||
// text explicitly so clients see a sensible message.
|
||||
// 3. On success, returns the dispatcher's result as the structured Output;
|
||||
// the SDK populates Content with the JSON marshalling automatically.
|
||||
func addTool[In inputAdapter](srv *mcp.Server, r *Resource, op Op, description string) {
|
||||
name := r.Name + "_" + op.ToolSuffix()
|
||||
tool := &mcp.Tool{
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
// Domain-layer failures (permission denials, missing rows, validation
|
||||
// errors) surface as IsError tool results per the SDK convention, not as
|
||||
// protocol-level errors. The handler intentionally returns a nil error
|
||||
// alongside an IsError result; the nolint:nilerr below silences the
|
||||
// linter, which can't tell that this is the correct contract for
|
||||
// ToolHandlerFor.
|
||||
handler := func(ctx context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, any, error) {
|
||||
result, err := DispatchTyped(ctx, name, in)
|
||||
if err != nil {
|
||||
res := &mcp.CallToolResult{
|
||||
IsError: true,
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}},
|
||||
}
|
||||
//nolint:nilerr // IsError tool result, not a JSON-RPC protocol error
|
||||
return res, nil, nil
|
||||
}
|
||||
// Serialise the result manually so Content carries a stable JSON
|
||||
// shape; the SDK would do the same automatically when Content is
|
||||
// nil, but doing it here keeps the contract explicit and lets us
|
||||
// return the same payload as both unstructured text (for clients
|
||||
// that ignore structuredContent) and structured output.
|
||||
body, marshalErr := json.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
return nil, nil, fmt.Errorf("mcp: marshal %s result: %w", name, marshalErr)
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: string(body)}},
|
||||
}, result, nil
|
||||
}
|
||||
mcp.AddTool(srv, tool, handler)
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
// 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"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Token 11 has {mcp:access, projects:[create, read_one, read_all, update, delete]}
|
||||
// — full access to every projects_* tool. Owner is user 1.
|
||||
const mcpFullProjectsToken = "tk_mcp_full_projects_token_test_0fullp003"
|
||||
|
||||
// mcpClient is a tiny harness that does the initialize / notifications /
|
||||
// tools-call dance against the live Echo server. Tests construct one per
|
||||
// case, optionally authed with a different token, and use callTool to drive
|
||||
// a single JSON-RPC method.
|
||||
type mcpClient struct {
|
||||
t *testing.T
|
||||
e *echo.Echo
|
||||
token string
|
||||
sessionID string
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newMCPClient(t *testing.T, token string) *mcpClient {
|
||||
t.Helper()
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
c := &mcpClient{t: t, e: e, token: token, nextID: 1}
|
||||
c.initialize()
|
||||
c.notifyInitialized()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *mcpClient) initialize() {
|
||||
c.t.Helper()
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`
|
||||
req := mcpRequest(http.MethodPost, body)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token)
|
||||
rec := httptest.NewRecorder()
|
||||
c.e.ServeHTTP(rec, req)
|
||||
require.Equal(c.t, http.StatusOK, rec.Code, "initialize body: %s", rec.Body.String())
|
||||
c.sessionID = rec.Header().Get("Mcp-Session-Id")
|
||||
require.NotEmpty(c.t, c.sessionID, "no session id on initialize response")
|
||||
}
|
||||
|
||||
func (c *mcpClient) notifyInitialized() {
|
||||
c.t.Helper()
|
||||
req := mcpRequest(http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token)
|
||||
req.Header.Set("Mcp-Session-Id", c.sessionID)
|
||||
rec := httptest.NewRecorder()
|
||||
c.e.ServeHTTP(rec, req)
|
||||
require.Less(c.t, rec.Code, 400, "notifications/initialized: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// rpc sends a JSON-RPC request with the given method/params and returns the
|
||||
// parsed response. Each call uses a fresh request id so the SDK doesn't
|
||||
// confuse them.
|
||||
func (c *mcpClient) rpc(method string, params any) map[string]any {
|
||||
c.t.Helper()
|
||||
c.nextID++
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
require.NoError(c.t, err)
|
||||
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":%q,"params":%s}`, c.nextID, method, paramsJSON)
|
||||
req := mcpRequest(http.MethodPost, body)
|
||||
req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token)
|
||||
req.Header.Set("Mcp-Session-Id", c.sessionID)
|
||||
rec := httptest.NewRecorder()
|
||||
c.e.ServeHTTP(rec, req)
|
||||
require.Equal(c.t, http.StatusOK, rec.Code, "rpc %s body: %s", method, rec.Body.String())
|
||||
return readMCPJSON(c.t, rec.Body.String())
|
||||
}
|
||||
|
||||
// callTool invokes tools/call for the given tool and returns the raw
|
||||
// "result" payload. Whether the call succeeded or failed is encoded in
|
||||
// result["isError"] per the MCP spec; tests check that explicitly.
|
||||
func (c *mcpClient) callTool(name string, args map[string]any) map[string]any {
|
||||
c.t.Helper()
|
||||
resp := c.rpc("tools/call", map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
result, ok := resp["result"].(map[string]any)
|
||||
require.Truef(c.t, ok, "missing result for %s: %v", name, resp)
|
||||
return result
|
||||
}
|
||||
|
||||
// toolResultText extracts the first TextContent entry from a tools/call
|
||||
// result. The SDK guarantees Content is non-empty for both success and
|
||||
// IsError paths in our handlers.
|
||||
func toolResultText(t *testing.T, result map[string]any) string {
|
||||
t.Helper()
|
||||
content, ok := result["content"].([]any)
|
||||
require.Truef(t, ok, "no content in result: %v", result)
|
||||
require.NotEmpty(t, content, "empty content array: %v", result)
|
||||
first, ok := content[0].(map[string]any)
|
||||
require.True(t, ok, "first content not an object: %v", content[0])
|
||||
text, ok := first["text"].(string)
|
||||
require.Truef(t, ok, "first content text missing or not a string: %v", first)
|
||||
return text
|
||||
}
|
||||
|
||||
func TestMCP_Projects_ToolsListAll(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
resp := c.rpc("tools/list", map[string]any{})
|
||||
result, ok := resp["result"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
tools, ok := result["tools"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tools, 5)
|
||||
|
||||
names := make(map[string]bool, len(tools))
|
||||
for _, raw := range tools {
|
||||
tool := raw.(map[string]any)
|
||||
names[tool["name"].(string)] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"projects_create",
|
||||
"projects_read_one",
|
||||
"projects_read_all",
|
||||
"projects_update",
|
||||
"projects_delete",
|
||||
} {
|
||||
assert.Truef(t, names[want], "missing tool %q in %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_Projects_Create(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
result := c.callTool("projects_create", map[string]any{
|
||||
"title": "MCP created project",
|
||||
"description": "Created by mcp_projects_test",
|
||||
})
|
||||
require.NotContains(t, result, "isError", "create unexpectedly errored: %v", result)
|
||||
|
||||
text := toolResultText(t, result)
|
||||
var project map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &project), "text was: %s", text)
|
||||
assert.Equal(t, "MCP created project", project["title"])
|
||||
assert.Equal(t, "Created by mcp_projects_test", project["description"])
|
||||
id, ok := project["id"].(float64)
|
||||
require.Truef(t, ok, "id missing or not a number: %v", project)
|
||||
assert.Positive(t, int(id))
|
||||
}
|
||||
|
||||
func TestMCP_Projects_CreateMissingTitle(t *testing.T) {
|
||||
// The SDK validates input against the schema before our handler runs;
|
||||
// "title" has no omitempty so it is required, and a request without it
|
||||
// must come back as an error response (either a JSON-RPC error or a
|
||||
// tool result with IsError set).
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
resp := c.rpc("tools/call", map[string]any{
|
||||
"name": "projects_create",
|
||||
"arguments": map[string]any{}, // missing title
|
||||
})
|
||||
// The SDK reports schema-validation failures as either a top-level
|
||||
// JSON-RPC error or a tool result with isError=true. Accept either.
|
||||
if errObj, has := resp["error"]; has {
|
||||
require.NotNil(t, errObj)
|
||||
return
|
||||
}
|
||||
result, ok := resp["result"].(map[string]any)
|
||||
require.True(t, ok, "missing both error and result: %v", resp)
|
||||
isErr, _ := result["isError"].(bool)
|
||||
assert.True(t, isErr, "expected isError for missing required title, got: %v", result)
|
||||
}
|
||||
|
||||
func TestMCP_Projects_ReadOneOwned(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
result := c.callTool("projects_read_one", map[string]any{"id": 1})
|
||||
require.NotContains(t, result, "isError")
|
||||
|
||||
text := toolResultText(t, result)
|
||||
var project map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &project))
|
||||
assert.InDelta(t, float64(1), project["id"], 0.0001)
|
||||
assert.Equal(t, "Test1", project["title"])
|
||||
}
|
||||
|
||||
func TestMCP_Projects_ReadOneForbidden(t *testing.T) {
|
||||
// Project 20 belongs to user 13. User 1 (token 11's owner) cannot see
|
||||
// it. The model returns a permission error; the dispatcher surfaces it
|
||||
// as the tool handler's error path, which maps to isError=true.
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
result := c.callTool("projects_read_one", map[string]any{"id": 20})
|
||||
isErr, _ := result["isError"].(bool)
|
||||
require.True(t, isErr, "expected isError for forbidden project, got: %v", result)
|
||||
}
|
||||
|
||||
func TestMCP_Projects_ReadOneNonexistent(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
result := c.callTool("projects_read_one", map[string]any{"id": 999999})
|
||||
isErr, _ := result["isError"].(bool)
|
||||
require.True(t, isErr, "expected isError for nonexistent project, got: %v", result)
|
||||
}
|
||||
|
||||
func TestMCP_Projects_ReadAll(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
result := c.callTool("projects_read_all", map[string]any{})
|
||||
require.NotContains(t, result, "isError", "read_all errored: %v", result)
|
||||
|
||||
text := toolResultText(t, result)
|
||||
var projects []map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &projects), "text was: %s", text)
|
||||
require.NotEmpty(t, projects, "expected at least one project")
|
||||
|
||||
// User 1 owns Test1 (project id 1); confirm it's in the response.
|
||||
titles := make(map[string]bool, len(projects))
|
||||
for _, p := range projects {
|
||||
title, _ := p["title"].(string)
|
||||
titles[title] = true
|
||||
}
|
||||
assert.True(t, titles["Test1"], "expected Test1 in: %v", titles)
|
||||
}
|
||||
|
||||
func TestMCP_Projects_ReadAllSearch(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
result := c.callTool("projects_read_all", map[string]any{
|
||||
"search": "Test1",
|
||||
"page": 1,
|
||||
"per_page": 50,
|
||||
})
|
||||
require.NotContains(t, result, "isError")
|
||||
|
||||
text := toolResultText(t, result)
|
||||
var projects []map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(text), &projects))
|
||||
// At minimum the matching project Test1 should appear.
|
||||
require.NotEmpty(t, projects)
|
||||
for _, p := range projects {
|
||||
title, _ := p["title"].(string)
|
||||
assert.NotEmpty(t, title, "project missing title: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_Projects_Update(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
|
||||
// First create a project so we can update it without disturbing other
|
||||
// fixtures (project 1 is referenced from a lot of test data).
|
||||
createResult := c.callTool("projects_create", map[string]any{
|
||||
"title": "mcp project to update",
|
||||
})
|
||||
require.NotContains(t, createResult, "isError")
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(toolResultText(t, createResult)), &created))
|
||||
pid := int64(created["id"].(float64))
|
||||
|
||||
updateResult := c.callTool("projects_update", map[string]any{
|
||||
"id": pid,
|
||||
"title": "mcp project updated",
|
||||
"description": "Updated description",
|
||||
})
|
||||
require.NotContains(t, updateResult, "isError", "update errored: %v", updateResult)
|
||||
|
||||
// Read it back to verify persistence.
|
||||
readResult := c.callTool("projects_read_one", map[string]any{"id": pid})
|
||||
require.NotContains(t, readResult, "isError")
|
||||
var project map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(toolResultText(t, readResult)), &project))
|
||||
assert.Equal(t, "mcp project updated", project["title"])
|
||||
assert.Equal(t, "Updated description", project["description"])
|
||||
}
|
||||
|
||||
func TestMCP_Projects_Delete(t *testing.T) {
|
||||
c := newMCPClient(t, mcpFullProjectsToken)
|
||||
|
||||
createResult := c.callTool("projects_create", map[string]any{
|
||||
"title": "mcp project to delete",
|
||||
})
|
||||
require.NotContains(t, createResult, "isError")
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(toolResultText(t, createResult)), &created))
|
||||
pid := int64(created["id"].(float64))
|
||||
|
||||
deleteResult := c.callTool("projects_delete", map[string]any{"id": pid})
|
||||
require.NotContains(t, deleteResult, "isError", "delete errored: %v", deleteResult)
|
||||
|
||||
// Subsequent read should fail with isError=true.
|
||||
readResult := c.callTool("projects_read_one", map[string]any{"id": pid})
|
||||
isErr, _ := readResult["isError"].(bool)
|
||||
require.True(t, isErr, "expected isError for deleted project, got: %v", readResult)
|
||||
// Sanity check the error message references the project.
|
||||
text := strings.ToLower(toolResultText(t, readResult))
|
||||
assert.NotEmpty(t, text)
|
||||
}
|
||||
|
|
@ -136,7 +136,11 @@ func TestMCP_InitializeWithMCPToken(t *testing.T) {
|
|||
assert.NotEmpty(t, rec.Header().Get("Mcp-Session-Id"))
|
||||
}
|
||||
|
||||
func TestMCP_ToolsListEmpty(t *testing.T) {
|
||||
func TestMCP_ToolsListReturnsRegisteredResources(t *testing.T) {
|
||||
// Task 5 wires the `projects` resource into the registry, so the live
|
||||
// SDK server should now expose its five tools to any token with
|
||||
// mcp:access. Task 6 will add per-token scope filtering and is when
|
||||
// the mcp-only token starts seeing a narrower list.
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -170,7 +174,24 @@ func TestMCP_ToolsListEmpty(t *testing.T) {
|
|||
require.True(t, ok, "response missing result: %s", listRec.Body.String())
|
||||
tools, ok := result["tools"].([]any)
|
||||
require.True(t, ok, "response missing tools array: %s", listRec.Body.String())
|
||||
assert.Empty(t, tools, "expected empty tools list, got: %v", tools)
|
||||
require.Len(t, tools, 5, "expected exactly the 5 project tools, got: %v", tools)
|
||||
|
||||
names := make(map[string]bool, len(tools))
|
||||
for _, raw := range tools {
|
||||
tool, isMap := raw.(map[string]any)
|
||||
require.True(t, isMap, "tool entry should be an object: %v", raw)
|
||||
name, _ := tool["name"].(string)
|
||||
names[name] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"projects_create",
|
||||
"projects_read_one",
|
||||
"projects_read_all",
|
||||
"projects_update",
|
||||
"projects_delete",
|
||||
} {
|
||||
assert.Truef(t, names[want], "tools/list missing %s; got %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCP_SessionRoundTrip(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue