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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rsc-runtime-hygiene.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion docs/preview-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Previews carry the version string `0.0.0-preview-<sha>`, 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
Expand Down
25 changes: 24 additions & 1 deletion examples/rsc-agent-runtime/tests/mcp-lowering.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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(
<Mcp.Result _meta={{ progressToken: 'p-1' }}>
<Mcp.Text>ready</Mcp.Text>
</Mcp.Result>,
);
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);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
104 changes: 20 additions & 84 deletions packages/rsc-runtime/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -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/<id>.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,
Expand Down
2 changes: 1 addition & 1 deletion packages/rsc-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions packages/rsc-runtime/src/lower-mcp.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -192,7 +212,7 @@ export const frozenJsonRecord = (value: unknown, message: string): Readonly<Reco
return clone;
};

const lowerContent = (node: ReactNode): CallToolResult['content'][number] => {
const lowerContent = (node: ReactNode): McpContentBlock => {
const element = asMcpElement(node);
const { props } = element;
switch (element.type) {
Expand Down Expand Up @@ -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') {
Expand Down
29 changes: 19 additions & 10 deletions packages/rsc-runtime/src/project-mcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { CallToolResult } from '@modelcontextprotocol/server';
import { Effect, Stream } from 'effect';

import {
Expand All @@ -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;

Expand Down Expand Up @@ -67,7 +73,7 @@ export interface ProjectMcpRenderOptions {

export interface McpProjectedToolResult {
readonly document: AgentDocument;
readonly result: CallToolResult;
readonly result: McpCallToolResult;
}

const resolveCapabilities = (
Expand All @@ -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' };
Expand All @@ -104,8 +110,6 @@ const gatedBlock = (
}
};

type McpContentBlock = CallToolResult['content'][number];

const appendNode = (
node: AgentDocumentNode,
content: McpContentBlock[],
Expand Down Expand Up @@ -191,7 +195,7 @@ const resultMetadata = (document: AgentDocument): JsonObject | undefined => {
export const documentToCallToolResult = (
document: AgentDocument,
options: Pick<ProjectMcpRenderOptions, 'capabilities' | 'richContentFallback' | 'structuredContent'> = {},
): CallToolResult => {
): McpCallToolResult => {
const content: McpContentBlock[] = [];
appendNode(
document.root,
Expand All @@ -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 = <TResult extends CallToolResult>(
result: TResult,
value: unknown,
): CallToolResult => {
): TResult => {
const structured = objectStructuredContent(value);
if (structured === undefined) return result;
return { ...result, structuredContent: structured };
Expand Down
Loading
Loading