vikunja/pkg/webtests/mcp_projects_test.go

344 lines
13 KiB
Go

// 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) {
// Token 11 has every project scope plus the scopes added in Task 7
// (tasks, labels, teams, tasks_comments, tasks_assignees). The total
// tool count therefore exceeds 5; what matters here is that all five
// project tools are present.
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)
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"])
}
// TestMCP_Projects_UpdateClearsArchived exercises the pointer-source path
// of copyByJSONTag: an explicit `is_archived: false` must un-archive a
// project that was previously archived.
func TestMCP_Projects_UpdateClearsArchived(t *testing.T) {
c := newMCPClient(t, mcpFullProjectsToken)
createResult := c.callTool("projects_create", map[string]any{
"title": "mcp project to un-archive",
"is_archived": true,
})
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))
require.True(t, created["is_archived"].(bool), "project should have been created archived")
updateResult := c.callTool("projects_update", map[string]any{
"id": pid,
"is_archived": false,
})
require.NotContains(t, updateResult, "isError", "update errored: %v", updateResult)
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.False(t, project["is_archived"].(bool), "is_archived must be false after explicit clear")
}
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)
}