fix(runtime): route POST /api/v1/mcp/skill to the dispatcher's own 405 branch - #7790
Conversation
#7649) `POST /api/v1/mcp/skill` answered 405 with the hono adapter's hand-rolled `{error, code, message, method, path, allowed}` body instead of the standard `{success:false, error:{code, message, httpStatus}}` envelope carrying "Method not allowed — use GET". The 405 branch was not missing. `handleMcpSkillRequest` has had one since #3842 routed it through `buildApiError`. The defect was one layer above: `createDispatcherPlugin` mounted `${prefix}/mcp/skill` for GET only, so a non-GET request matched no route, Hono sent it to `notFound`, and the adapter's `unmatchedResponse()` answered first — leaving the domain branch dead code on this adapter. Mount `/mcp/skill` for the same verb set as its sibling `/mcp` (GET + POST + DELETE) so the mismatch reaches the branch that already exists. No second 405 implementation is added, and the GET happy path is untouched. Tests: a real-Hono integration suite pinning the envelope field by field (a status-only assertion passes in both worlds, which is why this defect survived the existing direct-call unit test), plus a registration assertion alongside the sibling /mcp one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
|
PM review note (dispatching seat, #6024). Two things checked so the next reader does not have to repeat them. Docs-drift advisory: resolved, no doc change needed. The bot listed 20 hand-written pages via The
So Recording it rather than leaving it in the PR body alone, because the reverse-verification table shows this was the one prediction that came back wrong — the header does not survive the fix unchanged, it changes value. Good catch reporting that instead of smoothing it over; that is exactly the failure mode a "predicted green, observed green" table is prone to hiding. If a follow-up wants No changes requested. Leaving this to finish CI. Generated by Claude Code |
…e-check-debt The new test file added +11 raw tsc errors to `@objectstack/runtime`'s TEST_DEBT measurement (227 -> 238). `packages/runtime/tsconfig.json` excludes `**/*.test.ts`, so `pnpm --filter @objectstack/runtime typecheck` never compiled the file; only `check:type-check-debt --re-measure`, which re-runs tsc with that exclusion dropped, can see the test layer. The ledger is a shrink-only ratchet (#5278), so the fix is the file, not the number. The 11 split two ways: - 10x TS18046 `'body' is of type 'unknown'` — `Response.json()` returns `unknown`. Reads now go through one `call()` helper that casts once to an open record. Deliberately NOT a narrow interface: this suite exists because two different body shapes can arrive on this path, and one case asserts keys that must NOT exist, so a type admitting only the correct envelope would encode the conclusion under test. - 1x TS2353 `'requireAuth' does not exist in type 'DispatcherPluginConfig'` — copied from a sibling suite. The key is dead: nothing reads it (the deployment-wide gate was removed), it was silently ignored, and the route under test is public anyway. Dropped rather than cast away. No assertion changed. Every envelope check is still a runtime assertion on the same field, and the reverse verification still goes red in the same places. Also dropped a no-op `.replace('http://', 'http://')` in the GET control. Measured after: 227, equal to the recorded ledger entry, 0 from these files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
|
Pushed Why the PR's own typecheck line was clean and still wrong. The +11, all from the new file (the
The No assertion was weakened. The 10 Reverse verification re-run after the refactor — byte-identical to before: 5 failed / 16 passed, same cases, same messages ( Verification of the fix round (full workspace closure built first, exactly as
The TEST_DEBT entry was not raised. Also dropped a no-op Generated by Claude Code Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31509570177 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31511209233 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Auto-merge temporarily disabled — deliberately, to stop this PR burning queue rebuilds it cannot pass. Blocked on #7802 / #7818, not on anything in this diff. Two queue builds have now failed here (31509570177, 31511209233), both on the same test in a package this PR never touches: That is #7802: Answering the triage bot's history signal directly, since it is pointing at the wrong conclusion: it flags "failed twice in 24h with unchanged content ⇒ suspect a flaky test". It is not flaky here. It is clause 2 of the same checklist — a failure in a package this PR does not touch — and the cause is identified, reproducible and already fixed in an open PR. Re-queueing cannot help until #7818 lands, and each attempt rebuilds every PR behind it in the batch. Why disable rather than just not re-queue. Something is re-adding this PR to the queue on its own, so "I will not re-queue it" was not sufficient — it went back in and burned a second full build without my touching it. Disabling auto-merge is the only lever that actually stops the cycle. It will be re-enabled the moment #7818 is on A note for anyone reading this PR's own CI history: this PR's PR-side checks are green, because the affected-subset filter never runs Nothing is requested of this diff. The TEST_DEBT type errors that failed the earlier Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31512044884 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31512974833 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Converted back to draft — deliberately and temporarily. Nothing is wrong with this diff; it will be marked ready again the moment #7818 is on Escalating as stated: one comment ago I said that if a fourth queue failure arrived, disabling auto-merge had not been enough to get this PR out of the queue and I would remove it rather than explain again. 31512974833 is the fourth. Draft conversion drops both queue membership and auto-merge, and nothing can re-add it automatically — that is the only lever left that actually holds. The reason this became urgent rather than merely wasteful. The queue tests the merged result. While this PR sits ahead of #7818 in the queue, GitHub is testing To the triage bot's escalating signal (now "failed 3 times in 24h, unchanged content ⇒ strongly suspect flaky"): the count is right, the inference is still wrong, and it will keep climbing for as long as this PR is in the queue. Not flaky, not a semantic conflict with a co-queued PR — clause 2, a failure in Restore checklist, for whoever gets here first:
No changes to the diff are needed at any point. PR-side CI was green before this, including the Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31513730782 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #7649
Symptom
POST /api/v1/mcp/skillanswered 405 with a body no other error on this API returns:{ "error": "Method Not Allowed", "code": "METHOD_NOT_ALLOWED", "message": "POST is not supported for /api/v1/mcp/skill. Allowed: GET.", "method": "POST", "path": "/api/v1/mcp/skill", "allowed": ["GET"] }instead of the standard
{success:false, error:{code, message, httpStatus}}envelopecarrying the documented message "Method not allowed — use GET". A client branching on
error.codereadundefined, becauseerrorwas a string.Root cause — the 405 branch was never missing
handleMcpSkillRequest(packages/runtime/src/domains/mcp.ts) has had a 405 branch since#3842 routed it through
buildApiError. The defect was one layer above it:createDispatcherPluginmounted${prefix}/mcp/skillfor GET only.Since GET is the only method this route serves, a GET-only mount reads as correct. But an
unmounted verb never reaches the dispatcher at all — Hono sends it to
notFound, where theadapter's
unmatchedResponse()re-matches the path across verbs and answers 405 with its ownshape. The domain's branch was dead code on this adapter, and the API carried two different
405 envelopes depending on which route you hit.
The fix
Mount
/mcp/skillfor the same verb set as its sibling/mcp(GET + POST + DELETE), so themismatch reaches the branch that already exists. No second 405 implementation is added —
this PR stops bypassing the first one.
The method set tracks
/mcp's deliberately:server.get/post/deleteare also the three verbsthe plugin's observability Proxy instruments, so a PUT/PATCH mount here would be both wider
than the sibling route and silently un-instrumented. PUT/PATCH therefore stay adapter-owned,
which is correct — a route that does not exist under a verb is
unmatchedResponse()'s job.That boundary is pinned by a test rather than left implicit.
Behaviour change to note
The
Allowheader now readsGET— the domain branch's own literal — where the adapterpreviously derived
GET, HEADfrom its route table (Hono registers HEAD implicitly besideevery GET).
HEAD /api/v1/mcp/skillis still served either way. Themethod,pathandallowedbody keys are gone from this route's 405, anderroris now an object.GET /api/v1/mcp/skillis untouched: same 200, sametext/markdown, samecache-control: no-store.Tests
New:
packages/runtime/src/mcp-skill-method-not-allowed.hono.integration.test.ts— a realHono server driven over real
fetch, asserting the envelope field by field.Shaped that way on purpose. The existing coverage
(
http-dispatcher.mcp.test.ts, "405s non-GET with an Allow header") callsdispatcher.handleMcpSkill('POST', …)directly, so it cannot observe a defect that livesin the HTTP mount — and it asserts only status +
Allow. Both were already green while thebug shipped. A status-only assertion passes in both worlds, since the status was always
405; only the body differed.
Also added: a registration assertion in
dispatcher-plugin.routes.test.ts, beside theexisting
/mcpone.Reverse verification
Reverting only the source fix and keeping the tests, with the expected direction stated first:
body.successisundefinedunmatchedResponse()'s shapetypeof body.erroris'string'body.successisundefinedPOST /api/v1/mcp/skillAllowheaderGET, HEADvsGET(see below)The
Allowrow is the one prediction that was wrong, and it is reported rather thansmoothed over: the header does not merely survive the fix, it changes value. Pre-fix the
adapter derived
GET, HEADfrom its route table; post-fix the domain branch's literalGETanswers. The test name and the changeset were corrected to state that transition instead of
claiming the header is untouched.
Checks
pnpm --filter @objectstack/runtime test— 130 files, 2036 tests passedpnpm --filter @objectstack/http-conformance test— 4 files, 72 tests passed (thecross-adapter
IHttpServerunmatched-request contract)pnpm --filter @objectstack/runtime typecheck— clean (tsc --noEmit)eslint --no-inline-configon the three changed files — cleannode scripts/check-nul-bytes.mjs(+--self-test) — cleanA changeset is included (
@objectstack/runtime, patch);content/docs/releases/is untouched.Generated by Claude Code