feat(phase 3a mcp): MCP surface so mai/otto/Claude can read+write projax

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.
This commit is contained in:
mAi
2026-05-15 17:59:03 +02:00
parent 75a67c6a8b
commit dc50823860
11 changed files with 1666 additions and 2 deletions

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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 <PROJAX_MCP_TOKEN>`. `/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.

286
mcp/handler.go Normal file
View File

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

162
mcp/mcp_test.go Normal file
View File

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

725
mcp/tools.go Normal file
View File

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

184
mcp/tools_test.go Normal file
View File

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

View File

@@ -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 `<parent>.`
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
}

View File

@@ -82,12 +82,19 @@ func (e supabaseAuthError) Message() string {
// On invalid session it 302s to /login?redirectTo=<safe-path>.
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 == "" {

View File

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