---
number: 10065
title: 'fix(mcp): emit items for array schemas to satisfy strict validators (#10064)'
author: tobiu
state: MERGED
createdAt: '2026-04-18T17:51:28Z'
updatedAt: '2026-04-18T17:58:37Z'
closedAt: '2026-04-18T17:58:33Z'
mergedAt: '2026-04-18T17:58:33Z'
head: claude/admiring-easley-fc4b56
base: dev
url: 'https://github.com/neomjs/neo/pull/10065'
---
## Summary

GitHub Copilot's MCP client rejected `mcp_neo_mjs-neura_call_method` with:

> Failed to validate tool ... array type must have items

Root-caused to a three-stage interaction:

1. `neural-link/openapi.yaml` declared `CallMethodRequest.args` as `type: array` without an `items` field (four total occurrences across neural-link; other four MCP servers clean).
2. `OpenApiValidator.mjs` handled the missing `items` by producing `z.array(z.any())`.
3. `zod-to-json-schema` with `target: 'openApi3'` silently strips `items` from `z.array(z.any())`, emitting `{"type":"array"}`. Strict validators (Copilot) reject this; permissive ones (Claude, Cursor) accept it, which is why the bug was invisible until a Copilot user surfaced it.

## Fix (two layers)

**Layer 1 — `ai/mcp/validation/OpenApiValidator.mjs`**
Both the array fallback and the catch-all now return `z.unknown()` instead of `z.any()`. Runtime validation is identical (both accept anything); the only observable difference is in `zod-to-json-schema` emission — `z.unknown()` preserves `items: {}`, satisfying strict validators.

**Layer 2 — `ai/mcp/server/neural-link/openapi.yaml`**
- `CallMethodRequest.args` → added `items: {}` (heterogeneous method args)
- `/data/store/inspect` response `filters`, `sorters`, `items` → added `items: {type: object}`

## Verification

New regression test `test/playwright/unit/ai/mcp/validation/OpenApiValidatorCompliance.spec.mjs`:
- Asserts `z.array(z.unknown())` emits `items` under the `openApi3` target (direct guard on Layer 1).
- Walks the emitted input + output JSON Schemas for every operation on all five MCP servers, failing if any array node lacks `items`.

```
$ npx playwright test -c test/playwright/playwright.config.unit.mjs test/playwright/unit/ai/mcp/validation/OpenApiValidatorCompliance.spec.mjs
  6 passed (672ms)
```

## Orthogonality to prior work

- **#10043** (closed) removed the hardcoded `z.array(z.string())` — unrelated prior regression.
- **#9837** (open) covers nested array schemas defaulting to strings — also separate.
- **This PR** covers the residual third case: arrays with no `items` key at all.

## Avoided Traps

- **Not** reintroducing hardcoded `z.array(z.string())` fallback — would regress typed args.
- **Not** post-processing emitted JSON Schema to inject `items` — adds a second serialization layer when `z.unknown()` achieves the same outcome natively in Zod.
- **Not** silently widening every YAML array — keeps the YAML as the source of intent; Layer 1 is the safety net, not the primary contract.

## Origin Session ID

`97343e6e-9d1c-4170-b3a5-6e58d41511b6`

Resolves #10064

## Comments

### `@tobiu` commented on 2026-04-18T17:57:19Z

**Input from Claude Opus 4.7 (Claude Code):**

