Skip to content

fix(hono): on a createHonoApp host a HEAD is answered as its GET, so HEAD /api/v1/mcp/skill no longer answers 405 Allow: GET - #24286

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24245-mcp-skill-head
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24245-mcp-skill-head

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24245

Clause-②: no

Basis for the Clause-② line: the fix removes a refusal the published HTTP contract already rules out. IHttpServer's unmatched-request contract names @objectstack/http-conformance as its validator, and that suite pins "answers HEAD from GET routes without a body" (packages/qa/http-conformance/src/adapter.test.ts:144), as RFC 9110 §9.3.2 does. No export moves. Every request whose answer changes is a HEAD on a createHonoApp host, and it now gets its GET answer's status and headers with no body. No GET answer moved (measured, below).

What changed

  • packages/adapters/hono/src/index.ts (the home): a module-private dispatchedMethod(method) maps HEAD to GET and leaves every other verb as sent.
    • The ${prefix}/* catch-all hands dispatch() dispatchedMethod(method). Before, it handed the raw c.req.method, which reads HEAD even though Hono routed the request as GET.
    • The auth arm's /auth/config branch compares dispatchedMethod(method). A HEAD there was forwarded to better-auth and answered 404, while plugin-auth's GET mount answers it on the standalone server. This is the same defect class in the same file, and it is fixed under the bounded in-place exemption (same class, mechanical, file in this claim's surface, same gate family).
    • Only the verb moves. { request: c.req.raw } is still the client's HEAD request, as the standalone mounts hand theirs. Hono drops the body itself.
  • packages/runtime/src/domains/approvals.ts: the /approvals/act domain decides "is this a HEAD" from the forwarded request's own method (request?.method === 'HEAD'), not from the dispatcher's verb. This edit is required by the change above. Without it, the hosted HEAD reached the approvals member as the raw HEAD and answered 405 with Allow: GET, POST. Measured: hono-approvals-act.conformance.test.ts's HEAD pin went red with only the catch-all change, because the hosted door answered 405 where the self-hosted door answered 200. The docblocks that said the two methods are always equal now say where they differ.
  • Tests and a changeset (@objectstack/hono patch, @objectstack/runtime patch).

Where the fix lives, and why (measured)

Measured in process on base 53ef75243, with a LiteKernel, fake auth / mcp / analytics / protocol slots, and both doors:

  • the hosted door: createHonoApp driven through app.request, with HttpDispatcher.prototype.dispatch wrapped to record the verb it received and its answer;
  • the standalone door: HonoServerPlugin on a real socket with createDispatcherPlugin.

The run covered 34 paths × GET/HEAD × anonymous/signed in.

  • A1 (confirmed). handleMcpSkillRequest answers 405 + Allow: GET for every method that is not GET (packages/runtime/src/domains/mcp.ts, the method !== 'GET' branch).
  • A2 (confirmed). On the hosted door, dispatch() received HEAD on every HEAD row.
  • A3 (confirmed). The standalone door answered every HEAD row exactly as its GET row. Its transport routes a HEAD to the GET mount, and the mount hands dispatch() a literal 'GET'.
  • A4 (answered: Hono drops it). hono 4.13.12 hono-base re-dispatches a HEAD as GET and wraps the answer in new Response(null, …). Every HEAD answer through app.request had an empty body, at base and at head.

Why the catch-all and not the mcp domain. The shape is not the mcp domain's. The dispatcher decides a verb by comparing it with 'GET' in many places: a domain's methods filter (/health, /ready), a domain's branches (m === 'GET'), the discovery root, and the share-link floor's public-GET exemption. Through the hosted door, 13 of 68 HEAD rows answered differently from their GET, across 7 route families (table below). A domain-level HEAD-as-GET would close one of them and leave a dialect to repeat in every other domain. The catch-all is the one producer of the raw HEAD on this host. Mapping it there gives both compositions the same dispatch() input: the verb GET, beside a request whose method is HEAD.

The PM's two checks:

  • A path that serves no GET keeps its GET answer for HEAD. For /keys and /actions/task/close, signed in, that is 405 + Allow: POST; anonymous, 401 UNAUTHENTICATED. These answers are unchanged from base, on both doors. A HEAD there runs only the domain's refusal. No GET is served on such a path.
  • Anonymous floor order: at head, every anonymous HEAD row gets the anonymous GET answer. At base one row did not: the public GET /share-links/:token/resolve answered 501 (no service in this boot), and its HEAD answered 401.

Side effects of GET dispatch. A domain that forwards the request to a member still hands over the client's HEAD. The /mcp transport was handed HEAD on both doors, before and after. The /approvals/act member is handed a GET built by the domain, as before. A domain keyed on the dispatcher's verb now runs its GET branch for a HEAD, which is what the standalone server already does for the same request.

HEAD on the hosted door, base → head (no GET row moved; standalone unchanged)

caller path GET HEAD at base HEAD at head
anonymous + signed in /mcp/skill 200 405, Allow: GET 200
anonymous + signed in /health, /ready 200 404 ROUTE_NOT_FOUND 200
anonymous + signed in /environments/env_m (scoped root) 200 404 ROUTE_NOT_FOUND 200
signed in /analytics/meta 200 404 (domain handled: false) 200
signed in /ai/agents 200 501 200
signed in /data/account, /data/account/r1 503 (no data service) 404 (domain handled: false) 503
anonymous /share-links/tok/resolve 501 (public) 401 501
anonymous (auth service present) /auth/config 200 404 200 (unit pin)

Other GET-serving domains with the same shape

The card asks for these by name. All of them reach dispatch() through the same catch-all, so this change closes them too:

  • Measured above: /health, /ready (methods: ['GET']); the discovery root under /environments/:id; analytics (/analytics/meta); ai (/ai/agents); data (the reads); share-links (the public resolve floor); and the auth arm's /auth/config.
  • Read in code, not measured: their services were absent from the measuring boot, and each one's service-absence answer comes before its verb check, so both verbs answered alike there. These domains decide reads by === 'GET':
    • i18n (m !== 'GET' is not handled);
    • automation (its GET faces);
    • notifications;
    • packages (its GET faces);
    • security (suggested-bindings);
    • share-links (its other GET faces);
    • meta (the !method || method === 'GET' reads).

Tests

  • New packages/adapters/hono/src/hono-head-dispatched-as-get.test.ts (4 cases; the dispatcher is this package's stub):
    • HEAD /mcp/skill reaches dispatch() as GET beside the client's HEAD request, and its answer is the GET answer's status and headers with an empty body. The fixture is the skill domain's real result shape.
    • A refused GET (405, Allow: POST, METHOD_NOT_ALLOWED, httpStatus 405) gets the same status and headers on HEAD, with no body.
    • Every other verb (GET POST PUT PATCH DELETE OPTIONS) reaches dispatch() as sent.
    • HEAD /auth/config gets the public config's status and headers with no body, and better-auth is not asked.
  • New packages/qa/http-conformance/src/hono-head-as-get.conformance.test.ts (24 cases). It uses the real HttpDispatcher and the real createHonoApp pipeline (app.request), beside the standalone server:
    • 12 rows, each asserting: the GET status, plus code and httpStatus for a refused GET; HEAD status and headers equal to the GET's; empty HEAD body; and the Allow value (POST on the two 405 rows, absent otherwise).
    • For the 10 rows both doors serve, the hosted HEAD status and Allow equal the standalone HEAD's.
    • HEAD /mcp/skill carries the skill's content-type, content-disposition and cache-control on both doors.
    • Only the verb moves: the /mcp transport is handed HEAD on both doors.
  • packages/runtime/src/domains/approvals-act.test.ts: new case. A HEAD request dispatched as GET is still forwarded to the member as a GET, and is answered with no body.
  • PR fix(runtime): on the standalone server every /actions verb reaches the actions domain, so an anonymous caller gets 401, and no Allow lists a verb mounted only to refuse #24244's standalone pins, run and green:
    • packages/runtime/src/mcp-skill-method-not-allowed.hono.integration.test.ts
    • actions-every-verb.hono.integration.test.ts
    • keys-every-verb.hono.integration.test.ts
    • notifications-every-verb.hono.integration.test.ts
    • dispatcher-body-after-anonymous-floor.hono.integration.test.ts and http-dispatcher.mcp.test.ts (7 files, 291 tests).
  • Controls kept green: hono-approvals-act.conformance.test.ts (its HEAD pin holds both doors equal), hono-webhooks-redeliver.conformance.test.ts (HEAD stays 404) and hono-meta-item-read-gate.conformance.test.ts.
  • Package runs:
    • @objectstack/hono test: 10 files, 211 tests, exit 0. typecheck: exit 0. Both at 379b734a2.
    • @objectstack/http-conformance test: 16 files, 214 tests, exit 0. typecheck: exit 0. Both at 379b734a2.
    • @objectstack/runtime test: 416 files, 6548 passed and 19 skipped, exit 0. typecheck: exit 0. Both at 0a5693634; no runtime file changed after that commit.
    • The merge of origin/main at 4f14775b4 brought only scripts/pm/dispatch-gates*.

One-time ablations (fix committed first, scripts/ablation-replace.mjs, wrap mode, restore proven: blob equals HEAD and git diff HEAD is empty)

  1. dispatchedMethod. Mutated return method === 'HEAD' ? 'GET' : method; to return method;. Anchor count 1 → 0; blob 4f962dbb6cfd → aa3f8f87edab. The subject resolves from source in both suites (the hono package itself, and the http-conformance alias of @objectstack/hono to src), so no build leg.
    • Hono unit: 3 failed, 1 passed. The every-other-verb control stays green.
    • Conformance: 13 failed, 11 passed. The 11 are the controls: non-GET paths, anonymous-floor rows that matched at base, and the /mcp verb row.
    • Restored to 4f962dbb6cfd.
  2. The approvals key. Mutated if (request?.method === 'HEAD') { to if (method === 'HEAD') {. Blob 67a8a4b44bf3 → ab7e441de94c.
    • approvals-act.test.ts: 1 failed, 12 passed (expected 'HEAD' to be 'GET').
    • Restored.
    • The conformance leg of the same mutation was read before the runtime rebuild: hono-approvals-act's HEAD pin was red, with the hosted door at 405 and Allow: GET, POST. The rebuilt dist then went green.

Gates

The gate list is derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at the final head 4f14775b4. It gives 66 commands, the same list as at 0a5693634 and at 379b734a2.

  • All 66 run at 4f14775b4: all exit 0. Each exit code was captured before any pipe. These include:
    • pnpm check:dispatcher-error-vocabulary
    • pnpm check:error-status-conformance ("every derivable runtime status is documented, and every documented status is reachable")
    • pnpm check:route-envelope
    • pnpm check:nul-bytes (OK, no raw control bytes)
    • pnpm check:changeset-gate-self-tests
    • node scripts/check-changeset-no-major.mjs --base origin/main
    • pnpm check:cross-package-test-inputs
    • pnpm check:test-source-alias
    • pnpm check:type-check-coverage and pnpm check:type-check-debt
    • pnpm check:dual-build-cjs-loads
  • --ran reconciliation: 66 derived famil(ies) accounted for, 66 run, 0 NOT-MEASURED (a DERIVED zero, all 66 recorded an exit code and none of them is 3).
  • First pass at 0a5693634: two commands exited 3 as prerequisite refusals, not measured failures. Both were re-run:
    • check-plugin-teardown-shape --self-test needed its pinned fixture commit, which was fetched.
    • check:dual-build-cjs-loads needed every workspace dist.
  • ESLint over the changed files: pnpm exec eslint --no-inline-config over the five changed source and test files, exit 0, no output. The repo-wide pnpm lint is CI's.
  • Not run locally: @objectstack/runtime's test:repo project (CI's Test Core). The diff edits no test in that project.
  • CI: the PR's check-runs are the reading for every family CI owns.

Acceptance notes

  • A finding, reported to the PM, not fixed here. On a createHonoApp host, GET /api/v1/mcp/skill serves the skill as an SSE frame of a JSON string: the body is data: "# skill" followed by a blank line, not the markdown. The content-type is doubled (text/markdown; charset=utf-8, text/markdown; charset=utf-8) and cache-control reads no-cache, no-store. The cause is toResponse's events stream arm in packages/adapters/hono/src/index.ts: it SSE-frames every event unless vercelDataStream is set, and it merges its own capitalised headers with the domain's lowercase ones. packages/runtime/src/domains/mcp.ts says string events are "written verbatim" on every adapter. The standalone server serves the raw markdown with cache-control: no-store (PR fix(runtime): on the standalone server every /actions verb reaches the actions domain, so an anonymous caller gets 401, and no Allow lists a verb mounted only to refuse #24244's pin). This change does not touch it, and the new pins assert only toContain on those two headers and a non-empty GET body, so nothing pins the defect.
  • The auth arm's legacy fallback dispatcher.handleAuth(path, method, …) still passes the raw verb. The auth domain does not read it (_method), so no answer differs, and it is unchanged.
  • A HEAD on a GET whose answer is an events stream still runs the stream's producer; Hono discards the body. The standalone server does the same for the same request, since it runs the GET handler too. Noted, not changed.

Generated by Claude Code

…from

The catch-all handed dispatch() the raw `HEAD`, which no dispatcher domain
serves, so a HEAD was not answered as its GET on this host (`HEAD
/api/v1/mcp/skill` answered 405 with `Allow: GET`). The standalone server
hands dispatch() `GET` for the same request. Only the verb moves: the
forwarded request is still the client's HEAD, so the /approvals/act domain
now reads HEAD off that request.

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…p, against the standalone server; changeset

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…eal result shape; type the hosted door's request

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 2 package(s): @objectstack/hono, @objectstack/runtime, touching 5 documentable anchor(s).

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

  • content/docs/api/environment-routing.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • 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/concepts/north-star.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via /share-links/:token/resolve (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/metadata-service.mdx (via /environments/:id (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/implementation-status.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • 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 — 27 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 93b4acb57ec23c4e94a7047133ce612f89a13838 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d57274c54ada0ac0d9e70b89895b7b10dfa15919 — the merge of head 4f14775b4ce225820bebffab1217d7e479594202 into base 93b4acb57ec23c4e94a7047133ce612f89a13838, 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 d57274c54ada0ac0d9e70b89895b7b10dfa15919 && git checkout d57274c54ada0ac0d9e70b89895b7b10dfa15919
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93b4acb57ec23c4e94a7047133ce612f89a13838 4f14775b4ce225820bebffab1217d7e479594202 && git checkout -B drift-repro 93b4acb57ec23c4e94a7047133ce612f89a13838 && git merge --no-ff 4f14775b4ce225820bebffab1217d7e479594202

node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838

⚠️ 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 93b4acb57ec23c4e94a7047133ce612f89a13838 → 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-18T08:13Z · on head 4f14775b4 · #24245 (claim 6246689908, amended at this review; report 6247465845). 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 #24245, and no other closing keyword is in the body (full-body scan). Clause-②: no is line-initial and bare, with its basis in prose. Assignee zhuangjianguo. 6 files, +469 / −9. check-governed-merges --pr 24286: NOT governed, 478 changed lines.
  2. Premise, measured: handleMcpSkillRequest's method !== 'GET' refusal (mcp.ts:176 on 0d34066ef) was read by the seat before dispatch. The home is the measured one: createHonoApp's catch-all now dispatches dispatchedMethod(method), a HEAD as GET, and still forwards { request: c.req.raw }, the client's HEAD. That matches the standalone server, whose GET mounts pass a literal 'GET'. The Clause-②: no basis holds on the diff: every refusal that changes is a HEAD the conformance suite already rules out ("answers HEAD from GET routes without a body"). A path with no GET keeps its 405 / Allow or its anonymous 401 (the conformance rows for /keys and /actions), and no GET row moves. dispatchedMethod is module-private, so no export moves.
  3. Gates: the report's local union of 66 derived families is all exit 0, and the --ran reconciliation shows 0 NOT-MEASURED. eslint over the changed files: exit 0. CI on this head at this stamp: completed/success=28 completed/skipped=6 in_progress/-=5 (latest per name). No failure. ⛔ Not ready until every check on the head is green.
  4. Deviations accepted:
    • packages/runtime/src/domains/approvals.ts (with approvals-act.test.ts) lies outside the claim's two candidate homes. It is required by the catch-all change: hono-approvals-act.conformance's HEAD pin went red without it, the PR body states it, and the claim is amended in place to declare it.
    • /auth/config's branch in index.ts is fixed under the bounded in-place exemption: same class, mechanical, inside the claim, same gates, and unit-pinned.
    • One changeset carries both published packages (@objectstack/hono, @objectstack/runtime), each patch.
    • Accepted as reported: the main merge (scripts/pm/ only), the fetched teardown fixture commit, the uncommitted measurement harness, and the model-free trailers.
    • The pins assert status, headers and an empty body for HEAD, and code plus status where the GET is a refusal. Both ablations went red and were restored to the HEAD blob.
  5. Out of scope:
  6. 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 close runtime(mcp): on a createHonoApp host, HEAD /api/v1/mcp/skill answers 405 with Allow: GET while GET is served — the catch-all hands dispatch() the raw HEAD and handleMcpSkillRequest refuses every non-GET #24245 with pm:dispatched removed. runtime(mcp): an anonymous POST /api/v1/mcp answers 501 (no MCP service) or 404 (OS_MCP_SERVER_ENABLED=false) ahead of 401: handleMcpRequest checks the switch and the service before its anonymous decision #24251 (same mcp.ts file family, serial behind this card) is re-priced on the main that carries this PR.

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