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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 80 additions & 10 deletions docs/agents/mcp-apps-starter-guide.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# MCP Apps starter guide

This guide explains how to build MCP Apps in this starter project in a reusable
way. It is intentionally **not** tied to the calculator example.
way. The current implementation uses a single generic shell resource that can
render inline generated code or reopen saved UI artifacts by id. The shell is
intentionally thin: it just hosts an iframe, delivers render payloads, and
bridges host actions back to the MCP App host.

Use this when replacing starter tools/resources with your own product-specific
UI and workflows.
Expand All @@ -12,7 +15,7 @@ UI and workflows.
hosts.
- Keep tool/resource metadata aligned with the MCP Apps specification.
- Keep implementation modular so starter examples are easy to replace.
- Ensure apps support host messaging, theming, and predictable validation.
- Ensure apps support host messaging and predictable validation.

## Architecture in this repo

Expand All @@ -39,7 +42,7 @@ Create a dedicated module under `packages/worker/src/mcp/apps/`:

- Export a stable `ui://` URI.
- Export a render function that returns the app HTML.
- Keep widget logic local to this module.
- Keep shell markup minimal and push visible UI into the generated app source.

Keep file names lower-kebab-case and prefer one entry point per app.

Expand All @@ -65,6 +68,10 @@ In `packages/worker/src/mcp/tools/<your-tool>.ts`:
- Set `_meta.ui.resourceUri` to the **same** `ui://` URI as the resource.
- Include annotations (`readOnlyHint`, `idempotentHint`, etc.).
- Provide `outputSchema` for machine-usable outputs where relevant.
- Prefer returning a compact render envelope in `structuredContent` so a single
shell can render different app payloads per invocation.
- Prefer self-contained HTML documents or fragments as the render source so the
generated app owns the visible document.

### 4) Wire registration in server init

Expand All @@ -91,28 +98,71 @@ When a UI should communicate back to the host agent:
- `ui/message` (send a user-style message)
- `tools/call` (call server tools)
- `ui/open-link` (request external link open)
- Use app-only tools (`visibility: ["app"]`) for server-side follow-up work that
should stay out of the model tool list, such as loading saved app source by id
or polling for live widget state.
- Keep messages concise and deterministic where possible.
- For inline `rawHtml` widgets in this repo, prefer reusing the shared runtime
in `packages/worker/client/mcp-apps/widget-host-bridge.ts` (bundled into
`packages/worker/public/mcp-apps/calculator-widget.js`) instead of duplicating
bridge code.
`packages/worker/public/mcp-apps/generated-ui-shell.js`) instead of
duplicating bridge code.

You can also send simplified MCP-UI actions via `window.parent.postMessage(...)`
(`type: 'tool' | 'prompt' | 'notify' | 'link'`) when using the `mcpApps`
adapter. Those shorthand actions depend on adapter translation and may not be
available in every host runtime.

### Fullscreen and display modes

- Prefer `ui/request-display-mode` for fullscreen entry/exit instead of trying
to manipulate the iframe directly.
- Treat fullscreen as host-dependent. Always check `availableDisplayModes` from
render data / host context before offering the action.
- Keep fullscreen UI optional and degrade gracefully when unsupported.

### Generic shell render contract

The current repo uses one generic shell resource:

- `ui://generated-ui-shell/entry-point.html`

The public tool should accept exactly one of:

- inline source (ephemeral render)
- saved `app_id` (server-resolved reopen)

Recommended render envelope fields in tool `structuredContent`:

- render mode (`inline_code` or `saved_app`)
- saved artifact id when present
- optional title / description for the current render session
- inline source only for ephemeral renders

This keeps the shell reusable while avoiding source round-trips through the
model for saved artifacts.

## HTML-First Contract

Prefer generated app source that is already a complete HTML document, or at
least a self-contained HTML fragment. That keeps the generic shell simple and
avoids coupling the app contract to shell-owned containers like `#app`.

If you keep a helper bridge, treat it as transport only:

- send host messages
- request display modes
- call app-only tools
- avoid shipping shell-owned visible controls

## Theme and design-system guidance

### Theme support

For robust light/dark behavior:

