first draft - #2320
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for A2UI v1.0 over the Model Context Protocol (MCP), enabling dynamic dual-mode capability negotiation between Native A2UI Mode and Iframe Sandboxed Mode. It adds Python and TypeScript SDK helpers, a formal protocol specification, and a complete dual-mode sample application. The review feedback highlights several opportunities to improve the robustness and security of the implementation. Specifically, it is recommended to refactor the Python capability negotiation helper to avoid fragile type checks, add defensive error handling in the TypeScript action dispatcher and payload extractor to prevent runtime crashes, implement the fallback logic in the Python server tool, and secure the postMessage target origin in the sandbox proxy to prevent data leakage.
| def supports_native_a2ui(client_capabilities: Any) -> bool: | ||
| """Determines whether an MCP client advertises native A2UI support. | ||
|
|
||
| Inspects client capabilities or initialize parameters for the | ||
| 'io.modelcontextprotocol/ui' extension advertising 'application/a2ui+json' | ||
| in its mimeTypes list. | ||
|
|
||
| Args: | ||
| client_capabilities: A dictionary, types.ClientCapabilities, or | ||
| InitializeParams object representing client capabilities. | ||
|
|
||
| Returns: | ||
| True if the client explicitly supports application/a2ui+json, False otherwise. | ||
| """ | ||
| if client_capabilities is None: | ||
| return False | ||
|
|
||
| # Extract dictionary representation if pydantic / custom model | ||
| caps: dict[str, Any] = {} | ||
| if isinstance(client_capabilities, dict): | ||
| caps = client_capabilities | ||
| elif hasattr(client_capabilities, "model_dump"): | ||
| try: | ||
| caps = client_capabilities.model_dump(by_alias=True) | ||
| except Exception: | ||
| caps = {} | ||
| elif hasattr(client_capabilities, "dict"): | ||
| try: | ||
| caps = client_capabilities.dict() | ||
| except Exception: | ||
| caps = {} | ||
| elif hasattr(client_capabilities, "__dict__"): | ||
| caps = client_capabilities.__dict__ | ||
|
|
||
| # If wrapped under 'capabilities' (e.g. InitializeParams) | ||
| if "capabilities" in caps and isinstance(caps["capabilities"], dict): | ||
| caps = caps["capabilities"] | ||
|
|
||
| # Extract extensions | ||
| extensions = caps.get("extensions") | ||
| if extensions is None and hasattr(client_capabilities, "extensions"): | ||
| extensions = getattr(client_capabilities, "extensions") | ||
|
|
||
| if not isinstance(extensions, dict): | ||
| return False | ||
|
|
||
| ui_ext = extensions.get(MCP_UI_EXTENSION_KEY) | ||
| if ui_ext is None: | ||
| return False | ||
|
|
||
| if isinstance(ui_ext, dict): | ||
| mime_types = ui_ext.get("mimeTypes") or ui_ext.get("mime_types") | ||
| elif hasattr(ui_ext, "mimeTypes"): | ||
| mime_types = getattr(ui_ext, "mimeTypes") | ||
| elif hasattr(ui_ext, "mime_types"): | ||
| mime_types = getattr(ui_ext, "mime_types") | ||
| else: | ||
| mime_types = None | ||
|
|
||
| if isinstance(mime_types, (list, tuple, set)): | ||
| return A2UI_MIME_TYPE in mime_types | ||
|
|
||
| return False |
There was a problem hiding this comment.
The current implementation of supports_native_a2ui relies on fragile type checks (isinstance(client_capabilities, dict)) and nested dictionary conversions (model_dump, dict, __dict__). If client_capabilities is an object whose capabilities or extensions attributes are also custom objects (rather than dictionaries), the unwrapping logic fails or returns False prematurely. Refactoring this to use a simple helper function that dynamically retrieves keys/attributes is much more robust and maintainable.
def supports_native_a2ui(client_capabilities: Any) -> bool:
"""Determines whether an MCP client advertises native A2UI support.
Inspects client capabilities or initialize parameters for the
'io.modelcontextprotocol/ui' extension advertising 'application/a2ui+json'
in its mimeTypes list.
Args:
client_capabilities: A dictionary, types.ClientCapabilities, or
InitializeParams object representing client capabilities.
Returns:
True if the client explicitly supports application/a2ui+json, False otherwise.
"""
if client_capabilities is None:
return False
def get_field(obj: Any, key: str) -> Any:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
# Handle wrapping under 'capabilities' (e.g. InitializeParams)
caps = get_field(client_capabilities, "capabilities")
if caps is None:
caps = client_capabilities
extensions = get_field(caps, "extensions")
if extensions is None:
return False
ui_ext = get_field(extensions, MCP_UI_EXTENSION_KEY)
if ui_ext is None:
return False
mime_types = get_field(ui_ext, "mimeTypes")
if mime_types is None:
mime_types = get_field(ui_ext, "mime_types")
if isinstance(mime_types, (list, tuple, set)):
return A2UI_MIME_TYPE in mime_types
return False| if (typeof surfaceModel.dispatchError === 'function') { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| await surfaceModel.dispatchError({ | ||
| code: 'MCP_TOOL_ERROR', | ||
| message: `Failed to execute MCP tool '${action.name}': ${message}`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
If surfaceModel.dispatchError throws an error or rejects, it will result in an unhandled promise rejection because the subscribe listener is an asynchronous function. Wrapping the call in a try-catch block prevents unexpected runtime crashes or unhandled rejections during error dispatching.
if (typeof surfaceModel.dispatchError === 'function') {
const message = err instanceof Error ? err.message : String(err);
try {
await surfaceModel.dispatchError({
code: 'MCP_TOOL_ERROR',
message: `Failed to execute MCP tool '${action.name}': ${message}`,
});
} catch (dispatchErr) {
console.error('Failed to dispatch error to surface model:', dispatchErr);
}
}|
|
||
| for (const item of result.content) { | ||
| // 1. Embedded resource format: { type: 'resource', resource: { mimeType: 'application/a2ui+json', text: '...' } } |
There was a problem hiding this comment.
If result.content contains null, undefined, or non-object items, accessing item.type or item.mimeType directly will throw a TypeError and crash the application. Adding a defensive check at the beginning of the loop ensures robust execution.
for (const item of result.content) {
if (!item || typeof item !== 'object') {
continue;
}
// 1. Embedded resource format: { type: 'resource', resource: { mimeType: 'application/a2ui+json', text: '...' } }
if (item.type === 'resource' && item.resource) {| def get_counter_app(native: bool = False) -> types.CallToolResult: | ||
| return create_a2ui_tool_result( | ||
| get_counter_a2ui(COUNTER), | ||
| text_fallback=f"Current count: {COUNTER}", | ||
| resource_uri="a2ui://counter-view", | ||
| ) |
There was a problem hiding this comment.
The native parameter in get_counter_app is currently ignored, meaning the tool always returns the native A2UI payload regardless of the client's capabilities or the parameter value. Implementing the conditional logic to return the fallback HTML app when native is False makes the fallback mode functional and matches the tool's description.
def get_counter_app(native: bool = False) -> types.CallToolResult:
if not native:
html_path = pathlib.Path(__file__).parent / "public" / "fallback_app.html"
html_content = html_path.read_text(encoding="utf-8")
return types.CallToolResult(
content=[
types.EmbeddedResource(
type="resource",
resource=types.TextResourceContents(
uri="ui://counter/app",
mimeType=MCP_APPS_MIME_TYPE,
text=html_content,
),
)
]
)
return create_a2ui_tool_result(
get_counter_a2ui(COUNTER),
text_fallback=f"Current count: {COUNTER}",
resource_uri="a2ui://counter-view",
)| } else if (event.source === inner.contentWindow) { | ||
| window.parent.postMessage(event.data, '*'); | ||
| } |
There was a problem hiding this comment.
Using wildcard * as the target origin in postMessage allows any origin to intercept the messages if the window is re-routed or hijacked. Since the proxy iframe is loaded from the same origin as the parent host, using window.location.origin is much more secure and prevents potential data leakage.
| } else if (event.source === inner.contentWindow) { | |
| window.parent.postMessage(event.data, '*'); | |
| } | |
| } else if (event.source === inner.contentWindow) { | |
| window.parent.postMessage(event.data, window.location.origin); | |
| } |
No description provided.