Skip to content
Open
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
31 changes: 31 additions & 0 deletions samples/community/mcp/a2ui-in-mcpapps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This sample demonstrates a Model Context Protocol (MCP) Application Host that is
- **`server/`**: The MCP Server (Python/uv) that provides the micro-app resources and tools.
- **`server/apps/src/`**: Source code for the **Basic** isolated micro-app.
- **`server/apps/editor/`**: Source code for the **Editor** isolated micro-app.
- **`server/apps/react/`**: Source code for the **Generic React Renderer** micro-app (server-agnostic).

## Communication Flow

Expand Down Expand Up @@ -108,6 +109,36 @@ yarn build:all

_(Generates `server/apps/public/app.html`)_

#### Option C: The Generic React Renderer

```bash
cd server/apps/react
yarn install
yarn build:all
```

_(Generates `server/apps/public/react.html`)_

---

## Generic A2UI renderer

The React micro-app (`server/apps/react/`) is a **generic, server-agnostic A2UI renderer**: it
contains no logic specific to this server, so any A2UI-speaking MCP server can serve the built
`react.html` as its own `ui://` resource. All content reaches the view through tool results.

A server is compatible if it follows two conventions:

1. **A2UI payloads travel as embedded resources.** A2UI messages (v0.9+) are delivered as
`EmbeddedResource` content blocks with mimeType `application/a2ui+json` in tool results —
including the entry tool's result, which the renderer draws as the initial view.
2. **A2UI actions map to tools.** Each A2UI action `name` matches an app-visible tool name
(`_meta.ui.visibility` includes `"app"`), and the action's resolved `context` becomes the
tool's `arguments`. The tool's response payload is applied incrementally to the same surfaces.

In this sample, the `get_react_app` entry tool returns a v0.9 counter payload and the
`increase_counter_v0_9` tool answers each button press with a data-model update.

---

