diff --git a/pkgs/mcpx/docs/spec/conformance-matrix.md b/pkgs/mcpx/docs/spec/conformance-matrix.md
index 800a0aa80..8c22df074 100644
--- a/pkgs/mcpx/docs/spec/conformance-matrix.md
+++ b/pkgs/mcpx/docs/spec/conformance-matrix.md
@@ -2,7 +2,7 @@
Generated by `internal/conformance` (`MCPX_UPDATE_MATRIX=1 go test ./internal/conformance -run TestMatrix`); do not edit.
-Generated from inputs `fb2ae39dd9de0779` (sha256 of docs/spec/requirements.md, internal/conformance/overrides/*.json, internal/conformance/coverage*.go). `TestMatrix` regenerates this file and fails when it differs, so it describes the tree it is committed in.
+Generated from inputs `46df49c7a0e8cc95` (sha256 of docs/spec/requirements.md, internal/conformance/overrides/*.json, internal/conformance/coverage*.go). `TestMatrix` regenerates this file and fails when it differs, so it describes the tree it is committed in.
Every normative requirement of every revision (catalogue: `docs/spec/requirements.md`), for mcpx as a server and as a client. A requirement is *tested* when a named test verifies it in that revision, *n/a* when it does not bind mcpx in that role, and a *gap* when it binds mcpx and mcpx does not meet it yet, with the issue that tracks it. Run exactly the covering tests with `go run ./internal/conformance/cmd/covering`.
@@ -46,27 +46,27 @@ Every normative requirement of every revision (catalogue: `docs/spec/requirement
| 2025-06-18 | client | SHOULD NOT | 2 | 1 | 1 | 0 | 3 |
| 2025-06-18 | client | MAY | 15 | 13 | 2 | 0 | 20 |
| 2025-06-18 | client | other | 10 | 8 | 2 | 0 | 8 |
-| 2025-11-25 | server | MUST | 115 | 94 | 21 | 0 | 65 |
+| 2025-11-25 | server | MUST | 115 | 96 | 19 | 0 | 65 |
| 2025-11-25 | server | MUST NOT | 22 | 18 | 4 | 0 | 13 |
| 2025-11-25 | server | SHOULD | 78 | 61 | 17 | 0 | 81 |
| 2025-11-25 | server | SHOULD NOT | 11 | 9 | 2 | 0 | 3 |
| 2025-11-25 | server | MAY | 27 | 25 | 2 | 0 | 28 |
| 2025-11-25 | server | other | 19 | 19 | 0 | 0 | 2 |
-| 2025-11-25 | client | MUST | 76 | 53 | 23 | 0 | 104 |
+| 2025-11-25 | client | MUST | 76 | 54 | 22 | 0 | 104 |
| 2025-11-25 | client | MUST NOT | 16 | 14 | 2 | 0 | 19 |
| 2025-11-25 | client | SHOULD | 67 | 45 | 22 | 0 | 92 |
| 2025-11-25 | client | SHOULD NOT | 5 | 4 | 1 | 0 | 9 |
| 2025-11-25 | client | MAY | 20 | 17 | 3 | 0 | 35 |
| 2025-11-25 | client | other | 13 | 11 | 2 | 0 | 8 |
-| 2026-07-28 | server | MUST | 128 | 105 | 23 | 0 | 96 |
+| 2026-07-28 | server | MUST | 128 | 107 | 21 | 0 | 96 |
| 2026-07-28 | server | MUST NOT | 44 | 41 | 3 | 0 | 20 |
-| 2026-07-28 | server | SHOULD | 74 | 59 | 15 | 0 | 86 |
+| 2026-07-28 | server | SHOULD | 74 | 60 | 14 | 0 | 86 |
| 2026-07-28 | server | SHOULD NOT | 11 | 9 | 2 | 0 | 9 |
| 2026-07-28 | server | MAY | 31 | 29 | 2 | 0 | 30 |
| 2026-07-28 | server | other | 23 | 22 | 1 | 0 | 4 |
-| 2026-07-28 | client | MUST | 91 | 61 | 30 | 0 | 133 |
+| 2026-07-28 | client | MUST | 91 | 62 | 29 | 0 | 133 |
| 2026-07-28 | client | MUST NOT | 26 | 22 | 4 | 0 | 38 |
-| 2026-07-28 | client | SHOULD | 75 | 51 | 24 | 0 | 85 |
+| 2026-07-28 | client | SHOULD | 75 | 52 | 23 | 0 | 85 |
| 2026-07-28 | client | SHOULD NOT | 13 | 11 | 2 | 0 | 7 |
| 2026-07-28 | client | MAY | 15 | 11 | 4 | 0 | 46 |
| 2026-07-28 | client | other | 13 | 11 | 2 | 0 | 14 |
@@ -1377,8 +1377,8 @@ Every normative requirement of every revision (catalogue: `docs/spec/requirement
|---|---|---|---|---|
| [json-schema-default-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect) | (definitional) | both | tested: `conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-default-2020-12` | n/a: catalogue marks it n/a |
| [json-schema-may-declare-dialect](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect) | MAY | both | tested: `conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-may-declare-dialect` | tested: `conformance.TestMessagesClient/2025-11-25/json-schema/json-schema-may-declare-dialect` |
-| [json-schema-support-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | **gap** #254: mcpx never validates arguments against a tool's inputSchema; schemas pass through | n/a: passes through |
-| [json-schema-validate-per-dialect-and-error-on-unsupported](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | **gap** #254: no validator in the binary, so an unsupported $schema is forwarded, not refused | **gap** #254: no validator in the binary, so an unsupported $schema is accepted |
+| [json-schema-support-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | tested: `mcpclient.TestCallToolRejectsInvalidArgumentsBeforeTheTransport@all` | n/a: passes through |
+| [json-schema-validate-per-dialect-and-error-on-unsupported](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | tested: `mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all`
`schemaval.TestDeclaredDialectsCompile@all`
`schemaval.TestUnsupportedDialect@all` | tested: `mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all`
`schemaval.TestDeclaredDialectsCompile@all`
`schemaval.TestUnsupportedDialect@all` |
| [json-schema-document-dialects](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | SHOULD | both | **gap** #254: no document states which dialects mcpx supports | **gap** #254: no document states which dialects mcpx supports |
| [json-schema-recommend-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect) | RECOMMENDED | both | tested: `conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-recommend-2020-12` | n/a: catalogue marks it n/a |
| [json-schema-schemas-valid](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-validation) | MUST | both | tested: `conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-schemas-valid` | n/a: catalogue marks it n/a |
@@ -2065,14 +2065,14 @@ Every normative requirement of every revision (catalogue: `docs/spec/requirement
|---|---|---|---|---|
| [json-schema-default-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect) | (definitional) | both | tested: `conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-default-2020-12` | n/a: catalogue marks it n/a |
| [json-schema-may-declare-dialect](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect) | MAY | both | tested: `conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-may-declare-dialect` | tested: `conformance.TestMessagesClient/2026-07-28/json-schema/json-schema-may-declare-dialect` |
-| [json-schema-support-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | **gap** #254: mcpx never validates arguments against a tool's inputSchema; schemas pass through | n/a: passes through |
-| [json-schema-validate-per-dialect-and-error-on-unsupported](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | **gap** #254: no validator in the binary, so an unsupported $schema is forwarded, not refused | **gap** #254: no validator in the binary, so an unsupported $schema is accepted |
+| [json-schema-support-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | tested: `mcpclient.TestCallToolRejectsInvalidArgumentsBeforeTheTransport@all` | n/a: passes through |
+| [json-schema-validate-per-dialect-and-error-on-unsupported](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | tested: `mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all`
`schemaval.TestDeclaredDialectsCompile@all`
`schemaval.TestUnsupportedDialect@all` | tested: `mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all`
`schemaval.TestDeclaredDialectsCompile@all`
`schemaval.TestUnsupportedDialect@all` |
| [json-schema-document-dialects](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | SHOULD | both | **gap** #254: no document states which dialects mcpx supports | **gap** #254: no document states which dialects mcpx supports |
| [json-schema-recommend-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect) | RECOMMENDED | both | tested: `conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-recommend-2020-12` | n/a: catalogue marks it n/a |
| [json-schema-schemas-valid](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-validation) | MUST | both | tested: `conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-schemas-valid` | n/a: catalogue marks it n/a |
| [json-schema-no-network-ref](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution) | MUST NOT | both | tested: `conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-no-network-ref` | tested: `conformance.TestMessagesClient/2026-07-28/json-schema/json-schema-no-network-ref` |
| [json-schema-remote-ref-opt-in-off-by-default](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution) | MAY / MUST / SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
-| [json-schema-reject-unresolved-ref](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution) | SHOULD | both | **gap** #254: an unresolvable $ref is rendered permissively (codegen) rather than rejected | **gap** #254: an unresolvable $ref is rendered permissively (codegen) rather than rejected |
+| [json-schema-reject-unresolved-ref](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution) | SHOULD | both | tested: `mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all`
`schemaval.TestRemoteRefMakesNoRequests@all`
`schemaval.TestUnresolvedInternalRef@all` | tested: `mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all`
`schemaval.TestRemoteRefMakesNoRequests@all`
`schemaval.TestUnresolvedInternalRef@all` |
| [json-schema-bound-composition-cost](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#composition-keyword-resource-use) | SHOULD | both | n/a: no validator | n/a: catalogue marks it n/a |
| [tools-schema-default-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool) | (definition) | both | tested: `conformance.TestMessagesServer/2026-07-28/json-schema/tools-schema-default-2020-12` | tested: `conformance.TestMessagesClient/2026-07-28/json-schema/tools-schema-default-2020-12` |
diff --git a/pkgs/mcpx/internal/conformance/coverage_messages.go b/pkgs/mcpx/internal/conformance/coverage_messages.go
index e42275365..ba8ebf759 100644
--- a/pkgs/mcpx/internal/conformance/coverage_messages.go
+++ b/pkgs/mcpx/internal/conformance/coverage_messages.go
@@ -109,6 +109,11 @@ func init() {
"tools-schema-default-2020-12",
"json-schema-no-network-ref",
),
+ S("json-schema-support-2020-12", "mcpclient.TestCallToolRejectsInvalidArgumentsBeforeTheTransport@all"),
+ S("json-schema-validate-per-dialect-and-error-on-unsupported", "mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all", "schemaval.TestUnsupportedDialect@all", "schemaval.TestDeclaredDialectsCompile@all"),
+ C("json-schema-validate-per-dialect-and-error-on-unsupported", "mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all", "schemaval.TestUnsupportedDialect@all", "schemaval.TestDeclaredDialectsCompile@all"),
+ S("json-schema-reject-unresolved-ref", "mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all", "schemaval.TestUnresolvedInternalRef@all", "schemaval.TestRemoteRefMakesNoRequests@all"),
+ C("json-schema-reject-unresolved-ref", "mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@all", "schemaval.TestUnresolvedInternalRef@all", "schemaval.TestRemoteRefMakesNoRequests@all"),
)
registerGaps(
Gap{ID: "errors-local-errors-not-mistaken-for-peer", Side: Server, Issue: "#206",
@@ -117,16 +122,6 @@ func init() {
Why: "mcpx answers methods whatever was negotiated (accept liberally, revisions.go); the schema guidance asks for -32601"},
Gap{ID: "mrtr-missing-capability-error-32021", Side: Server, Issue: "#257",
Why: "through the gateway (mcpx_call) a call that needs input from a client that declared none goes to the broker instead of failing with -32021; the specification contradicts itself here (docs/spec/revision-conflicts.md). In pass-through mode the client's declaration is relayed and the upstream's -32021 returned (mcpserver.TestAPassThroughUpstreamsMissingCapabilityIsRelayed)"},
- Gap{ID: "json-schema-support-2020-12", Side: Server, Issue: "#254",
- Why: "mcpx never validates arguments against a tool's inputSchema; schemas pass through"},
- Gap{ID: "json-schema-validate-per-dialect-and-error-on-unsupported", Side: Server, Issue: "#254",
- Why: "no validator in the binary, so an unsupported $schema is forwarded, not refused"},
- Gap{ID: "json-schema-validate-per-dialect-and-error-on-unsupported", Side: Client, Issue: "#254",
- Why: "no validator in the binary, so an unsupported $schema is accepted"},
- Gap{ID: "json-schema-reject-unresolved-ref", Side: Server, Issue: "#254",
- Why: "an unresolvable $ref is rendered permissively (codegen) rather than rejected"},
- Gap{ID: "json-schema-reject-unresolved-ref", Side: Client, Issue: "#254",
- Why: "an unresolvable $ref is rendered permissively (codegen) rather than rejected"},
Gap{ID: "json-schema-document-dialects", Side: Server, Issue: "#254",
Why: "no document states which dialects mcpx supports"},
Gap{ID: "json-schema-document-dialects", Side: Client, Issue: "#254",
diff --git a/pkgs/mcpx/internal/conformance/officialclient/main.go b/pkgs/mcpx/internal/conformance/officialclient/main.go
index e7c2a144c..2cf15eca1 100644
--- a/pkgs/mcpx/internal/conformance/officialclient/main.go
+++ b/pkgs/mcpx/internal/conformance/officialclient/main.go
@@ -241,8 +241,9 @@ func callsFor(ctx context.Context, m *mcpx, scenario string) ([]toolCall, error)
return nil, fmt.Errorf("search: %v\n%s", err, out)
}
var hits []struct {
- Namespace string `json:"namespace"`
- Tool string `json:"tool"`
+ Namespace string `json:"namespace"`
+ Tool string `json:"tool"`
+ InputSchema map[string]any `json:"inputSchema"`
}
if err := json.Unmarshal(out, &hits); err != nil {
return nil, fmt.Errorf("search output: %v\n%s", err, out)
@@ -250,12 +251,83 @@ func callsFor(ctx context.Context, m *mcpx, scenario string) ([]toolCall, error)
var calls []toolCall
for _, h := range hits {
if h.Namespace == *namespace {
- calls = append(calls, toolCall{Name: h.Tool})
+ calls = append(calls, toolCall{Name: h.Tool, Arguments: minimalArgs(h.InputSchema)})
}
}
return calls, nil
}
+// minimalArgs builds the smallest argument object a tool's inputSchema
+// accepts: one minimal value per required property, nothing else. This
+// fallback used to call every advertised tool with no arguments, which
+// was fine while mcpx forwarded arguments unvalidated; since #1483 the
+// client enforces inputSchema at CallTool, so a driver exercising a
+// tool it knows nothing about has to ask the schema what a call looks
+// like -- the same thing a schema-aware host does.
+func minimalArgs(schema map[string]any) map[string]any {
+ args := map[string]any{}
+ props, _ := schema["properties"].(map[string]any)
+ for _, name := range requiredNames(schema) {
+ sub, _ := props[name].(map[string]any)
+ args[name] = minimalValue(sub)
+ }
+ return args
+}
+
+func requiredNames(schema map[string]any) []string {
+ raw, _ := schema["required"].([]any)
+ names := make([]string, 0, len(raw))
+ for _, r := range raw {
+ if s, ok := r.(string); ok {
+ names = append(names, s)
+ }
+ }
+ return names
+}
+
+func minimalValue(schema map[string]any) any {
+ if schema == nil {
+ return ""
+ }
+ if v, ok := schema["default"]; ok {
+ return v
+ }
+ if v, ok := schema["const"]; ok {
+ return v
+ }
+ if e, ok := schema["enum"].([]any); ok && len(e) > 0 {
+ return e[0]
+ }
+ typ := schema["type"]
+ if list, ok := typ.([]any); ok {
+ typ = nil
+ for _, item := range list {
+ if s, _ := item.(string); s != "" && s != "null" {
+ typ = s
+ break
+ }
+ }
+ }
+ switch typ {
+ case "string":
+ return ""
+ case "integer", "number":
+ return 0
+ case "boolean":
+ return false
+ case "array":
+ return []any{}
+ case "object":
+ return minimalArgs(schema)
+ case "null":
+ return nil
+ }
+ if _, ok := schema["properties"]; ok {
+ return minimalArgs(schema)
+ }
+ return ""
+}
+
// schemaEcho round-trips the focal tool's inputSchema -- as mcpx holds it,
// which is what the scenario is checking mcpx preserved -- back through the
// scenario's echo tool.
diff --git a/pkgs/mcpx/internal/daemon/callerr.go b/pkgs/mcpx/internal/daemon/callerr.go
index 678627343..068518e86 100644
--- a/pkgs/mcpx/internal/daemon/callerr.go
+++ b/pkgs/mcpx/internal/daemon/callerr.go
@@ -114,6 +114,17 @@ func (r *Registry) explainCall(ctx context.Context, server, tool string, args an
return err
}
code, message, ok := upstreamFault(err)
+ if !ok {
+ // A client-side validation refusal (Phase 2 of #1481) never
+ // reached the server, but the failure is still the arguments'
+ // fault: the client detected the same -32602 the server would
+ // have answered with, and the error carries that code (the
+ // gateway relays it by the same interface). Explain it with
+ // its own message.
+ if c, m, coded := clientFault(err); coded {
+ code, message, ok = c, m, true
+ }
+ }
if !ok {
// A timeout, a server that would not start, a pipe that broke before
// the answer: nothing came back, so there is nothing to explain, and
@@ -199,6 +210,21 @@ func upstreamFault(err error) (code int, message string, ok bool) {
return 0, "", false
}
+// clientFault is a failure mcpx itself detected before the call reached
+// a server, when the error carries the JSON-RPC code it stands for.
+// Only Invalid Params is read this way: it is the one code that says the
+// arguments were at fault, which is the one thing a schema can explain.
+// The check is an interface assertion (the gateway's precedent in
+// internal/mcpserver), not a concrete type, so a validation error from
+// any layer that carries its code is explained the same way.
+func clientFault(err error) (code int, message string, ok bool) {
+ var coded interface{ RPCCode() int }
+ if errors.As(err, &coded) && coded.RPCCode() == diagnose.InvalidParams {
+ return coded.RPCCode(), err.Error(), true
+ }
+ return 0, "", false
+}
+
// upstreamData is the data member of the error upstreamFault found.
func upstreamData(err error) json.RawMessage {
for e := err; e != nil; e = errors.Unwrap(e) {
diff --git a/pkgs/mcpx/internal/e2e/callerr_test.go b/pkgs/mcpx/internal/e2e/callerr_test.go
index 3702c1d0a..e1f3519f0 100644
--- a/pkgs/mcpx/internal/e2e/callerr_test.go
+++ b/pkgs/mcpx/internal/e2e/callerr_test.go
@@ -126,10 +126,14 @@ func TestARefusedCallCarriesTheSchemaChangeOverV1(t *testing.T) {
if !hasRepoChange(doc.Diagnostics) {
t.Errorf("/v1/call should name the tool, the field and what changed:\n%s", b)
}
- // Added to, never replaced.
- if !strings.HasPrefix(doc.Error, "mcp error -32602: Invalid params\n") ||
+ // Added to, never replaced. Phase 2 of #1481: the refusal is now
+ // detected by mcpx's own argument validation before the call
+ // reaches the server, so the headline is the validator's -32602
+ // naming the tool and the path-qualified reason, not the server's
+ // answer — still first and whole, the diagnostic after it.
+ if !strings.HasPrefix(doc.Error, "Invalid params: tool \"create_issue\": ") ||
!strings.Contains(doc.Error, "repo is required") {
- t.Errorf("the error text should be the server's, then the rendered diagnostic:\n%s", doc.Error)
+ t.Errorf("the error text should be the refusal's, then the rendered diagnostic:\n%s", doc.Error)
}
// The same call collected later, as a task.
diff --git a/pkgs/mcpx/internal/e2e/ceiling_test.go b/pkgs/mcpx/internal/e2e/ceiling_test.go
index 91f207946..fbdcc386a 100644
--- a/pkgs/mcpx/internal/e2e/ceiling_test.go
+++ b/pkgs/mcpx/internal/e2e/ceiling_test.go
@@ -270,8 +270,11 @@ func TestRepairCannotRaiseAutonomyAboveTheCeiling(t *testing.T) {
if len(got.Diagnostics) != 0 {
t.Errorf("repair.autonomy run past autonomy.max off still attached diagnostics:\n%s", b)
}
- if !strings.HasPrefix(got.Error, "mcp error -32602") {
- t.Errorf("the server's own error should still come back:\n%s", b)
+ // Phase 2 of #1481: the refusal is detected by mcpx's own argument
+ // validation, so the error that comes back unexplained is the
+ // validator's -32602 headline, not a server answer.
+ if !strings.HasPrefix(got.Error, "Invalid params") {
+ t.Errorf("the refusal's own error should still come back:\n%s", b)
}
}
diff --git a/pkgs/mcpx/internal/mcpclient/client.go b/pkgs/mcpx/internal/mcpclient/client.go
index 79ffe5a8e..2ad120281 100644
--- a/pkgs/mcpx/internal/mcpclient/client.go
+++ b/pkgs/mcpx/internal/mcpclient/client.go
@@ -95,6 +95,9 @@ type Client struct {
loopDone chan struct{}
// Negotiated is the version actually in use.
Negotiated string
+ // toolValidators holds each listed tool's compiled input/output
+ // schema validators (schemacheck.go), keyed by tool name.
+ toolValidators map[string]*toolSchemas
// onElicit answers server-initiated requests.
onElicit ElicitHandler
// notif holds notification handlers.
@@ -1106,7 +1109,7 @@ func (c *Client) ListToolsWithHints(ctx context.Context) ([]Tool, CacheHints, er
}
cursor = &next
}
- checked := c.checkToolHeaders(all)
+ checked := c.checkToolHeaders(c.checkToolSchemas(all))
return checked, hints, nil
}
@@ -1242,6 +1245,13 @@ func (c *Client) CallTool(ctx context.Context, name string, args any) (json.RawM
if args == nil {
args = map[string]any{}
}
+ argsJSON, err := json.Marshal(args)
+ if err != nil {
+ return nil, err
+ }
+ if err := c.validateToolArgs(ctx, name, argsJSON); err != nil {
+ return nil, err
+ }
params, err := json.Marshal(map[string]any{"name": name, "arguments": args})
if err != nil {
return nil, err
@@ -1265,6 +1275,7 @@ func (c *Client) CallTool(ctx context.Context, name string, args any) (json.RawM
if err != nil {
return nil, err
}
+ c.checkToolOutput(ctx, name, raw)
return raw, nil
}
}
diff --git a/pkgs/mcpx/internal/mcpclient/schemacheck.go b/pkgs/mcpx/internal/mcpclient/schemacheck.go
new file mode 100644
index 000000000..6f429cfe6
--- /dev/null
+++ b/pkgs/mcpx/internal/mcpclient/schemacheck.go
@@ -0,0 +1,210 @@
+package mcpclient
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+
+ "github.com/dezren39/mcpx/internal/schemaval"
+)
+
+// This file wires internal/schemaval into the client (Phase 2 of #1481,
+// issue #1483). ListTools compiles every tool's input/output schema; a
+// tool whose schema does not compile is excluded with a Warning, the
+// x-mcp-header precedent. CallTool then enforces the compiled input
+// validator before the transport is touched, and checks a result's
+// structuredContent against the output validator warn-only.
+
+// toolSchemas is one tool's compiled validators. A nil validator means
+// the tool declared no schema of that kind.
+type toolSchemas struct {
+ hash string
+ input *schemaval.Schema
+ output *schemaval.Schema
+ // required names the inputSchema's required top-level properties,
+ // parsed from the raw schema at compile time. validateToolArgs uses
+ // it to tell an omitted optional property apart from a required one
+ // sent as explicit null.
+ required map[string]bool
+}
+
+// InvalidParamsError is a CallTool argument set that failed validation
+// against the tool's inputSchema. It carries the JSON-RPC Invalid Params
+// code (-32602): the caller sent something wrong, which is a protocol
+// error, never a tool failure — surfaces that translate generic call
+// errors into isError results must pass it through (see RPCCode).
+type InvalidParamsError struct {
+ Tool string
+ Err error
+}
+
+func (e *InvalidParamsError) Error() string {
+ return fmt.Sprintf("Invalid params: tool %q: %v", e.Tool, e.Err)
+}
+
+func (e *InvalidParamsError) Unwrap() error { return e.Err }
+
+// RPCCode is the JSON-RPC error code this failure stands for. The
+// gateway's invoke path checks for it (an interface assertion, so no
+// import cycle) and relays the error as a protocol error.
+func (e *InvalidParamsError) RPCCode() int { return codeInvalidParams }
+
+// schemaHash keys the compiled-validator cache: a re-list recompiles
+// only a tool whose schema bytes changed.
+func schemaHash(t Tool) string {
+ h := sha256.New()
+ h.Write(t.InputSchema)
+ h.Write([]byte{0})
+ h.Write(t.OutputSchema)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// checkToolSchemas compiles each tool's schemas and drops the tools
+// whose schemas do not compile, warning about each. It runs on every
+// connection (unlike checkToolHeaders, which is modern-HTTP only):
+// argument enforcement is a property of the choke point, not the era.
+func (c *Client) checkToolSchemas(tools []Tool) []Tool {
+ c.mu.Lock()
+ prev := c.toolValidators
+ c.mu.Unlock()
+ next := make(map[string]*toolSchemas, len(tools))
+ kept := tools[:0:0]
+ for _, t := range tools {
+ if len(t.InputSchema) == 0 && len(t.OutputSchema) == 0 {
+ next[t.Name] = &toolSchemas{hash: schemaHash(t)}
+ kept = append(kept, t)
+ continue
+ }
+ hash := schemaHash(t)
+ if old, ok := prev[t.Name]; ok && old.hash == hash && (old.input != nil || len(t.InputSchema) == 0) && (old.output != nil || len(t.OutputSchema) == 0) {
+ next[t.Name] = old
+ kept = append(kept, t)
+ continue
+ }
+ ts := &toolSchemas{hash: hash}
+ bad := ""
+ if len(t.InputSchema) > 0 {
+ s, err := schemaval.Compile(t.InputSchema)
+ if err != nil {
+ bad = fmt.Sprintf("inputSchema: %v", err)
+ } else {
+ ts.input = s
+ ts.required = requiredProps(t.InputSchema)
+ }
+ }
+ if bad == "" && len(t.OutputSchema) > 0 {
+ s, err := schemaval.Compile(t.OutputSchema)
+ if err != nil {
+ bad = fmt.Sprintf("outputSchema: %v", err)
+ } else {
+ ts.output = s
+ }
+ }
+ if bad != "" {
+ c.warn(Warning{Tool: t.Name, Reason: "excluded: schema does not compile: " + bad})
+ continue
+ }
+ next[t.Name] = ts
+ kept = append(kept, t)
+ }
+ c.mu.Lock()
+ c.toolValidators = next
+ c.mu.Unlock()
+ return kept
+}
+
+// validatorsFor returns the tool's compiled validators. Validation
+// applies only once a list has happened on this connection: the pool
+// lists a server's tools before it ever calls them, so the choke
+// point is covered in production, while a connection that has never
+// listed behaves exactly as before validation existed (no surprise
+// tools/list is issued on a call's behalf). ok is false when the tool
+// is not in the list — such a call is the upstream's to answer for.
+func (c *Client) validatorsFor(name string) (*toolSchemas, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ ts, ok := c.toolValidators[name]
+ return ts, ok
+}
+
+// validateToolArgs enforces the tool's compiled input validator against
+// the marshalled arguments, before any transport use.
+func (c *Client) validateToolArgs(ctx context.Context, name string, argsJSON json.RawMessage) error {
+ ts, ok := c.validatorsFor(name)
+ if !ok || ts.input == nil {
+ return nil
+ }
+ if err := ts.input.ValidateJSON(validationCopy(ts, argsJSON)); err != nil {
+ return &InvalidParamsError{Tool: name, Err: err}
+ }
+ return nil
+}
+
+// requiredProps extracts the top-level required property names from a
+// raw inputSchema. A schema whose required list does not parse simply
+// yields none; Compile has already judged the schema itself.
+func requiredProps(raw json.RawMessage) map[string]bool {
+ var doc struct {
+ Required []string `json:"required"`
+ }
+ if err := json.Unmarshal(raw, &doc); err != nil || len(doc.Required) == 0 {
+ return nil
+ }
+ out := make(map[string]bool, len(doc.Required))
+ for _, name := range doc.Required {
+ out[name] = true
+ }
+ return out
+}
+
+// validationCopy returns the argument bytes to validate. An explicit
+// JSON null for a property the schema does not require is an omission,
+// not a value: SEP-2243 treats a null parameter as not provided (its
+// header must be omitted), and the conformance suite calls tools that
+// way -- a boolean-typed optional parameter sent as null. The copy
+// drops those keys; the caller's original arguments still go on the
+// wire unchanged. A null for a required property stays in the copy and
+// passes or fails exactly as the schema says.
+func validationCopy(ts *toolSchemas, argsJSON json.RawMessage) json.RawMessage {
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(argsJSON, &obj); err != nil {
+ return argsJSON
+ }
+ changed := false
+ for key, value := range obj {
+ if string(bytes.TrimSpace(value)) == "null" && !ts.required[key] {
+ delete(obj, key)
+ changed = true
+ }
+ }
+ if !changed {
+ return argsJSON
+ }
+ out, err := json.Marshal(obj)
+ if err != nil {
+ return argsJSON
+ }
+ return out
+}
+
+// checkToolOutput validates a result's structuredContent against the
+// tool's output validator, warn-only: the result is delivered unchanged
+// either way (the enforcement flip is an open question on #1481).
+func (c *Client) checkToolOutput(ctx context.Context, name string, raw json.RawMessage) {
+ ts, ok := c.validatorsFor(name)
+ if !ok || ts.output == nil {
+ return
+ }
+ var res struct {
+ StructuredContent json.RawMessage `json:"structuredContent"`
+ }
+ if err := json.Unmarshal(raw, &res); err != nil || len(res.StructuredContent) == 0 {
+ return
+ }
+ if err := ts.output.ValidateJSON(res.StructuredContent); err != nil {
+ c.warn(Warning{Tool: name, Reason: "structuredContent violates outputSchema (delivered unchanged): " + err.Error()})
+ }
+}
diff --git a/pkgs/mcpx/internal/mcpclient/schemacheck_test.go b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go
new file mode 100644
index 000000000..ba5711097
--- /dev/null
+++ b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go
@@ -0,0 +1,265 @@
+package mcpclient_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/dezren39/mcpx/internal/mcpclient"
+)
+
+// schemaServer serves one tools/list page carrying the given tools and
+// counts tools/call requests. callResult is what tools/call returns.
+type schemaServer struct {
+ *scriptedServer
+ calls atomic.Int64
+}
+
+func newSchemaServer(t *testing.T, tools []any, callResult map[string]any) (*schemaServer, *mcpclient.Client, chan mcpclient.Warning) {
+ t.Helper()
+ ss := &schemaServer{}
+ ss.scriptedServer = newScripted(func(method string, params map[string]any) any {
+ switch method {
+ case "server/discover":
+ return modernDiscover(params)
+ case "tools/list":
+ return map[string]any{"resultType": "complete", "tools": tools}
+ case "tools/call":
+ ss.calls.Add(1)
+ return callResult
+ }
+ return nil
+ })
+ c := dialModern(t, ss.scriptedServer, mcpclient.Options{})
+ warned := make(chan mcpclient.Warning, 16)
+ c.Subscribe(mcpclient.Notifications{OnWarning: func(w mcpclient.Warning) { warned <- w }})
+ return ss, c, warned
+}
+
+func toolFixture(name, input, output string) map[string]any {
+ t := map[string]any{"name": name}
+ if input != "" {
+ t["inputSchema"] = json.RawMessage(input)
+ }
+ if output != "" {
+ t["outputSchema"] = json.RawMessage(output)
+ }
+ return t
+}
+
+const echoInput = `{"type":"object","properties":{"count":{"type":"integer"},"label":{"type":"string"}},"required":["count"],"additionalProperties":false}`
+
+func TestCallToolRejectsInvalidArgumentsBeforeTheTransport(t *testing.T) {
+ ss, c, _ := newSchemaServer(t,
+ []any{toolFixture("echo", echoInput, "")},
+ map[string]any{"resultType": "complete", "content": []any{}})
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if _, err := c.ListTools(ctx); err != nil {
+ t.Fatal(err)
+ }
+ for name, args := range map[string]map[string]any{
+ "wrong type": {"count": "three"},
+ "missing required": {"label": "x"},
+ "additional prop": {"count": 1, "nope": true},
+ } {
+ _, err := c.CallTool(ctx, "echo", args)
+ var ipe *mcpclient.InvalidParamsError
+ if !errors.As(err, &ipe) {
+ t.Fatalf("%s: error is %T (%v), want InvalidParamsError", name, err, err)
+ }
+ if ipe.RPCCode() != -32602 {
+ t.Fatalf("%s: RPCCode %d, want -32602", name, ipe.RPCCode())
+ }
+ if !strings.Contains(err.Error(), "echo") {
+ t.Fatalf("%s: error does not name the tool: %v", name, err)
+ }
+ }
+ if got := ss.calls.Load(); got != 0 {
+ t.Fatalf("upstream received %d tools/call requests for invalid arguments, want 0", got)
+ }
+
+ // Valid arguments reach the upstream exactly once.
+ if _, err := c.CallTool(ctx, "echo", map[string]any{"count": 2, "label": "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := ss.calls.Load(); got != 1 {
+ t.Fatalf("upstream received %d tools/call requests for valid arguments, want 1", got)
+ }
+}
+
+// A tool whose inputSchema fails through a combinator (anyOf) must
+// surface the -32602 InvalidParamsError, never panic: the validation
+// error tree for combinators has interior units with no message
+// (regression from the PR #1596 conformance crash, #1483).
+func TestCallToolCombinatorSchemaFailureReturnsInvalidParams(t *testing.T) {
+ ss, c, _ := newSchemaServer(t,
+ []any{toolFixture("pick", `{"type":"object","properties":{"mode":{"anyOf":[{"const":"a"},{"const":"b"}]}},"required":["mode"]}`, "")},
+ map[string]any{"resultType": "complete", "content": []any{}})
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if _, err := c.ListTools(ctx); err != nil {
+ t.Fatal(err)
+ }
+ _, err := c.CallTool(ctx, "pick", map[string]any{"mode": "c"})
+ var ipe *mcpclient.InvalidParamsError
+ if !errors.As(err, &ipe) {
+ t.Fatalf("error is %T (%v), want InvalidParamsError", err, err)
+ }
+ if ipe.RPCCode() != -32602 {
+ t.Fatalf("RPCCode %d, want -32602", ipe.RPCCode())
+ }
+ if !strings.Contains(err.Error(), "$.mode") {
+ t.Fatalf("error does not name the failing path: %v", err)
+ }
+ if got := ss.calls.Load(); got != 0 {
+ t.Fatalf("upstream received %d tools/call requests for invalid arguments, want 0", got)
+ }
+}
+
+func TestListToolsExcludesUncompilableSchemasWithWarning(t *testing.T) {
+ _, c, warned := newSchemaServer(t,
+ []any{
+ toolFixture("good", echoInput, ""),
+ toolFixture("bad-dialect", `{"$schema":"http://json-schema.org/draft-03/schema#","type":"object"}`, ""),
+ toolFixture("bad-ref", `{"type":"object","properties":{"x":{"$ref":"https://example.com/ext.json"}}}`, ""),
+ },
+ map[string]any{"resultType": "complete", "content": []any{}})
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ tools, err := c.ListTools(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(tools) != 1 || tools[0].Name != "good" {
+ t.Fatalf("listed tools = %v, want only [good]", tools)
+ }
+ got := map[string]string{}
+ for i := 0; i < 2; i++ {
+ select {
+ case w := <-warned:
+ got[w.Tool] = w.Reason
+ case <-time.After(2 * time.Second):
+ t.Fatalf("only %d warnings received: %v", len(got), got)
+ }
+ }
+ if !strings.Contains(got["bad-dialect"], "dialect") {
+ t.Errorf("bad-dialect warning = %q, want the dialect named", got["bad-dialect"])
+ }
+ if !strings.Contains(got["bad-ref"], "$ref") {
+ t.Errorf("bad-ref warning = %q, want the $ref named", got["bad-ref"])
+ }
+}
+
+func TestCallToolOutputValidationIsWarnOnly(t *testing.T) {
+ const out = `{"type":"object","properties":{"total":{"type":"integer"}},"required":["total"]}`
+ result := map[string]any{"resultType": "complete",
+ "content": []any{},
+ "structuredContent": map[string]any{"total": "not-a-number"}}
+ _, c, warned := newSchemaServer(t,
+ []any{toolFixture("sum", `{"type":"object"}`, out)}, result)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if _, err := c.ListTools(ctx); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := c.CallTool(ctx, "sum", map[string]any{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "not-a-number") {
+ t.Fatalf("result was not delivered unchanged: %s", raw)
+ }
+ select {
+ case w := <-warned:
+ if w.Tool != "sum" || !strings.Contains(w.Reason, "outputSchema") {
+ t.Fatalf("warning = %+v, want an outputSchema warning for sum", w)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("no warning recorded for a structuredContent violation")
+ }
+}
+
+// BenchmarkCallToolValidateArgs measures argument validation on a
+// representative nested schema (~5 levels, ~20 properties, modelled on
+// a catalog tool). The triage budget for #1483: <=100us/op median,
+// <=1ms worst per call.
+func BenchmarkCallToolValidateArgs(b *testing.B) {
+ const nested = `{"type":"object","properties":{
+ "name":{"type":"string"},"count":{"type":"integer"},
+ "window":{"type":"object","properties":{"start":{"type":"string"},"end":{"type":"string"},"tz":{"type":"string"}},"required":["start","end"]},
+ "filters":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"op":{"type":"string"},"value":{"type":"string"}},"required":["field","op"]}},
+ "opts":{"type":"object","properties":{"deep":{"type":"object","properties":{"deeper":{"type":"object","properties":{"leaf":{"type":"integer"},"flag":{"type":"boolean"},"mode":{"type":"string"}},"required":["leaf"]},"level":{"type":"integer"}},"required":["deeper"]},"verbose":{"type":"boolean"}}},
+ "tags":{"type":"array","items":{"type":"string"}},
+ "a":{"type":"string"},"b":{"type":"string"},"c":{"type":"string"},"d":{"type":"string"},"e":{"type":"string"}
+ },"required":["name","count"]}`
+ ss := &schemaServer{}
+ ss.scriptedServer = newScripted(func(method string, params map[string]any) any {
+ switch method {
+ case "server/discover":
+ return modernDiscover(params)
+ case "tools/list":
+ return map[string]any{"resultType": "complete", "tools": []any{toolFixture("bench", nested, "")}}
+ case "tools/call":
+ return map[string]any{"resultType": "complete", "content": []any{}}
+ }
+ return nil
+ })
+ ctx := context.Background()
+ c, err := mcpclient.NewWithOptions(ctx, ss.scriptedServer, mcpclient.Options{
+ ClientName: "mcpx", ClientVersion: "bench", Preference: mcpclient.ForceModern})
+ if err != nil {
+ b.Fatal(err)
+ }
+ defer c.Close()
+ if _, err := c.ListTools(ctx); err != nil {
+ b.Fatal(err)
+ }
+ args := map[string]any{
+ "name": "x", "count": 3,
+ "window": map[string]any{"start": "a", "end": "b", "tz": "UTC"},
+ "filters": []any{map[string]any{"field": "f", "op": "=", "value": "1"}},
+ "opts": map[string]any{"deep": map[string]any{"deeper": map[string]any{"leaf": 1, "flag": true, "mode": "m"}, "level": 2}},
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if _, err := c.CallTool(ctx, "bench", args); err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// An explicit null for a property the schema does not require is an
+// omission, not a type violation (SEP-2243: a null parameter's header
+// is omitted, and the conformance suite calls a boolean-typed optional
+// parameter with null). A null for a required property is still
+// validated as sent and fails here, because the schema does not admit
+// null for it.
+func TestCallToolNullForOptionalPropertyIsAnOmission(t *testing.T) {
+ ss, c, _ := newSchemaServer(t,
+ []any{toolFixture("opts", `{"type":"object","properties":{"region":{"type":"string"},"verbose":{"type":"boolean"}},"required":["region"],"additionalProperties":false}`, "")},
+ map[string]any{"resultType": "complete", "content": []any{}})
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if _, err := c.ListTools(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.CallTool(ctx, "opts", map[string]any{"region": "us-east1", "verbose": nil}); err != nil {
+ t.Fatalf("null for optional property: %v", err)
+ }
+ if got := ss.calls.Load(); got != 1 {
+ t.Fatalf("upstream received %d tools/call requests, want 1", got)
+ }
+ _, err := c.CallTool(ctx, "opts", map[string]any{"region": nil})
+ var ipe *mcpclient.InvalidParamsError
+ if !errors.As(err, &ipe) {
+ t.Fatalf("null for required property: error is %T (%v), want InvalidParamsError", err, err)
+ }
+ if got := ss.calls.Load(); got != 1 {
+ t.Fatalf("upstream received %d tools/call requests after the rejected call, want 1", got)
+ }
+}
diff --git a/pkgs/mcpx/internal/mcpserver/server.go b/pkgs/mcpx/internal/mcpserver/server.go
index 586c475eb..8bb3edf01 100644
--- a/pkgs/mcpx/internal/mcpserver/server.go
+++ b/pkgs/mcpx/internal/mcpserver/server.go
@@ -1009,6 +1009,16 @@ func (s *Server) handle(ctx context.Context, c *Conn, req request) *response {
if up := (*UpstreamError)(nil); errors.As(err, &up) {
return up.relay(req.ID)
}
+ // A client-side validation failure (the caller's arguments
+ // failed the tool's inputSchema, code -32602) is the caller's
+ // bug, not a tool failure: relay it as the protocol error it
+ // is instead of an isError result. The check is an interface
+ // assertion so this package does not import the client's
+ // error type.
+ var coded interface{ RPCCode() int }
+ if errors.As(err, &coded) && coded.RPCCode() == codeInvalidParams {
+ return fail(codeInvalidParams, err.Error())
+ }
if err != nil {
// A tool that fails is a result with isError, not a protocol
// error. The distinction matters: a protocol error means the
diff --git a/pkgs/mcpx/internal/schemaval/schemaval.go b/pkgs/mcpx/internal/schemaval/schemaval.go
index 581400a42..b585444f0 100644
--- a/pkgs/mcpx/internal/schemaval/schemaval.go
+++ b/pkgs/mcpx/internal/schemaval/schemaval.go
@@ -254,10 +254,23 @@ func (s *Schema) Validate(v any) error {
return err
}
out := ve.DetailedOutput()
+ // DetailedOutput is a tree, not a flat list: a unit that groups
+ // sub-errors (every combinator failure — anyOf/oneOf/allOf/not —
+ // and any failure under a grouping keyword) carries Errors but a
+ // nil Error. Flatten by walking to the leaves; reading Error off
+ // an interior unit panics (found by the mcpx conformance suite:
+ // an anyOf argument failure crashed the daemon, #1483).
problems := make([]Problem, 0, len(out.Errors))
- for _, u := range out.Errors {
- problems = append(problems, Problem{Path: jsonPath(u.InstanceLocation), Message: u.Error.String()})
+ var walk func(u *jsonschema.OutputUnit)
+ walk = func(u *jsonschema.OutputUnit) {
+ if u.Error != nil {
+ problems = append(problems, Problem{Path: jsonPath(u.InstanceLocation), Message: u.Error.String()})
+ }
+ for i := range u.Errors {
+ walk(&u.Errors[i])
+ }
}
+ walk(out)
if len(problems) == 0 {
problems = append(problems, Problem{Path: "$", Message: ve.Error()})
}
diff --git a/pkgs/mcpx/internal/schemaval/schemaval_test.go b/pkgs/mcpx/internal/schemaval/schemaval_test.go
index 23960cd12..f25e0c879 100644
--- a/pkgs/mcpx/internal/schemaval/schemaval_test.go
+++ b/pkgs/mcpx/internal/schemaval/schemaval_test.go
@@ -188,6 +188,47 @@ func TestFlattenedPathErrors(t *testing.T) {
}
}
+// Combinator failures (anyOf/oneOf/allOf/not/if-then) produce a
+// DetailedOutput tree whose interior units carry a nil Error;
+// Validate must flatten to the leaves instead of dereferencing it.
+// Regression: an anyOf argument failure panicked the daemon in the
+// mcpx conformance suite (#1483, PR #1596 server-all crash).
+func TestFlattenedCombinatorErrors(t *testing.T) {
+ cases := []struct {
+ name, schema, inst, wantPath string
+ }{
+ {"anyOf", `{"anyOf": [{"type": "integer"}, {"type": "boolean"}]}`, `"x"`, "$"},
+ {"oneOf", `{"oneOf": [{"type": "integer"}, {"type": "number"}]}`, `3`, "$"},
+ {"allOf", `{"allOf": [{"type": "integer"}, {"minimum": 5}]}`, `"x"`, "$"},
+ {"not", `{"not": {"type": "integer"}}`, `3`, "$"},
+ {"ifThen", `{"if": {"type": "integer"}, "then": {"minimum": 10}}`, `3`, "$"},
+ {"nestedAnyOf", `{"type": "object", "properties": {"mode": {"anyOf": [{"const": "a"}, {"const": "b"}]}}}`, `{"mode": "c"}`, "$.mode"},
+ {"contains", `{"type": "array", "contains": {"type": "integer"}}`, `["a", "b"]`, "$[0]"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := mustCompile(t, tc.schema)
+ err := mustNotValidate(t, s, tc.inst)
+ var ve *ValidationError
+ if !errors.As(err, &ve) {
+ t.Fatalf("err = %T, want ValidationError", err)
+ }
+ if len(ve.Problems) == 0 {
+ t.Fatalf("no problems flattened from %v", err)
+ }
+ found := false
+ for _, pr := range ve.Problems {
+ if pr.Path == tc.wantPath {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("problems = %v, want one at %s", ve.Problems, tc.wantPath)
+ }
+ })
+ }
+}
+
// A schema over the 1 MiB bound fails compilation with the typed error.
func TestSchemaTooLarge(t *testing.T) {
big := `{"description": "` + strings.Repeat("x", MaxSchemaBytes) + `", "type": "integer"}`