From 335165e88b485849362c6a2cf1ef1558f9f11746 Mon Sep 17 00:00:00 2001 From: Mark Rai Date: Tue, 31 Mar 2026 11:03:12 -0400 Subject: [PATCH 1/4] Add initial MCP adapter with todo, sprint, and tag tools Signed-off-by: Mark Rai --- .gitignore | 1 + cmd/scrumboy/main.go | 2 + internal/httpapi/server.go | 8 + internal/mcp/adapter.go | 193 +++ internal/mcp/adapter_test.go | 2938 ++++++++++++++++++++++++++++++++ internal/mcp/errors.go | 54 + internal/mcp/http_handler.go | 105 ++ internal/mcp/projects_tools.go | 50 + internal/mcp/registry.go | 29 + internal/mcp/sprint_tools.go | 557 ++++++ internal/mcp/system_tools.go | 29 + internal/mcp/tag_tools.go | 180 ++ internal/mcp/todos_tools.go | 649 +++++++ internal/mcp/types.go | 115 ++ 14 files changed, 4910 insertions(+) create mode 100644 internal/mcp/adapter.go create mode 100644 internal/mcp/adapter_test.go create mode 100644 internal/mcp/errors.go create mode 100644 internal/mcp/http_handler.go create mode 100644 internal/mcp/projects_tools.go create mode 100644 internal/mcp/registry.go create mode 100644 internal/mcp/sprint_tools.go create mode 100644 internal/mcp/system_tools.go create mode 100644 internal/mcp/tag_tools.go create mode 100644 internal/mcp/todos_tools.go create mode 100644 internal/mcp/types.go diff --git a/.gitignore b/.gitignore index a650293..dcd5a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ coverage.out *.test # Development related +/docs/draft/ f.bat a.bat *.pem diff --git a/cmd/scrumboy/main.go b/cmd/scrumboy/main.go index ec6d80b..91441db 100644 --- a/cmd/scrumboy/main.go +++ b/cmd/scrumboy/main.go @@ -14,6 +14,7 @@ import ( "scrumboy/internal/crypto" "scrumboy/internal/db" "scrumboy/internal/httpapi" + "scrumboy/internal/mcp" "scrumboy/internal/migrate" "scrumboy/internal/projectcolor" "scrumboy/internal/store" @@ -76,6 +77,7 @@ func main() { Logger: logger, MaxRequestBody: cfg.MaxRequestBodyBytes, ScrumboyMode: cfg.ScrumboyMode, + MCPHandler: mcp.New(st, mcp.Options{Mode: cfg.ScrumboyMode}), EncryptionKey: encKey, }) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 1366309..7a6333f 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -20,6 +20,7 @@ type Options struct { MaxRequestBody int64 ScrumboyMode string // "full" or "anonymous" AuthRateLimit *ratelimit.Limiter + MCPHandler http.Handler // EncryptionKey is the HMAC secret for password reset tokens. Required for admin password reset. // Set from SCRUMBOY_ENCRYPTION_KEY (base64). If unset, password reset endpoints return 503. EncryptionKey []byte @@ -45,6 +46,7 @@ type Server struct { indexHTML []byte landingHTML []byte swJS []byte // Service worker with version injected + mcpHandler http.Handler } type storeAPI interface { @@ -244,6 +246,7 @@ func NewServer(st storeAPI, opts Options) *Server { indexHTML: indexHTML, landingHTML: landingHTML, swJS: swJS, + mcpHandler: opts.MCPHandler, } } @@ -272,6 +275,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if s.mcpHandler != nil && (r.URL.Path == "/mcp" || strings.HasPrefix(r.URL.Path, "/mcp/")) { + s.mcpHandler.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") { s.handleAPI(w, r) return diff --git a/internal/mcp/adapter.go b/internal/mcp/adapter.go new file mode 100644 index 0000000..66d7254 --- /dev/null +++ b/internal/mcp/adapter.go @@ -0,0 +1,193 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "scrumboy/internal/store" +) + +type storeAPI interface { + CountUsers(ctx context.Context) (int, error) + GetUserBySessionToken(ctx context.Context, token string) (store.User, error) + ListProjects(ctx context.Context) ([]store.ProjectListEntry, error) + GetProjectContextBySlug(ctx context.Context, slug string, mode store.Mode) (store.ProjectContext, error) + CreateTodo(ctx context.Context, projectID int64, in store.CreateTodoInput, mode store.Mode) (store.Todo, error) + GetTodoByLocalID(ctx context.Context, projectID, localID int64, mode store.Mode) (store.Todo, error) + SearchTodosForLinkPicker(ctx context.Context, projectID int64, q string, limit int, excludeLocalIDs []int64, mode store.Mode) ([]store.TodoLinkTarget, error) + UpdateTodoByLocalID(ctx context.Context, projectID, localID int64, in store.UpdateTodoInput, mode store.Mode) (store.Todo, error) + DeleteTodoByLocalID(ctx context.Context, projectID, localID int64, mode store.Mode) error + MoveTodoByLocalID(ctx context.Context, projectID, localID int64, toColumnKey string, afterLocalID, beforeLocalID *int64, mode store.Mode) (store.Todo, error) + ListTodosForBoardLane(ctx context.Context, projectID int64, columnKey string, limit int, afterRank, afterID int64, tagFilter, searchFilter string, sprintFilter store.SprintFilter) ([]store.Todo, string, bool, error) + ListSprintsWithTodoCount(ctx context.Context, projectID int64) ([]store.SprintWithTodoCount, error) + CountUnscheduledTodos(ctx context.Context, projectID int64) (int64, error) + GetSprintByID(ctx context.Context, sprintID int64) (store.Sprint, error) + GetActiveSprintByProjectID(ctx context.Context, projectID int64) (*store.Sprint, error) + CreateSprint(ctx context.Context, projectID int64, name string, plannedStartAt, plannedEndAt time.Time) (store.Sprint, error) + GetProjectRole(ctx context.Context, projectID int64, userID int64) (store.ProjectRole, error) + ActivateSprint(ctx context.Context, projectID, sprintID int64) error + CloseSprint(ctx context.Context, sprintID int64) error + UpdateSprint(ctx context.Context, sprintID int64, in store.UpdateSprintInput) error + DeleteSprint(ctx context.Context, projectID, sprintID int64) error + ListTagCounts(ctx context.Context, pc *store.ProjectContext) ([]store.TagCount, error) + ListUserTags(ctx context.Context, userID int64) ([]store.TagWithColor, error) + UpdateTagColor(ctx context.Context, viewerUserID *int64, tagID int64, color *string) error +} + +type Options struct { + Mode string +} + +type Adapter struct { + store storeAPI + mode string + tools toolRegistry +} + +func New(st storeAPI, opts Options) *Adapter { + mode := opts.Mode + if mode != "full" && mode != "anonymous" { + mode = "full" + } + + a := &Adapter{ + store: st, + mode: mode, + tools: make(toolRegistry), + } + a.registerTools() + return a +} + +func (a *Adapter) requestContext(r *http.Request) context.Context { + ctx := r.Context() + + // Keep anonymous mode aligned with the existing HTTP API boundary: + // valid session cookies are ignored entirely. + if a.mode == "anonymous" { + return ctx + } + + c, err := r.Cookie("scrumboy_session") + if err != nil || c == nil || c.Value == "" { + return ctx + } + + u, err := a.store.GetUserBySessionToken(ctx, c.Value) + if err != nil { + return ctx + } + + ctx = store.WithUserID(ctx, u.ID) + ctx = store.WithUserEmail(ctx, u.Email) + ctx = store.WithUserName(ctx, u.Name) + return ctx +} + +func (a *Adapter) authState(ctx context.Context) (authCapabilities, bool, *adapterError) { + if a.mode == "anonymous" { + reason := "server mode anonymous disables authenticated MCP tools" + return authCapabilities{ + Mode: "disabled", + Authenticated: false, + AuthenticatedToolsUsable: false, + Reason: &reason, + }, false, nil + } + + n, err := a.store.CountUsers(ctx) + if err != nil { + return authCapabilities{}, false, newAdapterError(http.StatusInternalServerError, CodeInternal, "internal error", map[string]any{"detail": err.Error()}) + } + + _, authenticated := store.UserIDFromContext(ctx) + bootstrapAvailable := n == 0 + authUsable := n > 0 + + var reason *string + if bootstrapAvailable { + msg := "bootstrap required before authenticated MCP tools are available" + reason = &msg + } + + return authCapabilities{ + Mode: "sessionCookie", + Authenticated: authenticated, + AuthenticatedToolsUsable: authUsable, + Reason: reason, + }, bootstrapAvailable, nil +} + +func (a *Adapter) implementedTools() []string { + return []string{ + "system.getCapabilities", + "projects.list", + "todos.create", + "todos.get", + "todos.search", + "todos.update", + "todos.delete", + "todos.move", + "sprints.list", + "sprints.get", + "sprints.getActive", + "sprints.create", + "sprints.activate", + "sprints.close", + "sprints.update", + "sprints.delete", + "tags.listProject", + "tags.listMine", + "tags.updateMineColor", + } +} + +func (a *Adapter) plannedTools() []string { + return []string{ + "board.get", + } +} + +func (a *Adapter) storeMode() store.Mode { + mode, _ := store.ParseMode(a.mode) + if mode == "" { + return store.ModeFull + } + return mode +} + +func decodeInput(input any, dst any) error { + b, err := json.Marshal(input) + if err != nil { + return err + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + return nil +} + +func normalizeColumnKey(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "": + return "" + case "backlog": + return store.DefaultColumnBacklog + case "not_started", "not-started": + return store.DefaultColumnNotStarted + case "doing", "in_progress", "in-progress": + return store.DefaultColumnDoing + case "testing": + return store.DefaultColumnTesting + case "done": + return store.DefaultColumnDone + default: + return strings.ToLower(strings.TrimSpace(v)) + } +} diff --git a/internal/mcp/adapter_test.go b/internal/mcp/adapter_test.go new file mode 100644 index 0000000..234d356 --- /dev/null +++ b/internal/mcp/adapter_test.go @@ -0,0 +1,2938 @@ +package mcp_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "scrumboy/internal/db" + "scrumboy/internal/httpapi" + "scrumboy/internal/mcp" + "scrumboy/internal/migrate" + "scrumboy/internal/store" +) + +func newTestServer(t *testing.T, mode string) (*httptest.Server, *sql.DB, func()) { + t.Helper() + + dir := t.TempDir() + sqlDB, err := db.Open(filepath.Join(dir, "app.db"), db.Options{ + BusyTimeout: 5000, + JournalMode: "WAL", + Synchronous: "FULL", + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := migrate.Apply(context.Background(), sqlDB); err != nil { + _ = sqlDB.Close() + t.Fatalf("migrate: %v", err) + } + + st := store.New(sqlDB, nil) + srv := httpapi.NewServer(st, httpapi.Options{ + MaxRequestBody: 1 << 20, + ScrumboyMode: mode, + MCPHandler: mcp.New(st, mcp.Options{Mode: mode}), + }) + ts := httptest.NewServer(srv) + return ts, sqlDB, func() { + ts.Close() + _ = sqlDB.Close() + } +} + +func doMCP(t *testing.T, client *http.Client, url string, body any) (*http.Response, map[string]any) { + t.Helper() + + var reqBody io.Reader + if body != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode mcp body: %v", err) + } + reqBody = &buf + } + + req, err := http.NewRequest(http.MethodPost, url, reqBody) + if err != nil { + t.Fatalf("new mcp request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do mcp request: %v", err) + } + defer resp.Body.Close() + + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode mcp response: %v", err) + } + return resp, out +} + +func doJSON(t *testing.T, client *http.Client, method, url string, body any, out any) *http.Response { + t.Helper() + + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode json: %v", err) + } + } + + req, err := http.NewRequest(method, url, &buf) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Scrumboy", "1") + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + t.Fatalf("decode json: %v", err) + } + } + return resp +} + +func newCookieClient(t *testing.T, ts *httptest.Server) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("cookie jar: %v", err) + } + client := ts.Client() + client.Jar = jar + return client +} + +func newStatelessClient(ts *httptest.Server) *http.Client { + base := ts.Client() + return &http.Client{Transport: base.Transport} +} + +func projectSlugByName(t *testing.T, sqlDB *sql.DB, name string) string { + t.Helper() + var slug string + if err := sqlDB.QueryRow(`SELECT slug FROM projects WHERE name = ?`, name).Scan(&slug); err != nil { + t.Fatalf("query project slug: %v", err) + } + return slug +} + +func projectIDBySlug(t *testing.T, sqlDB *sql.DB, slug string) int64 { + t.Helper() + var projectID int64 + if err := sqlDB.QueryRow(`SELECT id FROM projects WHERE slug = ?`, slug).Scan(&projectID); err != nil { + t.Fatalf("query project id by slug: %v", err) + } + return projectID +} + +func firstUserID(t *testing.T, sqlDB *sql.DB) int64 { + t.Helper() + var userID int64 + if err := sqlDB.QueryRow(`SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&userID); err != nil { + t.Fatalf("query first user id: %v", err) + } + return userID +} + +func todoLocalIDsByColumn(t *testing.T, sqlDB *sql.DB, slug, columnKey string) []int64 { + t.Helper() + rows, err := sqlDB.Query(` +SELECT t.local_id +FROM todos t +JOIN projects p ON p.id = t.project_id +WHERE p.slug = ? AND t.column_key = ? +ORDER BY t.rank ASC, t.id ASC +`, slug, columnKey) + if err != nil { + t.Fatalf("query todo order: %v", err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var localID int64 + if err := rows.Scan(&localID); err != nil { + t.Fatalf("scan todo order: %v", err) + } + out = append(out, localID) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate todo order: %v", err) + } + return out +} + +func bootstrapUser(t *testing.T, client *http.Client, baseURL string) { + t.Helper() + resp := doJSON(t, client, http.MethodPost, baseURL+"/api/auth/bootstrap", map[string]any{ + "email": "owner@example.com", + "password": "password123", + "name": "Owner", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("bootstrap status=%d", resp.StatusCode) + } +} + +func TestMCPMountDoesNotBreakSPARoutes(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("get root: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected root 200, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "text/html; charset=utf-8" { + t.Fatalf("expected html content type, got %q", got) + } + + resp, err = http.Get(ts.URL + "/mcp") + if err != nil { + t.Fatalf("get /mcp: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected /mcp 200, got %d", resp.StatusCode) + } +} + +func TestMCPSystemGetCapabilities_FullPreBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, err := http.Get(ts.URL + "/mcp") + if err != nil { + t.Fatalf("get /mcp: %v", err) + } + defer resp.Body.Close() + + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Cache-Control"); got != "no-store" { + t.Fatalf("expected Cache-Control no-store, got %q", got) + } + if out["ok"] != true { + t.Fatalf("expected ok=true, got %#v", out["ok"]) + } + data := out["data"].(map[string]any) + if data["serverMode"] != "full" { + t.Fatalf("expected serverMode full, got %#v", data["serverMode"]) + } + if data["bootstrapAvailable"] != true { + t.Fatalf("expected bootstrapAvailable true, got %#v", data["bootstrapAvailable"]) + } + auth := data["auth"].(map[string]any) + if auth["mode"] != "sessionCookie" { + t.Fatalf("expected sessionCookie auth mode, got %#v", auth["mode"]) + } + if auth["authenticatedToolsUsable"] != false { + t.Fatalf("expected authenticatedToolsUsable false, got %#v", auth["authenticatedToolsUsable"]) + } + tools := data["implementedTools"].([]any) + if len(tools) != 19 || tools[0] != "system.getCapabilities" || tools[1] != "projects.list" || tools[2] != "todos.create" || tools[3] != "todos.get" || tools[4] != "todos.search" || tools[5] != "todos.update" || tools[6] != "todos.delete" || tools[7] != "todos.move" || tools[8] != "sprints.list" || tools[9] != "sprints.get" || tools[10] != "sprints.getActive" || tools[11] != "sprints.create" || tools[12] != "sprints.activate" || tools[13] != "sprints.close" || tools[14] != "sprints.update" || tools[15] != "sprints.delete" || tools[16] != "tags.listProject" || tools[17] != "tags.listMine" || tools[18] != "tags.updateMineColor" { + t.Fatalf("unexpected implementedTools: %#v", tools) + } + planned := data["plannedTools"].([]any) + if len(planned) != 1 || planned[0] != "board.get" { + t.Fatalf("unexpected plannedTools: %#v", planned) + } +} + +func TestMCPSystemGetCapabilities_AnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, err := http.Get(ts.URL + "/mcp") + if err != nil { + t.Fatalf("get /mcp: %v", err) + } + defer resp.Body.Close() + + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + + data := out["data"].(map[string]any) + if data["serverMode"] != "anonymous" { + t.Fatalf("expected anonymous mode, got %#v", data["serverMode"]) + } + auth := data["auth"].(map[string]any) + if auth["mode"] != "disabled" { + t.Fatalf("expected disabled auth mode, got %#v", auth["mode"]) + } + if auth["authenticatedToolsUsable"] != false { + t.Fatalf("expected authenticatedToolsUsable false, got %#v", auth["authenticatedToolsUsable"]) + } +} + +func TestMCPProjectsListRequiresAuthAfterBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + authClient := newCookieClient(t, ts) + bootstrapUser(t, authClient, ts.URL) + + resp, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "projects.list", + "input": map[string]any{}, + }) + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + if out["ok"] != false { + t.Fatalf("expected ok=false, got %#v", out["ok"]) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPProjectsListSuccessWithAuthenticatedSession(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + var created map[string]any + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "MCP Project", + }, &created) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "projects.list", + "input": map[string]any{}, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true, got %#v", out["ok"]) + } + + data := out["data"].(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected 1 project, got %#v", items) + } + item := items[0].(map[string]any) + if item["projectSlug"] == "" { + t.Fatalf("expected projectSlug, got %#v", item["projectSlug"]) + } + if item["name"] != "MCP Project" { + t.Fatalf("expected project name, got %#v", item["name"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPProjectsListCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "projects.list", + "input": map[string]any{}, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPTodosCreateSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Create Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Create Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Add MCP adapter", + "body": "Thin layer only", + "tags": []string{"mcp"}, + "columnKey": "backlog", + "position": map[string]any{ + "afterLocalId": nil, + "beforeLocalId": nil, + }, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true, got %#v", out["ok"]) + } + data := out["data"].(map[string]any) + todo := data["todo"].(map[string]any) + if todo["projectSlug"] != slug { + t.Fatalf("expected projectSlug %q, got %#v", slug, todo["projectSlug"]) + } + if todo["localId"] == nil { + t.Fatalf("expected localId, got %#v", todo["localId"]) + } + if todo["columnKey"] != "backlog" { + t.Fatalf("expected columnKey backlog, got %#v", todo["columnKey"]) + } + if _, ok := todo["id"]; ok { + t.Fatalf("did not expect global todo id in MCP todo object: %#v", todo) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPTodosCreateRequiresAuthAfterBootstrap(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Auth Required Todo Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Auth Required Todo Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Unauthed", + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTodosCreateCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": "demo", + "title": "Nope", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPTodosCreateValidationErrorForMalformedInput(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": "demo", + "title": "Bad", + "unknownField": true, + }, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosGetSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Get Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Get Project") + + resp = doJSON(t, client, http.MethodPost, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Fetch me", + }, + }, &map[string]any{}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("todos.create status=%d", resp.StatusCode) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.get", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + data := out["data"].(map[string]any) + todo := data["todo"].(map[string]any) + if todo["projectSlug"] != slug || todo["localId"] != float64(1) { + t.Fatalf("unexpected canonical identity: %#v", todo) + } + if todo["title"] != "Fetch me" { + t.Fatalf("expected title, got %#v", todo["title"]) + } + if _, ok := todo["id"]; ok { + t.Fatalf("did not expect global todo id in MCP todo object: %#v", todo) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPTodosGetNotFound(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Missing Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Missing Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.get", + "input": map[string]any{ + "projectSlug": slug, + "localId": 999, + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND, got %#v", errObj["code"]) + } +} + +func TestMCPTodosGetRequiresAuth(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Get Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Get Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "todos.get", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTodosSearchSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Search Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Search Project") + + doJSON(t, client, http.MethodPost, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Add MCP adapter", + }, + }, &map[string]any{}) + doJSON(t, client, http.MethodPost, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Other task", + }, + }, &map[string]any{}) + + limit := 20 + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.search", + "input": map[string]any{ + "projectSlug": slug, + "query": "adapter", + "limit": limit, + "excludeLocalIds": []int64{}, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + data := out["data"].(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected 1 result, got %#v", items) + } + item := items[0].(map[string]any) + if item["projectSlug"] != slug || item["localId"] == nil { + t.Fatalf("unexpected canonical search item: %#v", item) + } + if item["title"] != "Add MCP adapter" { + t.Fatalf("unexpected title: %#v", item["title"]) + } + if _, ok := item["id"]; ok { + t.Fatalf("did not expect global todo id in search result: %#v", item) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } + if len(out["meta"].(map[string]any)) != 0 { + t.Fatalf("expected empty meta for honest non-cursor search, got %#v", out["meta"]) + } +} + +func TestMCPTodosSearchValidationErrorForMalformedInput(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.search", + "input": map[string]any{ + "projectSlug": "demo", + "limit": 0, + }, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosUpdateSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + userID := firstUserID(t, sqlDB) + + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Update Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Update Project") + + createResp, createOut := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Original", + "body": "Original body", + "tags": []string{"mcp"}, + "estimationPoints": 3, + "assigneeUserId": userID, + }, + }) + if createResp.StatusCode != http.StatusOK { + t.Fatalf("todos.create status=%d", createResp.StatusCode) + } + localID := int(createOut["data"].(map[string]any)["todo"].(map[string]any)["localId"].(float64)) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": slug, + "localId": localID, + "patch": map[string]any{ + "title": "Updated", + "body": "Updated body", + }, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + data := out["data"].(map[string]any) + todo := data["todo"].(map[string]any) + if todo["projectSlug"] != slug || todo["localId"] != float64(localID) { + t.Fatalf("unexpected canonical identity: %#v", todo) + } + if todo["title"] != "Updated" || todo["body"] != "Updated body" { + t.Fatalf("unexpected updated fields: %#v", todo) + } + if _, ok := todo["id"]; ok { + t.Fatalf("did not expect global todo id in MCP todo object: %#v", todo) + } +} + +func TestMCPTodosUpdateOmittedFieldsRemainUnchanged(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Omit Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Omit Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Keep title", + "body": "Keep body", + "tags": []string{"mcp", "backend"}, + "estimationPoints": 5, + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "patch": map[string]any{ + "body": "Changed only body", + }, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + todo := out["data"].(map[string]any)["todo"].(map[string]any) + if todo["title"] != "Keep title" { + t.Fatalf("expected title unchanged, got %#v", todo["title"]) + } + if todo["body"] != "Changed only body" { + t.Fatalf("expected body changed, got %#v", todo["body"]) + } + tags := todo["tags"].([]any) + if len(tags) != 2 || tags[0] != "backend" || tags[1] != "mcp" { + t.Fatalf("expected tags unchanged, got %#v", todo["tags"]) + } + if todo["estimationPoints"] != float64(5) { + t.Fatalf("expected estimation unchanged, got %#v", todo["estimationPoints"]) + } +} + +func TestMCPTodosUpdateNullClearsSupportedFields(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + userID := firstUserID(t, sqlDB) + + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Clear Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Clear Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Clear me", + "estimationPoints": 8, + "assigneeUserId": userID, + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "patch": map[string]any{ + "estimationPoints": nil, + "assigneeUserId": nil, + }, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + todo := out["data"].(map[string]any)["todo"].(map[string]any) + if todo["estimationPoints"] != nil { + t.Fatalf("expected estimationPoints cleared, got %#v", todo["estimationPoints"]) + } + if todo["assigneeUserId"] != nil { + t.Fatalf("expected assigneeUserId cleared, got %#v", todo["assigneeUserId"]) + } +} + +func TestMCPTodosUpdateValidationErrorForMalformedPatch(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Bad Patch Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Bad Patch Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Patch target", + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "patch": map[string]any{ + "columnKey": "doing", + }, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosUpdateRejectsInvalidNullField(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Invalid Null Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Invalid Null Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Patch target", + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "patch": map[string]any{ + "title": nil, + }, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosUpdateRequiresAuth(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Update Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Update Auth Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Patch me", + }, + }) + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "patch": map[string]any{ + "title": "No auth", + }, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTodosUpdateCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "todos.update", + "input": map[string]any{ + "projectSlug": "demo", + "localId": 1, + "patch": map[string]any{ + "title": "Nope", + }, + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPTodosDeleteSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Delete Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Delete Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Delete me", + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.delete", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true, got %#v", out["ok"]) + } + data := out["data"].(map[string]any) + if data["status"] != "deleted" { + t.Fatalf("expected deleted status, got %#v", data["status"]) + } + if data["projectSlug"] != slug || data["localId"] != float64(1) { + t.Fatalf("unexpected delete identity echo: %#v", data) + } + if _, ok := data["id"]; ok { + t.Fatalf("did not expect global todo id in delete response: %#v", data) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPTodosDeleteNotFound(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Delete Missing Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Delete Missing Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.delete", + "input": map[string]any{ + "projectSlug": slug, + "localId": 999, + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND, got %#v", errObj["code"]) + } +} + +func TestMCPTodosDeleteRequiresAuth(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Delete Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Delete Auth Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Delete me", + }, + }) + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "todos.delete", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTodosDeleteCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "todos.delete", + "input": map[string]any{ + "projectSlug": "demo", + "localId": 1, + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPTodosMoveSuccessToAnotherColumn(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Move me", + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "toColumnKey": "in-progress", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + todo := out["data"].(map[string]any)["todo"].(map[string]any) + if todo["projectSlug"] != slug || todo["localId"] != float64(1) { + t.Fatalf("unexpected canonical identity: %#v", todo) + } + if todo["columnKey"] != "doing" { + t.Fatalf("expected normalized columnKey doing, got %#v", todo["columnKey"]) + } + if _, ok := todo["id"]; ok { + t.Fatalf("did not expect global todo id in move response: %#v", todo) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPTodosMoveSuccessWithAfterLocalId(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move After Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move After Project") + + for i := 1; i <= 2; i++ { + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Task", + }, + }) + } + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{"projectSlug": slug, "localId": 1, "toColumnKey": "doing"}, + }) + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 2, + "toColumnKey": "doing", + "afterLocalId": 1, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + todo := out["data"].(map[string]any)["todo"].(map[string]any) + if todo["columnKey"] != "doing" { + t.Fatalf("expected doing, got %#v", todo["columnKey"]) + } + got := todoLocalIDsByColumn(t, sqlDB, slug, "doing") + want := []int64{1, 2} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("unexpected doing order: got=%v want=%v", got, want) + } +} + +func TestMCPTodosMoveSuccessWithBeforeLocalId(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move Before Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move Before Project") + + for i := 1; i <= 2; i++ { + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Task", + }, + }) + } + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{"projectSlug": slug, "localId": 2, "toColumnKey": "doing"}, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "toColumnKey": "doing", + "beforeLocalId": 2, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + todo := out["data"].(map[string]any)["todo"].(map[string]any) + if todo["columnKey"] != "doing" { + t.Fatalf("expected doing, got %#v", todo["columnKey"]) + } + got := todoLocalIDsByColumn(t, sqlDB, slug, "doing") + want := []int64{1, 2} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("unexpected doing order: got=%v want=%v", got, want) + } +} + +func TestMCPTodosMoveValidationErrorWhenBothNeighborsSet(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": "demo", + "localId": 1, + "toColumnKey": "doing", + "afterLocalId": 2, + "beforeLocalId": 3, + }, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosMoveValidationErrorForNonexistentNeighbor(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move Missing Neighbor Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move Missing Neighbor Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Task", + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "toColumnKey": "doing", + "afterLocalId": 999, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosMoveValidationErrorForWrongColumnNeighbor(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move Wrong Column Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move Wrong Column Project") + + for i := 1; i <= 2; i++ { + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Task", + }, + }) + } + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{"projectSlug": slug, "localId": 1, "toColumnKey": "doing"}, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 2, + "toColumnKey": "testing", + "afterLocalId": 1, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosMoveValidationErrorForAmbiguousAfterPlacement(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move Ambiguous Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move Ambiguous Project") + + for i := 1; i <= 3; i++ { + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Task", + }, + }) + } + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{"projectSlug": slug, "localId": 1, "toColumnKey": "doing"}, + }) + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{"projectSlug": slug, "localId": 2, "toColumnKey": "doing"}, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 3, + "toColumnKey": "doing", + "afterLocalId": 1, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTodosMoveRequiresAuth(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Todo Move Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Todo Move Auth Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Move me", + }, + }) + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": slug, + "localId": 1, + "toColumnKey": "doing", + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTodosMoveCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "todos.move", + "input": map[string]any{ + "projectSlug": "demo", + "localId": 1, + "toColumnKey": "doing", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsListSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint List Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint List Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp1, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint 1: %v", err) + } + if _, err := st.CreateSprint(context.Background(), projectID, "Sprint 2", time.UnixMilli(3000), time.UnixMilli(4000)); err != nil { + t.Fatalf("create sprint 2: %v", err) + } + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Backlog todo", + }, + }) + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Sprint todo", + "sprintId": sp1.ID, + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + data := out["data"].(map[string]any) + items := data["items"].([]any) + if len(items) != 2 { + t.Fatalf("expected 2 sprints, got %#v", items) + } + first := items[0].(map[string]any) + if first["projectSlug"] != slug || first["sprintId"] == nil { + t.Fatalf("unexpected canonical sprint identity: %#v", first) + } + meta := out["meta"].(map[string]any) + if meta["unscheduledCount"] != float64(1) { + t.Fatalf("expected unscheduledCount=1, got %#v", meta["unscheduledCount"]) + } +} + +func TestMCPSprintsGetSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Get Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Get Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.get", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["projectSlug"] != slug || sprint["sprintId"] != float64(sp.ID) { + t.Fatalf("unexpected canonical sprint identity: %#v", sprint) + } +} + +func TestMCPSprintsGetActiveSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Active Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Active Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Active Sprint", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + if err := st.ActivateSprint(context.Background(), projectID, sp.ID); err != nil { + t.Fatalf("activate sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.getActive", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["projectSlug"] != slug || sprint["sprintId"] != float64(sp.ID) { + t.Fatalf("unexpected active sprint identity: %#v", sprint) + } +} + +func TestMCPSprintsGetActiveNoActiveSprintReturnsNull(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint No Active Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint No Active Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + if _, err := st.CreateSprint(context.Background(), projectID, "Planned Sprint", time.UnixMilli(1000), time.UnixMilli(2000)); err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.getActive", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["data"].(map[string]any)["sprint"] != nil { + t.Fatalf("expected sprint null, got %#v", out["data"]) + } +} + +func TestMCPSprintsAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.list", + "input": map[string]any{ + "projectSlug": "demo", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsCreateSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Create Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Create Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.create", + "input": map[string]any{ + "projectSlug": slug, + "name": "Sprint 1", + "plannedStartAt": "2026-04-01T00:00:00Z", + "plannedEndAt": "2026-04-14T23:59:59Z", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["projectSlug"] != slug || sprint["sprintId"] == nil { + t.Fatalf("unexpected sprint identity: %#v", sprint) + } + if sprint["name"] != "Sprint 1" { + t.Fatalf("expected sprint name, got %#v", sprint["name"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPSprintsCreateValidationErrorForMalformedInput(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Bad Input Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Bad Input Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.create", + "input": map[string]any{ + "projectSlug": slug, + "name": "Sprint 1", + "plannedStartAt": "not-a-time", + "plannedEndAt": "2026-04-14T23:59:59Z", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsCreateValidationErrorForInvalidDateRange(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Bad Range Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Bad Range Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.create", + "input": map[string]any{ + "projectSlug": slug, + "name": "Sprint 1", + "plannedStartAt": "2026-04-14T23:59:59Z", + "plannedEndAt": "2026-04-01T00:00:00Z", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsCreateAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Create Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Create Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.create", + "input": map[string]any{ + "projectSlug": slug, + "name": "Sprint 1", + "plannedStartAt": "2026-04-01T00:00:00Z", + "plannedEndAt": "2026-04-14T23:59:59Z", + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsCreateCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.create", + "input": map[string]any{ + "projectSlug": "demo", + "name": "Sprint 1", + "plannedStartAt": "2026-04-01T00:00:00Z", + "plannedEndAt": "2026-04-14T23:59:59Z", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsActivateSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Activate Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Activate Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Planned Sprint", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.activate", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["projectSlug"] != slug || sprint["sprintId"] != float64(sp.ID) { + t.Fatalf("unexpected sprint identity: %#v", sprint) + } + if sprint["state"] != "ACTIVE" { + t.Fatalf("expected ACTIVE, got %#v", sprint["state"]) + } +} + +func TestMCPSprintsCloseSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Close Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Close Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + if err := st.ActivateSprint(context.Background(), projectID, sp.ID); err != nil { + t.Fatalf("activate sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.close", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["state"] != "CLOSED" { + t.Fatalf("expected CLOSED, got %#v", sprint["state"]) + } +} + +func TestMCPSprintsActivateValidationErrorFromWrongState(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Activate Wrong State Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Activate Wrong State Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + if err := st.ActivateSprint(context.Background(), projectID, sp.ID); err != nil { + t.Fatalf("activate sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.activate", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsCloseValidationErrorFromWrongState(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Close Wrong State Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Close Wrong State Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.close", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintActionsAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Action Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Action Auth Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.activate", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPSprintActionsCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.activate", + "input": map[string]any{ + "projectSlug": "demo", + "sprintId": 1, + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsUpdateSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Update Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Update Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + "patch": map[string]any{ + "name": "Sprint 1 revised", + "plannedStartAt": 3000, + "plannedEndAt": 4000, + }, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["projectSlug"] != slug || sprint["sprintId"] != float64(sp.ID) { + t.Fatalf("unexpected sprint identity: %#v", sprint) + } + if sprint["name"] != "Sprint 1 revised" { + t.Fatalf("expected updated name, got %#v", sprint["name"]) + } + if sprint["plannedStartAt"] != float64(3000) || sprint["plannedEndAt"] != float64(4000) { + t.Fatalf("expected updated dates, got %#v", sprint) + } +} + +func TestMCPSprintsUpdateOmissionLeavesFieldsUnchanged(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Omit Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Omit Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + "patch": map[string]any{ + "name": "Sprint renamed", + }, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + sprint := out["data"].(map[string]any)["sprint"].(map[string]any) + if sprint["name"] != "Sprint renamed" { + t.Fatalf("expected updated name, got %#v", sprint["name"]) + } + if sprint["plannedStartAt"] != float64(1000) || sprint["plannedEndAt"] != float64(2000) { + t.Fatalf("expected dates unchanged, got %#v", sprint) + } +} + +func TestMCPSprintsUpdateValidationErrorForStateFieldCombo(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint State Update Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint State Update Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.Now().Add(24*time.Hour), time.Now().Add(48*time.Hour)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + if err := st.ActivateSprint(context.Background(), projectID, sp.ID); err != nil { + t.Fatalf("activate sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + "patch": map[string]any{ + "name": "Nope", + }, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsUpdateMalformedTimestampValidation(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Bad Timestamp Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Bad Timestamp Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + "patch": map[string]any{ + "plannedStartAt": "not-a-number", + }, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsUpdateInvalidDateRangeValidation(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Bad Update Range Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Bad Update Range Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + "patch": map[string]any{ + "plannedStartAt": 3000, + "plannedEndAt": 2000, + }, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsUpdateAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Update Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Update Auth Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + "patch": map[string]any{ + "name": "No auth", + }, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsUpdateCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.update", + "input": map[string]any{ + "projectSlug": "demo", + "sprintId": 1, + "patch": map[string]any{ + "name": "Nope", + }, + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsDeleteSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Delete Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Delete Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.delete", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true, got %#v", out["ok"]) + } + data := out["data"].(map[string]any) + if data["status"] != "deleted" { + t.Fatalf("expected deleted status, got %#v", data["status"]) + } + if data["projectSlug"] != slug || data["sprintId"] != float64(sp.ID) { + t.Fatalf("unexpected delete identity echo: %#v", data) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPSprintsDeleteNotFound(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Delete Missing Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Delete Missing Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "sprints.delete", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": 999, + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsDeleteAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Sprint Delete Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Sprint Delete Auth Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + sp, err := st.CreateSprint(context.Background(), projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.delete", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPSprintsDeleteCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "sprints.delete", + "input": map[string]any{ + "projectSlug": "demo", + "sprintId": 1, + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPTagsListProjectSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"mcp"}, + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listProject", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + items := out["data"].(map[string]any)["items"].([]any) + if len(items) == 0 { + t.Fatalf("expected project tags, got %#v", items) + } + tag := items[0].(map[string]any) + if tag["tagId"] == nil || tag["name"] != "mcp" { + t.Fatalf("unexpected project tag shape: %#v", tag) + } + if tag["count"] != float64(1) { + t.Fatalf("expected count 1, got %#v", tag["count"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPTagsListMineSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Mine Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Mine Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"backend", "mcp"}, + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + items := out["data"].(map[string]any)["items"].([]any) + if len(items) < 2 { + t.Fatalf("expected mine tags, got %#v", items) + } + tag := items[0].(map[string]any) + if tag["tagId"] == nil || tag["name"] == nil { + t.Fatalf("unexpected mine tag shape: %#v", tag) + } + if _, hasCount := tag["count"]; hasCount { + t.Fatalf("did not expect count in tags.listMine shape: %#v", tag) + } +} + +func TestMCPTagsAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "tags.listProject", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTagsCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateMineColorSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Color Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Color Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"backend"}, + }, + }) + + _, mine := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + tagID := mine["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["tagId"] + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": tagID, + "color": "#7c3aed", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + tag := out["data"].(map[string]any)["tag"].(map[string]any) + if tag["tagId"] != tagID || tag["name"] != "backend" { + t.Fatalf("unexpected tag response: %#v", tag) + } + if tag["color"] != "#7c3aed" { + t.Fatalf("expected updated color, got %#v", tag["color"]) + } +} + +func TestMCPTagsUpdateMineColorClearSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Clear Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Clear Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"backend"}, + }, + }) + + _, mine := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + tagID := mine["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["tagId"] + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": tagID, + "color": "#7c3aed", + }, + }) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": tagID, + "color": nil, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + tag := out["data"].(map[string]any)["tag"].(map[string]any) + if tag["color"] != nil { + t.Fatalf("expected cleared color, got %#v", tag["color"]) + } +} + +func TestMCPTagsUpdateMineColorMalformedColorValidation(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Bad Color Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Bad Color Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"backend"}, + }, + }) + + _, mine := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + tagID := mine["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["tagId"] + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": tagID, + "color": "purple", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateMineColorMalformedInputValidation(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": 1, + "color": "#7c3aed", + "unknownField": true, + }, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateMineColorAuthFailure(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + + resp, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": 1, + "color": "#7c3aed", + }, + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateMineColorCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": 1, + "color": "#7c3aed", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPUnknownToolReturnsNotFoundEnvelope(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "missing.tool", + "input": map[string]any{}, + }) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + if out["ok"] != false { + t.Fatalf("expected ok=false, got %#v", out["ok"]) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND, got %#v", errObj["code"]) + } + if _, ok := errObj["details"].(map[string]any); !ok { + t.Fatalf("expected details object, got %#v", errObj["details"]) + } +} + +func TestMCPInvalidJSONReturnsValidationError(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/mcp", bytes.NewBufferString(`{"tool":"projects.list"} {"extra":true}`)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + if out["ok"] != false { + t.Fatalf("expected ok=false, got %#v", out["ok"]) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go new file mode 100644 index 0000000..dda2517 --- /dev/null +++ b/internal/mcp/errors.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "errors" + "net/http" + + "scrumboy/internal/store" +) + +const ( + CodeAuthRequired = "AUTH_REQUIRED" + CodeForbidden = "FORBIDDEN" + CodeNotFound = "NOT_FOUND" + CodeValidationError = "VALIDATION_ERROR" + CodeConflict = "CONFLICT" + CodeCapabilityUnavailable = "CAPABILITY_UNAVAILABLE" + CodeInternal = "INTERNAL" + CodeMethodNotAllowed = "METHOD_NOT_ALLOWED" +) + +type adapterError struct { + Status int + Code string + Message string + Details any +} + +func (e *adapterError) Error() string { + return e.Message +} + +func newAdapterError(status int, code, message string, details any) *adapterError { + return &adapterError{ + Status: status, + Code: code, + Message: message, + Details: details, + } +} + +func mapStoreError(err error) *adapterError { + switch { + case errors.Is(err, store.ErrUnauthorized): + return newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + case errors.Is(err, store.ErrNotFound): + return newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + case errors.Is(err, store.ErrValidation): + return newAdapterError(http.StatusBadRequest, CodeValidationError, err.Error(), nil) + case errors.Is(err, store.ErrConflict): + return newAdapterError(http.StatusConflict, CodeConflict, err.Error(), nil) + default: + return newAdapterError(http.StatusInternalServerError, CodeInternal, "internal error", map[string]any{"detail": err.Error()}) + } +} diff --git a/internal/mcp/http_handler.go b/internal/mcp/http_handler.go new file mode 100644 index 0000000..38fb02f --- /dev/null +++ b/internal/mcp/http_handler.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +func (a *Adapter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + + if r.URL.Path != "/mcp" && r.URL.Path != "/mcp/" { + writeError(w, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil)) + return + } + + if r.Method == http.MethodGet { + ctx := a.requestContext(r) + data, meta, err := a.handleSystemGetCapabilities(ctx, nil) + if err != nil { + writeError(w, err) + return + } + writeSuccess(w, http.StatusOK, data, meta) + return + } + + if r.Method != http.MethodPost { + writeError(w, newAdapterError(http.StatusMethodNotAllowed, CodeMethodNotAllowed, "method not allowed", nil)) + return + } + + var req requestEnvelope + if err := readJSON(r, &req); err != nil { + writeError(w, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid json", map[string]any{"detail": err.Error()})) + return + } + if req.Tool == "" { + writeError(w, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing tool", map[string]any{"field": "tool"})) + return + } + + handler, ok := a.tools[req.Tool] + if !ok { + writeError(w, newAdapterError(http.StatusNotFound, CodeNotFound, "tool not found", map[string]any{"tool": req.Tool})) + return + } + + ctx := a.requestContext(r) + data, meta, toolErr := handler(ctx, req.Input) + if toolErr != nil { + writeError(w, toolErr) + return + } + writeSuccess(w, http.StatusOK, data, meta) +} + +func readJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("extra json data") + } + return err + } + return nil +} + +func writeSuccess(w http.ResponseWriter, status int, data any, meta map[string]any) { + if meta == nil { + meta = map[string]any{} + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(successResponse{ + OK: true, + Data: data, + Meta: meta, + }) +} + +func writeError(w http.ResponseWriter, err *adapterError) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(err.Status) + _ = json.NewEncoder(w).Encode(errorResponse{ + OK: false, + Error: errorResponseBody{ + Code: err.Code, + Message: err.Message, + Details: normalizeDetails(err.Details), + }, + }) +} + +func normalizeDetails(v any) any { + if v == nil { + return map[string]any{} + } + return v +} diff --git a/internal/mcp/projects_tools.go b/internal/mcp/projects_tools.go new file mode 100644 index 0000000..6fa5205 --- /dev/null +++ b/internal/mcp/projects_tools.go @@ -0,0 +1,50 @@ +package mcp + +import ( + "context" + + "scrumboy/internal/store" +) + +func (a *Adapter) handleProjectsList(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(403, CodeCapabilityUnavailable, "projects.list is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(403, CodeCapabilityUnavailable, "projects.list is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(401, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + entries, listErr := a.store.ListProjects(ctx) + if listErr != nil { + return nil, nil, mapStoreError(listErr) + } + + items := make([]projectItem, 0, len(entries)) + for _, entry := range entries { + items = append(items, projectListEntryToItem(entry)) + } + + return map[string]any{"items": items}, map[string]any{}, nil +} + +func projectListEntryToItem(entry store.ProjectListEntry) projectItem { + return projectItem{ + ProjectSlug: entry.Project.Slug, + ProjectID: entry.Project.ID, + Name: entry.Project.Name, + Image: entry.Project.Image, + DominantColor: entry.Project.DominantColor, + DefaultSprintWeeks: entry.Project.DefaultSprintWeeks, + ExpiresAt: entry.Project.ExpiresAt, + CreatedAt: entry.Project.CreatedAt, + UpdatedAt: entry.Project.UpdatedAt, + Role: entry.Role.String(), + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go new file mode 100644 index 0000000..1a20b8a --- /dev/null +++ b/internal/mcp/registry.go @@ -0,0 +1,29 @@ +package mcp + +import "context" + +type toolHandler func(ctx context.Context, input any) (any, map[string]any, *adapterError) + +type toolRegistry map[string]toolHandler + +func (a *Adapter) registerTools() { + a.tools["system.getCapabilities"] = a.handleSystemGetCapabilities + a.tools["projects.list"] = a.handleProjectsList + a.tools["todos.create"] = a.handleTodosCreate + a.tools["todos.get"] = a.handleTodosGet + a.tools["todos.search"] = a.handleTodosSearch + a.tools["todos.update"] = a.handleTodosUpdate + a.tools["todos.delete"] = a.handleTodosDelete + a.tools["todos.move"] = a.handleTodosMove + a.tools["sprints.list"] = a.handleSprintsList + a.tools["sprints.get"] = a.handleSprintsGet + a.tools["sprints.getActive"] = a.handleSprintsGetActive + a.tools["sprints.create"] = a.handleSprintsCreate + a.tools["sprints.activate"] = a.handleSprintsActivate + a.tools["sprints.close"] = a.handleSprintsClose + a.tools["sprints.update"] = a.handleSprintsUpdate + a.tools["sprints.delete"] = a.handleSprintsDelete + a.tools["tags.listProject"] = a.handleTagsListProject + a.tools["tags.listMine"] = a.handleTagsListMine + a.tools["tags.updateMineColor"] = a.handleTagsUpdateMineColor +} diff --git a/internal/mcp/sprint_tools.go b/internal/mcp/sprint_tools.go new file mode 100644 index 0000000..f0479da --- /dev/null +++ b/internal/mcp/sprint_tools.go @@ -0,0 +1,557 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "scrumboy/internal/store" +) + +type sprintProjectInput struct { + ProjectSlug string `json:"projectSlug"` +} + +type sprintGetInput struct { + ProjectSlug string `json:"projectSlug"` + SprintID int64 `json:"sprintId"` +} + +type sprintCreateInput struct { + ProjectSlug string `json:"projectSlug"` + Name string `json:"name"` + PlannedStartAt string `json:"plannedStartAt"` + PlannedEndAt string `json:"plannedEndAt"` +} + +type sprintUpdateEnvelope struct { + ProjectSlug string `json:"projectSlug"` + SprintID int64 `json:"sprintId"` + Patch json.RawMessage `json:"patch"` +} + +func (a *Adapter) handleSprintsList(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.list is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.list is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintProjectInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + sprints, listErr := a.store.ListSprintsWithTodoCount(ctx, pc.Project.ID) + if listErr != nil { + return nil, nil, mapStoreError(listErr) + } + unscheduledCount, countErr := a.store.CountUnscheduledTodos(ctx, pc.Project.ID) + if countErr != nil { + return nil, nil, mapStoreError(countErr) + } + + items := make([]sprintItem, 0, len(sprints)) + for _, sp := range sprints { + todoCount := sp.TodoCount + items = append(items, sprintToItem(in.ProjectSlug, sp.Sprint, &todoCount)) + } + + return map[string]any{ + "items": items, + }, map[string]any{ + "unscheduledCount": unscheduledCount, + }, nil +} + +func (a *Adapter) handleSprintsGet(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.get is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.get is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintGetInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.SprintID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid sprintId", map[string]any{"field": "sprintId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + sp, getErr := a.store.GetSprintByID(ctx, in.SprintID) + if getErr != nil { + return nil, nil, mapStoreError(getErr) + } + if sp.ProjectID != pc.Project.ID { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + return map[string]any{ + "sprint": sprintToItem(in.ProjectSlug, sp, nil), + }, map[string]any{}, nil +} + +func (a *Adapter) handleSprintsGetActive(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.getActive is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.getActive is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintProjectInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + sp, activeErr := a.store.GetActiveSprintByProjectID(ctx, pc.Project.ID) + if activeErr != nil { + return nil, nil, mapStoreError(activeErr) + } + if sp == nil { + return map[string]any{ + "sprint": nil, + }, map[string]any{}, nil + } + if sp.ProjectID != pc.Project.ID { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + return map[string]any{ + "sprint": sprintToItem(in.ProjectSlug, *sp, nil), + }, map[string]any{}, nil +} + +func (a *Adapter) handleSprintsCreate(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.create is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.create is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintCreateInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.Name == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing name", map[string]any{"field": "name"}) + } + if in.PlannedStartAt == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing plannedStartAt", map[string]any{"field": "plannedStartAt"}) + } + if in.PlannedEndAt == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing plannedEndAt", map[string]any{"field": "plannedEndAt"}) + } + + plannedStartAt, parseErr := time.Parse(time.RFC3339, in.PlannedStartAt) + if parseErr != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid plannedStartAt", map[string]any{"field": "plannedStartAt", "detail": parseErr.Error()}) + } + plannedEndAt, parseErr := time.Parse(time.RFC3339, in.PlannedEndAt) + if parseErr != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid plannedEndAt", map[string]any{"field": "plannedEndAt", "detail": parseErr.Error()}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + role, roleErr := a.store.GetProjectRole(ctx, pc.Project.ID, userID) + if roleErr != nil { + return nil, nil, mapStoreError(roleErr) + } + if !role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + sp, createErr := a.store.CreateSprint(ctx, pc.Project.ID, in.Name, plannedStartAt, plannedEndAt) + if createErr != nil { + return nil, nil, mapStoreError(createErr) + } + + return map[string]any{ + "sprint": sprintToItem(in.ProjectSlug, sp, nil), + }, map[string]any{}, nil +} + +func (a *Adapter) handleSprintsUpdate(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.update is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.update is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var env sprintUpdateEnvelope + if err := decodeInput(input, &env); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if env.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if env.SprintID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid sprintId", map[string]any{"field": "sprintId"}) + } + if len(env.Patch) == 0 || string(env.Patch) == "null" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing patch", map[string]any{"field": "patch"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, env.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + role, roleErr := a.store.GetProjectRole(ctx, pc.Project.ID, userID) + if roleErr != nil { + return nil, nil, mapStoreError(roleErr) + } + if !role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + sp, getErr := a.store.GetSprintByID(ctx, env.SprintID) + if getErr != nil { + return nil, nil, mapStoreError(getErr) + } + if sp.ProjectID != pc.Project.ID { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + updateIn, changed, patchErr := buildSprintUpdateInput(env.Patch) + if patchErr != nil { + return nil, nil, patchErr + } + if !changed { + return map[string]any{ + "sprint": sprintToItem(env.ProjectSlug, sp, nil), + }, map[string]any{}, nil + } + + if err := a.store.UpdateSprint(ctx, env.SprintID, updateIn); err != nil { + return nil, nil, mapStoreError(err) + } + updated, updatedErr := a.store.GetSprintByID(ctx, env.SprintID) + if updatedErr != nil { + return nil, nil, mapStoreError(updatedErr) + } + + return map[string]any{ + "sprint": sprintToItem(env.ProjectSlug, updated, nil), + }, map[string]any{}, nil +} + +func (a *Adapter) handleSprintsActivate(ctx context.Context, input any) (any, map[string]any, *adapterError) { + return a.handleSprintAction(ctx, input, "activate") +} + +func (a *Adapter) handleSprintsClose(ctx context.Context, input any) (any, map[string]any, *adapterError) { + return a.handleSprintAction(ctx, input, "close") +} + +func (a *Adapter) handleSprintsDelete(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.delete is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "sprints.delete is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintGetInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.SprintID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid sprintId", map[string]any{"field": "sprintId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + role, roleErr := a.store.GetProjectRole(ctx, pc.Project.ID, userID) + if roleErr != nil { + return nil, nil, mapStoreError(roleErr) + } + if !role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + sp, getErr := a.store.GetSprintByID(ctx, in.SprintID) + if getErr != nil { + return nil, nil, mapStoreError(getErr) + } + if sp.ProjectID != pc.Project.ID { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + if err := a.store.DeleteSprint(ctx, pc.Project.ID, in.SprintID); err != nil { + return nil, nil, mapStoreError(err) + } + + return map[string]any{ + "status": "deleted", + "projectSlug": in.ProjectSlug, + "sprintId": in.SprintID, + }, map[string]any{}, nil +} + +func (a *Adapter) handleSprintAction(ctx context.Context, input any, action string) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + toolName := "sprints." + action + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, toolName+" is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, toolName+" is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintGetInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.SprintID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid sprintId", map[string]any{"field": "sprintId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + role, roleErr := a.store.GetProjectRole(ctx, pc.Project.ID, userID) + if roleErr != nil { + return nil, nil, mapStoreError(roleErr) + } + if !role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + sp, getErr := a.store.GetSprintByID(ctx, in.SprintID) + if getErr != nil { + return nil, nil, mapStoreError(getErr) + } + if sp.ProjectID != pc.Project.ID { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + switch action { + case "activate": + if sp.State != store.SprintStatePlanned { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "sprint must be PLANNED to activate", map[string]any{"field": "sprintId"}) + } + if !sp.PlannedEndAt.After(time.Now().UTC()) { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "sprint end date is on or before now; cannot activate", map[string]any{"field": "plannedEndAt"}) + } + if err := a.store.ActivateSprint(ctx, pc.Project.ID, in.SprintID); err != nil { + return nil, nil, mapStoreError(err) + } + case "close": + if sp.State != store.SprintStateActive { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "sprint must be ACTIVE to close", map[string]any{"field": "sprintId"}) + } + if err := a.store.CloseSprint(ctx, in.SprintID); err != nil { + return nil, nil, mapStoreError(err) + } + default: + return nil, nil, newAdapterError(http.StatusInternalServerError, CodeInternal, "internal error", map[string]any{"detail": "unknown sprint action"}) + } + + updated, updatedErr := a.store.GetSprintByID(ctx, in.SprintID) + if updatedErr != nil { + return nil, nil, mapStoreError(updatedErr) + } + + return map[string]any{ + "sprint": sprintToItem(in.ProjectSlug, updated, nil), + }, map[string]any{}, nil +} + +func sprintToItem(projectSlug string, sp store.Sprint, todoCount *int64) sprintItem { + var startedAt *int64 + if sp.StartedAt != nil { + v := sp.StartedAt.UnixMilli() + startedAt = &v + } + var closedAt *int64 + if sp.ClosedAt != nil { + v := sp.ClosedAt.UnixMilli() + closedAt = &v + } + return sprintItem{ + ProjectSlug: projectSlug, + SprintID: sp.ID, + Number: sp.Number, + Name: sp.Name, + PlannedStartAt: sp.PlannedStartAt.UnixMilli(), + PlannedEndAt: sp.PlannedEndAt.UnixMilli(), + StartedAt: startedAt, + ClosedAt: closedAt, + State: sp.State, + TodoCount: todoCount, + } +} + +func buildSprintUpdateInput(patchRaw json.RawMessage) (store.UpdateSprintInput, bool, *adapterError) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(patchRaw, &raw); err != nil { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid patch", map[string]any{"detail": err.Error()}) + } + if raw == nil { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid patch", map[string]any{"field": "patch"}) + } + + allowed := map[string]struct{}{ + "name": {}, + "plannedStartAt": {}, + "plannedEndAt": {}, + } + for key := range raw { + if _, ok := allowed[key]; !ok { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "unsupported patch field", map[string]any{"field": key}) + } + } + + var in store.UpdateSprintInput + changed := false + + if v, ok := raw["name"]; ok { + if isNullJSON(v) { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "name cannot be null", map[string]any{"field": "name"}) + } + var name string + if err := json.Unmarshal(v, &name); err != nil { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid name", map[string]any{"field": "name"}) + } + in.Name = &name + changed = true + } + + if v, ok := raw["plannedStartAt"]; ok { + if isNullJSON(v) { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "plannedStartAt cannot be null", map[string]any{"field": "plannedStartAt"}) + } + var ms int64 + if err := json.Unmarshal(v, &ms); err != nil { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid plannedStartAt", map[string]any{"field": "plannedStartAt"}) + } + t := time.UnixMilli(ms).UTC() + in.PlannedStartAt = &t + changed = true + } + + if v, ok := raw["plannedEndAt"]; ok { + if isNullJSON(v) { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "plannedEndAt cannot be null", map[string]any{"field": "plannedEndAt"}) + } + var ms int64 + if err := json.Unmarshal(v, &ms); err != nil { + return store.UpdateSprintInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid plannedEndAt", map[string]any{"field": "plannedEndAt"}) + } + t := time.UnixMilli(ms).UTC() + in.PlannedEndAt = &t + changed = true + } + + return in, changed, nil +} diff --git a/internal/mcp/system_tools.go b/internal/mcp/system_tools.go new file mode 100644 index 0000000..018c60b --- /dev/null +++ b/internal/mcp/system_tools.go @@ -0,0 +1,29 @@ +package mcp + +import "context" + +func (a *Adapter) handleSystemGetCapabilities(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + data := capabilitiesData{ + ServerMode: a.mode, + Auth: auth, + BootstrapAvailable: bootstrapAvailable, + Identity: identityCapabilities{ + Project: "projectSlug", + Todo: []string{"projectSlug", "localId"}, + }, + Pagination: paginationCapabilities{ + DefaultInput: []string{"limit", "cursor"}, + DefaultOutput: []string{"nextCursor", "hasMore"}, + FutureSpecialCases: []string{"board.get"}, + }, + ImplementedTools: a.implementedTools(), + PlannedTools: a.plannedTools(), + } + + return data, map[string]any{"adapterVersion": 1}, nil +} diff --git a/internal/mcp/tag_tools.go b/internal/mcp/tag_tools.go new file mode 100644 index 0000000..cf5e7cd --- /dev/null +++ b/internal/mcp/tag_tools.go @@ -0,0 +1,180 @@ +package mcp + +import ( + "context" + "errors" + "net/http" + + "scrumboy/internal/store" +) + +type updateMineTagColorInput struct { + TagID int64 `json:"tagId"` + Color *string `json:"color"` +} + +func (a *Adapter) handleTagsListProject(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.listProject is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.listProject is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintProjectInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + tags, tagsErr := a.store.ListTagCounts(ctx, &pc) + if tagsErr != nil { + return nil, nil, mapStoreError(tagsErr) + } + + items := make([]projectTagItem, 0, len(tags)) + for _, tag := range tags { + items = append(items, projectTagItem{ + TagID: tag.TagID, + Name: tag.Name, + Count: tag.Count, + Color: tag.Color, + CanDelete: tag.CanDelete, + }) + } + + return map[string]any{ + "items": items, + }, map[string]any{}, nil +} + +func (a *Adapter) handleTagsListMine(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.listMine is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.listMine is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + tags, tagsErr := a.store.ListUserTags(ctx, userID) + if tagsErr != nil { + return nil, nil, mapStoreError(tagsErr) + } + + items := make([]mineTagItem, 0, len(tags)) + for _, tag := range tags { + items = append(items, mineTagItem{ + TagID: tag.TagID, + Name: tag.Name, + Color: tag.Color, + CanDelete: tag.CanDelete, + }) + } + + return map[string]any{ + "items": items, + }, map[string]any{}, nil +} + +func (a *Adapter) handleTagsUpdateMineColor(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.updateMineColor is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.updateMineColor is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in updateMineTagColorInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.TagID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tagId", map[string]any{"field": "tagId"}) + } + + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + tags, tagsErr := a.store.ListUserTags(ctx, userID) + if tagsErr != nil { + return nil, nil, mapStoreError(tagsErr) + } + tag, found := findMineTag(tags, in.TagID) + if !found { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + updateErr := a.store.UpdateTagColor(ctx, &userID, in.TagID, in.Color) + if updateErr != nil { + // Clearing a color preference when none exists is a harmless no-op for this + // mine-scope MCP tool; normalize the store quirk into a successful clear. + if !(isColorClear(in.Color) && errors.Is(updateErr, store.ErrNotFound)) { + return nil, nil, mapStoreError(updateErr) + } + } + + tag.Color = normalizedMineColor(in.Color) + return map[string]any{ + "tag": mineTagItem{ + TagID: tag.TagID, + Name: tag.Name, + Color: tag.Color, + CanDelete: tag.CanDelete, + }, + }, map[string]any{}, nil +} + +func findMineTag(tags []store.TagWithColor, tagID int64) (store.TagWithColor, bool) { + for _, tag := range tags { + if tag.TagID == tagID { + return tag, true + } + } + return store.TagWithColor{}, false +} + +func isColorClear(color *string) bool { + return color == nil || *color == "" +} + +func normalizedMineColor(color *string) *string { + if isColorClear(color) { + return nil + } + return color +} diff --git a/internal/mcp/todos_tools.go b/internal/mcp/todos_tools.go new file mode 100644 index 0000000..b327fa7 --- /dev/null +++ b/internal/mcp/todos_tools.go @@ -0,0 +1,649 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "scrumboy/internal/store" +) + +type createTodoInput struct { + ProjectSlug string `json:"projectSlug"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + ColumnKey string `json:"columnKey"` + EstimationPoints *int64 `json:"estimationPoints"` + SprintId *int64 `json:"sprintId"` + AssigneeUserId *int64 `json:"assigneeUserId"` + Position *struct { + AfterLocalId *int64 `json:"afterLocalId"` + BeforeLocalId *int64 `json:"beforeLocalId"` + } `json:"position"` +} + +type getTodoInput struct { + ProjectSlug string `json:"projectSlug"` + LocalID int64 `json:"localId"` +} + +type searchTodosInput struct { + ProjectSlug string `json:"projectSlug"` + Query string `json:"query"` + Limit *int `json:"limit"` + ExcludeLocalIds []int64 `json:"excludeLocalIds"` +} + +type updateTodoEnvelope struct { + ProjectSlug string `json:"projectSlug"` + LocalID int64 `json:"localId"` + Patch json.RawMessage `json:"patch"` +} + +type deleteTodoInput struct { + ProjectSlug string `json:"projectSlug"` + LocalID int64 `json:"localId"` +} + +type moveTodoInput struct { + ProjectSlug string `json:"projectSlug"` + LocalID int64 `json:"localId"` + ToColumnKey string `json:"toColumnKey"` + AfterLocalId *int64 `json:"afterLocalId"` + BeforeLocalId *int64 `json:"beforeLocalId"` +} + +func (a *Adapter) handleTodosCreate(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.create is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.create is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in createTodoInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + columnKey := normalizeColumnKey(in.ColumnKey) + if columnKey == "" { + columnKey = store.DefaultColumnBacklog + } + + afterID, beforeID, posErr := a.resolvePositionIDs(ctx, pc.Project.ID, in.Position, a.storeMode(), columnKey) + if posErr != nil { + return nil, nil, posErr + } + + todo, createErr := a.store.CreateTodo(ctx, pc.Project.ID, store.CreateTodoInput{ + Title: in.Title, + Body: in.Body, + Tags: in.Tags, + ColumnKey: columnKey, + EstimationPoints: in.EstimationPoints, + SprintID: in.SprintId, + AssigneeUserID: in.AssigneeUserId, + AfterID: afterID, + BeforeID: beforeID, + }, a.storeMode()) + if createErr != nil { + if errors.Is(createErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(createErr) + } + + return map[string]any{ + "todo": todoToItem(in.ProjectSlug, todo), + }, map[string]any{}, nil +} + +func (a *Adapter) handleTodosGet(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.get is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.get is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in getTodoInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.LocalID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid localId", map[string]any{"field": "localId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + todo, getErr := a.store.GetTodoByLocalID(ctx, pc.Project.ID, in.LocalID, a.storeMode()) + if getErr != nil { + if errors.Is(getErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(getErr) + } + + return map[string]any{ + "todo": todoToItem(in.ProjectSlug, todo), + }, map[string]any{}, nil +} + +func (a *Adapter) handleTodosSearch(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.search is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.search is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in searchTodosInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + limit := 20 + if in.Limit != nil { + if *in.Limit <= 0 || *in.Limit > 50 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid limit", map[string]any{"field": "limit"}) + } + limit = *in.Limit + } + for _, id := range in.ExcludeLocalIds { + if id <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid excludeLocalIds", map[string]any{"field": "excludeLocalIds"}) + } + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + items, searchErr := a.store.SearchTodosForLinkPicker(ctx, pc.Project.ID, strings.TrimSpace(in.Query), limit, in.ExcludeLocalIds, a.storeMode()) + if searchErr != nil { + if errors.Is(searchErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(searchErr) + } + + out := make([]todoSearchItem, 0, len(items)) + for _, item := range items { + out = append(out, todoSearchItem{ + ProjectSlug: in.ProjectSlug, + LocalID: item.LocalID, + Title: item.Title, + }) + } + + return map[string]any{ + "items": out, + }, map[string]any{}, nil +} + +func (a *Adapter) handleTodosUpdate(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.update is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.update is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var env updateTodoEnvelope + if err := decodeInput(input, &env); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if env.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if env.LocalID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid localId", map[string]any{"field": "localId"}) + } + if len(env.Patch) == 0 || string(env.Patch) == "null" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing patch", map[string]any{"field": "patch"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, env.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + existing, getErr := a.store.GetTodoByLocalID(ctx, pc.Project.ID, env.LocalID, a.storeMode()) + if getErr != nil { + if errors.Is(getErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(getErr) + } + + updateIn, changed, patchErr := buildUpdateTodoInput(existing, env.Patch) + if patchErr != nil { + return nil, nil, patchErr + } + if !changed { + return map[string]any{ + "todo": todoToItem(env.ProjectSlug, existing), + }, map[string]any{}, nil + } + + todo, updateErr := a.store.UpdateTodoByLocalID(ctx, pc.Project.ID, env.LocalID, updateIn, a.storeMode()) + if updateErr != nil { + if errors.Is(updateErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(updateErr) + } + + return map[string]any{ + "todo": todoToItem(env.ProjectSlug, todo), + }, map[string]any{}, nil +} + +func (a *Adapter) handleTodosDelete(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.delete is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.delete is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in deleteTodoInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.LocalID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid localId", map[string]any{"field": "localId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + deleteErr := a.store.DeleteTodoByLocalID(ctx, pc.Project.ID, in.LocalID, a.storeMode()) + if deleteErr != nil { + if errors.Is(deleteErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(deleteErr) + } + + return map[string]any{ + "status": "deleted", + "projectSlug": in.ProjectSlug, + "localId": in.LocalID, + }, map[string]any{}, nil +} + +func (a *Adapter) handleTodosMove(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.move is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "todos.move is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in moveTodoInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.LocalID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid localId", map[string]any{"field": "localId"}) + } + if in.AfterLocalId != nil && in.BeforeLocalId != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "at most one neighbor reference may be set", map[string]any{"fields": []string{"afterLocalId", "beforeLocalId"}}) + } + if in.AfterLocalId != nil && *in.AfterLocalId == in.LocalID { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "afterLocalId cannot equal localId", map[string]any{"field": "afterLocalId"}) + } + if in.BeforeLocalId != nil && *in.BeforeLocalId == in.LocalID { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "beforeLocalId cannot equal localId", map[string]any{"field": "beforeLocalId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + movingTodo, getErr := a.store.GetTodoByLocalID(ctx, pc.Project.ID, in.LocalID, a.storeMode()) + if getErr != nil { + if errors.Is(getErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, nil, mapStoreError(getErr) + } + + toColumnKey := normalizeColumnKey(in.ToColumnKey) + if toColumnKey == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing toColumnKey", map[string]any{"field": "toColumnKey"}) + } + + afterTodo, err := a.resolveLocalTodoForColumn(ctx, pc.Project.ID, in.AfterLocalId, "afterLocalId", a.storeMode(), toColumnKey) + if err != nil { + return nil, nil, err + } + beforeTodo, err := a.resolveLocalTodoForColumn(ctx, pc.Project.ID, in.BeforeLocalId, "beforeLocalId", a.storeMode(), toColumnKey) + if err != nil { + return nil, nil, err + } + if err := a.validateMoveAnchors(ctx, pc.Project.ID, toColumnKey, afterTodo, beforeTodo, a.storeMode()); err != nil { + return nil, nil, err + } + + todo, moveErr := a.store.MoveTodoByLocalID(ctx, pc.Project.ID, movingTodo.LocalID, toColumnKey, in.AfterLocalId, in.BeforeLocalId, a.storeMode()) + if moveErr != nil { + if errors.Is(moveErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + if errors.Is(moveErr, store.ErrNotFound) && (in.AfterLocalId != nil || in.BeforeLocalId != nil) { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid neighbor reference", map[string]any{}) + } + return nil, nil, mapStoreError(moveErr) + } + + return map[string]any{ + "todo": todoToItem(in.ProjectSlug, todo), + }, map[string]any{}, nil +} + +func buildUpdateTodoInput(existing store.Todo, patchRaw json.RawMessage) (store.UpdateTodoInput, bool, *adapterError) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(patchRaw, &raw); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid patch", map[string]any{"detail": err.Error()}) + } + if raw == nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid patch", map[string]any{"field": "patch"}) + } + + allowed := map[string]struct{}{ + "title": {}, + "body": {}, + "tags": {}, + "estimationPoints": {}, + "assigneeUserId": {}, + "sprintId": {}, + } + for key := range raw { + if _, ok := allowed[key]; !ok { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "unsupported patch field", map[string]any{"field": key}) + } + } + + in := store.UpdateTodoInput{ + Title: existing.Title, + Body: existing.Body, + Tags: cloneStrings(existing.Tags), + EstimationPoints: cloneInt64(existing.EstimationPoints), + AssigneeUserID: cloneInt64(existing.AssigneeUserID), + SprintID: cloneInt64(existing.SprintID), + } + changed := false + + if v, ok := raw["title"]; ok { + if isNullJSON(v) { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "title cannot be null", map[string]any{"field": "title"}) + } + var title string + if err := json.Unmarshal(v, &title); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid title", map[string]any{"field": "title"}) + } + in.Title = title + changed = true + } + + if v, ok := raw["body"]; ok { + if isNullJSON(v) { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "body cannot be null", map[string]any{"field": "body"}) + } + var body string + if err := json.Unmarshal(v, &body); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid body", map[string]any{"field": "body"}) + } + in.Body = body + changed = true + } + + if v, ok := raw["tags"]; ok { + if isNullJSON(v) { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "tags cannot be null", map[string]any{"field": "tags"}) + } + var tags []string + if err := json.Unmarshal(v, &tags); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tags", map[string]any{"field": "tags"}) + } + in.Tags = tags + changed = true + } + + if v, ok := raw["estimationPoints"]; ok { + if isNullJSON(v) { + in.EstimationPoints = nil + } else { + var points int64 + if err := json.Unmarshal(v, &points); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid estimationPoints", map[string]any{"field": "estimationPoints"}) + } + in.EstimationPoints = &points + } + changed = true + } + + if v, ok := raw["assigneeUserId"]; ok { + if isNullJSON(v) { + in.AssigneeUserID = nil + } else { + var assignee int64 + if err := json.Unmarshal(v, &assignee); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid assigneeUserId", map[string]any{"field": "assigneeUserId"}) + } + in.AssigneeUserID = &assignee + } + changed = true + } + + if v, ok := raw["sprintId"]; ok { + if isNullJSON(v) { + in.SprintID = nil + in.ClearSprint = true + } else { + var sprintID int64 + if err := json.Unmarshal(v, &sprintID); err != nil { + return store.UpdateTodoInput{}, false, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid sprintId", map[string]any{"field": "sprintId"}) + } + in.SprintID = &sprintID + in.ClearSprint = false + } + changed = true + } + + return in, changed, nil +} + +func isNullJSON(v json.RawMessage) bool { + return strings.TrimSpace(string(v)) == "null" +} + +func cloneStrings(v []string) []string { + if v == nil { + return nil + } + out := make([]string, len(v)) + copy(out, v) + return out +} + +func cloneInt64(v *int64) *int64 { + if v == nil { + return nil + } + c := *v + return &c +} + +func (a *Adapter) resolvePositionIDs(ctx context.Context, projectID int64, position *struct { + AfterLocalId *int64 `json:"afterLocalId"` + BeforeLocalId *int64 `json:"beforeLocalId"` +}, mode store.Mode, columnKey string) (*int64, *int64, *adapterError) { + if position == nil { + return nil, nil, nil + } + + afterTodo, err := a.resolveLocalTodoForColumn(ctx, projectID, position.AfterLocalId, "afterLocalId", mode, columnKey) + if err != nil { + return nil, nil, err + } + beforeTodo, err := a.resolveLocalTodoForColumn(ctx, projectID, position.BeforeLocalId, "beforeLocalId", mode, columnKey) + if err != nil { + return nil, nil, err + } + var afterID, beforeID *int64 + if afterTodo != nil { + id := afterTodo.ID + afterID = &id + } + if beforeTodo != nil { + id := beforeTodo.ID + beforeID = &id + } + return afterID, beforeID, nil +} + +func (a *Adapter) resolveLocalTodoForColumn(ctx context.Context, projectID int64, localID *int64, field string, mode store.Mode, targetColumnKey string) (*store.Todo, *adapterError) { + if localID == nil { + return nil, nil + } + if *localID <= 0 { + return nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid local todo reference", map[string]any{"field": field}) + } + + todo, err := a.store.GetTodoByLocalID(ctx, projectID, *localID, mode) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid local todo reference", map[string]any{"field": field, "localId": *localID}) + } + if errors.Is(err, store.ErrUnauthorized) { + return nil, newAdapterError(http.StatusForbidden, CodeForbidden, "forbidden", nil) + } + return nil, mapStoreError(err) + } + if todo.ColumnKey != targetColumnKey { + return nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "position reference must be in target column", map[string]any{"field": field, "localId": *localID}) + } + return &todo, nil +} + +func (a *Adapter) validateMoveAnchors(ctx context.Context, projectID int64, columnKey string, afterTodo, beforeTodo *store.Todo, mode store.Mode) *adapterError { + // One-sided anchors are intentionally stricter than the raw store API here. + // The backend rank logic can place "after X" or "before Y" non-adjacently when + // there are additional todos on the far side, so MCP only accepts anchors that + // are already at the boundary of the target column. + if afterTodo != nil { + items, _, _, err := a.store.ListTodosForBoardLane(ctx, projectID, columnKey, 1, afterTodo.Rank, afterTodo.ID, "", "", store.SprintFilter{}) + if err != nil { + return mapStoreError(err) + } + if len(items) > 0 { + return newAdapterError(http.StatusBadRequest, CodeValidationError, "afterLocalId is ambiguous unless it is already the last item in the target column", map[string]any{"field": "afterLocalId", "localId": afterTodo.LocalID}) + } + } + + if beforeTodo != nil { + const laneStartRank int64 = -1 << 63 + items, _, _, err := a.store.ListTodosForBoardLane(ctx, projectID, columnKey, 1, laneStartRank, 0, "", "", store.SprintFilter{}) + if err != nil { + return mapStoreError(err) + } + if len(items) > 0 && items[0].LocalID != beforeTodo.LocalID { + return newAdapterError(http.StatusBadRequest, CodeValidationError, "beforeLocalId is ambiguous unless it is already the first item in the target column", map[string]any{"field": "beforeLocalId", "localId": beforeTodo.LocalID}) + } + } + + return nil +} + +func todoToItem(projectSlug string, todo store.Todo) todoItem { + return todoItem{ + ProjectSlug: projectSlug, + LocalID: todo.LocalID, + Title: todo.Title, + Body: todo.Body, + ColumnKey: todo.ColumnKey, + Tags: todo.Tags, + EstimationPoints: todo.EstimationPoints, + AssigneeUserId: todo.AssigneeUserID, + SprintId: todo.SprintID, + CreatedAt: todo.CreatedAt, + UpdatedAt: todo.UpdatedAt, + DoneAt: todo.DoneAt, + } +} diff --git a/internal/mcp/types.go b/internal/mcp/types.go new file mode 100644 index 0000000..1e4aff4 --- /dev/null +++ b/internal/mcp/types.go @@ -0,0 +1,115 @@ +package mcp + +import "time" + +type successResponse struct { + OK bool `json:"ok"` + Data any `json:"data"` + Meta map[string]any `json:"meta"` +} + +type errorResponse struct { + OK bool `json:"ok"` + Error errorResponseBody `json:"error"` +} + +type errorResponseBody struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details"` +} + +type requestEnvelope struct { + Tool string `json:"tool"` + Input any `json:"input"` +} + +type authCapabilities struct { + Mode string `json:"mode"` + Authenticated bool `json:"authenticated"` + AuthenticatedToolsUsable bool `json:"authenticatedToolsUsable"` + Reason *string `json:"reason,omitempty"` +} + +type identityCapabilities struct { + Project string `json:"project"` + Todo []string `json:"todo"` +} + +type paginationCapabilities struct { + DefaultInput []string `json:"defaultInput"` + DefaultOutput []string `json:"defaultOutput"` + FutureSpecialCases []string `json:"futureSpecialCases,omitempty"` +} + +type capabilitiesData struct { + ServerMode string `json:"serverMode"` + Auth authCapabilities `json:"auth"` + BootstrapAvailable bool `json:"bootstrapAvailable"` + Identity identityCapabilities `json:"identity"` + Pagination paginationCapabilities `json:"pagination"` + ImplementedTools []string `json:"implementedTools"` + PlannedTools []string `json:"plannedTools,omitempty"` +} + +type projectItem struct { + ProjectSlug string `json:"projectSlug"` + ProjectID int64 `json:"projectId"` + Name string `json:"name"` + Image *string `json:"image"` + DominantColor string `json:"dominantColor"` + DefaultSprintWeeks int `json:"defaultSprintWeeks"` + ExpiresAt *time.Time `json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Role string `json:"role,omitempty"` +} + +type todoItem struct { + ProjectSlug string `json:"projectSlug"` + LocalID int64 `json:"localId"` + Title string `json:"title"` + Body string `json:"body"` + ColumnKey string `json:"columnKey"` + Tags []string `json:"tags"` + EstimationPoints *int64 `json:"estimationPoints"` + AssigneeUserId *int64 `json:"assigneeUserId"` + SprintId *int64 `json:"sprintId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DoneAt *time.Time `json:"doneAt"` +} + +type todoSearchItem struct { + ProjectSlug string `json:"projectSlug"` + LocalID int64 `json:"localId"` + Title string `json:"title"` +} + +type sprintItem struct { + ProjectSlug string `json:"projectSlug"` + SprintID int64 `json:"sprintId"` + Number int64 `json:"number"` + Name string `json:"name"` + PlannedStartAt int64 `json:"plannedStartAt"` + PlannedEndAt int64 `json:"plannedEndAt"` + StartedAt *int64 `json:"startedAt"` + ClosedAt *int64 `json:"closedAt"` + State string `json:"state"` + TodoCount *int64 `json:"todoCount"` +} + +type projectTagItem struct { + TagID int64 `json:"tagId"` + Name string `json:"name"` + Count int `json:"count"` + Color *string `json:"color"` + CanDelete bool `json:"canDelete"` +} + +type mineTagItem struct { + TagID int64 `json:"tagId"` + Name string `json:"name"` + Color *string `json:"color"` + CanDelete bool `json:"canDelete"` +} From ed088ef17009998cde1262bf5da63812b57fd911 Mon Sep 17 00:00:00 2001 From: Mark Rai Date: Tue, 31 Mar 2026 11:44:03 -0400 Subject: [PATCH 2/4] Implemented members.list and members members.listAvailable Signed-off-by: Mark Rai --- internal/mcp/adapter.go | 6 + internal/mcp/adapter_test.go | 597 +++++++++++++++++++++++++++++++++- internal/mcp/members_tools.go | 123 +++++++ internal/mcp/registry.go | 3 + internal/mcp/system_tools.go | 6 +- internal/mcp/tag_tools.go | 86 ++++- internal/mcp/types.go | 27 +- internal/store/tags.go | 28 +- 8 files changed, 869 insertions(+), 7 deletions(-) create mode 100644 internal/mcp/members_tools.go diff --git a/internal/mcp/adapter.go b/internal/mcp/adapter.go index 66d7254..b7c6170 100644 --- a/internal/mcp/adapter.go +++ b/internal/mcp/adapter.go @@ -36,6 +36,9 @@ type storeAPI interface { ListTagCounts(ctx context.Context, pc *store.ProjectContext) ([]store.TagCount, error) ListUserTags(ctx context.Context, userID int64) ([]store.TagWithColor, error) UpdateTagColor(ctx context.Context, viewerUserID *int64, tagID int64, color *string) error + GetProjectScopedTagByID(ctx context.Context, projectID, tagID int64) (store.TagWithColor, error) + ListProjectMembers(ctx context.Context, projectID int64, userID int64) ([]store.ProjectMember, error) + ListAvailableUsersForProject(ctx context.Context, requesterID, projectID int64) ([]store.User, error) } type Options struct { @@ -143,6 +146,9 @@ func (a *Adapter) implementedTools() []string { "tags.listProject", "tags.listMine", "tags.updateMineColor", + "tags.updateProjectColor", + "members.list", + "members.listAvailable", } } diff --git a/internal/mcp/adapter_test.go b/internal/mcp/adapter_test.go index 234d356..a937a02 100644 --- a/internal/mcp/adapter_test.go +++ b/internal/mcp/adapter_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/cookiejar" "net/http/httptest" + "net/url" "path/filepath" "testing" "time" @@ -195,6 +196,44 @@ func bootstrapUser(t *testing.T, client *http.Client, baseURL string) { } } +func insertProjectScopedTag(t *testing.T, sqlDB *sql.DB, projectID int64, name string, color *string) int64 { + t.Helper() + nowMs := time.Now().UTC().UnixMilli() + var colorArg any + if color != nil { + colorArg = *color + } + res, err := sqlDB.Exec(`INSERT INTO tags(user_id, project_id, name, created_at, color) VALUES (NULL, ?, ?, ?, ?)`, projectID, name, nowMs, colorArg) + if err != nil { + t.Fatalf("insert project-scoped tag: %v", err) + } + tagID, err := res.LastInsertId() + if err != nil { + t.Fatalf("project tag last insert id: %v", err) + } + return tagID +} + +func newSessionClientForUser(t *testing.T, ts *httptest.Server, st *store.Store, userID int64) *http.Client { + t.Helper() + client := newCookieClient(t, ts) + token, expiresAt, err := st.CreateSession(context.Background(), userID, 24*time.Hour) + if err != nil { + t.Fatalf("create session: %v", err) + } + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + client.Jar.SetCookies(u, []*http.Cookie{{ + Name: "scrumboy_session", + Value: token, + Path: "/", + Expires: expiresAt, + }}) + return client +} + func TestMCPMountDoesNotBreakSPARoutes(t *testing.T) { ts, _, cleanup := newTestServer(t, "full") defer cleanup() @@ -260,7 +299,7 @@ func TestMCPSystemGetCapabilities_FullPreBootstrap(t *testing.T) { t.Fatalf("expected authenticatedToolsUsable false, got %#v", auth["authenticatedToolsUsable"]) } tools := data["implementedTools"].([]any) - if len(tools) != 19 || tools[0] != "system.getCapabilities" || tools[1] != "projects.list" || tools[2] != "todos.create" || tools[3] != "todos.get" || tools[4] != "todos.search" || tools[5] != "todos.update" || tools[6] != "todos.delete" || tools[7] != "todos.move" || tools[8] != "sprints.list" || tools[9] != "sprints.get" || tools[10] != "sprints.getActive" || tools[11] != "sprints.create" || tools[12] != "sprints.activate" || tools[13] != "sprints.close" || tools[14] != "sprints.update" || tools[15] != "sprints.delete" || tools[16] != "tags.listProject" || tools[17] != "tags.listMine" || tools[18] != "tags.updateMineColor" { + if len(tools) != 22 || tools[0] != "system.getCapabilities" || tools[1] != "projects.list" || tools[2] != "todos.create" || tools[3] != "todos.get" || tools[4] != "todos.search" || tools[5] != "todos.update" || tools[6] != "todos.delete" || tools[7] != "todos.move" || tools[8] != "sprints.list" || tools[9] != "sprints.get" || tools[10] != "sprints.getActive" || tools[11] != "sprints.create" || tools[12] != "sprints.activate" || tools[13] != "sprints.close" || tools[14] != "sprints.update" || tools[15] != "sprints.delete" || tools[16] != "tags.listProject" || tools[17] != "tags.listMine" || tools[18] != "tags.updateMineColor" || tools[19] != "tags.updateProjectColor" || tools[20] != "members.list" || tools[21] != "members.listAvailable" { t.Fatalf("unexpected implementedTools: %#v", tools) } planned := data["plannedTools"].([]any) @@ -2815,6 +2854,51 @@ func TestMCPTagsUpdateMineColorMalformedColorValidation(t *testing.T) { } } +func TestMCPTagsUpdateMineColorRejectsEmptyStringClear(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Empty Clear Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Empty Clear Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"backend"}, + }, + }) + + _, mine := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + tagID := mine["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["tagId"] + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateMineColor", + "input": map[string]any{ + "tagId": tagID, + "color": "", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + func TestMCPTagsUpdateMineColorMalformedInputValidation(t *testing.T) { ts, _, cleanup := newTestServer(t, "full") defer cleanup() @@ -2882,6 +2966,517 @@ func TestMCPTagsUpdateMineColorCapabilityUnavailableInAnonymousMode(t *testing.T } } +func TestMCPTagsUpdateProjectColorSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Color Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Color Project") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": "#7c3aed", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + tag := out["data"].(map[string]any)["tag"].(map[string]any) + if tag["tagId"] != float64(tagID) || tag["name"] != "backend" { + t.Fatalf("unexpected tag response: %#v", tag) + } + if tag["color"] != "#7c3aed" { + t.Fatalf("expected updated color, got %#v", tag["color"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +// TestMCPTagsUpdateProjectColorVisibleToOtherMemberViaListProject checks that a project-scoped +// color change is stored on the shared tag row (tags.color), not as a per-viewer preference: +// a different project member sees the same color via tags.listProject / ListTagCounts. +// The maintainer updates color before adding the viewer so the write path is unambiguously the owner session. +func TestMCPTagsUpdateProjectColorVisibleToOtherMemberViaListProject(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Shared Color", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Shared Color") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + wantColor := "#aabbcc" + resp2, _ := doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": wantColor, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("update project color status=%d", resp2.StatusCode) + } + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "viewer2@example.com", "password123", "Viewer2") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer membership: %v", err) + } + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp3, listOut := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listProject", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp3.StatusCode != http.StatusOK { + t.Fatalf("tags.listProject status=%d", resp3.StatusCode) + } + items := listOut["data"].(map[string]any)["items"].([]any) + var found bool + for _, it := range items { + m := it.(map[string]any) + if int64(m["tagId"].(float64)) != tagID { + continue + } + found = true + if m["color"] != wantColor { + t.Fatalf("viewer expected shared project color %q, got %#v", wantColor, m["color"]) + } + } + if !found { + t.Fatalf("tag %d not in listProject items: %#v", tagID, items) + } +} + +func TestMCPTagsUpdateProjectColorPermissionFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Permission Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Permission Project") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "viewer@example.com", "password123", "Viewer") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer membership: %v", err) + } + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": "#7c3aed", + }, + }) + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateProjectColorWrongProjectNotFound(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag First", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + resp = doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Second", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create second project status=%d", resp.StatusCode) + } + firstSlug := projectSlugByName(t, sqlDB, "Project Tag First") + secondSlug := projectSlugByName(t, sqlDB, "Project Tag Second") + secondProjectID := projectIDBySlug(t, sqlDB, secondSlug) + tagID := insertProjectScopedTag(t, sqlDB, secondProjectID, "backend", nil) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": firstSlug, + "tagId": tagID, + "color": "#7c3aed", + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateProjectColorMalformedColorValidation(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Bad Color", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Bad Color") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": "purple", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateProjectColorClearSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + initialColor := "#7c3aed" + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Clear", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Clear") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", &initialColor) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": nil, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + tag := out["data"].(map[string]any)["tag"].(map[string]any) + if tag["color"] != nil { + t.Fatalf("expected cleared project color, got %#v", tag["color"]) + } +} + +func TestMCPTagsUpdateProjectColorAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Auth Failure", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Auth Failure") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + resp, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": "#7c3aed", + }, + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateProjectColorCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": "demo", + "tagId": 1, + "color": "#7c3aed", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members List Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members List Project") + ownerID := firstUserID(t, sqlDB) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + data := out["data"].(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected one member, got %d", len(items)) + } + m := items[0].(map[string]any) + if m["projectSlug"] != slug { + t.Fatalf("projectSlug: %#v", m["projectSlug"]) + } + if int64(m["userId"].(float64)) != ownerID { + t.Fatalf("userId: %#v", m["userId"]) + } + if m["email"] != "owner@example.com" { + t.Fatalf("email: %#v", m["email"]) + } + if m["role"] != "maintainer" { + t.Fatalf("role: %#v", m["role"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPMembersListAvailableSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Avail Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Avail Project") + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "other@example.com", "password123", "Other") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.listAvailable", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + items := out["data"].(map[string]any)["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected one available user, got %d", len(items)) + } + u := items[0].(map[string]any) + if int64(u["userId"].(float64)) != other.ID { + t.Fatalf("userId: %#v", u["userId"]) + } + if u["email"] != "other@example.com" { + t.Fatalf("email: %#v", u["email"]) + } +} + +func TestMCPMembersListAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": "demo", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListAvailableCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.listAvailable", + "input": map[string]any{ + "projectSlug": "demo", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListAvailablePermissionFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Perm Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Perm Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "vmem@example.com", "password123", "V") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer: %v", err) + } + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.listAvailable", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN, got %#v", errObj["code"]) + } +} + func TestMCPUnknownToolReturnsNotFoundEnvelope(t *testing.T) { ts, _, cleanup := newTestServer(t, "full") defer cleanup() diff --git a/internal/mcp/members_tools.go b/internal/mcp/members_tools.go new file mode 100644 index 0000000..fb9f586 --- /dev/null +++ b/internal/mcp/members_tools.go @@ -0,0 +1,123 @@ +package mcp + +import ( + "context" + "errors" + "net/http" + + "scrumboy/internal/store" +) + +func (a *Adapter) handleMembersList(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.list is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.list is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintProjectInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + members, mErr := a.store.ListProjectMembers(ctx, pc.Project.ID, userID) + if mErr != nil { + return nil, nil, mapStoreError(mErr) + } + + items := make([]projectMemberItem, 0, len(members)) + for _, m := range members { + items = append(items, projectMemberItem{ + ProjectSlug: in.ProjectSlug, + UserID: m.UserID, + Email: m.Email, + Name: m.Name, + Image: m.Image, + Role: string(m.Role), + CreatedAt: m.CreatedAt, + }) + } + + return map[string]any{ + "items": items, + }, map[string]any{}, nil +} + +func (a *Adapter) handleMembersListAvailable(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.listAvailable is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.listAvailable is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in sprintProjectInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + users, uErr := a.store.ListAvailableUsersForProject(ctx, userID, pc.Project.ID) + if uErr != nil { + if errors.Is(uErr, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + return nil, nil, mapStoreError(uErr) + } + + items := make([]availableUserItem, 0, len(users)) + for _, u := range users { + items = append(items, availableUserItem{ + UserID: u.ID, + Email: u.Email, + Name: u.Name, + SystemRole: string(u.SystemRole), + IsBootstrap: u.IsBootstrap, + CreatedAt: u.CreatedAt, + }) + } + + return map[string]any{ + "items": items, + }, map[string]any{}, nil +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 1a20b8a..d19c9e2 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -26,4 +26,7 @@ func (a *Adapter) registerTools() { a.tools["tags.listProject"] = a.handleTagsListProject a.tools["tags.listMine"] = a.handleTagsListMine a.tools["tags.updateMineColor"] = a.handleTagsUpdateMineColor + a.tools["tags.updateProjectColor"] = a.handleTagsUpdateProjectColor + a.tools["members.list"] = a.handleMembersList + a.tools["members.listAvailable"] = a.handleMembersListAvailable } diff --git a/internal/mcp/system_tools.go b/internal/mcp/system_tools.go index 018c60b..7bfebe0 100644 --- a/internal/mcp/system_tools.go +++ b/internal/mcp/system_tools.go @@ -13,8 +13,10 @@ func (a *Adapter) handleSystemGetCapabilities(ctx context.Context, input any) (a Auth: auth, BootstrapAvailable: bootstrapAvailable, Identity: identityCapabilities{ - Project: "projectSlug", - Todo: []string{"projectSlug", "localId"}, + Project: "projectSlug", + Todo: []string{"projectSlug", "localId"}, + ProjectMember: []string{"projectSlug", "userId"}, + AvailableUser: []string{"userId"}, }, Pagination: paginationCapabilities{ DefaultInput: []string{"limit", "cursor"}, diff --git a/internal/mcp/tag_tools.go b/internal/mcp/tag_tools.go index cf5e7cd..3f01294 100644 --- a/internal/mcp/tag_tools.go +++ b/internal/mcp/tag_tools.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "scrumboy/internal/store" ) @@ -13,6 +14,12 @@ type updateMineTagColorInput struct { Color *string `json:"color"` } +type updateProjectTagColorInput struct { + ProjectSlug string `json:"projectSlug"` + TagID int64 `json:"tagId"` + Color *string `json:"color"` +} + func (a *Adapter) handleTagsListProject(ctx context.Context, input any) (any, map[string]any, *adapterError) { auth, bootstrapAvailable, err := a.authState(ctx) if err != nil { @@ -124,6 +131,9 @@ func (a *Adapter) handleTagsUpdateMineColor(ctx context.Context, input any) (any if in.TagID <= 0 { return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tagId", map[string]any{"field": "tagId"}) } + if in.Color != nil && strings.TrimSpace(*in.Color) == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "color cannot be empty; use null to clear", map[string]any{"field": "color"}) + } userID, ok := store.UserIDFromContext(ctx) if !ok { @@ -159,6 +169,80 @@ func (a *Adapter) handleTagsUpdateMineColor(ctx context.Context, input any) (any }, map[string]any{}, nil } +func (a *Adapter) handleTagsUpdateProjectColor(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.updateProjectColor is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.updateProjectColor is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in updateProjectTagColorInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.TagID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tagId", map[string]any{"field": "tagId"}) + } + if in.Color != nil && strings.TrimSpace(*in.Color) == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "color cannot be empty; use null to clear", map[string]any{"field": "color"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + if !pc.Role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + if _, tagErr := a.store.GetProjectScopedTagByID(ctx, pc.Project.ID, in.TagID); tagErr != nil { + return nil, nil, mapStoreError(tagErr) + } + + // UpdateTagColor mutates tags.color for project-scoped rows; viewerUserID is only used for user-owned tags. + updateErr := a.store.UpdateTagColor(ctx, &userID, in.TagID, in.Color) + if updateErr != nil { + return nil, nil, mapStoreError(updateErr) + } + + projectTags, listErr := a.store.ListTagCounts(ctx, &pc) + if listErr != nil { + return nil, nil, mapStoreError(listErr) + } + for _, tc := range projectTags { + if tc.TagID == in.TagID { + return map[string]any{ + "tag": projectTagItem{ + TagID: tc.TagID, + Name: tc.Name, + Count: tc.Count, + Color: tc.Color, + CanDelete: tc.CanDelete, + }, + }, map[string]any{}, nil + } + } + + // Tag existence in project scope was already verified above; if it disappears + // here, treat it as an internal inconsistency rather than weakening the contract. + return nil, nil, newAdapterError(http.StatusInternalServerError, CodeInternal, "internal error", map[string]any{"detail": "updated project tag not found in post-read"}) +} + func findMineTag(tags []store.TagWithColor, tagID int64) (store.TagWithColor, bool) { for _, tag := range tags { if tag.TagID == tagID { @@ -169,7 +253,7 @@ func findMineTag(tags []store.TagWithColor, tagID int64) (store.TagWithColor, bo } func isColorClear(color *string) bool { - return color == nil || *color == "" + return color == nil } func normalizedMineColor(color *string) *string { diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 1e4aff4..7402817 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -32,8 +32,10 @@ type authCapabilities struct { } type identityCapabilities struct { - Project string `json:"project"` - Todo []string `json:"todo"` + Project string `json:"project"` + Todo []string `json:"todo"` + ProjectMember []string `json:"projectMember,omitempty"` + AvailableUser []string `json:"availableUser,omitempty"` } type paginationCapabilities struct { @@ -113,3 +115,24 @@ type mineTagItem struct { Color *string `json:"color"` CanDelete bool `json:"canDelete"` } + +type projectMemberItem struct { + ProjectSlug string `json:"projectSlug"` + UserID int64 `json:"userId"` + Email string `json:"email"` + Name string `json:"name"` + Image *string `json:"image,omitempty"` + Role string `json:"role"` + CreatedAt time.Time `json:"createdAt"` +} + +// availableUserItem is the shape for members.listAvailable only (users not yet in the project). +// It intentionally omits fields the store does not load for that query (e.g. image). +type availableUserItem struct { + UserID int64 `json:"userId"` + Email string `json:"email"` + Name string `json:"name"` + SystemRole string `json:"systemRole"` + IsBootstrap bool `json:"isBootstrap"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/internal/store/tags.go b/internal/store/tags.go index 2ef3a88..20ffbec 100644 --- a/internal/store/tags.go +++ b/internal/store/tags.go @@ -317,6 +317,31 @@ type TagWithColor struct { CanDelete bool // Computed per tag_id from role/ownership; never from name groups } +// GetProjectScopedTagByID resolves a project-scoped tag by tag_id and project_id. +// It returns only tags with user_id IS NULL, so callers can safely distinguish +// project-scoped mutation targets from user-owned tags. +func (s *Store) GetProjectScopedTagByID(ctx context.Context, projectID, tagID int64) (TagWithColor, error) { + var ( + twc TagWithColor + color sql.NullString + ) + err := s.db.QueryRowContext(ctx, ` +SELECT id, name, color +FROM tags +WHERE id = ? AND project_id = ? AND user_id IS NULL +`, tagID, projectID).Scan(&twc.TagID, &twc.Name, &color) + if err == sql.ErrNoRows { + return TagWithColor{}, fmt.Errorf("%w: tag not found", ErrNotFound) + } + if err != nil { + return TagWithColor{}, fmt.Errorf("get project-scoped tag: %w", err) + } + if color.Valid && color.String != "" { + twc.Color = &color.String + } + return twc, nil +} + // ListUserTags returns all tags owned by user (cross-project tag library). // All are user-owned so CanDelete = true for every row. func (s *Store) ListUserTags(ctx context.Context, userID int64) ([]TagWithColor, error) { @@ -534,7 +559,8 @@ ON CONFLICT(user_id, tag_id) DO UPDATE SET color = excluded.color`, *viewerUserI } return nil } else if tagProjectID.Valid { - // Board-scoped tag: update tags.color directly (board-wide color) + // Board-scoped tag: update tags.color directly (board-wide color). + // viewerUserID is not used for this branch; the shared column is updated for all viewers. if color == nil || *color == "" { // Remove color _, err := s.db.ExecContext(ctx, `UPDATE tags SET color = NULL WHERE id = ?`, tagID) From 64e1a08949d0dda4b79e6707c8092f5292161496 Mon Sep 17 00:00:00 2001 From: Mark Rai Date: Tue, 31 Mar 2026 20:42:15 -0400 Subject: [PATCH 3/4] Added MCP board.get, member mutations, and tag delete tools Signed-off-by: Mark Rai --- internal/mcp/adapter.go | 17 +- internal/mcp/adapter_test.go | 2203 ++++++++++++++++++++++++++-- internal/mcp/board_tools.go | 188 +++ internal/mcp/members_tools.go | 255 +++- internal/mcp/members_tools_test.go | 22 + internal/mcp/registry.go | 6 + internal/mcp/tag_tools.go | 133 ++ internal/mcp/types.go | 26 + 8 files changed, 2713 insertions(+), 137 deletions(-) create mode 100644 internal/mcp/board_tools.go create mode 100644 internal/mcp/members_tools_test.go diff --git a/internal/mcp/adapter.go b/internal/mcp/adapter.go index b7c6170..c9bb639 100644 --- a/internal/mcp/adapter.go +++ b/internal/mcp/adapter.go @@ -36,9 +36,16 @@ type storeAPI interface { ListTagCounts(ctx context.Context, pc *store.ProjectContext) ([]store.TagCount, error) ListUserTags(ctx context.Context, userID int64) ([]store.TagWithColor, error) UpdateTagColor(ctx context.Context, viewerUserID *int64, tagID int64, color *string) error + DeleteTag(ctx context.Context, userID int64, tagID int64, isAnonymousBoard bool) error GetProjectScopedTagByID(ctx context.Context, projectID, tagID int64) (store.TagWithColor, error) ListProjectMembers(ctx context.Context, projectID int64, userID int64) ([]store.ProjectMember, error) ListAvailableUsersForProject(ctx context.Context, requesterID, projectID int64) ([]store.User, error) + AddProjectMember(ctx context.Context, requesterID, projectID, targetUserID int64, role store.ProjectRole) error + UpdateProjectMemberRole(ctx context.Context, requesterID, projectID, targetUserID int64, role store.ProjectRole) error + RemoveProjectMember(ctx context.Context, requesterID, projectID, targetUserID int64) error + GetProjectWorkflow(ctx context.Context, projectID int64) ([]store.WorkflowColumn, error) + CountTodosForBoardLane(ctx context.Context, projectID int64, columnKey string, tagFilter string, searchFilter string, sprintFilter store.SprintFilter) (int, error) + UpdateBoardActivity(ctx context.Context, projectID int64) error } type Options struct { @@ -146,16 +153,20 @@ func (a *Adapter) implementedTools() []string { "tags.listProject", "tags.listMine", "tags.updateMineColor", + "tags.deleteMine", "tags.updateProjectColor", + "tags.deleteProject", "members.list", "members.listAvailable", + "members.add", + "members.updateRole", + "members.remove", + "board.get", } } func (a *Adapter) plannedTools() []string { - return []string{ - "board.get", - } + return nil } func (a *Adapter) storeMode() store.Mode { diff --git a/internal/mcp/adapter_test.go b/internal/mcp/adapter_test.go index a937a02..9d8b8d4 100644 --- a/internal/mcp/adapter_test.go +++ b/internal/mcp/adapter_test.go @@ -184,6 +184,18 @@ ORDER BY t.rank ASC, t.id ASC return out } +func boardColumnByKey(t *testing.T, cols []any, key string) map[string]any { + t.Helper() + for _, col := range cols { + m := col.(map[string]any) + if m["key"] == key { + return m + } + } + t.Fatalf("column %q not found in %#v", key, cols) + return nil +} + func bootstrapUser(t *testing.T, client *http.Client, baseURL string) { t.Helper() resp := doJSON(t, client, http.MethodPost, baseURL+"/api/auth/bootstrap", map[string]any{ @@ -299,12 +311,11 @@ func TestMCPSystemGetCapabilities_FullPreBootstrap(t *testing.T) { t.Fatalf("expected authenticatedToolsUsable false, got %#v", auth["authenticatedToolsUsable"]) } tools := data["implementedTools"].([]any) - if len(tools) != 22 || tools[0] != "system.getCapabilities" || tools[1] != "projects.list" || tools[2] != "todos.create" || tools[3] != "todos.get" || tools[4] != "todos.search" || tools[5] != "todos.update" || tools[6] != "todos.delete" || tools[7] != "todos.move" || tools[8] != "sprints.list" || tools[9] != "sprints.get" || tools[10] != "sprints.getActive" || tools[11] != "sprints.create" || tools[12] != "sprints.activate" || tools[13] != "sprints.close" || tools[14] != "sprints.update" || tools[15] != "sprints.delete" || tools[16] != "tags.listProject" || tools[17] != "tags.listMine" || tools[18] != "tags.updateMineColor" || tools[19] != "tags.updateProjectColor" || tools[20] != "members.list" || tools[21] != "members.listAvailable" { + if len(tools) != 28 || tools[0] != "system.getCapabilities" || tools[1] != "projects.list" || tools[2] != "todos.create" || tools[3] != "todos.get" || tools[4] != "todos.search" || tools[5] != "todos.update" || tools[6] != "todos.delete" || tools[7] != "todos.move" || tools[8] != "sprints.list" || tools[9] != "sprints.get" || tools[10] != "sprints.getActive" || tools[11] != "sprints.create" || tools[12] != "sprints.activate" || tools[13] != "sprints.close" || tools[14] != "sprints.update" || tools[15] != "sprints.delete" || tools[16] != "tags.listProject" || tools[17] != "tags.listMine" || tools[18] != "tags.updateMineColor" || tools[19] != "tags.deleteMine" || tools[20] != "tags.updateProjectColor" || tools[21] != "tags.deleteProject" || tools[22] != "members.list" || tools[23] != "members.listAvailable" || tools[24] != "members.add" || tools[25] != "members.updateRole" || tools[26] != "members.remove" || tools[27] != "board.get" { t.Fatalf("unexpected implementedTools: %#v", tools) } - planned := data["plannedTools"].([]any) - if len(planned) != 1 || planned[0] != "board.get" { - t.Fatalf("unexpected plannedTools: %#v", planned) + if _, ok := data["plannedTools"]; ok { + t.Fatalf("expected plannedTools omitted once board.get is implemented, got %#v", data["plannedTools"]) } } @@ -2756,6 +2767,269 @@ func TestMCPTagsUpdateMineColorSuccess(t *testing.T) { } } +func TestMCPTagsDeleteMineSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Delete Mine Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Delete Mine Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "Tagged todo", + "tags": []string{"deleteme"}, + }, + }) + + _, mine := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + var tagID int64 + for _, it := range mine["data"].(map[string]any)["items"].([]any) { + m := it.(map[string]any) + if m["name"] == "deleteme" { + tagID = int64(m["tagId"].(float64)) + break + } + } + if tagID == 0 { + t.Fatalf("tag deleteme not in listMine: %#v", mine) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": tagID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true") + } + del := out["data"].(map[string]any)["deleted"].(map[string]any) + if int64(del["tagId"].(float64)) != tagID { + t.Fatalf("deleted tagId: %#v", del["tagId"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object") + } + + _, mine2 := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + for _, it := range mine2["data"].(map[string]any)["items"].([]any) { + m := it.(map[string]any) + if int64(m["tagId"].(float64)) == tagID { + t.Fatalf("tag still in listMine after delete") + } + } +} + +func TestMCPTagsDeleteMineValidationInvalidTagId(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Del Val Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": float64(0), + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR") + } +} + +func TestMCPTagsDeleteMineValidationUnknownField(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Del UF Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": float64(1), + "projectSlug": "nope", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR") + } +} + +func TestMCPTagsDeleteMineAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Del Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Del Auth Project") + + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "t", + "tags": []string{"x"}, + }, + }) + _, mine := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + tagID := mine["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["tagId"] + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": tagID, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED") + } +} + +func TestMCPTagsDeleteMineCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": float64(1), + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") + } +} + +func TestMCPTagsDeleteMineCapabilityUnavailableBeforeBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": float64(1), + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") + } +} + +func TestMCPTagsDeleteMineNotInViewerLibraryNotFound(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Tag Del Other Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Tag Del Other Project") + + doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", + "input": map[string]any{ + "projectSlug": slug, + "title": "t", + "tags": []string{"owneronly"}, + }, + }) + _, mine := doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "tags.listMine", + "input": map[string]any{}, + }) + var tagID int64 + for _, it := range mine["data"].(map[string]any)["items"].([]any) { + m := it.(map[string]any) + if m["name"] == "owneronly" { + tagID = int64(m["tagId"].(float64)) + break + } + } + if tagID == 0 { + t.Fatalf("expected owner tag") + } + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "tagdelother@example.com", "password123", "O") + if err != nil { + t.Fatalf("create user: %v", err) + } + otherClient := newSessionClientForUser(t, ts, st, other.ID) + + resp2, out := doMCP(t, otherClient, ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteMine", + "input": map[string]any{ + "tagId": tagID, + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND for non-owner mine-scope precheck, got %#v", out["error"]) + } +} + func TestMCPTagsUpdateMineColorClearSuccess(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() @@ -3159,284 +3433,254 @@ func TestMCPTagsUpdateProjectColorWrongProjectNotFound(t *testing.T) { } } -func TestMCPTagsUpdateProjectColorMalformedColorValidation(t *testing.T) { +func TestMCPTagsDeleteProjectSuccess(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() client := newCookieClient(t, ts) bootstrapUser(t, client, ts.URL) resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Project Tag Bad Color", + "name": "Del Proj Tag Project", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Project Tag Bad Color") + slug := projectSlugByName(t, sqlDB, "Del Proj Tag Project") projectID := projectIDBySlug(t, sqlDB, slug) - tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "scoped-del", nil) resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ - "tool": "tags.updateProjectColor", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": slug, "tagId": tagID, - "color": "purple", }, }) - if resp2.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", resp2.StatusCode) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) } - errObj := out["error"].(map[string]any) - if errObj["code"] != "VALIDATION_ERROR" { - t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + if out["ok"] != true { + t.Fatalf("expected ok=true") + } + del := out["data"].(map[string]any)["deleted"].(map[string]any) + if del["projectSlug"] != slug || int64(del["tagId"].(float64)) != tagID { + t.Fatalf("deleted: %#v", del) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object") } } -func TestMCPTagsUpdateProjectColorClearSuccess(t *testing.T) { +func TestMCPTagsDeleteProjectWrongProjectNotFound(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() client := newCookieClient(t, ts) bootstrapUser(t, client, ts.URL) - initialColor := "#7c3aed" resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Project Tag Clear", + "name": "Del PT First", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Project Tag Clear") - projectID := projectIDBySlug(t, sqlDB, slug) - tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", &initialColor) + resp = doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Del PT Second", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create second project status=%d", resp.StatusCode) + } + firstSlug := projectSlugByName(t, sqlDB, "Del PT First") + secondSlug := projectSlugByName(t, sqlDB, "Del PT Second") + secondProjectID := projectIDBySlug(t, sqlDB, secondSlug) + tagID := insertProjectScopedTag(t, sqlDB, secondProjectID, "xdel", nil) resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ - "tool": "tags.updateProjectColor", + "tool": "tags.deleteProject", "input": map[string]any{ - "projectSlug": slug, + "projectSlug": firstSlug, "tagId": tagID, - "color": nil, }, }) - if resp2.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp2.StatusCode) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) } - tag := out["data"].(map[string]any)["tag"].(map[string]any) - if tag["color"] != nil { - t.Fatalf("expected cleared project color, got %#v", tag["color"]) + if out["error"].(map[string]any)["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND") } } -func TestMCPTagsUpdateProjectColorAuthFailure(t *testing.T) { +func TestMCPTagsDeleteProjectUserOwnedTagNotFound(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() client := newCookieClient(t, ts) bootstrapUser(t, client, ts.URL) resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Project Tag Auth Failure", + "name": "Del PT User Tag Project", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Project Tag Auth Failure") - projectID := projectIDBySlug(t, sqlDB, slug) - tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + slug := projectSlugByName(t, sqlDB, "Del PT User Tag Project") - resp, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ - "tool": "tags.updateProjectColor", + doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "todos.create", "input": map[string]any{ "projectSlug": slug, - "tagId": tagID, - "color": "#7c3aed", + "title": "t", + "tags": []string{"userownedtag"}, }, }) - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", resp.StatusCode) - } - errObj := out["error"].(map[string]any) - if errObj["code"] != "AUTH_REQUIRED" { - t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + + var userTagID int64 + if err := sqlDB.QueryRow(`SELECT id FROM tags WHERE name = 'userownedtag' AND user_id IS NOT NULL`).Scan(&userTagID); err != nil { + t.Fatalf("query user-owned tag: %v", err) } -} -func TestMCPTagsUpdateProjectColorCapabilityUnavailableInAnonymousMode(t *testing.T) { - ts, _, cleanup := newTestServer(t, "anonymous") - defer cleanup() - - resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ - "tool": "tags.updateProjectColor", + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.deleteProject", "input": map[string]any{ - "projectSlug": "demo", - "tagId": 1, - "color": "#7c3aed", + "projectSlug": slug, + "tagId": userTagID, }, }) - if resp.StatusCode != http.StatusForbidden { - t.Fatalf("expected 403, got %d", resp.StatusCode) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) } - errObj := out["error"].(map[string]any) - if errObj["code"] != "CAPABILITY_UNAVAILABLE" { - t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + if out["error"].(map[string]any)["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND for user-owned tag id") } } -func TestMCPMembersListSuccess(t *testing.T) { +func TestMCPTagsDeleteProjectValidationInvalidTagId(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() client := newCookieClient(t, ts) bootstrapUser(t, client, ts.URL) resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Members List Project", + "name": "Del PT Val Project", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Members List Project") - ownerID := firstUserID(t, sqlDB) + slug := projectSlugByName(t, sqlDB, "Del PT Val Project") resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ - "tool": "members.list", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": slug, + "tagId": float64(0), }, }) - if resp2.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp2.StatusCode) - } - data := out["data"].(map[string]any) - items := data["items"].([]any) - if len(items) != 1 { - t.Fatalf("expected one member, got %d", len(items)) - } - m := items[0].(map[string]any) - if m["projectSlug"] != slug { - t.Fatalf("projectSlug: %#v", m["projectSlug"]) - } - if int64(m["userId"].(float64)) != ownerID { - t.Fatalf("userId: %#v", m["userId"]) - } - if m["email"] != "owner@example.com" { - t.Fatalf("email: %#v", m["email"]) - } - if m["role"] != "maintainer" { - t.Fatalf("role: %#v", m["role"]) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) } - if _, ok := out["meta"].(map[string]any); !ok { - t.Fatalf("expected meta object, got %#v", out["meta"]) + if out["error"].(map[string]any)["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR") } } -func TestMCPMembersListAvailableSuccess(t *testing.T) { +func TestMCPTagsDeleteProjectValidationUnknownField(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() client := newCookieClient(t, ts) bootstrapUser(t, client, ts.URL) resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Members Avail Project", + "name": "Del PT UF Project", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Members Avail Project") - - st := store.New(sqlDB, nil) - other, err := st.CreateUser(context.Background(), "other@example.com", "password123", "Other") - if err != nil { - t.Fatalf("create user: %v", err) - } + slug := projectSlugByName(t, sqlDB, "Del PT UF Project") resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ - "tool": "members.listAvailable", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": slug, + "tagId": float64(1), + "tagName": "nope", }, }) - if resp2.StatusCode != http.StatusOK { - t.Fatalf("expected 200, got %d", resp2.StatusCode) - } - items := out["data"].(map[string]any)["items"].([]any) - if len(items) != 1 { - t.Fatalf("expected one available user, got %d", len(items)) - } - u := items[0].(map[string]any) - if int64(u["userId"].(float64)) != other.ID { - t.Fatalf("userId: %#v", u["userId"]) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) } - if u["email"] != "other@example.com" { - t.Fatalf("email: %#v", u["email"]) + if out["error"].(map[string]any)["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR") } } -func TestMCPMembersListAuthFailure(t *testing.T) { +func TestMCPTagsDeleteProjectAuthFailure(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() client := newCookieClient(t, ts) bootstrapUser(t, client, ts.URL) resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Members Auth Project", + "name": "Del PT Auth Project", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Members Auth Project") + slug := projectSlugByName(t, sqlDB, "Del PT Auth Project") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "authdel", nil) resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ - "tool": "members.list", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": slug, + "tagId": tagID, }, }) if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", resp2.StatusCode) } - errObj := out["error"].(map[string]any) - if errObj["code"] != "AUTH_REQUIRED" { - t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + if out["error"].(map[string]any)["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED") } } -func TestMCPMembersListCapabilityUnavailableInAnonymousMode(t *testing.T) { +func TestMCPTagsDeleteProjectCapabilityUnavailableInAnonymousMode(t *testing.T) { ts, _, cleanup := newTestServer(t, "anonymous") defer cleanup() resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ - "tool": "members.list", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": "demo", + "tagId": float64(1), }, }) if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403, got %d", resp.StatusCode) } - errObj := out["error"].(map[string]any) - if errObj["code"] != "CAPABILITY_UNAVAILABLE" { - t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") } } -func TestMCPMembersListAvailableCapabilityUnavailableInAnonymousMode(t *testing.T) { - ts, _, cleanup := newTestServer(t, "anonymous") +func TestMCPTagsDeleteProjectCapabilityUnavailableBeforeBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") defer cleanup() resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ - "tool": "members.listAvailable", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": "demo", + "tagId": float64(1), }, }) if resp.StatusCode != http.StatusForbidden { t.Fatalf("expected 403, got %d", resp.StatusCode) } - errObj := out["error"].(map[string]any) - if errObj["code"] != "CAPABILITY_UNAVAILABLE" { - t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") } } -func TestMCPMembersListAvailablePermissionFailure(t *testing.T) { +func TestMCPTagsDeleteProjectPermissionFailure(t *testing.T) { ts, sqlDB, cleanup := newTestServer(t, "full") defer cleanup() @@ -3444,16 +3688,17 @@ func TestMCPMembersListAvailablePermissionFailure(t *testing.T) { bootstrapUser(t, ownerClient, ts.URL) ownerID := firstUserID(t, sqlDB) resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ - "name": "Members Perm Project", + "name": "Del PT Perm Project", }, &map[string]any{}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project status=%d", resp.StatusCode) } - slug := projectSlugByName(t, sqlDB, "Members Perm Project") + slug := projectSlugByName(t, sqlDB, "Del PT Perm Project") projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "perm-del", nil) st := store.New(sqlDB, nil) - viewer, err := st.CreateUser(context.Background(), "vmem@example.com", "password123", "V") + viewer, err := st.CreateUser(context.Background(), "delptview@example.com", "password123", "V") if err != nil { t.Fatalf("create viewer: %v", err) } @@ -3463,17 +3708,1709 @@ func TestMCPMembersListAvailablePermissionFailure(t *testing.T) { viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ - "tool": "members.listAvailable", + "tool": "tags.deleteProject", "input": map[string]any{ "projectSlug": slug, + "tagId": tagID, }, }) if resp2.StatusCode != http.StatusForbidden { t.Fatalf("expected 403, got %d", resp2.StatusCode) } + if out["error"].(map[string]any)["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN") + } +} + +func TestMCPTagsUpdateProjectColorMalformedColorValidation(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Bad Color", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Bad Color") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": "purple", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } errObj := out["error"].(map[string]any) - if errObj["code"] != "FORBIDDEN" { - t.Fatalf("expected FORBIDDEN, got %#v", errObj["code"]) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateProjectColorClearSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + initialColor := "#7c3aed" + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Clear", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Clear") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", &initialColor) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": nil, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + tag := out["data"].(map[string]any)["tag"].(map[string]any) + if tag["color"] != nil { + t.Fatalf("expected cleared project color, got %#v", tag["color"]) + } +} + +func TestMCPTagsUpdateProjectColorAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Project Tag Auth Failure", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Project Tag Auth Failure") + projectID := projectIDBySlug(t, sqlDB, slug) + tagID := insertProjectScopedTag(t, sqlDB, projectID, "backend", nil) + + resp, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": slug, + "tagId": tagID, + "color": "#7c3aed", + }, + }) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPTagsUpdateProjectColorCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "tags.updateProjectColor", + "input": map[string]any{ + "projectSlug": "demo", + "tagId": 1, + "color": "#7c3aed", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members List Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members List Project") + ownerID := firstUserID(t, sqlDB) + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + data := out["data"].(map[string]any) + items := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected one member, got %d", len(items)) + } + m := items[0].(map[string]any) + if m["projectSlug"] != slug { + t.Fatalf("projectSlug: %#v", m["projectSlug"]) + } + if int64(m["userId"].(float64)) != ownerID { + t.Fatalf("userId: %#v", m["userId"]) + } + if m["email"] != "owner@example.com" { + t.Fatalf("email: %#v", m["email"]) + } + if m["role"] != "maintainer" { + t.Fatalf("role: %#v", m["role"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPMembersListNormalizesLegacyStoredRoles(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members List Legacy Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members List Legacy Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "listleg@example.com", "password123", "Leg") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + if _, err := sqlDB.Exec(`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`, "owner", projectID, ownerID); err != nil { + t.Fatalf("legacy owner: %v", err) + } + if _, err := sqlDB.Exec(`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`, "editor", projectID, other.ID); err != nil { + t.Fatalf("legacy editor: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + items := out["data"].(map[string]any)["items"].([]any) + if len(items) != 2 { + t.Fatalf("expected 2 members, got %d", len(items)) + } + byUser := make(map[int64]string) + for _, it := range items { + m := it.(map[string]any) + byUser[int64(m["userId"].(float64))] = m["role"].(string) + } + if byUser[ownerID] != "maintainer" { + t.Fatalf("owner row: want maintainer, got %q", byUser[ownerID]) + } + if byUser[other.ID] != "contributor" { + t.Fatalf("editor row: want contributor, got %q", byUser[other.ID]) + } +} + +func TestMCPMembersListAvailableSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Avail Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Avail Project") + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "other@example.com", "password123", "Other") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.listAvailable", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + items := out["data"].(map[string]any)["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected one available user, got %d", len(items)) + } + u := items[0].(map[string]any) + if int64(u["userId"].(float64)) != other.ID { + t.Fatalf("userId: %#v", u["userId"]) + } + if u["email"] != "other@example.com" { + t.Fatalf("email: %#v", u["email"]) + } +} + +func TestMCPMembersListAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.list", + "input": map[string]any{ + "projectSlug": "demo", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListAvailableCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.listAvailable", + "input": map[string]any{ + "projectSlug": "demo", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersListAvailablePermissionFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Perm Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Perm Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "vmem@example.com", "password123", "V") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer: %v", err) + } + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.listAvailable", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Add Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Add Project") + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "memberadd@example.com", "password123", "Member Add") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "contributor", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true, got %#v", out["ok"]) + } + data := out["data"].(map[string]any) + m := data["member"].(map[string]any) + if m["projectSlug"] != slug { + t.Fatalf("projectSlug: %#v", m["projectSlug"]) + } + if int64(m["userId"].(float64)) != other.ID { + t.Fatalf("userId: %#v", m["userId"]) + } + if m["email"] != "memberadd@example.com" { + t.Fatalf("email: %#v", m["email"]) + } + if m["role"] != "contributor" { + t.Fatalf("role: %#v", m["role"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPMembersAddDuplicateConflict(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Dup Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Dup Project") + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "memberdup@example.com", "password123", "Dup") + if err != nil { + t.Fatalf("create user: %v", err) + } + + body := map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "viewer", + }, + } + resp2, _ := doMCP(t, client, ts.URL+"/mcp", body) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("first add: expected 200, got %d", resp2.StatusCode) + } + resp3, out := doMCP(t, client, ts.URL+"/mcp", body) + if resp3.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp3.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CONFLICT" { + t.Fatalf("expected CONFLICT, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddUnsupportedRole(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Role Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Role Project") + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "badrole@example.com", "password123", "Bad") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "owner", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddUserNotFound(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members NF User Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members NF User Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": slug, + "userId": int64(999999999), + "role": "contributor", + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Add Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Add Auth Project") + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "addauth@example.com", "password123", "A") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "contributor", + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": "demo", + "userId": float64(1), + "role": "contributor", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddCapabilityUnavailableBeforeBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": "demo", + "userId": float64(1), + "role": "contributor", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) + } +} + +func TestMCPMembersAddPermissionFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Add Perm Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Add Perm Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "vadd@example.com", "password123", "VAdd") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer: %v", err) + } + target, err := st.CreateUser(context.Background(), "targetadd@example.com", "password123", "T") + if err != nil { + t.Fatalf("create target: %v", err) + } + + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.add", + "input": map[string]any{ + "projectSlug": slug, + "userId": target.ID, + "role": "contributor", + }, + }) + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN, got %#v", errObj["code"]) + } +} + +func TestMCPMembersUpdateRoleSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Update Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Update Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "uprole@example.com", "password123", "UpRole") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "viewer", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true") + } + m := out["data"].(map[string]any)["member"].(map[string]any) + if m["role"] != "viewer" { + t.Fatalf("role: %#v", m["role"]) + } + if int64(m["userId"].(float64)) != other.ID { + t.Fatalf("userId: %#v", m["userId"]) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object") + } +} + +func TestMCPMembersUpdateRoleUnchangedNoOp(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members NoOp Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members NoOp Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "noop@example.com", "password123", "NoOp") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "contributor", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + m := out["data"].(map[string]any)["member"].(map[string]any) + if m["role"] != "contributor" { + t.Fatalf("role: %#v", m["role"]) + } +} + +func TestMCPMembersUpdateRoleUnsupportedRole(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members UR Role Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members UR Role Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "urrole@example.com", "password123", "U") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "owner", + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR") + } +} + +func TestMCPMembersUpdateRoleTargetNotMember(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members UR NF Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members UR NF Project") + + st := store.New(sqlDB, nil) + loner, err := st.CreateUser(context.Background(), "notmember@example.com", "password123", "N") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": loner.ID, + "role": "viewer", + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND") + } +} + +func TestMCPMembersUpdateRoleAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members UR Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members UR Auth Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "urauth@example.com", "password123", "U") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), firstUserID(t, sqlDB), projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "viewer", + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED") + } +} + +func TestMCPMembersUpdateRoleCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": "demo", + "userId": float64(1), + "role": "contributor", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") + } +} + +func TestMCPMembersUpdateRoleCapabilityUnavailableBeforeBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": "demo", + "userId": float64(1), + "role": "contributor", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") + } +} + +func TestMCPMembersUpdateRolePermissionFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members UR Perm Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members UR Perm Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "urview@example.com", "password123", "V") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer: %v", err) + } + target, err := st.CreateUser(context.Background(), "urtarget@example.com", "password123", "T") + if err != nil { + t.Fatalf("create target: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, target.ID, store.RoleContributor); err != nil { + t.Fatalf("add target: %v", err) + } + + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": target.ID, + "role": "viewer", + }, + }) + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN") + } +} + +func TestMCPMembersUpdateRoleSelfDemotionConflict(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Self Demo Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Self Demo Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": ownerID, + "role": "contributor", + }, + }) + if resp2.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CONFLICT" { + t.Fatalf("expected CONFLICT") + } +} + +func TestMCPMembersUpdateRoleLastMaintainerDemotionConflict(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Last M Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Last M Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + m2, err := st.CreateUser(context.Background(), "lastm2@example.com", "password123", "M2") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, m2.ID, store.RoleContributor); err != nil { + t.Fatalf("add m2: %v", err) + } + // Align with store TestUpdateProjectMemberRole_LastMaintainerCannotDemoteToViewer setup. + r1, _ := doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{"projectSlug": slug, "userId": m2.ID, "role": "maintainer"}, + }) + if r1.StatusCode != http.StatusOK { + t.Fatalf("mcp promote m2: %d", r1.StatusCode) + } + r2, _ := doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{"projectSlug": slug, "userId": m2.ID, "role": "contributor"}, + }) + if r2.StatusCode != http.StatusOK { + t.Fatalf("mcp demote m2: %d", r2.StatusCode) + } + r3, _ := doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{"projectSlug": slug, "userId": m2.ID, "role": "maintainer"}, + }) + if r3.StatusCode != http.StatusOK { + t.Fatalf("mcp re-promote m2: %d", r3.StatusCode) + } + m2Client := newSessionClientForUser(t, ts, st, m2.ID) + r4, _ := doMCP(t, m2Client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{"projectSlug": slug, "userId": ownerID, "role": "contributor"}, + }) + if r4.StatusCode != http.StatusOK { + t.Fatalf("mcp demote owner: %d", r4.StatusCode) + } + // m2 is now the only maintainer; self-demotion to viewer must fail (ErrConflict, last-maintainer path in store). + resp2, out := doMCP(t, m2Client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": m2.ID, + "role": "viewer", + }, + }) + if resp2.StatusCode != http.StatusConflict { + t.Fatalf("expected 409, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CONFLICT" { + t.Fatalf("expected CONFLICT, got %#v", out["error"]) + } +} + +func TestMCPMembersUpdateRoleLegacyOutputNormalization(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members UR Legacy Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members UR Legacy Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "urlegacy@example.com", "password123", "L") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + if _, err := sqlDB.Exec(`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`, "owner", projectID, other.ID); err != nil { + t.Fatalf("legacy role: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + "role": "maintainer", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + m := out["data"].(map[string]any)["member"].(map[string]any) + if m["role"] != "maintainer" { + t.Fatalf("expected normalized maintainer, got %#v", m["role"]) + } +} + +func TestMCPMembersRemoveSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members Remove Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members Remove Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "rmuser@example.com", "password123", "Rm") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + if out["ok"] != true { + t.Fatalf("expected ok=true") + } + rem := out["data"].(map[string]any)["removed"].(map[string]any) + if rem["projectSlug"] != slug || int64(rem["userId"].(float64)) != other.ID { + t.Fatalf("removed: %#v", rem) + } + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object") + } +} + +func TestMCPMembersRemoveTargetNotMember(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members RM NF Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members RM NF Project") + + st := store.New(sqlDB, nil) + loner, err := st.CreateUser(context.Background(), "rmnf@example.com", "password123", "N") + if err != nil { + t.Fatalf("create user: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": slug, + "userId": loner.ID, + }, + }) + if resp2.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "NOT_FOUND" { + t.Fatalf("expected NOT_FOUND") + } +} + +func TestMCPMembersRemoveAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members RM Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members RM Auth Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + other, err := st.CreateUser(context.Background(), "rmauth@example.com", "password123", "A") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), firstUserID(t, sqlDB), projectID, other.ID, store.RoleContributor); err != nil { + t.Fatalf("add member: %v", err) + } + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": slug, + "userId": other.ID, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED") + } +} + +func TestMCPMembersRemoveCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": "demo", + "userId": float64(1), + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") + } +} + +func TestMCPMembersRemoveCapabilityUnavailableBeforeBootstrap(t *testing.T) { + ts, _, cleanup := newTestServer(t, "full") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": "demo", + "userId": float64(1), + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + if out["error"].(map[string]any)["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE") + } +} + +func TestMCPMembersRemovePermissionFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members RM Perm Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members RM Perm Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + viewer, err := st.CreateUser(context.Background(), "rmview@example.com", "password123", "V") + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, viewer.ID, store.RoleViewer); err != nil { + t.Fatalf("add viewer: %v", err) + } + target, err := st.CreateUser(context.Background(), "rmtarget@example.com", "password123", "T") + if err != nil { + t.Fatalf("create target: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, target.ID, store.RoleContributor); err != nil { + t.Fatalf("add target: %v", err) + } + + viewerClient := newSessionClientForUser(t, ts, st, viewer.ID) + + resp2, out := doMCP(t, viewerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": slug, + "userId": target.ID, + }, + }) + if resp2.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "FORBIDDEN" { + t.Fatalf("expected FORBIDDEN") + } +} + +func TestMCPMembersRemoveLastMaintainerValidation(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members RM Last M Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members RM Last M Project") + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": slug, + "userId": ownerID, + }, + }) + if resp2.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp2.StatusCode) + } + if out["error"].(map[string]any)["code"] != "VALIDATION_ERROR" { + t.Fatalf("expected VALIDATION_ERROR, got %#v", out["error"]) + } +} + +func TestMCPMembersRemoveSelfSuccessWhenNotLastMaintainer(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + ownerClient := newCookieClient(t, ts) + bootstrapUser(t, ownerClient, ts.URL) + ownerID := firstUserID(t, sqlDB) + resp := doJSON(t, ownerClient, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Members RM Self Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Members RM Self Project") + projectID := projectIDBySlug(t, sqlDB, slug) + + st := store.New(sqlDB, nil) + m2, err := st.CreateUser(context.Background(), "rmself2@example.com", "password123", "S2") + if err != nil { + t.Fatalf("create user: %v", err) + } + if err := st.AddProjectMember(context.Background(), ownerID, projectID, m2.ID, store.RoleContributor); err != nil { + t.Fatalf("add m2: %v", err) + } + r1, _ := doMCP(t, ownerClient, ts.URL+"/mcp", map[string]any{ + "tool": "members.updateRole", + "input": map[string]any{"projectSlug": slug, "userId": m2.ID, "role": "maintainer"}, + }) + if r1.StatusCode != http.StatusOK { + t.Fatalf("promote m2: %d", r1.StatusCode) + } + + m2Client := newSessionClientForUser(t, ts, st, m2.ID) + resp2, out := doMCP(t, m2Client, ts.URL+"/mcp", map[string]any{ + "tool": "members.remove", + "input": map[string]any{ + "projectSlug": slug, + "userId": m2.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + rem := out["data"].(map[string]any)["removed"].(map[string]any) + if int64(rem["userId"].(float64)) != m2.ID { + t.Fatalf("removed userId: %#v", rem["userId"]) + } +} + +func TestMCPBoardGetSuccess(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Board Get Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + slug := projectSlugByName(t, sqlDB, "Board Get Project") + projectID := projectIDBySlug(t, sqlDB, slug) + ownerID := firstUserID(t, sqlDB) + st := store.New(sqlDB, nil) + ctx := store.WithUserID(context.Background(), ownerID) + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "Backlog todo", + ColumnKey: store.DefaultColumnBacklog, + Tags: []string{"bug"}, + }, store.ModeFull); err != nil { + t.Fatalf("create backlog todo: %v", err) + } + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "Doing todo", + ColumnKey: store.DefaultColumnDoing, + }, store.ModeFull); err != nil { + t.Fatalf("create doing todo: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + + data := out["data"].(map[string]any) + project := data["project"].(map[string]any) + if project["projectSlug"] != slug || project["name"] != "Board Get Project" || project["role"] != "maintainer" { + t.Fatalf("unexpected project shape: %#v", project) + } + if _, ok := project["projectId"]; ok { + t.Fatalf("board project should not expose projectId: %#v", project) + } + + columns := data["columns"].([]any) + backlog := boardColumnByKey(t, columns, store.DefaultColumnBacklog) + items := backlog["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected one backlog item, got %#v", items) + } + item := items[0].(map[string]any) + if item["projectSlug"] != slug || item["title"] != "Backlog todo" || item["columnKey"] != store.DefaultColumnBacklog { + t.Fatalf("unexpected board todo item: %#v", item) + } + if _, ok := item["id"]; ok { + t.Fatalf("board item should not expose global todo id: %#v", item) + } + + if _, ok := out["meta"].(map[string]any); !ok { + t.Fatalf("expected meta object, got %#v", out["meta"]) + } +} + +func TestMCPBoardGetPerColumnPagination(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Board Page Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + slug := projectSlugByName(t, sqlDB, "Board Page Project") + projectID := projectIDBySlug(t, sqlDB, slug) + ownerID := firstUserID(t, sqlDB) + st := store.New(sqlDB, nil) + ctx := store.WithUserID(context.Background(), ownerID) + for i := 0; i < 3; i++ { + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "Paged todo", + ColumnKey: store.DefaultColumnBacklog, + }, store.ModeFull); err != nil { + t.Fatalf("create todo %d: %v", i, err) + } + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": slug, + "limit": 2, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + meta := out["meta"].(map[string]any) + hasMoreByColumn := meta["hasMoreByColumn"].(map[string]any) + nextCursorByColumn := meta["nextCursorByColumn"].(map[string]any) + totalCountByColumn := meta["totalCountByColumn"].(map[string]any) + if hasMoreByColumn[store.DefaultColumnBacklog] != true { + t.Fatalf("expected backlog hasMore=true, got %#v", hasMoreByColumn) + } + cursor, ok := nextCursorByColumn[store.DefaultColumnBacklog].(string) + if !ok || cursor == "" { + t.Fatalf("expected opaque backlog cursor, got %#v", nextCursorByColumn[store.DefaultColumnBacklog]) + } + if int(totalCountByColumn[store.DefaultColumnBacklog].(float64)) != 3 { + t.Fatalf("expected totalCount 3, got %#v", totalCountByColumn[store.DefaultColumnBacklog]) + } + + resp3, out2 := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": slug, + "limit": 2, + "cursorByColumn": map[string]any{ + store.DefaultColumnBacklog: cursor, + }, + }, + }) + if resp3.StatusCode != http.StatusOK { + t.Fatalf("expected 200 on follow-up page, got %d", resp3.StatusCode) + } + backlog := boardColumnByKey(t, out2["data"].(map[string]any)["columns"].([]any), store.DefaultColumnBacklog) + if len(backlog["items"].([]any)) != 1 { + t.Fatalf("expected one remaining backlog item, got %#v", backlog["items"]) + } +} + +func TestMCPBoardGetFilters(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Board Filter Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + slug := projectSlugByName(t, sqlDB, "Board Filter Project") + projectID := projectIDBySlug(t, sqlDB, slug) + ownerID := firstUserID(t, sqlDB) + st := store.New(sqlDB, nil) + ctx := store.WithUserID(context.Background(), ownerID) + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "Fix login bug", + ColumnKey: store.DefaultColumnBacklog, + Tags: []string{"bug"}, + }, store.ModeFull); err != nil { + t.Fatalf("create matching todo: %v", err) + } + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "Fix login copy", + ColumnKey: store.DefaultColumnBacklog, + Tags: []string{"docs"}, + }, store.ModeFull); err != nil { + t.Fatalf("create non-matching todo: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": slug, + "tag": "bug", + "search": "login", + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + backlog := boardColumnByKey(t, out["data"].(map[string]any)["columns"].([]any), store.DefaultColumnBacklog) + items := backlog["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["title"] != "Fix login bug" { + t.Fatalf("unexpected filtered items: %#v", items) + } +} + +func TestMCPBoardGetSprintFilter(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Board Sprint Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + + slug := projectSlugByName(t, sqlDB, "Board Sprint Project") + projectID := projectIDBySlug(t, sqlDB, slug) + ownerID := firstUserID(t, sqlDB) + st := store.New(sqlDB, nil) + ctx := store.WithUserID(context.Background(), ownerID) + sp, err := st.CreateSprint(ctx, projectID, "Sprint 1", time.UnixMilli(1000), time.UnixMilli(2000)) + if err != nil { + t.Fatalf("create sprint: %v", err) + } + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "In sprint", + ColumnKey: store.DefaultColumnBacklog, + SprintID: &sp.ID, + }, store.ModeFull); err != nil { + t.Fatalf("create sprint todo: %v", err) + } + if _, err := st.CreateTodo(ctx, projectID, store.CreateTodoInput{ + Title: "Outside sprint", + ColumnKey: store.DefaultColumnBacklog, + }, store.ModeFull); err != nil { + t.Fatalf("create unscheduled todo: %v", err) + } + + resp2, out := doMCP(t, client, ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": slug, + "sprintId": sp.ID, + }, + }) + if resp2.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp2.StatusCode) + } + backlog := boardColumnByKey(t, out["data"].(map[string]any)["columns"].([]any), store.DefaultColumnBacklog) + items := backlog["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["title"] != "In sprint" { + t.Fatalf("unexpected sprint-filtered items: %#v", items) + } +} + +func TestMCPBoardGetAuthFailure(t *testing.T) { + ts, sqlDB, cleanup := newTestServer(t, "full") + defer cleanup() + + client := newCookieClient(t, ts) + bootstrapUser(t, client, ts.URL) + resp := doJSON(t, client, http.MethodPost, ts.URL+"/api/projects", map[string]any{ + "name": "Board Auth Project", + }, &map[string]any{}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create project status=%d", resp.StatusCode) + } + slug := projectSlugByName(t, sqlDB, "Board Auth Project") + + resp2, out := doMCP(t, newStatelessClient(ts), ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": slug, + }, + }) + if resp2.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp2.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "AUTH_REQUIRED" { + t.Fatalf("expected AUTH_REQUIRED, got %#v", errObj["code"]) + } +} + +func TestMCPBoardGetCapabilityUnavailableInAnonymousMode(t *testing.T) { + ts, _, cleanup := newTestServer(t, "anonymous") + defer cleanup() + + resp, out := doMCP(t, ts.Client(), ts.URL+"/mcp", map[string]any{ + "tool": "board.get", + "input": map[string]any{ + "projectSlug": "demo", + }, + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + errObj := out["error"].(map[string]any) + if errObj["code"] != "CAPABILITY_UNAVAILABLE" { + t.Fatalf("expected CAPABILITY_UNAVAILABLE, got %#v", errObj["code"]) } } diff --git a/internal/mcp/board_tools.go b/internal/mcp/board_tools.go new file mode 100644 index 0000000..d7adf26 --- /dev/null +++ b/internal/mcp/board_tools.go @@ -0,0 +1,188 @@ +package mcp + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "math" + "net/http" + "strings" + + "scrumboy/internal/store" +) + +type boardGetInput struct { + ProjectSlug string `json:"projectSlug"` + Tag string `json:"tag"` + Search string `json:"search"` + SprintId *int64 `json:"sprintId"` + Limit int `json:"limit"` + CursorByColumn map[string]string `json:"cursorByColumn"` +} + +func (a *Adapter) handleBoardGet(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "board.get is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "board.get is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in boardGetInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + + limit := in.Limit + if limit == 0 { + limit = 20 + } + if limit < 1 || limit > 100 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid limit", map[string]any{"field": "limit"}) + } + + tag := strings.TrimSpace(in.Tag) + if tag != "" { + tag = store.CanonicalizeTag(tag) + if tag == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tag", map[string]any{"field": "tag"}) + } + } + search := strings.TrimSpace(in.Search) + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + sprintFilter, filterErr := a.resolveBoardSprintFilter(ctx, pc.Project.ID, in.SprintId) + if filterErr != nil { + return nil, nil, filterErr + } + + workflow, workflowErr := a.store.GetProjectWorkflow(ctx, pc.Project.ID) + if workflowErr != nil { + return nil, nil, mapStoreError(workflowErr) + } + + knownColumns := make(map[string]struct{}, len(workflow)) + for _, col := range workflow { + knownColumns[col.Key] = struct{}{} + } + for key := range in.CursorByColumn { + if _, ok := knownColumns[key]; !ok { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid column cursor", map[string]any{"field": "cursorByColumn", "columnKey": key}) + } + } + + columns := make([]boardColumnItem, 0, len(workflow)) + nextCursorByColumn := make(map[string]any, len(workflow)) + hasMoreByColumn := make(map[string]bool, len(workflow)) + totalCountByColumn := make(map[string]int, len(workflow)) + + for _, col := range workflow { + afterRank := int64(math.MinInt64) + afterID := int64(0) + if token, ok := in.CursorByColumn[col.Key]; ok && strings.TrimSpace(token) != "" { + rawCursor, decodeErr := decodeBoardCursor(token) + if decodeErr != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid board cursor", map[string]any{"field": "cursorByColumn", "columnKey": col.Key}) + } + rank, id := store.ParseLaneCursor(rawCursor) + if rank == 0 && id == 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid board cursor", map[string]any{"field": "cursorByColumn", "columnKey": col.Key}) + } + afterRank = rank + afterID = id + } + + todos, _, hasMore, listErr := a.store.ListTodosForBoardLane(ctx, pc.Project.ID, col.Key, limit, afterRank, afterID, tag, search, sprintFilter) + if listErr != nil { + return nil, nil, mapStoreError(listErr) + } + totalCount, countErr := a.store.CountTodosForBoardLane(ctx, pc.Project.ID, col.Key, tag, search, sprintFilter) + if countErr != nil { + return nil, nil, mapStoreError(countErr) + } + + items := make([]todoItem, 0, len(todos)) + for _, todo := range todos { + items = append(items, todoToItem(in.ProjectSlug, todo)) + } + columns = append(columns, boardColumnItem{ + Key: col.Key, + Name: col.Name, + IsDone: col.IsDone, + Items: items, + }) + + if hasMore && len(todos) > 0 { + nextCursorByColumn[col.Key] = encodeBoardCursor(fmt.Sprintf("%d:%d", todos[len(todos)-1].Rank, todos[len(todos)-1].ID)) + } else { + nextCursorByColumn[col.Key] = nil + } + hasMoreByColumn[col.Key] = hasMore + totalCountByColumn[col.Key] = totalCount + } + + if err := a.store.UpdateBoardActivity(ctx, pc.Project.ID); err != nil { + return nil, nil, mapStoreError(err) + } + + return map[string]any{ + "project": boardProjectItem{ + ProjectSlug: in.ProjectSlug, + Name: pc.Project.Name, + Role: pc.Role.String(), + }, + "columns": columns, + }, map[string]any{ + "nextCursorByColumn": nextCursorByColumn, + "hasMoreByColumn": hasMoreByColumn, + "totalCountByColumn": totalCountByColumn, + }, nil +} + +func (a *Adapter) resolveBoardSprintFilter(ctx context.Context, projectID int64, sprintID *int64) (store.SprintFilter, *adapterError) { + if sprintID == nil { + return store.SprintFilter{Mode: "none"}, nil + } + if *sprintID <= 0 { + return store.SprintFilter{}, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid sprintId", map[string]any{"field": "sprintId"}) + } + + sp, err := a.store.GetSprintByID(ctx, *sprintID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return store.SprintFilter{}, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + return store.SprintFilter{}, mapStoreError(err) + } + if sp.ProjectID != projectID { + return store.SprintFilter{}, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + return store.SprintFilter{Mode: "sprint", SprintID: *sprintID}, nil +} + +func encodeBoardCursor(raw string) string { + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func decodeBoardCursor(token string) (string, error) { + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/internal/mcp/members_tools.go b/internal/mcp/members_tools.go index fb9f586..7c79f81 100644 --- a/internal/mcp/members_tools.go +++ b/internal/mcp/members_tools.go @@ -4,10 +4,24 @@ import ( "context" "errors" "net/http" + "strings" "scrumboy/internal/store" ) +// normalizeProjectMemberRoleForMCP maps legacy stored role strings to canonical MCP output. +func normalizeProjectMemberRoleForMCP(role string) string { + s := strings.TrimSpace(role) + switch strings.ToLower(s) { + case "owner": + return "maintainer" + case "editor": + return "contributor" + default: + return s + } +} + func (a *Adapter) handleMembersList(ctx context.Context, input any) (any, map[string]any, *adapterError) { auth, bootstrapAvailable, err := a.authState(ctx) if err != nil { @@ -54,7 +68,7 @@ func (a *Adapter) handleMembersList(ctx context.Context, input any) (any, map[st Email: m.Email, Name: m.Name, Image: m.Image, - Role: string(m.Role), + Role: normalizeProjectMemberRoleForMCP(string(m.Role)), CreatedAt: m.CreatedAt, }) } @@ -121,3 +135,242 @@ func (a *Adapter) handleMembersListAvailable(ctx context.Context, input any) (an "items": items, }, map[string]any{}, nil } + +func (a *Adapter) handleMembersAdd(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.add is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.add is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in membersAddInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.UserID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid userId", map[string]any{"field": "userId"}) + } + if strings.TrimSpace(in.Role) == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing role", map[string]any{"field": "role"}) + } + + pr, ok := store.ParseMemberRole(in.Role) + if !ok { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "unsupported role", map[string]any{"field": "role"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + if !pc.Role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + requesterID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + if err := a.store.AddProjectMember(ctx, requesterID, pc.Project.ID, in.UserID, pr); err != nil { + if errors.Is(err, store.ErrUnauthorized) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + return nil, nil, mapStoreError(err) + } + + members, mErr := a.store.ListProjectMembers(ctx, pc.Project.ID, requesterID) + if mErr != nil { + return nil, nil, mapStoreError(mErr) + } + var found *store.ProjectMember + for i := range members { + if members[i].UserID == in.UserID { + found = &members[i] + break + } + } + if found == nil { + return nil, nil, newAdapterError(http.StatusInternalServerError, CodeInternal, "member not found after add", nil) + } + + item := projectMemberItem{ + ProjectSlug: in.ProjectSlug, + UserID: found.UserID, + Email: found.Email, + Name: found.Name, + Image: found.Image, + Role: normalizeProjectMemberRoleForMCP(string(found.Role)), + CreatedAt: found.CreatedAt, + } + + return map[string]any{ + "member": item, + }, map[string]any{}, nil +} + +func (a *Adapter) handleMembersUpdateRole(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.updateRole is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.updateRole is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in membersAddInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.UserID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid userId", map[string]any{"field": "userId"}) + } + if strings.TrimSpace(in.Role) == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing role", map[string]any{"field": "role"}) + } + + pr, ok := store.ParseMemberRole(in.Role) + if !ok { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "unsupported role", map[string]any{"field": "role"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + if !pc.Role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + requesterID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + if err := a.store.UpdateProjectMemberRole(ctx, requesterID, pc.Project.ID, in.UserID, pr); err != nil { + switch { + case errors.Is(err, store.ErrUnauthorized): + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + case errors.Is(err, store.ErrConflict): + return nil, nil, newAdapterError(http.StatusConflict, CodeConflict, err.Error(), nil) + case errors.Is(err, store.ErrNotFound): + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + case errors.Is(err, store.ErrValidation): + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, err.Error(), map[string]any{"field": "role"}) + default: + return nil, nil, mapStoreError(err) + } + } + + members, mErr := a.store.ListProjectMembers(ctx, pc.Project.ID, requesterID) + if mErr != nil { + return nil, nil, mapStoreError(mErr) + } + var found *store.ProjectMember + for i := range members { + if members[i].UserID == in.UserID { + found = &members[i] + break + } + } + if found == nil { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + item := projectMemberItem{ + ProjectSlug: in.ProjectSlug, + UserID: found.UserID, + Email: found.Email, + Name: found.Name, + Image: found.Image, + Role: normalizeProjectMemberRoleForMCP(string(found.Role)), + CreatedAt: found.CreatedAt, + } + + return map[string]any{ + "member": item, + }, map[string]any{}, nil +} + +func (a *Adapter) handleMembersRemove(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.remove is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "members.remove is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in membersRemoveInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.UserID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid userId", map[string]any{"field": "userId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + + if !pc.Role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + requesterID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + if err := a.store.RemoveProjectMember(ctx, requesterID, pc.Project.ID, in.UserID); err != nil { + switch { + case errors.Is(err, store.ErrUnauthorized): + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + case errors.Is(err, store.ErrNotFound): + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + case errors.Is(err, store.ErrValidation): + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, err.Error(), nil) + default: + return nil, nil, mapStoreError(err) + } + } + + return map[string]any{ + "removed": map[string]any{ + "projectSlug": in.ProjectSlug, + "userId": in.UserID, + }, + }, map[string]any{}, nil +} diff --git a/internal/mcp/members_tools_test.go b/internal/mcp/members_tools_test.go new file mode 100644 index 0000000..2ad430b --- /dev/null +++ b/internal/mcp/members_tools_test.go @@ -0,0 +1,22 @@ +package mcp + +import "testing" + +func TestNormalizeProjectMemberRoleForMCP(t *testing.T) { + t.Parallel() + tests := []struct { + in, want string + }{ + {"owner", "maintainer"}, + {"OWNER", "maintainer"}, + {"editor", "contributor"}, + {"maintainer", "maintainer"}, + {"contributor", "contributor"}, + {"viewer", "viewer"}, + } + for _, tc := range tests { + if got := normalizeProjectMemberRoleForMCP(tc.in); got != tc.want { + t.Fatalf("normalizeProjectMemberRoleForMCP(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index d19c9e2..467433a 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -26,7 +26,13 @@ func (a *Adapter) registerTools() { a.tools["tags.listProject"] = a.handleTagsListProject a.tools["tags.listMine"] = a.handleTagsListMine a.tools["tags.updateMineColor"] = a.handleTagsUpdateMineColor + a.tools["tags.deleteMine"] = a.handleTagsDeleteMine a.tools["tags.updateProjectColor"] = a.handleTagsUpdateProjectColor + a.tools["tags.deleteProject"] = a.handleTagsDeleteProject a.tools["members.list"] = a.handleMembersList a.tools["members.listAvailable"] = a.handleMembersListAvailable + a.tools["members.add"] = a.handleMembersAdd + a.tools["members.updateRole"] = a.handleMembersUpdateRole + a.tools["members.remove"] = a.handleMembersRemove + a.tools["board.get"] = a.handleBoardGet } diff --git a/internal/mcp/tag_tools.go b/internal/mcp/tag_tools.go index 3f01294..73830fe 100644 --- a/internal/mcp/tag_tools.go +++ b/internal/mcp/tag_tools.go @@ -14,12 +14,23 @@ type updateMineTagColorInput struct { Color *string `json:"color"` } +// deleteMineTagInput is the input for tags.deleteMine (tagId only; mine-scope / user library). +type deleteMineTagInput struct { + TagID int64 `json:"tagId"` +} + type updateProjectTagColorInput struct { ProjectSlug string `json:"projectSlug"` TagID int64 `json:"tagId"` Color *string `json:"color"` } +// deleteProjectTagInput is the input for tags.deleteProject (projectSlug + tagId; project-scoped rows only). +type deleteProjectTagInput struct { + ProjectSlug string `json:"projectSlug"` + TagID int64 `json:"tagId"` +} + func (a *Adapter) handleTagsListProject(ctx context.Context, input any) (any, map[string]any, *adapterError) { auth, bootstrapAvailable, err := a.authState(ctx) if err != nil { @@ -169,6 +180,62 @@ func (a *Adapter) handleTagsUpdateMineColor(ctx context.Context, input any) (any }, map[string]any{}, nil } +func (a *Adapter) handleTagsDeleteMine(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.deleteMine is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.deleteMine is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in deleteMineTagInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.TagID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tagId", map[string]any{"field": "tagId"}) + } + + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + tags, tagsErr := a.store.ListUserTags(ctx, userID) + if tagsErr != nil { + return nil, nil, mapStoreError(tagsErr) + } + if _, found := findMineTag(tags, in.TagID); !found { + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + } + + if err := a.store.DeleteTag(ctx, userID, in.TagID, false); err != nil { + switch { + case errors.Is(err, store.ErrNotFound): + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + case errors.Is(err, store.ErrUnauthorized): + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, err.Error(), nil) + case errors.Is(err, store.ErrConflict): + return nil, nil, newAdapterError(http.StatusConflict, CodeConflict, err.Error(), nil) + default: + return nil, nil, mapStoreError(err) + } + } + + return map[string]any{ + "deleted": map[string]any{ + "tagId": in.TagID, + }, + }, map[string]any{}, nil +} + func (a *Adapter) handleTagsUpdateProjectColor(ctx context.Context, input any) (any, map[string]any, *adapterError) { auth, bootstrapAvailable, err := a.authState(ctx) if err != nil { @@ -243,6 +310,72 @@ func (a *Adapter) handleTagsUpdateProjectColor(ctx context.Context, input any) ( return nil, nil, newAdapterError(http.StatusInternalServerError, CodeInternal, "internal error", map[string]any{"detail": "updated project tag not found in post-read"}) } +func (a *Adapter) handleTagsDeleteProject(ctx context.Context, input any) (any, map[string]any, *adapterError) { + auth, bootstrapAvailable, err := a.authState(ctx) + if err != nil { + return nil, nil, err + } + + switch { + case a.mode == "anonymous": + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.deleteProject is unavailable in anonymous mode", nil) + case bootstrapAvailable: + return nil, nil, newAdapterError(http.StatusForbidden, CodeCapabilityUnavailable, "tags.deleteProject is unavailable before bootstrap", nil) + case !auth.Authenticated: + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + + var in deleteProjectTagInput + if err := decodeInput(input, &in); err != nil { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid input", map[string]any{"detail": err.Error()}) + } + if in.ProjectSlug == "" { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "missing projectSlug", map[string]any{"field": "projectSlug"}) + } + if in.TagID <= 0 { + return nil, nil, newAdapterError(http.StatusBadRequest, CodeValidationError, "invalid tagId", map[string]any{"field": "tagId"}) + } + + pc, pcErr := a.store.GetProjectContextBySlug(ctx, in.ProjectSlug, a.storeMode()) + if pcErr != nil { + return nil, nil, mapStoreError(pcErr) + } + userID, ok := store.UserIDFromContext(ctx) + if !ok { + return nil, nil, newAdapterError(http.StatusUnauthorized, CodeAuthRequired, "Sign-in required for this tool", nil) + } + if !pc.Role.HasMinimumRole(store.RoleMaintainer) { + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, "maintainer or higher required", nil) + } + + if _, tagErr := a.store.GetProjectScopedTagByID(ctx, pc.Project.ID, in.TagID); tagErr != nil { + return nil, nil, mapStoreError(tagErr) + } + + p := pc.Project + isAnonymousBoard := p.ExpiresAt != nil && p.CreatorUserID == nil + + if err := a.store.DeleteTag(ctx, userID, in.TagID, isAnonymousBoard); err != nil { + switch { + case errors.Is(err, store.ErrNotFound): + return nil, nil, newAdapterError(http.StatusNotFound, CodeNotFound, "not found", nil) + case errors.Is(err, store.ErrUnauthorized): + return nil, nil, newAdapterError(http.StatusForbidden, CodeForbidden, err.Error(), nil) + case errors.Is(err, store.ErrConflict): + return nil, nil, newAdapterError(http.StatusConflict, CodeConflict, err.Error(), nil) + default: + return nil, nil, mapStoreError(err) + } + } + + return map[string]any{ + "deleted": map[string]any{ + "projectSlug": in.ProjectSlug, + "tagId": in.TagID, + }, + }, map[string]any{}, nil +} + func findMineTag(tags []store.TagWithColor, tagID int64) (store.TagWithColor, bool) { for _, tag := range tags { if tag.TagID == tagID { diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 7402817..1c37a9c 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -116,6 +116,19 @@ type mineTagItem struct { CanDelete bool `json:"canDelete"` } +type boardProjectItem struct { + ProjectSlug string `json:"projectSlug"` + Name string `json:"name"` + Role string `json:"role,omitempty"` +} + +type boardColumnItem struct { + Key string `json:"key"` + Name string `json:"name"` + IsDone bool `json:"isDone"` + Items []todoItem `json:"items"` +} + type projectMemberItem struct { ProjectSlug string `json:"projectSlug"` UserID int64 `json:"userId"` @@ -126,6 +139,19 @@ type projectMemberItem struct { CreatedAt time.Time `json:"createdAt"` } +// membersAddInput is the input for members.add and members.updateRole (projectSlug + userId + canonical role only). +type membersAddInput struct { + ProjectSlug string `json:"projectSlug"` + UserID int64 `json:"userId"` + Role string `json:"role"` +} + +// membersRemoveInput is the input for members.remove (projectSlug + userId only). +type membersRemoveInput struct { + ProjectSlug string `json:"projectSlug"` + UserID int64 `json:"userId"` +} + // availableUserItem is the shape for members.listAvailable only (users not yet in the project). // It intentionally omits fields the store does not load for that query (e.g. image). type availableUserItem struct { From bf3566c62dcf2d35336b48f7b0ebc502d10c432f Mon Sep 17 00:00:00 2001 From: Mark Rai Date: Tue, 31 Mar 2026 20:56:21 -0400 Subject: [PATCH 4/4] Add MCP API.md documenting current adapter surface Signed-off-by: Mark Rai --- API.md | 300 ++++++++++++++++++++++++++++++++++++ internal/version/version.go | 2 +- 2 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..ab79376 --- /dev/null +++ b/API.md @@ -0,0 +1,300 @@ +# Scrumboy MCP HTTP API + +This API is intended for programmatic clients (e.g., agents or integrations), not direct browser use. + +This document describes the **Model Context Protocol (MCP) HTTP surface** implemented under `internal/mcp` and mounted by the Scrumboy HTTP server. It reflects **current behavior only**, not a roadmap. + +**Base path:** `/mcp` (exactly; paths like `/mcp/foo` return 404). + +The MCP adapter is constructed in `cmd/scrumboy/main.go` with server mode from configuration and registered on the main `httpapi` server. + +--- + +## Transport + +- **`GET /mcp`** - Capabilities discovery (same `data` as `system.getCapabilities` via POST). +- **`POST /mcp`** - Invoke a single tool. + +There are **no per-tool URL paths**. Every tool is invoked by posting a JSON body to `POST /mcp`. + +Tool names are case-sensitive. + +**POST body envelope:** + +```json +{ + "tool": "tool.name", + "input": {} +} +``` + +- `tool` (string, required): registered tool name. +- `input` (object, required for tools that decode structured input): pass `{}` when a tool expects no fields. Omitting `input` or sending JSON `null` may cause decoding errors for tools expecting an object. + +Unknown top-level fields on the POST body are rejected (strict JSON decode). + +**Other methods** on `/mcp` return **405** with error code `METHOD_NOT_ALLOWED`. + +Responses use `Cache-Control: no-store` and `Content-Type: application/json; charset=utf-8`. + +--- + +## Response envelopes + +### Success + +```json +{ + "ok": true, + "data": {}, + "meta": {} +} +``` + +- `data` holds the tool result (shape varies by tool). +- `meta` is **always** a JSON object on success (empty if the tool has no metadata). +- List-style tools return their array under **`data.items`** unless noted otherwise. + +### Error + +```json +{ + "ok": false, + "error": { + "code": "NOT_FOUND", + "message": "not found", + "details": {} + } +} +``` + +- `details` is always present; it is an object when the adapter has nothing to attach (`{}`). +- HTTP status codes generally align with error codes (e.g. 401 for `AUTH_REQUIRED`, 403 for `CAPABILITY_UNAVAILABLE`, 404 for `NOT_FOUND`), but exact mappings may vary by handler. + +--- + +## Authentication and capability model + +**Server mode** (`SCRUMBOY_MODE` / config): `full` or `anonymous`. + +**Session:** In `full` mode, the adapter reads the `scrumboy_session` cookie and loads the user into request context when the cookie is valid. In `anonymous` mode, cookies are **not** applied for MCP (same rule as the documented HTTP API boundary). + +**Bootstrap:** If there are **no users** in the database, authenticated MCP tools are treated as unavailable until bootstrap completes (`CountUsers == 0`). + +**Typical codes:** `AUTH_REQUIRED` when a tool needs a signed-in user but the session is missing or invalid. `CAPABILITY_UNAVAILABLE` when the server is in **anonymous mode**, or **before bootstrap** (no users yet), or the tool is otherwise gated as unavailable. + +**Practical rule:** Almost all project-scoped tools (todos, sprints, tags, members, board) require **full mode**, **post-bootstrap**, and a **valid session**. Capabilities (`GET /mcp` / `system.getCapabilities`) still run without sign-in so clients can inspect the server. + +**`system.getCapabilities`** and **`GET /mcp`** do not require sign-in; they report `auth`, `bootstrapAvailable`, `serverMode`, and `implementedTools`. + +## Authentication example (curl) + +Use cookie-jar mode for authenticated MCP tools. + +If the server is not bootstrapped yet (no users), create the first user: + +```bash +curl -c cookies.txt -X POST http://localhost:8080/api/auth/bootstrap \ + -H "Content-Type: application/json" \ + -H "X-Scrumboy: 1" \ + -d '{"email":"user@example.com","password":"password","name":"User"}' +``` + +If users already exist, log in: + +```bash +curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -H "X-Scrumboy: 1" \ + -d '{"email":"user@example.com","password":"password"}' +``` + +Then call MCP with the session cookie: + +```bash +curl -b cookies.txt -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -d '{"tool":"projects.list","input":{}}' +``` + +--- + +## Canonical identities (MCP) + +Tools use these **public** identifiers as primary keys in inputs and outputs: + +- **Project:** `projectSlug` +- **Todo:** `projectSlug` + `localId` (no global todo id in MCP todo/board shapes) +- **Sprint:** `projectSlug` + `sprintId` - `sprintId` is the **stored sprint row id** (see sprint list/get); sprint payloads also include `number` for display ordering +- **Mine-scope tag:** `tagId` (current user’s tag library) +- **Project-scope tag:** `projectSlug` + `tagId` (tag row scoped to that project; not user-owned) +- **Project member / membership target:** `projectSlug` + `userId` +- **Available user (invite list):** `userId` (from `members.listAvailable`) + +`system.getCapabilities` includes an `identity` object echoing some of these patterns. + +**Note:** `projects.list` returns **`projectId`** on each item in addition to `projectSlug`. MCP mutations still key off **`projectSlug`**. `projectId` is returned for informational purposes only and is not used as an input identifier in MCP tools. + +--- + +## Implemented tools (summary) + +Grouped by domain. All are listed in `implementedTools` from capabilities. + +**system** + +- `system.getCapabilities` - Server mode, auth snapshot, identity/pagination hints, full tool list. + +**projects** + +- `projects.list` - Projects visible to the user (with role). + +**board** + +- `board.get` - Paged board view per workflow column (special pagination; see below). + +**todos** + +- `todos.create`, `todos.get`, `todos.search`, `todos.update`, `todos.delete`, `todos.move` + +**sprints** + +- `sprints.list`, `sprints.get`, `sprints.getActive`, `sprints.create`, `sprints.activate`, `sprints.close`, `sprints.update`, `sprints.delete` + +**tags** + +- `tags.listProject`, `tags.listMine`, `tags.updateMineColor`, `tags.deleteMine`, `tags.updateProjectColor`, `tags.deleteProject` + +**members** + +- `members.list`, `members.listAvailable`, `members.add`, `members.updateRole`, `members.remove` + +**Planned tools:** none exposed in capabilities today (`plannedTools` omitted when empty). + +--- + +## Tool reference + +Conventions: + +- Inputs use **camelCase** JSON keys matching the Go structs; unknown keys are rejected where `decodeInput` is used. +- Auth gates omitted below repeat: **anonymous mode** → `CAPABILITY_UNAVAILABLE`; **pre-bootstrap** → `CAPABILITY_UNAVAILABLE`; **no session** → `AUTH_REQUIRED` for tools that require it. + +### `system.getCapabilities` + +- **Purpose:** Describe server, auth, identities, pagination notes, and implemented tools. +- **Input:** `{}` (use empty object for POST). +- **Output:** `data` = capabilities object: `serverMode`, `auth`, `bootstrapAvailable`, `identity`, `pagination`, `implementedTools`, optional `plannedTools`. +- **Meta:** e.g. `adapterVersion` (integer). +- **Example (GET or POST):** + `POST /mcp` `{"tool":"system.getCapabilities","input":{}}` + → `ok: true`, `data.implementedTools` = full tool array. + +### `projects.list` + +- **Purpose:** List projects for the current user with role. +- **Input:** `{}` +- **Output:** `data.items` - array of projects (`projectSlug`, `projectId`, `name`, `image`, `dominantColor`, `defaultSprintWeeks`, `expiresAt`, `createdAt`, `updatedAt`, `role`). + +### `board.get` + +- **Purpose:** Board snapshot with optional tag/search/sprint filters and **per-column** pagination. +- **Input:** `projectSlug` (required); optional `tag`, `search`, `sprintId` (sprint row id; must belong to the project when set); optional `limit` (default 20, max 100); optional `cursorByColumn` (map column key → opaque cursor string). Omitting `sprintId` applies no sprint-based filter on the board query (internal mode `none`). +- **Output:** `data.project` (`projectSlug`, `name`, `role`), `data.columns` (each: `key`, `name`, `isDone`, `items` as todo-shaped objects). +- **Meta:** `nextCursorByColumn`, `hasMoreByColumn`, `totalCountByColumn` (per column key). See **Board pagination** below. +- **Note:** Not available in anonymous mode or before bootstrap; requires sign-in. + +### Todos + +| Tool | Input (summary) | Output (summary) | +|------|-----------------|------------------| +| `todos.create` | `projectSlug`, `title`, optional `body`, `tags`, `columnKey`, `estimationPoints`, `sprintId`, `assigneeUserId`, `position` | `data.todo` | +| `todos.get` | `projectSlug`, `localId` | `data.todo` | +| `todos.search` | `projectSlug`, `query`, optional `limit`, `excludeLocalIds` | `data.items` (lightweight search hits) | +| `todos.update` | `projectSlug`, `localId`, `patch` (JSON patch object) | `data.todo` | +| `todos.delete` | `projectSlug`, `localId` | `data` with `status: "deleted"`, `projectSlug`, `localId` | +| `todos.move` | `projectSlug`, `localId`, `toColumnKey`, optional `afterLocalId`, `beforeLocalId` | `data.todo` | + +Column keys accept common aliases (normalized internally). Todo payloads use **`localId`** and **`projectSlug`**; they do not expose the internal global todo id. + +### Sprints + +Shared inputs: many tools use `projectSlug` only or `projectSlug` + `sprintId` (stored id). + +| Tool | Input | Output | +|------|-------|--------| +| `sprints.list` | `projectSlug` | `data.items` (sprint rows + counts), `meta.unscheduledCount` | +| `sprints.get` | `projectSlug`, `sprintId` | `data.sprint` | +| `sprints.getActive` | `projectSlug` | `data.sprint` - sprint object or JSON `null` when there is no active sprint | +| `sprints.create` | `projectSlug`, `name`, `plannedStartAt`, `plannedEndAt` (ISO-8601 strings) | `data.sprint` | +| `sprints.activate` | `projectSlug`, `sprintId` | `data.sprint` | +| `sprints.close` | `projectSlug`, `sprintId` | `data.sprint` (closed) | +| `sprints.update` | `projectSlug`, `sprintId`, `patch` | `data.sprint` | +| `sprints.delete` | `projectSlug`, `sprintId` (maintainer+) | `data` with `status: "deleted"`, `projectSlug`, `sprintId` | + +Activate/close enforce sprint state (e.g. planned vs active); violations return `VALIDATION_ERROR` with details. + +### Tags + +| Tool | Input | Output | +|------|-------|--------| +| `tags.listProject` | `projectSlug` | `data.items` (`tagId`, `name`, `count`, `color`, `canDelete`) | +| `tags.listMine` | `{}` | `data.items` (mine tags; no `count`) | +| `tags.updateMineColor` | `tagId`, `color` (hex or `null` to clear) | `data.tag` | +| `tags.deleteMine` | `tagId` | `data.deleted` `{ tagId }` - only if tag is in the viewer’s mine list, then store delete | +| `tags.updateProjectColor` | `projectSlug`, `tagId`, `color` | `data.tag` - **maintainer+**; tag must be **project-scoped** in that project | +| `tags.deleteProject` | `projectSlug`, `tagId` | `data.deleted` `{ projectSlug, tagId }` - **maintainer+**; tag must exist as a **project-scoped** tag in that project | + +### Members + +| Tool | Input | Output | +|------|-------|--------| +| `members.list` | `projectSlug` | `data.items` (member rows with normalized roles where implemented) | +| `members.listAvailable` | `projectSlug` | `data.items` (users not in project) - **maintainer+** | +| `members.add` | `projectSlug`, `userId`, `role` (`maintainer` \| `contributor` \| `viewer` only) | `data.member` | +| `members.updateRole` | `projectSlug`, `userId`, `role` (same three) | `data.member` | +| `members.remove` | `projectSlug`, `userId` | `data.removed` `{ projectSlug, userId }` | + +Member list payloads normalize legacy role strings where the adapter applies mapping (`owner`→`maintainer`, `editor`→`contributor`). + +`members.updateRole`: self-demotion and last-maintainer demotion → `CONFLICT`. +`members.remove`: last maintainer removal → `VALIDATION_ERROR` (store mapping). + +--- + +## Board pagination (`board.get`) + +This is **not** a single cursor for the whole board. + +- **`limit`:** Maximum todos returned **per workflow column** (default 20, clamped 1-100). +- **`cursorByColumn`:** Map from **column key** (string) to an **opaque** cursor token (base64url). Cursors are produced by the server; clients should not parse them. +- **`meta.nextCursorByColumn`:** Per-column next cursor, or `null` when there is no next page. +- **`meta.hasMoreByColumn`:** Whether more todos exist in that column for the same filters. +- **`meta.totalCountByColumn`:** Total matching todos in that column (independent of the current page). + +Invalid column keys in `cursorByColumn` or malformed cursors → `VALIDATION_ERROR` with field hints. + +--- + +## Error codes + +- **`AUTH_REQUIRED`** - Sign-in required (including some store unauthorized paths mapped from the store layer). +- **`CAPABILITY_UNAVAILABLE`** - Anonymous server mode, pre-bootstrap, or a tool that is unavailable in the current mode. +- **`NOT_FOUND`** - Unknown tool name, or resource not found in the requested scope. +- **`FORBIDDEN`** - Authenticated but not allowed (e.g. role too low for the operation). +- **`VALIDATION_ERROR`** - Invalid JSON input, missing fields, invalid values, or store validation (e.g. sprint state, last-maintainer removal rules). +- **`CONFLICT`** - Store-reported conflict (e.g. duplicate member, role demotion rules). +- **`INTERNAL`** - Unexpected server or store failure. +- **`METHOD_NOT_ALLOWED`** - Any HTTP method other than `GET` or `POST` on `/mcp`. + +Some handlers return **`FORBIDDEN`** with a clear message where **`mapStoreError`** would map the same store error to **`AUTH_REQUIRED`**; both patterns exist in the current code. + +--- + +## Notes and guarantees + +1. **Public identifiers first:** Mutations and reads are keyed by **`projectSlug`**, **`localId`**, and similar fields - not internal numeric ids for todos or projects in MCP command shapes (except `projectId` on list output as noted). +2. **Capabilities match implementation:** `implementedTools` is the authoritative list of POST tool names. +3. **Narrower than REST:** Some MCP tools intentionally pre-check scope (e.g. mine-tag delete via library membership) or map errors deterministically; behavior may differ from every REST edge case. +4. **Anonymous MCP:** Tag, member, board, todo, and sprint tools are **not** offered in anonymous server mode through MCP (`CAPABILITY_UNAVAILABLE`), even if anonymous boards exist elsewhere in the product. + diff --git a/internal/version/version.go b/internal/version/version.go index 24e3729..0754f1f 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -8,7 +8,7 @@ package version // // Convention: Update when releasing (e.g., "1.0.0", "1.1.0"); match git tags // (e.g., tag "v1.0.0" should have Version = "1.0.0"). -const Version = "3.6.1" +const Version = "3.7.0" // ExportFormatVersion is the version of the backup/export data format. // Only increment this when the ExportData structure changes in a breaking way.