diff --git a/.changeset/clarify-rsc-runtime-not-rsc.md b/.changeset/clarify-rsc-runtime-not-rsc.md new file mode 100644 index 000000000..68e1bb6ef --- /dev/null +++ b/.changeset/clarify-rsc-runtime-not-rsc.md @@ -0,0 +1,15 @@ +--- +"@agent-bundle/rsc-runtime": patch +--- + +Docs-only clarification of the operation/JSX model (#88). The published +README now states explicitly that the package is **not** a React Server +Components renderer or runtime and that no Flight transport is involved: it +is a synchronous React-element protocol DSL (an "MCP result DSL") whose +`lowerMcpResult`/`lowerHookResult` walk an element tree and lower it into +plain protocol results. The README also spells out the operation model — an +operation is a host-neutral use-case definition whose shared core +(`id`/`inputSchema`/`execute`/`resultSchema`) runs identically under the CLI +and MCP projections, `render` is consumed only by MCP, and the CLI prints +validated JSON — and the npm `description` field no longer claims "React +Server Component primitives". No runtime code or export surface changes. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 0b5f13bf2..af8add3b6 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -57,6 +57,103 @@ The server's structural declaration (`mcp.servers.runtime: {}`) lives in the config; the name passed to `createRscMcpServer` only selects which operations to serve. +## One operation, end to end + +An **operation** is a host-neutral use-case definition: one named unit of +work with a validated input, an implementation, a validated result, and a +result renderer. It is *not* a CLI command — the CLI command and the MCP +tool are optional projections declared alongside the shared core, and +either (or both) may be present. The `status` operation used above looks +like this in full, including the JSX: + +```tsx +// src/operations/status.tsx +import { defineOperation } from '@agent-bundle/rsc-runtime/plugin'; +import { Mcp } from '@agent-bundle/rsc-runtime'; +import { z } from 'zod'; + +export const status = defineOperation({ + // Shared core — both projections funnel through these four fields. + id: 'status', + inputSchema: z.object({ verbose: z.boolean().optional() }).strict(), + execute: async () => ({ status: 'ready' as const }), + resultSchema: z.object({ status: z.literal('ready') }).strict(), + + // CLI projection — argv parsing, help text, exit codes. No JSX: the CLI + // prints the validated result as one line of JSON. + cli: { + name: 'status', + parse: (args) => (args.includes('--verbose') ? { verbose: true } : {}), + summary: 'Read runtime status.', + usage: 'status [--verbose]', + }, + + // MCP projection — tool metadata plus the result renderer. Only MCP + // consumes `render`, but it is a required field: a CLI-only operation + // still has to declare one. + mcp: { + description: 'Read runtime status.', + name: 'runtime_status', + readOnly: true, + server: 'runtime', + }, + render: (result) => ( + + {`Runtime is ${result.status}.`} + + ), +}); +``` + +Both projections run the identical pipeline — +`inputSchema.parse(input)` → `execute(input, { signal })` → +`resultSchema.parse(result)` — so inputs, implementation, and output +validation cannot drift between surfaces. Only the last step differs: + +- **CLI** (`runRscCli`): `cli.parse` receives the arguments after the + command name and produces the input; the validated result is written to + stdout as one line of JSON (`JSON.stringify`), and `cli.exitCode(result)` + (default `0`) is returned to the entry, which sets it as the process exit + code. The CLI never touches `render` and never renders JSX. +- **MCP** (`createRscMcpServer`): the tool handler calls `render(result)` + and `lowerMcpResult` synchronously lowers the returned React element tree + (`Mcp.Result`, `Mcp.Text`, `Mcp.Image`, `Mcp.Audio`, `Mcp.ResourceLink`, + `Mcp.EmbeddedResource`) into a plain MCP `CallToolResult` object. The + lowering is strict — `Mcp.Text` takes exactly one string child, hence the + template literal above. + +## Why `.tsx`, and what "RSC runtime" is not + +Despite the package name, `@agent-bundle/rsc-runtime` is **not a React +Server Components renderer or runtime, and no Flight transport is +involved**. Nothing streams a component tree to a client, hydrates, or +holds server component state. What the MCP projection uses is an **MCP +result DSL**: `render` returns ordinary React elements, and +`lowerMcpResult` walks that tree synchronously — function components are +simply called — to produce the `CallToolResult` the MCP SDK sends. The +package owns no transport, persistence, or application state; those remain +explicit dependencies of `execute` implementations. + +Operation modules are `.tsx` for exactly one reason: the `render` callback +returns JSX. Everything else in an operation — schemas, argv parsing, MCP +metadata — is plain TypeScript, and modules with no runtime JSX (such as an +application module that only composes operation arrays) stay `.ts`. + +For a new reader, in one breath: + +1. **What is an operation?** A host-neutral use-case definition — id, input + schema, `execute`, result schema, `render` — with optional CLI and MCP + projections. +2. **Which parts are shared by CLI and MCP?** The core four: `id`, + `inputSchema`, `execute`, `resultSchema` (plus the validation pipeline + around them). +3. **Which projection consumes `render`?** Only MCP, though every operation + must declare one. The CLI serializes the validated result as JSON. +4. **Is any React Server Components renderer or Flight transport + involved?** No. `lowerMcpResult` is a synchronous element-tree lowering, + not a renderer or transport. +5. **Why are operation modules `.tsx`?** Only because `render` returns JSX. + ## Rendered skills (power tier, never required) A skill whose document is generated: put `SKILL.tsx` (or `SKILL.ts`) in the diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 6475f709a..5fe9ae1c3 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -48,6 +48,36 @@ script, and lifecycle-wrapped MCP server) plus the npm package build beneath `agent-bundle` and `@agent-bundle/rsc-runtime` exports with `workspace:*` dependencies. +## Operation model + +Every command is one `defineOperation` definition: a host-neutral use case — +`id`, input schema, `execute`, result schema, `render` — with two +projections declared beside it. The shared core runs identically on both +surfaces (`inputSchema.parse` → `execute` → `resultSchema.parse`); `cli` +adds argv parsing and exit codes, and `mcp` adds tool metadata. `render` is +a sibling of both, required on every operation but consumed only by the MCP +projection: the CLI prints each validated receipt as one line of JSON and +never renders JSX. + +The runtime JSX for every operation is `` in +[`src/result.tsx`](src/result.tsx), which wraps the receipt in the MCP +result DSL: + +```tsx +export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) => ( + + {summary(receipt)} + +); +``` + +`lowerMcpResult` lowers that element tree synchronously into an MCP +`CallToolResult`. No React Server Components renderer or Flight transport is +involved anywhere in this example; operation modules are `.tsx` only because +`render` returns JSX, and `src/application.ts` stays `.ts` because it merely +composes the operation arrays. The end-to-end walkthrough is in +[Framework mode](../../docs/framework-mode.md). + ## Source layout - `agent-bundle.config.ts` — the structure: plugin identity, targets, the CLI diff --git a/examples/audiobook-curator/src/application.ts b/examples/audiobook-curator/src/application.ts index 7230c3e1e..8c695610d 100644 --- a/examples/audiobook-curator/src/application.ts +++ b/examples/audiobook-curator/src/application.ts @@ -3,7 +3,9 @@ * catalog. Structure — targets, the Skill, the CLI script, the MCP server — * lives in `agent-bundle.config.ts` and file conventions; the operations * themselves live in feature modules under `./operations/`, and this file - * only merges their defaults. + * only merges their defaults. There is deliberately no JSX here: the only + * runtime JSX is each operation's `render`, which delegates to + * `` in `./result.tsx` for the MCP projection. */ import { defineRscApplication } from '@agent-bundle/rsc-runtime/plugin'; diff --git a/examples/audiobook-curator/src/result.tsx b/examples/audiobook-curator/src/result.tsx index 42f23ebfa..6e7c1981c 100644 --- a/examples/audiobook-curator/src/result.tsx +++ b/examples/audiobook-curator/src/result.tsx @@ -1,3 +1,11 @@ +/** + * The one place runtime JSX lives: every operation's `render` wraps its + * receipt in ``, and the MCP projection lowers that element + * tree synchronously into a `CallToolResult` via `lowerMcpResult`. This is + * the MCP result DSL, not React Server Components — no renderer or Flight + * transport is involved, and the CLI projection never calls it (it prints + * the validated receipt as JSON instead). + */ import { Mcp } from '@agent-bundle/rsc-runtime'; import React from 'react'; diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index 43f23800c..f9d3e7e12 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -4,6 +4,16 @@ Small React primitives for producing Agent Bundle hook and MCP protocol results No npm release is cut yet; install the pkg.pr.new preview of any `main` commit or pull request — see [Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md). +Despite the name, this package is **not a React Server Components renderer +or runtime, and no Flight transport is involved**. It is a synchronous +React-element protocol DSL — an *MCP result DSL*: `lowerMcpResult` walks an +element tree, calling your function components as it goes, and lowers it +into a plain MCP `CallToolResult`. `lowerHookResult` lowers a `Hook.Result` +tree into a native `PostToolUse` output the same way, except that it +resolves only the `Hook` elements themselves — a hook tree returned from +your own component is rejected. Nothing streams components, hydrates, or +holds server component state. + ```tsx import { Mcp, lowerMcpResult } from '@agent-bundle/rsc-runtime'; @@ -78,6 +88,17 @@ export const application = defineRscApplication({ }); ``` +An operation is a host-neutral use-case definition, not a CLI command: the +shared core (`id`, `inputSchema`, `execute`, `resultSchema`) is what both +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 CLI never renders JSX and instead prints the +validated result as one line of JSON. Operation modules are `.tsx` only +because `render` returns JSX. The end-to-end walkthrough lives in +[Framework mode](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/framework-mode.md). + Use `runRscCli(application, argv)` in the conventional `src/cli.ts` entry and `createRscMcpServer(application, 'runtime')` in the conventional `src/mcp/runtime.ts` entry. Operation inputs, implementations, output diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json index 6e2862762..76c654d46 100644 --- a/packages/rsc-runtime/package.json +++ b/packages/rsc-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@agent-bundle/rsc-runtime", "version": "0.0.0", - "description": "React Server Component primitives for Agent Bundle hook and MCP results.", + "description": "React-element result primitives (an MCP result DSL) for Agent Bundle hook and MCP protocol results.", "license": "MIT", "keywords": [ "agent-bundle", diff --git a/packages/rsc-runtime/tests/docs-contract.test.ts b/packages/rsc-runtime/tests/docs-contract.test.ts new file mode 100644 index 000000000..6c6a1ff12 --- /dev/null +++ b/packages/rsc-runtime/tests/docs-contract.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; +import { z } from 'zod'; + +import { Mcp, defineOperation, defineRscApplication, lowerMcpResult, runRscCli } from '../src/index.js'; + +/** + * The `status` operation printed in `docs/framework-mode.md` and this + * package's README, with a render counter so the "only MCP consumes + * `render`" claim is observable. + */ +const documentedStatus = (onRender: () => void) => defineOperation({ + cli: { + name: 'status', + parse: (args) => (args.includes('--verbose') ? { verbose: true } : {}), + summary: 'Read runtime status.', + usage: 'status [--verbose]', + }, + execute: async () => ({ status: 'ready' as const }), + id: 'status', + inputSchema: z.object({ verbose: z.boolean().optional() }).strict(), + mcp: { + description: 'Read runtime status.', + name: 'runtime_status', + readOnly: true, + server: 'runtime', + }, + render: (result) => { + onRender(); + return createElement( + Mcp.Result, + { structuredContent: result }, + createElement(Mcp.Text, null, `Runtime is ${result.status}.`), + ); + }, + resultSchema: z.object({ status: z.literal('ready') }).strict(), +}); + +describe('documented operation model', () => { + it('serves both projections from one shared core and renders only for MCP', async () => { + let renders = 0; + const status = documentedStatus(() => { + renders += 1; + }); + const application = defineRscApplication({ + name: 'runtime', + operations: [status], + version: '0.1.0', + }); + const output: string[] = []; + + await expect(runRscCli(application, ['status'], { write: (value) => output.push(value) })).resolves.toBe(0); + // The CLI projection prints one line of JSON and never touches `render`. + expect(output.join('')).toBe('{"status":"ready"}\n'); + expect(renders).toBe(0); + + const result = await status.execute({}, { signal: new AbortController().signal }); + expect(lowerMcpResult(status.render(result))).toEqual({ + content: [{ text: 'Runtime is ready.', type: 'text' }], + structuredContent: { status: 'ready' }, + }); + expect(renders).toBe(1); + }); + + it('rejects the multi-child `Mcp.Text` the documented template literal avoids', () => { + expect(() => lowerMcpResult(createElement( + Mcp.Result, + null, + createElement(Mcp.Text, null, 'Runtime is ', 'ready', '.'), + ))).toThrow('mcp-text requires one text child'); + }); +});