diff --git a/.changeset/24287-hono-stream-string-verbatim.md b/.changeset/24287-hono-stream-string-verbatim.md new file mode 100644 index 00000000000..7248e2f22a8 --- /dev/null +++ b/.changeset/24287-hono-stream-string-verbatim.md @@ -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: `, 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. diff --git a/packages/adapters/hono/src/hono-stream-events-framing.test.ts b/packages/adapters/hono/src/hono-stream-events-framing.test.ts new file mode 100644 index 00000000000..45ccc5bb18c --- /dev/null +++ b/packages/adapters/hono/src/hono-stream-events-framing.test.ts @@ -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): Promise { + 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'); + }); +}); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 4f962dbb6cf..62e6f29d401 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -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 = { + /** + * [#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) { diff --git a/packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts b/packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts new file mode 100644 index 00000000000..77711182e55 --- /dev/null +++ b/packages/qa/http-conformance/src/hono-mcp-skill-raw-body.conformance.test.ts @@ -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 = { + '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; + let standalone: () => Promise; + + 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('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((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)); + } + }); +});