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