Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/24287-hono-stream-string-verbatim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/hono": patch
---

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

Clause-②: no

- **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).
- **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`.
- **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.
111 changes: 111 additions & 0 deletions packages/adapters/hono/src/hono-stream-events-framing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24287] `toResponse`'s events arm writes a string event as given, and a
* producer's own header replaces the default it names.
*
* The standalone server's writer (`runtime/src/dispatcher-plugin.ts`,
* `sendResultBase`) writes a string event verbatim and an object event as one
* SSE frame, and a route's header replaces the default. This arm SSE-framed
* every event outside a Vercel data stream and appended the producer's
* lowercase headers beside its own capitalised defaults, so the `/mcp/skill`
* domain's single markdown chunk reached the caller as `data: "# skill"` under
* a doubled `Content-Type` and `no-cache, no-store`.
*
* The rows below are the producers that reach this arm: the `/mcp/skill`
* domain (`runtime/src/domains/mcp.ts`), and the `/ai` domain
* (`runtime/src/domains/ai.ts`) in both its shapes — a Vercel data stream of
* pre-encoded string frames, and an SSE stream of object parts. The two `/ai`
* rows pin answers this card leaves unchanged. The dispatcher is a fixture
* (this package's config aliases `@objectstack/runtime` to a stub); the same
* skill row through the real dispatcher and both doors is
* `packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts`.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockDispatcher = {
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
handleAuth: vi.fn(),
dispatch: vi.fn(),
};

vi.mock('@objectstack/runtime', () => ({
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
}));

import { createHonoApp } from './index';

const PREFIX = '/api/v1';
const kernel = { name: 'test-kernel' } as any;

async function* events(...items: unknown[]) {
for (const item of items) yield item;
}

async function get(result: Record<string, unknown>): Promise<Response> {
mockDispatcher.dispatch.mockResolvedValue({ handled: true, result: { type: 'stream', ...result } });
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
return app.request(`http://hosted.test${PREFIX}/some/stream`);
}

beforeEach(() => {
mockDispatcher.dispatch.mockReset();
});

