Skip to content

Commit a01d0e1

Browse files
os-helpclaude
andcommitted
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
1 parent 7a8476f commit a01d0e1

4 files changed

Lines changed: 277 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: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false, requireAuth: false }));
90+
91+
await kernel.bootstrap();
92+
93+
const httpServer = kernel.getService<IHttpServer>('http.server');
94+
baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`;
95+
}, 30_000);
96+
97+
afterAll(async () => {
98+
if (prevEnabled === undefined) delete process.env.OS_MCP_SERVER_ENABLED;
99+
else process.env.OS_MCP_SERVER_ENABLED = prevEnabled;
100+
if (kernel) {
101+
await Promise.race([
102+
kernel.shutdown(),
103+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
104+
]);
105+
}
106+
}, 30_000);
107+
108+
// ── ① the defect ────────────────────────────────────────────────────────
109+
it('POST returns {success:false, error:{code, message, httpStatus}} — not the adapter\'s hand-rolled body', async () => {
110+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'POST' });
111+
const body = await res.json();
112+
113+
expect(res.status).toBe(405);
114+
// The envelope, field by field — the whole defect is that these differ,
115+
// so the status assertion above proves nothing on its own.
116+
expect(body.success).toBe(false);
117+
expect(body.error).toBeTypeOf('object');
118+
expect(body.error.code).toBe('METHOD_NOT_ALLOWED');
119+
expect(body.error.message).toBe(EXPECTED_MESSAGE);
120+
expect(body.error.httpStatus).toBe(405);
121+
});
122+
123+
it('POST does not answer with `unmatchedResponse()`\'s shape', async () => {
124+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'POST' });
125+
const body = await res.json();
126+
127+
// The four keys that identify the adapter's unmatched-route answer.
128+
// `error` as a STRING is the tell — the standard envelope nests an
129+
// object there, so this assertion cannot be satisfied by both shapes.
130+
expect(typeof body.error).not.toBe('string');
131+
expect(body).not.toHaveProperty('method');
132+
expect(body).not.toHaveProperty('path');
133+
expect(body).not.toHaveProperty('allowed');
134+
});
135+
136+
// The `Allow` header CHANGES with this fix, which is worth stating exactly
137+
// rather than filing under "unchanged". Before, the adapter derived it from
138+
// its own route table and Hono registers HEAD implicitly alongside every
139+
// GET, so the hint read `GET, HEAD`. Now the domain branch's own literal
140+
// answers, and it says `GET` — matching the message next to it ("use GET")
141+
// and the one verb this route actually serves. HEAD is still served; the
142+
// hint just no longer enumerates it.
143+
it('answers Allow: GET — the domain branch\'s literal, not the adapter\'s derived `GET, HEAD`', async () => {
144+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'POST' });
145+
expect(res.status).toBe(405);
146+
expect(res.headers.get('allow')).toBe('GET');
147+
});
148+
149+
// DELETE is mounted for the same reason POST is — `/mcp` carries all three
150+
// verbs, and one of them answering a different 405 envelope than the other
151+
// is the drift this issue closes.
152+
it('DELETE gets the same standard envelope', async () => {
153+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'DELETE' });
154+
const body = await res.json();
155+
156+
expect(res.status).toBe(405);
157+
expect(body.success).toBe(false);
158+
expect(body.error.code).toBe('METHOD_NOT_ALLOWED');
159+
expect(body.error.message).toBe(EXPECTED_MESSAGE);
160+
expect(body.error.httpStatus).toBe(405);
161+
});
162+
163+
// ── ② positive control: the happy path is untouched ─────────────────────
164+
it('GET still serves the SKILL.md as text/markdown, anonymously', async () => {
165+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'GET' });
166+
const text = await res.text();
167+
168+
expect(res.status).toBe(200);
169+
expect(res.headers.get('content-type')).toContain('text/markdown');
170+
expect(res.headers.get('cache-control')).toBe('no-store');
171+
expect(text).toContain(SKILL_MARKER);
172+
// Derived from the request host — the auth service is absent here.
173+
expect(text).toContain(`${baseUrl.replace('http://', 'http://')}/api/v1/mcp`);
174+
});
175+
176+
// A verb with no mount at all still falls to the adapter, and should:
177+
// `unmatchedResponse()` is the correct owner of a route that does not
178+
// exist under that verb. This pins the BOUNDARY of the fix rather than
179+
// claiming the adapter answer is wrong everywhere.
180+
it('PUT — unmounted — still falls through to the adapter (boundary, not a regression)', async () => {
181+
const res = await fetch(`${baseUrl}${SKILL_PATH}`, { method: 'PUT' });
182+
expect(res.status).toBe(405);
183+
expect(await res.json()).toHaveProperty('allowed');
184+
});
185+
});

0 commit comments

Comments
 (0)