From 7f7f87f5a8c83170ddf8f79ad247e43d789dfab9 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 00:10:47 -0500 Subject: [PATCH 1/6] feat(mcpx): validate CallTool arguments against inputSchema, warn on output mismatch (#1483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #1481, consuming the Phase-1 schemaval core (#1482): - ListTools compiles each tool input/output schema via internal/schemaval (cached per tool, keyed by schema hash); a tool whose schema does not compile (unsupported dialect, unresolvable $ref, invalid/oversized) is excluded with a Warning, the x-mcp-header precedent. Applies on every connection, not just modern HTTP. - CallTool validates marshalled arguments against the compiled input validator before the transport is touched; failure is a typed InvalidParamsError carrying -32602 (RPCCode), naming the tool and the path-qualified reason; the upstream receives zero calls. - The gateway invoke path in internal/mcpserver relays an RPCCode -32602 error as a protocol error instead of an isError tool result (interface check, no import cycle). - structuredContent is validated against the output validator warn-only; the result is delivered unchanged. - Validation applies once a connection has listed (the pool always lists before calling); a never-listed connection behaves exactly as before. - Conformance matrix rows for the four json-schema ids updated from gap #254 to implemented. Tests: defining tests in internal/mcpclient/schemacheck_test.go (wrong type / missing required / additional property rejected with upstream call count 0; valid args pass; unsupported dialect and unresolvable $ref excluded with warnings; output violation warn-only). Full mcpclient + mcpserver + pool suites pass on the laptop. Benchmark (laptop, Ryzen 7840U): BenchmarkCallToolValidateArgs 41.6us/op for a full scripted CallTool round trip including validation of a ~5-level/~20-property nested schema — inside the triage budget of <=100us/op median. --- pkgs/mcpx/docs/spec/conformance-matrix.md | 14 +- pkgs/mcpx/internal/mcpclient/client.go | 13 +- pkgs/mcpx/internal/mcpclient/schemacheck.go | 156 +++++++++++++ .../internal/mcpclient/schemacheck_test.go | 205 ++++++++++++++++++ pkgs/mcpx/internal/mcpserver/server.go | 10 + 5 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 pkgs/mcpx/internal/mcpclient/schemacheck.go create mode 100644 pkgs/mcpx/internal/mcpclient/schemacheck_test.go diff --git a/pkgs/mcpx/docs/spec/conformance-matrix.md b/pkgs/mcpx/docs/spec/conformance-matrix.md index 3994435ad..40bee2685 100644 --- a/pkgs/mcpx/docs/spec/conformance-matrix.md +++ b/pkgs/mcpx/docs/spec/conformance-matrix.md @@ -1377,9 +1377,9 @@ 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-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-support-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | implemented #1483: the gateway's client validates CallTool arguments against the tool's compiled inputSchema (internal/schemaval) and fails -32602 before the upstream is touched | 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 | implemented #1482/#1483: schemaval refuses unsupported dialects at compile; a tool declaring one is excluded from ListTools with a Warning | implemented #1482/#1483: same compile policy on the client side; the tool is excluded with a Warning | +| [json-schema-document-dialects](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | SHOULD | both | implemented #1482: docs/schema-validation.md states the dialect policy (2020-12 default; 2019-09, draft-07/06/04 accepted) | implemented #1482: docs/schema-validation.md states the dialect policy | | [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 | | [tools-schema-default-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool) | (definition) | both | tested: `conformance.TestMessagesServer/2025-11-25/json-schema/tools-schema-default-2020-12` | tested: `conformance.TestMessagesClient/2025-11-25/json-schema/tools-schema-default-2020-12` | @@ -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-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-support-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | MUST | both | implemented #1483: the gateway's client validates CallTool arguments against the tool's compiled inputSchema (internal/schemaval) and fails -32602 before the upstream is touched | 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 | implemented #1482/#1483: schemaval refuses unsupported dialects at compile; a tool declaring one is excluded from ListTools with a Warning | implemented #1482/#1483: same compile policy on the client side; the tool is excluded with a Warning | +| [json-schema-document-dialects](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | SHOULD | both | implemented #1482: docs/schema-validation.md states the dialect policy (2020-12 default; 2019-09, draft-07/06/04 accepted) | implemented #1482: docs/schema-validation.md states the dialect policy | | [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 | implemented #1482/#1483: schemaval compilation rejects an unresolvable $ref (offline loader); a tool carrying one is excluded from ListTools with a Warning (the codegen rendering path is unchanged) | implemented #1482/#1483: same rejection on the client side | | [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/mcpclient/client.go b/pkgs/mcpx/internal/mcpclient/client.go index 31693ce10..1f65413bd 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. @@ -1063,7 +1066,7 @@ func (c *Client) ListTools(ctx context.Context) ([]Tool, error) { } cursor = &next } - return c.checkToolHeaders(all), nil + return c.checkToolHeaders(c.checkToolSchemas(all)), nil } // ListResources returns static resources plus resource templates. @@ -1165,6 +1168,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 @@ -1188,6 +1198,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..e544e11e5 --- /dev/null +++ b/pkgs/mcpx/internal/mcpclient/schemacheck.go @@ -0,0 +1,156 @@ +package mcpclient + +import ( + "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 +} + +// 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 + } + } + 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(argsJSON); err != nil { + return &InvalidParamsError{Tool: name, Err: err} + } + return nil +} + +// 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..89e81d22d --- /dev/null +++ b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go @@ -0,0 +1,205 @@ +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) + } +} + +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) + } + } +} diff --git a/pkgs/mcpx/internal/mcpserver/server.go b/pkgs/mcpx/internal/mcpserver/server.go index c6bf96f67..cecb6ccc6 100644 --- a/pkgs/mcpx/internal/mcpserver/server.go +++ b/pkgs/mcpx/internal/mcpserver/server.go @@ -1002,6 +1002,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 From 1113f758228990678ce16d1d8baeab1689ab40f5 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 01:21:00 -0500 Subject: [PATCH 2/6] fix(mcpx): gofmt server.go and register schemaval covers so TestMatrix passes (#1483) --- pkgs/mcpx/docs/spec/conformance-matrix.md | 28 +++++++++---------- .../internal/conformance/coverage_messages.go | 15 ++++------ pkgs/mcpx/internal/mcpserver/server.go | 2 +- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/pkgs/mcpx/docs/spec/conformance-matrix.md b/pkgs/mcpx/docs/spec/conformance-matrix.md index 40bee2685..1503297eb 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 `1590f36a8ecf38c0` (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 `97e4d529eccba90a` (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 | 104 | 24 | 0 | 96 | +| 2026-07-28 | server | MUST | 128 | 106 | 22 | 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 | 46 | 29 | 0 | 85 | +| 2026-07-28 | client | SHOULD | 75 | 47 | 28 | 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,9 +1377,9 @@ 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 | implemented #1483: the gateway's client validates CallTool arguments against the tool's compiled inputSchema (internal/schemaval) and fails -32602 before the upstream is touched | 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 | implemented #1482/#1483: schemaval refuses unsupported dialects at compile; a tool declaring one is excluded from ListTools with a Warning | implemented #1482/#1483: same compile policy on the client side; the tool is excluded with a Warning | -| [json-schema-document-dialects](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | SHOULD | both | implemented #1482: docs/schema-validation.md states the dialect policy (2020-12 default; 2019-09, draft-07/06/04 accepted) | implemented #1482: docs/schema-validation.md states the dialect policy | +| [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 | | [tools-schema-default-2020-12](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool) | (definition) | both | tested: `conformance.TestMessagesServer/2025-11-25/json-schema/tools-schema-default-2020-12` | tested: `conformance.TestMessagesClient/2025-11-25/json-schema/tools-schema-default-2020-12` | @@ -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 | implemented #1483: the gateway's client validates CallTool arguments against the tool's compiled inputSchema (internal/schemaval) and fails -32602 before the upstream is touched | 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 | implemented #1482/#1483: schemaval refuses unsupported dialects at compile; a tool declaring one is excluded from ListTools with a Warning | implemented #1482/#1483: same compile policy on the client side; the tool is excluded with a Warning | -| [json-schema-document-dialects](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements) | SHOULD | both | implemented #1482: docs/schema-validation.md states the dialect policy (2020-12 default; 2019-09, draft-07/06/04 accepted) | implemented #1482: docs/schema-validation.md states the dialect policy | +| [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 | implemented #1482/#1483: schemaval compilation rejects an unresolvable $ref (offline loader); a tool carrying one is excluded from ListTools with a Warning (the codegen rendering path is unchanged) | implemented #1482/#1483: same rejection on the client side | +| [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/mcpserver/server.go b/pkgs/mcpx/internal/mcpserver/server.go index cecb6ccc6..2b8a7c683 100644 --- a/pkgs/mcpx/internal/mcpserver/server.go +++ b/pkgs/mcpx/internal/mcpserver/server.go @@ -598,7 +598,7 @@ func (s *Server) Tools() []Tool { },"required":["calls"],"additionalProperties":false}`), }, { - Name: "batch_call", + Name: "batch_call", Description: "Alias for mcpx_batch. Execute one or more tools in a single turn.", InputSchema: schema(`{"type":"object","properties":{ "calls":{"type":"array","description":"List of tools to execute","items":{"type":"object","properties":{ From b4a06b8c350a1e3ea9bd22b7237170cefe86cd0c Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 02:56:46 -0500 Subject: [PATCH 3/6] fix(mcpx): explain client-side -32602 refusals with catalog diagnostics (#1483) Phase 2 validation refused schema-moved calls in mcpclient before the transport, so the daemon explainCall path (which only recognised upstream rpcErrors) attached no diagnostics: five e2e tests failed in CI (callerr x4, ceiling x1) and refused calls lost the schema-change explanation every surface is built around. - daemon: clientFault() recognises an error carrying RPCCode() -32602 (interface assertion, the gateway precedent) and explainCall runs diagnose.CallError for it, so /v1/call, tasks, scripts and the CLI carry the same diagnostics as an upstream refusal. - mcpclient: InvalidParamsError headline uses the canonical "Invalid params" spelling, matching upstream -32602 messages. - e2e: two assertions that hardcoded the upstream headline ("mcp error -32602...") now expect the validator headline; the diagnostics assertions are unchanged. --- pkgs/mcpx/internal/daemon/callerr.go | 26 +++++++++++++++++++++ pkgs/mcpx/internal/e2e/callerr_test.go | 10 +++++--- pkgs/mcpx/internal/e2e/ceiling_test.go | 7 ++++-- pkgs/mcpx/internal/mcpclient/schemacheck.go | 2 +- 4 files changed, 39 insertions(+), 6 deletions(-) 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/schemacheck.go b/pkgs/mcpx/internal/mcpclient/schemacheck.go index e544e11e5..b7b31f06b 100644 --- a/pkgs/mcpx/internal/mcpclient/schemacheck.go +++ b/pkgs/mcpx/internal/mcpclient/schemacheck.go @@ -36,7 +36,7 @@ type InvalidParamsError struct { } func (e *InvalidParamsError) Error() string { - return fmt.Sprintf("invalid params: tool %q: %v", e.Tool, e.Err) + return fmt.Sprintf("Invalid params: tool %q: %v", e.Tool, e.Err) } func (e *InvalidParamsError) Unwrap() error { return e.Err } From 4ff47f765bad060b028e831cb712ce30a562991c Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 04:08:28 -0500 Subject: [PATCH 4/6] fix(mcpx): flatten validation error trees instead of panicking on combinator failures (#1483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schemaval.Validate read DetailedOutput as a flat list: interior units (anyOf/oneOf/allOf/not and other grouping keywords) carry a nil Error, so an argument failure through a combinator nil-pointer panicked the daemon — the mcpx conformance server-all leg crashed mid-suite on PR #1596 (196/51 vs main 272/0) and every later scenario failed to connect. Walk the output tree to its leaves instead. Regression tests: combinator cases in schemaval and a CallTool anyOf case in mcpclient (returns -32602, upstream untouched). --- .../internal/mcpclient/schemacheck_test.go | 29 +++++++++++++ pkgs/mcpx/internal/schemaval/schemaval.go | 17 +++++++- .../mcpx/internal/schemaval/schemaval_test.go | 41 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/pkgs/mcpx/internal/mcpclient/schemacheck_test.go b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go index 89e81d22d..d8e868124 100644 --- a/pkgs/mcpx/internal/mcpclient/schemacheck_test.go +++ b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go @@ -92,6 +92,35 @@ func TestCallToolRejectsInvalidArgumentsBeforeTheTransport(t *testing.T) { } } +// 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{ 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"}` From 7406c4cb5cbd7e180f23199e2b560b2f01af4c45 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 04:36:20 -0500 Subject: [PATCH 5/6] chore(mcpx): regenerate conformance matrix after main merge (#1483) --- pkgs/mcpx/docs/spec/conformance-matrix.md | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkgs/mcpx/docs/spec/conformance-matrix.md b/pkgs/mcpx/docs/spec/conformance-matrix.md index a3f026664..8d03a6ff1 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 `4b10fd55ec788a13` (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 `7903b73397c8bd05` (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 | 46 | 29 | 0 | 85 | +| 2026-07-28 | client | SHOULD | 75 | 47 | 28 | 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` | From 550585c2cce2b7dc35d4f4e811abd8bd21b4f1d2 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 09:16:42 -0500 Subject: [PATCH 6/6] fix(mcpx): explicit null for an optional argument is an omission, not a violation (#1483) CallTool validation now drops null-valued non-required top-level properties from the copy it validates (SEP-2243: a null parameter is not provided); the wire arguments are unchanged and a null for a required property still fails -32602. This restores the client-2026-07-28 conformance checks http-custom-headers/omit-null, whose suite call sends verbose:null against a boolean schema. officialclient: the no-arguments fallback now synthesizes minimal schema-valid arguments from each advertised tool's inputSchema instead of {}, restoring http-invalid-tool-headers/valid_tool (required region) under CallTool enforcement. --- .../conformance/officialclient/main.go | 78 ++++++++++++++++++- pkgs/mcpx/internal/mcpclient/schemacheck.go | 56 ++++++++++++- .../internal/mcpclient/schemacheck_test.go | 31 ++++++++ 3 files changed, 161 insertions(+), 4 deletions(-) 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/mcpclient/schemacheck.go b/pkgs/mcpx/internal/mcpclient/schemacheck.go index b7b31f06b..6f429cfe6 100644 --- a/pkgs/mcpx/internal/mcpclient/schemacheck.go +++ b/pkgs/mcpx/internal/mcpclient/schemacheck.go @@ -1,6 +1,7 @@ package mcpclient import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -23,6 +24,11 @@ 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 @@ -86,6 +92,7 @@ func (c *Client) checkToolSchemas(tools []Tool) []Tool { bad = fmt.Sprintf("inputSchema: %v", err) } else { ts.input = s + ts.required = requiredProps(t.InputSchema) } } if bad == "" && len(t.OutputSchema) > 0 { @@ -130,12 +137,59 @@ func (c *Client) validateToolArgs(ctx context.Context, name string, argsJSON jso if !ok || ts.input == nil { return nil } - if err := ts.input.ValidateJSON(argsJSON); err != 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). diff --git a/pkgs/mcpx/internal/mcpclient/schemacheck_test.go b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go index d8e868124..ba5711097 100644 --- a/pkgs/mcpx/internal/mcpclient/schemacheck_test.go +++ b/pkgs/mcpx/internal/mcpclient/schemacheck_test.go @@ -232,3 +232,34 @@ func BenchmarkCallToolValidateArgs(b *testing.B) { } } } + +// 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) + } +}