Skip to content

fix(mcp): result-shaped output schemas for application CRUD tools (#1324) - #1336

Merged
kriszyp merged 16 commits into
mainfrom
fix/1324-mcp-crud-output-schema
Jun 17, 2026
Merged

fix(mcp): result-shaped output schemas for application CRUD tools (#1324)#1336
kriszyp merged 16 commits into
mainfrom
fix/1324-mcp-crud-output-schema

Conversation

@kylebernhardy

Copy link
Copy Markdown
Member

Fixes #1324. Stacked on #1320 (base branch is fix/1317-mcp-application-500-and-conformance) — review/merge #1320 first.

Summary

The auto-generated MCP application-profile CRUD tools advertised the full-record outputSchema, but their handlers return a result envelope. The official @modelcontextprotocol/sdk client validates results against the advertised schema and rejected every call with -32600 ("has an output schema but did not return structured content"):

  • create_* returned a bare primary key (scalar) → no structuredContent at all.
  • update_*/patch_* returned {ok:true}, which the full-record schema (with required server-assigned fields like @updatedTime) doesn't satisfy.

So these tools were unusable from spec-conformant hosts (Claude Desktop, etc.). Harper's server doesn't validate outputSchema — only strict clients do — which is why #1320's round-trip used raw JSON-RPC.

Fix

Advertise what the handlers actually return, mirroring the existing delete(boolean)/search(omit) precedent:

Tool Returns outputSchema
create_* { id } { id: <pk type> }
update_*/patch_* { ok: true } { ok: boolean }
delete_* { deleted: <bool> } { deleted: boolean }

Handlers wrap their scalar return into the matching object so structuredContent is present. get_* keeps the full-record schema (it genuinely returns the record). The #1317 integration round-trip is flipped from raw JSON-RPC to the SDK client, which now validates the contract end-to-end.

Where to look

  • components/mcp/tools/application.tsisStructuredEnvelope() guard so a custom Resource that returns a structured object (typically with a static outputSchemas override) passes through unchanged rather than being re-wrapped. This guard is the main correctness surface; it's applied symmetrically to create/update/patch/delete.
  • components/mcp/tools/schemas/derive.tsderiveRecordSchema is now used by get_* only; create/update/patch/delete get result-shaped schemas. This also fixes the secondary bug where a freshly-created record could lack a required @updatedTime.

🤖 Generated by an LLM (Claude Opus 4.8).

nizzlenitz and others added 10 commits June 16, 2026 11:58
…1317)

P1: the application-profile MCP endpoint 500'd on every request. Harper's
RequestBody exposes only .on()/.pipe() but the adapter read it with
`for await`, throwing "body is not async iterable". Read the body via the
stream event API, and make RequestBody/BunRequestBody async-iterable for
defense in depth.

S1: malformed JSON on the operations (Fastify) route now returns a JSON-RPC
-32700 frame instead of a framework 400 — the /mcp route is encapsulated in
a Fastify child plugin with a raw-string application/json content-type parser
so the transport's parseMessage is the single parse point.

S2: an invalid pagination cursor on tools/list and resources/list now returns
-32602 instead of silently restarting at page 1. New shared pagination.ts
(decodeCursor -> null on invalid); listTools/listResources take a decoded
offset and the transport decodes + validates at the boundary.

S3: enforce Accept content negotiation — 406 only when the header is present
and excludes the produced type and a matching wildcard (absent = allowed).

S4: Origin validation was already enforced (403 via the CORS allow-list);
documented the secure-default guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng payload

Two regressions surfaced by the new integration coverage (the P1 fix made the
application profile actually reach these paths for the first time):

- Application profile resources/list 500'd with "Cannot find module
  '../../resources/Resources.ts'". The component-loader context can't resolve
  `.ts`-extension lazy requires (dist ships `.js`); the working sibling
  `tools/application.ts` already uses the extension-less form. Drop the `.ts`
  extension on resources.ts's three lazy requires (Resources, openApi, Server).

- Operations profile tools/list failed with FST_ERR_REP_INVALID_PAYLOAD
  ("invalid type 'object'"). The MCP routes live in an encapsulated Fastify
  child plugin where the default object serializer isn't applied and Harper's
  content-negotiation serializer is skipped once Content-Type is set, so a large
  object payload reached @fastify/compress unserialized. Send a pre-serialized
  JSON string from the Fastify adapter (mirrors the Harper-HTTP adapter), making
  the transport the single source of the wire bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- pagination.decodeCursor: reject cursors longer than 512 chars before parsing
  (a real cursor is ~24 chars), so the cursor field can't force large
  allocations. (gemini-code-assist security-medium)
- harperHttp readBody: fix the now-contradictory comment — RequestBody DID only
  expose .on()/.pipe(); this PR adds Symbol.asyncIterator, but we still read via
  the event API as the canonical contract. (claude review nit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a change

The CI run surfaced two issues the new integration coverage exposed (the P1
fix made the application profile actually reach these paths):

B) Operations profile tools/list 500'd (FST_ERR_REP_INVALID_PAYLOAD). The S1
   route encapsulation (Fastify child plugin for a scoped raw-body parser)
   prevented the MCP route from inheriting Harper's response serializers — both
   the JSON content-negotiation serializer and the SSE text/event-stream writer
   — so object/SSE payloads reached @fastify/compress unserialized. Revert the
   encapsulation and register routes directly on the operations instance again.
   S1 now: the application profile (which reads the raw body) returns a
   JSON-RPC -32700 for malformed JSON; the operations (Fastify) profile returns
   a spec-permitted HTTP 400. The Streamable HTTP transport explicitly allows an
   HTTP error for unparseable input.

C) Application tools/list returned an empty set: registerApplicationTools ran
   once at component load, before the app's exported tables were registered, and
   was never rebuilt. Add refreshApplicationTools() (idempotent rebuild via
   clearProfileTools) and invoke it from listChanged.onSchemaChange, so tools
   appear once tables load. Registration is now idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the schema-change tool rebuild in try/catch so a throw during Resource
