Skip to content

build(rsc-runtime): one module graph, drop @modelcontextprotocol/sdk 1.x, expose ./package.json - #571

Merged
ScriptedAlchemy merged 10 commits into
mainfrom
build/rsc-runtime-hygiene
Sep 5, 2026
Merged

build(rsc-runtime): one module graph, drop @modelcontextprotocol/sdk 1.x, expose ./package.json#571
ScriptedAlchemy merged 10 commits into
mainfrom
build/rsc-runtime-hygiene

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes the packages/rsc-runtime items of #566 (Section 2). Scope is @agent-bundle/runtime only; the other publishable packages' manifests and configs are untouched, as is src/state/sqlite.ts.

What changed

P1 — @modelcontextprotocol/sdk 1.x dropped

  • packages/rsc-runtime/src/lower-mcp.ts:3 and src/project-mcp.ts import CallToolResult from @modelcontextprotocol/server 2.x (already a dependency) instead of @modelcontextprotocol/sdk/types.js; package.json:88 no longer lists @modelcontextprotocol/sdk@1.30.0; pnpm-lock.yaml loses the runtime's importer entry (the 1.x snapshot itself stays, agent-bundle still depends on it).
  • Structural difference between the two SDKs: 1.x types structuredContent as Record<string, unknown> and _meta as a record, 2.x types both as unknown. Passing the 2.x type straight through broke examples/rsc-agent-runtime/src/mcp/handlers.ts, which hands runtime results to a 1.x handler. The package now states the shape it actually emits — McpCallToolResult / McpContentBlock (lower-mcp.ts:51-68, exported from the root at src/index.ts:97): SDK content blocks, _meta and structuredContent as finite JsonObjects — which is assignable to both SDK lines' CallToolResult. lowerMcpResult, documentToCallToolResult, and McpProjectedToolResult.result return/carry it; attachMcpStructuredContent (project-mcp.ts:215-228) is generic over its input (<T extends CallToolResult>(result: T, value) => T), so a caller holding a result typed by either SDK line gets it back as the same type — a 1.x CallToolResult is not assignable to McpCallToolResult (_meta is a loose record there), and the first draft of this PR would have stopped such callers compiling (self-review finding 1). Every modelcontextprotocol import in the repo (git grep -n "modelcontextprotocol" -- ':!repos' ':!pnpm-lock.yaml') typechecks against it.
  • Packed tarball: no file in it mentions @modelcontextprotocol/sdk (manifest, dist/*.js, dist/*.d.ts); the shipped declarations import only react, zod, effect, @modelcontextprotocol/server, rsc-markdown-stream.

P2 — one module graph, no string externals

packages/rsc-runtime/rslib.config.ts:5-35 builds all nine public entries in one bundle-mode lib (option (a)). Rslib/Rspack emits every module two entries share in a common chunk (dist/<id>.js) that both import, so each class exists once; a module only one entry reaches stays in that entry's own chunk, so the graphs remain disjoint where they must.

Why not (b) bundle: false + redirect.js — measured, not assumed (scratch build of the same nine entries into /tmp, Rslib 0.23.2, redirect.js.extension: true): it passes every boundary in state-packaging.test.ts and yields an identical identity report, so it is a valid answer too, but it ships 48 JS files instead of 22 at +1.4 % bytes (374,108 vs 368,894), forces four exports import targets to move (state, notices, mount, lineage*/index.js, plus two test fixtures), and shows no measurable cold-import difference. Same guarantee, more files and churn, so (a).

Evidence (release-mode npm pack, before = origin/main, after = this branch):

