diff --git a/pkg/db/fixtures/api_tokens.yml b/pkg/db/fixtures/api_tokens.yml
index f38d9619b..58969b651 100644
--- a/pkg/db/fixtures/api_tokens.yml
+++ b/pkg/db/fixtures/api_tokens.yml
@@ -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
diff --git a/pkg/models/api_tokens_test.go b/pkg/models/api_tokens_test.go
index 4be6f19b6..22ef09492 100644
--- a/pkg/models/api_tokens_test.go
+++ b/pkg/models/api_tokens_test.go
@@ -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) {
diff --git a/pkg/modules/mcp/dispatcher.go b/pkg/modules/mcp/dispatcher.go
index 463d1be0e..c9162c785 100644
--- a/pkg/modules/mcp/dispatcher.go
+++ b/pkg/modules/mcp/dispatcher.go
@@ -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
diff --git a/pkg/modules/mcp/inputs.go b/pkg/modules/mcp/inputs.go
index 65a431e7a..8b36298fd 100644
--- a/pkg/modules/mcp/inputs.go
+++ b/pkg/modules/mcp/inputs.go
@@ -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)
+}
diff --git a/pkg/modules/mcp/mcp.go b/pkg/modules/mcp/mcp.go
index dc7e0b62d..3058f6e1b 100644
--- a/pkg/modules/mcp/mcp.go
+++ b/pkg/modules/mcp/mcp.go
@@ -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
diff --git a/pkg/modules/mcp/resources.go b/pkg/modules/mcp/resources.go
new file mode 100644
index 000000000..43c720f9e
--- /dev/null
+++ b/pkg/modules/mcp/resources.go
@@ -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 .
+
+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)
+}
diff --git a/pkg/webtests/mcp_projects_test.go b/pkg/webtests/mcp_projects_test.go
new file mode 100644
index 000000000..bdef932b0
--- /dev/null
+++ b/pkg/webtests/mcp_projects_test.go
@@ -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 .
+
+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)
+}
diff --git a/pkg/webtests/mcp_test.go b/pkg/webtests/mcp_test.go
index 69020c1df..50e6b8e78 100644
--- a/pkg/webtests/mcp_test.go
+++ b/pkg/webtests/mcp_test.go
@@ -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) {