Skip to content

Commit c60dd45

Browse files
os-helpclaude
andauthored
fix(runtime): route POST /api/v1/mcp/skill to the dispatcher's own 405 branch (#7790)
* fix(runtime): route POST /mcp/skill to the dispatcher's own 405 branch (#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 * fix(runtime): make the #7649 regression test type-clean for check:type-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 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c106f5d commit c60dd45

4 files changed

Lines changed: 298 additions & 8 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): `POST /api/v1/mcp/skill` answers the standard error envelope, not the adapter's hand-rolled 405 (#7649)
6+
7+
A method mismatch on the public SKILL.md route returned a body no other error on
8+
this API returns:
9+
10+
```json
11+
{ "error": "Method Not Allowed", "code": "METHOD_NOT_ALLOWED",
12+
"message": "POST is not supported for /api/v1/mcp/skill. Allowed: GET.",
13+
"method": "POST", "path": "/api/v1/mcp/skill", "allowed": ["GET"] }
14+
```
15+
16+
instead of the standard `{success:false, error:{code, message, httpStatus}}`
17+
carrying the documented message *"Method not allowed — use GET"*. A client
18+
branching on `error.code` read `undefined`, because `error` was a string.
19+
20+
**The 405 branch was never missing.** `handleMcpSkillRequest` has had one since
21+
#3842 routed it through `buildApiError`. The defect was one layer above it:
22+
`createDispatcherPlugin` mounted `${prefix}/mcp/skill` for **GET only**. Since
23+
GET is the only method the route serves, that read as correct — but an unmounted
24+
verb never reaches the dispatcher at all. Hono sends it to `notFound`, where the
25+
adapter's `unmatchedResponse()` re-matches the path across verbs and answers 405
26+
with its own shape. The domain's branch was dead code on this adapter, and the
27+
API had two 405 envelopes depending on which route you hit.
28+
29+
`/mcp/skill` is now mounted for the same verb set as its sibling `/mcp`
30+
(GET + POST + DELETE), so the mismatch reaches the branch that already exists.
31+
No new 405 logic was written, and `GET /api/v1/mcp/skill` is untouched — same
32+
200, same `text/markdown`, same `cache-control: no-store`.
33+
34+
Note for callers that parse the old body: the `method`, `path` and `allowed`
35+
keys are gone from this route's 405, and `error` is now an object. The `Allow`
36+
response header remains the interoperable place to read the hint, and now
37+
reads `GET` — the domain branch's own literal — where the adapter previously
38+
derived `GET, HEAD` from its route table (Hono registers HEAD implicitly
39+
beside every GET). `HEAD /api/v1/mcp/skill` is still served either way.

packages/runtime/src/dispatcher-plugin.routes.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
5959
expect(routes).toContain('POST /api/v1/keys');
6060
});
6161