before after
dependencies @modelcontextprotocol/sdk 1.30.0, @modelcontextprotocol/server 2.0.0, effect, flare-redact, react-server-dom-rspack, rsc-markdown-stream, zod @modelcontextprotocol/server 2.0.0, effect, flare-redact, react-server-dom-rspack, rsc-markdown-stream, zod
peerDependencies @rspack/core ^2.2.0-0, react 19.2.8, react-dom 19.2.8 same; @rspack/core now peerDependenciesMeta.optional
tarball (gzip) 134,554 B 132,707 B
unpacked 624,988 B (64 files) 603,000 B (74 files)
dist/*.js 387,837 B 368,047 B
dist/*.d.ts 187,655 B 185,376 B
this.name = 'AgentStateError' definitions in dist 2 (state.js, state/sqlite.js) 1
AgentContractError / AgentRequestError definitions 3 / 3 1 / 1
node:sqlite referenced by state/sqlite.js only state/sqlite.js only
__webpack_require__ in dist none none
exports map 9 subpaths same 9 + ./package.json
sideEffects: false yes yes
attw --profile esm-only on the tarball clean (exit 0)
publint on the tarball "All good!" (also runs at build via rsbuild-plugin-publint, throwOn: 'warning')

The duplication in today's build is real, not hypothetical: the literal externals covered '../state/index.js', but src/state/sqlite.ts imports ./contract.js directly (expectOperable, expectRevisionShape), so Rspack re-bundled the kernel's contract module — and its AgentStateError — into state/sqlite.js. Observable today:

$ node identity-probe.mjs <main dist>
sqlite store.read({revision:-1}) threw AgentStateError invalid-input | instanceof state.AgentStateError: false
$ node identity-probe.mjs <this branch's dist>
sqlite store.read({revision:-1}) threw AgentStateError invalid-input | instanceof state.AgentStateError: true

The existing state-packaging identity test passed on main only because its trigger (lifetime-mismatch) is thrown by code that reached AgentStateError through the externalized ./index.js.

Tests (both fail against main's dist — swapping main's dist into the workspace gives AgentContractError is defined in 3 dist files and sqliteRevisionError.instanceOfStateError: false):

  • packages/rsc-runtime/tests/state-packaging.test.ts (integration pool) now walks the dist import graph per entry (tests/support/dist-graph.ts): every dist file is reachable from some public entry (so a chunk the walker's import parser missed would fail the test, not silently weaken it); root and ./plugin graphs contain no file that defines AgentStateError/AgentNoticeError (by definition file, whatever the quoting) and no kernel/ledger/sqlite identifier, and ./plugin stays Effect-free; every file that mentions node:sqlite/DatabaseSync is reachable from ./state/sqlite and from no other entry (by reachability, so Rspack may move the sqlite code into its own chunk without breaking the test); every error class declared in src (this.name = '…Error') is defined exactly once across the dist (occurrences counted, not files), and AgentStateError's file is in the graph of ./state, ./state/sqlite, ./mount, ./lineage, ./notices; a child process (--conditions=react-server, NODE_OPTIONS stripped so a host preload cannot skew moduleLoadList) imports all nine entries, asserts process.moduleLoadList has no NativeModule sqlite before ./state/sqlite and has it after, root.AgentRequestError === plugin.AgentRequestError, and errors thrown by the sqlite entry (lifetime-mismatch, and the formerly forked invalid-input) and the mount entry are instanceof ./state's AgentStateError.
  • packages/rsc-runtime/tests/packed-entry-identity.test.ts (packed pool, registered in rstest.integration-tests.ts:152) installs the release tarball with npm, runs the same static graph checks against the installed dist (reachability, one definition per class, sqlite confinement — the probe alone would not see a deferred import('node:sqlite')), and repeats the probe through the exports map (@agent-bundle/runtime, /plugin, /flight/server, /state, /state/sqlite, /notices, /notices/inbox-route, /mount, /lineage), plus: no @modelcontextprotocol/sdk in the installed manifest or declarations, no * peer range, @rspack/core optional, and import.meta.resolve('@agent-bundle/runtime/package.json') resolves.
  • lineage has no cheap runtime trigger for an AgentStateError (its instanceof is inside idempotency-conflict redelivery handling), so it is covered structurally: one definition in the dist, and that file is in the lineage entry's graph.

Rslib 0.23 vs 1.0: the config uses only lib[].source.entry, bundle, dts, format, syntax, and top-level output — the same shape on both; nothing here needs a follow-up for build/rslib-1.

P3 — manifest hygiene (packages/rsc-runtime/package.json)

  • :71"./package.json": "./package.json" export (attw: 🟢 JSON on every resolution mode).
  • :83-87@rspack/core marked peerDependenciesMeta.optional. No runtime entry imports it (packed dist/*.js externals: react, react-server-dom-rspack/{server,client}.node, effect, zod, flare-redact, rsc-markdown-stream, @modelcontextprotocol/server, node:*); it is react-server-dom-rspack's build-plugin peer, which agent-bundle supplies at build time. This is exactly the AB7014 guidance for a required peer nothing imports. Measured against real installs (npm 12 and pnpm 11, before/after tarballs in fresh consumers): today the flag changes nothing observable — react-server-dom-rspack@0.1.0 still declares the same @rspack/core ^2.2.0-0 as a required peer, so both package managers auto-install @rspack/core@2.2.2 either way with byte-identical lockfile entries and no warnings; the only visible difference is pnpm peers check (with auto-install-peers=false) no longer listing the runtime as a wanter. The runtime provably loads every entry, including ./state/sqlite, with @rspack/core absent (npm install --legacy-peer-deps). The consumer-facing win lands when react-server-dom-rspack marks its peer optional too — worth a follow-up there, not here.
  • react / react-dom stay exact 19.2.8 and required — deliberate, not accidental: the Flight wire format is pinned to the React build (docs/plans/2026-08-14-rsc-agent-runtime-demo-design.md:364, agent-bundle pins the same), and react-server-dom-rspack's client.node (root entry) and server.node (./flight/server) both require("react-dom") at import time. No * ranges.
  • rsc-markdown-stream stays ^0.1.0 (:93) with the pnpm-workspace.yaml workspace:* override, per the read of docs/preview-packages.md and the harness: scripts/run-packed-tests.mjs and tests/support/shared-pack.ts pack with npm pack, which does not rewrite workspace: protocols, so a workspace:* specifier would ship verbatim in the harness tarball and fail the consumer install with EUNSUPPORTEDPROTOCOL; the AB7015 rule documents the same (workspace: counts as a registry specifier only under a pnpm/Yarn/Bun prepack lifecycle). The override already gives the workspace the linked package; the published range is what consumers need.
  • Release gate: pnpm lint:release (root package.json) now runs attw --pack --profile esm-only and scripts/check-declaration-imports.mjs on the packed runtime as well — build: gate releases on attw + declaration-import check; manifest hygiene for agent-bundle, rsc-markdown-stream, create-agent-bundle (#566 §2) #568 added that gate for the other three packages; the runtime is the fourth (48 packed declarations, 44 reachable from 9 export entries, 0 errors, 0 warnings). preview-packages.mdx (en + zh) lists it.
  • AB7014/AB7015 for this package: agent-bundle prepack applies to agent-bundle projects, so the rules were checked by hand against the packed tarball — every dependencies entry is imported by packed JS or referenced by a packed .d.ts; every specifier is a registry version/range; the one peer nothing imports is now optional.

Docs

No page under website/docs/en/** lists the runtime's dependencies, install size, or entry layout (installation.mdx names the package; preview-packages.mdx mentions the renderer dependency, unchanged). guide/authoring/mcp.mdx (en + zh) gains a paragraph naming the lowered result type McpCallToolResult/McpContentBlock and its assignability to both SDK lines' CallToolResult; packages/rsc-runtime/README.md:158 says the same. docs/preview-packages.md:70 listed the runtime's peers as react, react-dom, @rspack/core — now says the last is optional. A repo-wide sweep found no consumer outside packages/rsc-runtime naming a dist/ path of the runtime (everything goes through the exports subpaths), so the extra shared chunks are invisible to them. pnpm docs:site:build passes (TypeDoc compiles the public API, which reaches the new exported types).

Gates

On the final state: pnpm build, pnpm typecheck (plus examples/rsc-agent-runtime's tsc, the 1.x consumer), pnpm lint, pnpm test:unit (3589 passed), state-packaging.test.ts (7/7), packed-entry-identity.test.ts through scripts/run-packed-tests.mjs (1/1), pnpm docs:site:build. On the previous push: pnpm test:integration:run (1065 passed), pnpm test:packed (11 files, 28 passed, 1 skipped). A compile check (in /tmp, against the workspace types) confirmed attachMcpStructuredContent round-trips a 1.x CallToolResult, a 2.x CallToolResult, and McpCallToolResult, and that McpCallToolResult is assignable to both SDK lines; its negative control (const bad: McpCallToolResult = legacy) fails as expected.

CI on f22c2e9 had one red job, Release gates (Node 22.19): packages/workbench/tests/packed-release.e2e.test.ts › "foreground outage ledger quiet fence" — the first /api/project/session probe 9 ms into a Workbench restart got net::ERR_SOCKET_NOT_CONNECTED where packed-outage-ledger.ts:167 tolerates only ERR_CONNECTION_REFUSED/ERR_CONNECTION_RESET; the next eleven probes were REFUSED and recovery returned 200. This PR does not touch packages/workbench, and the runtime's own packed test passed in that same job. It recurred on the first merged head (e9f041967, run 33936651225) while Release gates passed on main and four other PRs in the same window; closeChild sends SIGTERM (graceful), so the browser's first retry races the server closing its keep-alive sockets and Chromium reports RESET (tolerated since 7eefeae) or SOCKET_NOT_CONNECTED (not yet) by sub-millisecond ordering — nothing the runtime's dist layout can influence. One gh run rerun --failed of 33936651225 passed with no code change. Follow-up, out of this PR's scope: add net::ERR_SOCKET_NOT_CONNECTED to downServerProbeCodes in packages/workbench/tests/support/packed-outage-ledger.ts:167.

Self-review

Reviewer: change-risk-reviewer subagent, model gpt-5.6-sol-medium, diff vs origin/main; plus a generalPurpose test-robustness review of the new test files.

  1. attachMcpStructuredContent input narrowed from SDK 1.x CallToolResult to McpCallToolResult — should-fix. A 1.x-typed result has _meta: { [x: string]: unknown; … }, not assignable to JsonObject; external callers would stop compiling. Fixed: the function is generic <T extends CallToolResult>(result: T, value: unknown): T (project-mcp.ts:215-228); compile check with both SDK lines and the package's own type passes, negative control fails.
  2. errorClassDefinitions counted files, not occurrences — two copies in one chunk would pass. Fixed: one entry per match (dist-graph.ts), assertion unchanged (toHaveLength(1)).
  3. Regex import walker could see imports in strings/comments or miss a fourth import form. Fixed: static/side-effect imports match from statement start only; dynamic import() kept; and both tests now assert unreachedFiles(sources) is empty — Rslib emits no unreferenced chunk, so a missed edge form surfaces as an unreached file instead of a silently smaller closure.
  4. sqlite confinement required the identifiers to sit in state/sqlite.js by name — a legitimate Rspack split into a private chunk would fail it. Fixed: every file mentioning node:sqlite/DatabaseSync must be reached by ./state/sqlite and no other entry (entriesReaching).
  5. Packed test had no static sqlite check — a deferred import('node:sqlite') in another entry would not run during the probe. Fixed: the installed dist gets the same reachability, definition-count, and confinement checks.
  6. Root/plugin kernel check keyed on the single-quoted this.name = 'AgentStateError' marker. Fixed: the root and plugin closures must not contain the definition file of AgentStateError/AgentNoticeError, found quote-agnostically.
  7. Probe children inherited NODE_OPTIONS — a host preload importing node:sqlite would fail the test spuriously. Fixed: probeEnvironment() strips it in both tests.
  8. No findings on: build layout and export targets, node:sqlite isolation of the shipped dist, in-repo consumers of the renamed types, changeset, docs parity.

Second pass (change-risk-reviewer, gpt-5.6-sol-medium, after the fixes above) — verified finding 1's fix; two new findings:

  1. No regression test for the 1.x compatibility of attachMcpStructuredContent — should-fix. Fixed: examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx (the repo's one 1.x-typed consumer) annotates a lowered result and its attachMcpStructuredContent round-trip as the 1.x SDK's CallToolResult; the example's tsc (CI "Examples check") fails if either narrows again — verified by reintroducing the narrowing, rebuilding the runtime declarations, and watching the example typecheck fail with TS2345 on that line.
  2. New public types McpCallToolResult/McpContentBlock undocumented on the website — must-fix per AGENTS.md docs parity. Fixed: paragraph in website/docs/{en,zh}/guide/authoring/mcp.mdx plus the README line; pnpm docs:site:build green (parity check included).

Third pass (change-risk-reviewer, gpt-5.6-sol-medium, on the final head): no findings in any category — docs match source, zh paragraph faithful, example test correct, no remaining merge risk.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bfbbee8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@agent-bundle/runtime Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@571
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@571
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@571
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@571

commit: bfbbee8

…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.
ScriptedAlchemy and others added 3 commits September 5, 2026 01:02
…kaging 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.
… 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.
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 5, 2026 01:16
ScriptedAlchemy and others added 5 commits September 4, 2026 18:25
…e 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).
@ScriptedAlchemy
ScriptedAlchemy merged commit baed7d8 into main Sep 5, 2026
14 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the build/rsc-runtime-hygiene branch September 5, 2026 02:33
ScriptedAlchemy added a commit that referenced this pull request Sep 5, 2026
ScriptedAlchemy added a commit that referenced this pull request Sep 5, 2026
) (#575)

* build: compile on one Rspack engine with Rslib 1.0 and Rsbuild 2.2 (#566)

Bump @rslib/core to 1.0.0 and @rsbuild/core to 2.2.3 in every workspace
package, example, and scaffolder template, and @rstest/* to 0.11.12, so a
consumer installs one @rspack/core (2.2.2) and one native binding.

Compiler (src/build/rslib.ts): output.autoExternal: false (v1 form); URL
and worker parsing off for plugin builds so generated `new URL(…)` and
`new Worker(new URL(…))` expressions survive verbatim; inspectConfig runs
in production mode with NODE_ENV restored; persistent cache off.
externalsType stays Rslib's ESM default, which reproduces the 0.x
createRequire shim for CommonJS requires of Node builtins.

Own sources resolve sibling modules with fileURLToPath + path.join
instead of new URL(…, import.meta.url).

Tests: lowered-config and packed assertions that plugin output is
self-contained (Node builtins the only externals), a packed guard for a
single Rspack engine, and inspect coverage under NODE_ENV=development.

* chore: reference #575 in the rslib-1 changeset

* fix(build): hand an unset NODE_ENV back after inspecting, even when the inspection throws

* docs: name the Rslib 1 option shape (output.autoExternal) in AGENTS.md

* docs(types): state the one-engine hatch reality on AgentBundleToolsConfig (Codex review)

* chore: drop the runtime chunk-name note from the rslib-1 changeset (superseded by #571's single module graph)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant