Repository navigation
fix(hono): on a createHonoApp host, GET /mcp/skill answers the raw markdown; a stream's string event is written as given and a producer header replaces the default it names - #24319
Conversation
…wip, red before the fix) Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
… header replaces the default it names On a createHonoApp host, GET /api/v1/mcp/skill answered an SSE frame of a JSON-encoded string under a doubled Content-Type and no-cache, no-store. toResponse's events arm now writes a string event verbatim in every mode and merges the producer's headers case-insensitively over its defaults, as the standalone server's writer does. Object events keep their SSE frame. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a003350311629cacb774849f2bfbfe985ccd46aa && git checkout a003350311629cacb774849f2bfbfe985ccd46aa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 52ff308b8686f1f5a304341534bd8b8eb1cdd59e 32900fd25e0c2c0e2823ac0dfa3d19f99a2abc68 && git checkout -B drift-repro 52ff308b8686f1f5a304341534bd8b8eb1cdd59e && git merge --no-ff 32900fd25e0c2c0e2823ac0dfa3d19f99a2abc68
node scripts/docs-audit/affected-docs.mjs --json 52ff308b8686f1f5a304341534bd8b8eb1cdd59e
|
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #24287
Clause-②: no
Basis for the Clause-② line: a wrong response body and a doubled header on one adapter are made to match the
resultstream channel's stated contract (packages/runtime/src/domains/mcp.ts: "string events are written verbatim, custom headers honored") and the standalone server's answer. No request is newly accepted or refused, and no export moves (the only source change is insidetoResponse, a closure increateHonoApp).What a caller gets on a
createHonoApphostGET /api/v1/mcp/skill, measured with the probe's fixture skill (front matter and a double-quoted heading; the conformance pin's fixture adds an em dash):fa501123b, unfixedindex.ts)32900fd25)app.requestdata: "---\nname: objectstack\n---\n\n# skill \"q\"\n"plus a blank line (an SSE frame of a JSON string)content-type,app.requesttext/markdown; charset=utf-8, text/markdown; charset=utf-8text/markdown; charset=utf-8text/markdown; charset=utf-8cache-control,app.requestno-cache, no-storeno-storeno-store@hono/node-serverservingapp.fetch)Content-Type,Cache-Control: no-cache,Connection,content-type,content-disposition,cache-control: no-store(twoContent-Typelines, twoCache-Controllines)content-type,cache-control: no-store,content-disposition,connectionmcp-skill-method-not-allowed.hono.integration.test.ts)The real-socket rows come from a one-off probe (a
LiteKernelwith a fakemcpservice,createHonoApp,serve({ fetch: app.fetch, port: 0 }), read withnode:httprawHeaders), run against the built@objectstack/honodist before and after the fix. It is not a committed test; the card had that socket as NOT MEASURED.Where the fix lives
packages/adapters/hono/src/index.ts,toResponse's events arm (the claim's narrowed home). Measured against the card's two mechanisms:data: ${JSON.stringify(event)}unlessvercelDataStreamwas set (:835–:837on21070a2f0). It now writes a string event as given in every mode; an object event is still onedata:frame (one JSON line in a Vercel data stream). That is the standalone writer's rule (runtime/src/dispatcher-plugin.ts,sendResultBase).content-type/cache-controlinto a record beside its own capitalisedContent-Type/Cache-Control, andnew Responseappended both. It now seeds aHeaderswith the same three defaults andsets each producer header over them, so a producer's header replaces the default it names whatever its case. The standalone writer does the same withres.header().The domain's channel (
mcp.ts, held by #24251) was not needed: its contract was right and this adapter was the one door not honouring it.Producers that reach this arm (on
origin/main21070a2f0)The arm runs for a dispatcher
resultwithtype: 'stream'andevents. Only two domains return one:vercelDataStream/mcp/skill(runtime/src/domains/mcp.ts)/airoutes viaruntime/src/domains/ai.ts, Vercel data stream (service-ai's/ai/chat,/ai/agents/:agentName/chatwith its quota refusal,/ai/assistant/chat)encodeVercelDataStream, endingdata: [DONE]/airoutes viaai.ts, plain SSE (service-ai's/ai/chat/stream)aiService.streamChat()parts, which are objects (TextStreamPart; the memory and Vercel adapters both yield objects)data:frame)The
service-airows were read from the published@objectstack/service-ai@10.3.0tarball (dist/index.js), since that package lives outside this repository. Every otherresultproducer (auth,approvals,automation-hooks,webhooks) hands aResponseand leaves at the arm above. No producer relies on SSE framing of a string event, so no design question arose.Tests
packages/adapters/hono/src/hono-stream-events-framing.test.ts(fixture dispatcher): the/mcp/skillshape answers the markdown verbatim with each declared header once; object events outside a Vercel data stream are still SSE frames under the SSE defaults; the/aiSSE shape and the/aiVercel data stream shape answer exactly as before.packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts(realLiteKernel, realHttpDispatcher,@objectstack/honoaliased to source): on the hosted door the body is the markdown andcontent-type,content-dispositionandcache-controleach have the one value the domain declares; the hosted answer equals the standalone answer (HonoServerPlugin+ dispatcher plugin over a real socket) in status, body and those headers.Runs at
32900fd25:pnpm --filter @objectstack/hono test215 passed (11 files), exit 0;pnpm --filter @objectstack/hono typecheckexit 0 (the new test file is intsconfig.test.json's program, counted with--listFiles);pnpm --filter @objectstack/http-conformance exec vitest runover the new file plushono-head-as-getandhono-dispatcher-result-response, 29 passed, exit 0;pnpm --filter @objectstack/http-conformance typecheckexit 0.Reverse verification and ablation
fa501123b: tests committed,index.tsunfixed): both conformance cases and the unit/mcp/skillcase red on the body (data: "---\nname: objectstack\n…"received); the three other unit cases green.headers.set(k, String(v))toheaders.append(k, String(v)), throughscripts/ablation-replace.mjs, blob62e6f29d4016toe958969acbd3): unit 3 of 4 red, conformance 2 of 2 red. Direction: more red than the old defect, becauseappendalso doubles a same-case header (the/airows), which the old record spread did not. Restored: blob back to62e6f29d4016andgit diff HEADempty.const chunk = typeof event === 'string'toconst chunk = res.vercelDataStream && typeof event === 'string', blob62e6f29d4016to91002889678f): unit 1 of 4 red (the/mcp/skillrow; the Vercel row stays green), conformance 2 of 2 red. Restored the same way. A first attempt at this leg was a no-op: its replacement contained its anchor,ablation-replacerefused it (anchor count 1 to 1) and ran no test, so that reading is void and the leg was rerun with the anchor above.Both subjects resolve to source (the hono suite imports
./index; http-conformance aliases@objectstack/honotosrc/index.ts), so neither leg needed a rebuild. No ablation file is committed.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat32900fd25(merge base30da04f8e): 66 commands, all run, 66 exit 0.pnpm check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, nodist/for 34 packages); after the workspace build thatpnpm check:type-check-debtruns, it was re-run: exit 0.--ranreconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN, every line carrying its exit code.pnpm exec eslint --no-inline-configover the three changed TypeScript files: exit 0, no output. The repo-widepnpm lint, the type-check lanes and the path-scheduled CI jobs are CI's.Acceptance notes
Observations, not filed (each a reading of code with no producer exercising it, or an unmeasured inference):
200whateverresult.statussays (both producers mean200); anullevent is framed asdata: nullwhere the standalone writer skips it; a stream that throws is closed silently where the standalone writer writes anevent: errorframe; and theConnection: keep-alivedefault stays when a producer sets other headers (the standalone writer sends only the producer's). Owner: none.runtime/src/domains/ai.tsbuilds its own headers for a stream and does not pass the AI route's, so on acreateHonoApphost a Vercel data stream fromservice-ai@10.3.0would carryContent-Type: text/plain; charset=utf-8and none of the route'sx-vercel-ai-ui-message-stream,x-objectstack-agentorx-objectstack-skills, which the standalone route mount writes. Read from code and the published tarball only, NOT MEASURED at a door. Owner: none.Generated by Claude Code