fix(mcp): result-shaped output schemas for application CRUD tools (#1324) - #1336
Conversation
…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>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
Reviewed; no blockers found. |
|
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 |
… 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
|
@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 ( So it isn't stale storage on the original run; it's the blob download failing. Tellingly, runs on (The 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 🤖 AI-generated (Claude), posted by Kyle. |
…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>
|
@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 ( The more important find: with integration finally running, the #1324 SDK-client round-trip caught a real operational bug — (The transient Net: result-shaped output schemas (create→{id}, update/patch→{ok}, delete→{deleted}) + the 🤖 AI-generated (Claude), posted by Kyle. |
kriszyp
left a comment
There was a problem hiding this comment.
Output schema/handler contract correctly aligned for all CRUD tools; this-binding fix is a good catch.
Reviewed by claude-sonnet-4-6.
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/sdkclient 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) → nostructuredContentat all.update_*/patch_*returned{ok:true}, which the full-record schema (withrequiredserver-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:outputSchemacreate_*{ 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
structuredContentis 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.ts—isStructuredEnvelope()guard so a custom Resource that returns a structured object (typically with astatic outputSchemasoverride) 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.ts—deriveRecordSchemais now used byget_*only; create/update/patch/delete get result-shaped schemas. This also fixes the secondary bug where a freshly-created record could lack arequired@updatedTime.🤖 Generated by an LLM (Claude Opus 4.8).