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
15 changes: 15 additions & 0 deletions .changeset/clarify-rsc-runtime-not-rsc.md
Original file line number Diff line number Diff line change
@@ -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.
97 changes: 97 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Mcp.Result structuredContent={result}>
<Mcp.Text>{`Runtime is ${result.status}.`}</Mcp.Text>
</Mcp.Result>
),
});
```

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
Expand Down
30 changes: 30 additions & 0 deletions examples/audiobook-curator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<CuratorResult>` in
[`src/result.tsx`](src/result.tsx), which wraps the receipt in the MCP
result DSL:

```tsx
export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) => (
<Mcp.Result structuredContent={receipt}>
<Mcp.Text>{summary(receipt)}</Mcp.Text>
</Mcp.Result>
);
```

`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
Expand Down
4 changes: 3 additions & 1 deletion examples/audiobook-curator/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<CuratorResult>` in `./result.tsx` for the MCP projection.
*/
import { defineRscApplication } from '@agent-bundle/rsc-runtime/plugin';

Expand Down
8 changes: 8 additions & 0 deletions examples/audiobook-curator/src/result.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/**
* The one place runtime JSX lives: every operation's `render` wraps its
* receipt in `<CuratorResult>`, 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';

Expand Down
21 changes: 21 additions & 0 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
72 changes: 72 additions & 0 deletions packages/rsc-runtime/tests/docs-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading