Skip to content

Commit 1ef0ee8

Browse files
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)
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 `set`s 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](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b7fc52 commit 1ef0ee8

4 files changed

Lines changed: 266 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/hono": patch
3+
---
4+
5+
fix(hono): on a `createHonoApp` host, `GET /api/v1/mcp/skill` answers the raw markdown with one `Content-Type: text/markdown; charset=utf-8` and `Cache-Control: no-store`, as the standalone server does
6+
7+
Clause-②: no
8+
9+
- **What a caller got.** The skill is served as a single string event on the dispatcher's `result` stream channel. The adapter's stream writer framed every event outside a Vercel data stream as `data: <JSON>`, so the body was an SSE frame of a JSON-encoded string (`data: "---\nname: …"`), not markdown. It also set its own `Content-Type` and `Cache-Control` beside the domain's, so the answer carried `text/markdown; charset=utf-8, text/markdown; charset=utf-8` and `no-cache, no-store` (on a socket: two `Content-Type` lines and two `Cache-Control` lines).
10+
- **What it answers now.** A string event is written as given, in every stream mode, and a header the producer sets replaces the adapter's default of the same name, whatever its case. Object events are still one `data:` frame each (one JSON line in a Vercel data stream), and a stream that sets no headers still gets `text/event-stream`, `no-cache` and `keep-alive`.
11+
- **Unchanged:** the AI chat streams, which reach the same writer as pre-encoded Vercel data stream frames or as object parts, and every other route.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24287] `toResponse`'s events arm writes a string event as given, and a
5+
* producer's own header replaces the default it names.
6+
*
7+
* The standalone server's writer (`runtime/src/dispatcher-plugin.ts`,
8+
* `sendResultBase`) writes a string event verbatim and an object event as one
9+
* SSE frame, and a route's header replaces the default. This arm SSE-framed
10+
* every event outside a Vercel data stream and appended the producer's
11+
* lowercase headers beside its own capitalised defaults, so the `/mcp/skill`
12+
* domain's single markdown chunk reached the caller as `data: "# skill"` under
13+
* a doubled `Content-Type` and `no-cache, no-store`.
14+
*
15+
* The rows below are the producers that reach this arm: the `/mcp/skill`
16+
* domain (`runtime/src/domains/mcp.ts`), and the `/ai` domain
17+
* (`runtime/src/domains/ai.ts`) in both its shapes — a Vercel data stream of
18+
* pre-encoded string frames, and an SSE stream of object parts. The two `/ai`
19+
* rows pin answers this card leaves unchanged. The dispatcher is a fixture
20+
* (this package's config aliases `@objectstack/runtime` to a stub); the same
21+
* skill row through the real dispatcher and both doors is
22+
* `packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts`.
23+
*/
24+
25+
import { describe, it, expect, vi, beforeEach } from 'vitest';
26+
27+
const mockDispatcher = {
28+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
29+
handleAuth: vi.fn(),
30+
dispatch: vi.fn(),
31+
};
32+
33+
vi.mock('@objectstack/runtime', () => ({
34+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
35+
}));
36+
37+
import { createHonoApp } from './index';
38+
39+
const PREFIX = '/api/v1';
40+
const kernel = { name: 'test-kernel' } as any;
41+
42+
async function* events(...items: unknown[]) {
43+
for (const item of items) yield item;
44+
}
45+
46+
async function get(result: Record<string, unknown>): Promise<Response> {
47+
mockDispatcher.dispatch.mockResolvedValue({ handled: true, result: { type: 'stream', ...result } });
48+
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
49+
return app.request(`http://hosted.test${PREFIX}/some/stream`);
50+
}
51+
52+
beforeEach(() => {
53+
mockDispatcher.dispatch.mockReset();
54+
});
55+
56+
describe('[#24287] toResponse events arm: string events verbatim, producer headers replace defaults', () => {
57+
it('the /mcp/skill shape: the markdown verbatim, and each declared header once', async () => {
58+
const markdown = '---\nname: objectstack\n---\n\n# skill "quoted"\n';
59+
const res = await get({
60+
status: 200,
61+
contentType: 'text/markdown; charset=utf-8',
62+
headers: {
63+
'content-type': 'text/markdown; charset=utf-8',
64+
'content-disposition': 'inline; filename="SKILL.md"',
65+
'cache-control': 'no-store',
66+
},
67+
events: events(markdown),
68+
});
69+
70+
expect(res.status).toBe(200);
71+
expect(await res.text()).toBe(markdown);
72+
expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8');
73+
expect(res.headers.get('cache-control')).toBe('no-store');
74+
expect(res.headers.get('content-disposition')).toBe('inline; filename="SKILL.md"');
75+
});
76+
77+
it('an object event outside a Vercel data stream is still one SSE frame, under the SSE defaults', async () => {
78+
const res = await get({ contentType: 'text/event-stream', events: events({ type: 'text-delta', text: 'hi' }, { type: 'finish' }) });
79+
80+
expect(await res.text()).toBe('data: {"type":"text-delta","text":"hi"}\n\ndata: {"type":"finish"}\n\n');
81+
expect(res.headers.get('content-type')).toBe('text/event-stream');
82+
expect(res.headers.get('cache-control')).toBe('no-cache');
83+
expect(res.headers.get('connection')).toBe('keep-alive');
84+
});
85+
86+
it('the /ai SSE shape (object parts, capitalised headers): unchanged', async () => {
87+
const res = await get({
88+
contentType: 'text/event-stream',
89+
events: events({ type: 'text-delta', id: '0', text: 'a' }),
90+
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' },
91+
});
92+
93+
expect(await res.text()).toBe('data: {"type":"text-delta","id":"0","text":"a"}\n\n');
94+
expect(res.headers.get('content-type')).toBe('text/event-stream');
95+
expect(res.headers.get('cache-control')).toBe('no-cache');
96+
});
97+
98+
it('the /ai Vercel data stream shape (pre-encoded string frames): unchanged', async () => {
99+
const frames = ['data: {"type":"start"}\n\n', 'data: {"type":"text-delta","id":"0","delta":"a"}\n\n', 'data: [DONE]\n\n'];
100+
const res = await get({
101+
contentType: 'text/plain; charset=utf-8',
102+
vercelDataStream: true,
103+
events: events(...frames, { type: 'raw' }),
104+
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' },
105+
});
106+
107+
expect(await res.text()).toBe(`${frames.join('')}{"type":"raw"}\n`);
108+
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
109+
expect(res.headers.get('cache-control')).toBe('no-cache');
110+
});
111+
});

‎packages/adapters/hono/src/index.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -820,21 +820,40 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
820820
return c.redirect(res.url);
821821
}
822822
if (res.type === 'stream' && res.events) {
823-
// SSE / Vercel Data Stream streaming response
824-
const headers: Record<string, string> = {
823+
/**
824+
* [#24287] SSE / Vercel Data Stream / raw single-chunk response.
825+
*
826+
* A string event is written as given, in every mode; an object
827+
* event is one SSE frame (or one JSON line in a Vercel data
828+
* stream). A producer's own header replaces the default it names,
829+
* matched case-insensitively. That is what the standalone server's
830+
* writer does (`runtime/src/dispatcher-plugin.ts`,
831+
* `sendResultBase`), and what `/mcp/skill`'s domain relies on: it
832+
* serves the markdown as one string event under its own lowercase
833+
* `content-type` and `cache-control`. This arm used to SSE-frame
834+
* every event outside a Vercel data stream and spread those headers
835+
* into a record beside its capitalised defaults, so the caller got
836+
* `data: "# skill"` under a doubled `Content-Type` and
837+
* `no-cache, no-store`. The `/ai` domain's two shapes reach this arm
838+
* too — pre-encoded string frames with `vercelDataStream`, object
839+
* parts without it — and both answer as before.
840+
*/
841+
const headers = new Headers({
825842
'Content-Type': res.contentType || 'text/event-stream',
826843
'Cache-Control': 'no-cache',
827844
'Connection': 'keep-alive',
828-
...(res.headers || {}),
829-
};
845+
});
846+
for (const [k, v] of Object.entries(res.headers || {})) headers.set(k, String(v));
830847
const stream = new ReadableStream({
831848
async start(controller) {
832849
try {
833850
const encoder = new TextEncoder();
834851
for await (const event of res.events) {
835-
const chunk = res.vercelDataStream
836-
? (typeof event === 'string' ? event : JSON.stringify(event) + '\n')
837-
: `data: ${JSON.stringify(event)}\n\n`;
852+
const chunk = typeof event === 'string'
853+
? event
854+
: res.vercelDataStream
855+
? JSON.stringify(event) + '\n'
856+
: `data: ${JSON.stringify(event)}\n\n`;
838857
controller.enqueue(encoder.encode(chunk));
839858
}
840859
} catch (err) {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24287] On a `createHonoApp` host, `GET /api/v1/mcp/skill` answers the raw
5+
* markdown the standalone server answers — through the host's own pipeline.
6+
*
7+
* ## The defect
8+
*
9+
* `handleMcpSkillRequest` (`packages/runtime/src/domains/mcp.ts`) serves the
10+
* skill as a single-chunk `result` stream: one string event, its own lowercase
11+
* `content-type` / `content-disposition` / `cache-control`. The standalone
12+
* server writes a string event verbatim and lets the domain's headers replace
13+
* its defaults. `toResponse`'s events arm in `@objectstack/hono` SSE-framed
14+
* every event outside a Vercel data stream (`data: ${JSON.stringify(event)}`)
15+
* and appended the domain's headers beside its own capitalised defaults.
16+
* Measured through this host before the fix:
17+
*
18+
* GET /api/v1/mcp/skill
19+
* body : data: "---\nname: objectstack\n---\n\n# …" (an SSE frame of a JSON string)
20+
* content-type : text/markdown; charset=utf-8, text/markdown; charset=utf-8
21+
* cache-control : no-cache, no-store
22+
*
23+
* ## What is pinned
24+
*
25+
* 1. On the hosted door (`app.request` into the Hono app `createHonoApp`
26+
* returns): the body is the markdown byte for byte, and the three headers
27+
* the domain declares are each its one value.
28+
* 2. The hosted answer equals the standalone answer (`HonoServerPlugin` + the
29+
* dispatcher plugin, over a real socket): status, body and those headers.
30+
*
31+
* The skill text carries newlines, double quotes and a non-ASCII character, so
32+
* any JSON or SSE encoding of it differs from it. A real socket on a
33+
* `createHonoApp` host is not booted here. `LiteKernel`, a fake `mcp` service
34+
* that renders only the skill.
35+
*/
36+
37+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
38+
import { LiteKernel } from '@objectstack/core';
39+
import type { Plugin, PluginContext } from '@objectstack/core';
40+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
41+
import { createDispatcherPlugin } from '@objectstack/runtime';
42+
import { createHonoApp } from '@objectstack/hono';
43+
import type { IHttpServer } from '@objectstack/spec/contracts';
44+
45+
const PREFIX = '/api/v1';
46+
const SKILL = '---\nname: objectstack\ndescription: "Use the MCP server"\n---\n\n# Skill — fixture\n\nLine two.\n';
47+
48+
/** The headers the skill domain declares, each with its one value. */
49+
const SKILL_HEADERS: Record<string, string> = {
50+
'content-type': 'text/markdown; charset=utf-8',
51+
'content-disposition': 'inline; filename="SKILL.md"',
52+
'cache-control': 'no-store',
53+
};
54+
55+
function fakeMcpPlugin(): Plugin {
56+
return {
57+
name: 'com.objectstack.test.fake-mcp-skill-raw-body',
58+
version: '1.0.0',
59+
init: async (ctx: PluginContext) => {
60+
ctx.registerService('mcp', { renderSkill: () => SKILL });
61+
},
62+
};
63+
}
64+
65+
describe('[#24287] GET /mcp/skill answers the raw markdown on a createHonoApp host (conformance)', () => {
66+
let hostedKernel: LiteKernel;
67+
let standaloneKernel: LiteKernel;
68+
let hosted: () => Promise<Response>;
69+
let standalone: () => Promise<Response>;
70+
71+
beforeAll(async () => {
72+
hostedKernel = new LiteKernel();
73+
hostedKernel.use(fakeMcpPlugin());
74+
await hostedKernel.bootstrap();
75+
const app = createHonoApp({ kernel: hostedKernel as any, prefix: PREFIX, cors: false });
76+
hosted = async () => app.request(`http://hosted.test${PREFIX}/mcp/skill`);
77+
78+
standaloneKernel = new LiteKernel();
79+
standaloneKernel.use(fakeMcpPlugin());
80+
standaloneKernel.use(new HonoServerPlugin({ port: 0, cors: false }));
81+
standaloneKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false }));
82+
await standaloneKernel.bootstrap();
83+
const base = `http://127.0.0.1:${standaloneKernel.getService<IHttpServer>('http.server').getPort!()}`;
84+
standalone = () => fetch(`${base}${PREFIX}/mcp/skill`);
85+
}, 30_000);
86+
87+
afterAll(async () => {
88+
for (const kernel of [hostedKernel, standaloneKernel]) {
89+
if (!kernel) continue;
90+
await Promise.race([kernel.shutdown(), new Promise<void>((resolve) => setTimeout(resolve, 10_000))]);
91+
}
92+
}, 30_000);
93+
94+
it('the hosted door answers the markdown verbatim, with one value per declared header', async () => {
95+
const res = await hosted();
96+
97+
expect(res.status).toBe(200);
98+
expect(await res.text()).toBe(SKILL);
99+
// `Headers.get` joins repeated values with ", ", so equality is one value.
100+
for (const [name, value] of Object.entries(SKILL_HEADERS)) {
101+
expect(res.headers.get(name), name).toBe(value);
102+
}
103+
});
104+
105+
it('the hosted answer is the standalone answer', async () => {
106+
const viaHost = await hosted();
107+
const viaStandalone = await standalone();
108+
109+
expect(viaStandalone.status).toBe(200);
110+
expect(viaHost.status).toBe(viaStandalone.status);
111+
const standaloneBody = await viaStandalone.text();
112+
expect(standaloneBody).toBe(SKILL);
113+
expect(await viaHost.text()).toBe(standaloneBody);
114+
for (const name of Object.keys(SKILL_HEADERS)) {
115+
expect(viaHost.headers.get(name), name).toBe(viaStandalone.headers.get(name));
116+
}
117+
});
118+
});

0 commit comments

Comments
 (0)