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
6 changes: 5 additions & 1 deletion .version-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,5 +369,9 @@
"tsconfig.json",
"vitest.config.ts"
],
"release_paths": ["templates/lit-*/**", "THIRD_PARTY_NOTICES.md"]
"release_paths": [
"templates/lit-*/**",
"templates/react-*/**",
"THIRD_PARTY_NOTICES.md"
]
}
16 changes: 13 additions & 3 deletions apps/lit-host/e2e/starter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import type {
} from '../../../templates/lit-node/server/listener.js';
import type { HostedResource } from './host-fixture/host.js';

// Each qualified `lit-mcp` starter (templates/lit-node on Node, templates/lit-express on Express, templates/lit-fastify on Fastify, templates/lit-hono on Hono,
// see scripts/qualified-starters.ts), in a real browser. The page under test is the one the
// Each qualified starter (the `lit-mcp` templates on Node, Express, Fastify, Hono and Hapi, and
// templates/react-hono, the `react-mcp` stack on Hono; see scripts/qualified-starters.ts), in a real browser. The page under test is the one the
// starter's own build produced from packed SDK tarballs (scripts/build-starter.ts)
// and the one its server serves over a real MCP connection. It is mounted in the sandboxed
// iframe of the local host fixture under the CSP the server declared, and every result reaches it
Expand All @@ -27,6 +27,13 @@ interface Entry {
readonly ACCOUNT_SCOPE: string;
}

/**
* The element a template's view renders into, which is what an ended view must leave empty:
* `<starter-app>` for a Lit template, `#root` for a React template.
*/
const containerOf = (template: string): string =>
template.startsWith('react-') ? '#root' : 'starter-app';

