diff --git a/.version-policy.json b/.version-policy.json index 79015f08..b33bdc40 100644 --- a/.version-policy.json +++ b/.version-policy.json @@ -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" + ] } diff --git a/apps/lit-host/e2e/starter.spec.ts b/apps/lit-host/e2e/starter.spec.ts index f5f95cf6..33d462d6 100644 --- a/apps/lit-host/e2e/starter.spec.ts +++ b/apps/lit-host/e2e/starter.spec.ts @@ -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 @@ -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: + * `` 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( @@ -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); diff --git a/apps/lit-host/e2e/tour.spec.ts b/apps/lit-host/e2e/tour.spec.ts index 17792eea..7809669a 100644 --- a/apps/lit-host/e2e/tour.spec.ts +++ b/apps/lit-host/e2e/tour.spec.ts @@ -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); }); diff --git a/demo/tour/README.md b/demo/tour/README.md index 464414c0..22d409ef 100644 --- a/demo/tour/README.md +++ b/demo/tour/README.md @@ -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. diff --git a/demo/tour/index.html b/demo/tour/index.html index fd8e8274..6a9d381d 100644 --- a/demo/tour/index.html +++ b/demo/tour/index.html @@ -177,7 +177,7 @@

What this tour does not show

diff --git a/demo/tour/src/scenarios.ts b/demo/tour/src/scenarios.ts index c04cf076..0f80bb83 100644 --- a/demo/tour/src/scenarios.ts +++ b/demo/tour/src/scenarios.ts @@ -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.', }, ]; diff --git a/diagrams/saga-setup-paths.excalidraw b/diagrams/saga-setup-paths.excalidraw index e4edc3b4..2e79f8ab 100644 --- a/diagrams/saga-setup-paths.excalidraw +++ b/diagrams/saga-setup-paths.excalidraw @@ -1 +1 @@ -{"type":"excalidraw","version":2,"source":"gstack-diagram-render","elements":[{"id":"o5V4xY1hScU8WdyBCo0fQ","type":"rectangle","x":163,"y":8,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,"seed":1599874483,"version":4,"versionNonce":1344979133,"isDeleted":false,"boundElements":[{"type":"text","id":"qY7cOt18eTdFNdkrPb0OO"},{"id":"B0ihV3kEZROi65VqyhbQm","type":"arrow"},{"id":"OHqKSkinOb1BcBKAnBGl7","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"2bKbvt4yRDDbrICEZ9OV-","type":"rectangle","x":8,"y":148,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":null,"seed":1881703165,"version":4,"versionNonce":1429566845,"isDeleted":false,"boundElements":[{"type":"text","id":"VQxck1Y8dO1O5CxcgVcwd"},{"id":"B0ihV3kEZROi65VqyhbQm","type":"arrow"},{"id":"r-JEpkY_ImYYe6W8vYtuo","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"Ytpydx8RXcziFzsfeAiQU","type":"rectangle","x":318,"y":148,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":null,"seed":1725101907,"version":4,"versionNonce":153745981,"isDeleted":false,"boundElements":[{"type":"text","id":"1fhWk3q25YsElbAK2AFfD"},{"id":"OHqKSkinOb1BcBKAnBGl7","type":"arrow"},{"id":"QrUhtFnAI5eySNFUT6iQB","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"QP6fHql6aQ_tz3M2U-VhO","type":"rectangle","x":8,"y":288,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a3","roundness":null,"seed":1568217949,"version":4,"versionNonce":2010402557,"isDeleted":false,"boundElements":[{"type":"text","id":"ShgdfFizmfTMCyoTZLUeT"},{"id":"r-JEpkY_ImYYe6W8vYtuo","type":"arrow"},{"id":"xOXPve5Nce8i6heOEan9B","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"VIs3okZ4m13rWnMk3xn8u","type":"rectangle","x":318,"y":288,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a4","roundness":null,"seed":650024179,"version":4,"versionNonce":704672701,"isDeleted":false,"boundElements":[{"type":"text","id":"VaEcK-sOl7XqxqB1-7nsJ"},{"id":"QrUhtFnAI5eySNFUT6iQB","type":"arrow"},{"id":"y-iwFGySlrXhKPHGeRank","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"4zf6oUdOi9lJQ0pHhbc4f","type":"rectangle","x":163,"y":458,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a5","roundness":null,"seed":433563581,"version":5,"versionNonce":1702520957,"isDeleted":false,"boundElements":[{"type":"text","id":"eC9PiUu2yhv0WnsIBVkxL"},{"id":"xOXPve5Nce8i6heOEan9B","type":"arrow"},{"id":"y-iwFGySlrXhKPHGeRank","type":"arrow"},{"id":"-ua2QTQj11gGyETCE1MCn","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"DWFUSHdm_4f6c5qovD62W","type":"rectangle","x":163,"y":598,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a6","roundness":null,"seed":1319468691,"version":4,"versionNonce":1829645629,"isDeleted":false,"boundElements":[{"type":"text","id":"Izh6_ojrcCgPuJEFOoFwi"},{"id":"-ua2QTQj11gGyETCE1MCn","type":"arrow"},{"id":"WP6PY6fUEAjMNfn8NXkEP","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"r29fqYKpfiEyCVKTeHGqC","type":"rectangle","x":163,"y":768,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a7","roundness":null,"seed":2003150877,"version":3,"versionNonce":2091875741,"isDeleted":false,"boundElements":[{"type":"text","id":"_gfz_1MAS_8sZFN47mX5C"},{"id":"WP6PY6fUEAjMNfn8NXkEP","type":"arrow"}],"updated":1793041129249,"link":null,"locked":false},{"id":"B0ihV3kEZROi65VqyhbQm","type":"arrow","x":193.357,"y":98,"width":55.357,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a8","roundness":{"type":2},"seed":1497248819,"version":4,"versionNonce":354135635,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0,0.5],[-55.357,25],[-55.357,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"o5V4xY1hScU8WdyBCo0fQ","focus":0.0000017418370979026966,"gap":1},"endBinding":{"elementId":"2bKbvt4yRDDbrICEZ9OV-","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"OHqKSkinOb1BcBKAnBGl7","type":"arrow","x":392.643,"y":98,"width":55.35700000000003,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a9","roundness":{"type":2},"seed":135568509,"version":4,"versionNonce":762082707,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0,0.5],[55.35700000000003,25],[55.35700000000003,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"o5V4xY1hScU8WdyBCo0fQ","focus":-0.000001741837098225585,"gap":1},"endBinding":{"elementId":"Ytpydx8RXcziFzsfeAiQU","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"r-JEpkY_ImYYe6W8vYtuo","type":"arrow","x":138,"y":238,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aA","roundness":{"type":2},"seed":644822483,"version":4,"versionNonce":1529113811,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"2bKbvt4yRDDbrICEZ9OV-","focus":0,"gap":1},"endBinding":{"elementId":"QP6fHql6aQ_tz3M2U-VhO","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"QrUhtFnAI5eySNFUT6iQB","type":"arrow","x":448,"y":238,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aB","roundness":{"type":2},"seed":598082781,"version":4,"versionNonce":1916245011,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"Ytpydx8RXcziFzsfeAiQU","focus":0,"gap":1},"endBinding":{"elementId":"VIs3okZ4m13rWnMk3xn8u","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"xOXPve5Nce8i6heOEan9B","type":"arrow","x":138,"y":408,"width":51.71199999999999,"height":48.353999999999985,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aC","roundness":{"type":2},"seed":1431241587,"version":4,"versionNonce":1503259475,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0.5,0.5],[0,25],[51.21199999999999,47.853999999999985]],"lastCommittedPoint":null,"startBinding":{"elementId":"QP6fHql6aQ_tz3M2U-VhO","focus":0,"gap":1},"endBinding":{"elementId":"4zf6oUdOi9lJQ0pHhbc4f","focus":-0.000005593821437364755,"gap":1.646000000000015},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"y-iwFGySlrXhKPHGeRank","type":"arrow","x":448,"y":408,"width":51.71199999999999,"height":48.353999999999985,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aD","roundness":{"type":2},"seed":766206269,"version":4,"versionNonce":1262449299,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[-0.5,0.5],[0,25],[-51.21199999999999,47.853999999999985]],"lastCommittedPoint":null,"startBinding":{"elementId":"VIs3okZ4m13rWnMk3xn8u","focus":0,"gap":1},"endBinding":{"elementId":"4zf6oUdOi9lJQ0pHhbc4f","focus":0.000005593821437364755,"gap":1.646000000000015},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"-ua2QTQj11gGyETCE1MCn","type":"arrow","x":293,"y":548,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aE","roundness":{"type":2},"seed":1579425043,"version":4,"versionNonce":337390035,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"4zf6oUdOi9lJQ0pHhbc4f","focus":0,"gap":1},"endBinding":{"elementId":"DWFUSHdm_4f6c5qovD62W","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"WP6PY6fUEAjMNfn8NXkEP","type":"arrow","x":293,"y":718,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aF","roundness":{"type":2},"seed":461779357,"version":4,"versionNonce":1411649811,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"DWFUSHdm_4f6c5qovD62W","focus":0,"gap":1},"endBinding":{"elementId":"r29fqYKpfiEyCVKTeHGqC","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"qY7cOt18eTdFNdkrPb0OO","type":"text","x":175.2314453125,"y":40.5,"width":235.537109375,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aG","roundness":null,"seed":1828773811,"version":3,"versionNonce":105281203,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Build an MCP application UI","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"o5V4xY1hScU8WdyBCo0fQ","originalText":"Build an MCP application UI","autoResize":true,"lineHeight":1.25},{"id":"VQxck1Y8dO1O5CxcgVcwd","type":"text","x":54.40625,"y":168,"width":167.1875,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aH","roundness":null,"seed":332427603,"version":3,"versionNonce":1082638845,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Existing Lit or React\napplication","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"2bKbvt4yRDDbrICEZ9OV-","originalText":"Existing Lit or React application","autoResize":true,"lineHeight":1.25},{"id":"1fhWk3q25YsElbAK2AFfD","type":"text","x":351.369140625,"y":180.5,"width":193.26171875,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aI","roundness":null,"seed":167562995,"version":3,"versionNonce":1045112915,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Lit starter or local demo","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"Ytpydx8RXcziFzsfeAiQU","originalText":"Lit starter or local demo","autoResize":true,"lineHeight":1.25},{"id":"ShgdfFizmfTMCyoTZLUeT","type":"text","x":14.8896484375,"y":323,"width":246.220703125,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aJ","roundness":null,"seed":1644984467,"version":3,"versionNonce":2107144797,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"npm: chosen stack 0.7.7-rc.1 +\nframework peers","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"QP6fHql6aQ_tz3M2U-VhO","originalText":"npm: chosen stack 0.7.7-rc.1 + framework peers","autoResize":true,"lineHeight":1.25},{"id":"VaEcK-sOl7XqxqB1-7nsJ","type":"text","x":355.802734375,"y":323,"width":184.39453125,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aK","roundness":null,"seed":431353395,"version":3,"versionNonce":570992115,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Source checkout: build\nmatched candidates","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"VIs3okZ4m13rWnMk3xn8u","originalText":"Source checkout: build matched candidates","autoResize":true,"lineHeight":1.25},{"id":"eC9PiUu2yhv0WnsIBVkxL","type":"text","x":172.6533203125,"y":490.5,"width":240.693359375,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aL","roundness":null,"seed":870297555,"version":3,"versionNonce":1555244733,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Your catalog, data and actions","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"4zf6oUdOi9lJQ0pHhbc4f","originalText":"Your catalog, data and actions","autoResize":true,"lineHeight":1.25},{"id":"Izh6_ojrcCgPuJEFOoFwi","type":"text","x":172.638671875,"y":633,"width":240.72265625,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aM","roundness":null,"seed":312491379,"version":3,"versionNonce":1485139859,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Your backend verifies identity\nand authorizes","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"DWFUSHdm_4f6c5qovD62W","originalText":"Your backend verifies identity and authorizes","autoResize":true,"lineHeight":1.25},{"id":"_gfz_1MAS_8sZFN47mX5C","type":"text","x":178.5810546875,"y":803,"width":228.837890625,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aN","roundness":null,"seed":289281811,"version":3,"versionNonce":1886996253,"isDeleted":false,"boundElements":null,"updated":1793041129249,"link":null,"locked":false,"text":"Qualify the application in its\ntarget host","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"r29fqYKpfiEyCVKTeHGqC","originalText":"Qualify the application in its target host","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}} \ No newline at end of file +{"type":"excalidraw","version":2,"source":"gstack-diagram-render","elements":[{"id":"S1VKwYbjYEaDT1wGy33Wb","type":"rectangle","x":163,"y":8,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,"seed":231006836,"version":4,"versionNonce":243976140,"isDeleted":false,"boundElements":[{"type":"text","id":"s6SCV_JLAvL8WzHqp_DXx"},{"id":"-dz1XZ2bRdMv8wPwRzwZ0","type":"arrow"},{"id":"LMAzMGJlK0HZdFWPnTiFX","type":"arrow"}],"updated":1793231198785,"link":null,"locked":false},{"id":"6hbdxpkPJHC0YpB1Ou-dv","type":"rectangle","x":8,"y":148,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":null,"seed":1195880140,"version":4,"versionNonce":1576813772,"isDeleted":false,"boundElements":[{"type":"text","id":"ScLS3aEFm8fctuHs1m5Au"},{"id":"-dz1XZ2bRdMv8wPwRzwZ0","type":"arrow"},{"id":"Va1z_GtTCgjoPCckLLT2x","type":"arrow"}],"updated":1793231198785,"link":null,"locked":false},{"id":"gDCd-Yq0LdD9SEcaIqIGK","type":"rectangle","x":318,"y":148,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":null,"seed":1969779700,"version":4,"versionNonce":1391624652,"isDeleted":false,"boundElements":[{"type":"text","id":"HFyiVmQcdSbWsWU7tDzNK"},{"id":"LMAzMGJlK0HZdFWPnTiFX","type":"arrow"},{"id":"IrkJrMhKV5CFjbGR_gYDB","type":"arrow"}],"updated":1793231198786,"link":null,"locked":false},{"id":"FhSr5WNrQm6gSWGylEsWG","type":"rectangle","x":8,"y":288,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a3","roundness":null,"seed":1249899852,"version":4,"versionNonce":1664974540,"isDeleted":false,"boundElements":[{"type":"text","id":"du0fZyScy8-03hZnbKqI-"},{"id":"Va1z_GtTCgjoPCckLLT2x","type":"arrow"},{"id":"fKYAnm5hPEFe6y3y75a5S","type":"arrow"}],"updated":1793231198786,"link":null,"locked":false},{"id":"l9OtnViNjgEfVv-kX3dJH","type":"rectangle","x":318,"y":288,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a4","roundness":null,"seed":362665332,"version":4,"versionNonce":1353530316,"isDeleted":false,"boundElements":[{"type":"text","id":"FqaZC9l0ngI7_y1Qke0GR"},{"id":"IrkJrMhKV5CFjbGR_gYDB","type":"arrow"},{"id":"A2VkiBNaRj-OKBOmwiSXg","type":"arrow"}],"updated":1793231198786,"link":null,"locked":false},{"id":"OtYIu1W3Vc9ymbYCAdQX_","type":"rectangle","x":163,"y":458,"width":260,"height":90,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a5","roundness":null,"seed":2079026124,"version":5,"versionNonce":689027276,"isDeleted":false,"boundElements":[{"type":"text","id":"TdL5sGtKPmHvF41Bw_36b"},{"id":"fKYAnm5hPEFe6y3y75a5S","type":"arrow"},{"id":"A2VkiBNaRj-OKBOmwiSXg","type":"arrow"},{"id":"4WdwhToiwoc898n7hijG3","type":"arrow"}],"updated":1793231198786,"link":null,"locked":false},{"id":"-qqRKmrCR9ZkCqsIlMmhN","type":"rectangle","x":163,"y":598,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a6","roundness":null,"seed":464193268,"version":4,"versionNonce":1178269132,"isDeleted":false,"boundElements":[{"type":"text","id":"uhJQaz0_n66qUuqy2-kWL"},{"id":"4WdwhToiwoc898n7hijG3","type":"arrow"},{"id":"RUpts6wmru6PohBGhzj10","type":"arrow"}],"updated":1793231198786,"link":null,"locked":false},{"id":"qDYfHtjMq5XD9ksQbCcb8","type":"rectangle","x":163,"y":768,"width":260,"height":120,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a7","roundness":null,"seed":228856396,"version":3,"versionNonce":963796044,"isDeleted":false,"boundElements":[{"type":"text","id":"ZulxSQWiLRH3sa7QOpuB6"},{"id":"RUpts6wmru6PohBGhzj10","type":"arrow"}],"updated":1793231198787,"link":null,"locked":false},{"id":"-dz1XZ2bRdMv8wPwRzwZ0","type":"arrow","x":193.357,"y":98,"width":55.357,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a8","roundness":{"type":2},"seed":471206004,"version":4,"versionNonce":688248820,"isDeleted":false,"boundElements":null,"updated":1793231198784,"link":null,"locked":false,"points":[[0,0.5],[-55.357,25],[-55.357,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"S1VKwYbjYEaDT1wGy33Wb","focus":0.0000017418370979026966,"gap":1},"endBinding":{"elementId":"6hbdxpkPJHC0YpB1Ou-dv","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"LMAzMGJlK0HZdFWPnTiFX","type":"arrow","x":392.643,"y":98,"width":55.35700000000003,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a9","roundness":{"type":2},"seed":1585703116,"version":4,"versionNonce":171928308,"isDeleted":false,"boundElements":null,"updated":1793231198785,"link":null,"locked":false,"points":[[0,0.5],[55.35700000000003,25],[55.35700000000003,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"S1VKwYbjYEaDT1wGy33Wb","focus":-0.000001741837098225585,"gap":1},"endBinding":{"elementId":"gDCd-Yq0LdD9SEcaIqIGK","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"Va1z_GtTCgjoPCckLLT2x","type":"arrow","x":138,"y":238,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aA","roundness":{"type":2},"seed":715446772,"version":4,"versionNonce":1114652148,"isDeleted":false,"boundElements":null,"updated":1793231198786,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"6hbdxpkPJHC0YpB1Ou-dv","focus":0,"gap":1},"endBinding":{"elementId":"FhSr5WNrQm6gSWGylEsWG","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"IrkJrMhKV5CFjbGR_gYDB","type":"arrow","x":448,"y":238,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aB","roundness":{"type":2},"seed":1646587724,"version":4,"versionNonce":1825067252,"isDeleted":false,"boundElements":null,"updated":1793231198786,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"gDCd-Yq0LdD9SEcaIqIGK","focus":0,"gap":1},"endBinding":{"elementId":"l9OtnViNjgEfVv-kX3dJH","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"fKYAnm5hPEFe6y3y75a5S","type":"arrow","x":138,"y":408,"width":51.71199999999999,"height":48.353999999999985,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aC","roundness":{"type":2},"seed":1918729076,"version":4,"versionNonce":410493940,"isDeleted":false,"boundElements":null,"updated":1793231198786,"link":null,"locked":false,"points":[[0.5,0.5],[0,25],[51.21199999999999,47.853999999999985]],"lastCommittedPoint":null,"startBinding":{"elementId":"FhSr5WNrQm6gSWGylEsWG","focus":0,"gap":1},"endBinding":{"elementId":"OtYIu1W3Vc9ymbYCAdQX_","focus":-0.000005593821437364755,"gap":1.646000000000015},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"A2VkiBNaRj-OKBOmwiSXg","type":"arrow","x":448,"y":408,"width":51.71199999999999,"height":48.353999999999985,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aD","roundness":{"type":2},"seed":148973004,"version":4,"versionNonce":1219376884,"isDeleted":false,"boundElements":null,"updated":1793231198786,"link":null,"locked":false,"points":[[-0.5,0.5],[0,25],[-51.21199999999999,47.853999999999985]],"lastCommittedPoint":null,"startBinding":{"elementId":"l9OtnViNjgEfVv-kX3dJH","focus":0,"gap":1},"endBinding":{"elementId":"OtYIu1W3Vc9ymbYCAdQX_","focus":0.000005593821437364755,"gap":1.646000000000015},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"4WdwhToiwoc898n7hijG3","type":"arrow","x":293,"y":548,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aE","roundness":{"type":2},"seed":1300622580,"version":4,"versionNonce":1956383220,"isDeleted":false,"boundElements":null,"updated":1793231198786,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"OtYIu1W3Vc9ymbYCAdQX_","focus":0,"gap":1},"endBinding":{"elementId":"-qqRKmrCR9ZkCqsIlMmhN","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"RUpts6wmru6PohBGhzj10","type":"arrow","x":293,"y":718,"width":0,"height":46,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aF","roundness":{"type":2},"seed":668109900,"version":4,"versionNonce":124853492,"isDeleted":false,"boundElements":null,"updated":1793231198787,"link":null,"locked":false,"points":[[0,0.5],[0,25],[0,45.5]],"lastCommittedPoint":null,"startBinding":{"elementId":"-qqRKmrCR9ZkCqsIlMmhN","focus":0,"gap":1},"endBinding":{"elementId":"qDYfHtjMq5XD9ksQbCcb8","focus":0,"gap":4},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"s6SCV_JLAvL8WzHqp_DXx","type":"text","x":169.09375,"y":28,"width":247.8125,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aG","roundness":null,"seed":544701044,"version":3,"versionNonce":313089652,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Build an MCP application\nUI","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"S1VKwYbjYEaDT1wGy33Wb","originalText":"Build an MCP application UI","autoResize":true,"lineHeight":1.25},{"id":"ScLS3aEFm8fctuHs1m5Au","type":"text","x":38.15625,"y":168,"width":199.6875,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aH","roundness":null,"seed":1174932468,"version":3,"versionNonce":1308160716,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Existing Lit or React\napplication","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"6hbdxpkPJHC0YpB1Ou-dv","originalText":"Existing Lit or React application","autoResize":true,"lineHeight":1.25},{"id":"HFyiVmQcdSbWsWU7tDzNK","type":"text","x":337.6240234375,"y":168,"width":220.751953125,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aI","roundness":null,"seed":1586130292,"version":3,"versionNonce":1616736244,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Lit or React starter, or\nLit demo","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"gDCd-Yq0LdD9SEcaIqIGK","originalText":"Lit or React starter, or Lit demo","autoResize":true,"lineHeight":1.25},{"id":"du0fZyScy8-03hZnbKqI-","type":"text","x":21.427734375,"y":310.5,"width":233.14453125,"height":75,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aJ","roundness":null,"seed":1262788340,"version":3,"versionNonce":1919465804,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"npm: chosen stack\n0.7.7-rc.1 + framework\npeers","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"FhSr5WNrQm6gSWGylEsWG","originalText":"npm: chosen stack 0.7.7-rc.1 + framework peers","autoResize":true,"lineHeight":1.25},{"id":"FqaZC9l0ngI7_y1Qke0GR","type":"text","x":334.0400390625,"y":323,"width":227.919921875,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aK","roundness":null,"seed":1421647988,"version":3,"versionNonce":1351831924,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Source checkout: build\nmatched candidates","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"l9OtnViNjgEfVv-kX3dJH","originalText":"Source checkout: build matched candidates","autoResize":true,"lineHeight":1.25},{"id":"TdL5sGtKPmHvF41Bw_36b","type":"text","x":180.6806640625,"y":478,"width":224.638671875,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aL","roundness":null,"seed":1119384052,"version":3,"versionNonce":840675276,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Your catalog, data and\nactions","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"OtYIu1W3Vc9ymbYCAdQX_","originalText":"Your catalog, data and actions","autoResize":true,"lineHeight":1.25},{"id":"uhJQaz0_n66qUuqy2-kWL","type":"text","x":177.3310546875,"y":633,"width":231.337890625,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aM","roundness":null,"seed":1950225268,"version":3,"versionNonce":1385235188,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Your backend verifies\nidentity and authorizes","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"-qqRKmrCR9ZkCqsIlMmhN","originalText":"Your backend verifies identity and authorizes","autoResize":true,"lineHeight":1.25},{"id":"ZulxSQWiLRH3sa7QOpuB6","type":"text","x":179.9140625,"y":803,"width":226.171875,"height":50,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"aN","roundness":null,"seed":2006156532,"version":3,"versionNonce":489412172,"isDeleted":false,"boundElements":null,"updated":1793231198789,"link":null,"locked":false,"text":"Qualify the application\nin its target host","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"qDYfHtjMq5XD9ksQbCcb8","originalText":"Qualify the application in its target host","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}} \ No newline at end of file diff --git a/diagrams/saga-setup-paths.mmd b/diagrams/saga-setup-paths.mmd index aac33d54..b7cd009e 100644 --- a/diagrams/saga-setup-paths.mmd +++ b/diagrams/saga-setup-paths.mmd @@ -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"] diff --git a/diagrams/saga-setup-paths.png b/diagrams/saga-setup-paths.png index 2c491f9d..dcff91d7 100644 --- a/diagrams/saga-setup-paths.png +++ b/diagrams/saga-setup-paths.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f08e8176dd9e3553c79f1068d8bd3d8078612c3cd13dbab434ff6971dd82825 -size 301370 +oid sha256:207aefdade2d778e2b152b2282a6f466b4dbf6cffc95761e8b930f6e329910fe +size 286961 diff --git a/diagrams/saga-setup-paths.svg b/diagrams/saga-setup-paths.svg index 571e53a1..6712048e 100644 --- a/diagrams/saga-setup-paths.svg +++ b/diagrams/saga-setup-paths.svg @@ -1 +1 @@ -Build an MCP application UIExisting Lit or ReactapplicationLit starter or local demonpm: chosen stack0.7.7-rc.1 + frameworkpeersSource checkout: buildmatched candidatesYour catalog, data andactionsYour backend verifiesidentity and authorizesQualify the application in itstarget host \ No newline at end of file +Build an MCP applicationUIExisting Lit or ReactapplicationLit or React starter, orLit demonpm: chosen stack0.7.7-rc.1 + frameworkpeersSource checkout: buildmatched candidatesYour catalog, data andactionsYour backend verifiesidentity and authorizesQualify the applicationin its target host \ No newline at end of file diff --git a/docs/actions.md b/docs/actions.md index 7f8961f4..b70d4687 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -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. diff --git a/docs/attach-to-existing-backend.md b/docs/attach-to-existing-backend.md index fdc4e48e..4551f012 100644 --- a/docs/attach-to-existing-backend.md +++ b/docs/attach-to-existing-backend.md @@ -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 | 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. @@ -639,3 +640,125 @@ attach: (surfaceView) => { }, ``` + + +## `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), + ); +}, +``` + diff --git a/docs/development-reference.md b/docs/development-reference.md index 21b94fcb..66ba0f2c 100644 --- a/docs/development-reference.md +++ b/docs/development-reference.md @@ -1507,6 +1507,20 @@ Mutations of the source that the checks catch (each reverted): returning the ver **Limits, kept explicit.** This qualifies one more pair, not the matrix: `hapi` ([#65](https://github.com/MachineWisdomAI/saga-a2ui/issues/65)) keeps its own template and qualification, and `express` ([#62](https://github.com/MachineWisdomAI/saga-a2ui/issues/62)) and `fastify` ([#64](https://github.com/MachineWisdomAI/saga-a2ui/issues/64)) are qualified in their own sections. The pair is not registered with the supported-pair list the CLI ([#26](https://github.com/MachineWisdomAI/saga-a2ui/issues/26)) owns, so `saga init` does not offer it (qualification does not register a pair); the [generated existing-backend recipes](../docs/attach-to-existing-backend.md) (GUIDE1, [#25](https://github.com/MachineWisdomAI/saga-a2ui/issues/25)) show its snippets from the template, and the pair is recorded here and in `templates/lit-hono/template.json`. The listener is a loopback process, not a deployment (TLS, a public host name in `ALLOWED_HOSTNAMES`, the real identity provider and backend are the application's). No actual host, OAuth flow, published package, registry install or macOS/Windows run is involved; the local host fixture is not an assistant host. Environment: Linux x64, Node 26.7.0, npm 12.0.2, Playwright Chromium revision 1248 (CI pins Node 22.12.0). +### The clean React and Hono starter (STARTER-REACT-HONO) + +`templates/react-hono` is the first maintained template for the `react-mcp` stack, and `saga init --a2ui-stack react-mcp --server hono DIR` creates it. It is the `lit-hono` application with a React view, and it changes no SDK package or runtime export. [`templates/README.md`](../templates/README.md) lists every template; the starter's own [README](../templates/react-hono/README.md) is what a developer reads first. + +**What is shared with `lit-hono`, and what is not.** `server/` is the `lit-hono` text, byte for byte, with the same Hono 4.13.13, `@hono/node-server` 1.19.17 and `@modelcontextprotocol/hono` 2.0.2 pins; so are `tsconfig.server.json`, `vite.server.config.ts` and the wire contract `src/contract.ts` apart from one constant. `scripts/starter-hono.test.ts` fails if the React copy's server files, server build files, contract or Hono pins drift from the Lit copy, so a server fix lands in both. The view artifact check in `server/operations.ts` no longer looks for `-stack`), the page marker, the teardown selector in `apps/lit-host/e2e/starter.spec.ts` (`#root` for a React template), `extractSnippets` (which reads `.tsx` and records each snippet's language, so a recipe is fenced as `ts` or `tsx`), the packed set in `scripts/saga-init-offered.test.ts`, `REQUIRED_TEMPLATES` and the stack-import units. The Hono cases both Hono templates share live in `scripts/starter-hono-cases.ts`. + +- `scripts/starter-react-hono.test.ts` (35 headless cases: the shared suite and the Hono cases) runs a real MCP client over HTTP against the listener bundled from the exported starter, installed from packed candidate tarballs, type-checked and built with its own scripts. +- `apps/lit-host/e2e/starter.spec.ts` (Chromium, local host fixture) runs its six cases for `react-hono`: the served page is byte-identical to the build, the operation's result reaches the React view, Clear the note empties the input without a server call, a result of another build renders nothing, and host teardown and `pagehide` leave `#root` empty and dispose the binding. +- `scripts/saga-init-offered.test.ts` runs the real command for the pair, installs the packed candidates and builds the project; `scripts/saga.test.ts` covers the refusals, including the React stack on any other server. + +**Limits, kept explicit.** One server pair for React, not the matrix: `saga demo` stays Lit and Node only, and no actual host, OAuth flow or published package is involved. + ### The standalone synthetic demonstration (DEMO1) [#24](https://github.com/MachineWisdomAI/saga-a2ui/issues/24) runs the whole protected workflow from a download that was built outside the repository. `demo/lit-node` is the demonstration, `scripts/build-demo.ts` (`npm run build:demo`, part of `npm run build`) makes the download, and [`demo/lit-node/README.md`](../demo/lit-node/README.md) is what a person who receives it reads. It adds no package, runtime export or dependency to the SDK, and nothing is published, deployed or made public. @@ -1575,7 +1589,7 @@ npx tsx scripts/saga.ts --help # usage and the supported pairs **What it does.** `--a2ui-stack -mcp` and `--server ` name a pair; `init` copies that pair's maintained template (`exportTemplate`, the one implementation the starter, the snippets and the qualification share) into the new directory, with every `@saga-a2ui/*` dependency pinned to the build of the checkout it ran from (`0.0.0-candidate.g<12 hex>`, `.dirty` when tracked files differ; the identity of PACK2, not a release). It then prints the ordinary next commands (`cd`, `npm install`, `npm run build`, `npm start`) and says that the candidate is not published, so the install has to come from that build's tarballs ([candidate guide](../docs/candidate-update-and-rollback.md)). Exit status: 0 created, 1 refused or failed, 2 command line not understood, 130 interrupted. -**What it offers.** `SUPPORTED_PAIRS` in `scripts/saga.ts` is a plain list of the pairs CI qualifies: `lit-mcp` on `node` (`templates/lit-node`), `express` (`templates/lit-express`), `fastify` (`templates/lit-fastify`) and `hono` (`templates/lit-hono`), the four then in `QUALIFIED_STARTERS`, each qualified by its `scripts/starter*.test.ts` and `apps/lit-host/e2e/starter.spec.ts`. The three framework rows were registered by [#179](https://github.com/MachineWisdomAI/saga-a2ui/issues/179), the shared registration for criterion 5 of [#62](https://github.com/MachineWisdomAI/saga-a2ui/issues/62), [#63](https://github.com/MachineWisdomAI/saga-a2ui/issues/63) and [#64](https://github.com/MachineWisdomAI/saga-a2ui/issues/64); each row names its template explicitly and adds no code, parser or copy of a template. A template directory existing does not add a pair, and a pair whose template stops declaring that stack and server is refused. Any other pair (a React recipe, another stack) is refused before anything is written, and the refusal lists the supported ones. The owner of a further pair adds one row when its own qualification passes, and removes it while that fails; there is no registry or generator behind the list. When this section was written the list held four pairs; `hapi` ([#65](https://github.com/MachineWisdomAI/saga-a2ui/issues/65)) joined it with the SAGA-maintained Hapi starter (`templates/lit-hapi`), so `init` now offers five. +**What it offers.** (A later change added `react-mcp` on `hono` from `templates/react-hono`; see the React and Hono starter section.) `SUPPORTED_PAIRS` in `scripts/saga.ts` is a plain list of the pairs CI qualifies: `lit-mcp` on `node` (`templates/lit-node`), `express` (`templates/lit-express`), `fastify` (`templates/lit-fastify`) and `hono` (`templates/lit-hono`), the four then in `QUALIFIED_STARTERS`, each qualified by its `scripts/starter*.test.ts` and `apps/lit-host/e2e/starter.spec.ts`. The three framework rows were registered by [#179](https://github.com/MachineWisdomAI/saga-a2ui/issues/179), the shared registration for criterion 5 of [#62](https://github.com/MachineWisdomAI/saga-a2ui/issues/62), [#63](https://github.com/MachineWisdomAI/saga-a2ui/issues/63) and [#64](https://github.com/MachineWisdomAI/saga-a2ui/issues/64); each row names its template explicitly and adds no code, parser or copy of a template. A template directory existing does not add a pair, and a pair whose template stops declaring that stack and server is refused. Any other pair (a React recipe, another stack) is refused before anything is written, and the refusal lists the supported ones. The owner of a further pair adds one row when its own qualification passes, and removes it while that fails; there is no registry or generator behind the list. When this section was written the list held four pairs; `hapi` ([#65](https://github.com/MachineWisdomAI/saga-a2ui/issues/65)) joined it with the SAGA-maintained Hapi starter (`templates/lit-hapi`), so `init` now offers five. **What it will not do.** It creates one new directory and nothing else. A target that exists (a directory, a file, or a symbolic link, including a dangling one) is refused and left as it was; a missing parent directory is refused rather than created; a parent that is a symbolic link is resolved first, so the copy goes where the path leads. It starts no process: no install, no package manager, no script and no hook, and the starter's manifest declares no lifecycle hook. The directory is created only if it does not exist at that moment, so a competing creation is refused instead of merged into. If a write fails, the directory this run created is removed and nothing beside it is touched. "The directory this run created" is identified by the directory itself (device and inode from the exclusive creation), not by its path: before every file, after the last one and before any removal, the path is checked to still lead to that directory. If another process moved it away and put something else at the path, the command stops writing, removes nothing there, leaves the replacement as it is, and says so. Node has no directory-relative file calls, so a replacement landing between that check and the one call that follows it is not excluded; the check narrows the window to that instant, it does not close it. The copy hands control back to the event loop before each file, so a real SIGINT or SIGTERM is seen before the next write or before success is reported (a synchronous copy would run the signal callback only after it had finished): the directory this run created is removed, the created message is not printed, and the command exits 130. A second signal during that short cleanup is ignored. A process killed outright (SIGKILL, power loss) can leave a partial directory, which the developer deletes and re-creates with the same command. diff --git a/docs/getting-started.md b/docs/getting-started.md index b029a9ba..d2d8a019 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,9 +2,9 @@ SAGA connects an application's UI to an MCP host. Your application supplies the view, the meaning of its data and the actions its controls perform. A stack package connects the renderer to MCP delivery and manages the view's lifetime. -React and Lit are both supported renderer choices. Install the published MCP stack and peers for your application’s framework; both stacks provide custom typed surfaces, actions and FaithfulCard applications. For a generated Lit starter or local demonstration, use the source checkout and its matched candidate tarballs. The source CLI currently offers Lit templates; this is a tooling distinction, not a ranking of the two stacks. +React and Lit are both supported renderer choices. Install the published MCP stack and peers for your application’s framework; both stacks provide custom typed surfaces, actions and FaithfulCard applications. For a generated starter or local demonstration, use the source checkout and its matched candidate tarballs. The source CLI offers Lit templates on five servers and one React template, on Hono; the local demonstration is Lit only. This is a tooling distinction, not a ranking of the two stacks. -![Choose a published Lit or React stack, or a source-based Lit starter, then supply application behavior](../diagrams/saga-setup-paths.svg) +![Choose a published Lit or React stack, or a source-based Lit or React starter, then supply application behavior](../diagrams/saga-setup-paths.svg) ## Install a published stack @@ -69,7 +69,7 @@ Package names are flat under `@saga-a2ui`. There are no aggregate packages: no ` | Components | `@saga-a2ui/components` | Implemented, optional. The passive FaithfulCard slice. Both stacks install it transitively; an own-catalog application need not import it, and its main-entry browser bundle excludes it. | | Tooling | none | The source CLI implements `saga init`, `saga demo` and optional `saga publish` preparation/handoff commands. The `@saga-a2ui/publish` package does not exist; the [publication tooling](../scripts/publish/README.md) runs from this checkout and does not submit or publish an application. | -Renderer packages keep their own framework peers and lifecycle APIs. The maintained source starter declares the Lit stack and its matching SDK packages explicitly, imports its view from `@saga-a2ui/lit-mcp-stack`, and uses the lower-level `@saga-a2ui/mcp/server` registration API on its server. This is the starter's existing source composition. For a new stack application, use the stack exports described above; you do not need to copy those direct SDK dependencies. +Renderer packages keep their own framework peers and lifecycle APIs. Each maintained source starter declares its stack and the matching SDK packages explicitly, imports its view from `@saga-a2ui/lit-mcp-stack` (the Lit starters) or `@saga-a2ui/react-mcp-stack` (the React starter), and uses the lower-level `@saga-a2ui/mcp/server` registration API on its server. This is the starters' existing source composition. For a new stack application, use the stack exports described above; you do not need to copy those direct SDK dependencies. ## Component, definition, template and instance @@ -89,20 +89,21 @@ The `bind-action` snippet in the [generated recipes](attach-to-existing-backend. ## Create a new project -This is the source-based Lit path. From the checkout root, run `npm ci` if its dependencies are not already installed. +This is the source-based path for a Lit or a React view. From the checkout root, run `npm ci` if its dependencies are not already installed. `saga init` is a script in this repository, run from a checkout. It creates one new directory from the maintained starter template and installs nothing, runs no package manager and no hook, and never changes anything that already exists. ```sh repository npx tsx scripts/saga.ts --help npx tsx scripts/saga.ts init --a2ui-stack lit-mcp --server hono ./my-app +npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server hono ./my-react-app ``` -`--a2ui-stack` selects the renderer/delivery combination; `--server` selects the server framework. The command creates `my-app` and installs nothing. Use Node 22.23.3 or newer for this source checkout. Keep this checkout unchanged while exporting the project and packing its dependencies so their candidate identities match. +`--a2ui-stack` selects the renderer/delivery combination; `--server` selects the server framework. The commands create `my-app` (a Lit view) and `my-react-app` (a React view on `@saga-a2ui/react-mcp-stack` with React 19.3.1); both serve it from Hono and install nothing. The rest of this guide shows the Lit project, and what differs for React is noted where it matters. Use Node 22.23.3 or newer for this source checkout. Keep this checkout unchanged while exporting the project and packing its dependencies so their candidate identities match. ### Install the packages from a candidate -This source workflow qualifies the checkout using candidate builds of Lit and the Lit stack. Both packages are also published on npm. The project `saga init` creates depends on `@saga-a2ui/*` packages pinned to the exact candidate version of the commit that produced them. A package manager resolves those pins only from that candidate's tarballs, so a plain `npm install` cannot resolve those commit-specific candidate versions from the public registry. +This source workflow qualifies the checkout using candidate builds of the renderer and its stack (Lit and the Lit stack, or React and the React stack). The packages are also published on npm. The project `saga init` creates depends on `@saga-a2ui/*` packages pinned to the exact candidate version of the commit that produced them. A package manager resolves those pins only from that candidate's tarballs, so a plain `npm install` cannot resolve those commit-specific candidate versions from the public registry. Build the candidate from the checkout that ran `saga init`, then install the tarballs the project uses in one command: @@ -119,7 +120,7 @@ The second line is needed because the root build still builds the retained Lynx npm install ./PATH/candidate-out/packages/saga-a2ui-core-VERSION.tgz ./PATH/candidate-out/packages/saga-a2ui-components-VERSION.tgz ./PATH/candidate-out/packages/saga-a2ui-lit-VERSION.tgz ./PATH/candidate-out/packages/saga-a2ui-mcp-VERSION.tgz ./PATH/candidate-out/packages/saga-a2ui-lit-mcp-stack-VERSION.tgz ``` -Replace `PATH` and `VERSION` with your files; the version is the one in the project's `package.json`. A plain `npm install` cannot retrieve these candidate versions from npm; install their tarballs instead. A public release does not replace the matched candidate set used by this source workflow. A candidate is not a public registry release. Install every package of the combination you use together, from the same candidate, and keep the `@a2ui/markdown-it` override in your root `package.json` (the starter carries it). [Candidates: contents, update, rollback](candidate-update-and-rollback.md) is the authority for the matched set, the update sequence and rollback; do not update one package alone. CI also uploads each candidate as a workflow artifact. +Replace `PATH` and `VERSION` with your files; the version is the one in the project's `package.json`. A React project installs the five React-side tarballs instead (`core`, `components`, `react`, `mcp` and `react-mcp-stack`, from the same `candidate-out/packages` directory). A plain `npm install` cannot retrieve these candidate versions from npm; install their tarballs instead. A public release does not replace the matched candidate set used by this source workflow. A candidate is not a public registry release. Install every package of the combination you use together, from the same candidate, and keep the `@a2ui/markdown-it` override in your root `package.json` (the starter carries it). [Candidates: contents, update, rollback](candidate-update-and-rollback.md) is the authority for the matched set, the update sequence and rollback; do not update one package alone. CI also uploads each candidate as a workflow artifact. After installing those tarballs inside `my-app`, build and start it: @@ -132,7 +133,7 @@ npm start ### HTTP defaults -Use **stateless MCP** and **prepare the built view once at startup**. The five maintained starters each construct two persistent `createMcpHandler` instances: one for the public status endpoint and one for the protected account endpoint. Each factory constructs a fresh server for a request. Each handler owns a separate notification bus, subscription limit and shutdown operation, so public subscribers cannot receive protected endpoint notifications or consume its subscription slots. +Use **stateless MCP** and **prepare the built view once at startup**. The six maintained starters each construct two persistent `createMcpHandler` instances: one for the public status endpoint and one for the protected account endpoint. Each factory constructs a fresh server for a request. Each handler owns a separate notification bus, subscription limit and shutdown operation, so public subscribers cannot receive protected endpoint notifications or consume its subscription slots. The Hono target uses the official `@modelcontextprotocol/hono` validation middleware and Hono's Node adapter. Hono supplies routing and middleware; Node remains the runtime. Its routes mount `handler.fetch(c.req.raw, { parsedBody, authInfo })`; Node, Express and Fastify use the official `toNodeHandler` adapter. The application authenticates before invoking the protected handler and reads the body under the request's deadline. All mounting paths pass `parsedBody` explicitly: in the pinned SDK this preserves the original `Request` object in both modern and legacy factories, allowing the endpoint's WeakMap to recover the admitted lifetime through `ctx.requestInfo`. @@ -176,13 +177,13 @@ The CLI creates a separate reference project; it does not migrate an existing ap ### Unqualified pairs are refused -`saga init` offers only the pairs CI qualifies and that its own list names: `lit-mcp` on `node`, `express`, `fastify`, `hono` and `hapi`. Asking for another pair exits with status 1 before anything is written, and the message lists the five supported ones: +`saga init` offers only the pairs CI qualifies and that its own list names: `lit-mcp` on `node`, `express`, `fastify`, `hono` and `hapi`, and `react-mcp` on `hono`. Asking for another pair exits with status 1 before anything is written, and the message lists the six supported ones: ```sh repository -npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server node ./my-app +npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server express ./my-app ``` -A template directory existing does not add a pair, and a *qualified* template is not automatically *offered*: the [pair table](attach-to-existing-backend.md#which-pairs-have-a-recipe) shows which pairs have a qualified template and which of them `saga init` offers today (all five). Hapi uses the SAGA-maintained bridge over the official Node/server SDK; React has no starter, template or `saga init` pair. +A template directory existing does not add a pair, and a *qualified* template is not automatically *offered*: the [pair table](attach-to-existing-backend.md#which-pairs-have-a-recipe) shows which pairs have a qualified template and which of them `saga init` offers today (all six). Hapi uses the SAGA-maintained bridge over the official Node/server SDK; React has one starter, on Hono, and no `saga init` pair on any other server. ## Attach to an existing backend @@ -286,8 +287,8 @@ Defaults, kept separate: action work 30 seconds and each hook or cleanup 5 secon | --- | --- | --- | | Shared action contract | `@saga-a2ui/core/actions`, with the opt-in Lit and React `ActionButton` controls; Play runs on it | A registry or handler-name table, which do not exist | | Lit | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer checks cover the starter, demonstration and notes example | Starter and local fixture results do not establish every application or host combination; Windows is not qualified here | -| React | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer and browser checks cover own-catalog notes and branded answer cards. Bounded notes observations in Claude/ChatGPT are recorded in [host compatibility](host-compatibility.md) | No generated React starter, template or CLI pair. Qualification of a particular application and host remains specific to its built artifact; no complete host matrix is claimed | -| Server targets | `node`, `express`, `hono`, `fastify`, `hapi` qualified with `lit-mcp` | No full matrix; `saga init` offers those five servers; the standalone demonstration is the Node application only | +| React | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer and browser checks cover own-catalog notes and branded answer cards. Bounded notes observations in Claude/ChatGPT are recorded in [host compatibility](host-compatibility.md) | One generated React starter, `react-mcp` on Hono; no other React server pair and no React demonstration. Qualification of a particular application and host remains specific to its built artifact; no complete host matrix is claimed | +| Server targets | `node`, `express`, `hono`, `fastify`, `hapi` qualified with `lit-mcp`; `hono` qualified with `react-mcp` | No full matrix; `saga init` offers those six pairs; the standalone demonstration is the Lit and Node application only | | Lynx | Nothing. Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are **historical** designs and evidence, with no delivery promise, no recipe and no adapter | Any current Lynx setup | A renderer is qualified by running its own lifecycle, not by declaring that it could: a type declaration that compiles is not a working setup claim, and no renderer is forced through a Lit-specific lifecycle (custom elements and `createA2uiAppElement`). React attaches through its own provider and hook; the shared contract is renderer-neutral. @@ -312,7 +313,7 @@ import {A2uiSurface, Catalog, type ReactCatalogComponent} from '@saga-a2ui/react **The demo directory does not exist.** Run `npm run build:demo` in the source checkout first. It produces the standalone directory whose own `npm start` serves the demonstration. The standalone demo supports Node only; the other server targets are starter templates. -**A React starter command is refused.** The React stack is published, but there is no React `saga init` pair. Integrate the stack in a React application; do not replace its lifecycle with a Lit-specific lifecycle or follow the retired historical Lynx fixture as a setup path. +**A React starter command on another server is refused.** The React stack is published and `saga init` offers `react-mcp` on `hono` only. For another server, integrate the stack in a React application; do not replace its lifecycle with a Lit-specific lifecycle or follow the retired historical Lynx fixture as a setup path. ## Where to go next diff --git a/docs/host-compatibility.md b/docs/host-compatibility.md index 1404319d..8cea7997 100644 --- a/docs/host-compatibility.md +++ b/docs/host-compatibility.md @@ -17,7 +17,7 @@ These are observations of particular artifacts and flows, not a complete rendere React and Lit both provide an MCP stack for typed surfaces, actions and FaithfulCard applications. React is used by Ansari Faithful Answers; its [public destination candidate](https://github.com/iaser-ai/ansari/pull/323) records the application’s React stack recipe and local validation. The earlier [React integration record](https://github.com/MachineWisdomAI/saga-a2ui/pull/161) reports answer cards in ChatGPT web, Claude web and the Alexa+ development simulator. Keep those earlier host artifacts separate from the later application candidate and package versions. -Lit supplies the generated starters and standalone demo, and has its own bounded host observations. React has its own application and host evidence. Neither set establishes a complete host matrix or a universal maturity ranking. The missing React starter is a tooling gap; host limitations below must be evaluated for the actual flow and built resource, whichever renderer the application chooses. +Lit supplies the generated starters and standalone demo, React supplies one generated starter (on Hono), and each has its own bounded host observations. React has its own application and host evidence. Neither set establishes a complete host matrix or a universal maturity ranking. A React starter on any server other than Hono is a tooling gap; host limitations below must be evaluated for the actual flow and built resource, whichever renderer the application chooses. ## ChatGPT and Claude diff --git a/docs/public-install.md b/docs/public-install.md index 68d4d480..68a46ece 100644 --- a/docs/public-install.md +++ b/docs/public-install.md @@ -59,7 +59,7 @@ Applications that intentionally compose lower-level SDK APIs may install the pac ## Run a demo or use a starter -[Getting started](getting-started.md) runs the synthetic demo, creates a Lit starter or attaches SAGA to an existing backend. Those source-tooling workflows build matched candidate tarballs from a checkout so contributors can test unpublished changes. Candidates have versions such as `0.0.0-candidate.g<12 hex>` and are distinct from public npm releases; see [candidate installation, updates and rollback](candidate-update-and-rollback.md). The source CLI, root workspace and example applications are not npm packages. +[Getting started](getting-started.md) runs the synthetic demo, creates a Lit starter or the React starter on Hono, or attaches SAGA to an existing backend. Those source-tooling workflows build matched candidate tarballs from a checkout so contributors can test unpublished changes. Candidates have versions such as `0.0.0-candidate.g<12 hex>` and are distinct from public npm releases; see [candidate installation, updates and rollback](candidate-update-and-rollback.md). The source CLI, root workspace and example applications are not npm packages. Continue with [Getting started](getting-started.md#compose-a-view-with-a-stack) to connect a view, then the [action guide](actions.md) to trace a complete interaction. [Host compatibility](host-compatibility.md) records the flows observed in ChatGPT, Claude and Alexa+. Installing a package does not deploy an application or establish acceptance in an assistant host. diff --git a/docs/react-consumers.md b/docs/react-consumers.md index 565fcdbc..4124cc93 100644 --- a/docs/react-consumers.md +++ b/docs/react-consumers.md @@ -15,7 +15,7 @@ The main entry is for an application's own catalog. An application that installs Two footprints follow, and they are different things. An own-catalog application's *installed tree* includes `@saga-a2ui/components` and `markdown-it` as declared dependencies of the stack; that is the correct install result. Its *emitted browser bundle* and runtime import graph exclude them, because the main entry imports nothing from components. The stack's `exports.test.ts` bundles each entry's module graph from workspace source to show this, and `scripts/pack1.test.ts` builds the own-catalog and card pages from the packed copies; `npm run test:pnpm-react` and `npm run candidate:verify` check the install, not the bundle. -The stack belongs to the public release set. See [public installation](public-install.md) for its exact-version recipe and registry availability, and [release preparation](release-packages.md) for exact-artifact checks. Both React and Lit implement the typed-surface and FaithfulCard application contracts. Their framework APIs and teardown mechanics differ. The [host compatibility guide](host-compatibility.md) records the observed flows and remaining host limits; the absence of a generated React starter does not mean the React stack lacks a working application path. +The stack belongs to the public release set. See [public installation](public-install.md) for its exact-version recipe and registry availability, and [release preparation](release-packages.md) for exact-artifact checks. Both React and Lit implement the typed-surface and FaithfulCard application contracts. Their framework APIs and teardown mechanics differ. The [host compatibility guide](host-compatibility.md) records the observed flows and remaining host limits; the generated React starter covers one server (`react-mcp` on Hono), and an application on another server or an existing React application integrates the stack directly, which is a working application path. ## React in Ansari Faithful Answers @@ -34,7 +34,7 @@ The [notes React view](../examples/pack1-consumer/src/notes-react-view.tsx) is a Follow the [action guide](actions.md#trace-one-press-allowed-and-denied) for the complete server authorization and outcome trace. An expired session can stop Archive before its protected request; cancellation after sending that request leaves its effect uncertain. The application presents that uncertainty and does not retry the write automatically. -This is an existing application example, not a React starter command. Use [public installation](public-install.md) for the stack and exact peers, and [Getting started](getting-started.md) for a runnable local demo. +This is an existing application example. To start a new React application on Hono, run `saga init --a2ui-stack react-mcp --server hono` as [Getting started](getting-started.md#create-a-new-project) describes; the starter holds the same public status, protected account and local Clear action as the Lit starters. Use [public installation](public-install.md) for the stack and exact peers. ## The React integration surface diff --git a/package-lock.json b/package-lock.json index 5fad9500..4dd742c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6755,11 +6755,11 @@ }, "packages/components": { "name": "@saga-a2ui/components", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@a2ui/web_core": "0.13.0", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "markdown-it": "15.0.3", "zod": "4.6.7" }, @@ -6774,7 +6774,7 @@ }, "packages/core": { "name": "@saga-a2ui/core", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@a2ui/web_core": "0.13.0", @@ -6791,12 +6791,12 @@ }, "packages/lit": { "name": "@saga-a2ui/lit", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@a2ui/lit": "0.13.0", "@a2ui/web_core": "0.13.0", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "lit": "^3.3.3" }, "devDependencies": { @@ -6808,7 +6808,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1" + "@saga-a2ui/components": "0.7.8-rc.1" }, "peerDependenciesMeta": { "@saga-a2ui/components": { @@ -6818,16 +6818,16 @@ }, "packages/lit-mcp-stack": { "name": "@saga-a2ui/lit-mcp-stack", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@a2ui/lit": "0.13.0", "@a2ui/web_core": "0.13.0", "@modelcontextprotocol/ext-apps": "2.0.3", - "@saga-a2ui/components": "0.7.7-rc.1", - "@saga-a2ui/core": "0.7.7-rc.1", - "@saga-a2ui/lit": "0.7.7-rc.1", - "@saga-a2ui/mcp": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", + "@saga-a2ui/lit": "0.7.8-rc.1", + "@saga-a2ui/mcp": "0.7.8-rc.1", "zod": "4.6.7" }, "devDependencies": { @@ -6846,14 +6846,14 @@ }, "packages/mcp": { "name": "@saga-a2ui/mcp", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/client": "2.3.1", "@modelcontextprotocol/core": "2.3.1", "@modelcontextprotocol/ext-apps": "2.0.3", "@modelcontextprotocol/server": "2.3.1", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "zod": "4.6.7" }, "devDependencies": { @@ -6868,16 +6868,16 @@ }, "packages/react": { "name": "@saga-a2ui/react", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@a2ui/react": "0.13.0", "@a2ui/web_core": "0.13.0", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "zod": "3.25.76" }, "devDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", "@types/node": "22.20.5", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", @@ -6891,7 +6891,7 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", "react": "19.3.1", "react-dom": "19.3.1" }, @@ -6903,14 +6903,14 @@ }, "packages/react-mcp-stack": { "name": "@saga-a2ui/react-mcp-stack", - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/ext-apps": "2.0.3", - "@saga-a2ui/components": "0.7.7-rc.1", - "@saga-a2ui/core": "0.7.7-rc.1", - "@saga-a2ui/mcp": "0.7.7-rc.1", - "@saga-a2ui/react": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", + "@saga-a2ui/mcp": "0.7.8-rc.1", + "@saga-a2ui/react": "0.7.8-rc.1", "zod": "4.6.7" }, "devDependencies": { diff --git a/package.json b/package.json index ca19a63f..a6edd43c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build:react-card-consumer": "tsx scripts/build-react-card-consumer.ts", "build:installed-lit-host": "tsx scripts/build-installed-lit-host.ts", "serve:mcp": "npm run serve:mcp -w @saga-a2ui/lit-host-app", - "typecheck": "tsc -p tsconfig.json && npm run typecheck -w @saga-a2ui/core && npm run typecheck -w @saga-a2ui/components && npm run typecheck -w @saga-a2ui/lit && npm run typecheck -w @saga-a2ui/react && npm run typecheck -w @saga-a2ui/mcp && npm run typecheck -w @saga-a2ui/lit-mcp-stack && npm run typecheck -w @saga-a2ui/react-mcp-stack && npm run typecheck -w @saga-a2ui/lit-host-app && tsc -p examples/answer-consumer/tsconfig.json && tsc -p examples/answer-consumer/tsconfig.server.json && tsc -p examples/pack1-consumer/tsconfig.json && tsc -p examples/pack1-consumer/tsconfig.server.json && tsc -p examples/react-card-consumer/tsconfig.json && tsc -p examples/react-card-consumer/tsconfig.server.json && tsc -p templates/lit-node/tsconfig.json && tsc -p templates/lit-node/tsconfig.server.json && tsc -p templates/lit-express/tsconfig.json && tsc -p templates/lit-express/tsconfig.server.json && tsc -p templates/lit-fastify/tsconfig.json && tsc -p templates/lit-fastify/tsconfig.server.json && tsc -p templates/lit-hono/tsconfig.json && tsc -p templates/lit-hono/tsconfig.server.json && tsc -p templates/lit-hapi/tsconfig.json && tsc -p templates/lit-hapi/tsconfig.server.json && tsc -p demo/lit-node/tsconfig.json && tsc -p demo/lit-node/tsconfig.server.json && tsc -p demo/tour/tsconfig.json && npm run typecheck --prefix apps/lynx-fixture", + "typecheck": "tsc -p tsconfig.json && npm run typecheck -w @saga-a2ui/core && npm run typecheck -w @saga-a2ui/components && npm run typecheck -w @saga-a2ui/lit && npm run typecheck -w @saga-a2ui/react && npm run typecheck -w @saga-a2ui/mcp && npm run typecheck -w @saga-a2ui/lit-mcp-stack && npm run typecheck -w @saga-a2ui/react-mcp-stack && npm run typecheck -w @saga-a2ui/lit-host-app && tsc -p examples/answer-consumer/tsconfig.json && tsc -p examples/answer-consumer/tsconfig.server.json && tsc -p examples/pack1-consumer/tsconfig.json && tsc -p examples/pack1-consumer/tsconfig.server.json && tsc -p examples/react-card-consumer/tsconfig.json && tsc -p examples/react-card-consumer/tsconfig.server.json && tsc -p templates/lit-node/tsconfig.json && tsc -p templates/lit-node/tsconfig.server.json && tsc -p templates/lit-express/tsconfig.json && tsc -p templates/lit-express/tsconfig.server.json && tsc -p templates/lit-fastify/tsconfig.json && tsc -p templates/lit-fastify/tsconfig.server.json && tsc -p templates/lit-hono/tsconfig.json && tsc -p templates/lit-hono/tsconfig.server.json && tsc -p templates/lit-hapi/tsconfig.json && tsc -p templates/lit-hapi/tsconfig.server.json && tsc -p templates/react-hono/tsconfig.json && tsc -p templates/react-hono/tsconfig.server.json && tsc -p demo/lit-node/tsconfig.json && tsc -p demo/lit-node/tsconfig.server.json && tsc -p demo/tour/tsconfig.json && npm run typecheck --prefix apps/lynx-fixture", "test:browser": "npm run test:browser -w @saga-a2ui/lit-host-app && npm run test:browser --prefix apps/lynx-fixture", "test:headless": "vitest run", "candidate:identity": "tsx scripts/candidate-identity.ts", diff --git a/packages/components/package.json b/packages/components/package.json index f338dc53..713a5393 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@a2ui/web_core": "0.13.0", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "markdown-it": "15.0.3", "zod": "4.6.7" }, @@ -26,7 +26,7 @@ "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/packages/core/package.json b/packages/core/package.json index 2a1ecef2..8af31ce8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,7 +29,7 @@ "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/packages/lit-mcp-stack/package.json b/packages/lit-mcp-stack/package.json index be40304f..789b8431 100644 --- a/packages/lit-mcp-stack/package.json +++ b/packages/lit-mcp-stack/package.json @@ -23,10 +23,10 @@ "@a2ui/lit": "0.13.0", "@a2ui/web_core": "0.13.0", "@modelcontextprotocol/ext-apps": "2.0.3", - "@saga-a2ui/components": "0.7.7-rc.1", - "@saga-a2ui/core": "0.7.7-rc.1", - "@saga-a2ui/lit": "0.7.7-rc.1", - "@saga-a2ui/mcp": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", + "@saga-a2ui/lit": "0.7.8-rc.1", + "@saga-a2ui/mcp": "0.7.8-rc.1", "zod": "4.6.7" }, "peerDependencies": { @@ -39,7 +39,7 @@ "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/packages/lit/package.json b/packages/lit/package.json index fccedbcb..72c7f167 100644 --- a/packages/lit/package.json +++ b/packages/lit/package.json @@ -20,11 +20,11 @@ "dependencies": { "@a2ui/lit": "0.13.0", "@a2ui/web_core": "0.13.0", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "lit": "^3.3.3" }, "peerDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1" + "@saga-a2ui/components": "0.7.8-rc.1" }, "peerDependenciesMeta": { "@saga-a2ui/components": { @@ -32,11 +32,11 @@ } }, "devDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1f561ad9..0cc5fc5e 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -22,7 +22,7 @@ "@modelcontextprotocol/core": "2.3.1", "@modelcontextprotocol/ext-apps": "2.0.3", "@modelcontextprotocol/server": "2.3.1", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "zod": "4.6.7" }, "devDependencies": { @@ -31,7 +31,7 @@ "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/packages/react-mcp-stack/package.json b/packages/react-mcp-stack/package.json index 03a38739..d835ba43 100644 --- a/packages/react-mcp-stack/package.json +++ b/packages/react-mcp-stack/package.json @@ -24,10 +24,10 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "2.0.3", - "@saga-a2ui/components": "0.7.7-rc.1", - "@saga-a2ui/core": "0.7.7-rc.1", - "@saga-a2ui/mcp": "0.7.7-rc.1", - "@saga-a2ui/react": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", + "@saga-a2ui/mcp": "0.7.8-rc.1", + "@saga-a2ui/react": "0.7.8-rc.1", "zod": "4.6.7" }, "peerDependencies": { @@ -45,7 +45,7 @@ "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/packages/react/package.json b/packages/react/package.json index a13d7127..7ca6523c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -21,11 +21,11 @@ "dependencies": { "@a2ui/react": "0.13.0", "@a2ui/web_core": "0.13.0", - "@saga-a2ui/core": "0.7.7-rc.1", + "@saga-a2ui/core": "0.7.8-rc.1", "zod": "3.25.76" }, "peerDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", "react": "19.3.1", "react-dom": "19.3.1" }, @@ -35,7 +35,7 @@ } }, "devDependencies": { - "@saga-a2ui/components": "0.7.7-rc.1", + "@saga-a2ui/components": "0.7.8-rc.1", "@types/node": "22.20.5", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", @@ -45,7 +45,7 @@ "typescript": "^5.9.0", "vitest": "^5.0.4" }, - "version": "0.7.7-rc.1", + "version": "0.7.8-rc.1", "engines": { "node": ">=22.12.0" }, diff --git a/scripts/build-starter.ts b/scripts/build-starter.ts index 9a8f787d..76d02262 100644 --- a/scripts/build-starter.ts +++ b/scripts/build-starter.ts @@ -44,6 +44,7 @@ export const starterDist = (name: string): string => const QUALIFICATION_ENTRY = `export { startListener } from './server/listener.js'; export { ACCOUNT_SCOPE } from './server/authenticate.js'; export { ViewArtifactMissing } from './server/operations.js'; +export { VIEW_MARKER } from './src/contract.js'; `; export interface PreparedStarter { diff --git a/scripts/consumer-boundary.test.ts b/scripts/consumer-boundary.test.ts index 2f5157bc..f7184e48 100644 --- a/scripts/consumer-boundary.test.ts +++ b/scripts/consumer-boundary.test.ts @@ -568,7 +568,7 @@ describe('the repository consumers hold application meaning only', () => { ); }); - it('asserts all five maintained templates are present and scanned', () => { + it('asserts all six maintained templates are present and scanned', () => { const directories = fs .readdirSync(path.join(repoRoot, 'templates'), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) diff --git a/scripts/consumer-boundary.ts b/scripts/consumer-boundary.ts index 874ff03e..d81e126f 100644 --- a/scripts/consumer-boundary.ts +++ b/scripts/consumer-boundary.ts @@ -406,6 +406,7 @@ export const REQUIRED_TEMPLATES = [ 'lit-fastify', 'lit-hono', 'lit-hapi', + 'react-hono', ] as const; /** `*` is within one path segment, `**` crosses segments, `{a,b}` is a choice; the match covers the whole path. */ diff --git a/scripts/consumer-stack-imports.test.ts b/scripts/consumer-stack-imports.test.ts index 371b1fc7..bd9cd4e3 100644 --- a/scripts/consumer-stack-imports.test.ts +++ b/scripts/consumer-stack-imports.test.ts @@ -55,6 +55,11 @@ const UNITS: readonly Unit[] = [ directory: `templates/lit-${server}`, roles: every(stack('lit')), })), + { + name: 'templates/react-hono', + directory: 'templates/react-hono', + roles: every(stack('react')), + }, { name: 'examples/pack1-consumer', directory: 'examples/pack1-consumer', diff --git a/scripts/guide-actions-document.test.ts b/scripts/guide-actions-document.test.ts index 5a232f6e..1cc5ca7a 100644 --- a/scripts/guide-actions-document.test.ts +++ b/scripts/guide-actions-document.test.ts @@ -319,7 +319,7 @@ describe('what the guide must explain', () => { expect(line).toMatch(/retired|history/i); expect(guide).toMatch(/not shown/i); expect(guide).toMatch( - /no React starter, template, CLI pair or authenticated recipe/, + /one React starter, `react-mcp` on Hono, and its generated recipe[^|]*\|\s*No other React pair, no authenticated React recipe/, ); expect(guide).not.toMatch(/\bhapi\b[^\n]*\bis qualified\b/i); }); diff --git a/scripts/guide-recipes.ts b/scripts/guide-recipes.ts index 011b0d73..cf54e46a 100644 --- a/scripts/guide-recipes.ts +++ b/scripts/guide-recipes.ts @@ -135,7 +135,7 @@ function recipe(name: string): string { '', `From \`templates/${name}/${snippet.file}\`:`, '', - '```ts', + `\`\`\`${snippet.language}`, dedent(snippet.text), '```', ); diff --git a/scripts/guide.test.ts b/scripts/guide.test.ts index 2fc9ead7..43364a33 100644 --- a/scripts/guide.test.ts +++ b/scripts/guide.test.ts @@ -170,12 +170,17 @@ describe('the generated existing-backend recipes', () => { recipes.indexOf(''), recipes.indexOf(''), ); - const rows = table.split('\n').filter((line) => /^\| `lit-mcp`/.test(line)); + const rows = table + .split('\n') + .filter((line) => /^\| `[a-z]+-mcp` \|/.test(line)); const offered = rows .filter((row) => /\| offered \|$/.test(row)) - .map((row) => row.split('|')[2]?.trim()); + .map((row) => { + const [, stack, server] = row.split('|').map((cell) => cell.trim()); + return `${stack} on ${server}`; + }); expect(offered).toEqual( - SUPPORTED_PAIRS.map((pair) => `\`${pair.server}\``), + SUPPORTED_PAIRS.map((pair) => `\`${pair.stack}\` on \`${pair.server}\``), ); expect(rows).toHaveLength(QUALIFIED_STARTERS.length); }); diff --git a/scripts/qualified-starters.ts b/scripts/qualified-starters.ts index 8f37522d..350183fc 100644 --- a/scripts/qualified-starters.ts +++ b/scripts/qualified-starters.ts @@ -13,6 +13,7 @@ export const QUALIFIED_STARTERS = [ 'lit-fastify', 'lit-hono', 'lit-hapi', + 'react-hono', ] as const; export type QualifiedStarter = (typeof QUALIFIED_STARTERS)[number]; diff --git a/scripts/saga-init-offered.test.ts b/scripts/saga-init-offered.test.ts index 15fa929c..1cd07800 100644 --- a/scripts/saga-init-offered.test.ts +++ b/scripts/saga-init-offered.test.ts @@ -11,6 +11,7 @@ import { sourceRevision, buildConsumerFromConfig, type PackedPackage, + type SdkPackage, } from './packed-consumer.js'; import { SUPPORTED_PAIRS, runCli } from './saga.js'; import { readTemplateMetadata, templateDirectory } from './starter-template.js'; @@ -23,26 +24,42 @@ import { readTemplateMetadata, templateDirectory } from './starter-template.js'; // (starter*.test.ts, apps/lit-host/e2e/starter.spec.ts) and is not repeated here. const work = createWorkDirectory('saga-init-offered-'); -let packed: PackedPackage[] = []; let version = ''; +/** The SDK packages every offered pair's template names, packed once for all of them. */ +const packedSet = (): SdkPackage[] => + [ + ...new Set( + SUPPORTED_PAIRS.flatMap( + (pair) => + readTemplateMetadata(templateDirectory(pair.template)).sdkPackages, + ), + ), + ].sort(); +const packedByName = new Map(); + beforeAll(() => { const { commit, dirty } = sourceRevision(); version = candidateVersion(commit, dirty); - packed = packSdkPackages( + for (const one of packSdkPackages( work, - ['core', 'lit', 'components', 'mcp', 'lit-mcp-stack'], + packedSet(), path.join(work, 'tarballs'), version, - ); + )) + packedByName.set(one.name, one); }, 300_000); afterAll(() => fs.rmSync(work, { recursive: true, force: true })); -async function init(cwd: string, server: string): Promise { +async function init( + cwd: string, + stack: string, + server: string, +): Promise { let err = ''; const code = await runCli( - ['init', '--a2ui-stack', 'lit-mcp', '--server', server, 'project'], + ['init', '--a2ui-stack', stack, '--server', server, 'project'], { cwd, sdkVersion: () => version, @@ -53,7 +70,7 @@ async function init(cwd: string, server: string): Promise { }, ); if (code !== 0) - throw new Error(`saga init lit-mcp on ${server} exited ${code}: ${err}`); + throw new Error(`saga init ${stack} on ${server} exited ${code}: ${err}`); return path.join(cwd, 'project'); } @@ -61,9 +78,15 @@ describe.each(SUPPORTED_PAIRS)( 'the project saga init creates for $stack on $server', (pair) => { it('installs the matching packed candidates, type-checks and builds with its own configuration', async () => { - const cwd = fs.mkdtempSync(path.join(work, `${pair.server}-`)); - const project = await init(cwd, pair.server); + const cwd = fs.mkdtempSync( + path.join(work, `${pair.stack}-${pair.server}-`), + ); + const project = await init(cwd, pair.stack, pair.server); const metadata = readTemplateMetadata(templateDirectory(pair.template)); + // The packages this pair's template names, from the one packed set; no other is installed into it. + const packed = metadata.sdkPackages.map( + (name) => packedByName.get(name) as PackedPackage, + ); // The pins the command wrote are exactly the candidate that is installed. const manifest = JSON.parse( diff --git a/scripts/saga.test.ts b/scripts/saga.test.ts index 65bd2382..7b037560 100644 --- a/scripts/saga.test.ts +++ b/scripts/saga.test.ts @@ -258,7 +258,7 @@ describe('the pairs it offers', () => { } }); - it('offers the five qualified servers for the stack, each on its own maintained template', () => { + it('offers the five qualified Lit servers and the React view on Hono, each on its own maintained template', () => { expect( SUPPORTED_PAIRS.map( (pair) => `${pair.stack} on ${pair.server} from ${pair.template}`, @@ -269,13 +269,24 @@ describe('the pairs it offers', () => { 'lit-mcp on fastify from lit-fastify', 'lit-mcp on hono from lit-hono', 'lit-mcp on hapi from lit-hapi', + 'react-mcp on hono from react-hono', ]); }); it.each([ ['a server with no qualified starter', 'lit-mcp', 'koa'], - ['a qualified server for an unqualified stack', 'react-mcp', 'express'], - ['another stack on the qualified server', 'react-mcp', 'node'], + [ + 'the React stack on a server it is not qualified on', + 'react-mcp', + 'express', + ], + ['the React stack on a server it is not qualified on', 'react-mcp', 'node'], + ['the React stack on a server it is not qualified on', 'react-mcp', 'hapi'], + [ + 'the Lit stack on a server that is not qualified', + 'lit-mcp', + 'react-hono', + ], ['a stack without its delivery', 'lit', 'node'], ['a stack that names no renderer', 'mcp', 'node'], ['a server that is only a path', 'lit-mcp', '../node'], diff --git a/scripts/saga.ts b/scripts/saga.ts index 25fddaf1..35a11a2e 100644 --- a/scripts/saga.ts +++ b/scripts/saga.ts @@ -50,8 +50,9 @@ export interface QualifiedPair { * removes it while that qualification fails. The rows follow `QUALIFIED_STARTERS` * (scripts/qualified-starters.ts), whose templates CI qualifies from packed artifacts: Node * (scripts/starter.test.ts), Express (starter-express.test.ts), Fastify (starter-fastify.test.ts) and - * Hono (starter-hono.test.ts) and Hapi (starter-hapi.test.ts), each also in apps/lit-host/e2e/starter.spec.ts. No other pair is - * offered: React has no qualified template. + * Hono (starter-hono.test.ts) and Hapi (starter-hapi.test.ts) for `lit-mcp`, and the React view on + * Hono (starter-react-hono.test.ts), each also in apps/lit-host/e2e/starter.spec.ts. No other pair + * is offered: `react-mcp` has a qualified template on Hono only. */ export const SUPPORTED_PAIRS: readonly QualifiedPair[] = [ { stack: 'lit-mcp', server: 'node', template: 'lit-node' }, @@ -59,6 +60,7 @@ export const SUPPORTED_PAIRS: readonly QualifiedPair[] = [ { stack: 'lit-mcp', server: 'fastify', template: 'lit-fastify' }, { stack: 'lit-mcp', server: 'hono', template: 'lit-hono' }, { stack: 'lit-mcp', server: 'hapi', template: 'lit-hapi' }, + { stack: 'react-mcp', server: 'hono', template: 'react-hono' }, ]; /** diff --git a/scripts/starter-hono-cases.ts b/scripts/starter-hono-cases.ts new file mode 100644 index 00000000..76ce01c1 --- /dev/null +++ b/scripts/starter-hono-cases.ts @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { repoRoot } from './packed-consumer.js'; +import type { StarterHarness } from './starter-qualification.js'; +import { readTemplateMetadata, templateDirectory } from './starter-template.js'; + +// The cases only the Hono pairs add to the shared starter qualification (starter-qualification.ts): +// the official adapter's Host and Origin validation and the tested version recipe. Both Hono +// templates (templates/lit-hono and templates/react-hono) run them against their own built +// listener, because their server text is the same text (starter-hono.test.ts checks that). + +export function honoAdapterCases({ + template, + built, + start, + allowedToken, +}: Pick< + StarterHarness, + 'template' | 'built' | 'start' | 'allowedToken' +>): void { + describe("the official adapter's Host and Origin validation", () => { + /** A raw request, so the Host and Origin headers can be chosen; the answer is read as the HTTP answer it is. */ + function rawRequest( + url: string, + headers: Record, + ): Promise<{ status: number | undefined; text: string }> { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'get_status', arguments: {} }, + }); + const request = http.request(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'content-length': String(Buffer.byteLength(body)), + ...headers, + }, + }); + request.on('response', (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => + resolve({ + status: response.statusCode, + text: Buffer.concat(chunks).toString('utf8'), + }), + ); + }); + request.on('error', reject); + request.end(body); + }); + } + + it('answers a request for the local host name the starter lists, with or without a local Origin', async () => { + const { listener } = await start(); + const { port } = new URL(listener.statusUrl); + expect( + (await rawRequest(listener.statusUrl, { host: `localhost:${port}` })) + .status, + ).toBe(200); + expect( + ( + await rawRequest(listener.statusUrl, { + origin: `http://localhost:${port}`, + }) + ).status, + ).toBe(200); + }); + + it.each([ + [ + 'a Host header for another name (DNS rebinding)', + { host: 'rebound.example' }, + ], + ['an Origin from another site', { origin: 'https://other-site.example' }], + ] as const)( + 'refuses %s with 403 on both endpoints, and no credential or protected work is reached', + async (_label, headers) => { + const { listener, calls } = await start(); + for (const url of [listener.statusUrl, listener.accountUrl]) { + const answered = await rawRequest(url, { + ...headers, + authorization: `Bearer ${allowedToken}`, + }); + expect(answered.status, url).toBe(403); + expect(answered.text, url).toContain('"jsonrpc":"2.0"'); + } + expect(calls).toEqual({ verifier: 0, authorize: 0 }); + }, + ); + }); + + describe('the tested recipe', () => { + const read = (file: string): Record => + JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + const installed = ( + name: string, + ): { version?: string; peerDependencies?: Record } => + read(path.join(repoRoot, 'node_modules', name, 'package.json')); + + it('pins the adapter, its host and its peers to the exact versions this suite ran against', () => { + const metadata = readTemplateMetadata(templateDirectory(template)); + const manifest = read( + path.join(built().exported.directory, 'package.json'), + ) as { dependencies: Record; engines: { node: string } }; + + // The exported manifest names what was installed here, not a range that may resolve to something else. + for (const name of [ + '@modelcontextprotocol/hono', + '@hono/node-server', + 'hono', + '@modelcontextprotocol/server', + '@modelcontextprotocol/client', + ]) { + expect(manifest.dependencies[name], name).toBe(installed(name).version); + } + expect(manifest.dependencies[metadata.serverAdapter.package]).toBe( + metadata.serverAdapter.version, + ); + // The adapter's own peer ranges are what the starter's pins were chosen to satisfy; a new adapter that changes them must be looked at. + expect(installed('@modelcontextprotocol/hono').peerDependencies).toEqual({ + '@modelcontextprotocol/server': '^2.3.0', + hono: '^4.11.4', + }); + expect(manifest.engines.node).toBe('>=22.12.0'); + }); + }); +} diff --git a/scripts/starter-hono.test.ts b/scripts/starter-hono.test.ts index 4463bde6..edce2ca3 100644 --- a/scripts/starter-hono.test.ts +++ b/scripts/starter-hono.test.ts @@ -1,14 +1,9 @@ import fs from 'node:fs'; -import http from 'node:http'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { repoRoot } from './packed-consumer.js'; +import { honoAdapterCases } from './starter-hono-cases.js'; import { qualifyStarter } from './starter-qualification.js'; -import { - readTemplateMetadata, - templateDirectory, - templateFiles, -} from './starter-template.js'; +import { templateDirectory, templateFiles } from './starter-template.js'; // The `lit-mcp` starter on Hono (templates/lit-hono, STARTER-HONO #63): the server on the official // `@modelcontextprotocol/hono` 2.0.2 adapter and `@hono/node-server`. The shared cases of @@ -16,124 +11,28 @@ import { // missing-auth behavior, concurrent per-request identity (including a verifier that reuses and // rewrites the object it returned), the request and operation clocks, and the HTTP body and // delivery boundary, which Hono hands to the application differently from the other targets. The -// cases below are the ones only this pair adds: the adapter's Host and Origin validation, and the -// tested version recipe. The browser behavior of the built view is qualified for every pair by +// cases below are the ones only the Hono pairs add (starter-hono-cases.ts, which the React copy +// runs too), and the byte-identity rules that keep the two Hono templates' server text one text. The browser behavior of the built view is qualified for every pair by // apps/lit-host/e2e/starter.spec.ts. const STARTER = 'lit-hono'; +/** The React starter on Hono: its `server/` is this template's text, so a server fix lands in both. */ +const REACT_COPY = 'react-hono'; +/** Outside `server/`, the files that build and check the server: the same in both Hono templates. */ +const SERVER_SIDE_FILES = ['tsconfig.server.json', 'vite.server.config.ts']; +const HONO_PINS = [ + '@modelcontextprotocol/hono', + '@hono/node-server', + 'hono', + '@modelcontextprotocol/server', + '@modelcontextprotocol/client', + 'zod', +]; -qualifyStarter(STARTER, ({ built, start, allowedToken }) => { - describe("the official adapter's Host and Origin validation", () => { - /** A raw request, so the Host and Origin headers can be chosen; the answer is read as the HTTP answer it is. */ - function rawRequest( - url: string, - headers: Record, - ): Promise<{ status: number | undefined; text: string }> { - return new Promise((resolve, reject) => { - const body = JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: 'get_status', arguments: {} }, - }); - const request = http.request(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'content-length': String(Buffer.byteLength(body)), - ...headers, - }, - }); - request.on('response', (response) => { - const chunks: Buffer[] = []; - response.on('data', (chunk: Buffer) => chunks.push(chunk)); - response.on('end', () => - resolve({ - status: response.statusCode, - text: Buffer.concat(chunks).toString('utf8'), - }), - ); - }); - request.on('error', reject); - request.end(body); - }); - } - - it('answers a request for the local host name the starter lists, with or without a local Origin', async () => { - const { listener } = await start(); - const { port } = new URL(listener.statusUrl); - expect( - (await rawRequest(listener.statusUrl, { host: `localhost:${port}` })) - .status, - ).toBe(200); - expect( - ( - await rawRequest(listener.statusUrl, { - origin: `http://localhost:${port}`, - }) - ).status, - ).toBe(200); - }); - - it.each([ - [ - 'a Host header for another name (DNS rebinding)', - { host: 'rebound.example' }, - ], - ['an Origin from another site', { origin: 'https://other-site.example' }], - ] as const)( - 'refuses %s with 403 on both endpoints, and no credential or protected work is reached', - async (_label, headers) => { - const { listener, calls } = await start(); - for (const url of [listener.statusUrl, listener.accountUrl]) { - const answered = await rawRequest(url, { - ...headers, - authorization: `Bearer ${allowedToken}`, - }); - expect(answered.status, url).toBe(403); - expect(answered.text, url).toContain('"jsonrpc":"2.0"'); - } - expect(calls).toEqual({ verifier: 0, authorize: 0 }); - }, - ); - }); +qualifyStarter(STARTER, (harness) => { + honoAdapterCases(harness); describe('the tested recipe', () => { - const read = (file: string): Record => - JSON.parse(fs.readFileSync(file, 'utf8')) as Record; - const installed = ( - name: string, - ): { version?: string; peerDependencies?: Record } => - read(path.join(repoRoot, 'node_modules', name, 'package.json')); - - it('pins the adapter, its host and its peers to the exact versions this suite ran against', () => { - const metadata = readTemplateMetadata(templateDirectory(STARTER)); - const manifest = read( - path.join(built().exported.directory, 'package.json'), - ) as { dependencies: Record; engines: { node: string } }; - - // The exported manifest names what was installed here, not a range that may resolve to something else. - for (const name of [ - '@modelcontextprotocol/hono', - '@hono/node-server', - 'hono', - '@modelcontextprotocol/server', - '@modelcontextprotocol/client', - ]) { - expect(manifest.dependencies[name], name).toBe(installed(name).version); - } - expect(manifest.dependencies[metadata.serverAdapter.package]).toBe( - metadata.serverAdapter.version, - ); - // The adapter's own peer ranges are what the starter's pins were chosen to satisfy; a new adapter that changes them must be looked at. - expect(installed('@modelcontextprotocol/hono').peerDependencies).toEqual({ - '@modelcontextprotocol/server': '^2.3.0', - hono: '^4.11.4', - }); - expect(manifest.engines.node).toBe('>=22.12.0'); - }); - it('is the Node starter with only the server adapter replaced: its view, operations and policy files are the same files', () => { const node = templateDirectory('lit-node'); const hono = templateDirectory(STARTER); @@ -152,5 +51,48 @@ qualifyStarter(STARTER, ({ built, start, allowedToken }) => { ); } }); + + it('is the same server in the React copy: its server files, server build and policy are byte-identical, and its Hono pins are the same', () => { + const lit = templateDirectory(STARTER); + const react = templateDirectory(REACT_COPY); + const own = (directory: string): string[] => + templateFiles(directory).filter( + (file) => + file.startsWith('server/') || SERVER_SIDE_FILES.includes(file), + ); + expect(own(react)).toEqual(own(lit)); + for (const file of own(lit)) + expect(fs.readFileSync(path.join(react, file), 'utf8'), file).toBe( + fs.readFileSync(path.join(lit, file), 'utf8'), + ); + + // The wire contract is shared by the server and the view; only the element the view renders into differs. + const withoutMarker = (directory: string): string => + fs + .readFileSync(path.join(directory, 'src/contract.ts'), 'utf8') + .replace(/export const VIEW_MARKER = .*;\n/, ''); + expect(withoutMarker(react)).toBe(withoutMarker(lit)); + + const pins = (directory: string): Record => { + const { dependencies } = JSON.parse( + fs.readFileSync(path.join(directory, 'package.json'), 'utf8'), + ) as { dependencies: Record }; + return Object.fromEntries( + HONO_PINS.map((name) => [name, dependencies[name] as string]), + ); + }; + expect(pins(react)).toEqual(pins(lit)); + expect(Object.values(pins(lit)).every((one) => /^\d/.test(one))).toBe( + true, + ); + const metadata = (directory: string) => + JSON.parse( + fs.readFileSync(path.join(directory, 'template.json'), 'utf8'), + ) as { server: string; serverAdapter: unknown }; + expect(metadata(react).server).toBe(metadata(lit).server); + expect(metadata(react).serverAdapter).toEqual( + metadata(lit).serverAdapter, + ); + }); }); }); diff --git a/scripts/starter-qualification.ts b/scripts/starter-qualification.ts index 35024bb1..1152edf7 100644 --- a/scripts/starter-qualification.ts +++ b/scripts/starter-qualification.ts @@ -81,6 +81,7 @@ export function qualifyStarter( startListener(options: ListenerOptions): Promise; readonly ACCOUNT_SCOPE: string; readonly ViewArtifactMissing: new () => Error; + readonly VIEW_MARKER: string; } const ACCOUNT_A = 'qualification-account-a'; @@ -975,11 +976,14 @@ export function qualifyStarter( ).toMatch(/throw\s+new\s+OAuthError\(\s*OAuthErrorCode\.InvalidToken/); // SDK behavior is imported, not reimplemented: no copy of the runtime's modules in the starter's own source. const own = exported - .filter((one) => one.file.endsWith('.ts')) + .filter((one) => /\.tsx?$/.test(one.file)) .map((one) => one.text) .join('\n'); + // The starter imports its authoring surface from the one stack package its template declares. expect(own).toMatch( - /import\s*\{[^}]*\bAction\b[^}]*\}\s*from\s*'@saga-a2ui\/lit-mcp-stack'/, + new RegExp( + `import\\s*\\{[^}]*\\bAction\\b[^}]*\\}\\s*from\\s*'@saga-a2ui/${built.metadata.stack}-stack'`, + ), ); expect(own).not.toMatch( /function\s+(createBinding|createInstanceHost|registerProtectedOperation|createMessageSession)\b/, @@ -1002,7 +1006,7 @@ export function qualifyStarter( /NodeStreamableHTTPServerTransport|verifyAccessToken|registerProtectedOperation|@modelcontextprotocol\/(node|express)|localhostHostValidation/, ); expect(html).not.toMatch(/(?:from|import|require)\s*\(?\s*["']node:/); - expect(html).toContain(' { ['lit-express', 'req.auth = decision.authInfo'], ['lit-fastify', 'reply.code(decision.status)'], ['lit-hono', 'decision.authInfo'], + ['react-hono', 'decision.authInfo'], ] as const)( '%s: carry the identity handoff and the direct attachment a developer is meant to copy', (name, handoff) => { @@ -330,6 +331,26 @@ describe('snippets', () => { }, ); + it('are read from .tsx files too, and carry the language of the file they come from', () => { + const language = (name: string) => + Object.fromEntries( + extractSnippets(templateDirectory(name)).map((snippet) => [ + snippet.name, + [snippet.file, snippet.language], + ]), + ); + // The React template's view is a .tsx file, so its snippet is shown as TSX; the Lit template's stays TS. + expect(language('react-hono')['bind-action']).toEqual([ + 'src/view.tsx', + 'tsx', + ]); + expect(language('react-hono')['register-protected-operation']).toEqual([ + 'server/operations.ts', + 'ts', + ]); + expect(language('lit-hono')['bind-action']).toEqual(['src/view.ts', 'ts']); + }); + it('are written as files into a new directory, never over an existing one', () => { const target = path.join(work, 'snippet-files'); const written = writeSnippets(template, target); diff --git a/scripts/starter-template.ts b/scripts/starter-template.ts index 3e2a3f36..79819552 100644 --- a/scripts/starter-template.ts +++ b/scripts/starter-template.ts @@ -67,20 +67,22 @@ const REGION = export interface Snippet { readonly name: string; readonly file: string; + /** The language of the file it comes from, which is the language a guide shows it in. */ + readonly language: 'ts' | 'tsx'; readonly text: string; } /** * Cuts every `// #region saga-snippet:` ... `// #endregion saga-snippet:` out of the - * template's TypeScript files. The text is the template's own lines, unchanged and not dedented, - * so a guide that shows it shows exactly what the tested starter contains. An unclosed, - * unopened, mismatched or repeated region is an error: a snippet that silently differs from its - * source is the failure this exists to prevent. + * template's TypeScript files (`.ts` and `.tsx`). The text is the template's own lines, unchanged + * and not dedented, so a guide that shows it shows exactly what the tested starter contains. An + * unclosed, unopened, mismatched or repeated region is an error: a snippet that silently differs + * from its source is the failure this exists to prevent. */ export function extractSnippets(template: string): Snippet[] { const found: Snippet[] = []; for (const file of templateFiles(template).filter((one) => - one.endsWith('.ts'), + /\.tsx?$/.test(one), )) { let open: { readonly name: string; readonly lines: string[] } | undefined; for (const line of fs @@ -108,7 +110,12 @@ export function extractSnippets(template: string): Snippet[] { throw new TemplateError( `${file}: the end of snippet "${name}" does not match an open snippet.`, ); - found.push({ name, file, text: open.lines.join('\n') }); + found.push({ + name, + file, + language: file.endsWith('.tsx') ? 'tsx' : 'ts', + text: open.lines.join('\n'), + }); open = undefined; } } @@ -121,8 +128,9 @@ export function extractSnippets(template: string): Snippet[] { } /** - * Writes each snippet as `.ts` into a directory that does not exist yet, so a guide or the - * CLI can include the files rather than retype them. It never overwrites. + * Writes each snippet as `.ts` (`.tsx` when it comes from a `.tsx` file) into a + * directory that does not exist yet, so a guide or the CLI can include the files rather than + * retype them. It never overwrites. */ export function writeSnippets(template: string, destination: string): string[] { const target = path.resolve(destination); @@ -130,7 +138,7 @@ export function writeSnippets(template: string, destination: string): string[] { const snippets = extractSnippets(template); fs.mkdirSync(target, { recursive: true }); return snippets.map((snippet) => { - const out = path.join(target, `${snippet.name}.ts`); + const out = path.join(target, `${snippet.name}.${snippet.language}`); fs.writeFileSync(out, `${snippet.text}\n`, { flag: 'wx' }); return out; }); diff --git a/scripts/tour.test.ts b/scripts/tour.test.ts index 5b6d4291..40618073 100644 --- a/scripts/tour.test.ts +++ b/scripts/tour.test.ts @@ -354,6 +354,6 @@ describe('the scenarios the tour offers', () => { (one) => one.id === 'react', ) as (typeof RENDERERS)[number]; expect(react.evidence).toMatch(/not renderer parity/); - expect(react.evidence).toMatch(/no React starter/); + expect(react.evidence).toMatch(/not the React starter/); }); }); diff --git a/templates/README.md b/templates/README.md index 32442cb3..c456cebe 100644 --- a/templates/README.md +++ b/templates/README.md @@ -14,11 +14,14 @@ generated from the qualified templates by `npm run guide:recipes`; adding a temp | [`lit-fastify`](lit-fastify/) | `lit-mcp` (Lit view, MCP delivery) | `fastify` (`@modelcontextprotocol/fastify` 2.0.1, `fastify` 5.12.7) | Qualified in CI: [`scripts/starter-fastify.test.ts`](../scripts/starter-fastify.test.ts) and [`apps/lit-host/e2e/starter.spec.ts`](../apps/lit-host/e2e/starter.spec.ts) | | [`lit-hono`](lit-hono/) | `lit-mcp` (Lit view, MCP delivery) | `hono` (`@modelcontextprotocol/hono` 2.0.2 with `hono` 4.13.13 and `@hono/node-server` 1.19.17) | Qualified in CI: [`scripts/starter-hono.test.ts`](../scripts/starter-hono.test.ts) and [`apps/lit-host/e2e/starter.spec.ts`](../apps/lit-host/e2e/starter.spec.ts) | | [`lit-hapi`](lit-hapi/README.md) | `lit-mcp` (Lit view, MCP delivery) | `hapi` (SAGA-maintained [bridge](lit-hapi/server/bridge.ts) over `@modelcontextprotocol/node` 2.1.1, Hapi 21.4.10) | Qualified in CI: [`scripts/starter-hapi.test.ts`](../scripts/starter-hapi.test.ts), [`scripts/starter-hapi-identity.test.ts`](../scripts/starter-hapi-identity.test.ts) and [`apps/lit-host/e2e/starter.spec.ts`](../apps/lit-host/e2e/starter.spec.ts) | +| [`react-hono`](react-hono/) | `react-mcp` (React view, MCP delivery) | `hono` (the same `@modelcontextprotocol/hono` 2.0.2, `hono` 4.13.13 and `@hono/node-server` 1.19.17 as `lit-hono`) | Qualified in CI: [`scripts/starter-react-hono.test.ts`](../scripts/starter-react-hono.test.ts), [`scripts/starter-hono.test.ts`](../scripts/starter-hono.test.ts) and [`apps/lit-host/e2e/starter.spec.ts`](../apps/lit-host/e2e/starter.spec.ts) | The synthetic demonstration of the `lit-node` pair is [`demo/lit-node`](../demo/lit-node/README.md): it imports this template's request-authentication files as they ship and keeps every identity and fixture in its own directory. `lit-hono` is `lit-node` with only the server adapter files replaced (`server/listener.ts`, `server/authenticate.ts`, `package.json` and its README); a test fails if any other file differs. +`react-hono` is the same application with a React view: its `server/` (and the server build files `tsconfig.server.json` and `vite.server.config.ts`) is the `lit-hono` text, byte for byte, and `scripts/starter-hono.test.ts` fails if either copy changes alone. Its `src/` is written in `.tsx` against `@saga-a2ui/react-mcp-stack` and keeps the same public status, protected account operation (the verifier refuses every credential) and local "Clear the note" action. Each template declares in `src/contract.ts` the `VIEW_MARKER` its built page must contain, which the shared `server/operations.ts` checks before listening (` npx tsx scripts/build-starter.ts --template lit-hono # one of them -npx tsx scripts/starter-snippets.ts # list the attach-to-an-existing-backend snippets (--template lit-express or lit-hono for the other pairs) +npx tsx scripts/starter-snippets.ts # list the attach-to-an-existing-backend snippets (--template lit-express, lit-hono or react-hono for the other pairs) npx tsx scripts/starter-snippets.ts --out DIR # write them as files into a new directory npx tsx scripts/starter-snippets.ts --template lit-fastify # the Fastify pair's snippets npx tsx scripts/starter-snippets.ts --template lit-hono # choose another template (default lit-node) @@ -65,9 +68,9 @@ yields to the event loop between files, which a synchronous copy cannot do. npx tsx scripts/saga.ts init --a2ui-stack lit-mcp --server node ./my-app # a new project from a qualified pair ``` -`scripts/saga.ts` offers the pairs listed in its `SUPPORTED_PAIRS`, not every directory here: today all five -templates (`lit-node`, `lit-express`, `lit-fastify`, `lit-hono`, `lit-hapi`) are offered, each with `--a2ui-stack lit-mcp` and its -own `--server`. When a pair is qualified, its owner adds one row there; while it fails, the row is removed. +`scripts/saga.ts` offers the pairs listed in its `SUPPORTED_PAIRS`, not every directory here: today all six +templates are offered. `lit-node`, `lit-express`, `lit-fastify`, `lit-hono` and `lit-hapi` each take `--a2ui-stack lit-mcp` and +their own `--server`; `react-hono` takes `--a2ui-stack react-mcp --server hono`. When a pair is qualified, its owner adds one row there; while it fails, the row is removed. `saga demo --a2ui-stack lit-mcp --server node ./my-demo` is a different thing from a starter: it creates the standalone demonstration (`demo/lit-node`, DEMO1) of the pair `DEMO_TEMPLATE` names. Its synthetic accounts and diff --git a/templates/lit-express/server/operations.ts b/templates/lit-express/server/operations.ts index 6c0999e6..256407f1 100644 --- a/templates/lit-express/server/operations.ts +++ b/templates/lit-express/server/operations.ts @@ -15,6 +15,7 @@ import { STATUS_TOOL, StatusOutputSchema, TEXT, + VIEW_MARKER, } from '../src/contract.js'; import { identityOf, type AccountIdentity } from './identity.js'; @@ -37,7 +38,7 @@ export function prepareStarterView( html: unknown, filename = 'index.html', ): OperationView { - if (typeof html !== 'string' || !html.includes(' + + + + + + SAGA starter + + +

