Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e9b2b20
feat: Integrate MCP Apps into A2UI
dmandar Mar 2, 2026
77e96a2
chore: address PR review comments from gemini-code-assist
dmandar Mar 2, 2026
24cbc89
fix: Secure postMessage by capturing trusted host origin statefully
dmandar Mar 2, 2026
ab56d17
fix: fully secure MCP iframe initialization handshake with sandbox-in…
dmandar Mar 3, 2026
ad105f4
Merge branch 'main' into md-mcpuinew
dmandar Mar 3, 2026
e22bed7
style: run pyink auto-formatter to fix CI build
dmandar Mar 3, 2026
f5b2f41
fix(markdown-it): add missing package main and exports to resolve dow…
dmandar Mar 10, 2026
22859bf
fix(contact): resolve MCP iframe security issues and location double-…
dmandar Mar 10, 2026
0f43a44
revert(markdown-it): undo package exports change per PR review
dmandar Mar 10, 2026
63a3fd3
refactor(contact): address PR 748 review comments for McpApps integra…
dmandar Mar 10, 2026
04c0782
Merge main and resolve conflicts
dmandar Mar 11, 2026
c1b84a1
fix(lit): resolve compiler type errors and review comments
dmandar Mar 11, 2026
62bfcd3
chore: remove internal Google3 configs and properly align renderer sc…
dmandar Mar 11, 2026
22cfcec
Fix A2UI schema validator for incremental updates and update sample i…
dmandar Mar 12, 2026
6c53d20
Fix f-string curly brace escaping in prompt_builder.py
dmandar Mar 12, 2026
776fa4d
Fix LLM prompt for chart_node_click missing context to extract name
dmandar Mar 12, 2026
6bf4868
Address remaining PR #748 comments
dmandar Mar 13, 2026
7a544f8
Merge remote-tracking branch 'origin/main' into md-mcpuinew
dmandar Mar 13, 2026
441f282
Auto-format python code and add missing Apache license headers
dmandar Mar 13, 2026
b80812e
Merge main into md-mcpuinew
dmandar Mar 13, 2026
5f3a7db
chore: fix CI sample formatting, lit workspace, and revert agent_sdks…
dmandar Mar 13, 2026
57091eb
chore: fix NPM 401 error by regenerating package-lock.json via public…
dmandar Mar 13, 2026
dd1d846
chore: revert non-functional pyink formatting outside sample scope an…
dmandar Mar 13, 2026
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
9 changes: 7 additions & 2 deletions agent_sdks/python/src/a2ui/core/schema/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import importlib.resources
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass, field
from .utils import load_from_bundled_resource
from .utils import load_from_bundled_resource, deep_update
from ..inference_strategy import InferenceStrategy
from .constants import *
from .catalog import CatalogConfig, A2uiCatalog
Expand Down Expand Up @@ -146,10 +146,15 @@ def _select_catalog(
# Load the first inline catalog schema.
inline_catalog_schema = inline_catalogs[0]
inline_catalog_schema = self._apply_modifiers(inline_catalog_schema)

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.

This is a bit of a code smell to me.

In line 139 we add a prescription that client can only provide one or the other; inline or supported catalog.

But here, we are now implicitly baking the standard catalog.

1 ) Clients might NOT want standard catalog components included in responses.
2) component name overlaps could result in unexpected behavior without safe-guards

Can we revert this change and perhaps consider a more hi-fi solution to achieve this effect?

# Deep merge the standard catalog properties with the inline catalog
merged_schema = copy.deepcopy(self._supported_catalogs[0].catalog_schema)
deep_update(merged_schema, inline_catalog_schema)

return A2uiCatalog(
version=self._version,
name=INLINE_CATALOG_NAME,
catalog_schema=inline_catalog_schema,
catalog_schema=merged_schema,
s2c_schema=self._server_to_client_schema,
common_types_schema=self._common_types_schema,
)
Expand Down
10 changes: 10 additions & 0 deletions agent_sdks/python/src/a2ui/core/schema/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,13 @@ def wrap_as_json_array(a2ui_schema: dict[str, Any]) -> dict[str, Any]:
if not a2ui_schema:
raise ValueError("A2UI schema is empty")
return {"type": "array", "items": a2ui_schema}


def deep_update(d: dict, u: dict) -> dict:
"""Recursively update a dict with another dict."""
for k, v in u.items():
if isinstance(v, dict):
d[k] = deep_update(d.get(k, {}), v)
else:
d[k] = v
return d
66 changes: 42 additions & 24 deletions agent_sdks/python/src/a2ui/core/schema/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,44 +274,54 @@ def validate(self, a2ui_json: Union[Dict[str, Any], List[Any]]) -> None:
msg += f"\n - {sub_error.message}"
raise ValueError(msg)

root_id = _find_root_id(messages)

for message in messages:
if not isinstance(message, dict):
continue

