Skip to content

Commit 73fd875

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24197-adr-0079-note
2 parents 6d6ecfe + 1ef0ee8 commit 73fd875

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)