enumeration can't abort the session-notification loops that follow (clients
would otherwise silently miss the schema-change notification for that cycle).
Matches the file's existing fire-and-forget/trace pattern. (claude review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The round-trip grabbed the first create_* tool, which sorted to
create_AbuseCounter — an expiration-only table in the fixture with no post
handler — so the create failed. Target WorkItem explicitly (a full-CRUD
@table @export), and follow its actual contract: post generates the id and
returns it, so capture that id and read back by it. Test-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auto-generated create_* tools invoked `Resource.post(target, data)` with a
record-scoped (non-collection) target. Harper's base `Resource.post` only
inserts when the resolved resource is a collection (`#isCollection`); for a
record-scoped target it falls through to `missingMethod` → 405 "does not have a
post method implemented". So create_* tools were registered (detectVerbs sees
the base prototype `post`) but failed at call time against real tables — caught
by the new application-profile integration test (#1317).

Set `target.isCollection = true` in makeCreateHandler so `Resource.post`
resolves the table collection and routes to `create()`. Harmless for resources
with a custom `post` (it overrides the base and ignores the flag). Unit test
asserts the create target is flagged a collection; the integration round-trip
now accepts either an id string or an { id } record from create.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create_* tools declare an outputSchema but return a bare id with no
structuredContent, which the strict @modelcontextprotocol/sdk client rejects
("has an output schema but did not return structured content"). That output-
contract gap is pre-existing and tracked separately; it's orthogonal to whether
the create+get operation works. Drive this round-trip over a raw JSON-RPC client
(no client-side outputSchema enforcement) so it proves the operation end-to-end
against a real table. The create insert itself is fixed by the prior
target.isCollection change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Node's base64url decoder silently tolerates invalid/extra characters, so a
tampered cursor like `${validCursor}!` decoded to a valid offset and bypassed
the -32602 invalid-cursor path. Require the input to equal the canonical
encoding of the decoded offset, rejecting any junk/non-canonical form.
(Codex cross-model review, P2.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

The auto-generated application-profile create/update/patch/delete tools
advertised the full-record outputSchema but their handlers return a result
envelope, so strict @modelcontextprotocol/sdk clients rejected every call
with -32600 ("has an output schema but did not return structured content"):

- create_ returned a bare primary key (scalar) -> no structuredContent.
- update_/patch_ returned {ok:true}, which the full-record schema (with
  required @updatedTime etc.) does not satisfy.

Advertise what the handlers actually return, mirroring the existing
delete(boolean)/search(omit) precedent:

- create_  -> { id }      (typed by the primary-key attribute)
- update_/patch_ -> { ok } acknowledgement
- delete_  -> { deleted } boolean envelope

Handlers wrap their scalar return into the matching object so the result
carries structuredContent; a custom Resource that returns a structured
envelope (typically with a static outputSchemas override) passes through
unchanged. get_ keeps the full-record schema (it returns the record).

Flips the #1317 integration round-trip from raw JSON-RPC to the official SDK
client, which validates results against outputSchema end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp

kriszyp commented Jun 17, 2026

Copy link
Copy Markdown
Member

The integration test failures here are all artifact download errors across every shard — not test failures. The issue appears to be stale/poisoned artifact storage on this run. Could you push a new commit (or empty git commit --allow-empty) to trigger a fresh CI run with clean artifacts? The code looks fine otherwise.

nizzlenitz and others added 3 commits June 17, 2026 08:15
… doc/log fixes

Review follow-ups on the MCP application-profile work (#1317):

- harperHttp readBody: handle the stream 'close' event. On a premature client
  disconnect IncomingMessage emits 'close' without 'end'/'error', so the read
  promise hung forever and leaked the buffered chunks. A `settled` flag keeps
  'close' a no-op on normal completion (where it fires after 'end').
- registerApplicationTools: make the registry rebuild atomic. Snapshot the prior
  application tools, clear, rebuild; on a mid-loop throw restore the snapshot and
  rethrow so tools/list is never left empty. Registration is synchronous, so no
  reader can observe the intermediate gap.
- transport NormRequest.body: correct the stale doc-comment that still described
  the reverted raw-body content-type parser (Harper-HTTP passes a raw string,
  Fastify passes a pre-parsed object).
- listChanged: log a swallowed tool-rebuild failure at warn, not trace — a stale
  tools/list is otherwise invisible at default log levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration shards on the prior run failed only on a GitHub Actions
artifact-download infra error; re-running that run reused the same artifact.
Empty commit to produce a brand-new workflow run with fresh artifacts.
…-conformance' into fix/1324-mcp-crud-output-schema
@kylebernhardy

Copy link
Copy Markdown
Member Author

@kriszyp thanks — tried that, but it didn't clear it. Pushed an empty commit to get a fresh run with clean artifacts; the build uploaded a fresh 161 MB artifact successfully (successfully finalized), yet every integration shard still failed downloading it — Unable to download artifact(s)… after 5 retries, dying ~27s into the Azure blob transfer. Repeated with full re-runs (regenerating the artifact each time) and by merging this branch's base in — same result every time.

So it isn't stale storage on the original run; it's the blob download failing. Tellingly, runs on main and on the base branch (#1320) during the same window downloaded their artifacts fine, so the artifact service is healthy in general — #1336's runs just keep landing on flaky blob hosts (productionresultssa2/13/14).

(The transaction.test.js #1114-dedup unit failure on the latest run is an unrelated pre-existing flake — v24 only; this PR only touches components/mcp/**.)

The code itself is validated — unit/build/lint green, and Codex + Gemini cross-model reviews both clean. The reliable unblock looks like landing #1320 (green, and your review items are addressed) and rebasing this onto main, which sidesteps the stuck stacked run entirely. Let me know if you'd prefer a different approach.

🤖 AI-generated (Claude), posted by Kyle.

Base automatically changed from fix/1317-mcp-application-500-and-conformance to main June 17, 2026 16:27
nizzlenitz and others added 3 commits June 17, 2026 10:32
…put-schema

# Conflicts:
#	integrationTests/mcp/application.test.ts
#	unitTests/components/mcp/tools/application.test.js
makeUpdateHandler detached the verb method from its class
(`const fn = ResourceClass.put; fn(...)`), so the static Resource dispatcher
ran with `this === undefined` and threw `Cannot read properties of undefined
(reading 'directURLMapping')` on every update_/patch_ call. get_/create_/delete_
already invoke the method on the class; update was the only detached one.

Call `ResourceClass.put!(...)` / `ResourceClass.patch!(...)` directly so `this`
stays bound. Surfaced by #1324's SDK-client integration round-trip (the first to
exercise update_ over a real Resource); the unit mocks never referenced `this`,
so they missed it — added a regression test with a `this`-dereferencing handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kylebernhardy
kylebernhardy marked this pull request as ready for review June 17, 2026 17:52
@github-actions
github-actions Bot requested review from cb1kenobi and heskew June 17, 2026 17:52
@kylebernhardy

Copy link
Copy Markdown
Member Author

@kriszyp update — CI is fully green now. Here's how it resolved:

Artifact-download failures: these turned out to be intermittent Azure-blob degradation that this PR's stacked runs kept losing the coin flip on — builds uploaded a valid 161 MB artifact every time, but the download died ~27s in (after 5 retries), across fresh artifacts and multiple blob hosts, while main and #1320 runs in the same window downloaded fine. Once #1320 merged, I merged main into this branch so it runs on a clean (non-stacked) base — and the artifact step has passed cleanly since (30/30 integration shards green).

The more important find: with integration finally running, the #1324 SDK-client round-trip caught a real operational bug — update_/patch_ threw Cannot read properties of undefined (reading 'directURLMapping'). makeUpdateHandler was invoking the verb method detached from its class (const fn = ResourceClass.put; fn(...)), so the static Resource dispatcher ran with this === undefined. get_/create_/delete_ already called the method on the class; update was the lone exception, and the unit mocks never referenced this so they missed it. Fixed by calling ResourceClass.put!/patch! directly (this preserved) + a regression test with a this-dereferencing handler. The SDK round-trip now passes for all four verbs.

(The transient transaction.test.js #1114 and blob.test.js unit failures along the way were unrelated pre-existing flakes — different test/runtime each run — and cleared on re-run.)

Net: result-shaped output schemas (create→{id}, update/patch→{ok}, delete→{deleted}) + the this-binding fix; Codex + Gemini both clean. Ready for your review.

🤖 AI-generated (Claude), posted by Kyle.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Output schema/handler contract correctly aligned for all CRUD tools; this-binding fix is a good catch.

Reviewed by claude-sonnet-4-6.

@kriszyp
kriszyp merged commit 3fce4eb into main Jun 17, 2026
50 of 51 checks passed
@kriszyp
kriszyp deleted the fix/1324-mcp-crud-output-schema branch June 17, 2026 20:26
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.

MCP application create/update/delete tools: outputSchema vs return-value mismatch breaks strict SDK clients

3 participants