62+
// Regression (#7649): /mcp/skill was mounted for GET ONLY. The route serves
63+
// GET and nothing else, so that looked right — but the dispatcher owns a 405
64+
// branch for the other verbs ("Method not allowed — use GET", built through
65+
// `buildApiError` since #3842), and an unmounted verb never reaches it: Hono
66+
// sends it to `notFound`, where the adapter's `unmatchedResponse()` answers
67+
// 405 with its own hand-rolled `{error, code, message, method, path, allowed}`
68+
// body. Same status, different envelope, and the domain branch dead code.
69+
// Mounting the verbs is what routes the mismatch to the branch that exists.
70+
// The envelope itself is pinned end-to-end in
71+
// `mcp-skill-method-not-allowed.hono.integration.test.ts` — a status-only
72+
// assertion cannot see this defect.
73+
it('mounts /mcp/skill for the same verbs as /mcp so a method mismatch reaches the dispatcher 405', async () => {
74+
const { server, routes } = makeFakeServer();
75+
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
76+
await plugin.start?.(makeCtx(server));
77+
78+
expect(routes).toContain('GET /api/v1/mcp/skill');
79+
expect(routes).toContain('POST /api/v1/mcp/skill');
80+
expect(routes).toContain('DELETE /api/v1/mcp/skill');
81+
});
82+
6283
// Regression (framework #2217 seam #2): /ready shipped with a dispatch()
6384
// branch but NO server.<verb>() registration, so it 404'd over HTTP before
6485
// reaching the handler — the same class of bug as /mcp and /keys. /health and

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -916,14 +916,38 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
916916
// Public SKILL.md download (env-customized portable Agent Skill).
917917
// Separate registration: `/mcp` above is an exact-path mount, so
918918
// the sub-path needs its own route to be reachable over HTTP.
919-
server.get(`${prefix}/mcp/skill`, async (req: any, res: any) => {
920-
try {
921-
const result = await dispatcher.dispatch('GET', '/mcp/skill', req.body, req.query, { request: req });
922-
sendResult(result, res);
923-
} catch (err: any) {
924-
errorResponse(err, res);
925-
}
926-
});
919+
//
920+
// [#7649] Mounted for the SAME method set as `/mcp` above rather
921+
// than GET alone, even though GET is the only method this route
922+
// SERVES. The domain owns a 405 branch for the rest
923+
// (`handleMcpSkillRequest`: "Method not allowed — use GET", body
924+
// built through `buildApiError` per #3842) — but a branch can only
925+
// answer a mismatch that REACHES the dispatcher. With GET as the
926+
// sole registration, Hono routed `POST /api/v1/mcp/skill` to
927+
// `notFound`, where the adapter's `unmatchedResponse()` answered
928+
// with its own `{error, code, message, method, path, allowed}`
929+
// shape: a second, non-standard 405 envelope on the wire, and the
930+
// domain branch dead code on this adapter. Registering the verbs
931+
// hands the mismatch to the branch that already exists.
932+
//
933+
// The method set tracks `/mcp`'s deliberately: `server.get/post/
934+
// delete` are also the three verbs the observability Proxy above
935+
// instruments, so a PUT/PATCH mount here would be both wider than
936+
// the sibling route and silently un-instrumented.
937+
const mountMcpSkill = (method: 'GET' | 'POST' | 'DELETE') => {
938+
const register = method === 'GET' ? server.get : method === 'DELETE' ? server.delete : server.post;
939+
register.call(server, `${prefix}/mcp/skill`, async (req: any, res: any) => {
940+
try {
941+
const result = await dispatcher.dispatch(method, '/mcp/skill', req.body, req.query, { request: req });
942+
sendResult(result, res);
943+
} catch (err: any) {
944+
errorResponse(err, res);
945+
}
946+
});
947+
};
948+
mountMcpSkill('GET');
949+
mountMcpSkill('POST');
950+
mountMcpSkill('DELETE');
927951