describe('[#24287] toResponse events arm: string events verbatim, producer headers replace defaults', () => {
it('the /mcp/skill shape: the markdown verbatim, and each declared header once', async () => {
const markdown = '---\nname: objectstack\n---\n\n# skill "quoted"\n';
const res = await get({
status: 200,
contentType: 'text/markdown; charset=utf-8',
headers: {
'content-type': 'text/markdown; charset=utf-8',
'content-disposition': 'inline; filename="SKILL.md"',
'cache-control': 'no-store',
},
events: events(markdown),
});

expect(res.status).toBe(200);
expect(await res.text()).toBe(markdown);
expect(res.headers.get('content-type')).toBe('text/markdown; charset=utf-8');
expect(res.headers.get('cache-control')).toBe('no-store');
expect(res.headers.get('content-disposition')).toBe('inline; filename="SKILL.md"');
});

it('an object event outside a Vercel data stream is still one SSE frame, under the SSE defaults', async () => {
const res = await get({ contentType: 'text/event-stream', events: events({ type: 'text-delta', text: 'hi' }, { type: 'finish' }) });

expect(await res.text()).toBe('data: {"type":"text-delta","text":"hi"}\n\ndata: {"type":"finish"}\n\n');
expect(res.headers.get('content-type')).toBe('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache');
expect(res.headers.get('connection')).toBe('keep-alive');
});

it('the /ai SSE shape (object parts, capitalised headers): unchanged', async () => {
const res = await get({
contentType: 'text/event-stream',
events: events({ type: 'text-delta', id: '0', text: 'a' }),
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' },
});

expect(await res.text()).toBe('data: {"type":"text-delta","id":"0","text":"a"}\n\n');
expect(res.headers.get('content-type')).toBe('text/event-stream');
expect(res.headers.get('cache-control')).toBe('no-cache');
});

it('the /ai Vercel data stream shape (pre-encoded string frames): unchanged', async () => {
const frames = ['data: {"type":"start"}\n\n', 'data: {"type":"text-delta","id":"0","delta":"a"}\n\n', 'data: [DONE]\n\n'];
const res = await get({
contentType: 'text/plain; charset=utf-8',
vercelDataStream: true,
events: events(...frames, { type: 'raw' }),
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' },
});

expect(await res.text()).toBe(`${frames.join('')}{"type":"raw"}\n`);
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8');
expect(res.headers.get('cache-control')).toBe('no-cache');
});
});
33 changes: 26 additions & 7 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,21 +820,40 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
return c.redirect(res.url);
}
if (res.type === 'stream' && res.events) {
// SSE / Vercel Data Stream streaming response
const headers: Record<string, string> = {
/**
* [#24287] SSE / Vercel Data Stream / raw single-chunk response.
*
* A string event is written as given, in every mode; an object
* event is one SSE frame (or one JSON line in a Vercel data
* stream). A producer's own header replaces the default it names,
* matched case-insensitively. That is what the standalone server's
* writer does (`runtime/src/dispatcher-plugin.ts`,
* `sendResultBase`), and what `/mcp/skill`'s domain relies on: it
* serves the markdown as one string event under its own lowercase
* `content-type` and `cache-control`. This arm used to SSE-frame
* every event outside a Vercel data stream and spread those headers
* into a record beside its capitalised defaults, so the caller got
* `data: "# skill"` under a doubled `Content-Type` and
* `no-cache, no-store`. The `/ai` domain's two shapes reach this arm
* too — pre-encoded string frames with `vercelDataStream`, object
* parts without it — and both answer as before.
*/
const headers = new Headers({
'Content-Type': res.contentType || 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
...(res.headers || {}),
};
});
for (const [k, v] of Object.entries(res.headers || {})) headers.set(k, String(v));
const stream = new ReadableStream({
async start(controller) {
try {
const encoder = new TextEncoder();
for await (const event of res.events) {
const chunk = res.vercelDataStream
? (typeof event === 'string' ? event : JSON.stringify(event) + '\n')
: `data: ${JSON.stringify(event)}\n\n`;
const chunk = typeof event === 'string'
? event
: res.vercelDataStream
? JSON.stringify(event) + '\n'
: `data: ${JSON.stringify(event)}\n\n`;
controller.enqueue(encoder.encode(chunk));
}
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24287] On a `createHonoApp` host, `GET /api/v1/mcp/skill` answers the raw
* markdown the standalone server answers — through the host's own pipeline.
*
* ## The defect
*
* `handleMcpSkillRequest` (`packages/runtime/src/domains/mcp.ts`) serves the
* skill as a single-chunk `result` stream: one string event, its own lowercase
* `content-type` / `content-disposition` / `cache-control`. The standalone
* server writes a string event verbatim and lets the domain's headers replace
* its defaults. `toResponse`'s events arm in `@objectstack/hono` SSE-framed
* every event outside a Vercel data stream (`data: ${JSON.stringify(event)}`)
* and appended the domain's headers beside its own capitalised defaults.
* Measured through this host before the fix:
*
* GET /api/v1/mcp/skill
* body : data: "---\nname: objectstack\n---\n\n# …" (an SSE frame of a JSON string)
* content-type : text/markdown; charset=utf-8, text/markdown; charset=utf-8
* cache-control : no-cache, no-store
*
* ## What is pinned
*
* 1. On the hosted door (`app.request` into the Hono app `createHonoApp`
* returns): the body is the markdown byte for byte, and the three headers
* the domain declares are each its one value.
* 2. The hosted answer equals the standalone answer (`HonoServerPlugin` + the
* dispatcher plugin, over a real socket): status, body and those headers.
*
* The skill text carries newlines, double quotes and a non-ASCII character, so
* any JSON or SSE encoding of it differs from it. A real socket on a
* `createHonoApp` host is not booted here. `LiteKernel`, a fake `mcp` service
* that renders only the skill.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import type { Plugin, PluginContext } from '@objectstack/core';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import { createDispatcherPlugin } from '@objectstack/runtime';
import { createHonoApp } from '@objectstack/hono';
import type { IHttpServer } from '@objectstack/spec/contracts';

const PREFIX = '/api/v1';
const SKILL = '---\nname: objectstack\ndescription: "Use the MCP server"\n---\n\n# Skill — fixture\n\nLine two.\n';

/** The headers the skill domain declares, each with its one value. */
const SKILL_HEADERS: Record<string, string> = {
'content-type': 'text/markdown; charset=utf-8',
'content-disposition': 'inline; filename="SKILL.md"',
'cache-control': 'no-store',
};

function fakeMcpPlugin(): Plugin {
return {
name: 'com.objectstack.test.fake-mcp-skill-raw-body',
version: '1.0.0',
init: async (ctx: PluginContext) => {
ctx.registerService('mcp', { renderSkill: () => SKILL });
},
};
}

describe('[#24287] GET /mcp/skill answers the raw markdown on a createHonoApp host (conformance)', () => {
let hostedKernel: LiteKernel;
let standaloneKernel: LiteKernel;
let hosted: () => Promise<Response>;
let standalone: () => Promise<Response>;

beforeAll(async () => {
hostedKernel = new LiteKernel();
hostedKernel.use(fakeMcpPlugin());
await hostedKernel.bootstrap();
const app = createHonoApp({ kernel: hostedKernel as any, prefix: PREFIX, cors: false });
hosted = async () => app.request(`http://hosted.test${PREFIX}/mcp/skill`);

standaloneKernel = new LiteKernel();
standaloneKernel.use(fakeMcpPlugin());
standaloneKernel.use(new HonoServerPlugin({ port: 0, cors: false }));
standaloneKernel.use(createDispatcherPlugin({ prefix: PREFIX, securityHeaders: false }));
await standaloneKernel.bootstrap();
const base = `http://127.0.0.1:${standaloneKernel.getService<IHttpServer>('http.server').getPort!()}`;
standalone = () => fetch(`${base}${PREFIX}/mcp/skill`);
}, 30_000);

afterAll(async () => {
for (const kernel of [hostedKernel, standaloneKernel]) {
if (!kernel) continue;
await Promise.race([kernel.shutdown(), new Promise<void>((resolve) => setTimeout(resolve, 10_000))]);
}
}, 30_000);

it('the hosted door answers the markdown verbatim, with one value per declared header', async () => {
const res = await hosted();

expect(res.status).toBe(200);
expect(await res.text()).toBe(SKILL);
// `Headers.get` joins repeated values with ", ", so equality is one value.
for (const [name, value] of Object.entries(SKILL_HEADERS)) {
expect(res.headers.get(name), name).toBe(value);
}
});

it('the hosted answer is the standalone answer', async () => {
const viaHost = await hosted();
const viaStandalone = await standalone();

expect(viaStandalone.status).toBe(200);
expect(viaHost.status).toBe(viaStandalone.status);
const standaloneBody = await viaStandalone.text();
expect(standaloneBody).toBe(SKILL);
expect(await viaHost.text()).toBe(standaloneBody);
for (const name of Object.keys(SKILL_HEADERS)) {
expect(viaHost.headers.get(name), name).toBe(viaStandalone.headers.get(name));
}
});
});
Loading