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.
185 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|