for (const template of QUALIFIED_STARTERS) {
test.describe(template, () => {
const starterDist = new URL(
Expand Down Expand Up @@ -307,7 +314,10 @@ for (const template of QUALIFIED_STARTERS) {
await expect(view.getByTestId('view-status'), ending).toHaveText(
CLOSED_TEXT,
);
await expect(view.locator('starter-app > *'), ending).toHaveCount(0);
await expect(
view.locator(`${containerOf(template)} > *`),
ending,
).toHaveCount(0);

await retained?.evaluate((button) => (button as HTMLElement).click());
await page.waitForTimeout(150);
Expand Down
2 changes: 1 addition & 1 deletion apps/lit-host/e2e/tour.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ test.describe('reading order, limits and links', () => {
'not renderer parity with Lit',
);
await expect(page.getByTestId('renderer-evidence')).toContainText(
'no React starter, template or CLI pair',
'not the React starter',
);
expect(await page.locator('body').innerText()).not.toMatch(/lynx/i);
});
Expand Down
7 changes: 4 additions & 3 deletions demo/tour/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ the example. It does not make a simulated answer evidence that any server enforc
- The tour plays no media, reads no Patreon data and offers no protected content. Episode
descriptions and Play need the demonstration's server and are not offered.
- Renderer previews: **Lit** is the full renderer. **React** has bounded evidence (direct action
attachment and the notes application in a local fixture host); it is not renderer parity and there
is no React starter, template or CLI pair. Lynx delivery was retired and is not offered. A renderer
joins the list in `src/scenarios.ts` when its preview works.
attachment and the notes application in a local fixture host); it is not renderer parity, and it is
not the React starter (`saga init --a2ui-stack react-mcp --server hono`), which is a separate
pair. Lynx delivery was retired and is not offered. A renderer joins the list in
`src/scenarios.ts` when its preview works.
- The tour is built from packed candidate tarballs of this source, not from registry packages. Nothing
here says the stack packages are on a public registry.

Expand Down
2 changes: 1 addition & 1 deletion demo/tour/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ <h2 id="limits-heading">What this tour does not show</h2>
<ul data-testid="tour-limits">
<li>No real authentication, OAuth flow or identity provider, and no run in ChatGPT, Claude or Alexa+. The host here is a local stand-in, not an assistant host.</li>
<li>No protected media, no Patreon access and no playback. This tour plays nothing.</li>
<li>React has bounded evidence: direct action attachment and the notes application in a local fixture host. It is not renderer parity, and there is no React starter, template or CLI pair.</li>
<li>React has bounded evidence: direct action attachment and the notes application in a local fixture host. It is not renderer parity, and it is not the React starter, which is a separate Hono pair.</li>
<li>The SDK is an unpublished candidate. The packages are private and this page is built from locally packed candidate tarballs, not from a registry.</li>
</ul>
</section>
Expand Down
2 changes: 1 addition & 1 deletion demo/tour/src/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const RENDERERS: readonly RendererChoice[] = [
id: 'react',
label: 'React (bounded evidence)',
evidence:
'The same actions and server contract through a React consumer of @saga-a2ui/react in a local fixture host. There is no React starter, template or CLI pair, and this preview is not renderer parity with Lit.',
'The same actions and server contract through a React consumer of @saga-a2ui/react in a local fixture host. It is not the React starter, which is a separate Hono pair, and this preview is not renderer parity with Lit.',
},
];

Expand Down
2 changes: 1 addition & 1 deletion diagrams/saga-setup-paths.excalidraw

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion diagrams/saga-setup-paths.mmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
graph TD
App["Build an MCP application UI"] --> Existing["Existing Lit or React application"]
App --> Starter["Lit starter or local demo"]
App --> Starter["Lit or React starter, or Lit demo"]
Existing --> Registry["npm: chosen stack 0.7.7-rc.1 + framework peers"]
Starter --> Checkout["Source checkout: build matched candidates"]
Registry --> Meaning["Your catalog, data and actions"]
Expand Down
4 changes: 2 additions & 2 deletions diagrams/saga-setup-paths.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion diagrams/saga-setup-paths.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ Claim only what the evidence supports. The runnable tutorial stays on the qualif
| | Shown | Not shown |
| --- | --- | --- |
| **Lit** | Action controls and typed surfaces: `ActionButton` and `createLitActions` from `@saga-a2ui/lit/actions`; the notes application and the starter run in it, installed from packed tarballs | A Windows run; this tutorial itself makes no actual-host claim |
| **React** | Action controls and typed surfaces: `ActionButton`, `createReactActions` (over `InstanceHostProvider` and `useActionSetup`) from `@saga-a2ui/react/actions`, and surfaces from `@saga-a2ui/react/a2ui`. The notes application runs through a React consumer in a local fixture host | Generated tooling: no React starter, template, CLI pair or authenticated recipe. The application integration itself is implemented |
| **React** | Action controls and typed surfaces: `ActionButton`, `createReactActions` (over `InstanceHostProvider` and `useActionSetup`) from `@saga-a2ui/react/actions`, and surfaces from `@saga-a2ui/react/a2ui`. The notes application runs through a React consumer in a local fixture host. Generated tooling: one React starter, `react-mcp` on Hono, and its generated recipe, qualified from packed tarballs | No other React pair, no authenticated React recipe and no actual-host run of the React starter. The application integration itself is implemented |
| **Lynx** | Nothing current. Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are history | Any Lynx setup |

On React the binding is the same definition and the same `bindAction`; only how the setup runs differs. The notes React consumer's setup is the application's own function, given to `createReactActions` from `@saga-a2ui/react-mcp-stack`. The renderer package owns that implementation and the stack re-exports it. The React stack (`@saga-a2ui/react-mcp-stack`) renders the surface inside the scope that call returns, which owns the provider, runs the setup and ends it all when the view ends: unmounting that root disposes every instance and binding beneath it. A setup that throws is reported to `onSetupError`, never left to blank the view.
Expand Down
123 changes: 123 additions & 0 deletions docs/attach-to-existing-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Attaching SAGA to a backend you already run is an integration you make in your o
| `lit-mcp` | `fastify` | [`lit-fastify`](../templates/lit-fastify/) | offered |
| `lit-mcp` | `hono` | [`lit-hono`](../templates/lit-hono/) | offered |
| `lit-mcp` | `hapi` | [`lit-hapi`](../templates/lit-hapi/) | offered |
| `react-mcp` | `hono` | [`react-hono`](../templates/react-hono/) | offered |
<!-- /pairs -->

A pair is listed only while CI qualifies it, and a qualified pair is not automatically offered by `saga init`: that command offers the pairs in its own list. A recipe is not a claim about any other pair, and no full matrix is claimed.
Expand Down Expand Up @@ -639,3 +640,125 @@ attach: (surfaceView) => {
},
```
<!-- /recipe:lit-hapi -->

<!-- recipe:react-hono -->
## `hono` with `react-mcp`

Template: [`templates/react-hono`](../templates/react-hono/). Server adapter: `@modelcontextprotocol/hono` 2.0.2. SDK packages: `@saga-a2ui/core`, `@saga-a2ui/react`, `@saga-a2ui/components`, `@saga-a2ui/mcp`, `@saga-a2ui/react-mcp-stack`.

The snippets below call the starter's own application files, which you read in the template (or in a project `saga init` created) and adapt: `server/authenticate.ts`, `server/authorization.ts`, `server/endpoint.ts`, `server/identity.ts`, `server/verifier.ts`. `server/verifier.ts` and `server/authorization.ts` are where your identity provider and your policy go; the starter ships neither.

### Prepare the view and endpoint handlers once

From `templates/react-hono/server/listener.ts`:

```ts
const view = prepareStarterView(options.html, options.filename);
const status = createEndpoint(
(lifetime) => createStatusServer({ view, lifetime }),
{ maxRequestBodySize: MAX_REQUEST_BYTES },
);
const account = createEndpoint(
(lifetime) =>
createAccountServer({ view, lifetime, authorize: options.authorize }),
{ maxRequestBodySize: MAX_REQUEST_BYTES },
);
```

### Hand the verified identity to the transport

From `templates/react-hono/server/listener.ts`:

```ts
// Who is asking is decided before any server is built. The verified context travels to the tool
// only as the transport's per-request auth; no tool argument can name an account.
const decision = await authenticate(
c.req.header('authorization'),
options.verifier,
lifetime,
);
if (!decision.ok)
return new Response(null, {
status: decision.status,
headers:
decision.challenge === undefined
? {}
: { 'WWW-Authenticate': decision.challenge },
});
return serve(c, account, decision.authInfo);
```

### Register a protected operation

From `templates/react-hono/server/operations.ts`:

```ts
/**
* A protected operation. The listener verified the request and the transport hands that context
* to `identity`; with none the handler never runs. `handle` makes the application's own
* authorization decision for this request's identity, then returns a record, and `project` copies
* the public fields out of it by name.
*/
export function createAccountServer({
view,
lifetime,
authorize,
}: AccountServerOptions): McpServer {
const server = new McpServer({
name: 'saga-starter-account',
version: '0.0.0',
});
registerProtectedOperation(
server,
{
name: ACCOUNT_TOOL,
title: 'Show my account',
description: 'Shows the account the server verified for this request.',
annotations: READ_ONLY,
input: z.strictObject({}),
output: AccountOutputSchema,
view,
failures: {
absent: TEXT.noAccount,
unavailable: TEXT.unavailable,
invalidOutput: TEXT.invalidOutput,
signInRequired: TEXT.signInRequired,
},
identity: identityOf,
async handle(_input, identity, request) {
// Replace this with a call to your backend. The decision to answer is made here, for this request.
if (
!(await request.within('authorization', (signal) =>
authorize(identity, signal),
))
)
return { kind: 'refused', text: TEXT.notAllowed };
return { kind: 'record', record: identity };
},
project: (record) => ({
text: 'Signed in.',
data: { accountId: record.accountId },
}),
},
{ lifetime },
);
return server;
}
```

### Attach an action to a rendered control

From `templates/react-hono/src/view.tsx`:

```tsx
// Runs once for each presented surface. The same definition could be attached to any number
// of controls; here it is attached directly to the one rendered Clear control.
attach: (surfaceView) => {
surfaceView.watchById(
CLEAR_ID,
ActionButton,
(handle) => void handle.bindAction('activate', actions.clearNote),
);
},
```
<!-- /recipe:react-hono -->
Loading
Loading