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 strippedRoot 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 intentionally — set_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
Direct Motivation
Session 7258d884-6c5d-40d5-b180-d58ae9a9d729 narrowed the scope step-by-step:
- User reported neo-button-1 text change didn't take effect despite
{success: true}
- 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
- Ran ButtonBaseNL.spec.mjs → 3/4 fail → confirmed framework-wide, not isolated to one invocation
- Read+write bisection on
neo-button-1: both text and _text unchanged after set → narrowed to NL routing, not reactivity
- 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
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 strippedRoot Cause (#10043 input-side regression)
Before #10043,
buildZodSchemaused a hardcoded switch wheretype: objectfell through toz.any()(preserves payload as-is):default: schema = z.any();After #10043's recursion refactor,
buildZodSchemaFromNodeon atype: objectdeclaration with no childpropertiesemitsz.object({})— a strict empty object schema. Zod's default strict parse strips all unknown keys from any input matching that shape. Thepropertiesbag 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
additionalPropertiesor have per-property schemas).set_instance_properties.propertiesfind_instances.selectorquery_component.selectorquery_vdom.selectormodify_state_provider.datamanage_neo_config.configWhy
{success: true}was misleadingThe worker handler validates presence of the target instance, then passes whatever
propertiesit received toinstance.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:.passthrough()(landed in #10067)The six affected tools declare
type: objectwith no childpropertiesintentionally —set_instance_properties.body.propertiesis 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: whenbuildZodSchemaFromNodeencounterstype: objectwith no declaredpropertiesAND no declaredadditionalProperties, emitz.object({}).passthrough()instead of strictz.object({}). Thepassthrough()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
propertiesin 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: objectand noproperties/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: trueso 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
properties(closed contracts) stay strict — unknown fields there are legitimate caller errors.Acceptance Criteria
ai/mcp/validation/OpenApiValidator.mjsemits.passthrough()fortype: objectwith no declaredpropertiesand noadditionalPropertiesOpenApiValidatorCompliance.spec.mjsgains an input open-bag assertion catching the Zod strip-to-empty casetest/playwright/e2e/ButtonBaseNL.spec.mjsflips from 1/4 green to 4/4 greenpropertiesremain strict (regression guarded by the existing input-strictness test from #9837)Direct Motivation
Session
7258d884-6c5d-40d5-b180-d58ae9a9d729narrowed the scope step-by-step:{success: true}set_instance_propertiesworked successfully in sessions from 2026-02-25 and 2026-03-24 → confirmed real regressionneo-button-1: bothtextand_textunchanged after set → narrowed to NL routing, not reactivitySetInstancePropertiesRequest.propertiesbeingtype: objectwith no declared children → ran buildZodSchema parse empirically → confirmed strippingTotal diagnosis time: ~15 minutes, shortened by the memory-first reflex that #10068/#10069 just formalized.
Origin Session ID
7258d884-6c5d-40d5-b180-d58ae9a9d729