Files
projax/mcp/tools_test.go
mAi 8b51746183 feat(phase 4c-B slice 1): MCP timeline tool wrapping the chronological view
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).
2026-05-17 18:42:48 +02:00

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