Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
8 changes: 4 additions & 4 deletions renderers/web_core/src/v0_9/schema/client-to-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('Client-to-Server Schema Verification', () => {

versions.forEach(version => {
describe(`Protocol ${version}`, () => {
it(`validates a valid action message`, () => {
it('validates a valid action message', () => {
const validAction = {
version,
action: {
Expand All @@ -38,7 +38,7 @@ describe('Client-to-Server Schema Verification', () => {
assert.ok(result.success, result.success ? '' : result.error.message);
});

it(`validates a valid error message (validation failed)`, () => {
it('validates a valid error message (validation failed)', () => {
const validError = {
version,
error: {
Expand All @@ -52,7 +52,7 @@ describe('Client-to-Server Schema Verification', () => {
assert.ok(result.success, result.success ? '' : result.error.message);
});

it(`validates a valid error message (generic)`, () => {
it('validates a valid error message (generic)', () => {
const validError = {
version,
error: {
Expand All @@ -65,7 +65,7 @@ describe('Client-to-Server Schema Verification', () => {
assert.ok(result.success, result.success ? '' : result.error.message);
});

it(`validates a valid data model message`, () => {
it('validates a valid data model message', () => {
const validDataModel = {
version,
surfaces: {
Expand Down
12 changes: 11 additions & 1 deletion samples/community/agent/adk/mcp_app_proxy/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from google.adk.sessions import InMemorySessionService
from google.genai import types
from pydantic import PrivateAttr
from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, commentate_pong_game
from tools import get_calculator_app, calculate_via_mcp, get_pong_mcp_app_json, get_pong_app_web_frame_json, commentate_pong_game
from agent_executor import get_a2ui_enabled, get_a2ui_catalog, get_a2ui_examples

logger = logging.getLogger(__name__)
Expand All @@ -39,6 +39,7 @@
You are an expert A2UI Proxy Agent. Your primary functions are to fetch the Calculator App or the Pong App and display it to the user.
When the user asks for the calculator, you MUST call the `get_calculator_app` tool.
When the user asks for Pong with MCP Apps, you MUST call the `get_pong_mcp_app_json` tool.
When the user asks for Pong with WebApp URL, you MUST call the `get_pong_app_web_frame_json` tool.

IMPORTANT: Do NOT attempt to construct the JSON manually. The tools handle it automatically.

Expand All @@ -51,6 +52,7 @@
1. **Analyze Request**:
- If User asks for calculator: Call `get_calculator_app`.
- If User asks for Pong with MCP Apps: Call `get_pong_mcp_app_json`.
- If User asks for Pong with WebApp URL: Call `get_pong_app_web_frame_json`.
- If User interacts with the calculator (ACTION: calculate): Extract 'operation', 'a', and 'b' from the event context and call `calculate_via_mcp`. Return the result to the user.
- If you receive a `"commentate_pong"` action: Call `commentate_pong_game` with `"game_event"` from `"context" -> "game_event"`. Do not generate text responses; only call the tool.
"""
Expand Down Expand Up @@ -167,6 +169,13 @@ def _build_agent_card(self) -> AgentCard:
tags=["html", "app", "demo", "tool"],
examples=["open pong with mcp apps"],
),
AgentSkill(
id="open_pong_web_frame",
name="Open Pong with WebApp URL",
description="Opens Pong using the new WebAppFrame URL method.",
tags=["html", "app", "demo", "tool"],
examples=["open pong with webapp url"],
),
],
)

Expand Down Expand Up @@ -205,6 +214,7 @@ def _build_llm_agent(
get_calculator_app,
calculate_via_mcp,
get_pong_mcp_app_json,
get_pong_app_web_frame_json,
commentate_pong_game,
],
planner=BuiltInPlanner(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,65 @@
],
"unevaluatedProperties": false
},
"WebAppFrameUrl": {
Comment thread
sugoi-yuzuru marked this conversation as resolved.
"type": "object",
"allOf": [
{
"$ref": "common_types.json#/$defs/ComponentCommon"
},
{
"type": "object",
"properties": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it make sense to also have a way to pass arbitrary static data to the embedded app as a property, rather than needing to pass it via the data model?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the idea.

I have introduced a new property config to allow static data assignment to embedded app.
Please check this commit.

  1. I have updated the spec document to discuss about it. In addition to the spec update, I added a section to discuss the difference between the 3 flavors of data: config, data without mutableData, and data with mutableData.
  2. I have updated the component catalog and implementation.
  3. I have updated the WebAppFrameUrl example Pong game to leverage this config to define the "matching-score" of the game. Previously, it was hard-coded to announce a winner who scored 3 points first. Now, the Agent will return a config to set the exact score to call the winner (5).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if you think config is not the right design here.

"component": {
"const": "WebAppFrameUrl",
"description": "The component type identifier."
},
"url": {
"$ref": "common_types.json#/$defs/DynamicString",
"description": "The external URL to load inside the iframe."
},
"height": {
"$ref": "common_types.json#/$defs/DynamicNumber",
"description": "The height of the iframe in pixels."
},
"allowedEvents": {
"type": "object",
"description": "A map of authorized action names to JSON Schemas defining the expected data payload.",
"additionalProperties": {"type": "object"}
},
"allowedFunctions": {
"type": "object",
"description": "A map of authorized host client functions to JSON Schemas defining their expected arguments.",
"additionalProperties": {"type": "object"}
},
"mutableData": {
"type": "object",
"description": "A map of data model keys that the embedded application is authorized to mutate in the parent A2UI Data Model, mapped to JSON Schemas defining their allowed values.",
"additionalProperties": {"type": "object"}
},
"config": {
"type": "object",
"description": "A dictionary of static key-value initialization properties passed directly to the embedded application without reactive data model binding."
},
"data": {
"type": "object",
"description": "Data binding configuration for the component.",
"properties": {
"paths": {
"type": "object",
"description": "A dictionary mapping custom state keys to distinct JSON Pointer paths in the data model.",
"additionalProperties": {"type": "string"}
}
},
"required": ["paths"],
"additionalProperties": false
}
},
"required": ["component", "url"]
}
],
"unevaluatedProperties": false
},
"PongScoreBoard": {
"type": "object",
"allOf": [
Expand Down Expand Up @@ -156,6 +215,9 @@
},
{
"$ref": "#/components/Column"
},
{
"$ref": "#/components/WebAppFrameUrl"
}
]
}
Expand Down
15 changes: 10 additions & 5 deletions samples/community/agent/adk/mcp_app_proxy/pong_engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ function hideOverlay() {
overlayEl.classList.add('hidden');
}

// Default winning score for when the winning score is not provided by the host context
const DEFAULT_WINNING_SCORE = 3;

// Reference dimensions for scaling
const REF_WIDTH = 600;
const REF_HEIGHT = 400;
Expand Down Expand Up @@ -236,10 +239,12 @@ function syncScore(player) {
});
}

const targetScore = typeof winningScore !== 'undefined' ? winningScore : DEFAULT_WINNING_SCORE;

let eventDescription = 'player scored';
if (localPlayerScore >= 3) {
if (localPlayerScore >= targetScore) {
eventDescription = 'player won the match';
} else if (localCpuScore >= 3) {
} else if (localCpuScore >= targetScore) {
eventDescription = 'cpu won the match';
} else if (player === 'cpu') {
eventDescription = 'cpu scored';
Expand All @@ -253,12 +258,12 @@ function syncScore(player) {
},
}).catch(e => console.error('Failed to request commentary:', e));

if (localPlayerScore >= 3 || localCpuScore >= 3) {
if (localPlayerScore >= targetScore || localCpuScore >= targetScore) {
isPaused = true;
displayOverlay(localPlayerScore >= 3 ? 'YOU WIN!' : 'CPU WINS!');
displayOverlay(localPlayerScore >= targetScore ? 'YOU WIN!' : 'CPU WINS!');
sendRequest('ui/requests/function-call', {
call: 'showWinnerModal',
args: {winner: localPlayerScore >= 3 ? 'player' : 'cpu'},
args: {winner: localPlayerScore >= targetScore ? 'player' : 'cpu'},
}).catch(e => console.error('Failed to trigger showWinnerModal:', e));
}
}
Expand Down
80 changes: 80 additions & 0 deletions samples/community/agent/adk/mcp_app_proxy/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,86 @@ async def get_pong_mcp_app_json(tool_context: ToolContext):
return {"validated_a2ui_json": messages}


async def get_pong_app_web_frame_json(tool_context: ToolContext):
"""Fetches the Pong game app using the WebAppFrameUrl component."""

# Reset score on reload
global PONG_CURRENT_SCORE
PONG_CURRENT_SCORE = {"player": 0, "cpu": 0}

messages = [
{
"version": "v0.9",
"createSurface": {
"surfaceId": PONG_SURFACE_ID,
"catalogId": (
"https://a2ui.org/samples/community/agent/adk/mcp_app_proxy/catalogs/0.9/mcp_app_catalog.json"
),
},
},
{
"version": "v0.9",
"updateDataModel": {
"surfaceId": PONG_SURFACE_ID,
"path": "/",
"value": {
"pong_state": {
"player_score": PONG_CURRENT_SCORE["player"],
"cpu_score": PONG_CURRENT_SCORE["cpu"],
"commentary": "Let the match begin!",
}
},
},
},
{
"version": "v0.9",
"updateComponents": {
"surfaceId": PONG_SURFACE_ID,
"components": [
{
"id": "root",
"component": "PongLayout",
"mcpComponent": "web_frame_app_root",
"scoreboardComponent": "scoreboard_root",
},
{
"id": "web_frame_app_root",
"component": "WebAppFrameUrl",
"url": "http://localhost:8081/pong_app_web_frame.html",
"allowedEvents": {
"commentate_pong": {
"type": "object",
"properties": {
"game_event": {"type": "string"},
"silent": {"type": "boolean"},
},
}
},
"allowedFunctions": {
"showWinnerModal": {
"type": "object",
"properties": {"winner": {"type": "string"}},
}
},
"mutableData": {"state": {}},
"config": {"matchingScore": 5},
"data": {"paths": {"state": "/pong_state"}},
},
{
"id": "scoreboard_root",
"component": "PongScoreBoard",
"playerScore": {"path": "/pong_state/player_score"},
"cpuScore": {"path": "/pong_state/cpu_score"},
"commentary": {"path": "/pong_state/commentary"},
},
],
},
},
]
tool_context.actions.skip_summarization = True
return {"validated_a2ui_json": messages}


async def commentate_pong_game(tool_context: ToolContext, game_event: str):
"""Generates a witty neon-themed sports commentary or lighthearted trash talk comment based on the game event description, and applies it to the game scoreboard.

Expand Down
4 changes: 3 additions & 1 deletion samples/community/client/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"ng": "ng",
"start": "yarn build:sandbox && ng serve",
"build:sandbox": "mkdir -p projects/mcp_calculator/public/mcp_apps_inner_iframe && npx esbuild ../../client/shared/mcp_apps_inner_iframe/sandbox.ts --bundle --outfile=projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.js --format=esm --platform=browser && cp ../../client/shared/mcp_apps_inner_iframe/sandbox.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.html",
"build:sandbox": "mkdir -p projects/mcp_calculator/public/mcp_apps_inner_iframe && npx esbuild ../../client/shared/mcp_apps_inner_iframe/sandbox.ts --bundle --outfile=projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.js --format=esm --platform=browser && cp ../../client/shared/mcp_apps_inner_iframe/sandbox.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox.html && cp ../../client/shared/mcp_apps_inner_iframe/sandbox-url.html projects/mcp_calculator/public/mcp_apps_inner_iframe/sandbox-url.html",
"build": "ng build a2a-chat-canvas && ng build orchestrator && ng build mcp_calculator",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
Expand Down Expand Up @@ -34,6 +34,7 @@
"chart.js": "^4.5.1",
"chartjs-plugin-datalabels": "^2.2.0",
"express": "^5.2.1",
"fast-json-stable-stringify": "^2.1.0",
"markdown-it": "^14.2.0",
"ng2-charts": "^10.0.0",
"rxjs": "^7.8.2",
Expand All @@ -47,6 +48,7 @@
"@angular/cli": "^21.2.5",
"@angular/compiler-cli": "^21.2.5",
"@types/express": "^5.0.6",
"@types/fast-json-stable-stringify": "^2.1.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.9.3",
"@types/uuid": "^11.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
},
"dependencies": {
"@a2ui/angular": "^0.10.0",
"@a2ui/web_core": "^0.10.0"
"@a2ui/web_core": "^0.10.0",
"ajv": "^8.20.0"
},
"devDependencies": {
"wireit": "^0.15.0-pre.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {McpApp} from './mcp-app';
import {PongScoreBoard} from './pong-scoreboard';
import {PongLayout} from './pong-layout';
import {Column} from '@a2ui/angular';
import {WebAppFrameUrl} from './web-app-frame-url';

/**
* The catalog ID for the MCP App catalog.
Expand Down Expand Up @@ -53,6 +54,16 @@ const PongLayoutSchema = z.object({
scoreboardComponent: z.string().optional(),
});

const WebAppFrameUrlSchema = z.object({
url: DynamicStringSchema,
data: DynamicValueSchema.optional(),
height: DynamicNumberSchema.optional(),
allowedEvents: z.record(z.any()).optional(),
allowedFunctions: z.record(z.any()).optional(),
mutableData: z.record(z.any()).optional(),
disableSchemaValidation: z.boolean().optional(),
});

export const SHOW_WINNER_MODAL_FN = createFunctionImplementation(
{
name: 'showWinnerModal',
Expand Down Expand Up @@ -127,6 +138,7 @@ export const DEMO_CATALOG = new Catalog(
{name: 'McpApp', component: McpApp, schema: McpAppSchema},
{name: 'PongScoreBoard', component: PongScoreBoard, schema: PongScoreBoardSchema},
{name: 'PongLayout', component: PongLayout, schema: PongLayoutSchema},
{name: 'WebAppFrameUrl', component: WebAppFrameUrl, schema: WebAppFrameUrlSchema},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josemontesp is there some way in Angular that we the application developer could pass some configuration data to the Component when installing it in a catalog?

I think it could be great for a developer to be able to set some static domain allowlist for these apps here, to statically protect the client against loading apps at arbitrary domains.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooo

This would be cool.

Maintaining an allowlist on the client-side catalog would be a nice protection against Agent hallucination and compromised A2UI payloads coming from Agent/MCP servers.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Angular we could use injection tokens that are provided at the app root level, with an optional type-safe configuration. The component can then inject that configuration directly into the component class.

// Column should use ColumnApi.schema from @a2ui/web_core, but it is not currently
// exported by the version of @a2ui/web_core resolved in this community sample.
// We use z.any() to avoid duplicating the schema definition here.
Expand Down
Loading
Loading