From d9237a0929afbc194d239f1969eaaf274e0e7c1e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 00:24:34 +0000 Subject: [PATCH 1/5] build(rsc-runtime): one module graph, drop @modelcontextprotocol/sdk 1.x, expose ./package.json `@agent-bundle/runtime` imported `CallToolResult` from `@modelcontextprotocol/sdk` 1.x for two type positions, which kept the whole 1.x SDK (express 5, hono, jose, cors, ajv, ...) in every consumer's install. Take the type from `@modelcontextprotocol/server` 2.x (already a dependency) and publish the exact result shape the lowerers emit as `McpCallToolResult` / `McpContentBlock`, whose `_meta` and `structuredContent` are finite JSON objects, so the value stays assignable to both SDK lines' `CallToolResult`. Build every public entry in one Rslib lib instead of six bundle-mode libs stitched with literal-request `output.externals`. Shared modules land in common chunks once, so each error class is defined once in the dist and `instanceof` holds across subpaths. The old externals missed `src/state/sqlite.ts`'s direct `./contract.js` import, so today's tarball carries a second `AgentStateError` (and three `AgentContractError` / `AgentRequestError`): a sqlite store's `read({ revision: -1 })` rejection was not `instanceof` the class `./state` exports. Entry graphs stay disjoint where they must: `node:sqlite` loads only through `./state/sqlite`, the root and `./plugin` carry no kernel code. Tests: `state-packaging` now walks the dist import graph per entry, asserts one definition per error class, and runs a child-process probe that imports every entry, checks `process.moduleLoadList` for `node:sqlite`, and compares errors thrown by the sqlite and mount entries against `./state`'s class. A new packed test installs the release tarball and repeats the probe through the `exports` map. Both fail against the previous build. Manifest: `./package.json` export; `@rspack/core` peer marked optional (no runtime entry imports it; it is `react-server-dom-rspack`'s build-plugin peer). `react`/`react-dom` stay exact and required: the Flight wire format is pinned to the React build and `react-server-dom-rspack` loads both at import time. --- packages/rsc-runtime/package.json | 9 +- packages/rsc-runtime/rslib.config.ts | 104 ++------ packages/rsc-runtime/src/index.ts | 2 +- packages/rsc-runtime/src/lower-mcp.ts | 26 +- packages/rsc-runtime/src/project-mcp.ts | 21 +- .../tests/packed-entry-identity.test.ts | 118 +++++++++ .../rsc-runtime/tests/state-packaging.test.ts | 224 +++++++++++------- .../rsc-runtime/tests/support/dist-graph.ts | 185 +++++++++++++++ pnpm-lock.yaml | 3 - rstest.integration-tests.ts | 1 + 10 files changed, 505 insertions(+), 188 deletions(-) create mode 100644 packages/rsc-runtime/tests/packed-entry-identity.test.ts create mode 100644 packages/rsc-runtime/tests/support/dist-graph.ts diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 83299f60d..3c6768737 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -67,7 +67,8 @@ "./lineage": { "types": "./dist/lineage/index.d.ts", "import": "./dist/lineage.js" - } + }, + "./package.json": "./package.json" }, "scripts": { "build": "node ../../scripts/sync-license-files.mjs && rslib build", @@ -79,8 +80,12 @@ "react": "19.2.8", "react-dom": "19.2.8" }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + } + }, "dependencies": { - "@modelcontextprotocol/sdk": "1.30.0", "@modelcontextprotocol/server": "2.0.0", "effect": "4.0.0-rc.112", "flare-redact": "1.6.1", diff --git a/packages/rsc-runtime/rslib.config.ts b/packages/rsc-runtime/rslib.config.ts index e072e6547..687c1a4b7 100644 --- a/packages/rsc-runtime/rslib.config.ts +++ b/packages/rsc-runtime/rslib.config.ts @@ -1,102 +1,38 @@ import { defineConfig } from '@rslib/core'; import { pluginPublint } from 'rsbuild-plugin-publint'; -const sharedLib = { - bundle: true, - dts: true, - format: 'esm', - syntax: 'es2022', -} as const; - export default defineConfig({ lib: [ { - ...sharedLib, + bundle: true, + dts: true, + format: 'esm', + syntax: 'es2022', + // One lib, one module graph: every public entry is compiled together, + // so a module two entries share is emitted once, in a shared chunk both + // import (`dist/.js`), and class identity holds across subpaths — + // `instanceof AgentStateError` is true whether the error came through + // `./state`, `./state/sqlite`, `./mount`, or `./lineage`. Entry graphs + // still stay lean: a module only one entry reaches is emitted in that + // entry's own chunk, so `node:sqlite` loads only through `./state/sqlite`, + // the package root carries no kernel or ledger code, and `./plugin` + // stays Effect-free. tests/state-packaging.test.ts holds those + // boundaries against the workspace dist; tests/packed-entry-identity.test.ts + // holds them against the installed release tarball. source: { entry: { 'flight/server': './src/flight/server.ts', index: './src/index.ts', + lineage: './src/lineage/index.ts', + mount: './src/mount/index.ts', + notices: './src/notices/index.ts', + 'notices/inbox-route': './src/notices/inbox-route.ts', plugin: './src/plugin.ts', state: './src/state/index.ts', + 'state/sqlite': './src/state/sqlite.ts', }, }, }, - { - ...sharedLib, - // Notices are optional and reuse the state entry's kernel runtime. - // Keeping this entry separate means stateless package-root consumers - // receive no ledger code and the notice entry never loads node:sqlite. - output: { - cleanDistPath: false, - externals: { '../state/index.js': './state.js' }, - }, - source: { - entry: { notices: './src/notices/index.ts' }, - }, - }, - { - ...sharedLib, - // Generated mounting composes the optional state and notice entries but - // never imports the sqlite driver; durable callers inject that driver. - output: { - cleanDistPath: false, - externals: { - '../notices/index.js': './notices.js', - '../state/index.js': './state.js', - }, - }, - source: { - entry: { mount: './src/mount/index.ts' }, - }, - }, - { - ...sharedLib, - // The generated MCP inbox resource is React-bearing and therefore stays - // separate from the lean notice ledger entry. - output: { - cleanDistPath: false, - externals: { - '../index.js': '../index.js', - './index.js': '../notices.js', - }, - }, - source: { - entry: { 'notices/inbox-route': './src/notices/inbox-route.ts' }, - }, - }, - { - ...sharedLib, - // The lineage registry reuses the state entry's kernel (its durable - // journal is an ordinary state definition) and the package root's - // request-context helpers; stateless consumers never load it. - output: { - cleanDistPath: false, - externals: { - '../agent-request.js': './index.js', - '../lineage-native.js': './index.js', - '../state/contract.js': './state.js', - '../state/index.js': './state.js', - }, - }, - source: { - entry: { lineage: './src/lineage/index.ts' }, - }, - }, - { - ...sharedLib, - // The sqlite driver is its own entry so `node:sqlite` (and its - // ExperimentalWarning) never loads for volatile-state or stateless - // consumers. It imports the state entry's runtime instead of - // re-bundling the kernel: a duplicated module graph would fork class - // identity and break `instanceof AgentStateError` across entries. - output: { - cleanDistPath: false, - externals: { './index.js': '../state.js' }, - }, - source: { - entry: { 'state/sqlite': './src/state/sqlite.ts' }, - }, - }, ], output: { cleanDistPath: true, diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts index 08d894155..ad7caa984 100644 --- a/packages/rsc-runtime/src/index.ts +++ b/packages/rsc-runtime/src/index.ts @@ -94,7 +94,7 @@ export type { export { lowerHookResult } from './lower-hook.js'; export type { NativePostToolUseOutput } from './lower-hook.js'; export { lowerMcpResult } from './lower-mcp.js'; -export type { JsonObject, JsonValue } from './lower-mcp.js'; +export type { JsonObject, JsonValue, McpCallToolResult, McpContentBlock } from './lower-mcp.js'; export { createRscRequestContext } from './request-context.js'; export type { AgentRenderInvocation } from './agent-request.js'; // Registry-free lineage helpers: what a payload proves on its own. The diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts index 34fd3c8b2..25f0741a1 100644 --- a/packages/rsc-runtime/src/lower-mcp.ts +++ b/packages/rsc-runtime/src/lower-mcp.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'; type McpElement = { @@ -47,6 +47,26 @@ export interface JsonObject { export type JsonValue = null | boolean | number | string | readonly JsonValue[] | JsonObject; +/** One `CallToolResult.content` block, as the MCP SDK types it. */ +export type McpContentBlock = CallToolResult['content'][number]; + +/** + * The MCP `CallToolResult` this package emits. The content blocks are the + * SDK's own; `_meta` and `structuredContent` are the finite JSON objects the + * lowerers copy through the wire boundary, stated as such rather than as the + * SDK's `unknown`. That keeps the value assignable to the `CallToolResult` of + * `@modelcontextprotocol/server` 2.x and of the SDK's 1.x line (which types + * `structuredContent` as a record) alike. A type alias, not an interface: only + * object literal types get the implicit index signature the SDK's loose + * result object requires. + */ +export type McpCallToolResult = { + readonly _meta?: JsonObject; + readonly content: McpContentBlock[]; + readonly isError?: boolean; + readonly structuredContent?: JsonObject; +}; + /** Incremental depth / node / byte checks while cloning JSON (Agent Document bounds). */ export interface JsonSnapshotBudget { readonly addBytes: (n: number) => void; @@ -192,7 +212,7 @@ export const frozenJsonRecord = (value: unknown, message: string): Readonly { +const lowerContent = (node: ReactNode): McpContentBlock => { const element = asMcpElement(node); const { props } = element; switch (element.type) { @@ -253,7 +273,7 @@ const lowerContent = (node: ReactNode): CallToolResult['content'][number] => { } }; -export const lowerMcpResult = (node: ReactNode): CallToolResult => { +export const lowerMcpResult = (node: ReactNode): McpCallToolResult => { const root = asMcpElement(node); if (root.type !== 'mcp-result') throw new Error('Expected mcp-result as the root element'); if (root.props.isError !== undefined && typeof root.props.isError !== 'boolean') { diff --git a/packages/rsc-runtime/src/project-mcp.ts b/packages/rsc-runtime/src/project-mcp.ts index db752d70e..e51805d63 100644 --- a/packages/rsc-runtime/src/project-mcp.ts +++ b/packages/rsc-runtime/src/project-mcp.ts @@ -1,4 +1,3 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Effect, Stream } from 'effect'; import { @@ -9,7 +8,13 @@ import { type AgentRenderEvent, } from './agent-document.js'; import { interruptWhenAborted, runPromise, toRuntimeError } from './effect/boundary.js'; -import { snapshotJsonValue, type JsonObject, type JsonValue } from './lower-mcp.js'; +import { + snapshotJsonValue, + type JsonObject, + type JsonValue, + type McpCallToolResult, + type McpContentBlock, +} from './lower-mcp.js'; export const MCP_PROGRESS_MESSAGE_MAX = 200; @@ -67,7 +72,7 @@ export interface ProjectMcpRenderOptions { export interface McpProjectedToolResult { readonly document: AgentDocument; - readonly result: CallToolResult; + readonly result: McpCallToolResult; } const resolveCapabilities = ( @@ -87,7 +92,7 @@ const gatedBlock = ( kind: McpRichContentKind, summary: string, fallback: McpRichContentFallback, -): CallToolResult['content'][number] => { +): McpContentBlock => { switch (fallback) { case 'text': return { text: summary, type: 'text' }; @@ -104,8 +109,6 @@ const gatedBlock = ( } }; -type McpContentBlock = CallToolResult['content'][number]; - const appendNode = ( node: AgentDocumentNode, content: McpContentBlock[], @@ -191,7 +194,7 @@ const resultMetadata = (document: AgentDocument): JsonObject | undefined => { export const documentToCallToolResult = ( document: AgentDocument, options: Pick = {}, -): CallToolResult => { +): McpCallToolResult => { const content: McpContentBlock[] = []; appendNode( document.root, @@ -210,9 +213,9 @@ export const documentToCallToolResult = ( }; export const attachMcpStructuredContent = ( - result: CallToolResult, + result: McpCallToolResult, value: unknown, -): CallToolResult => { +): McpCallToolResult => { const structured = objectStructuredContent(value); if (structured === undefined) return result; return { ...result, structuredContent: structured }; diff --git a/packages/rsc-runtime/tests/packed-entry-identity.test.ts b/packages/rsc-runtime/tests/packed-entry-identity.test.ts new file mode 100644 index 000000000..5998e7fb5 --- /dev/null +++ b/packages/rsc-runtime/tests/packed-entry-identity.test.ts @@ -0,0 +1,118 @@ +import { execFile as executeFile } from 'node:child_process'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { describe, expect, it } from '@rstest/core'; + +import { + cachedNpmInstallArguments, + installedEnvironment, + sharedPackedTarball, +} from '../../agent-bundle/tests/support/shared-pack.ts'; +import { + declaredErrorClasses, + entryIdentityProbeScript, + errorClassDefinitions, + parseEntryIdentityReport, + readDistSources, +} from './support/dist-graph.ts'; + +const execFile = promisify(executeFile); +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); + +interface InstalledManifest { + readonly dependencies: Readonly>; + readonly exports: Readonly>; + readonly peerDependencies: Readonly>; + readonly peerDependenciesMeta?: Readonly>; +} + +/** + * The release tarball, installed by npm and imported through its `exports` + * map the way a consumer does (#566): one class per error across every + * subpath, `node:sqlite` only behind `@agent-bundle/runtime/state/sqlite`, + * no `@modelcontextprotocol/sdk` 1.x anywhere in the shipped manifest or + * declarations, and the manifest itself reachable as `./package.json`. + */ +describe.sequential('packed @agent-bundle/runtime entry identity', () => { + it('shares one kernel across every installed subpath and ships without the 1.x MCP SDK', async () => { + const [runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + const consumer = await mkdtemp(join(tmpdir(), 'runtime-packed-identity-')); + try { + await writeFile(join(consumer, 'package.json'), '{"name":"runtime-identity-consumer","type":"module","private":true}\n'); + // The renderer rides along until its version is on the registry; React + // is the runtime's required peer; zod is the probe's own import. + await execFile('npm', [ + 'install', + ...cachedNpmInstallArguments, + runtime.tarball, + markdownStream.tarball, + 'react@19.2.8', + 'react-dom@19.2.8', + 'zod@4.5.4', + ], { cwd: consumer, env: installedEnvironment() }); + + const installed = join(consumer, 'node_modules', '@agent-bundle', 'runtime'); + const manifest = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8')) as InstalledManifest; + expect(Object.keys(manifest.dependencies)).not.toContain('@modelcontextprotocol/sdk'); + expect(manifest.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(manifest.exports['./package.json']).toBe('./package.json'); + for (const range of Object.values(manifest.peerDependencies)) expect(range).not.toBe('*'); + expect(manifest.peerDependenciesMeta?.['@rspack/core']?.optional).toBe(true); + const declarations = (await readdir(join(installed, 'dist'), { recursive: true })) + .filter((file): file is string => typeof file === 'string' && file.endsWith('.d.ts')); + expect(declarations.length).toBeGreaterThan(0); + for (const file of declarations) { + expect(await readFile(join(installed, 'dist', file), 'utf8'), file).not.toContain('@modelcontextprotocol/sdk'); + } + + const sources = await readDistSources(join(installed, 'dist')); + for (const name of await declaredErrorClasses(join(packageRoot, 'src'))) { + const files = errorClassDefinitions(sources, name); + expect(files, `${name} is defined in ${files.length} installed dist files: ${files.join(', ')}`).toHaveLength(1); + } + + const stateRoot = join(consumer, 'state'); + const script = entryIdentityProbeScript({ + flightServer: '@agent-bundle/runtime/flight/server', + inboxRoute: '@agent-bundle/runtime/notices/inbox-route', + lineage: '@agent-bundle/runtime/lineage', + mount: '@agent-bundle/runtime/mount', + notices: '@agent-bundle/runtime/notices', + plugin: '@agent-bundle/runtime/plugin', + root: '@agent-bundle/runtime', + sqlite: '@agent-bundle/runtime/state/sqlite', + state: '@agent-bundle/runtime/state', + }, stateRoot); + const { stdout } = await execFile( + process.execPath, + ['--conditions=react-server', '--input-type=module', '--eval', script], + { cwd: consumer, env: installedEnvironment() }, + ); + const report = parseEntryIdentityReport(stdout); + expect(report).toEqual({ + mountLedgerError: { code: 'lifetime-mismatch', instanceOfStateError: true, name: 'AgentStateError' }, + requestErrorShared: true, + sqliteLoadedBeforeSqliteEntry: false, + sqliteLoadedAfterSqliteEntry: true, + sqliteLifetimeError: { code: 'lifetime-mismatch', instanceOfStateError: true, name: 'AgentStateError' }, + sqliteRevisionError: { code: 'invalid-input', instanceOfStateError: true, name: 'AgentStateError' }, + }); + + const resolved = await execFile( + process.execPath, + ['--input-type=module', '--eval', "process.stdout.write(import.meta.resolve('@agent-bundle/runtime/package.json'));"], + { cwd: consumer, env: installedEnvironment() }, + ); + expect(fileURLToPath(resolved.stdout)).toBe(join(installed, 'package.json')); + } finally { + await rm(consumer, { force: true, recursive: true }); + } + }, 180_000); +}); diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts index e0565aec2..7cb7227fe 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -1,82 +1,71 @@ -import { readFile } from 'node:fs/promises'; +import { execFile as executeFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { pathToFileURL, fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; -import { describe, expect, it } from '@rstest/core'; -import { z } from 'zod'; +import { beforeAll, describe, expect, it } from '@rstest/core'; + +import { + declaredErrorClasses, + entryIdentityProbeScript, + errorClassDefinitions, + importClosure, + parseEntryIdentityReport, + readDistSources, + runtimeEntryFiles, + type RuntimeEntrySubpath, +} from './support/dist-graph.ts'; + +const execFile = promisify(executeFile); /** - * Packaged-tree boundaries for the optional state kernel (#98): stateless - * consumers who import the package root (or `./plugin`) must receive none of - * the kernel or storage code, volatile-state consumers must never load - * `node:sqlite`, and the sqlite entry must share the state entry's runtime - * so error identity holds across subpaths. Runs against the prebuilt dist - * (the integration pool builds it up front). + * Packaged-tree boundaries for the optional state kernel (#98) and the + * single-graph build (#566): stateless consumers who import the package root + * (or `./plugin`) must receive none of the kernel or storage code, only the + * `./state/sqlite` entry may load `node:sqlite`, and every error class is + * defined once in the whole dist, so `instanceof` holds across entries. Runs + * against the prebuilt dist (the integration pool builds it up front). */ const packageRoot = fileURLToPath(new URL('..', import.meta.url)); -const distFile = async (...segments: string[]): Promise => - readFile(join(packageRoot, 'dist', ...segments), 'utf8'); +const dist = join(packageRoot, 'dist'); +const distFile = async (...segments: string[]): Promise => readFile(join(dist, ...segments), 'utf8'); -describe.sequential('state kernel packaging boundaries', () => { - it('publishes the provider invocation type from the root declaration entry', async () => { - const declaration = await distFile('index.d.ts'); - expect(declaration).toContain('AgentRenderInvocation'); - }); +// The class marker rather than the bare name: the Effect boundary in the root +// graph lists typed error *names* (`isTypedRuntimeError`) without the class. +const kernelIdentifiers = [ + 'node:sqlite', + 'defineState', + "this.name = 'AgentStateError'", + 'DatabaseSync', + 'agent_state_journal', + 'createAgentNoticeLedger', + 'agent-notice-ledger/v1', +] as const; +const sqliteIdentifiers = ['node:sqlite', 'DatabaseSync'] as const; - it('keeps every kernel and storage identifier out of the root and plugin entries', async () => { - const kernel = [ - 'node:sqlite', - 'defineState', - 'AgentStateError', - 'DatabaseSync', - 'agent_state_journal', - 'createAgentNoticeLedger', - 'agent-notice-ledger/v1', - ] as const; - for (const entry of ['index.js', 'plugin.js']) { - const source = await distFile(entry); - for (const identifier of kernel) { - expect(source, `${entry} must not contain ${identifier}`).not.toContain(identifier); - } - } - // Stage 2 puts Effect on the dispatcher, which is part of the root graph. - // The plugin entry stays Effect-free so hook-only artifacts still skip it. - const plugin = await distFile('plugin.js'); - for (const identifier of ['from "effect"', 'Effect.runPromise']) { - expect(plugin, `plugin.js must not contain ${identifier}`).not.toContain(identifier); - } - }); +let sources: ReadonlyMap; +const closureSource = (subpath: RuntimeEntrySubpath): ReadonlyMap => + new Map(importClosure(sources, runtimeEntryFiles[subpath]).map((file) => [file, sources.get(file)!])); - it('keeps node:sqlite out of the volatile state entry', async () => { - const source = await distFile('state.js'); - expect(source).toContain('defineState'); - expect(source).not.toContain('node:sqlite'); - expect(source).not.toContain('DatabaseSync'); - }); - - it('keeps the optional notice ledger out of stateless entries and node:sqlite out of its core entry', async () => { - const source = await distFile('notices.js'); - expect(source).toContain('createAgentNoticeLedger'); - expect(source).toContain('agent-notice-ledger/v1'); - expect(source).not.toContain('node:sqlite'); - expect(source).not.toContain('DatabaseSync'); +describe.sequential('state kernel packaging boundaries', () => { + beforeAll(async () => { + sources = await readDistSources(dist); }); - it('keeps sqlite out of the generated mount entry', async () => { - const source = await distFile('mount.js'); - expect(source).toContain('createGeneratedRuntimeState'); - expect(source).not.toContain('node:sqlite'); - expect(source).not.toContain('DatabaseSync'); + it('publishes the provider invocation type from the root declaration entry', async () => { + const declaration = await distFile('index.d.ts'); + expect(declaration).toContain('AgentRenderInvocation'); }); - it('gives the sqlite entry its own subpath that shares the state runtime', async () => { - const source = await distFile('state', 'sqlite.js'); - expect(source).toContain('node:sqlite'); - expect(source).toContain('from "../state.js"'); + it('exposes exactly the documented subpaths, each backed by a dist file, plus the manifest', async () => { const packageJson = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as { - exports: Record; + exports: Record; + sideEffects: unknown; }; + expect(packageJson.sideEffects).toBe(false); expect(Object.keys(packageJson.exports)).toEqual([ '.', './plugin', @@ -87,36 +76,99 @@ describe.sequential('state kernel packaging boundaries', () => { './notices/inbox-route', './mount', './lineage', + './package.json', ]); - for (const subpath of Object.keys(packageJson.exports)) { - const target = packageJson.exports[subpath]!; + expect(packageJson.exports['./package.json']).toBe('./package.json'); + for (const [subpath, target] of Object.entries(packageJson.exports)) { + if (typeof target === 'string') continue; + expect(target.import).toBe(`./dist/${runtimeEntryFiles[subpath as RuntimeEntrySubpath]}`); await expect(distFile(...target.import.replace('./dist/', '').split('/'))).resolves.toBeTruthy(); await expect(distFile(...target.types.replace('./dist/', '').split('/'))).resolves.toBeTruthy(); } }); - it('throws one shared AgentStateError identity across the state and sqlite entries', async () => { - const stateEntry = (await import(pathToFileURL(join(packageRoot, 'dist', 'state.js')).href)) as - typeof import('../src/state/index.js'); - const sqliteEntry = (await import(pathToFileURL(join(packageRoot, 'dist', 'state', 'sqlite.js')).href)) as - typeof import('../src/state/sqlite.js'); - const definition = stateEntry.defineState({ - events: { noted: z.object({ value: z.string() }).strict() }, - id: 'state-packaging/identity', - initial: { notes: [] as readonly string[] }, - lifetime: 'process', - reduce: (state, event) => ({ notes: [...state.notes, event.payload.value] }), - schema: z.object({ notes: z.array(z.string()) }).strict(), - }); - // The sqlite driver rejects a volatile definition; the error must be an - // instance of the state entry's AgentStateError class, proving one - // shared kernel runtime rather than a duplicated bundle. + it('keeps every kernel and storage identifier out of the root and plugin graphs', () => { + for (const subpath of ['.', './plugin'] as const) { + for (const [file, source] of closureSource(subpath)) { + for (const identifier of kernelIdentifiers) { + expect(source, `${subpath} loads ${file}, which must not contain ${identifier}`).not.toContain(identifier); + } + } + } + // Stage 2 puts Effect on the dispatcher, which is part of the root graph. + // The plugin entry stays Effect-free so hook-only artifacts still skip it. + for (const [file, source] of closureSource('./plugin')) { + for (const identifier of ['from "effect"', 'Effect.runPromise']) { + expect(source, `./plugin loads ${file}, which must not contain ${identifier}`).not.toContain(identifier); + } + } + }); + + it('confines node:sqlite to the state/sqlite entry file', () => { + const sqliteFiles = [...sources] + .filter(([, source]) => sqliteIdentifiers.some((identifier) => source.includes(identifier))) + .map(([file]) => file); + expect(sqliteFiles).toEqual([runtimeEntryFiles['./state/sqlite']]); + for (const subpath of Object.keys(runtimeEntryFiles) as RuntimeEntrySubpath[]) { + if (subpath === './state/sqlite') continue; + expect(importClosure(sources, runtimeEntryFiles[subpath]), `${subpath} must not reach the sqlite entry`) + .not.toContain(runtimeEntryFiles['./state/sqlite']); + } + expect([...closureSource('./state').values()].join('\n')).toContain('defineState'); + expect([...closureSource('./notices').values()].join('\n')).toContain('createAgentNoticeLedger'); + expect([...closureSource('./mount').values()].join('\n')).toContain('createGeneratedRuntimeState'); + }); + + it('defines every error class exactly once across the whole dist', async () => { + const declared = await declaredErrorClasses(join(packageRoot, 'src')); + expect(declared).toContain('AgentStateError'); + expect(declared).toContain('AgentRequestError'); + const definitions = Object.fromEntries(declared.map((name) => [name, errorClassDefinitions(sources, name)])); + for (const [name, files] of Object.entries(definitions)) { + expect(files, `${name} is defined in ${files.length} dist files: ${files.join(', ')}`).toHaveLength(1); + } + // The state kernel's error class must sit in the graph of every entry + // that throws or catches it, not only in the entry that exports it. + const [stateErrorFile] = definitions['AgentStateError']!; + for (const subpath of ['./state', './state/sqlite', './mount', './lineage', './notices'] as const) { + expect(importClosure(sources, runtimeEntryFiles[subpath]), `${subpath} must reach ${stateErrorFile}`) + .toContain(stateErrorFile); + } + }); + + it('shares one class per error across entries and loads node:sqlite only through state/sqlite', async () => { + const stateRoot = await mkdtemp(join(tmpdir(), 'runtime-entry-identity-')); try { - await sqliteEntry.createSqliteStateDriver({ root: packageRoot }).open(definition); - throw new Error('expected a lifetime-mismatch rejection'); - } catch (error) { - expect(error).toBeInstanceOf(stateEntry.AgentStateError); - expect((error as { code: string }).code).toBe('lifetime-mismatch'); + const specifier = (subpath: RuntimeEntrySubpath): string => pathToFileURL(join(dist, runtimeEntryFiles[subpath])).href; + const script = entryIdentityProbeScript({ + flightServer: specifier('./flight/server'), + inboxRoute: specifier('./notices/inbox-route'), + lineage: specifier('./lineage'), + mount: specifier('./mount'), + notices: specifier('./notices'), + plugin: specifier('./plugin'), + root: specifier('.'), + sqlite: specifier('./state/sqlite'), + state: specifier('./state'), + }, stateRoot); + // A child process: this worker may already have loaded node:sqlite for + // another file, and the flight entry needs the react-server condition. + const { stdout } = await execFile( + process.execPath, + ['--conditions=react-server', '--input-type=module', '--eval', script], + { cwd: packageRoot }, + ); + const report = parseEntryIdentityReport(stdout); + expect(report.sqliteLoadedBeforeSqliteEntry).toBe(false); + expect(report.sqliteLoadedAfterSqliteEntry).toBe(true); + expect(report.requestErrorShared).toBe(true); + expect(report.sqliteLifetimeError).toEqual({ code: 'lifetime-mismatch', instanceOfStateError: true, name: 'AgentStateError' }); + // The validators sqlite imports from the kernel directly, past the + // state entry: the fork the old per-entry externals never covered. + expect(report.sqliteRevisionError).toEqual({ code: 'invalid-input', instanceOfStateError: true, name: 'AgentStateError' }); + expect(report.mountLedgerError).toEqual({ code: 'lifetime-mismatch', instanceOfStateError: true, name: 'AgentStateError' }); + } finally { + await rm(stateRoot, { force: true, recursive: true }); } }); }); diff --git a/packages/rsc-runtime/tests/support/dist-graph.ts b/packages/rsc-runtime/tests/support/dist-graph.ts new file mode 100644 index 000000000..5b66d15d7 --- /dev/null +++ b/packages/rsc-runtime/tests/support/dist-graph.ts @@ -0,0 +1,185 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { dirname, join, normalize, sep } from 'node:path'; + +/** + * Static view of a built `@agent-bundle/runtime` dist: which files each + * public entry pulls in, and where each error class is defined. Shared by the + * integration-pool packaging test (workspace `dist`) and the packed identity + * test (the `dist` npm installed from the release tarball). + */ + +/** Public entry file for each `exports` subpath, relative to `dist`. */ +export const runtimeEntryFiles = Object.freeze({ + '.': 'index.js', + './flight/server': 'flight/server.js', + './lineage': 'lineage.js', + './mount': 'mount.js', + './notices': 'notices.js', + './notices/inbox-route': 'notices/inbox-route.js', + './plugin': 'plugin.js', + './state': 'state.js', + './state/sqlite': 'state/sqlite.js', +} as const); + +export type RuntimeEntrySubpath = keyof typeof runtimeEntryFiles; + +const relativeImportPattern = /(?:\bfrom|\bimport\s*\(?)\s*["'](\.\.?\/[^"']+)["']/gu; +const errorNamePattern = /this\.name = ['"]([A-Za-z]+Error)['"]/gu; + +const distPath = (file: string): string => normalize(file).split(sep).join('/'); + +/** Every `.js` file under `dist`, relative with `/` separators, with its source. */ +export const readDistSources = async (dist: string): Promise> => { + const entries = await readdir(dist, { recursive: true, withFileTypes: true }); + const sources = new Map(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue; + const absolute = join(entry.parentPath, entry.name); + sources.set(distPath(absolute.slice(dist.length + 1)), await readFile(absolute, 'utf8')); + } + return sources; +}; + +/** + * Files an entry loads: itself plus everything reachable through relative + * static imports, re-exports, and dynamic imports, as `dist`-relative paths. + * Bare specifiers (`react`, `effect`, `node:sqlite`) are not files and are + * left in the sources for the caller to grep. + */ +export const importClosure = (sources: ReadonlyMap, entry: string): readonly string[] => { + const closure = new Set(); + const pending = [entry]; + while (pending.length > 0) { + const file = pending.pop()!; + if (closure.has(file)) continue; + const source = sources.get(file); + if (source === undefined) throw new Error(`dist graph walk reached ${file}, which is not in dist`); + closure.add(file); + for (const match of source.matchAll(relativeImportPattern)) { + pending.push(distPath(join(dirname(file), match[1]!))); + } + } + return [...closure].sort(); +}; + +/** Names of the error classes a source tree declares through `this.name = ''`. */ +export const declaredErrorClasses = async (sourceRoot: string): Promise => { + const entries = await readdir(sourceRoot, { recursive: true, withFileTypes: true }); + const names = new Set(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) continue; + const source = await readFile(join(entry.parentPath, entry.name), 'utf8'); + for (const match of source.matchAll(errorNamePattern)) names.add(match[1]!); + } + return [...names].sort(); +}; + +/** + * Dist files that define the class named `name`. Rspack renames a class it + * bundles twice, so the constructor's `this.name` assignment is the one + * marker a duplicated definition cannot hide behind. + */ +export const errorClassDefinitions = (sources: ReadonlyMap, name: string): readonly string[] => + [...sources] + .filter(([, source]) => source.includes(`this.name = '${name}'`) || source.includes(`this.name = "${name}"`)) + .map(([file]) => file) + .sort(); + +/** Specifier for each entry the identity probe imports, in load order. */ +export interface EntryIdentityProbeSpecifiers { + readonly flightServer: string; + readonly inboxRoute: string; + readonly lineage: string; + readonly mount: string; + readonly notices: string; + readonly plugin: string; + readonly root: string; + readonly sqlite: string; + readonly state: string; +} + +/** What one thrown error looked like to the probe. */ +export interface ProbedError { + readonly code: string | undefined; + readonly instanceOfStateError: boolean; + readonly name: string; +} + +/** JSON the identity probe prints on its last stdout line. */ +export interface EntryIdentityReport { + /** `mount`'s request-lifetime ledger rejection, checked against `state`'s `AgentStateError`. */ + readonly mountLedgerError: ProbedError; + /** `root.AgentRequestError === plugin.AgentRequestError`. */ + readonly requestErrorShared: boolean; + /** `node:sqlite` in `process.moduleLoadList` after every entry but `state/sqlite` loaded. */ + readonly sqliteLoadedBeforeSqliteEntry: boolean; + /** `node:sqlite` in `process.moduleLoadList` after `state/sqlite` loaded. */ + readonly sqliteLoadedAfterSqliteEntry: boolean; + /** The sqlite driver's volatile-definition rejection, checked against `state`'s class. */ + readonly sqliteLifetimeError: ProbedError; + /** A sqlite store's malformed-revision rejection: thrown by kernel code sqlite imports directly. */ + readonly sqliteRevisionError: ProbedError; +} + +/** + * Source of an ESM script (`node --conditions=react-server --input-type=module + * --eval`) that imports every public entry, records whether `node:sqlite` + * came along, and provokes `AgentStateError`s from the sqlite and mount + * entries to compare against the class the state entry exports. `zod` must + * resolve from the child's working directory; `stateRoot` is a scratch + * directory the sqlite driver may write to. + */ +export const entryIdentityProbeScript = (specifiers: EntryIdentityProbeSpecifiers, stateRoot: string): string => [ + `const specifiers = ${JSON.stringify(specifiers)};`, + "const sqliteLoaded = () => process.moduleLoadList.includes('NativeModule sqlite');", + 'const root = await import(specifiers.root);', + 'const plugin = await import(specifiers.plugin);', + 'await import(specifiers.flightServer);', + 'const state = await import(specifiers.state);', + 'const notices = await import(specifiers.notices);', + 'await import(specifiers.inboxRoute);', + 'const mount = await import(specifiers.mount);', + 'await import(specifiers.lineage);', + 'const sqliteLoadedBeforeSqliteEntry = sqliteLoaded();', + 'const sqlite = await import(specifiers.sqlite);', + 'const sqliteLoadedAfterSqliteEntry = sqliteLoaded();', + "const { z } = await import('zod');", + 'const probe = async (run) => {', + ' try { await run(); } catch (error) {', + ' return { code: error?.code, instanceOfStateError: error instanceof state.AgentStateError, name: error?.name };', + ' }', + " throw new Error('expected a rejection');", + '};', + 'const definition = (lifetime) => state.defineState({', + ' events: { noted: z.object({ value: z.string() }).strict() },', + " id: 'entry-identity/' + lifetime,", + ' initial: { notes: [] },', + ' lifetime,', + ' reduce: (current, event) => ({ notes: [...current.notes, event.payload.value] }),', + ' schema: z.object({ notes: z.array(z.string()) }).strict(),', + '});', + `const driver = sqlite.createSqliteStateDriver({ root: ${JSON.stringify(stateRoot)} });`, + "const sqliteLifetimeError = await probe(() => driver.open(definition('process')));", + "const store = await driver.open(definition('workspace-durable'));", + 'const sqliteRevisionError = await probe(() => store.read({ revision: -1 }));', + 'await store.close();', + 'await driver.close();', + "const noticeRuntime = mount.createGeneratedNoticeRuntime({ driver: state.createMemoryStateDriver({ lifetime: 'request' }), lifetime: 'request' });", + 'const mountLedgerError = await probe(async () => (await noticeRuntime.noticeLedger()).read());', + 'await noticeRuntime.close();', + 'process.stdout.write(JSON.stringify({', + ' mountLedgerError,', + ' requestErrorShared: root.AgentRequestError === plugin.AgentRequestError,', + ' sqliteLoadedBeforeSqliteEntry,', + ' sqliteLoadedAfterSqliteEntry,', + ' sqliteLifetimeError,', + ' sqliteRevisionError,', + "}) + '\\n');", +].join('\n'); + +/** The report an identity probe run printed: the last non-empty stdout line. */ +export const parseEntryIdentityReport = (stdout: string): EntryIdentityReport => { + const line = stdout.trim().split('\n').at(-1); + if (line === undefined || line === '') throw new Error('entry identity probe printed nothing'); + return JSON.parse(line) as EntryIdentityReport; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6efd6094..6133699fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,9 +383,6 @@ importers: packages/rsc-runtime: dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.30.0 - version: 1.30.0(supports-color@7.2.0)(zod@4.5.4) '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index c6b3a3db8..ae791c0f1 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -149,6 +149,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/public-api-packed.test.ts', 'packages/agent-bundle/tests/rsc-runtime-optional-packaging.test.ts', 'packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts', + 'packages/rsc-runtime/tests/packed-entry-identity.test.ts', 'packages/workbench/tests/packed-release.e2e.test.ts', ]; From f22c2e9be28dfb6dfe7d730c2231e554505e189a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 00:27:56 +0000 Subject: [PATCH 2/5] changeset: @agent-bundle/runtime hygiene (#571) --- .changeset/rsc-runtime-hygiene.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rsc-runtime-hygiene.md diff --git a/.changeset/rsc-runtime-hygiene.md b/.changeset/rsc-runtime-hygiene.md new file mode 100644 index 000000000..75485c89a --- /dev/null +++ b/.changeset/rsc-runtime-hygiene.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": patch +--- + +Drop `@modelcontextprotocol/sdk` 1.x from `@agent-bundle/runtime`'s dependencies: the `CallToolResult` type now comes from `@modelcontextprotocol/server` 2.x, so installing the runtime no longer pulls the 1.x SDK's express, hono, jose, cors, and ajv trees. `lowerMcpResult`, `documentToCallToolResult`, and `attachMcpStructuredContent` return the new exported `McpCallToolResult` (content blocks per `McpContentBlock`, `_meta` and `structuredContent` as JSON objects), which is assignable to both SDK lines' `CallToolResult`. Build every public entry in one module graph: each error class is defined once in the package, so `instanceof AgentStateError` holds for errors raised through `@agent-bundle/runtime/state`, `/state/sqlite`, `/mount`, `/lineage`, and `/notices` (the previous build shipped a second `AgentStateError` inside the sqlite entry), while `node:sqlite` still loads only through `/state/sqlite`. Expose `./package.json` in `exports` and mark the `@rspack/core` peer optional — no runtime entry imports it. (#571) From 95d1b63a9151c66af0c955d81d96117d59eb4860 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 01:02:09 +0000 Subject: [PATCH 3/5] fix(rsc-runtime): keep attachMcpStructuredContent generic; harden packaging graph tests attachMcpStructuredContent is generic over its input so a CallToolResult typed by either MCP SDK line round-trips instead of being narrowed to McpCallToolResult. The dist-graph tests count class definitions by occurrence, check sqlite confinement by entry reachability, exclude the kernel error classes by definition file, assert every dist file is reached by some entry, run the same static checks against the installed tarball, and strip NODE_OPTIONS from probe children. docs/preview-packages notes the @rspack/core peer is optional. --- .changeset/rsc-runtime-hygiene.md | 2 +- docs/preview-packages.md | 2 +- packages/rsc-runtime/src/project-mcp.ts | 12 +++- .../tests/packed-entry-identity.test.ts | 18 +++++- .../rsc-runtime/tests/state-packaging.test.ts | 38 +++++++----- .../rsc-runtime/tests/support/dist-graph.ts | 62 ++++++++++++++++--- 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.changeset/rsc-runtime-hygiene.md b/.changeset/rsc-runtime-hygiene.md index 75485c89a..3e9517523 100644 --- a/.changeset/rsc-runtime-hygiene.md +++ b/.changeset/rsc-runtime-hygiene.md @@ -2,4 +2,4 @@ "@agent-bundle/runtime": patch --- -Drop `@modelcontextprotocol/sdk` 1.x from `@agent-bundle/runtime`'s dependencies: the `CallToolResult` type now comes from `@modelcontextprotocol/server` 2.x, so installing the runtime no longer pulls the 1.x SDK's express, hono, jose, cors, and ajv trees. `lowerMcpResult`, `documentToCallToolResult`, and `attachMcpStructuredContent` return the new exported `McpCallToolResult` (content blocks per `McpContentBlock`, `_meta` and `structuredContent` as JSON objects), which is assignable to both SDK lines' `CallToolResult`. Build every public entry in one module graph: each error class is defined once in the package, so `instanceof AgentStateError` holds for errors raised through `@agent-bundle/runtime/state`, `/state/sqlite`, `/mount`, `/lineage`, and `/notices` (the previous build shipped a second `AgentStateError` inside the sqlite entry), while `node:sqlite` still loads only through `/state/sqlite`. Expose `./package.json` in `exports` and mark the `@rspack/core` peer optional — no runtime entry imports it. (#571) +Drop `@modelcontextprotocol/sdk` 1.x from `@agent-bundle/runtime`'s dependencies: the `CallToolResult` type now comes from `@modelcontextprotocol/server` 2.x, so installing the runtime no longer pulls the 1.x SDK's express, hono, jose, cors, and ajv trees. `lowerMcpResult` and `documentToCallToolResult` return the new exported `McpCallToolResult` (content blocks per `McpContentBlock`, `_meta` and `structuredContent` as JSON objects), which is assignable to both SDK lines' `CallToolResult`; `attachMcpStructuredContent` is generic over its input, so a result typed by either SDK line comes back as the type it went in. Build every public entry in one module graph: each error class is defined once in the package, so `instanceof AgentStateError` holds for errors raised through `@agent-bundle/runtime/state`, `/state/sqlite`, `/mount`, `/lineage`, and `/notices` (the previous build shipped a second `AgentStateError` inside the sqlite entry), while `node:sqlite` still loads only through `/state/sqlite`. Expose `./package.json` in `exports` and mark the `@rspack/core` peer optional — no runtime entry imports it. (#571) diff --git a/docs/preview-packages.md b/docs/preview-packages.md index 9f778a58a..8d4646ede 100644 --- a/docs/preview-packages.md +++ b/docs/preview-packages.md @@ -67,7 +67,7 @@ Previews carry the version string `0.0.0-preview-`, and the publish package to that exact preview version inside the preview tarballs. Today that is the optional `@agent-bundle/runtime` peer declared by `agent-bundle` (`@agent-bundle/runtime` itself no longer declares an `agent-bundle` peer; -its peers are `react`, `react-dom`, and `@rspack/core`). A regular +its peers are `react`, `react-dom`, and the optional `@rspack/core`). A regular `dependencies` entry that names a sibling workspace package is rewritten to that sibling's same-sha tarball URL: `@agent-bundle/runtime`'s `rsc-markdown-stream` dependency resolves to the renderer preview of the same diff --git a/packages/rsc-runtime/src/project-mcp.ts b/packages/rsc-runtime/src/project-mcp.ts index e51805d63..e12979ce7 100644 --- a/packages/rsc-runtime/src/project-mcp.ts +++ b/packages/rsc-runtime/src/project-mcp.ts @@ -1,3 +1,4 @@ +import type { CallToolResult } from '@modelcontextprotocol/server'; import { Effect, Stream } from 'effect'; import { @@ -212,10 +213,15 @@ export const documentToCallToolResult = ( }; }; -export const attachMcpStructuredContent = ( - result: McpCallToolResult, +/** + * Generic over the result so a caller's own `CallToolResult` — this package's + * `McpCallToolResult`, or one typed by either SDK line, every one of which is + * assignable to the 2.x `CallToolResult` — comes back as the type it went in. + */ +export const attachMcpStructuredContent = ( + result: TResult, value: unknown, -): McpCallToolResult => { +): TResult => { const structured = objectStructuredContent(value); if (structured === undefined) return result; return { ...result, structuredContent: structured }; diff --git a/packages/rsc-runtime/tests/packed-entry-identity.test.ts b/packages/rsc-runtime/tests/packed-entry-identity.test.ts index 5998e7fb5..96329feb7 100644 --- a/packages/rsc-runtime/tests/packed-entry-identity.test.ts +++ b/packages/rsc-runtime/tests/packed-entry-identity.test.ts @@ -14,10 +14,15 @@ import { } from '../../agent-bundle/tests/support/shared-pack.ts'; import { declaredErrorClasses, + entriesReaching, entryIdentityProbeScript, errorClassDefinitions, + filesContaining, parseEntryIdentityReport, + probeEnvironment, readDistSources, + runtimeEntryFiles, + unreachedFiles, } from './support/dist-graph.ts'; const execFile = promisify(executeFile); @@ -72,10 +77,19 @@ describe.sequential('packed @agent-bundle/runtime entry identity', () => { expect(await readFile(join(installed, 'dist', file), 'utf8'), file).not.toContain('@modelcontextprotocol/sdk'); } + // The same static graph checks as state-packaging.test.ts, against the + // shipped files: the probe below only sees what runs at import time, so + // a deferred `import('node:sqlite')` in another entry needs this walk. const sources = await readDistSources(join(installed, 'dist')); + expect(unreachedFiles(sources)).toEqual([]); for (const name of await declaredErrorClasses(join(packageRoot, 'src'))) { const files = errorClassDefinitions(sources, name); - expect(files, `${name} is defined in ${files.length} installed dist files: ${files.join(', ')}`).toHaveLength(1); + expect(files, `${name} is defined ${files.length} times in the installed dist: ${files.join(', ')}`).toHaveLength(1); + } + const sqliteFiles = filesContaining(sources, ['node:sqlite', 'DatabaseSync']); + expect(sqliteFiles).toContain(runtimeEntryFiles['./state/sqlite']); + for (const file of sqliteFiles) { + expect(entriesReaching(sources, file), `${file} mentions node:sqlite`).toEqual(['./state/sqlite']); } const stateRoot = join(consumer, 'state'); @@ -93,7 +107,7 @@ describe.sequential('packed @agent-bundle/runtime entry identity', () => { const { stdout } = await execFile( process.execPath, ['--conditions=react-server', '--input-type=module', '--eval', script], - { cwd: consumer, env: installedEnvironment() }, + { cwd: consumer, env: probeEnvironment(installedEnvironment()) }, ); const report = parseEntryIdentityReport(stdout); expect(report).toEqual({ diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts index 7cb7227fe..b8ec120f4 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -9,12 +9,16 @@ import { beforeAll, describe, expect, it } from '@rstest/core'; import { declaredErrorClasses, + entriesReaching, entryIdentityProbeScript, errorClassDefinitions, + filesContaining, importClosure, parseEntryIdentityReport, + probeEnvironment, readDistSources, runtimeEntryFiles, + unreachedFiles, type RuntimeEntrySubpath, } from './support/dist-graph.ts'; @@ -33,17 +37,18 @@ const packageRoot = fileURLToPath(new URL('..', import.meta.url)); const dist = join(packageRoot, 'dist'); const distFile = async (...segments: string[]): Promise => readFile(join(dist, ...segments), 'utf8'); -// The class marker rather than the bare name: the Effect boundary in the root -// graph lists typed error *names* (`isTypedRuntimeError`) without the class. +// Identifiers, not class names: the Effect boundary in the root graph lists +// typed error *names* (`isTypedRuntimeError`) without the classes. The kernel +// classes themselves are excluded by definition file, whatever the quoting. const kernelIdentifiers = [ 'node:sqlite', 'defineState', - "this.name = 'AgentStateError'", 'DatabaseSync', 'agent_state_journal', 'createAgentNoticeLedger', 'agent-notice-ledger/v1', ] as const; +const kernelErrorClasses = ['AgentStateError', 'AgentNoticeError'] as const; const sqliteIdentifiers = ['node:sqlite', 'DatabaseSync'] as const; let sources: ReadonlyMap; @@ -55,6 +60,10 @@ describe.sequential('state kernel packaging boundaries', () => { sources = await readDistSources(dist); }); + it('reaches every dist file from some public entry, so the graph walk sees every edge', () => { + expect(unreachedFiles(sources)).toEqual([]); + }); + it('publishes the provider invocation type from the root declaration entry', async () => { const declaration = await distFile('index.d.ts'); expect(declaration).toContain('AgentRenderInvocation'); @@ -88,8 +97,11 @@ describe.sequential('state kernel packaging boundaries', () => { }); it('keeps every kernel and storage identifier out of the root and plugin graphs', () => { + const kernelClassFiles = kernelErrorClasses.flatMap((name) => errorClassDefinitions(sources, name)); + expect(kernelClassFiles.length).toBeGreaterThan(0); for (const subpath of ['.', './plugin'] as const) { for (const [file, source] of closureSource(subpath)) { + expect(kernelClassFiles, `${subpath} loads ${file}, which defines a kernel error class`).not.toContain(file); for (const identifier of kernelIdentifiers) { expect(source, `${subpath} loads ${file}, which must not contain ${identifier}`).not.toContain(identifier); } @@ -104,15 +116,13 @@ describe.sequential('state kernel packaging boundaries', () => { } }); - it('confines node:sqlite to the state/sqlite entry file', () => { - const sqliteFiles = [...sources] - .filter(([, source]) => sqliteIdentifiers.some((identifier) => source.includes(identifier))) - .map(([file]) => file); - expect(sqliteFiles).toEqual([runtimeEntryFiles['./state/sqlite']]); - for (const subpath of Object.keys(runtimeEntryFiles) as RuntimeEntrySubpath[]) { - if (subpath === './state/sqlite') continue; - expect(importClosure(sources, runtimeEntryFiles[subpath]), `${subpath} must not reach the sqlite entry`) - .not.toContain(runtimeEntryFiles['./state/sqlite']); + it('confines node:sqlite to files only the state/sqlite entry reaches', () => { + // By reachability, not file name: Rspack may legitimately move the sqlite + // code into a chunk of its own, as long as no other entry's graph has it. + const sqliteFiles = filesContaining(sources, sqliteIdentifiers); + expect(sqliteFiles).toContain(runtimeEntryFiles['./state/sqlite']); + for (const file of sqliteFiles) { + expect(entriesReaching(sources, file), `${file} mentions node:sqlite`).toEqual(['./state/sqlite']); } expect([...closureSource('./state').values()].join('\n')).toContain('defineState'); expect([...closureSource('./notices').values()].join('\n')).toContain('createAgentNoticeLedger'); @@ -125,7 +135,7 @@ describe.sequential('state kernel packaging boundaries', () => { expect(declared).toContain('AgentRequestError'); const definitions = Object.fromEntries(declared.map((name) => [name, errorClassDefinitions(sources, name)])); for (const [name, files] of Object.entries(definitions)) { - expect(files, `${name} is defined in ${files.length} dist files: ${files.join(', ')}`).toHaveLength(1); + expect(files, `${name} is defined ${files.length} times in dist: ${files.join(', ')}`).toHaveLength(1); } // The state kernel's error class must sit in the graph of every entry // that throws or catches it, not only in the entry that exports it. @@ -156,7 +166,7 @@ describe.sequential('state kernel packaging boundaries', () => { const { stdout } = await execFile( process.execPath, ['--conditions=react-server', '--input-type=module', '--eval', script], - { cwd: packageRoot }, + { cwd: packageRoot, env: probeEnvironment() }, ); const report = parseEntryIdentityReport(stdout); expect(report.sqliteLoadedBeforeSqliteEntry).toBe(false); diff --git a/packages/rsc-runtime/tests/support/dist-graph.ts b/packages/rsc-runtime/tests/support/dist-graph.ts index 5b66d15d7..01fd99574 100644 --- a/packages/rsc-runtime/tests/support/dist-graph.ts +++ b/packages/rsc-runtime/tests/support/dist-graph.ts @@ -23,7 +23,15 @@ export const runtimeEntryFiles = Object.freeze({ export type RuntimeEntrySubpath = keyof typeof runtimeEntryFiles; -const relativeImportPattern = /(?:\bfrom|\bimport\s*\(?)\s*["'](\.\.?\/[^"']+)["']/gu; +/** + * The three forms Rspack's ESM output uses for a relative edge: a static + * `import … from`/`export … from` statement (matched from the statement start, + * so a string literal or comment mid-line does not count), a bare side-effect + * `import "./x.js"`, and a dynamic `import("./x.js")`. `unreachedFiles` + * catches any fourth form: Rslib emits no chunk nothing imports. + */ +const relativeImportPattern = + /^\s*(?:import|export)\b[^;'"]*?\bfrom\s*["'](\.\.?\/[^"']+)["']|^\s*import\s*["'](\.\.?\/[^"']+)["']|\bimport\(\s*["'](\.\.?\/[^"']+)["']\s*\)/gmu; const errorNamePattern = /this\.name = ['"]([A-Za-z]+Error)['"]/gu; const distPath = (file: string): string => normalize(file).split(sep).join('/'); @@ -56,12 +64,36 @@ export const importClosure = (sources: ReadonlyMap, entry: strin if (source === undefined) throw new Error(`dist graph walk reached ${file}, which is not in dist`); closure.add(file); for (const match of source.matchAll(relativeImportPattern)) { - pending.push(distPath(join(dirname(file), match[1]!))); + pending.push(distPath(join(dirname(file), (match[1] ?? match[2] ?? match[3])!))); } } return [...closure].sort(); }; +/** The public entries whose graph includes `file`. */ +export const entriesReaching = (sources: ReadonlyMap, file: string): readonly RuntimeEntrySubpath[] => + (Object.keys(runtimeEntryFiles) as RuntimeEntrySubpath[]) + .filter((subpath) => importClosure(sources, runtimeEntryFiles[subpath]).includes(file)); + +/** + * Dist files no public entry reaches. Rslib emits a chunk only because some + * entry imports it, so a non-empty answer means an import form the walker + * does not parse — and every closure-based assertion is then incomplete. + */ +export const unreachedFiles = (sources: ReadonlyMap): readonly string[] => { + const reached = new Set( + (Object.values(runtimeEntryFiles) as string[]).flatMap((entry) => importClosure(sources, entry)), + ); + return [...sources.keys()].filter((file) => !reached.has(file)).sort(); +}; + +/** Dist files whose source contains any of `identifiers`. */ +export const filesContaining = (sources: ReadonlyMap, identifiers: readonly string[]): readonly string[] => + [...sources] + .filter(([, source]) => identifiers.some((identifier) => source.includes(identifier))) + .map(([file]) => file) + .sort(); + /** Names of the error classes a source tree declares through `this.name = ''`. */ export const declaredErrorClasses = async (sourceRoot: string): Promise => { const entries = await readdir(sourceRoot, { recursive: true, withFileTypes: true }); @@ -75,15 +107,27 @@ export const declaredErrorClasses = async (sourceRoot: string): Promise, name: string): readonly string[] => - [...sources] - .filter(([, source]) => source.includes(`this.name = '${name}'`) || source.includes(`this.name = "${name}"`)) - .map(([file]) => file) +export const errorClassDefinitions = (sources: ReadonlyMap, name: string): readonly string[] => { + const marker = new RegExp(`this\\.name = ['"]${name}['"]`, 'gu'); + return [...sources] + .flatMap(([file, source]) => Array.from(source.matchAll(marker), () => file)) .sort(); +}; + +/** + * Environment for a probe child: the caller's, without `NODE_OPTIONS`, so a + * preload or condition the host session set (one importing `node:sqlite`, say) + * cannot leak into what the probe measures. + */ +export const probeEnvironment = (base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => { + const { NODE_OPTIONS: _ignored, ...environment } = base; + return environment; +}; /** Specifier for each entry the identity probe imports, in load order. */ export interface EntryIdentityProbeSpecifiers { From 1846dbd3c18ff5a39b2dde329359dfc8a3ff204d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 01:13:42 +0000 Subject: [PATCH 4/5] docs(rsc-runtime): document McpCallToolResult; pin 1.x CallToolResult compatibility in the example's tests The website MCP page (en/zh) and the package README name the lowered result type and its assignability to both MCP SDK lines. The rsc-agent-runtime example's lowering test annotates a lowered result and the attachMcpStructuredContent round-trip as the 1.x SDK's CallToolResult, so the example's typecheck fails if either narrows again. --- .../tests/mcp-lowering.test.tsx | 25 ++++++++++++++++++- packages/rsc-runtime/README.md | 3 ++- website/docs/en/guide/authoring/mcp.mdx | 7 ++++++ website/docs/zh/guide/authoring/mcp.mdx | 6 +++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx b/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx index fce1add54..2604bf71e 100644 --- a/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx +++ b/examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx @@ -1,7 +1,8 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { expect, test } from '@rstest/core'; import React from 'react'; -import { Mcp, lowerMcpResult } from '@agent-bundle/runtime'; +import { Mcp, attachMcpStructuredContent, lowerMcpResult } from '@agent-bundle/runtime'; test('lowers every supported MCP result block in authored order', () => { const result = lowerMcpResult( @@ -184,3 +185,25 @@ test('preserves an own __proto__ key in valid structured content', () => { value: 'preserved', }); }); + +// This example hands runtime results to handlers typed by the 1.x MCP SDK +// (src/mcp/handlers.ts), while the runtime itself types against +// @modelcontextprotocol/server 2.x. The annotations are the test: the file +// stops typechecking if a lowered result ever stops being a 1.x +// CallToolResult, or if attachMcpStructuredContent narrows a 1.x result on +// the way back out. +test('lowered results stay assignable to the 1.x SDK CallToolResult, through attachMcpStructuredContent', () => { + const lowered: CallToolResult = lowerMcpResult( + + ready + , + ); + const attached: CallToolResult = attachMcpStructuredContent(lowered, { stateVersion: 3 }); + + expect(attached).toEqual({ + _meta: { progressToken: 'p-1' }, + content: [{ text: 'ready', type: 'text' }], + structuredContent: { stateVersion: 3 }, + }); + expect(attachMcpStructuredContent(lowered, ['not', 'an', 'object'])).toBe(lowered); +}); diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 3978dbf2f..a17e718d0 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -156,7 +156,8 @@ projections run — `inputSchema.parse` → `execute` → `resultSchema.parse` while `cli` and `mcp` are optional per-surface declarations. `render` is required on every operation but consumed only by the MCP projection, where `lowerMcpResult` synchronously lowers its element tree into the -`CallToolResult`; the `runRscCli` compatibility path never renders JSX and +`CallToolResult` (typed `McpCallToolResult`, assignable to both MCP SDK +lines' `CallToolResult`); the `runRscCli` compatibility path never renders JSX and instead prints the validated result as one line of JSON. Operation modules are `.tsx` only because `render` returns JSX. (Routed `src/cli/**` commands are the framework-mode CLI: there, `.tsx` routes do render — through the diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 74178c3ec..1acfbf98e 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -59,6 +59,13 @@ call renders through a warm internal Flight dispatcher and lowers the final Agen legal MCP output. Flight is an implementation transport inside the generated runtime — never a public host wire protocol, and raw Flight bytes never cross the MCP wire. +The lowered value is an `McpCallToolResult` (exported from `@agent-bundle/runtime`, with +`McpContentBlock` for its content blocks): the MCP SDK's content blocks, with `_meta` and +`structuredContent` as finite JSON objects. It is assignable to the `CallToolResult` of both +`@modelcontextprotocol/server` 2.x and `@modelcontextprotocol/sdk` 1.x, so a handler typed +against either SDK accepts it as is; `attachMcpStructuredContent` returns whatever result type +it was given. + A route may re-export its component and schemas from another module. This is how one tool is placed on two generated servers when only `config` differs between the placements — an MCP App `tools/call`, for example, reaches the server that served the widget: diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 1635c2492..41255f13b 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -55,6 +55,12 @@ export default async function Status({ input, signal }: ToolRouteProps Date: Sat, 5 Sep 2026 02:02:20 +0000 Subject: [PATCH 5/5] build: run attw and the declaration-import check on the packed runtime in lint:release #568 added the gate for the other three publishable packages; the runtime tarball this PR reshapes now goes through the same attw --profile esm-only and scripts/check-declaration-imports.mjs run (0 errors, 0 warnings). --- .changeset/rsc-runtime-hygiene.md | 2 +- package.json | 2 +- website/docs/en/guide/distribution/preview-packages.mdx | 5 +++-- website/docs/zh/guide/distribution/preview-packages.mdx | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/rsc-runtime-hygiene.md b/.changeset/rsc-runtime-hygiene.md index 3e9517523..6ce8cd1f3 100644 --- a/.changeset/rsc-runtime-hygiene.md +++ b/.changeset/rsc-runtime-hygiene.md @@ -2,4 +2,4 @@ "@agent-bundle/runtime": patch --- -Drop `@modelcontextprotocol/sdk` 1.x from `@agent-bundle/runtime`'s dependencies: the `CallToolResult` type now comes from `@modelcontextprotocol/server` 2.x, so installing the runtime no longer pulls the 1.x SDK's express, hono, jose, cors, and ajv trees. `lowerMcpResult` and `documentToCallToolResult` return the new exported `McpCallToolResult` (content blocks per `McpContentBlock`, `_meta` and `structuredContent` as JSON objects), which is assignable to both SDK lines' `CallToolResult`; `attachMcpStructuredContent` is generic over its input, so a result typed by either SDK line comes back as the type it went in. Build every public entry in one module graph: each error class is defined once in the package, so `instanceof AgentStateError` holds for errors raised through `@agent-bundle/runtime/state`, `/state/sqlite`, `/mount`, `/lineage`, and `/notices` (the previous build shipped a second `AgentStateError` inside the sqlite entry), while `node:sqlite` still loads only through `/state/sqlite`. Expose `./package.json` in `exports` and mark the `@rspack/core` peer optional — no runtime entry imports it. (#571) +Drop `@modelcontextprotocol/sdk` 1.x from `@agent-bundle/runtime`'s dependencies: the `CallToolResult` type now comes from `@modelcontextprotocol/server` 2.x, so installing the runtime no longer pulls the 1.x SDK's express, hono, jose, cors, and ajv trees. `lowerMcpResult` and `documentToCallToolResult` return the new exported `McpCallToolResult` (content blocks per `McpContentBlock`, `_meta` and `structuredContent` as JSON objects), which is assignable to both SDK lines' `CallToolResult`; `attachMcpStructuredContent` is generic over its input, so a result typed by either SDK line comes back as the type it went in. Build every public entry in one module graph: each error class is defined once in the package, so `instanceof AgentStateError` holds for errors raised through `@agent-bundle/runtime/state`, `/state/sqlite`, `/mount`, `/lineage`, and `/notices` (the previous build shipped a second `AgentStateError` inside the sqlite entry), while `node:sqlite` still loads only through `/state/sqlite`. Expose `./package.json` in `exports` and mark the `@rspack/core` peer optional — no runtime entry imports it. `pnpm lint:release` now runs `attw --profile esm-only` and `scripts/check-declaration-imports.mjs` on the packed runtime tarball too. (#571) diff --git a/package.json b/package.json index a809770ed..0f403d64e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "release": "pnpm check:release && changeset publish", "preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/rsc-markdown-stream' './packages/create-agent-bundle'", "pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json", - "lint:release": "attw --pack --profile esm-only packages/agent-bundle && attw --pack --profile esm-only packages/rsc-markdown-stream && attw --pack --profile esm-only packages/create-agent-bundle && node scripts/check-declaration-imports.mjs packages/agent-bundle packages/rsc-markdown-stream packages/create-agent-bundle", + "lint:release": "attw --pack --profile esm-only packages/agent-bundle && attw --pack --profile esm-only packages/rsc-runtime && attw --pack --profile esm-only packages/rsc-markdown-stream && attw --pack --profile esm-only packages/create-agent-bundle && node scripts/check-declaration-imports.mjs packages/agent-bundle packages/rsc-runtime packages/rsc-markdown-stream packages/create-agent-bundle", "check:release": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed:release", "check:release:ci": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed", "example:hooks": "pnpm build && pnpm --filter @agent-bundle-example/hooks-and-scripts dev", diff --git a/website/docs/en/guide/distribution/preview-packages.mdx b/website/docs/en/guide/distribution/preview-packages.mdx index d62443edf..26b4c0232 100644 --- a/website/docs/en/guide/distribution/preview-packages.mdx +++ b/website/docs/en/guide/distribution/preview-packages.mdx @@ -65,8 +65,9 @@ not currently overridden with `publishConfig.access`. `pnpm release` runs the release gate — `pnpm pack:dry-run`, `pnpm lint:release`, and `pnpm test:packed:release` — before publishing. `lint:release` runs `attw` with the `esm-only` -profile on the packed `agent-bundle`, `rsc-markdown-stream`, and `create-agent-bundle` tarballs, -then `scripts/check-declaration-imports.mjs` over the same inventories: every `.d.ts` a consumer can +profile on the packed `agent-bundle`, `@agent-bundle/runtime`, `rsc-markdown-stream`, and +`create-agent-bundle` tarballs, then `scripts/check-declaration-imports.mjs` over the same +inventories: every `.d.ts` a consumer can reach from `exports` may only import packed files, the package's own exported subpaths, `#` imports its `imports` map resolves, Node built-ins, and packages declared in `dependencies`, `peerDependencies`, or `optionalDependencies` — diff --git a/website/docs/zh/guide/distribution/preview-packages.mdx b/website/docs/zh/guide/distribution/preview-packages.mdx index f2b3784ac..8e5656682 100644 --- a/website/docs/zh/guide/distribution/preview-packages.mdx +++ b/website/docs/zh/guide/distribution/preview-packages.mdx @@ -54,7 +54,7 @@ npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@