components = None
surface_id = None
if "surfaceUpdate" in message: # v0.8
components = message["surfaceUpdate"].get(COMPONENTS)
surface_id = message["surfaceUpdate"].get("surfaceId")
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.

We should use constants for "surfaceId"

elif "updateComponents" in message and isinstance(
message["updateComponents"], dict
): # v0.9
components = message["updateComponents"].get(COMPONENTS)
surface_id = message["updateComponents"].get("surfaceId")

if components:
ref_map = _extract_component_ref_fields(self._catalog)
root_id = _find_root_id(messages, surface_id)
_validate_component_integrity(root_id, components, ref_map)
_validate_topology(root_id, components, ref_map)
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.

Making root_id optional and the validation to be prone to pass when root id is absent seems making this validator brittle and false-negative prone.


_validate_recursion_and_paths(message)


def _find_root_id(messages: List[Dict[str, Any]]) -> str:
def _find_root_id(
messages: List[Dict[str, Any]], surface_id: Optional[str] = None
) -> Optional[str]:
"""
Finds the root id from a list of A2UI messages.
Finds the root id from a list of A2UI messages for a given surface.
- For v0.8, the root id is in the beginRendering message.
- For v0.9+, the root id is 'root'.
"""
for message in messages:
if not isinstance(message, dict):
continue
if "beginRendering" in message:
if surface_id and message["beginRendering"].get("surfaceId") != surface_id:
continue
return message["beginRendering"].get(ROOT, ROOT)
return ROOT
if "createSurface" in message:
if surface_id and message["createSurface"].get("surfaceId") != surface_id:
continue
return ROOT
return None


def _validate_component_integrity(
root_id: str,
root_id: Optional[str],
components: List[Dict[str, Any]],
ref_fields_map: Dict[str, tuple[Set[str], Set[str]]],
) -> None:
Expand All @@ -334,21 +344,23 @@ def _validate_component_integrity(
ids.add(comp_id)

# 2. Check for root component
if root_id not in ids:
if root_id is not None and root_id not in ids:
raise ValueError(f"Missing root component: No component has id='{root_id}'")

# 3. Check for dangling references using helper
for comp in components:
for ref_id, field_name in _get_component_references(comp, ref_fields_map):
if ref_id not in ids:
raise ValueError(
f"Component '{comp.get(ID)}' references non-existent component '{ref_id}'"
f" in field '{field_name}'"
)
# In an incremental update (root_id is None), components may reference IDs already on the client.
if root_id is not None:
for comp in components:
for ref_id, field_name in _get_component_references(comp, ref_fields_map):
if ref_id not in ids:
raise ValueError(
f"Component '{comp.get(ID)}' references non-existent component '{ref_id}'"
f" in field '{field_name}'"
)


def _validate_topology(
root_id: str,
root_id: Optional[str],
components: List[Dict[str, Any]],
ref_fields_map: Dict[str, tuple[Set[str], Set[str]]],
) -> None:
Expand Down Expand Up @@ -401,16 +413,22 @@ def dfs(node_id: str, depth: int):

recursion_stack.remove(node_id)

if root_id in all_ids:
dfs(root_id, 0)
if root_id is not None:
if root_id in all_ids:
dfs(root_id, 0)

# Check for Orphans
orphans = all_ids - visited
if orphans:
sorted_orphans = sorted(list(orphans))
raise ValueError(
f"Component '{sorted_orphans[0]}' is not reachable from '{root_id}'"
)
# Check for Orphans
orphans = all_ids - visited
if orphans:
sorted_orphans = sorted(list(orphans))
raise ValueError(
f"Component '{sorted_orphans[0]}' is not reachable from '{root_id}'"
)
else:
# Partial update: we cannot check root reachability, but we still check for cycles
for node_id in all_ids:
if node_id not in visited:
dfs(node_id, 0)


def _extract_component_ref_fields(
Expand Down
118 changes: 74 additions & 44 deletions agent_sdks/python/tests/core/schema/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ def catalog_0_9(self):
"catalogId": {
"type": "string",
},
"theme": {"type": "object", "additionalProperties": True},
"theme": {
"type": "object",
"additionalProperties": True,
},
},
"required": ["surfaceId", "catalogId"],
"additionalProperties": False,
Expand Down Expand Up @@ -495,7 +498,10 @@ def test_custom_catalog_0_8(self, catalog_0_8):
"children": {
"type": "object",
"properties": {
"explicitList": {"type": "array", "items": {"type": "string"}}
"explicitList": {
"type": "array",
"items": {"type": "string"},
}
},
"required": ["explicitList"],
}
Expand Down Expand Up @@ -723,30 +729,53 @@ def make_payload(self, catalog, components=None, data_model=None):
processed.append(comp)

if catalog.version == VERSION_0_8:
payload = {
"surfaceUpdate": {"surfaceId": "test-surface", "components": processed}
}
payload = [
{"beginRendering": {"surfaceId": "test-surface", "root": "root"}},
{
"surfaceUpdate": {
"surfaceId": "test-surface",
"components": processed,
}
},
]
else:
payload = {
"version": "v0.9",
"updateComponents": {"surfaceId": "test-surface", "components": processed},
}
payload = [
{
"version": "v0.9",
"createSurface": {"surfaceId": "test-surface", "catalogId": "std"},
},
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "test-surface",
"components": processed,
},
},
]

