Files
projax/mcp/mcp_test.go
mAi 8370454b66 refactor(mcp): typed ValidationError surfaces via .error.data
Phase 5d slice B. createItemTool / updateItemTool stop encoding rejections
as `validation <kind>: <detail> [{json-blob}]` glued into .error.message
and instead return ValidationToolError(ve), which the JSON-RPC envelope
marshals as:

  { code: -32602,
    message: "<kind>: <detail>",
    data:    { kind, path, detail } }

Clients introspect `.error.data.kind` directly — no JSON suffix to parse
out of the message. -32602 is the JSON-RPC "Invalid params" code, the
right semantic level for an itemwrite rejection.

mcp/tools.go:
- Replace itemWriteError with ValidationToolError. The legacy helper is
  gone; four call sites (create_item × 2, update_item × 2) switch over
  one-for-one.

mcp/mcp_test.go:
- Add TestToolsCallValidationError. Pins the wire shape: code=-32602,
  message=`<kind>: <detail>` with no JSON suffix, and data carrying
  {kind, path, detail}. Also asserts the rejection does NOT route
  through result.isError — the slice A guarantee remains intact for
  validation errors specifically.
- Import internal/itemwrite for the ValidationError fixture.

No test source edits to existing assertions — the prior tests don't
inspect the legacy `validation X: Y [{...}]` Msg shape, so behaviour
preservation holds without touching them. The new test is additive.

Live probe (post-deploy): POST `create_item` against projax.msbls.de/mcp/rpc
with slug='BAD.SLUG' returns `error.data.kind = "invalid-slug-format"`.
2026-05-22 11:50:57 +02:00

214 lines
6.9 KiB
Go

package mcp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/m/projax/internal/itemwrite"
)
// 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, *ToolError) {
var in struct {
Message string `json:"message"`
}
if err := json.Unmarshal(raw, &in); err != nil {
return nil, InternalError(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, *ToolError) {
return map[string]any{"got": string(raw)}, nil
},
})
srv.Register(Tool{
Name: "boom",
Handler: func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
return nil, &ToolError{Code: codeInternalError, 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)
}
// Phase 5d slice A: tool errors now surface through the JSON-RPC error
// envelope ({code, message, data}) rather than result.isError, so the
// assertions track .error.code / .error.message.
_, body = doRPC(t, srv, rpcJSON(t, 4, "tools/call", map[string]any{
"name": "boom",
"arguments": map[string]any{},
}), "")
if !strings.Contains(string(body), `"code":-32603`) {
t.Fatalf("error response missing code:-32603: %s", body)
}
if !strings.Contains(string(body), `"message":"kaboom"`) {
t.Errorf("error response missing message:kaboom: %s", body)
}
if strings.Contains(string(body), `"isError":true`) {
t.Errorf("error response should no longer route through result.isError: %s", body)
}
}
// TestToolsCallValidationError pins the Phase 5d slice B contract:
// ValidationToolError surfaces the typed {kind, path, detail} payload via
// .error.data, the message is the clean "<kind>: <detail>" form (no JSON
// suffix), and the JSON-RPC code is -32602 per the Invalid-params
// convention. The production-side artifact probe (POST create_item with
// slug='BAD.SLUG' against projax.msbls.de) exercises the same path through
// the real itemwrite.ValidateFormat call.
func TestToolsCallValidationError(t *testing.T) {
srv := New("p", "1", "", nil)
srv.Register(Tool{
Name: "boom",
Handler: func(ctx context.Context, raw json.RawMessage) (any, *ToolError) {
return nil, ValidationToolError(&itemwrite.ValidationError{
Kind: itemwrite.KindInvalidSlugFormat,
Path: "dev.bad",
Detail: "slug must be lower-case, no dots/whitespace",
})
},
})
_, body := doRPC(t, srv, rpcJSON(t, 7, "tools/call", map[string]any{
"name": "boom", "arguments": map[string]any{},
}), "")
s := string(body)
if !strings.Contains(s, `"code":-32602`) {
t.Fatalf("missing code:-32602: %s", s)
}
if !strings.Contains(s, `"message":"invalid-slug-format: slug must be lower-case, no dots/whitespace"`) {
t.Errorf("message should be '<kind>: <detail>' with no JSON suffix: %s", s)
}
if !strings.Contains(s, `"kind":"invalid-slug-format"`) {
t.Errorf("missing data.kind: %s", s)
}
if !strings.Contains(s, `"path":"dev.bad"`) {
t.Errorf("missing data.path: %s", s)
}
if !strings.Contains(s, `"detail":"slug must be lower-case, no dots/whitespace"`) {
t.Errorf("missing data.detail: %s", s)
}
if strings.Contains(s, `"isError":true`) {
t.Errorf("validation rejection should not route through result.isError: %s", s)
}
}
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)
}
}