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": {
|
"headers": {
|
||||||
"Authorization": "Basic ${SUPABASE_AUTH}"
|
"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/caldav"
|
||||||
"github.com/m/projax/db"
|
"github.com/m/projax/db"
|
||||||
"github.com/m/projax/gitea"
|
"github.com/m/projax/gitea"
|
||||||
|
"github.com/m/projax/mcp"
|
||||||
"github.com/m/projax/store"
|
"github.com/m/projax/store"
|
||||||
"github.com/m/projax/web"
|
"github.com/m/projax/web"
|
||||||
)
|
)
|
||||||
@@ -103,6 +104,17 @@ func main() {
|
|||||||
logger.Info("gitea: disabled — GITEA_URL not set")
|
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{
|
httpServer := &http.Server{
|
||||||
Addr: listen,
|
Addr: listen,
|
||||||
Handler: srv.Routes(),
|
Handler: srv.Routes(),
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ secrets:
|
|||||||
- SUPABASE_ANON_KEY
|
- SUPABASE_ANON_KEY
|
||||||
- DAV_USER
|
- DAV_USER
|
||||||
- DAV_PASSWORD
|
- 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
|
||||||
|
|||||||
@@ -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.
|
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)
|
## 8. Open questions (post-PRD)
|
||||||
|
|
||||||
- **Path-trigger correctness** under cycle attempts: enforce acyclicity via check in trigger.
|
- **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)
|
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
|
||||||
return err
|
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>.
|
// On invalid session it 302s to /login?redirectTo=<safe-path>.
|
||||||
func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler {
|
func authMiddleware(cfg AuthConfig, logger *slog.Logger, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
switch r.URL.Path {
|
||||||
case "/healthz", loginPath, logoutPath:
|
case "/healthz", loginPath, logoutPath:
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/mcp/") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
access := tokenFromBearer(r)
|
access := tokenFromBearer(r)
|
||||||
if access == "" {
|
if access == "" {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Server struct {
|
|||||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||||
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
||||||
Gitea *GiteaDeps // nil → Gitea 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
|
// 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")
|
fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if s.MCP != nil {
|
||||||
|
mux.Handle("/mcp/", http.StripPrefix("/mcp", s.MCP))
|
||||||
|
}
|
||||||
|
|
||||||
static, _ := fs.Sub(staticFS, "static")
|
static, _ := fs.Sub(staticFS, "static")
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user