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:
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -46,3 +46,4 @@ secrets:
|
||||
- DAV_USER
|
||||
- DAV_PASSWORD
|
||||
- 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
|
||||
|
||||
@@ -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
286
mcp/handler.go
Normal 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
162
mcp/mcp_test.go
Normal 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
725
mcp/tools.go
Normal 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
184
mcp/tools_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
219
store/store.go
219
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 `<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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user