928952
server.post(`${prefix}/keys`, async (req: any, res: any) => {
929953
try {
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4+
import { LiteKernel, Plugin, PluginContext } from '@objectstack/core';
5+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
6+
import type { IHttpServer } from '@objectstack/spec/contracts';
7+
8+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
9+
10+
/**
11+
* End-to-end regression for #7649 — `POST /api/v1/mcp/skill` answered 405 with
12+
* the WRONG envelope.
13+
*
14+
* ## What was measured (QA run #7627)
15+
*
16+
* ```
17+
* POST /api/v1/mcp/skill
18+
* → HTTP 405
19+
* {"error":"Method Not Allowed","code":"METHOD_NOT_ALLOWED",
20+
* "message":"POST is not supported for /api/v1/mcp/skill. Allowed: GET.",
21+
* "method":"POST","path":"/api/v1/mcp/skill","allowed":["GET"]}
22+
* ```
23+
*
24+
* …instead of the standard dispatcher envelope
25+
* `{success:false, error:{code, message, httpStatus}}` carrying the documented
26+
* message "Method not allowed — use GET".
27+
*
28+
* ## Why the defect was invisible to the existing tests
29+
*
30+
* The 405 branch is NOT missing. `handleMcpSkillRequest` has had one since
31+
* #3842 routed it through `buildApiError`, and
32+
* `http-dispatcher.mcp.test.ts` covers it — by calling
33+
* `dispatcher.handleMcpSkill('POST', …)` DIRECTLY. That call cannot observe the
34+
* defect, because the defect is one layer above the dispatcher: the plugin
35+
* mounted `${prefix}/mcp/skill` for GET only, so a POST matched no route at
36+
* all, Hono routed it to `notFound`, and the hono adapter's
37+
* `unmatchedResponse()` — which re-matches the path across verbs and answers
38+
* 405 with its own hand-rolled body — replied first. The domain's branch was
39+
* dead code on this adapter.
40+
*
41+
* That is exactly the class of bug `dispatcher-plugin.routes.test.ts` opens by
42+
* naming ("unit tests called the handlers directly, hiding it"), with one extra
43+
* turn of the screw: here the status was already RIGHT. Only the body differed,
44+
* so a test asserting `res.status === 405` passes in both worlds. Hence this
45+
* suite drives a REAL Hono server over real `fetch` and asserts the BODY.
46+
*
47+
* ## Shape of the suite
48+
*
49+
* `LiteKernel` (as in `auth-unknown-subpath.hono.integration.test.ts`): this is
50+
* about the HTTP mount seam, and a full `ObjectKernel` would demand a `data`
51+
* service no assertion here reads. The fake `mcp` service implements only
52+
* `renderSkill`, which is all `GET /mcp/skill` calls — enough for the happy-path
53+
* control that proves the fix did not disturb the method the route serves.
54+
*/
55+
56+
/** The standard envelope's message for this branch — contract, not prose. */
57+
const EXPECTED_MESSAGE = 'Method not allowed — use GET';
58+
const SKILL_PATH = '/api/v1/mcp/skill';
59+
const SKILL_MARKER = 'OBJECTSTACK_SKILL_FIXTURE';
60+
61+
/** An `mcp` service that can render the skill and nothing else. */
62+
function fakeMcpPlugin(): Plugin {
63+
return {
64+
name: 'com.objectstack.test.fake-mcp-skill',
65+
version: '1.0.0',
66+
init: async (ctx: PluginContext) => {
67+
ctx.registerService('mcp', {
68+
renderSkill: (o: any) =>
69+
`---\nname: objectstack\n---\n\n# ${SKILL_MARKER}\n\nMCP: ${o?.mcpUrl ?? '<YOUR_ENV_MCP_URL>'}\n`,
70+
});
71+
},
72+
};
73+
}
74+
75+
describe('POST /api/v1/mcp/skill answers the standard 405 envelope (integration, #7649)', () => {
76+
let kernel: LiteKernel;
77+
let baseUrl: string;
78+
const prevEnabled = process.env.OS_MCP_SERVER_ENABLED;
79+
80+
beforeAll(async () => {
81+
// Default-on; set explicitly so a stray env var in the runner cannot
82+
// turn every assertion below into a 404 that still "passes" a laxer read.
83+
delete process.env.OS_MCP_SERVER_ENABLED;
84+
85+
kernel = new LiteKernel();
86+
kernel.use(fakeMcpPlugin());
87+
// port 0 → OS-assigned free port; resolved via getPort() after listening.
88+
kernel.use(new HonoServerPlugin({ port: 0, cors: false }));
89+
// No `requireAuth: false` here, though the sibling integration suites in
90+
// this package still pass one: `DispatcherPluginConfig` has no such field
91+
// (the deployment-wide gate was removed — see the comment at
92+
// `dispatcher-plugin.ts:209` and `http-dispatcher.requireauth.test.ts:56`,
93+
// "There is no `requireAuth: false` any more"). It was silently ignored,
94+
// and copying it here bought nothing but a type error. The route under
95+
// test is public by design, so nothing needs relaxing.
96+
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }));
97+
98+
await kernel.bootstrap();
99+
100+
const httpServer = kernel.getService<IHttpServer>('http.server');
101+
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
102+
}, 30_000);
103+
104+
afterAll(async () => {
105+
if (prevEnabled === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
106+
else process.env.OS_MCP_SERVER_ENABLED = prevEnabled;
107+
if (kernel) {
108+
await Promise.race([
109+
kernel.shutdown(),
110+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
111+
]);
112+
}
113+
}, 30_000);
114+
115+
/**
116+
* Drive the route and parse the body.
117+
*
118+
* `Response.json()` is typed `unknown`, and there is no honest interface to
119+
* narrow it to HERE: this suite exists precisely because **two different
120+
* body shapes** can arrive on this path, and one of the cases reads keys
121+
* that must NOT exist. A type admitting only the correct envelope would
122+
* encode the very conclusion the suite is meant to prove, and would make
123+
* the negative case unwritable. So the cast is to an open record and every
124+
* assertion below stays a RUNTIME assertion — nothing is checked by the
125+
* compiler here that the wire is not also checked for.
126+
*/
127+
async function call(method: string): Promise<{ res: Response; body: Record<string, any> }> {
128+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method });
129+
return { res, body: (await res.json()) as Record<string, any> };
130+
}
131+
132+
// ── ① the defect ────────────────────────────────────────────────────────
133+
it('POST returns {success:false, error:{code, message, httpStatus}} — not the adapter\'s hand-rolled body', async () => {
134+
const { res, body } = await call('POST');
135+
136+
expect(res.status).toBe(405);
137+
// The envelope, field by field — the whole defect is that these differ,
138+
// so the status assertion above proves nothing on its own.
139+
expect(body.success).toBe(false);
140+
expect(body.error).toBeTypeOf('object');
141+
expect(body.error.code).toBe('METHOD_NOT_ALLOWED');
142+
expect(body.error.message).toBe(EXPECTED_MESSAGE);
143+
expect(body.error.httpStatus).toBe(405);
144+
});
145+
146+
it('POST does not answer with `unmatchedResponse()`\'s shape', async () => {
147+
const { body } = await call('POST');
148+
149+
// The four keys that identify the adapter's unmatched-route answer.
150+
// `error` as a STRING is the tell — the standard envelope nests an
151+
// object there, so this assertion cannot be satisfied by both shapes.
152+
expect(typeof body.error).not.toBe('string');
153+
expect(body).not.toHaveProperty('method');
154+
expect(body).not.toHaveProperty('path');
155+
expect(body).not.toHaveProperty('allowed');
156+
});
157+
158+
// The `Allow` header CHANGES with this fix, which is worth stating exactly
159+
// rather than filing under "unchanged". Before, the adapter derived it from
160+
// its own route table and Hono registers HEAD implicitly alongside every
161+
// GET, so the hint read `GET, HEAD`. Now the domain branch's own literal
162+
// answers, and it says `GET` — matching the message next to it ("use GET")
163+
// and the one verb this route actually serves. HEAD is still served; the
164+
// hint just no longer enumerates it.
165+
it('answers Allow: GET — the domain branch\'s literal, not the adapter\'s derived `GET, HEAD`', async () => {
166+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'POST' });
167+
expect(res.status).toBe(405);
168+
expect(res.headers.get('allow')).toBe('GET');
169+
});
170+
171+
// DELETE is mounted for the same reason POST is — `/mcp` carries all three
172+
// verbs, and one of them answering a different 405 envelope than the other
173+
// is the drift this issue closes.
174+
it('DELETE gets the same standard envelope', async () => {
175+
const { res, body } = await call('DELETE');
176+
177+
expect(res.status).toBe(405);
178+
expect(body.success).toBe(false);
179+
expect(body.error.code).toBe('METHOD_NOT_ALLOWED');
180+
expect(body.error.message).toBe(EXPECTED_MESSAGE);
181+
expect(body.error.httpStatus).toBe(405);
182+
});
183+
184+
// ── ② positive control: the happy path is untouched ─────────────────────
185+
it('GET still serves the SKILL.md as text/markdown, anonymously', async () => {
186+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'GET' });
187+
const text = await res.text();
188+
189+
expect(res.status).toBe(200);
190+
expect(res.headers.get('content-type')).toContain('text/markdown');
191+
expect(res.headers.get('cache-control')).toBe('no-store');
192+
expect(text).toContain(SKILL_MARKER);
193+
// Derived from the request host — the auth service is absent here.
194+
expect(text).toContain(`${baseUrl}/api/v1/mcp`);
195+
});
196+
197+
// A verb with no mount at all still falls to the adapter, and should:
198+
// `unmatchedResponse()` is the correct owner of a route that does not
199+
// exist under that verb. This pins the BOUNDARY of the fix rather than
200+
// claiming the adapter answer is wrong everywhere.
201+
it('PUT — unmounted — still falls through to the adapter (boundary, not a regression)', async () => {
202+
const { res, body } = await call('PUT');
203+
expect(res.status).toBe(405);
204+
expect(body).toHaveProperty('allowed');
205+
});
206+
});

0 commit comments

Comments
 (0)