- Support browser fallback with `prefers-color-scheme`.
- Also support host-provided theme:
- Request render data (`ui-request-render-data`).
- Handle host updates (`ui-lifecycle-iframe-render-data`).
- Apply `renderData.theme` (`light`/`dark`) when present.
- If your generated app wants host-provided theme, pass it through your bridge
contract explicitly rather than relying on shell-owned UI chrome.

### Design-system alignment

Expand Down Expand Up @@ -148,11 +198,31 @@ token values into widget CSS. If you do this in an MCP App resource, set
- `bun run format`
- `bun run test:mcp`
- `bun run validate`
- `bun run inspect`
- Confirm docs in `docs/agents` reflect any new workflow or constraints.

## Inspector and MCP Jam verification

For end-to-end verification beyond local MCP tests:

1. Run `bun run inspect`.
2. Connect the local MCP server in the inspector / MCP Jam flow.
3. Call the generic app-opening tool with inline source and confirm the shell
renders the generated UI.
4. Save an app artifact, then reopen it by `app_id` and confirm the shell loads
it without resending the saved source through the model.
5. Verify host interactions that are supported by your widget:
- `ui/message`
- app-only `tools/call`
- `ui/open-link`
- `ui/request-display-mode` / fullscreen when the host exposes it

Treat MCP Jam as the highest-signal browser verification path because it matches
the sandboxed MCP Apps host model more closely than a plain iframe.

## Replacing starter examples safely

When removing starter examples (for example calculator + math tool):
When removing starter examples:

