Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24287-hono-stream-string-verbatim
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24287-hono-stream-string-verbatim

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 result stream 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 inside toResponse, a closure in createHonoApp).

What a caller gets on a createHonoApp host

GET /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):

before (fa501123b, unfixed index.ts) after (32900fd25) standalone server
body, app.request data: "---\nname: objectstack\n---\n\n# skill \"q\"\n" plus a blank line (an SSE frame of a JSON string) the markdown, byte for byte the markdown, byte for byte
content-type, app.request text/markdown; charset=utf-8, text/markdown; charset=utf-8 text/markdown; charset=utf-8 text/markdown; charset=utf-8
cache-control, app.request no-cache, no-store no-store no-store
wire header lines, real socket (@hono/node-server serving app.fetch) Content-Type, Cache-Control: no-cache, Connection, content-type, content-disposition, cache-control: no-store (two Content-Type lines, two Cache-Control lines) one each of content-type, cache-control: no-store, content-disposition, connection (pinned in mcp-skill-method-not-allowed.hono.integration.test.ts)

The real-socket rows come from a one-off probe (a LiteKernel with a fake mcp service, createHonoApp, serve({ fetch: app.fetch, port: 0 }), read with node:http rawHeaders), run against the built @objectstack/hono dist 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:

  • Body. The arm wrote every event as data: ${JSON.stringify(event)} unless vercelDataStream was set (:835–:837 on 21070a2f0). It now writes a string event as given in every mode; an object event is still one data: frame (one JSON line in a Vercel data stream). That is the standalone writer's rule (runtime/src/dispatcher-plugin.ts, sendResultBase).
  • Headers. The arm spread the domain's lowercase content-type / cache-control into a record beside its own capitalised Content-Type / Cache-Control, and new Response appended both. It now seeds a Headers with the same three defaults and sets each producer header over them, so a producer's header replaces the default it names whatever its case. The standalone writer does the same with res.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/main 21070a2f0)

The arm runs for a dispatcher result with type: 'stream' and events. Only two domains return one:

