diff --git a/.changeset/rsc-runtime-hygiene.md b/.changeset/rsc-runtime-hygiene.md new file mode 100644 index 000000000..6ce8cd1f3 --- /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` 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/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/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/package.json b/package.json index 21e2b8266..610d9794c 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/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/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..e12979ce7 100644 --- a/packages/rsc-runtime/src/project-mcp.ts +++ b/packages/rsc-runtime/src/project-mcp.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import { Effect, Stream } from 'effect'; import { @@ -9,7 +9,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 +73,7 @@ export interface ProjectMcpRenderOptions { export interface McpProjectedToolResult { readonly document: AgentDocument; - readonly result: CallToolResult; + readonly result: McpCallToolResult; } const resolveCapabilities = ( @@ -87,7 +93,7 @@ const gatedBlock = ( kind: McpRichContentKind, summary: string, fallback: McpRichContentFallback, -): CallToolResult['content'][number] => { +): McpContentBlock => { switch (fallback) { case 'text': return { text: summary, type: 'text' }; @@ -104,8 +110,6 @@ const gatedBlock = ( } }; -type McpContentBlock = CallToolResult['content'][number]; - const appendNode = ( node: AgentDocumentNode, content: McpContentBlock[], @@ -191,7 +195,7 @@ const resultMetadata = (document: AgentDocument): JsonObject | undefined => { export const documentToCallToolResult = ( document: AgentDocument, options: Pick = {}, -): CallToolResult => { +): McpCallToolResult => { const content: McpContentBlock[] = []; appendNode( document.root, @@ -209,10 +213,15 @@ export const documentToCallToolResult = ( }; }; -export const attachMcpStructuredContent = ( - result: CallToolResult, +/** + * 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, -): CallToolResult => { +): 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 new file mode 100644 index 000000000..96329feb7 --- /dev/null +++ b/packages/rsc-runtime/tests/packed-entry-identity.test.ts @@ -0,0 +1,132 @@ +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, + entriesReaching, + entryIdentityProbeScript, + errorClassDefinitions, + filesContaining, + parseEntryIdentityReport, + probeEnvironment, + readDistSources, + runtimeEntryFiles, + unreachedFiles, +} 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'); + } + + // 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 ${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'); + 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: probeEnvironment(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..b8ec120f4 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -1,82 +1,80 @@ -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, + entriesReaching, + entryIdentityProbeScript, + errorClassDefinitions, + filesContaining, + importClosure, + parseEntryIdentityReport, + probeEnvironment, + readDistSources, + runtimeEntryFiles, + unreachedFiles, + 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'); - }); +// 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', + 'DatabaseSync', + 'agent_state_journal', + 'createAgentNoticeLedger', + 'agent-notice-ledger/v1', +] as const; +const kernelErrorClasses = ['AgentStateError', 'AgentNoticeError'] 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'); +describe.sequential('state kernel packaging boundaries', () => { + beforeAll(async () => { + sources = await readDistSources(dist); }); - 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'); + it('reaches every dist file from some public entry, so the graph walk sees every edge', () => { + expect(unreachedFiles(sources)).toEqual([]); }); - 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 +85,100 @@ 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', () => { + 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); + } + } + } + // 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 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'); + 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 ${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. + 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, env: probeEnvironment() }, + ); + 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..01fd99574 --- /dev/null +++ b/packages/rsc-runtime/tests/support/dist-graph.ts @@ -0,0 +1,229 @@ +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; + +/** + * 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('/'); + +/** 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] ?? 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 }); + 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(); +}; + +/** + * One entry per definition of the class named `name`: the dist file it sits + * in, repeated when a file holds two copies. Rspack renames a class it bundles + * twice, so the constructor's `this.name` assignment (either quote style) is + * the one marker a duplicated definition cannot hide behind. + */ +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 { + 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 23682ff09..191c49c32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,9 +377,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 d1baeda6f..cb986bee1 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -158,6 +158,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', ]; 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/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/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