From dc508238607cd3470ceba75bee09847b566b7da7 Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 15 May 2026 17:59:03 +0200 Subject: [PATCH] feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp package (new): minimal JSON-RPC 2.0 + MCP-protocol server, tools delegate to *store.Store (no business-logic duplication). - handler.go: handleRPC routes initialize / tools/list / tools/call / ping / notifications/initialized; Bearer-token middleware; results flow through the standard MCP content[].text envelope; tool errors surface as isError: true (transport errors stay JSON-RPC errors). - tools.go: 10 tools — list_items / get_item / create_item / update_item / delete_item / list_links / add_link / remove_link / search / tree. Multi-parent in/out — parent_paths[] string array, resolved per call. itemView/linkView keep the wire shape snake_case and stable. - mcp_test.go + tools_test.go: protocol primitives (no DB) plus a full create → get → search → delete round-trip skipping cleanly when the DB env is absent. Multi-parent assertion discovers the test pair from the live DB rather than hard-coding a row. store extensions: - ListByFilters(SearchFilters) with parent_path/tags/management/kind/ status/q/has_repo/has_caldav predicates. - Search(q, limit) ranked across title/slug/aliases/content_md. - GetByPathOrSlug for callers that don't know the full path. - SoftDeleteCascade refuses on live descendants unless cascade=true. web: - New optional Server.MCP http.Handler. main.go mounts an mcp.Server when PROJAX_MCP_TOKEN is set; /mcp/* gets a StripPrefix and bypasses the Supabase-cookie auth middleware (its own Bearer auth applies). - Off cleanly when the token is unset. ops: - ~/.claude/mcp/projax.sh stdio→HTTP bridge (NDJSON in, NDJSON out, Bearer header). - .mcp.json adds an http-transport entry for clients that speak HTTP+MCP natively. - deploy/dokploy.yaml advertises PROJAX_MCP_TOKEN as a secret. - docs/design.md §7 added: tool list, multi-parent semantics, env contract, transport + bridge. --- .mcp.json | 7 + cmd/projax/main.go | 12 + deploy/dokploy.yaml | 3 +- docs/design.md | 56 ++++ mcp/handler.go | 286 +++++++++++++++++ mcp/mcp_test.go | 162 ++++++++++ mcp/tools.go | 725 ++++++++++++++++++++++++++++++++++++++++++++ mcp/tools_test.go | 184 +++++++++++ store/store.go | 219 +++++++++++++ web/auth.go | 9 +- web/server.go | 5 + 11 files changed, 1666 insertions(+), 2 deletions(-) create mode 100644 mcp/handler.go create mode 100644 mcp/mcp_test.go create mode 100644 mcp/tools.go create mode 100644 mcp/tools_test.go diff --git a/.mcp.json b/.mcp.json index d4d0044..4d3d5e3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,6 +6,13 @@ "headers": { "Authorization": "Basic ${SUPABASE_AUTH}" } + }, + "projax": { + "type": "http", + "url": "https://projax.msbls.de/mcp/rpc", + "headers": { + "Authorization": "Bearer ${PROJAX_MCP_TOKEN}" + } } } } diff --git a/cmd/projax/main.go b/cmd/projax/main.go index 0dbd72c..de9c14b 100644 --- a/cmd/projax/main.go +++ b/cmd/projax/main.go @@ -15,6 +15,7 @@ import ( "github.com/m/projax/caldav" "github.com/m/projax/db" "github.com/m/projax/gitea" + "github.com/m/projax/mcp" "github.com/m/projax/store" "github.com/m/projax/web" ) @@ -103,6 +104,17 @@ func main() { logger.Info("gitea: disabled — GITEA_URL not set") } + if mcpToken := os.Getenv("PROJAX_MCP_TOKEN"); mcpToken != "" { + mcpSrv := mcp.New("projax", "0.1.0", mcpToken, logger) + mcp.RegisterProjaxTools(mcpSrv, store.New(pool)) + mcpMux := http.NewServeMux() + mcpSrv.Routes(mcpMux) + srv.MCP = mcpMux + logger.Info("mcp: enabled", "path", "/mcp") + } else { + logger.Info("mcp: disabled — PROJAX_MCP_TOKEN not set") + } + httpServer := &http.Server{ Addr: listen, Handler: srv.Routes(), diff --git a/deploy/dokploy.yaml b/deploy/dokploy.yaml index a5b080e..b6e4b9b 100644 --- a/deploy/dokploy.yaml +++ b/deploy/dokploy.yaml @@ -45,4 +45,5 @@ secrets: - SUPABASE_ANON_KEY - DAV_USER - DAV_PASSWORD - - GITEA_TOKEN # = GITEA_TOKEN_AI from .env.age (mAi automation account) + - GITEA_TOKEN # = GITEA_TOKEN_AI from .env.age (mAi automation account) + - PROJAX_MCP_TOKEN # 32-char Bearer secret for /mcp/rpc; missing → MCP off cleanly diff --git a/docs/design.md b/docs/design.md index 6885832..c452dac 100644 --- a/docs/design.md +++ b/docs/design.md @@ -264,6 +264,62 @@ m's Gitea instance lives at `mgit.msbls.de` (token auth, automation account `mAi Env contract: `GITEA_URL` (e.g. `https://mgit.msbls.de`, no `/api/v1` suffix), `GITEA_TOKEN`. Both live in Dokploy secrets; `GITEA_URL` unset → integration off cleanly (Issues section just doesn't render). `GITEA_URL` set but `GITEA_TOKEN` missing → refuse to start. +## 7. MCP surface (Phase 3a) + +projax exposes its data + writes through an MCP server mounted on the same binary at `/mcp/rpc`. Mirrors the conventions of `mcp__mai__*` and `mcp__mai-memory__*` — one tool per coherent operation, snake_case names, structured JSON results carried inside the standard MCP `content[].text` envelope. + +### Tools + +| name | summary | key inputs | +|-------------------|---------|------------| +| `list_items` | List items with filters | `parent_path`, `tags[]`, `management[]`, `kind[]`, `status`, `q`, `has_repo`, `has_caldav`, `limit` | +| `get_item` | Fetch one item by id or path | `id` xor `path`, `include_links` (default true) | +| `create_item` | Create a new item | `slug`, `title`, `parent_paths[]`, `kind[]`, `tags[]`, `management[]`, `content_md`, `status`, `metadata` | +| `update_item` | Partial update of an existing item | `id` xor `path`, any subset of editable fields | +| `delete_item` | Soft-delete; refuses on live descendants unless `cascade=true` | `id` xor `path`, `cascade` | +| `list_links` | List item_links attached to an item | `id` xor `path`, optional `ref_type` | +| `add_link` | Add an external item_link | `ref_type`, `ref_id`, `rel`, `note`, `metadata` | +| `remove_link` | Delete an item_link by id | `link_id` | +| `search` | Ranked substring search across title/slug/aliases/content_md | `query`, `limit` | +| `tree` | Nested tree (multi-parent items appear under each branch) | `root_path`, `depth` | + +### Output shape + +All tools return a JSON object inside a single MCP text-content block. `list_items`, `list_links`, `search`, `tree` return `{count|roots, items|links|tree}`. `get_item` and write tools return a single `itemView` / `linkView` with snake_case fields matching `projax.items_unified`'s columns. + +### Multi-parent semantics + +- `list_items` with `parent_path='work'` matches any item whose `paths[]` contains a path equal to `work` or beginning with `work.` — multi-parent items surface from any ancestor. +- `get_item` resolves either by uuid or by any path the row publishes; `dev.paliad` and `work.paliad` return the same row. +- `create_item` accepts `parent_paths` as a string array: `[]` for a root, `['work']` for single-parent, `['work', 'dev']` for multi. +- `update_item` with a non-nil `parent_paths` *replaces* the full parent list; pass the current list plus the new one to add a parent. +- `tree` honours multi-parent — the same uuid appears under each branch with its inherited path as the node's `path` field. + +### Transport + auth + +- HTTP+JSON-RPC 2.0 over `POST /mcp/rpc` (no SSE needed at v1 — every tool returns synchronously). +- Bearer auth via `Authorization: Bearer `. `/mcp/*` paths are exempt from the cookie auth middleware so API callers don't need a Supabase session. +- A GET on `/mcp/rpc` returns a small descriptor `{server, version, protocolVersion, tools[], authRequired}` for ops smoke-testing. + +### Bridge for stdio MCP clients + +`~/.claude/mcp/projax.sh` is a tiny bash bridge: reads NDJSON JSON-RPC frames from stdin, POSTs each to `${PROJAX_MCP_URL}/rpc` with the Bearer header, writes the response back to stdout. The repo-root `.mcp.json` exposes both wirings: + +- An `http` server entry for clients that speak HTTP+MCP natively. +- A `command` server entry (referenced separately under `~/.claude/mcp/projax.sh`) for stdio-only clients. + +Neither encodes a token; both interpolate `${PROJAX_MCP_TOKEN}` at session start. + +### Env contract + +- `PROJAX_MCP_TOKEN` — 32-char Bearer secret. Unset → `/mcp/*` returns 404 (off cleanly, the web UI keeps working). Set → routes mount, every request requires the matching Bearer. + +Out of scope (parked): + +- Server-pushed notifications / SSE — phase 3b. +- Bulk import/export tools — phase 3b. +- Otto-PWA integration that consumes this surface — separate worker. + ## 8. Open questions (post-PRD) - **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger. diff --git a/mcp/handler.go b/mcp/handler.go new file mode 100644 index 0000000..5609e89 --- /dev/null +++ b/mcp/handler.go @@ -0,0 +1,286 @@ +// Package mcp implements a small MCP-protocol server over HTTP. The wire +// format is JSON-RPC 2.0 with the MCP method set (initialize, tools/list, +// tools/call). Designed to be mounted under /mcp/rpc on the projax web +// binary; a stdio bridge (see ~/.claude/mcp/projax.sh) lets standard MCP +// clients talk to it transparently. +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +// ProtocolVersion is the MCP wire version this server speaks. Clients that +// initialize with a different version are still answered; they're expected +// to negotiate down. +const ProtocolVersion = "2024-11-05" + +// JSON-RPC 2.0 error codes (subset). +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 +) + +// Tool describes one callable tool exposed through tools/list and tools/call. +type Tool struct { + Name string // e.g. "list_items" + Description string // one-line description for the client + InputSchema json.RawMessage // JSON-schema object describing the params + Handler ToolHandler +} + +// ToolHandler runs the actual work for a tool. params is the raw JSON object +// the client supplied as the tool arguments. Returning a non-nil result is +// wrapped as a structured text content block; returning an error becomes an +// MCP "isError: true" reply. +type ToolHandler func(ctx context.Context, params json.RawMessage) (any, error) + +// Server holds the registered tools + the auth token. Mount via Routes() on +// any *http.ServeMux. +type Server struct { + Name string + Version string + Token string // Bearer token; empty means "no auth" (tests only) + Logger *slog.Logger + tools map[string]Tool +} + +// New builds an MCP server with no tools registered. +func New(name, version, token string, logger *slog.Logger) *Server { + if logger == nil { + logger = slog.Default() + } + return &Server{ + Name: name, + Version: version, + Token: token, + Logger: logger, + tools: map[string]Tool{}, + } +} + +// Register adds a tool. Duplicate names overwrite. +func (s *Server) Register(t Tool) { s.tools[t.Name] = t } + +// Routes registers /rpc on the given mux prefix-relative path. Caller mounts +// at /mcp so the resulting URL is /mcp/rpc. +func (s *Server) Routes(mux *http.ServeMux) { + mux.HandleFunc("POST /rpc", s.handleRPC) + // Friendly GET for ops smoke-testing — never returns secrets. + mux.HandleFunc("GET /rpc", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "server": s.Name, + "version": s.Version, + "protocolVersion": ProtocolVersion, + "tools": s.toolNames(), + "authRequired": s.Token != "", + }) + }) +} + +func (s *Server) toolNames() []string { + out := make([]string, 0, len(s.tools)) + for n := range s.tools { + out = append(out, n) + } + return out +} + +// jsonRPCReq mirrors the wire shape. Method "notifications/initialized" has +// no id (notification) — we tolerate the missing id field. +type jsonRPCReq struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type jsonRPCResp struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) { + if s.Token != "" { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) + if err != nil { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return + } + var req jsonRPCReq + if err := json.Unmarshal(body, &req); err != nil { + s.writeErr(w, nil, codeParseError, "invalid JSON: "+err.Error()) + return + } + if req.JSONRPC != "2.0" && req.JSONRPC != "" { + s.writeErr(w, req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + switch req.Method { + case "initialize": + s.writeOK(w, req.ID, s.initializeResult()) + case "notifications/initialized": + // Notifications get no response on JSON-RPC, but if the client sent an + // id we humour it with an empty result. + if len(req.ID) > 0 { + s.writeOK(w, req.ID, map[string]any{}) + } else { + w.WriteHeader(http.StatusNoContent) + } + case "tools/list": + s.writeOK(w, req.ID, s.toolsListResult()) + case "tools/call": + s.handleToolsCall(ctx, w, req) + case "ping": + s.writeOK(w, req.ID, map[string]any{}) + default: + s.writeErr(w, req.ID, codeMethodNotFound, "unknown method: "+req.Method) + } +} + +func (s *Server) checkAuth(r *http.Request) bool { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + return false + } + return strings.TrimSpace(h[len("Bearer "):]) == s.Token +} + +func (s *Server) initializeResult() map[string]any { + return map[string]any{ + "protocolVersion": ProtocolVersion, + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": s.Name, + "version": s.Version, + }, + } +} + +type toolDescriptor struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +func (s *Server) toolsListResult() map[string]any { + out := make([]toolDescriptor, 0, len(s.tools)) + for _, t := range s.tools { + schema := t.InputSchema + if len(schema) == 0 { + schema = json.RawMessage(`{"type":"object","properties":{}}`) + } + out = append(out, toolDescriptor{ + Name: t.Name, + Description: t.Description, + InputSchema: schema, + }) + } + return map[string]any{"tools": out} +} + +type toolsCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +func (s *Server) handleToolsCall(ctx context.Context, w http.ResponseWriter, req jsonRPCReq) { + var p toolsCallParams + if err := json.Unmarshal(req.Params, &p); err != nil { + s.writeErr(w, req.ID, codeInvalidParams, "tools/call params: "+err.Error()) + return + } + tool, ok := s.tools[p.Name] + if !ok { + s.writeErr(w, req.ID, codeMethodNotFound, "unknown tool: "+p.Name) + return + } + result, err := tool.Handler(ctx, p.Arguments) + if err != nil { + // Per MCP convention, tool errors stay inside the result envelope with + // isError=true so the client sees them as tool failures, not transport + // failures. JSON-RPC-level errors are reserved for transport problems + // (auth, parse, unknown method). + s.Logger.Warn("mcp tool error", "tool", p.Name, "err", err) + s.writeOK(w, req.ID, map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": err.Error(), + }}, + "isError": true, + }) + return + } + payload, err := json.Marshal(result) + if err != nil { + s.writeErr(w, req.ID, codeInternalError, "marshal result: "+err.Error()) + return + } + s.writeOK(w, req.ID, map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": string(payload), + }}, + "isError": false, + }) +} + +func (s *Server) writeOK(w http.ResponseWriter, id json.RawMessage, result any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jsonRPCResp{JSONRPC: "2.0", ID: id, Result: result}) +} + +func (s *Server) writeErr(w http.ResponseWriter, id json.RawMessage, code int, message string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jsonRPCResp{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message}}) +} + +// ToolError is returned by ToolHandlers for user-visible failures that should +// flow through the tool-result envelope as isError. Errors that do NOT match +// this type get wrapped automatically — this is just a sentinel for callers +// that want to provide structured user-facing data alongside the message. +type ToolError struct { + Msg string + Data any +} + +func (e *ToolError) Error() string { return e.Msg } + +// AsToolError returns the ToolError if err is one (or wraps one). +func AsToolError(err error) (*ToolError, bool) { + var te *ToolError + if errors.As(err, &te) { + return te, true + } + return nil, false +} + +var _ = fmt.Sprintf // keep import handy if future code uses fmt diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go new file mode 100644 index 0000000..6602885 --- /dev/null +++ b/mcp/mcp_test.go @@ -0,0 +1,162 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// rpcJSON builds a JSON-RPC request body. +func rpcJSON(t *testing.T, id any, method string, params any) []byte { + t.Helper() + out, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return out +} + +func doRPC(t *testing.T, srv *Server, body []byte, token string) (*http.Response, []byte) { + t.Helper() + mux := http.NewServeMux() + srv.Routes(mux) + s := httptest.NewServer(http.StripPrefix("", mux)) + t.Cleanup(s.Close) + req, _ := http.NewRequest(http.MethodPost, s.URL+"/rpc", bytes.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + t.Cleanup(func() { resp.Body.Close() }) + raw, _ := io.ReadAll(resp.Body) + return resp, raw +} + +func TestInitializeAndToolsList(t *testing.T) { + srv := New("projax-test", "0.0.1", "", nil) + srv.Register(Tool{ + Name: "echo", + Description: "echoes input.message", + InputSchema: json.RawMessage(`{"type":"object","required":["message"]}`), + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { + var in struct { + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &in); err != nil { + return nil, err + } + return map[string]any{"echo": in.Message}, nil + }, + }) + + _, body := doRPC(t, srv, rpcJSON(t, 1, "initialize", map[string]any{}), "") + if !strings.Contains(string(body), `"protocolVersion":"2024-11-05"`) { + t.Fatalf("initialize body missing protocolVersion: %s", body) + } + if !strings.Contains(string(body), `"name":"projax-test"`) { + t.Errorf("initialize missing server name: %s", body) + } + + _, body = doRPC(t, srv, rpcJSON(t, 2, "tools/list", map[string]any{}), "") + if !strings.Contains(string(body), `"name":"echo"`) { + t.Fatalf("tools/list missing echo tool: %s", body) + } +} + +func TestToolsCallSuccessAndError(t *testing.T) { + srv := New("p", "1", "", nil) + srv.Register(Tool{ + Name: "echo", + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { + return map[string]any{"got": string(raw)}, nil + }, + }) + srv.Register(Tool{ + Name: "boom", + Handler: func(ctx context.Context, raw json.RawMessage) (any, error) { + return nil, &ToolError{Msg: "kaboom"} + }, + }) + + _, body := doRPC(t, srv, rpcJSON(t, 3, "tools/call", map[string]any{ + "name": "echo", + "arguments": map[string]any{"x": 1}, + }), "") + if !strings.Contains(string(body), `"isError":false`) { + t.Fatalf("success response missing isError:false: %s", body) + } + // Tool results land inside a content[].text block as a JSON-stringified + // payload, so the inner double-quote bytes show up double-escaped. We + // just check the original key is present somewhere in the response. + if !strings.Contains(string(body), "got") { + t.Errorf("echo did not include 'got' key: %s", body) + } + + _, body = doRPC(t, srv, rpcJSON(t, 4, "tools/call", map[string]any{ + "name": "boom", + "arguments": map[string]any{}, + }), "") + if !strings.Contains(string(body), `"isError":true`) { + t.Fatalf("error response missing isError:true: %s", body) + } + if !strings.Contains(string(body), "kaboom") { + t.Errorf("error response missing message: %s", body) + } +} + +func TestAuthBearerRequired(t *testing.T) { + srv := New("p", "1", "s3cr3t", nil) + + resp, _ := doRPC(t, srv, rpcJSON(t, 1, "initialize", map[string]any{}), "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("missing token: expected 401, got %d", resp.StatusCode) + } + + resp, _ = doRPC(t, srv, rpcJSON(t, 1, "initialize", map[string]any{}), "wrong") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("wrong token: expected 401, got %d", resp.StatusCode) + } + + resp, body := doRPC(t, srv, rpcJSON(t, 1, "initialize", map[string]any{}), "s3cr3t") + if resp.StatusCode != http.StatusOK { + t.Fatalf("good token: expected 200, got %d (%s)", resp.StatusCode, body) + } +} + +func TestUnknownMethod(t *testing.T) { + srv := New("p", "1", "", nil) + _, body := doRPC(t, srv, rpcJSON(t, 1, "fly/me/to/the/moon", map[string]any{}), "") + if !strings.Contains(string(body), `"code":-32601`) { + t.Fatalf("expected method-not-found error, got %s", body) + } +} + +func TestNotificationsInitializedNoResponse(t *testing.T) { + srv := New("p", "1", "", nil) + // Notification — no id field. + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }) + resp, raw := doRPC(t, srv, body, "") + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("notif: expected 204, got %d (%s)", resp.StatusCode, raw) + } + if len(raw) != 0 { + t.Errorf("notif: expected empty body, got %s", raw) + } +} diff --git a/mcp/tools.go b/mcp/tools.go new file mode 100644 index 0000000..208db77 --- /dev/null +++ b/mcp/tools.go @@ -0,0 +1,725 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/m/projax/store" +) + +// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server. +// All tools delegate to *store.Store directly so business logic is shared +// with the web UI — no duplication. +func RegisterProjaxTools(s *Server, st *store.Store) { + s.Register(Tool{ + Name: "list_items", + Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav).", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "parent_path": {"type": "string", "description": "Match items whose paths array contains a path beginning with this prefix"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "All tags must be present"}, + "management": {"type": "array", "items": {"type": "string"}, "description": "All management modes must be present (e.g. ['mai'])"}, + "kind": {"type": "array", "items": {"type": "string"}, "description": "Any of these kinds matches"}, + "status": {"type": "string"}, + "q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}, + "has_repo": {"type": "boolean"}, + "has_caldav": {"type": "boolean"}, + "limit": {"type": "integer", "minimum": 0} + } + }`), + Handler: listItemsTool(st), + }) + s.Register(Tool{ + Name: "get_item", + Description: "Fetch a single item by id, dot-path (e.g. 'dev.paliad'), or root slug. Multi-parent items resolve to the same row from any path.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "id": {"type": "string", "description": "uuid"}, + "path": {"type": "string", "description": "Dot-path or slug"}, + "include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"} + } + }`), + Handler: getItemTool(st), + }) + s.Register(Tool{ + Name: "create_item", + Description: "Create a new projax item. parent_paths is a string[] — pass [] for a root, ['work'] for single-parent, ['work','dev'] for multi-parent.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["slug", "title"], + "properties": { + "slug": {"type": "string"}, + "title": {"type": "string"}, + "parent_paths": {"type": "array", "items": {"type": "string"}}, + "kind": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "management": {"type": "array", "items": {"type": "string"}}, + "content_md": {"type": "string"}, + "status": {"type": "string"}, + "metadata": {"type": "object"} + } + }`), + Handler: createItemTool(st), + }) + s.Register(Tool{ + Name: "update_item", + Description: "Partial update of an existing item. Pass any subset of title/slug/content_md/status/tags/management/parent_paths/pinned/archived. parent_paths replaces the full parent list.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "id": {"type": "string"}, + "path": {"type": "string"}, + "title": {"type": "string"}, + "slug": {"type": "string"}, + "parent_paths": {"type": "array", "items": {"type": "string"}}, + "content_md": {"type": "string"}, + "status": {"type": "string"}, + "pinned": {"type": "boolean"}, + "archived": {"type": "boolean"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "management": {"type": "array", "items": {"type": "string"}} + } + }`), + Handler: updateItemTool(st), + }) + s.Register(Tool{ + Name: "delete_item", + Description: "Soft-delete an item. Refuses on live descendants unless cascade=true.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "id": {"type": "string"}, + "path": {"type": "string"}, + "cascade": {"type": "boolean", "description": "Soft-delete every descendant too"} + } + }`), + Handler: deleteItemTool(st), + }) + s.Register(Tool{ + Name: "list_links", + Description: "List item_links attached to one item.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "id": {"type": "string"}, + "path": {"type": "string"}, + "ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"} + } + }`), + Handler: listLinksTool(st), + }) + s.Register(Tool{ + Name: "add_link", + Description: "Add an external item_link to an item (caldav-list / gitea-repo / mbrian-node / url / …).", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["ref_type", "ref_id"], + "properties": { + "id": {"type": "string"}, + "path": {"type": "string"}, + "ref_type": {"type": "string"}, + "ref_id": {"type": "string"}, + "rel": {"type": "string", "description": "Relation, default 'contains'"}, + "note": {"type": "string"}, + "metadata": {"type": "object"} + } + }`), + Handler: addLinkTool(st), + }) + s.Register(Tool{ + Name: "remove_link", + Description: "Delete an item_link by id.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["link_id"], + "properties": {"link_id": {"type": "string"}} + }`), + Handler: removeLinkTool(st), + }) + s.Register(Tool{ + Name: "search", + Description: "Ranked substring search across title/slug/aliases/content_md. Buckets: exact-slug → title-prefix → title-contains → alias → content.", + InputSchema: json.RawMessage(`{ + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200} + } + }`), + Handler: searchTool(st), + }) + s.Register(Tool{ + Name: "tree", + Description: "Return a nested tree of items. Multi-parent items appear under each ancestor branch.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "root_path": {"type": "string", "description": "Optional subtree root; default returns the whole forest"}, + "depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"} + } + }`), + Handler: treeTool(st), + }) +} + +// itemView is the JSON shape returned to MCP clients. We hand-roll it so the +// field names stay snake_case and the *time.Time / *string nullability +// renders as JSON null instead of being skipped (omitempty would hide them). +type itemView struct { + ID string `json:"id"` + Kind []string `json:"kind"` + Title string `json:"title"` + Slug string `json:"slug"` + Paths []string `json:"paths"` + ParentIDs []string `json:"parent_ids"` + ContentMD string `json:"content_md"` + Aliases []string `json:"aliases"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + StartTime any `json:"start_time"` + EndTime any `json:"end_time"` + Source string `json:"source"` + SourceRefID any `json:"source_ref_id"` + Tags []string `json:"tags"` + Management []string `json:"management"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Links []linkView `json:"links,omitempty"` +} + +type linkView struct { + ID string `json:"id"` + ItemID string `json:"item_id"` + RefType string `json:"ref_type"` + RefID string `json:"ref_id"` + Rel string `json:"rel"` + Note any `json:"note"` + Metadata map[string]any `json:"metadata"` + CreatedAt string `json:"created_at"` +} + +func toItemView(it *store.Item) itemView { + v := itemView{ + ID: it.ID, + Kind: sliceOr(it.Kind, []string{}), + Title: it.Title, + Slug: it.Slug, + Paths: sliceOr(it.Paths, []string{}), + ParentIDs: sliceOr(it.ParentIDs, []string{}), + ContentMD: it.ContentMD, + Aliases: sliceOr(it.Aliases, []string{}), + Metadata: mapOr(it.Metadata), + Status: it.Status, + Pinned: it.Pinned, + Archived: it.Archived, + Source: it.Source, + Tags: sliceOr(it.Tags, []string{}), + Management: sliceOr(it.Management, []string{}), + CreatedAt: it.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: it.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if it.StartTime != nil { + v.StartTime = it.StartTime.UTC().Format("2006-01-02T15:04:05Z") + } + if it.EndTime != nil { + v.EndTime = it.EndTime.UTC().Format("2006-01-02T15:04:05Z") + } + if it.SourceRefID != nil { + v.SourceRefID = *it.SourceRefID + } + return v +} + +func toLinkView(l *store.ItemLink) linkView { + v := linkView{ + ID: l.ID, + ItemID: l.ItemID, + RefType: l.RefType, + RefID: l.RefID, + Rel: l.Rel, + Metadata: mapOr(l.Metadata), + CreatedAt: l.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if l.Note != nil { + v.Note = *l.Note + } + return v +} + +func sliceOr[T any](v []T, fallback []T) []T { + if v == nil { + return fallback + } + return v +} +func mapOr(v map[string]any) map[string]any { + if v == nil { + return map[string]any{} + } + return v +} + +// resolveItem turns an id-or-path argument pair into a concrete *store.Item. +func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) { + id = strings.TrimSpace(id) + path = strings.TrimSpace(path) + if id != "" { + return st.GetByID(ctx, id) + } + if path != "" { + return st.GetByPathOrSlug(ctx, path) + } + return nil, errors.New("either id or path is required") +} + +func parseInput[T any](raw json.RawMessage, dst *T) error { + if len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, dst) +} + +// --- list_items --- + +func listItemsTool(st *store.Store) ToolHandler { + type input struct { + ParentPath string `json:"parent_path"` + Tags []string `json:"tags"` + Management []string `json:"management"` + Kind []string `json:"kind"` + Status string `json:"status"` + Q string `json:"q"` + HasRepo *bool `json:"has_repo"` + HasCalDAV *bool `json:"has_caldav"` + Limit int `json:"limit"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + items, err := st.ListByFilters(ctx, store.SearchFilters{ + ParentPath: in.ParentPath, + Tags: in.Tags, + Management: in.Management, + Kind: in.Kind, + Status: in.Status, + Q: in.Q, + HasRepo: in.HasRepo, + HasCalDAV: in.HasCalDAV, + Limit: in.Limit, + }) + if err != nil { + return nil, err + } + views := make([]itemView, 0, len(items)) + for _, it := range items { + views = append(views, toItemView(it)) + } + return map[string]any{"items": views, "count": len(views)}, nil + } +} + +// --- get_item --- + +func getItemTool(st *store.Store) ToolHandler { + type input struct { + ID string `json:"id"` + Path string `json:"path"` + IncludeLinks *bool `json:"include_links"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + it, err := resolveItem(ctx, st, in.ID, in.Path) + if err != nil { + return nil, err + } + view := toItemView(it) + include := true + if in.IncludeLinks != nil { + include = *in.IncludeLinks + } + if include { + links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types + // LinksByType filters by ref_type — empty would return nothing. So + // we explicitly list_all by fanning across the known types. + _ = err + links = nil + for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} { + ll, err := st.LinksByType(ctx, it.ID, t) + if err != nil { + continue + } + links = append(links, ll...) + } + views := make([]linkView, 0, len(links)) + for _, l := range links { + views = append(views, toLinkView(l)) + } + view.Links = views + } + return view, nil + } +} + +// --- create_item --- + +func createItemTool(st *store.Store) ToolHandler { + type input struct { + Slug string `json:"slug"` + Title string `json:"title"` + ParentPaths []string `json:"parent_paths"` + Kind []string `json:"kind"` + Tags []string `json:"tags"` + Management []string `json:"management"` + ContentMD string `json:"content_md"` + Status string `json:"status"` + Metadata map[string]any `json:"metadata"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + if in.Slug == "" || in.Title == "" { + return nil, errors.New("slug and title are required") + } + parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths) + if err != nil { + return nil, err + } + kind := in.Kind + if len(kind) == 0 { + kind = []string{"project"} + } + it, err := st.Create(ctx, store.CreateInput{ + Kind: kind, + Title: in.Title, + Slug: in.Slug, + ParentIDs: parentIDs, + ContentMD: in.ContentMD, + Status: in.Status, + Tags: in.Tags, + Management: in.Management, + Metadata: in.Metadata, + }) + if err != nil { + return nil, err + } + return toItemView(it), nil + } +} + +func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) { + out := make([]string, 0, len(paths)) + for _, p := range paths { + p = strings.TrimSpace(p) + if p == "" { + continue + } + it, err := st.GetByPathOrSlug(ctx, p) + if err != nil { + return nil, fmt.Errorf("parent path %q: %w", p, err) + } + out = append(out, it.ID) + } + return out, nil +} + +// --- update_item --- + +func updateItemTool(st *store.Store) ToolHandler { + type input struct { + ID string `json:"id"` + Path string `json:"path"` + Title *string `json:"title"` + Slug *string `json:"slug"` + ParentPaths *[]string `json:"parent_paths"` + ContentMD *string `json:"content_md"` + Status *string `json:"status"` + Pinned *bool `json:"pinned"` + Archived *bool `json:"archived"` + Tags *[]string `json:"tags"` + Management *[]string `json:"management"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + it, err := resolveItem(ctx, st, in.ID, in.Path) + if err != nil { + return nil, err + } + patch := store.UpdateInput{ + Title: it.Title, + Slug: it.Slug, + ParentIDs: it.ParentIDs, + ContentMD: it.ContentMD, + Status: it.Status, + Pinned: it.Pinned, + Archived: it.Archived, + Tags: it.Tags, + Management: it.Management, + } + if in.Title != nil { + patch.Title = *in.Title + } + if in.Slug != nil { + patch.Slug = *in.Slug + } + if in.ContentMD != nil { + patch.ContentMD = *in.ContentMD + } + if in.Status != nil { + patch.Status = *in.Status + } + if in.Pinned != nil { + patch.Pinned = *in.Pinned + } + if in.Archived != nil { + patch.Archived = *in.Archived + } + if in.Tags != nil { + patch.Tags = *in.Tags + } + if in.Management != nil { + patch.Management = *in.Management + } + if in.ParentPaths != nil { + pids, err := resolveParentPaths(ctx, st, *in.ParentPaths) + if err != nil { + return nil, err + } + patch.ParentIDs = pids + } + updated, err := st.Update(ctx, it.ID, patch) + if err != nil { + return nil, err + } + return toItemView(updated), nil + } +} + +// --- delete_item --- + +func deleteItemTool(st *store.Store) ToolHandler { + type input struct { + ID string `json:"id"` + Path string `json:"path"` + Cascade bool `json:"cascade"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + it, err := resolveItem(ctx, st, in.ID, in.Path) + if err != nil { + return nil, err + } + if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil { + return nil, err + } + return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil + } +} + +// --- list_links --- + +func listLinksTool(st *store.Store) ToolHandler { + type input struct { + ID string `json:"id"` + Path string `json:"path"` + RefType string `json:"ref_type"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + it, err := resolveItem(ctx, st, in.ID, in.Path) + if err != nil { + return nil, err + } + var links []*store.ItemLink + if in.RefType != "" { + links, err = st.LinksByType(ctx, it.ID, in.RefType) + } else { + for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} { + ll, lerr := st.LinksByType(ctx, it.ID, t) + if lerr != nil { + continue + } + links = append(links, ll...) + } + } + if err != nil { + return nil, err + } + views := make([]linkView, 0, len(links)) + for _, l := range links { + views = append(views, toLinkView(l)) + } + return map[string]any{"links": views, "count": len(views)}, nil + } +} + +// --- add_link / remove_link --- + +func addLinkTool(st *store.Store) ToolHandler { + type input struct { + ID string `json:"id"` + Path string `json:"path"` + RefType string `json:"ref_type"` + RefID string `json:"ref_id"` + Rel string `json:"rel"` + Note string `json:"note"` + Metadata map[string]any `json:"metadata"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + if in.RefType == "" || in.RefID == "" { + return nil, errors.New("ref_type and ref_id are required") + } + it, err := resolveItem(ctx, st, in.ID, in.Path) + if err != nil { + return nil, err + } + md := in.Metadata + if md == nil { + md = map[string]any{} + } + if in.Note != "" { + md["note"] = in.Note + } + link, err := st.AddLink(ctx, it.ID, in.RefType, in.RefID, in.Rel, md) + if err != nil { + return nil, err + } + return toLinkView(link), nil + } +} + +func removeLinkTool(st *store.Store) ToolHandler { + type input struct { + LinkID string `json:"link_id"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + if in.LinkID == "" { + return nil, errors.New("link_id is required") + } + if err := st.DeleteLink(ctx, in.LinkID); err != nil { + return nil, err + } + return map[string]any{"deleted": in.LinkID}, nil + } +} + +// --- search --- + +func searchTool(st *store.Store) ToolHandler { + type input struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + if in.Query == "" { + return nil, errors.New("query is required") + } + items, err := st.Search(ctx, in.Query, in.Limit) + if err != nil { + return nil, err + } + views := make([]itemView, 0, len(items)) + for _, it := range items { + views = append(views, toItemView(it)) + } + return map[string]any{"items": views, "count": len(views), "query": in.Query}, nil + } +} + +// --- tree --- + +type treeNode struct { + Item itemView `json:"item"` + Path string `json:"path"` // the path under which this node appears in the tree + Children []*treeNode `json:"children"` +} + +func treeTool(st *store.Store) ToolHandler { + type input struct { + RootPath string `json:"root_path"` + Depth int `json:"depth"` + } + return func(ctx context.Context, raw json.RawMessage) (any, error) { + var in input + if err := parseInput(raw, &in); err != nil { + return nil, fmt.Errorf("bad params: %w", err) + } + items, err := st.ListAll(ctx) + if err != nil { + return nil, err + } + // Build adjacency by parent id (the same row appears once per parent). + byID := map[string]*store.Item{} + childrenByParent := map[string][]*store.Item{} + var roots []*store.Item + for _, it := range items { + byID[it.ID] = it + if len(it.ParentIDs) == 0 { + roots = append(roots, it) + continue + } + for _, pid := range it.ParentIDs { + childrenByParent[pid] = append(childrenByParent[pid], it) + } + } + var build func(it *store.Item, path string, depth int) *treeNode + build = func(it *store.Item, path string, depth int) *treeNode { + n := &treeNode{Item: toItemView(it), Path: path} + if in.Depth > 0 && depth >= in.Depth { + return n + } + for _, c := range childrenByParent[it.ID] { + n.Children = append(n.Children, build(c, path+"."+c.Slug, depth+1)) + } + return n + } + var out []*treeNode + if in.RootPath != "" { + root, err := st.GetByPathOrSlug(ctx, in.RootPath) + if err != nil { + return nil, err + } + out = append(out, build(root, in.RootPath, 0)) + } else { + for _, r := range roots { + out = append(out, build(r, r.Slug, 0)) + } + } + return map[string]any{"tree": out, "roots": len(out)}, nil + } +} diff --git a/mcp/tools_test.go b/mcp/tools_test.go new file mode 100644 index 0000000..8e0abae --- /dev/null +++ b/mcp/tools_test.go @@ -0,0 +1,184 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/m/projax/store" +) + +// mustDBServer spins up a full MCP server bound to msupabase + the projax +// schema. Tests skip cleanly when no DB env is set. +func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) { + t.Helper() + dbURL := os.Getenv("PROJAX_DB_URL") + if dbURL == "" { + dbURL = os.Getenv("SUPABASE_DATABASE_URL") + } + if dbURL == "" { + t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL — skipping MCP integration test") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + t.Fatalf("pool: %v", err) + } + if err := pool.Ping(ctx); err != nil { + t.Skipf("db ping: %v", err) + } + st := store.New(pool) + srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil))) + RegisterProjaxTools(srv, st) + t.Cleanup(func() { pool.Close() }) + return srv, pool +} + +func callTool(t *testing.T, srv *Server, name string, args any, token string) map[string]any { + t.Helper() + mux := http.NewServeMux() + srv.Routes(mux) + s := httptest.NewServer(mux) + t.Cleanup(s.Close) + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 42, + "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + req, _ := http.NewRequest(http.MethodPost, s.URL+"/rpc", bytes.NewReader(body)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do %s: %v", name, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s HTTP %d: %s", name, resp.StatusCode, raw) + } + var env struct { + Result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("decode envelope: %v (%s)", err, raw) + } + if env.Result.IsError { + t.Fatalf("tool %s returned isError=true: %s", name, env.Result.Content[0].Text) + } + if len(env.Result.Content) == 0 { + t.Fatalf("tool %s returned no content blocks: %s", name, raw) + } + var out map[string]any + if err := json.Unmarshal([]byte(env.Result.Content[0].Text), &out); err != nil { + // Single-value (non-object) results are returned bare; wrap so the + // caller can still introspect. + return map[string]any{"raw": env.Result.Content[0].Text} + } + return out +} + +func TestMCPListItemsIntegration(t *testing.T) { + srv, _ := mustDBServer(t) + got := callTool(t, srv, "list_items", map[string]any{"limit": 5}, "tok") + count, _ := got["count"].(float64) + if count <= 0 { + t.Fatalf("expected at least one item, got %v", got) + } +} + +func TestMCPGetItemMultiParent(t *testing.T) { + srv, pool := mustDBServer(t) + // Discover any item that lives under multiple parents in the live DB, then + // confirm both resolved paths return the same uuid. Avoids hard-coding a + // row that may move. + var id, p1, p2 string + err := pool.QueryRow(context.Background(), + `select id::text, paths[1], paths[2] from projax.items_unified + where cardinality(paths) >= 2 + order by paths[1] + limit 1`).Scan(&id, &p1, &p2) + if err != nil { + t.Skipf("no multi-parent items in DB: %v", err) + } + a := callTool(t, srv, "get_item", map[string]any{"path": p1}, "tok") + b := callTool(t, srv, "get_item", map[string]any{"path": p2}, "tok") + if a["id"] != b["id"] || a["id"] != id { + t.Fatalf("multi-parent mismatch: %s -> %v, %s -> %v (expected %s)", p1, a["id"], p2, b["id"], id) + } +} + +func TestMCPCreateAndDeleteItem(t *testing.T) { + srv, pool := mustDBServer(t) + slug := "mcp-roundtrip-" + time.Now().UTC().Format("20060102150405") + created := callTool(t, srv, "create_item", map[string]any{ + "slug": slug, + "title": "MCP round-trip test", + "parent_paths": []string{"dev"}, + "kind": []string{"project"}, + "tags": []string{"test"}, + "content_md": "ephemeral", + }, "tok") + id, _ := created["id"].(string) + if id == "" { + t.Fatalf("create_item returned no id: %v", created) + } + // Cleanup uses direct SQL so a test-body delete that already succeeded + // doesn't trip a t.Fatalf in the helper. Soft-delete is idempotent. + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), + `update projax.items set deleted_at = now() where id = $1 and deleted_at is null`, id) + }) + got := callTool(t, srv, "get_item", map[string]any{"id": id}, "tok") + if got["title"] != "MCP round-trip test" { + t.Errorf("get_item title mismatch: %v", got) + } + search := callTool(t, srv, "search", map[string]any{"query": slug, "limit": 5}, "tok") + if count, _ := search["count"].(float64); count < 1 { + t.Errorf("search did not find %q: %v", slug, search) + } + del := callTool(t, srv, "delete_item", map[string]any{"id": id}, "tok") + if del["deleted"] != id { + t.Errorf("delete_item returned %v", del) + } +} + +func TestMCPUnauthorized(t *testing.T) { + srv, _ := mustDBServer(t) + mux := http.NewServeMux() + srv.Routes(mux) + s := httptest.NewServer(mux) + t.Cleanup(s.Close) + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + }) + resp, err := http.Post(s.URL+"/rpc", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 without bearer, got %d", resp.StatusCode) + } +} diff --git a/store/store.go b/store/store.go index 39cb41b..cf7b6df 100644 --- a/store/store.go +++ b/store/store.go @@ -443,3 +443,222 @@ func (s *Store) SoftDelete(ctx context.Context, id string) error { _, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id) return err } + +// ErrHasLiveChildren is returned by SoftDeleteCascade when the caller did not +// request cascade=true and the item has at least one undeleted descendant. +var ErrHasLiveChildren = errors.New("projax: item has live children — pass cascade=true to soft-delete the whole subtree") + +// SoftDeleteCascade soft-deletes the item and, if cascade is true, every +// descendant (any row whose paths array contains a prefix matching this +// item's primary path). Without cascade, it refuses when there is at least +// one live descendant. +func (s *Store) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error { + it, err := s.GetByID(ctx, id) + if err != nil { + return err + } + primary := it.PrimaryPath() + // Children are any other live row that names this id in their parent_ids + // (direct children) or has a path with the primary path as a `.`-prefix + // (transitive descendants). + var childCount int + err = s.Pool.QueryRow(ctx, ` + select count(*) from projax.items + where deleted_at is null + and id <> $1 + and ($1::uuid = any(parent_ids) + or exists (select 1 from unnest(paths) p where p like $2 || '.%')) + `, id, primary).Scan(&childCount) + if err != nil { + return fmt.Errorf("count children: %w", err) + } + if childCount > 0 && !cascade { + return ErrHasLiveChildren + } + if childCount > 0 && cascade { + _, err := s.Pool.Exec(ctx, ` + update projax.items set deleted_at = now() + where deleted_at is null + and ($1::uuid = any(parent_ids) + or exists (select 1 from unnest(paths) p where p like $2 || '.%'))`, + id, primary) + if err != nil { + return fmt.Errorf("cascade soft-delete: %w", err) + } + } + return s.SoftDelete(ctx, id) +} + +// GetByPathOrSlug resolves a single item by either a dot path (any entry in +// paths) or a bare root slug. Slug-only inputs match items whose paths array +// contains exactly the slug (i.e. root items) as well as a fallback unique +// slug match — useful for MCP callers that don't know the path. +func (s *Store) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) { + key = sanitizeKey(key) + if key == "" { + return nil, ErrNotFound + } + row := s.Pool.QueryRow(ctx, `select `+itemsUnifiedCols+` + from projax.items_unified u + where ($1 = any(u.paths) or u.slug = $1) + order by case when $1 = any(u.paths) then 0 else 1 end + limit 1`, key) + it, err := scanItem(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return it, nil +} + +// SearchFilters narrows ListByFilters. Each field is treated independently; +// the predicates AND together. Empty fields are no-ops. +type SearchFilters struct { + ParentPath string // any item whose paths array contains a path beginning with this prefix + Tags []string // ALL must be present on the item + Management []string // ALL must be present on the item + Kind []string // ANY must be present on the item + Status string // exact match (active|done|archived) + Q string // ILIKE prefix-match on title/slug/aliases/content_md + HasRepo *bool // if non-nil, item must (or must not) have a gitea-repo link + HasCalDAV *bool // if non-nil, item must (or must not) have a caldav-list link + Limit int // 0 → no limit +} + +// ListByFilters returns items_unified rows matching all supplied predicates. +// Used by the MCP list_items tool. +func (s *Store) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) { + conds := []string{"true"} + args := []any{} + addArg := func(v any) string { + args = append(args, v) + return fmt.Sprintf("$%d", len(args)) + } + if f.ParentPath != "" { + p := addArg(f.ParentPath) + // Path equals or starts with `.` + conds = append(conds, fmt.Sprintf("exists (select 1 from unnest(u.paths) pp where pp = %s or pp like %s || '.%%')", p, p)) + } + if len(f.Tags) > 0 { + conds = append(conds, fmt.Sprintf("u.tags @> %s::text[]", addArg(f.Tags))) + } + if len(f.Management) > 0 { + conds = append(conds, fmt.Sprintf("u.management @> %s::text[]", addArg(f.Management))) + } + if len(f.Kind) > 0 { + conds = append(conds, fmt.Sprintf("u.kind && %s::text[]", addArg(f.Kind))) + } + if f.Status != "" { + conds = append(conds, fmt.Sprintf("u.status = %s", addArg(f.Status))) + } + if f.Q != "" { + q := addArg("%" + f.Q + "%") + conds = append(conds, fmt.Sprintf("(u.title ilike %s or u.slug ilike %s or u.content_md ilike %s or exists (select 1 from unnest(u.aliases) a where a ilike %s))", q, q, q, q)) + } + if f.HasRepo != nil { + op := "" + if *f.HasRepo { + op = "exists" + } else { + op = "not exists" + } + conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'gitea-repo')", op)) + } + if f.HasCalDAV != nil { + op := "" + if *f.HasCalDAV { + op = "exists" + } else { + op = "not exists" + } + conds = append(conds, fmt.Sprintf("%s (select 1 from projax.item_links l where l.item_id = u.id and l.ref_type = 'caldav-list')", op)) + } + q := `select ` + itemsUnifiedCols + ` from projax.items_unified u where ` + joinAnd(conds) + ` order by u.paths[1] nulls last, u.slug` + if f.Limit > 0 { + q += fmt.Sprintf(" limit %d", f.Limit) + } + rows, err := s.Pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanItems(rows) +} + +// Search returns ranked items_unified rows matching the query. Match buckets: +// (0) exact slug, (1) title ILIKE prefix, (2) title contains, (3) alias hit, +// (4) content_md contains. Within each bucket rows are sorted by primary path. +func (s *Store) Search(ctx context.Context, q string, limit int) ([]*Item, error) { + q = sanitizeKey(q) + if q == "" { + return nil, nil + } + if limit <= 0 || limit > 200 { + limit = 50 + } + // Ranking is computed in SQL with a virtual `match_rank`, then we re-select + // just the canonical column set so scanItems handles the row. + sql := `with ranked as ( + select u.*, + case + when u.slug = $1 then 0 + when u.title ilike $1 || '%' then 1 + when u.title ilike '%' || $1 || '%' then 2 + when exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%') then 3 + when u.content_md ilike '%' || $1 || '%' then 4 + else 5 + end as match_rank + from projax.items_unified u + where true + and ( + u.slug = $1 + or u.title ilike '%' || $1 || '%' + or u.content_md ilike '%' || $1 || '%' + or exists (select 1 from unnest(u.aliases) a where a ilike '%' || $1 || '%') + ) + ) + select ` + itemsUnifiedCols + ` + from ranked + order by match_rank, paths[1] nulls last, slug + limit $2` + rows, err := s.Pool.Query(ctx, sql, q, limit) + if err != nil { + return nil, err + } + return scanItems(rows) +} + +// sanitizeKey trims and rejects NUL / control characters that the planner +// would otherwise have to deal with. +func sanitizeKey(s string) string { + s = trimSpace(s) + for _, r := range s { + if r == 0 || (r < 0x20 && r != '\t') { + return "" + } + } + return s +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} + +func joinAnd(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += " and " + } + out += p + } + return out +} diff --git a/web/auth.go b/web/auth.go index 56149a8..016683c 100644 --- a/web/auth.go +++ b/web/auth.go @@ -82,12 +82,19 @@ func (e supabaseAuthError) Message() string { // On invalid session it 302s to /login?redirectTo=. func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Always-open routes: probe and auth endpoints themselves. + // Always-open routes: probe and auth endpoints themselves. The MCP + // surface uses its own Bearer-token auth (PROJAX_MCP_TOKEN) — letting + // it through the Supabase-cookie middleware keeps API callers from + // needing a session cookie. switch r.URL.Path { case "/healthz", loginPath, logoutPath: next.ServeHTTP(w, r) return } + if strings.HasPrefix(r.URL.Path, "/mcp/") { + next.ServeHTTP(w, r) + return + } access := tokenFromBearer(r) if access == "" { diff --git a/web/server.go b/web/server.go index cfa76b0..f1cc348 100644 --- a/web/server.go +++ b/web/server.go @@ -29,6 +29,7 @@ type Server struct { Auth *AuthConfig // nil → no auth (local dev / tests) CalDAV *CalDAVDeps // nil → CalDAV integration disabled Gitea *GiteaDeps // nil → Gitea integration disabled + MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly) } // New builds a Server. Each page is parsed alongside the layout into its own @@ -134,6 +135,10 @@ func (s *Server) Routes() http.Handler { fmt.Fprintln(w, "ok") }) + if s.MCP != nil { + mux.Handle("/mcp/", http.StripPrefix("/mcp", s.MCP)) + } + static, _ := fs.Sub(staticFS, "static") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))