## Running the Sample
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ <h1>Simple MCP Apps Host</h1>
Generative Editor App
</option>
<option value="basic" [selected]="selectedApp() === 'basic'">Basic Counter App</option>
<option value="react" [selected]="selectedApp() === 'react'">Generic React Renderer</option>
</select>
</div>
<button (click)="connectAndLoadApp()" [disabled]="isAppLoading()">
Expand Down
42 changes: 25 additions & 17 deletions samples/community/mcp/a2ui-in-mcpapps/client/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class App implements AfterViewInit {
private messageListenerAdded = false;
protected readonly mcpAppHtmlUrl = signal<string | null>(null);
protected readonly isAppLoading = signal<boolean>(false);
protected readonly selectedApp = signal<'editor' | 'basic'>('editor');
protected readonly selectedApp = signal<'editor' | 'basic' | 'react'>('editor');

private mcpClient: Client | null = null;

Expand Down Expand Up @@ -118,23 +118,25 @@ export class App implements AfterViewInit {
} else if (data?.method === 'ui/notifications/initialized') {
// The host must not message the View before this notification; once it
// arrives, deliver the instantiating tool call's input and result.
target.postMessage(
{
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: {arguments: this.toolCallArguments},
},
window.location.origin,
);
if (this.toolCallResult) {
if (target) {
target.postMessage(
{
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: this.toolCallResult,
method: 'ui/notifications/tool-input',
params: {arguments: this.toolCallArguments},
},
window.location.origin,
);
if (this.toolCallResult) {
target.postMessage(
{
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: this.toolCallResult,
},
window.location.origin,
);
}
}
Comment thread
liady marked this conversation as resolved.
} else if (data?.method === 'ui/notifications/size-changed') {
const height = data.params?.height;
Expand Down Expand Up @@ -191,7 +193,7 @@ export class App implements AfterViewInit {
}

onAppChange(value: string) {
if (value === 'editor' || value === 'basic') {
if (value === 'editor' || value === 'basic' || value === 'react') {
this.selectedApp.set(value);
} else {
console.error(`[Host] Invalid app selected: ${value}`);
Expand Down Expand Up @@ -219,7 +221,12 @@ export class App implements AfterViewInit {
await client.connect(transport);
this.mcpClient = client;

const toolName = this.selectedApp() === 'editor' ? 'get_editor_app' : 'get_basic_app';
const entryTools = {
editor: 'get_editor_app',
basic: 'get_basic_app',
react: 'get_react_app',
} as const;
const toolName = entryTools[this.selectedApp()];

// 2. Discover the tool's predeclared UI template (_meta.ui.resourceUri)
// and which tools the View may call (_meta.ui.visibility).
Expand Down Expand Up @@ -256,9 +263,10 @@ export class App implements AfterViewInit {

// 4. Read the resource
const appResource = await client.readResource({uri: resourceUri});
const htmlContentObj = appResource.contents.find(
(c: any) => c.mimeType === 'text/html;profile=mcp-app' || 'text' in c,
) as any;
// Prefer the MCP App HTML block; fall back to any text-bearing block.
const htmlContentObj = (appResource.contents.find(
(c: any) => c.mimeType === 'text/html;profile=mcp-app',
) ?? appResource.contents.find((c: any) => 'text' in c)) as any;

if (!htmlContentObj || typeof htmlContentObj.text !== 'string') {
throw new Error('Resource did not return valid HTML content');
Expand Down
20 changes: 20 additions & 0 deletions samples/community/mcp/a2ui-in-mcpapps/server/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This directory contains the hosted application for the MCP server. The applicati
## Directory Structure

- `src/`: Contains the source code for the hosted application (e.g., an Angular app).
- `react/`: Contains the generic, server-agnostic React A2UI renderer.
- `dist/`: Temporary build output directory for the raw hosted app build (ignored by git).
- `public/`: Output directory for the final bundled/inlined artifact (ignored by git).

Expand Down Expand Up @@ -43,3 +44,22 @@ yarn build:all
```

_(Runs Angular compilation and triggers `node inline.js` to single-file inline it into `public/app.html`)_

## MCP Application (React, generic)

The application in `react/` (`generic-a2ui-mcp-app-react`) is a server-agnostic A2UI renderer:
it does the MCP Apps handshake via the official `@modelcontextprotocol/ext-apps` SDK, renders
`application/a2ui+json` embedded resources from tool results with `@a2ui/react`, and routes A2UI
user actions back to the server as `tools/call` requests. See `react/README.md` for the
conventions that make it reusable by any A2UI-speaking MCP server.

Unlike the Angular apps it needs no `inline.js` post-processing — React has no forced chunking,
so Vite + `vite-plugin-singlefile` emits the final self-contained artifact directly:

```bash
cd react
yarn install
yarn build:all
```

_(Generates `public/react.html`)_
40 changes: 40 additions & 0 deletions samples/community/mcp/a2ui-in-mcpapps/server/apps/react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generic A2UI MCP App renderer (React)

A server-agnostic [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) view that renders
[A2UI](https://a2ui.org) (v0.9+) payloads. It contains **zero server-specific logic**: all content
arrives through tool results, so any A2UI-speaking MCP server can serve the built `react.html` as
its `ui://` resource — this directory is written to be extracted and reused as-is.

A server is compatible if it follows two conventions:

1. **A2UI payloads travel as embedded resources.** Tool results (including the entry tool's
result) carry A2UI messages as `EmbeddedResource` content blocks with mimeType
`application/a2ui+json` (a single message or an array).
2. **A2UI actions map to tools.** Each A2UI action `name` matches an app-visible tool name
(`_meta.ui.visibility` includes `"app"`), and the action's resolved `context` becomes the
tool's `arguments`. The tool's response A2UI is applied incrementally to the same surfaces.

## Build

```bash
yarn install
yarn build:all # emits ../public/react.html (single, self-contained file)
```

The build uses Vite + `vite-plugin-singlefile`, so the output HTML has no external references and
can be delivered verbatim via `resources/read`.

## Implementation notes

- The renderer is a reusable React component, `GenericA2uiApp` (`src/generic-a2ui-app.tsx`);
`src/main.tsx` only mounts it. Embedders can use the component directly and pass an optional
`actionToToolName` map to route A2UI action names to differently-named server tools (actions
without an entry call the tool named after the action itself).
- The MCP Apps handshake and host bridge come from the official
`@modelcontextprotocol/ext-apps` SDK (`App` class); iframe auto-resizing is handled by the
SDK's built-in `size-changed` notifications.
- Rendering uses `@a2ui/react` (v0.9 basic catalog) driven by a `MessageProcessor` from
`@a2ui/web_core`.
- The entry tool's result resets and fully renders the view; subsequent action-triggered tool
results patch it in place (`src/extract-a2ui-messages.ts` does the payload extraction and is
unit-tested via `yarn test`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "generic-a2ui-mcp-app-react",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@a2ui/markdown-it": "^0.0.4",
"@a2ui/react": "^0.10.1",
"@a2ui/web_core": "^0.10.3",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/sdk": "^1.21.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"prettier": "^3.8.4",
"typescript": "5.9.3",
"vite": "^7.3.4",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.8"
},
"scripts": {
"build": "vite build",
"build:all": "yarn run build",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "tsc --noEmit",
"test": "vitest run"
}
}
33 changes: 33 additions & 0 deletions samples/community/mcp/a2ui-in-mcpapps/server/apps/react/react.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<!--
Copyright 2026 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Generic A2UI Renderer</title>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {describe, expect, it, vi} from 'vitest';
import {extractA2uiMessages} from './extract-a2ui-messages';

const MESSAGE = {
version: 'v0.9',
updateDataModel: {surfaceId: 'counter', path: '/counter', value: 1},
};

function resourceBlock(text: string, mimeType = 'application/a2ui+json') {
return {type: 'resource', resource: {uri: 'a2ui://test', mimeType, text}};
}

describe('extractA2uiMessages', () => {
it('returns [] for non-array content', () => {
expect(extractA2uiMessages(undefined)).toEqual([]);
expect(extractA2uiMessages(null)).toEqual([]);
expect(extractA2uiMessages({})).toEqual([]);
});

it('extracts an array payload from an a2ui+json resource', () => {
const content = [resourceBlock(JSON.stringify([MESSAGE, MESSAGE]))];
expect(extractA2uiMessages(content)).toEqual([MESSAGE, MESSAGE]);
});

it('wraps a single-message payload in an array', () => {
const content = [resourceBlock(JSON.stringify(MESSAGE))];
expect(extractA2uiMessages(content)).toEqual([MESSAGE]);
});

it('accepts the legacy application/json+a2ui mime type', () => {
const content = [resourceBlock(JSON.stringify([MESSAGE]), 'application/json+a2ui')];
expect(extractA2uiMessages(content)).toEqual([MESSAGE]);
});

it('collects payloads from multiple resources in order', () => {
const other = {...MESSAGE, updateDataModel: {...MESSAGE.updateDataModel, value: 2}};
const content = [
resourceBlock(JSON.stringify([MESSAGE])),
{type: 'text', text: 'ignored'},
resourceBlock(JSON.stringify([other])),
];
expect(extractA2uiMessages(content)).toEqual([MESSAGE, other]);
});

it('ignores non-resource blocks and other mime types', () => {
const content = [
{type: 'text', text: JSON.stringify([MESSAGE])},
resourceBlock(JSON.stringify([MESSAGE]), 'text/html'),
{type: 'resource', resource: {uri: 'a2ui://no-text', mimeType: 'application/a2ui+json'}},
];
expect(extractA2uiMessages(content)).toEqual([]);
});

it('ignores JSON scalars and null payloads', () => {
const content = [resourceBlock('null'), resourceBlock('123'), resourceBlock('"text"')];
expect(extractA2uiMessages(content)).toEqual([]);
});

it('drops null and scalar entries inside array payloads', () => {
const content = [resourceBlock(JSON.stringify([null, 42, 'text', MESSAGE]))];
expect(extractA2uiMessages(content)).toEqual([MESSAGE]);
});

it('accepts mime types with parameters and different casing', () => {
const content = [
resourceBlock(JSON.stringify([MESSAGE]), 'application/a2ui+json; charset=utf-8'),
resourceBlock(JSON.stringify([MESSAGE]), 'Application/A2UI+JSON'),
];
expect(extractA2uiMessages(content)).toEqual([MESSAGE, MESSAGE]);
});

it('skips resources with invalid JSON and keeps the rest', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const content = [resourceBlock('{not json'), resourceBlock(JSON.stringify([MESSAGE]))];
expect(extractA2uiMessages(content)).toEqual([MESSAGE]);
expect(errorSpy).toHaveBeenCalledOnce();
errorSpy.mockRestore();
});
});
Loading
Loading