1. Delete example modules under `packages/worker/src/mcp/apps`,
`packages/worker/src/mcp/tools`, and `packages/worker/src/mcp/resources`.
Expand Down
23 changes: 12 additions & 11 deletions docs/mcp-server-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,9 @@ z.object({

```typescript
outputSchema: {
expression: z.string().describe('Expression string, e.g. "8 + 4"'),
result: z.number().finite().describe('Exact numeric result (not rounded)'),
precisionUsed: z.number().int().min(0).max(15),
app_id: z.string().describe('Persisted app identifier'),
title: z.string().describe('Human-facing app title'),
runtime: z.enum(['html', 'javascript']),
}
```

Expand Down Expand Up @@ -282,10 +282,11 @@ and handler colocated. Keep a small `register-tools` module that imports each
tool module and registers them.

```typescript
// packages/worker/src/mcp/tools/do-math.ts
export async function registerDoMathTool(agent: MCP) {
agent.server.registerTool(
'do_math',
// packages/worker/src/mcp/tools/open-generated-ui.ts
export async function registerOpenGeneratedUiTool(agent: MCP) {
registerAppTool(
agent.server,
'open_generated_ui',
{
/* metadata + schemas */
},
Expand All @@ -296,9 +297,9 @@ export async function registerDoMathTool(agent: MCP) {
}

// packages/worker/src/mcp/register-tools.ts
import { registerDoMathTool } from './tools/do-math.ts'
import { registerOpenGeneratedUiTool } from './tools/open-generated-ui.ts'
export async function registerTools(agent: MCP) {
await registerDoMathTool(agent)
await registerOpenGeneratedUiTool(agent)
}
```

Expand All @@ -310,8 +311,8 @@ export async function registerTools(agent: MCP) {

**Example in this repo:** Server instructions live in
`packages/worker/src/mcp/index.ts`, and tool metadata is colocated with tool
registration + schemas in `packages/worker/src/mcp/tools/do-math.ts` (with
`packages/worker/src/mcp/register-tools.ts` as the small aggregator).
registration + schemas in `packages/worker/src/mcp/tools/open-generated-ui.ts`
(with `packages/worker/src/mcp/register-tools.ts` as the small aggregator).

---

Expand Down
62 changes: 0 additions & 62 deletions e2e/calculator-widget.spec.ts

This file was deleted.

14 changes: 10 additions & 4 deletions e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,23 @@ test('responds to mock tool commands in chat', async ({ page, login }) => {

await page
.getByRole('textbox', { name: 'Message' })
.fill('tool:do_math;left=1;right=2;operator=+')
.fill(
'tool:open_generated_ui;code=<main><h1>Mock App</h1><p>Opened from chat.</p></main>;title=Mock App',
)
await page.getByRole('button', { name: 'Send message' }).click()

await expect(page).toHaveURL(/\/chat\/.+/)
await expect(
messages.getByText('tool:do_math;left=1;right=2;operator=+').last(),
messages
.getByText(
'tool:open_generated_ui;code=<main><h1>Mock App</h1><p>Opened from chat.</p></main>;title=Mock App',
)
.last(),
).toBeVisible()
await expect(
messages.getByText('## ✅ Result', { exact: false }).last(),
messages.getByText('## Generated UI ready', { exact: false }).last(),
).toBeVisible()
await expect(
messages.getByText('**Result**: `3`', { exact: false }).last(),
messages.getByText('The generated UI is attached', { exact: false }).last(),
).toBeVisible()
})
95 changes: 95 additions & 0 deletions e2e/generated-ui-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { expect, test } from '@playwright/test'

const inlineCode = [
'<main>',
' <h1>Inline app</h1>',
' <button type="button" data-send-message>Send message</button>',
' <button type="button" data-open-link>Open docs</button>',
' <button type="button" data-fullscreen>Fullscreen</button>',
'</main>',
'<script>',
' const sendButton = document.querySelector("[data-send-message]");',
' const linkButton = document.querySelector("[data-open-link]");',
' const fullscreenButton = document.querySelector("[data-fullscreen]");',
' sendButton?.addEventListener("click", () => {',
' window.kodyWidget.sendMessage("Inline widget says hello");',
' });',
' linkButton?.addEventListener("click", () => {',
' window.kodyWidget.openLink("https://example.com/docs");',
' });',
' fullscreenButton?.addEventListener("click", async () => {',
' const nextMode = await window.kodyWidget.toggleFullscreen();',
' const status = document.createElement("p");',
' status.textContent = `Mode: ${nextMode}`;',
' document.body.append(status);',
' });',
'</script>',
].join('\n')

test('generated ui shell renders inline code in default iframe', async ({
page,
}) => {
await page.goto('/dev/generated-ui-shell-test.html')
await expect(
page.getByRole('heading', { name: 'Generated UI Shell Test' }),
).toBeVisible()

const shellFrame = page.frameLocator('#generated-default')
const appFrame = shellFrame.locator('[data-generated-ui-frame]')
await expect(appFrame).toBeVisible()
await expect(
appFrame.contentFrame().getByRole('heading', { name: 'Inline app' }),
).toBeVisible()
})

test('generated ui shell renders inline code in sandboxed iframe', async ({
page,
}) => {
await page.goto(
`/dev/generated-ui-shell-test.html?code=${encodeURIComponent(inlineCode)}`,
)
const shellFrame = page.frameLocator('#generated-sandboxed')
const sandboxedIframe = shellFrame.locator('[data-generated-ui-frame]')
await expect(sandboxedIframe).toBeVisible()
await expect(
sandboxedIframe.contentFrame().getByRole('heading', { name: 'Inline app' }),
).toBeVisible()
})

test('sandboxed generated ui shell supports host messaging actions', async ({
page,
}) => {
await page.goto(
`/dev/generated-ui-shell-test.html?code=${encodeURIComponent(inlineCode)}`,
)
const shellFrame = page.frameLocator('#generated-sandboxed')
const sandboxedIframe = shellFrame.locator('[data-generated-ui-frame]')
await expect(sandboxedIframe).toBeVisible()
await expect(
sandboxedIframe.contentFrame().getByRole('heading', { name: 'Inline app' }),
).toBeVisible()

await sandboxedIframe
.contentFrame()
.getByRole('button', { name: 'Send message' })
.click()
await expect(
page.locator('[data-host-chat="generated-sandboxed"] [data-chat-message]'),
).toContainText('Inline widget says hello')

await sandboxedIframe
.contentFrame()
.getByRole('button', { name: 'Open docs' })
.click()
await expect(
page.locator('[data-host-links="generated-sandboxed"] [data-open-link]'),
).toContainText('https://example.com/docs')

await sandboxedIframe
.contentFrame()
.getByRole('button', { name: 'Fullscreen' })
.click()
await expect(
sandboxedIframe.contentFrame().getByText('Mode: fullscreen'),
).toBeVisible()
})
Loading
Loading