diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index aadb1a7..8c8e081 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -9,6 +9,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/m/projax/internal/itemwrite" ) // rpcJSON builds a JSON-RPC request body. @@ -124,6 +126,49 @@ func TestToolsCallSuccessAndError(t *testing.T) { } } +// TestToolsCallValidationError pins the Phase 5d slice B contract: +// ValidationToolError surfaces the typed {kind, path, detail} payload via +// .error.data, the message is the clean ": " 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 ': ' 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) diff --git a/mcp/tools.go b/mcp/tools.go index e90d092..5b374d3 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -14,23 +14,20 @@ import ( "github.com/m/projax/store" ) -// itemWriteError serialises an *itemwrite.ValidationError into a *ToolError -// whose Msg carries the JSON-suffixed legacy shape (kind/path/detail in a -// bracketed JSON blob). Slice B promotes this to the typed -// ValidationToolError below — until then this helper preserves the -// pre-Phase-5d wire text so consumers reading .error.message keep working. -func itemWriteError(ve *itemwrite.ValidationError) *ToolError { - body, err := json.Marshal(map[string]any{ - "kind": ve.Kind, - "path": ve.Path, - "detail": ve.Detail, - }) - if err != nil { - return &ToolError{Code: codeInternalError, Msg: ve.Error()} - } +// ValidationToolError promotes an *itemwrite.ValidationError into a typed +// *ToolError: code -32602 (Invalid params) per JSON-RPC convention, a +// clean ": " Msg, and Data carrying the structured +// {kind, path, detail} object MCP clients introspect via .error.data +// without parsing a JSON suffix out of the message. +func ValidationToolError(ve *itemwrite.ValidationError) *ToolError { return &ToolError{ - Code: codeInternalError, - Msg: fmt.Sprintf("validation %s: %s [%s]", ve.Kind, ve.Detail, string(body)), + Code: codeInvalidParams, + Msg: fmt.Sprintf("%s: %s", ve.Kind, ve.Detail), + Data: map[string]any{ + "kind": ve.Kind, + "path": ve.Path, + "detail": ve.Detail, + }, } } @@ -852,12 +849,12 @@ func createItemTool(st *store.Store) ToolHandler { if ve := itemwrite.ValidateFormat(itemwrite.Input{ Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs, }); ve != nil { - return nil, itemWriteError(ve) + return nil, ValidationToolError(ve) } if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{ Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs, }); ve != nil { - return nil, itemWriteError(ve) + return nil, ValidationToolError(ve) } it, err := st.Create(ctx, store.CreateInput{ Kind: kind, @@ -1016,10 +1013,10 @@ func updateItemTool(st *store.Store) ToolHandler { Path: it.PrimaryPath(), } if ve := itemwrite.ValidateFormat(validateIn); ve != nil { - return nil, itemWriteError(ve) + return nil, ValidationToolError(ve) } if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil { - return nil, itemWriteError(ve) + return nil, ValidationToolError(ve) } updated, err := st.Update(ctx, it.ID, patch) if err != nil {