producer events vercelDataStream answer after this PR
/mcp/skill (runtime/src/domains/mcp.ts) one string (the markdown) no changed: the defect fixed
/ai routes via runtime/src/domains/ai.ts, Vercel data stream (service-ai's /ai/chat, /ai/agents/:agentName/chat with its quota refusal, /ai/assistant/chat) pre-encoded string frames from encodeVercelDataStream, ending data: [DONE] yes unchanged (strings were already verbatim in this mode)
/ai routes via ai.ts, plain SSE (service-ai's /ai/chat/stream) aiService.streamChat() parts, which are objects (TextStreamPart; the memory and Vercel adapters both yield objects) no unchanged (objects keep their data: frame)

The service-ai rows were read from the published @objectstack/service-ai@10.3.0 tarball (dist/index.js), since that package lives outside this repository. Every other result producer (auth, approvals, automation-hooks, webhooks) hands a Response and 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/skill shape 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 /ai SSE shape and the /ai Vercel data stream shape answer exactly as before.
  • packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts (real LiteKernel, real HttpDispatcher, @objectstack/hono aliased to source): on the hosted door the body is the markdown and content-type, content-disposition and cache-control each 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 test 215 passed (11 files), exit 0; pnpm --filter @objectstack/hono typecheck exit 0 (the new test file is in tsconfig.test.json's program, counted with --listFiles); pnpm --filter @objectstack/http-conformance exec vitest run over the new file plus hono-head-as-get and hono-dispatcher-result-response, 29 passed, exit 0; pnpm --filter @objectstack/http-conformance typecheck exit 0.

Reverse verification and ablation

  • Before the fix (fa501123b: tests committed, index.ts unfixed): both conformance cases and the unit /mcp/skill case red on the body (data: "---\nname: objectstack\n…" received); the three other unit cases green.
  • Leg A, header merge (headers.set(k, String(v)) to headers.append(k, String(v)), through scripts/ablation-replace.mjs, blob 62e6f29d4016 to e958969acbd3): unit 3 of 4 red, conformance 2 of 2 red. Direction: more red than the old defect, because append also doubles a same-case header (the /ai rows), which the old record spread did not. Restored: blob back to 62e6f29d4016 and git diff HEAD empty.
  • Leg B, string verbatim (const chunk = typeof event === 'string' to const chunk = res.vercelDataStream && typeof event === 'string', blob 62e6f29d4016 to 91002889678f): unit 1 of 4 red (the /mcp/skill row; 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-replace refused 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/hono to src/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/objectstack at 32900fd25 (merge base 30da04f8e): 66 commands, all run, 66 exit 0. pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, no dist/ for 34 packages); after the workspace build that pnpm check:type-check-debt runs, it was re-run: exit 0. --ran reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN, every line carrying its exit code. pnpm exec eslint --no-inline-config over the three changed TypeScript files: exit 0, no output. The repo-wide pnpm 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):

  • Remaining differences between this arm and the standalone writer, none exercised by a producer today: the arm answers 200 whatever result.status says (both producers mean 200); a null event is framed as data: null where the standalone writer skips it; a stream that throws is closed silently where the standalone writer writes an event: error frame; and the Connection: keep-alive default stays when a producer sets other headers (the standalone writer sends only the producer's). Owner: none.
  • runtime/src/domains/ai.ts builds its own headers for a stream and does not pass the AI route's, so on a createHonoApp host a Vercel data stream from service-ai@10.3.0 would carry Content-Type: text/plain; charset=utf-8 and none of the route's x-vercel-ai-ui-message-stream, x-objectstack-agent or x-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

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/hono, touching 1 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/index.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/api/wire-format.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via createHonoApp (symbol, a top-level function))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/releases/v17/17-5.mdx (via createHonoApp (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 52ff308b8686f1f5a304341534bd8b8eb1cdd59e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a003350311629cacb774849f2bfbfe985ccd46aa — the merge of head 32900fd25e0c2c0e2823ac0dfa3d19f99a2abc68 into base 52ff308b8686f1f5a304341534bd8b8eb1cdd59e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 52ff308b8686f1f5a304341534bd8b8eb1cdd59e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T11:52Z · on head 32900fd25 · #24287 (claim 6248781476, report on the card). This is the seat's own review at the default tier (Clause-②: no, no packages/spec path, not governed), read against GitHub, not the report.

  1. Shape: draft against main. The first line is Fixes #24287, with no other closing keyword in the body. Clause-②: no is line-initial and bare, with its basis in prose. Assignee zhuangjianguo. 4 files, +266 / −7. check-governed-merges --pr 24319: NOT governed.

  2. Premise, measured, and the fence held:

  3. Other producers (A4): only /mcp/skill (one string event) and /ai reach this arm. /ai's producers send pre-encoded string frames with vercelDataStream (already verbatim) or object parts without it (still SSE-framed), so neither answer moves. ⚠️ That reading of /ai comes from the published @objectstack/service-ai@10.3.0 tarball, read and not executed, because the cloud repository answered 403 to this session. A cloud main that sends string events outside a Vercel data stream and relies on SSE framing would see them written verbatim now. Recorded here as the residual risk. It was not measured.

  4. Gates: the report's local union is 66/66 exit 0. check:dual-build-cjs-loads was turned from exit 3 into a reading after a workspace build, and the --ran reconciliation shows 0 NOT-MEASURED. eslint exits 0. CI on this head at this stamp: 28 completed, all success or skipped; 9 in_progress; no failure. ⛔ Not ready until every check on the head is green.

  5. Deviations accepted:

  6. Out of scope: both notes are carrier: none, recorded in the PR's Acceptance notes:

    • the arm's other differences from the standalone writer (status, null events, a throwing stream, Connection), read from code with no producer exercising them;
    • /ai's stream headers on a createHonoApp host, inferred and NOT MEASURED at a door.

    Neither reaches the filing gate without a measured reach:.

  7. Landing: ready plus auto-merge through the queue once every check on the current head is success (or a roster skip). Then read origin/main and the queue branch for the merge, and hono: on a createHonoApp host, GET /api/v1/mcp/skill answers an SSE frame of a JSON string (data: "# skill") with a doubled Content-Type, not the raw markdown the standalone server serves #24287 closes on Fixes.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants