---
id: 10070
title: 'MCP: Open-bag input objects silently stripped — six Neural Link write tools broken since #10043'
state: CLOSED
labels:
  - bug
  - ai
assignees:
  - tobiu
createdAt: '2026-04-18T20:42:43Z'
updatedAt: '2026-05-15T12:42:12Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10070'
author: tobiu
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-04-18T21:03:20Z'
---
# MCP: Open-bag input objects silently stripped — six Neural Link write tools broken since #10043

## 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](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):

```javascript
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](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`

## Timeline

- 2026-04-18T20:42:44Z @tobiu added the `bug` label
- 2026-04-18T20:42:44Z @tobiu added the `ai` label
- 2026-04-18T20:42:48Z @tobiu assigned to @tobiu
- 2026-04-18T20:48:56Z @tobiu referenced in commit `04d4f07` - "fix(mcp): preserve open-bag input payloads through Zod validation (#10070)"
- 2026-04-18T20:50:31Z @tobiu cross-referenced by PR #10071
- 2026-04-18T21:03:20Z @tobiu referenced in commit `d814388` - "fix(mcp): preserve open-bag input payloads through Zod validation (#10070) (#10071)"
- 2026-04-18T21:03:20Z @tobiu closed this issue
- 2026-04-18T23:01:05Z @tobiu cross-referenced by #10074
- 2026-04-19T07:02:13Z @tobiu cross-referenced by #10075

