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"`.
214 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|