Files
projax/mcp/mcp_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

163 lines
4.8 KiB
Go

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