> ❋ # PR Review Summary
> 
> **Status:** Comment (Self-Review)
> 
> **Self-Review Opening:** Self-review of #10064. I chose a **two-layer fix** (defense-in-depth in `OpenApiValidator` + source-of-truth in the YAML) over either one in isolation, because mid-verification I discovered the YAML fix alone was insufficient — `items: {}` recurses through `buildZodSchemaFromNode` to the catch-all, which was still `z.any()`. That forced the Layer 1 scope to cover both the array fallback *and* the final catch-all. Key trade-offs and gaps noted below.
> 
> ---
> 
> ### 📊 Evaluation Metrics
> *   **`[ARCH_ALIGNMENT]`**: 88 — Respects the OpenAPI-as-source-of-intent principle (YAML is primary contract, validator is safety net). `z.unknown()` over `z.any()` aligns with the "prefer the stricter type when runtime behavior is identical" principle already established in the file.
> *   **`[CONTENT_COMPLETENESS]`**: 82 — Both inline comments in the validator explain the *why* (zod-to-json-schema openApi3 quirk) not just the *what*. JSDoc on the new test helper cites the exact error and ticket. The validator module itself lacks a file-level `@summary` Anchor — not touched here since it predates this PR, but worth flagging (see `[KB_GAP]`).
> *   **`[EXECUTION_QUALITY]`**: 85 — 6/6 tests green; sweep covers 34 operations across all 5 servers; caught the two-stage fallback during verification rather than post-merge. Loss: test couples directly to `js-yaml` + `zod-to-json-schema` rather than exercising `ToolService.listTools()` end-to-end. See `[TOOLING_GAP]`.
> *   **`[PRODUCTIVITY]`**: 95 — Ticket goal achieved: Copilot's `call_method` schema now emits `items: {}`. Zero scope creep into #9837 (adjacent ticket in the same file).
> *   **`[IMPACT]`**: 70 — Unblocks an entire MCP host (GitHub Copilot) and hardens the validator against any future YAML gap in any of the five servers. Not framework-critical, but a real integration-surface fix.
> *   **`[COMPLEXITY]`**: 35 — Three small touchpoints (+96/-3). The cognitive load was in diagnosing the three-stage pipeline interaction, not in the patch itself.
> *   **`[EFFORT_PROFILE]`**: Quick Win — high ROI (restores a major harness), low code complexity.
> 
> ---
> 
> ### 🕸️ Context & Graph Linking
> *   **Target Epic / Issue ID:** Resolves #10064
> *   **Related Graph Nodes:** #10043 (closed — input schema hardcoding fix), #9837 (open — nested array items). This PR is the orthogonal third case and does not subsume #9837.
> *   **Origin Session ID:** `97343e6e-9d1c-4170-b3a5-6e58d41511b6`
> 
> ---
> 
> ### 🧠 Graph Ingestion Notes
> 
> *   **`[KB_GAP]`**: `ai/mcp/validation/OpenApiValidator.mjs` has per-function JSDoc but no file/module-level Anchor describing the three-stage YAML→Zod→JSON-Schema pipeline. Future agents hitting a similar class of bug (serialization quirk) would benefit from an Anchor at the top calling out "this file bridges OpenAPI to MCP-consumable JSON Schema; emission quirks live here". Out-of-scope for this fix.
> *   **`[TOOLING_GAP]`**: (1) The regression test replicates the server's dependency chain (`js-yaml` + `zod-to-json-schema`) rather than exercising `ToolService.listTools()` directly. If the server swapped emitters, the test would still pass against the old library while shipping a broken schema. A stronger oracle would boot `ToolService` and validate the emitted tools with Ajv in strict mode. (2) `test/playwright/unit/ai/mcp/server/memory-core/managers/ChromaManager.spec.mjs` fails to import `ai/mcp/server/memory-core/config.mjs` in this worktree — unrelated to this PR but surfaces that config scaffolding is gitignored without a template fallback for worktree-isolated harnesses. (3) `Authorization.spec.mjs` has a pre-existing timeout on the "401 when no token provided" case — also unrelated.
> *   **`[RETROSPECTIVE]`**: Two takeaways from my own process on this one. (a) **Assign before implementing.** I filed #10064 and started coding without running `manage_issue_assignees add @me`; tobiu flagged it as a minor process flaw. Feedback memory saved — next session will fold assignment into the same Ticket-First step as creation. (b) **Trust-but-verify fallback paths.** My first validator patch only touched the array-missing-items branch. If I had stopped there and relied on the YAML fix, the `items: {}` sentinel would have slipped through via the unrelated catch-all — an invisible regression. Running the emission through `buildZodSchema` end-to-end (not just reading the diff) caught it. Worth codifying: any time you change a schema-emission path, re-emit the actual artifact and diff it, don't just reason about it.
> 
> ---
> 
> ### 📋 Required Actions
> 
> - [ ] Human QA confirms Copilot no longer errors on `call_method` (the user-facing trigger for this ticket).
> - [ ] (Optional, follow-up) File a lightweight ticket for the stronger Ajv-strict oracle test (`[TOOLING_GAP]` #1). Scope is small enough to bundle with whoever picks up #9837.
> 
> No block-level changes requested — the fix itself is complete and tested.
> 
> *Handoff: awaiting human merge per `pull-request-workflow.md` §5 (Human-in-the-Loop). I will not self-merge.*
> 

---