elif data_model:
if catalog.version == VERSION_0_8:
payload = {
"dataModelUpdate": {"surfaceId": "test-surface", "contents": data_model}
}
payload = [
{
"dataModelUpdate": {
"surfaceId": "test-surface",
"contents": data_model,
}
}
]
else:
payload = {
payload = [{
"version": "v0.9",
"updateDataModel": {"surfaceId": "test-surface", "value": data_model},
}
"updateDataModel": {
"surfaceId": "test-surface",
"value": data_model,
},
}]

if payload is None:
return [] if catalog.version == VERSION_0_9 else {}
return []

return [payload] if catalog.version == VERSION_0_9 else payload
return payload

def test_validate_duplicate_ids(self, test_catalog):
components = [
Expand All @@ -762,20 +791,29 @@ def test_validate_missing_root(self, test_catalog):
# This payload has components but none are 'root'
# bypass make_payload as it adds root if missing
if test_catalog.version == VERSION_0_8:
payload = {
"surfaceUpdate": {
"surfaceId": "test",
"components": [{"id": "c1", "component": {"Text": {"text": "hi"}}}],
}
}
payload = [
{"beginRendering": {"surfaceId": "test", "root": "root"}},
{
"surfaceUpdate": {
"surfaceId": "test",
"components": [{"id": "c1", "component": {"Text": {"text": "hi"}}}],
}
},
]
else:
payload = [{
"version": "v0.9",
"updateComponents": {
"surfaceId": "test",
"components": [{"id": "c1", "component": "Text", "text": "hi"}],
payload = [
{
"version": "v0.9",
"createSurface": {"surfaceId": "test", "catalogId": "std"},
},
{
"version": "v0.9",
"updateComponents": {
"surfaceId": "test",
"components": [{"id": "c1", "component": "Text", "text": "hi"}],
},
},
}]
]

with pytest.raises(ValueError, match="Missing root component"):
test_catalog.validator.validate(payload)
Expand Down Expand Up @@ -915,12 +953,9 @@ def test_validate_v08_custom_root_reachability(self, test_catalog):
{"id": "custom-root", "component": "Text", "text": "I am the root"},
{"id": "orphan", "component": "Text", "text": "I am an orphan"},
]
# make_payload only gives us surfaceUpdate, we need to wrap it with beginRendering
surface_update = self.make_payload(test_catalog, components=components)
payload = [
{"beginRendering": {"surfaceId": "test-surface", "root": "custom-root"}},
surface_update,
]
# make_payload gives us both beginRendering and surfaceUpdate. We just need to change root.
payload = self.make_payload(test_catalog, components=components)
payload[0]["beginRendering"]["root"] = "custom-root"

# This should fail because 'orphan' is not reachable from 'custom-root'
with pytest.raises(
Expand All @@ -933,13 +968,8 @@ def test_validate_v08_custom_root_reachability(self, test_catalog):
{"id": "custom-root", "component": "Card", "child": "orphan"},
{"id": "orphan", "component": "Text", "text": "I am no longer an orphan"},
]
surface_update_connected = self.make_payload(
test_catalog, components=components_connected
)
payload_connected = [
{"beginRendering": {"surfaceId": "test-surface", "root": "custom-root"}},
surface_update_connected,
]
payload_connected = self.make_payload(test_catalog, components=components_connected)
payload_connected[0]["beginRendering"]["root"] = "custom-root"
test_catalog.validator.validate(payload_connected)

@pytest.mark.parametrize(
Expand Down Expand Up @@ -985,8 +1015,8 @@ def test_validate_invalid_paths(self, test_catalog, payload):
p[0]["updateDataModel"]["path"] = data.get("path")
p[0]["updateDataModel"]["surfaceId"] = data.get("surfaceId", "surface1")
else:
p["dataModelUpdate"]["path"] = data.get("path")
p["dataModelUpdate"]["surfaceId"] = data.get("surfaceId", "surface1")
p[0]["dataModelUpdate"]["path"] = data.get("path")
p[0]["dataModelUpdate"]["surfaceId"] = data.get("surfaceId", "surface1")

with pytest.raises(
ValueError,
Expand Down
6 changes: 6 additions & 0 deletions samples/agent/adk/contact_multiple_surfaces/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ This sample uses the Agent Development Kit (ADK) along with the A2A protocol to
uv run .
```

4. (Optional) Run the server with standard `WebFrame` instead of the custom `McpAppsCustomComponent`:

```bash
USE_MCP_SANDBOX=false uv run .
```


## Disclaimer

Expand Down
Loading
Loading