+ + +
+ + + diff --git a/templates/react-hono/package.json b/templates/react-hono/package.json new file mode 100644 index 00000000..b08b9ae0 --- /dev/null +++ b/templates/react-hono/package.json @@ -0,0 +1,43 @@ +{ + "name": "saga-react-hono-starter", + "private": true, + "description": "A SAGA A2UI starter: a React view served by a Hono MCP server.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build:view": "vite build", + "build:server": "vite build --config vite.server.config.ts", + "build": "npm run build:view && npm run build:server", + "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.server.json", + "start": "node dist/server.mjs" + }, + "dependencies": { + "@hono/node-server": "1.19.17", + "@modelcontextprotocol/client": "2.3.1", + "@modelcontextprotocol/hono": "2.0.2", + "@modelcontextprotocol/server": "2.3.1", + "@saga-a2ui/components": "*", + "@saga-a2ui/core": "*", + "@saga-a2ui/mcp": "*", + "@saga-a2ui/react": "*", + "@saga-a2ui/react-mcp-stack": "*", + "hono": "4.13.13", + "react": "19.3.1", + "react-dom": "19.3.1", + "zod": "4.6.7" + }, + "devDependencies": { + "@types/node": "22.20.5", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "typescript": "^5.9.0", + "vite": "^8.3.4", + "vite-plugin-singlefile": "2.3.3" + }, + "overrides": { + "@a2ui/markdown-it": "0.2.1" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/templates/react-hono/server/authenticate.ts b/templates/react-hono/server/authenticate.ts new file mode 100644 index 00000000..21365eae --- /dev/null +++ b/templates/react-hono/server/authenticate.ts @@ -0,0 +1,89 @@ +import { + OAuthError, + OAuthErrorCode, + type AuthInfo, + type OAuthTokenVerifier, +} from '@modelcontextprotocol/server'; +import type { RequestLifetime } from '@saga-a2ui/mcp/server'; +import { identityOf } from './identity.js'; + +// APPLICATION-OWNED request authentication for the protected endpoint. A verifier (verifier.ts) +// decides who a bearer credential belongs to; this module decides what the application accepts +// and turns the outcome into HTTP. It never reads an identity from tool arguments or from shared +// "current user" state: the result belongs to exactly one request. + +/** The scope a credential must carry to reach the protected endpoint. */ +export const ACCOUNT_SCOPE = 'account:read'; +const REALM = 'saga-starter'; + +export type AuthDecision = + | { readonly ok: true; readonly authInfo: AuthInfo } + | { + readonly ok: false; + readonly status: 401 | 403 | 503; + readonly challenge?: string; + }; + +const refuse = ( + status: 401 | 403, + error: string, + scope?: string, +): AuthDecision => ({ + ok: false, + status, + challenge: `Bearer realm="${REALM}", error="${error}"${scope === undefined ? '' : `, scope="${scope}"`}`, +}); + +/** + * Checks the value of the `Authorization` header: a bearer credential the verifier accepts, not expired, + * carrying `ACCOUNT_SCOPE` and naming an account. Anything else is refused before any protected + * work. The verifier runs as the request's `authentication` stage, so a verifier that never + * answers cannot hold the request past its budget. + */ +export async function authenticate( + header: string | undefined, + verifier: OAuthTokenVerifier, + lifetime: RequestLifetime, +): Promise { + if (header === undefined) + return { ok: false, status: 401, challenge: `Bearer realm="${REALM}"` }; + const match = /^Bearer ([^\s]+)$/i.exec(header); + if (!match?.[1]) return refuse(401, 'invalid_token'); + + const token = match[1]; + const verified = await lifetime.run('authentication', () => + verifier.verifyAccessToken(token), + ); + if (!verified.ok) { + // A verifier that rejects the credential is a 401; one that fails or runs out of time is not the caller's fault. + const cause = verified.cause; + const rejected = + cause.kind === 'dependency-failure' && + OAuthError.isInstance(cause.error) && + cause.error.code === OAuthErrorCode.InvalidToken; + return rejected ? refuse(401, 'invalid_token') : { ok: false, status: 503 }; + } + const authInfo = verified.value; + if ( + typeof authInfo.expiresAt !== 'number' || + authInfo.expiresAt <= Date.now() / 1000 + ) + return refuse(401, 'invalid_token'); + if (!authInfo.scopes.includes(ACCOUNT_SCOPE)) + return refuse(403, 'insufficient_scope', ACCOUNT_SCOPE); + const identity = identityOf(authInfo); + if (!identity) return refuse(403, 'insufficient_scope', ACCOUNT_SCOPE); + // The verifier may reuse the object it returned (a cached token record). Copy what this request + // owns and freeze it, so a later request's claims can never decide this one's account. + return { + ok: true, + authInfo: Object.freeze({ + ...authInfo, + scopes: [...authInfo.scopes], + extra: Object.freeze({ + ...authInfo.extra, + accountId: identity.accountId, + }), + }), + }; +} diff --git a/templates/react-hono/server/authorization.ts b/templates/react-hono/server/authorization.ts new file mode 100644 index 00000000..767822ef --- /dev/null +++ b/templates/react-hono/server/authorization.ts @@ -0,0 +1,14 @@ +import type { AccountIdentity } from './identity.js'; + +// APPLICATION-OWNED. SAGA verifies nothing about what an account may do: the server asks this +// function for every protected request, with that request's own identity. Replace the body with +// your policy (a database lookup, a role check). It receives a signal that aborts when the +// request's budget ends, so a slow lookup can stop. + +/** The starter allows every signed-in account. That is a placeholder, not a policy. */ +export async function mayViewAccount( + _identity: AccountIdentity, + _signal: AbortSignal, +): Promise { + return true; +} diff --git a/templates/react-hono/server/endpoint.ts b/templates/react-hono/server/endpoint.ts new file mode 100644 index 00000000..8c8112fb --- /dev/null +++ b/templates/react-hono/server/endpoint.ts @@ -0,0 +1,40 @@ +import { + createMcpHandler, + type CreateMcpHandlerOptions, + type McpHandlerRequestOptions, + type McpServer, +} from '@modelcontextprotocol/server'; +import type { RequestLifetime } from '@saga-a2ui/mcp/server'; + +/** One endpoint's persistent handler, admission association, notification bus and capacity. */ +export function createEndpoint( + build: (lifetime: RequestLifetime) => McpServer, + options: CreateMcpHandlerOptions = {}, +) { + const admissions = new WeakMap(); + const handler = createMcpHandler((context) => { + const lifetime = + context.requestInfo === undefined + ? undefined + : admissions.get(context.requestInfo); + if (lifetime === undefined) + throw new Error('The MCP request has no admitted lifetime.'); + return build(lifetime); + }, options); + return { + notify: handler.notify, + close: handler.close, + fetch( + request: Request, + lifetime: RequestLifetime, + requestOptions: McpHandlerRequestOptions, + ): Promise { + admissions.set(request, lifetime); + // A bounded parsedBody must be supplied for POST: the legacy classifier otherwise clones + // the Request, which would discard this object-identity association. + return handler.fetch(request, requestOptions); + }, + }; +} + +export type Endpoint = ReturnType; diff --git a/templates/react-hono/server/identity.ts b/templates/react-hono/server/identity.ts new file mode 100644 index 00000000..4df323de --- /dev/null +++ b/templates/react-hono/server/identity.ts @@ -0,0 +1,20 @@ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +/** The application's own immutable value for who is asking. Nothing a caller sends can name it. */ +export interface AccountIdentity { + readonly accountId: string; +} + +/** + * Turns the verified request context into an identity. A credential that was verified but names + * no account (a service credential, for example) yields `undefined`, and a protected operation + * then fails closed without running its handler. + */ +export function identityOf( + authInfo: AuthInfo | undefined, +): AccountIdentity | undefined { + const accountId = authInfo?.extra?.accountId; + return typeof accountId === 'string' && accountId !== '' + ? Object.freeze({ accountId }) + : undefined; +} diff --git a/templates/react-hono/server/listener.ts b/templates/react-hono/server/listener.ts new file mode 100644 index 00000000..c255eda1 --- /dev/null +++ b/templates/react-hono/server/listener.ts @@ -0,0 +1,313 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { getRequestListener, type HttpBindings } from '@hono/node-server'; +import { + hostHeaderValidation, + localhostOriginValidation, +} from '@modelcontextprotocol/hono'; +import { + readRequestBody, + type AuthInfo, + type OAuthTokenVerifier, +} from '@modelcontextprotocol/server'; +import { + createServerBudgets, + type BudgetSettings, + type LifetimeCause, + type RequestLifetime, +} from '@saga-a2ui/mcp/server'; +import { Hono, type Context } from 'hono'; +import { createEndpoint, type Endpoint } from './endpoint.js'; +import { authenticate } from './authenticate.js'; +import { + createAccountServer, + createStatusServer, + prepareStarterView, + type AccountServerOptions, +} from './operations.js'; + +// APPLICATION-OWNED HTTP hosting for the MCP servers: a Hono application on a loopback Node listener, +// stateless Streamable HTTP (a persistent handler per endpoint, a fresh server per request, modern auto responses) and DNS-rebinding +// protection from the official `@modelcontextprotocol/hono` middleware (Host and Origin). Put your own +// ingress (TLS, a public host name) in front of it, and add that host name to `ALLOWED_HOSTNAMES`, +// when you deploy. +// +// The adapter's `createMcpHonoApp` is not used: it reads and parses the request body before your routes +// run, so the application could not bound that wait by the request's lifetime. This file installs the +// adapter's two validators and reads the body itself, as a bounded stage. +// +// Time: each request's lifetime starts when the request is admitted, before any credential or body +// wait. This file wires that one deadline into the waits the application owns: authentication, the +// request body and delivery of the response. A body that stalls is answered once with 408 and its +// connection is closed. A response the deadline overtakes is destroyed, never replaced, so a +// committed response is not given a second status. + +const HOST = '127.0.0.1'; +const ALLOWED_HOSTNAMES = ['127.0.0.1', 'localhost']; +const STATUS_PATH = '/status/mcp'; +const ACCOUNT_PATH = '/account/mcp'; +const MAX_REQUEST_BYTES = 1024 * 1024; + +export interface ListenerOptions { + /** The built page, read by the caller before the port opens. */ + readonly html: string; + /** The built page's relative filename; the starter builds index.html. */ + readonly filename?: string; + /** Decides who a bearer credential belongs to. */ + readonly verifier: OAuthTokenVerifier; + /** Decides what a verified account may see. */ + readonly authorize: AccountServerOptions['authorize']; + /** Use 0 for an ephemeral port. */ + readonly port: number; + /** + * 3,000 ms per operation and 10,000 ms per request unless stated. A field you leave out keeps + * its default, and an invalid value fails before the port opens. + */ + readonly budgets?: BudgetSettings; +} + +export interface Listener { + /** Public endpoint: no credential is read. */ + readonly statusUrl: string; + /** Protected endpoint: a bearer credential is required. */ + readonly accountUrl: string; + close(): Promise; +} + +/** `Bindings` are the Node request and response the Hono Node adapter hands to every request. */ +interface Environment { + Bindings: HttpBindings; + Variables: { lifetime: RequestLifetime }; +} + +type RequestContext = Context; + +export async function startListener( + options: ListenerOptions, +): Promise { + // Prepare and validate deployment inputs once, before opening the port. Each request + // reuses the same HTML and URI while owning its server, identity and lifetime. + const budgets = createServerBudgets(options.budgets); + // #region saga-snippet:prepare-view + 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 }, + ); + // #endregion saga-snippet:prepare-view + const active = new Set(); + let stopping = false; + let closing: Promise | undefined; + function track( + req: IncomingMessage, + res: ServerResponse, + lifetime: RequestLifetime, + ): void { + active.add(lifetime); + res.once('close', () => active.delete(lifetime)); + bindResponseToLifetime(req, res, lifetime); + } + + const app = new Hono(); + + // The request's clock starts at admission, before any credential or body wait. + app.use('*', async (c, next) => { + if (stopping) return c.json({ error: 'shutting_down' }, 503); + const lifetime = budgets.startRequest(); + track(c.env.incoming, c.env.outgoing, lifetime); + c.set('lifetime', lifetime); + await next(); + }); + app.use('*', hostHeaderValidation(ALLOWED_HOSTNAMES)); + app.use('*', localhostOriginValidation()); + + /** One safe answer, and only while nothing is committed and the caller has not gone. */ + function mayAnswer(lifetime: RequestLifetime, res: ServerResponse): boolean { + if (res.headersSent || res.destroyed) return false; + const cause = lifetime.cause; + return cause === undefined || cause.kind === 'request-timeout'; + } + + /** What a handler returns when nothing may be written: the caller has gone, or the connection is being destroyed. */ + const unanswered = (): Response => new Response(null, { status: 408 }); + + /** Answers, then drops the connection: the rest of the request body is not wanted. */ + function refuseAndClose( + c: RequestContext, + status: 408 | 413, + body: object, + ): Response { + const { incoming, outgoing } = c.env; + if (outgoing.writableFinished) incoming.destroy(); + else outgoing.once('finish', () => incoming.destroy()); + return c.json(body, status, { connection: 'close' }); + } + + /** A body stage that did not finish. An expiry is answered once; a disconnect writes nothing. */ + function bodyFailed(c: RequestContext, cause: LifetimeCause): Response { + const lifetime = c.get('lifetime'); + // The lifetime recorded its own expiry, disconnect and completion; the rest are this stage's. + if ( + cause.kind !== 'request-timeout' && + cause.kind !== 'disconnected' && + cause.kind !== 'completed' + ) + lifetime.record(cause); + if ( + cause.kind === 'disconnected' || + cause.kind === 'cancelled' || + cause.kind === 'completed' + ) + return unanswered(); + return mayAnswer(lifetime, c.env.outgoing) + ? refuseAndClose(c, 408, { error: 'request_timeout' }) + : unanswered(); + } + + /** + * Reads and parses a POST body as one bounded stage of the request. A `Response` means the + * request has been dealt with (or the caller has gone) and nothing may be built for it. + */ + async function readJsonBody( + c: RequestContext, + ): Promise<{ readonly value: unknown } | Response> { + const lifetime = c.get('lifetime'); + // The adapter's own bounded reader (it refuses a declared or observed size over the limit); the + // stage stops waiting when the budget ends, and the connection is closed behind it. + const read = await lifetime.run('body', () => + readRequestBody(c.req.raw, MAX_REQUEST_BYTES), + ); + if (!read.ok) return bodyFailed(c, read.cause); + if (!mayAnswer(lifetime, c.env.outgoing)) return unanswered(); + if (read.value.tooLarge) + return refuseAndClose(c, 413, { error: 'payload_too_large' }); + try { + return { value: JSON.parse(read.value.text) as unknown }; + } catch { + return c.json( + { + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }, + 400, + ); + } + } + + async function serve( + c: RequestContext, + endpoint: Endpoint, + authInfo?: AuthInfo, + ): Promise { + // The body is the application's wait, so it runs under the request's lifetime and is bounded + // before any server or transport exists. Nothing is built for a request that has ended. + const body = c.req.method === 'POST' ? await readJsonBody(c) : undefined; + if (body instanceof Response) return body; + if (c.get('lifetime').check() !== undefined) return unanswered(); + return endpoint.fetch(c.req.raw, c.get('lifetime'), { + ...(body === undefined ? {} : { parsedBody: body.value }), + ...(authInfo === undefined ? {} : { authInfo }), + }); + } + + app.all(STATUS_PATH, (c) => { + return serve(c, status); + }); + + app.all(ACCOUNT_PATH, async (c) => { + const lifetime = c.get('lifetime'); + // #region saga-snippet:identity-handoff + // 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); + // #endregion saga-snippet:identity-handoff + }); + + app.notFound(() => new Response(null, { status: 404 })); + app.onError(() => new Response(null, { status: 500 })); + + const http = createServer(getRequestListener(app.fetch)); + await new Promise((resolve, reject) => { + http.once('error', reject); + http.listen(options.port, HOST, resolve); + }); + const { port } = http.address() as AddressInfo; // listen() on a TCP port yields AddressInfo + + return { + statusUrl: `http://${HOST}:${port}${STATUS_PATH}`, + accountUrl: `http://${HOST}:${port}${ACCOUNT_PATH}`, + close: () => + (closing ??= (async () => { + stopping = true; + for (const lifetime of active) lifetime.disconnect(); + await Promise.all([status.close(), account.close()]); + await new Promise((resolve, reject) => { + http.close((error) => (error ? reject(error) : resolve())); + http.closeAllConnections(); + }); + })()), + }; +} + +/** + * Connects one request's lifetime to its response when the request is admitted. The transport + * finishing the response ends the lifetime; the caller going away does too. In the other direction, + * a request deadline that passes before the response is delivered destroys the response, which + * releases the connection and the transport's listeners. A response is never given a second status. + */ +function bindResponseToLifetime( + req: IncomingMessage, + res: ServerResponse, + lifetime: RequestLifetime, +): void { + // A write to a response this listener destroyed must not escape as an unhandled error. + res.on('error', () => undefined); + req.on('error', () => undefined); + + // Destroying a response also emits `finish` on current Node, with `destroyed` already true, so + // neither the event nor `writableFinished` says the peer received the whole response. + let delivered = false; + res.once('finish', () => { + if (res.destroyed) return; + delivered = true; + lifetime.finish(); + }); + res.once('close', () => { + if (!delivered) lifetime.disconnect(); + }); + lifetime.signal.addEventListener( + 'abort', + () => { + if (lifetime.cause?.kind !== 'request-timeout') return; + // The stage that owns the response may write one safe answer in this turn of the event loop; + // delivering it gets no extra time. + setImmediate(() => { + if (!res.writableFinished && !res.destroyed) res.destroy(); + }); + }, + { once: true }, + ); +} diff --git a/templates/react-hono/server/main.ts b/templates/react-hono/server/main.ts new file mode 100644 index 00000000..ca118d04 --- /dev/null +++ b/templates/react-hono/server/main.ts @@ -0,0 +1,34 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mayViewAccount } from './authorization.js'; +import { startListener } from './listener.js'; +import { verifier } from './verifier.js'; + +// The server entry. It reads the built view before it opens a port, so a missing build stops the +// process instead of serving a listener without its view. `npm run build` writes the view beside +// this file; `npm start` runs it. +const html = await readFile( + path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.html'), + 'utf8', +); +const listener = await startListener({ + html, + verifier, + authorize: mayViewAccount, + port: Number(process.env.PORT ?? 3000), +}); + +console.log(`Public status endpoint: ${listener.statusUrl}`); +console.log( + `Protected account endpoint (every credential is refused until you replace server/verifier.ts): ${listener.accountUrl}`, +); + +// Stop both MCP handlers and every HTTP exchange before allowing Node to exit. +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + void listener.close().catch(() => { + process.exitCode = 1; + }); + }); +} diff --git a/templates/react-hono/server/operations.ts b/templates/react-hono/server/operations.ts new file mode 100644 index 00000000..256407f1 --- /dev/null +++ b/templates/react-hono/server/operations.ts @@ -0,0 +1,156 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import { CLOSED_VIEW_CSP } from '@saga-a2ui/mcp/policy'; +import { + registerProtectedOperation, + registerPublicOperation, + viewUriForHtml, + type OperationView, + type RequestLifetime, +} from '@saga-a2ui/mcp/server'; +import { z } from 'zod'; +import { + ACCOUNT_TOOL, + AccountOutputSchema, + READ_ONLY, + STATUS_TOOL, + StatusOutputSchema, + TEXT, + VIEW_MARKER, +} from '../src/contract.js'; +import { identityOf, type AccountIdentity } from './identity.js'; + +// The application's MCP operations, declared with SAGA's server export. The application owns the +// listener (listener.ts), who is asking (authenticate.ts), what each account may see +// (authorization.ts) and the built view it serves. Each request builds its own server. + +/** The built view is required before the server can claim to serve anything. */ +export class ViewArtifactMissing extends Error { + constructor() { + super( + 'The view has not been built: the server needs the built page and will not start without it.', + ); + this.name = 'ViewArtifactMissing'; + } +} + +/** Validate and fingerprint the deployment document once, before listening. */ +export function prepareStarterView( + html: unknown, + filename = 'index.html', +): OperationView { + if (typeof html !== 'string' || !html.includes(VIEW_MARKER)) + throw new ViewArtifactMissing(); + return Object.freeze({ + uri: viewUriForHtml(html, filename), + title: 'Starter view', + html, + csp: CLOSED_VIEW_CSP, + }); +} + +export interface StatusServerOptions { + /** The immutable view prepared before the listener opened. */ + readonly view: OperationView; + /** The request this server was built for, started by the listener at admission. */ + readonly lifetime: RequestLifetime; +} + +/** An explicitly public operation: it reads no credential and has no identity. */ +export function createStatusServer({ + view, + lifetime, +}: StatusServerOptions): McpServer { + const server = new McpServer({ + name: 'saga-starter-status', + version: '0.0.0', + }); + registerPublicOperation( + server, + { + name: STATUS_TOOL, + title: 'Show the service status', + description: 'Shows whether the service is running.', + annotations: READ_ONLY, + input: z.strictObject({}), + output: StatusOutputSchema, + view, + failures: { + absent: TEXT.noStatus, + unavailable: TEXT.unavailable, + invalidOutput: TEXT.invalidOutput, + }, + handle: () => ({ + kind: 'record', + record: { status: 'running' as const }, + }), + project: (record) => ({ + text: 'The service is running.', + data: { status: record.status }, + }), + }, + { lifetime }, + ); + return server; +} + +export interface AccountServerOptions extends StatusServerOptions { + /** The application's policy for this request's identity (authorization.ts). */ + readonly authorize: ( + identity: AccountIdentity, + signal: AbortSignal, + ) => Promise; +} + +// #region saga-snippet:register-protected-operation +/** + * 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; +} +// #endregion saga-snippet:register-protected-operation diff --git a/templates/react-hono/server/verifier.ts b/templates/react-hono/server/verifier.ts new file mode 100644 index 00000000..db26c944 --- /dev/null +++ b/templates/react-hono/server/verifier.ts @@ -0,0 +1,25 @@ +import { + OAuthError, + OAuthErrorCode, + type OAuthTokenVerifier, +} from '@modelcontextprotocol/server'; + +// APPLICATION-OWNED. This is the one place that decides who a bearer credential belongs to. +// +// The starter ships no accounts and no tokens, so it refuses every credential: the public status +// operation works, and every protected request fails closed until you replace this with the +// verifier for your identity provider. A verifier returns: +// +// { token, clientId, scopes: [ACCOUNT_SCOPE], expiresAt: , +// extra: { accountId: '' } } +// +// and throws `OAuthError(OAuthErrorCode.InvalidToken, ...)` for a credential it does not accept. + +export const verifier: OAuthTokenVerifier = { + async verifyAccessToken() { + throw new OAuthError( + OAuthErrorCode.InvalidToken, + 'No identity provider is configured', + ); + }, +}; diff --git a/templates/react-hono/src/actions.ts b/templates/react-hono/src/actions.ts new file mode 100644 index 00000000..4d03b7a1 --- /dev/null +++ b/templates/react-hono/src/actions.ts @@ -0,0 +1,25 @@ +import { + Action, + type ActionButtonActivation, +} from '@saga-a2ui/react-mcp-stack'; + +// The application's actions, each defined once. A definition is immutable and holds no pending +// state, so one definition can be attached to any number of rendered controls; each attachment +// keeps its own state. The application attaches them (view.ts); no markup names them. + +export interface StarterEnvironment { + /** The application-owned input the Clear control empties. */ + readonly noteInput: HTMLInputElement; +} + +export function createStarterActions(environment: StarterEnvironment) { + /** A local action: no server request and no prerequisite. */ + const clearNote = Action.local('clear-note') + .run(() => { + environment.noteInput.value = ''; + environment.noteInput.focus(); + }) + .build(); + + return { clearNote }; +} diff --git a/templates/react-hono/src/catalog.tsx b/templates/react-hono/src/catalog.tsx new file mode 100644 index 00000000..eafeda27 --- /dev/null +++ b/templates/react-hono/src/catalog.tsx @@ -0,0 +1,70 @@ +import { + ActionButtonEntry, + Catalog, + ColumnApi, + CommonSchemas, + createComponentImplementation, + z, + type ReactCatalogComponent, +} from '@saga-a2ui/react-mcp-stack'; +import type { ReactNode } from 'react'; +import { CATALOG_ID } from './surface.js'; + +// The application-owned catalog: the components its layout may name, and nothing else. `Line` is +// this application's own component; it shows supplied text as text, never as markup. `Column` is +// built over the stack's layout contract, and `ActionButton` is SAGA's opt-in control. Choosing a +// component is the application's decision: nothing here is registered globally by the SDK. + +type ChildReference = + string | { readonly id: string; readonly basePath: string }; + +// The view hands a child list over as tokens, or `{ id, basePath }` for a template item. +function childrenOf( + buildChild: (id: string, basePath?: string) => ReactNode, + value: unknown, +): ReactNode[] { + const list = Array.isArray(value) ? (value as ChildReference[]) : []; + return list.map((child) => + typeof child === 'string' + ? buildChild(child) + : buildChild(child.id, child.basePath), + ); +} + +const LineApi = { + name: 'Line', + schema: z + .object({ + text: CommonSchemas.DynamicString, + kind: z.enum(['title', 'body']), + }) + .strict(), +}; + +const Line: ReactCatalogComponent = createComponentImplementation( + LineApi, + ({ props }) => { + const text = String(props.text ?? ''); + return props.kind === 'title' ?

{text}

: {text}; + }, +); + +const Column: ReactCatalogComponent = createComponentImplementation( + ColumnApi, + ({ props, buildChild }) => ( +
{childrenOf(buildChild, props.children)}
+ ), +); + +export const starterEntries: readonly ReactCatalogComponent[] = [ + Column, + Line, + ActionButtonEntry, +]; + +/** What the stack presents the layout over. */ +export const starterCatalog = new Catalog( + CATALOG_ID, + 'v0.9', + [...starterEntries], +); diff --git a/templates/react-hono/src/contract.ts b/templates/react-hono/src/contract.ts new file mode 100644 index 00000000..68dc5cf2 --- /dev/null +++ b/templates/react-hono/src/contract.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +// The wire contract shared by the server and the view. It imports only `zod`, so a view bundle that +// includes it carries no server code. Application-owned: nothing here is a SAGA export. + +/** Public: the view shows that the service is running. Needs no credential. */ +export const STATUS_TOOL = 'get_status'; +/** Protected: the view shows the account the server verified for this request. */ +export const ACCOUNT_TOOL = 'get_account'; + +export const StatusOutputSchema = z.strictObject({ + status: z.literal('running'), +}); +export const AccountOutputSchema = z.strictObject({ + accountId: z.string().min(1).max(200), +}); + +/** Fixed sentences. A failure never repeats a backend message, an identifier or a credential. */ +export const TEXT = { + signInRequired: 'Sign in to see your account.', + notAllowed: 'This account may not see this.', + unavailable: 'This is unavailable right now.', + invalidOutput: 'This could not be shown.', + noAccount: 'There is no account to show.', + noStatus: 'There is no status to show.', +} as const; + +export const READ_ONLY = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +/** + * What the built page must contain for the server to serve it: the element the view renders into. + * The server refuses to start with a page that lacks it, so a missing or wrong build fails early. + */ +export const VIEW_MARKER = '
(selector: string): T { + const found = document.querySelector(selector); + if (found === null) throw new Error(`the page has no ${selector}`); + return found; +} + +export async function startView(): Promise { + const status = requireElement('[data-testid="view-status"]'); + const say = (text: string): void => void (status.textContent = text); + const actions = createStarterActions({ + noteInput: requireElement('[data-testid="note"]'), + }); + + const show = ( + result: { + readonly isError?: boolean; + readonly structuredContent?: unknown; + }, + surface: BoundSurface, + ): void => { + const account = + result.isError === true + ? undefined + : AccountOutputSchema.safeParse(result.structuredContent); + const running = + result.isError === true || account?.success + ? undefined + : StatusOutputSchema.safeParse(result.structuredContent); + const headline = account?.success + ? `Signed in as ${account.data.accountId}` + : running?.success + ? 'The service is running' + : ''; + surface.update({ headline }); + say( + result.isError === true + ? PROBLEM_TEXT + : headline === '' + ? MISMATCH_TEXT + : 'Ready', + ); + }; + + // What ending the view takes with it. The surface owns the discovery and the action bindings of + // its presentation: they end when the surface leaves the page, before the stack calls this, on a + // host teardown request and on the page going away alike, so a retained button no longer does + // anything. This ends only what the application owns itself. A binding does not stop work a + // server action started; whatever such work holds is the application's to end. + const retire = (): void => say(CLOSED_TEXT); + + // The first result is shown once the view is ready; a later one replaces what is shown. + const view = startA2uiView({ + container: requireElement('#root'), + layout: starterSurface, + catalog: starterCatalog, + identity: { name: 'saga-starter-view', version: '0.0.0' }, + actions: createReactActions({ + // #region saga-snippet:bind-action + // 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), + ); + }, + // #endregion saga-snippet:bind-action + onSetupError: () => say(PROBLEM_TEXT), + }), + onToolResult: (result, running) => show(result, running.surface), + onTeardown: retire, + }); + await view.ready; +} diff --git a/templates/react-hono/template.json b/templates/react-hono/template.json new file mode 100644 index 00000000..e69950bc --- /dev/null +++ b/templates/react-hono/template.json @@ -0,0 +1,15 @@ +{ + "stack": "react-mcp", + "server": "hono", + "serverAdapter": { + "package": "@modelcontextprotocol/hono", + "version": "2.0.2" + }, + "sdkPackages": ["core", "react", "components", "mcp", "react-mcp-stack"], + "snippets": [ + "prepare-view", + "register-protected-operation", + "identity-handoff", + "bind-action" + ] +} diff --git a/templates/react-hono/tsconfig.json b/templates/react-hono/tsconfig.json new file mode 100644 index 00000000..40db0b7f --- /dev/null +++ b/templates/react-hono/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "jsx": "react-jsx", + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src"] +} diff --git a/templates/react-hono/tsconfig.server.json b/templates/react-hono/tsconfig.server.json new file mode 100644 index 00000000..2434839f --- /dev/null +++ b/templates/react-hono/tsconfig.server.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["server", "src/contract.ts", "vite.server.config.ts"] +} diff --git a/templates/react-hono/vite.config.ts b/templates/react-hono/vite.config.ts new file mode 100644 index 00000000..641cafd8 --- /dev/null +++ b/templates/react-hono/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +// The view is one self-contained HTML document: an MCP host mounts the exact bytes the server +// serves, so nothing may load from a separate file. The server reads dist/index.html. +export default defineConfig({ + base: './', + plugins: [viteSingleFile()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/templates/react-hono/vite.server.config.ts b/templates/react-hono/vite.server.config.ts new file mode 100644 index 00000000..15512d20 --- /dev/null +++ b/templates/react-hono/vite.server.config.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; + +// The server is bundled for Node into dist/server.mjs. The SAGA packages ship TypeScript source, +// which Node does not load from node_modules, so they are bundled in; every other dependency +// stays external and resolves from node_modules at run time. +export default defineConfig({ + ssr: { noExternal: [/^@saga-a2ui\//] }, + build: { + ssr: path.resolve('server/main.ts'), + outDir: 'dist', + emptyOutDir: false, // the view build wrote dist/index.html; keep it + minify: false, + target: 'es2022', + rollupOptions: { output: { entryFileNames: 'server.mjs', format: 'es' } }, + }, +});