LearnNewsExamplesServices
Frontmatter
id10070
titleMCP: Open-bag input objects silently stripped — six Neural Link write tools broken since #10043
stateClosed
labels
bugai
assigneestobiu
createdAtApr 18, 2026, 10:42 PM
updatedAtMay 15, 2026, 2:42 PM
githubUrlhttps://github.com/neomjs/neo/issues/10070
authortobiu
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtApr 18, 2026, 11:03 PM

MCP: Open-bag input objects silently stripped — six Neural Link write tools broken since #10043

tobiu
tobiu commented on Apr 18, 2026, 10:42 PM

Problem

set_instance_properties (and five other Neural Link write tools) silently no-op. The MCP call returns {success: true} but the target instance's state is never updated. Reported by tobiu on 2026-04-18 when a DOM-visible button text change didn't happen despite the tool returning success.

Confirmed by running test/playwright/e2e/ButtonBaseNL.spec.mjs against a live server: 3 of 4 tests fail; the passing one is the non-mutating "Layout vs DOM Physics" test. Every test that mutates state via the Neural Link fixture fails with the DOM still showing the old value.

Empirical verification via direct Zod parse:

buildZodSchema(doc, setInstancePropertiesOp).parse({id: 'neo-button-1', properties: {text: 'X'}})
→ {id: 'neo-button-1', properties: {}}   // payload silently stripped

Root Cause (#10043 input-side regression)

Before #10043, buildZodSchema used a hardcoded switch where type: object fell through to z.any() (preserves payload as-is):

default: schema = z.any();

After #10043's recursion refactor, buildZodSchemaFromNode on a type: object declaration with no child properties emits z.object({}) — a strict empty object schema. Zod's default strict parse strips all unknown keys from any input matching that shape. The properties bag passed by the agent is silently emptied before reaching the worker.

Worker-side handler then calls instance.set({}) (a no-op) and returns {success: true}. The success envelope is hollow.

Scope — Cross-Server Sweep

Six affected tools, all in neural-link. Other four servers are clean (their bag-type inputs already declare additionalProperties or have per-property schemas).

Tool Broken field Effect
set_instance_properties .properties Silent no-op on all config writes (the reported bug)
find_instances .selector Selector stripped → matches all instances or none
query_component .selector Same
query_vdom .selector Same
modify_state_provider .data Silent no-op on state updates
manage_neo_config .config Silent no-op on global config changes

Why {success: true} was misleading

The worker handler validates presence of the target instance, then passes whatever properties it received to instance.set(...). An empty {} is a valid argument — set({}) is a legal no-op. The handler returns success. Nothing in the call path reports "your payload was emptied during validation".

I myself was deceived by this earlier in the same session: I reported "live end-to-end proof that #10067's .passthrough() fix is working" based on {success: true} alone, without reading back the property. The hollow success response looked identical to real success. Durable feedback saved in my own memory (feedback_verify_effect_not_just_success.md) so future agents verify via read-after-write.

Architectural Reality — A Third Schema Category

My earlier feedback_mcp_output_category_split.md (after #9837) named two output categories: closed contracts (strict) vs runtime introspection (open). This bug surfaces a parallel input category I missed:

Side Has declared properties? Correct strategy
Input (this bug) No Open — the omission signals "caller decides the shape"
Input Yes Strict — unknown keys are caller errors
Output (closed contract) Yes, stable Strict OK
Output (runtime introspection) Varies / none Open via .passthrough() (landed in #10067)

The six affected tools declare type: object with no child properties intentionallyset_instance_properties.body.properties is inherently a bag of arbitrary configs; the caller decides which ones to set. Strict empty-object parsing fights this design.

Proposed Fix (parallel to #10067, input side)

Step 1 — defense-in-depth in ai/mcp/validation/OpenApiValidator.mjs: when buildZodSchemaFromNode encounters type: object with no declared properties AND no declared additionalProperties, emit z.object({}).passthrough() instead of strict z.object({}). The passthrough() preserves unknown keys through the parse, so the caller's payload reaches the worker intact.

Reason this is correct (not a hack): the absence of properties in the OpenAPI YAML is a design signal — "I'm not declaring a shape because there is no fixed shape." Strict parsing only makes sense when the author has listed specific expected fields. This mirrors #10067's output-side rule: the schema's declaration shape IS the intent signal.

Step 2 — regression test: extend test/playwright/unit/ai/mcp/validation/OpenApiValidatorCompliance.spec.mjs with an input-side open-bag guard: for every operation where the input body declares an object property with type: object and no properties/additionalProperties, assert the Zod parse preserves a sample payload (i.e., doesn't strip unknown keys).

Step 3 (optional, follow-up) — make the six YAML declarations explicit with additionalProperties: true so intent is un-ambiguous even without relying on the validator's omission-is-open heuristic. Deferred to avoid scope creep on this PR.

Avoided Traps

  • Not reverting #10043. It correctly recurses for declared object shapes; the bug is only in the undeclared-properties edge case.
  • Not blanket-loosening all input validation. Inputs with declared properties (closed contracts) stay strict — unknown fields there are legitimate caller errors.
  • Not updating individual service handlers to re-extract data from the raw request. The validator is the right layer to fix; handlers would have to do the same work 6+ times.

Acceptance Criteria

  • ai/mcp/validation/OpenApiValidator.mjs emits .passthrough() for type: object with no declared properties and no additionalProperties
  • OpenApiValidatorCompliance.spec.mjs gains an input open-bag assertion catching the Zod strip-to-empty case
  • Running test/playwright/e2e/ButtonBaseNL.spec.mjs flips from 1/4 green to 4/4 green
  • Input schemas WITH declared properties remain strict (regression guarded by the existing input-strictness test from #9837)

Direct Motivation

Session 7258d884-6c5d-40d5-b180-d58ae9a9d729 narrowed the scope step-by-step:

  1. User reported neo-button-1 text change didn't take effect despite {success: true}
  2. Memory query (applying #10068's memory-first reflex) showed set_instance_properties worked successfully in sessions from 2026-02-25 and 2026-03-24 → confirmed real regression
  3. Ran ButtonBaseNL.spec.mjs → 3/4 fail → confirmed framework-wide, not isolated to one invocation
  4. Read+write bisection on neo-button-1: both text and _text unchanged after set → narrowed to NL routing, not reactivity
  5. Traced to SetInstancePropertiesRequest.properties being type: object with no declared children → ran buildZodSchema parse empirically → confirmed stripping

Total diagnosis time: ~15 minutes, shortened by the memory-first reflex that #10068/#10069 just formalized.

Origin Session ID

7258d884-6c5d-40d5-b180-d58ae9a9d729

tobiu added the bug label on Apr 18, 2026, 10:42 PM
tobiu added the ai label on Apr 18, 2026, 10:42 PM
tobiu assigned to @tobiu on Apr 18, 2026, 10:42 PM
tobiu referenced in commit 04d4f07 - "fix(mcp): preserve open-bag input payloads through Zod validation (#10070)" on Apr 18, 2026, 10:48 PM
tobiu cross-referenced by PR #10071 on Apr 18, 2026, 10:50 PM
tobiu referenced in commit d814388 - "fix(mcp): preserve open-bag input payloads through Zod validation (#10070) (#10071)" on Apr 18, 2026, 11:03 PM
tobiu closed this issue on Apr 18, 2026, 11:03 PM
tobiu cross-referenced by #10074 on Apr 19, 2026, 1:01 AM
tobiu cross-referenced by #10075 on Apr 19, 2026, 9:02 AM