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:
kolaente 2026-05-26 23:43:59 +02:00
parent dbf352cc96
commit e423167ce1
8 changed files with 655 additions and 22 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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) {