Skip to content

schema: nullable arguments as anyOf [X, {type: null}]; gate reports unchanged (see Consiliency/pmcp#369) - #371

Merged
ViperJuice merged 1 commit into
mainfrom
fix/369-nullable-anyof
Oct 5, 2026
Merged

ViperJuice merged 1 commit into
mainfrom
fix/369-nullable-anyof

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

See #369. This implements the plan merged in #370, rev 2, with the two edits the ruling required.

What changes

All 42 nullable arguments are now advertised as anyOf: [X, {"type": "null"}]. Before, they used type: [X, "null"], plus null among an enum's values. Now null never appears as an enum value, a const, a default, or a member of a type array. The change is made in one place, src/pmcp/tools/schema.py::_canonical_nullable, and the snapshot diff is exactly those 42 nodes.

Hosts now receive (captured from the real binaries for the plan):

  • OpenCode's Google provider: sends {type: X, nullable: true}. Before, it sent the string "null" in 4 enums and dropped null from all 42 arguments.
  • Codex and Claude Code: pass the new form through unchanged.

The gate

The gate accepts and refuses exactly what it did before.

gate_error_for unfolds a nullable union's refusal into X's own errors before best_match ranks them. So a refused call reports the same message, keyword and path as 3.0, including calls with several failures. The one exception is that enum messages no longer list None.

validate_at_gate is the server's only gate call. The migration guide's checker uses gate_error_for.

Ruling edits (on top of the plan's patch)

  • Fallback holds by construction. best_match now runs inside the fallback's try. If it fails, the gate uses jsonschema's own best_match, and if that also fails, the first raw error. A new test, test_a_refusal_survives_a_ranking_that_raises, forces best_match to raise. It checks that the call is still refused and that the scoped audit records exactly one audit.rejection with terminal_status: "invalid_arguments".
  • CHANGELOG wording. In the amended api: advertised tool schemas drift from the pydantic models and unknown keys are silently ignored (C-10, M-02) #236 Changed entry, the "28 optional arguments … were previously rejected" clause now follows "accepts an explicit null … as the handlers always did".

Evidence (head 6439b21, base b9bf9d3, dev0)

  • The patch is the proven one. The patch embedded in the merged plan is byte-identical to the proven spike's. Every file it touches matched the spike before the ruling edits, and the regenerated snapshot is byte-identical.
  • Gates: ruff check, ruff format --check (182 files), mypy src/pmcp (53 files) and scripts/check_security_claims.py (131 cited node ids) all pass.
  • Full suite, py3.10: 9101 passed, 5 skipped, 80 deselected.
  • Targeted modules on 3.10, 3.11 and 3.12: 1129 passed on each. The modules are portability, schemas, migration_doc, scoped_advisor_audit, task_numeric_bounds and task_units.
  • Differential fuzz: 69,000 calls with 1–4 sites each, compared against main's live schemas. 0 differences in what is reported, 0 accept/refuse mismatches.
  • Mutants: 19/19 killed. These include:
    • the rev-1 rank-then-fold order (M15);
    • no fold fallback (M16);
    • best_match moved outside the try (M18, the rev-2 shape);
    • no last-resort first error (M19).

https://claude.ai/code/session_01Qq9UsYfWoENCKhQsuqRkNK

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…nchanged (see #369)

Optional arguments were advertised as type: [X, "null"], with null among
an enum's values. OpenCode's Google provider turns that enum null into the
string "null" and drops null from type arrays. They are now advertised as
anyOf: [X, {"type": "null"}] (input_schema_for / _canonical_nullable), the
spelling the AI SDK sends to Gemini as {type: X, nullable: true}.

The gate accepts and refuses exactly what it did. gate_error_for unfolds a
nullable union's refusal into X's own errors before best_match ranks them,
so the reported message, keyword and path match the old spelling (enum
messages no longer list None). Unfold and ranking failures fall back to
jsonschema's choice and then to the first raw error, so a refusal is always
reported and audited. The migration guide's checker uses the same choice.

Implements the plan merged in #370.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qq9UsYfWoENCKhQsuqRkNK
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@ViperJuice
ViperJuice merged commit 23edd92 into main Oct 5, 2026
14 checks passed
@ViperJuice
ViperJuice deleted the fix/369-nullable-anyof branch October 5, 2026 18:55
ViperJuice added a commit that referenced this pull request Oct 5, 2026
Clean merge; CHANGELOG keeps both entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qq9UsYfWoENCKhQsuqRkNK
ViperJuice added a commit that referenced this pull request Oct 6, 2026
…they are made, on every transport; SDK server logs masked; exception readers registry-guarded (see #297)

Round 18 on #314 (claude DISAGREE, codex F001).
- Merge origin/main 23edd92 (#371 nullable anyOf,
  #364 docs) with a real merge: server.py keeps main's
  validate_at_gate. A gate refusal is described as #371's gate_error_for
  reports it; a nullable X reads "X or null"; the sweep reads anyOf
  [X, null] as X (135 -> 252 cases; gateway.sync_environment covered).
  The migration guide's gate texts and its checker use the structural
  description; a versioned package pattern is a fixed-text error.
- claude F001: pmcp.sdk_rejections wraps the SDK's
  handler_exception_to_error_data (dispatcher and runner) and the modern
  ladder writer: an SDK-origin error keeps its code and id, its message
  only if reviewed, its data only in a reviewed shape (method name
  dropped, requested only if a revision). pmcp handler errors are marked
  by _described_errors and pass unchanged. The HTTP layer keeps only the
  out-of-session id-null bodies. Every error construction in the whole
  mcp package is classified by AST, exact both ways.
- claude N2: mcp.server/mcp.shared/sse_starlette records mask text args
  and f-string placeholders; stdio and HTTP tested at DEBUG.
- codex F001: parse_url_elicitation_error refuses a registered error's
  chain; the sink guard seeds annotated exception parameters, accepts a
  read only after the registry check, and every exempt callee carries a
  test-enforced reason (predicate, scanned, guarded, handoff,
  passthrough, reregisters).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2ovNe4qBzpdunYGYTM6kR
ViperJuice added a commit that referenced this pull request Oct 6, 2026
…; exception readers registry-guarded; re-based on main 23edd92 (see #297)

Answers round 18 on #314 @ acf99e9.
- claude F001: pmcp.sdk_rejections rebuilds every SDK-origin JSON-RPC
  error at the one exception-to-wire mapping (stdio, legacy and modern
  HTTP): code and id kept; message only if an SDK literal or a reviewed
  template; data only in a reviewed shape (method name dropped,
  requested only if a revision). pmcp's handler errors are marked and
  pass unchanged. All 49 non-literal error constructions in the mcp
  package are classified by AST, exact both ways.
- claude N2: SDK server-side and sse_starlette records masked at
  creation; stdio and HTTP grids run at DEBUG.
- codex F001: the elicitation parser and pyjwt_text refuse a registered
  error's chain; the sink guard seeds annotated exception parameters
  (22 new reads found), accepts a read only after the registry check, and
  every exemption carries a test-enforced reason.
- claude N1: a real merge of main 23edd92 (#371,
  #364); nullable unions described as gate_error_for
  reports them; the sweep reads anyOf [X, null] (135 -> 252 cases).
- Spike wip/297-code @ f89527e; 51 patches, cmp-identical on 23edd92.
  137 mutants: 135 / 130 killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W2ovNe4qBzpdunYGYTM6kR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant