Exposes projax's /timeline aggregation (Phase 4a) over MCP-RPC so the PWA (mAi#228) can fetch it without a session cookie against projax.msbls.de. Same tool surface m's other agents already use. ## Changes - web/timeline.go: export TimelineQuery, TimelinePayload, add typed TimelineArgs + BuildTimelinePayloadFromArgs entrypoint. The web cache stays scoped to the HTTP handler; MCP path re-aggregates per call. - mcp/tools.go: register `timeline` tool when a TimelineBuilder is passed. Output mirrors the web template's shape but stringifies timestamps to YYYY-MM-DD or ISO-8601 UTC so JSON-RPC consumers don't need Go time semantics. - mcp/tools_test.go: existing tests pass nil builder (no behaviour change to the rest of the tool surface). - mcp/timeline_test.go: 7 unit tests covering registration, arg forwarding, error propagation, empty payload, and view serialisation. - cmd/projax/main.go: pass the running *web.Server as the third arg so the timeline tool registers on the live server (CalDAV-aware). - docs/design.md §14: documents the tool, schema, output shape, cache semantics. ## Out of scope - Caching the MCP path (rejected — re-aggregation per call is cheap; divergent cache keys aren't worth invalidation complexity). - Wrapping CalDAV writes (S2 — separate slice once m greenlights). - PWA backend bridge + frontend (S2/S3 — m/mAi side, after this deploys).
187 lines
5.7 KiB
Go
187 lines
5.7 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)))
|
|
// The MCP tests don't need a real timeline builder — passing nil keeps
|
|
// the timeline tool unregistered without requiring a web.Server here.
|
|
RegisterProjaxTools(srv, st, nil)
|
|
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)
|
|
}
|
|
}
|