Skip to content

first draft - #2320

Open
wrenj wants to merge 7 commits into
mainfrom
mcpappsa2ui
Open

first draft#2320
wrenj wants to merge 7 commits into
mainfrom
mcpappsa2ui

Conversation

@wrenj

@wrenj wrenj commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +21 to +83
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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

Comment on lines +91 to +97
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}`,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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);
        }
      }

Comment on lines +62 to +64

for (const item of result.content) {
// 1. Embedded resource format: { type: 'resource', resource: { mimeType: 'application/a2ui+json', text: '...' } }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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) {

Comment on lines +147 to +152
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",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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",
        )

Comment on lines +38 to +40
} else if (event.source === inner.contentWindow) {
window.parent.postMessage(event.data, '*');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

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.

Suggested change
} 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);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant