Files
projax/mcp/tools_test.go
mAi dc50823860 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.
2026-05-15 17:59:03 +02:00

185 lines
5.5 KiB
Go

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