From 0651f319c7b6791dbf94642973765a3ed5f2e346 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:17:38 +0900 Subject: [PATCH 01/16] Add protocol adapter interface and default OpenAI adapter --- fastapps/core/adapters/openai_apps.py | 306 ++++++++++++++++++++++++++ fastapps/core/protocol.py | 24 ++ fastapps/core/server.py | 18 +- 3 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 fastapps/core/adapters/openai_apps.py create mode 100644 fastapps/core/protocol.py diff --git a/fastapps/core/adapters/openai_apps.py b/fastapps/core/adapters/openai_apps.py new file mode 100644 index 0000000..53cad23 --- /dev/null +++ b/fastapps/core/adapters/openai_apps.py @@ -0,0 +1,306 @@ +"""Default adapter for OpenAI Apps SDK-compatible behavior.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from mcp import types + +from fastapps.core.protocol import ProtocolAdapter +from fastapps.core.utils import get_cli_version +from fastapps.core.widget import BaseWidget, ClientContext, UserContext + +if TYPE_CHECKING: + from fastapps.core.server import WidgetMCPServer + + +class OpenAIAppsAdapter(ProtocolAdapter): + """Implements the legacy/default OpenAI Apps SDK wiring.""" + + def register_handlers(self, widget_server: "WidgetMCPServer") -> None: + """Attach handlers on the given WidgetMCPServer.""" + server = widget_server.mcp._mcp_server + + original_initialize = server.request_handlers.get(types.InitializeRequest) + + async def initialize_handler( + req: types.InitializeRequest, + ) -> types.ServerResult: + meta = req.params._meta if hasattr(req.params, "_meta") else {} + requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") + + if requested_locale: + widget_server.client_locale = requested_locale + for widget in widget_server.widgets_by_id.values(): + resolved = widget.negotiate_locale(requested_locale) + widget.resolved_locale = resolved + + if original_initialize: + return await original_initialize(req) + + return types.ServerResult( + types.InitializeResult( + protocolVersion=req.params.protocolVersion, + capabilities=types.ServerCapabilities(), + serverInfo=types.Implementation( + name="FastApps", version=get_cli_version() + ), + ) + ) + + server.request_handlers[types.InitializeRequest] = initialize_handler + + @server.list_tools() + async def list_tools_handler() -> List[types.Tool]: + tools_list = [] + for w in widget_server.widgets_by_id.values(): + tool_meta = w.get_tool_meta() + + if "securitySchemes" not in tool_meta and widget_server.server_requires_auth: + tool_meta["securitySchemes"] = [ + {"type": "oauth2", "scopes": widget_server.server_auth_scopes} + ] + + tools_list.append( + types.Tool( + name=w.identifier, + title=w.title, + description=w.description or w.title, + inputSchema=w.get_input_schema(), + _meta=tool_meta, + ) + ) + return tools_list + + @server.list_resources() + async def list_resources_handler() -> List[types.Resource]: + resources = [] + for w in widget_server.widgets_by_id.values(): + meta = w.get_resource_meta() + resource = types.Resource( + name=w.title, + title=w.title, + uri=w.template_uri, + description=f"{w.title} widget markup", + mimeType="text/html+skybridge", + _meta=meta, + ) + resources.append(resource) + return resources + + @server.list_resource_templates() + async def list_resource_templates_handler() -> List[types.ResourceTemplate]: + return [ + types.ResourceTemplate( + name=w.title, + title=w.title, + uriTemplate=w.template_uri, + description=f"{w.title} widget markup", + mimeType="text/html+skybridge", + _meta=w.get_resource_meta(), + ) + for w in widget_server.widgets_by_id.values() + ] + + async def read_resource_handler( + req: types.ReadResourceRequest, + ) -> types.ServerResult: + widget = widget_server.widgets_by_uri.get(str(req.params.uri)) + if not widget: + return types.ServerResult( + types.ReadResourceResult( + contents=[], + _meta={"error": f"Unknown resource: {req.params.uri}"}, + ) + ) + + contents = [ + types.TextResourceContents( + uri=widget.template_uri, + mimeType="text/html+skybridge", + text=widget.build_result.html, + _meta=widget.get_resource_meta(), + ) + ] + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: + widget = widget_server.widgets_by_id.get(req.params.name) + if not widget: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", text=f"Unknown tool: {req.params.name}" + ) + ], + isError=True, + ) + ) + + try: + access_token = None + if hasattr(req, "context") and hasattr(req.context, "access_token"): + access_token = req.context.access_token + elif hasattr(req.params, "_meta"): + meta_token = req.params._meta.get("access_token") + if meta_token: + access_token = meta_token + + widget_requires_auth = getattr(widget, "_auth_required", None) + + if widget_requires_auth is None and widget_server.server_requires_auth: + widget_requires_auth = True + + if widget_requires_auth is True and not access_token: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="Authentication required for this tool", + ) + ], + isError=True, + ) + ) + + if ( + access_token + and hasattr(widget, "_auth_scopes") + and widget._auth_scopes + ): + user_scopes = getattr(access_token, "scopes", []) + missing_scopes = set(widget._auth_scopes) - set(user_scopes) + + if missing_scopes: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Missing required scopes: {', '.join(missing_scopes)}", + ) + ], + isError=True, + ) + ) + + arguments = req.params.arguments or {} + input_data = widget.input_schema.model_validate(arguments) + + meta = req.params._meta if hasattr(req.params, "_meta") else {} + + requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") + if requested_locale: + widget.resolved_locale = widget.negotiate_locale(requested_locale) + + context = ClientContext(meta) + user = UserContext(access_token) + + result_data = await widget.execute(input_data, context, user) + except Exception as exc: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent(type="text", text=f"Error: {str(exc)}") + ], + isError=True, + ) + ) + + widget_resource = widget.get_embedded_resource() + meta: Dict[str, Any] = { + "openai.com/widget": widget_resource.model_dump(mode="json"), + "openai/outputTemplate": widget.template_uri, + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetAccessible": widget.widget_accessible, + "openai/resultCanProduceWidget": True, + } + + if widget.resolved_locale: + meta["openai/locale"] = widget.resolved_locale + + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=widget.invoked)], + structuredContent=result_data, + _meta=meta, + ) + ) + + server.request_handlers[types.ReadResourceRequest] = read_resource_handler + server.request_handlers[types.CallToolRequest] = call_tool_handler + + self._register_assets_proxy(widget_server) + + def _register_assets_proxy(self, widget_server: "WidgetMCPServer") -> None: + """Proxy /assets requests to local asset server (same behavior as before).""" + app = widget_server.mcp._mcp_server.http_app + if app is None: + app = widget_server.mcp.http_app() + + try: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=False, + ) + except Exception: + pass + + try: + import httpx + from starlette.responses import Response + from starlette.routing import Route + + async def proxy_assets(request): + path = request.path_params.get("path", "") + upstream_url = f"http://127.0.0.1:4444/{path}" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + upstream_response = await client.get(upstream_url) + + allowed_headers = { + "content-type", + "cache-control", + "etag", + "last-modified", + "content-length", + } + response_headers = { + k: v + for k, v in upstream_response.headers.items() + if k.lower() in allowed_headers + } + + if "content-type" not in response_headers: + response_headers["content-type"] = "application/octet-stream" + + response_headers["access-control-allow-origin"] = "*" + response_headers["access-control-allow-methods"] = "GET, OPTIONS" + response_headers["access-control-allow-headers"] = "*" + + return Response( + content=upstream_response.content, + status_code=upstream_response.status_code, + headers=response_headers, + ) + except httpx.RequestError: + return Response( + content=b"Asset server unavailable", + status_code=502, + headers={"content-type": "text/plain"}, + ) + + app.routes.append(Route("/assets/{path:path}", proxy_assets, methods=["GET"])) + except Exception as e: + # Log error but don't crash + print(f"Warning: Could not register /assets proxy route: {e}") + pass diff --git a/fastapps/core/protocol.py b/fastapps/core/protocol.py new file mode 100644 index 0000000..8fce3c0 --- /dev/null +++ b/fastapps/core/protocol.py @@ -0,0 +1,24 @@ +"""Protocol adapter interface for MCP UI variants. + +Adapters allow the framework to target different MCP UI extensions +without rewriting widget or server code. The default behavior remains +OpenAI Apps SDK compatible; other adapters can register their own +handlers on the server instance. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapps.core.server import WidgetMCPServer + + +class ProtocolAdapter(ABC): + """Defines how a protocol variation wires MCP handlers.""" + + @abstractmethod + def register_handlers(self, widget_server: "WidgetMCPServer") -> None: + """Attach protocol-specific handlers to the given server.""" + raise NotImplementedError diff --git a/fastapps/core/server.py b/fastapps/core/server.py index db0a0bf..2a8d628 100644 --- a/fastapps/core/server.py +++ b/fastapps/core/server.py @@ -4,6 +4,7 @@ from mcp import types from fastapps.core.utils import get_cli_version +from .protocol import ProtocolAdapter from .widget import BaseWidget, ClientContext, UserContext @@ -32,6 +33,7 @@ def __init__( name: str, widgets: List[BaseWidget], # OAuth 2.1 authentication parameters (optional) + adapter: Optional[ProtocolAdapter] = None, auth_issuer_url: Optional[str] = None, auth_resource_server_url: Optional[str] = None, auth_required_scopes: Optional[List[str]] = None, @@ -47,6 +49,7 @@ def __init__( Args: name: Server name widgets: List of widget instances + adapter: Optional protocol adapter for UI variants (default: built-in OpenAI Apps behavior) auth_issuer_url: OAuth issuer URL (e.g., https://tenant.auth0.com) auth_resource_server_url: Your MCP server URL (e.g., https://example.com/mcp) auth_required_scopes: Required OAuth scopes (e.g., ["user", "read:data"]) @@ -90,6 +93,9 @@ def __init__( self.widgets_by_uri = {w.template_uri: w for w in widgets} self.client_locale: Optional[str] = None + # Optional protocol adapter (e.g., MCP Apps); default behavior remains intact + self.adapter: Optional[ProtocolAdapter] = None + # Store global CSP configuration self.global_resource_domains = global_resource_domains or [] self.global_connect_domains = global_connect_domains or [] @@ -139,7 +145,17 @@ def __init__( fastmcp_kwargs.update({"token_verifier": verifier, "auth": auth_settings}) self.mcp = FastMCP(**fastmcp_kwargs) - self._register_handlers() + # Use adapter if provided; otherwise fall back to built-in OpenAI Apps behavior + if adapter: + if not isinstance(adapter, ProtocolAdapter): + raise TypeError("adapter must implement ProtocolAdapter") + self.adapter = adapter + else: + from .adapters.openai_apps import OpenAIAppsAdapter + + self.adapter = OpenAIAppsAdapter() + + self.adapter.register_handlers(self) def _configure_widget_csp(self, widgets: List[BaseWidget]): """ From e28b608581342452e9515b057bc119b5f7009385 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:17:50 +0900 Subject: [PATCH 02/16] Add MCP Apps adapter for SEP-1865 --- fastapps/__init__.py | 6 + fastapps/core/adapters/mcp_apps.py | 247 +++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 fastapps/core/adapters/mcp_apps.py diff --git a/fastapps/__init__.py b/fastapps/__init__.py index 737f3e1..d034e67 100644 --- a/fastapps/__init__.py +++ b/fastapps/__init__.py @@ -18,6 +18,9 @@ async def execute(self, input_data) -> Dict[str, Any]: __author__ = "FastApps Team" from .builder.compiler import WidgetBuilder, WidgetBuildResult +from .core.protocol import ProtocolAdapter +from .core.adapters.openai_apps import OpenAIAppsAdapter +from .core.adapters.mcp_apps import MCPAppsAdapter from .core.server import WidgetMCPServer from .core.widget import BaseWidget, ClientContext, UserContext from .types.schema import ConfigDict, Field @@ -45,6 +48,9 @@ async def execute(self, input_data) -> Dict[str, Any]: "ClientContext", "UserContext", "WidgetMCPServer", + "ProtocolAdapter", + "OpenAIAppsAdapter", + "MCPAppsAdapter", "WidgetBuilder", "WidgetBuildResult", "Field", diff --git a/fastapps/core/adapters/mcp_apps.py b/fastapps/core/adapters/mcp_apps.py new file mode 100644 index 0000000..9e9d12e --- /dev/null +++ b/fastapps/core/adapters/mcp_apps.py @@ -0,0 +1,247 @@ +"""Adapter for MCP Apps Extension (SEP-1865 draft).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from mcp import types + +from fastapps.core.protocol import ProtocolAdapter +from fastapps.core.utils import get_cli_version +from fastapps.core.widget import BaseWidget, ClientContext, UserContext + +if TYPE_CHECKING: + from fastapps.core.server import WidgetMCPServer + + +class MCPAppsAdapter(ProtocolAdapter): + """ + Implements handler wiring for the MCP Apps extension (ui:// resources, text/html+mcp). + + References: + - reference/ext-apps/specification/draft/apps.mdx + """ + + def register_handlers(self, widget_server: "WidgetMCPServer") -> None: + server = widget_server.mcp._mcp_server + + original_initialize = server.request_handlers.get(types.InitializeRequest) + + async def initialize_handler( + req: types.InitializeRequest, + ) -> types.ServerResult: + # MCP Apps uses ui/initialize between host <-> iframe. + # Here we just mirror locale negotiation for consistency. + meta = req.params._meta if hasattr(req.params, "_meta") else {} + requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") + + if requested_locale: + widget_server.client_locale = requested_locale + for widget in widget_server.widgets_by_id.values(): + resolved = widget.negotiate_locale(requested_locale) + widget.resolved_locale = resolved + + if original_initialize: + return await original_initialize(req) + + capabilities = types.ServerCapabilities() + # Advertise MCP Apps extension if supported by the types model + try: + setattr(capabilities, "extensions", {"io.modelcontextprotocol/ui": {}}) + except Exception: + pass + + return types.ServerResult( + types.InitializeResult( + protocolVersion=req.params.protocolVersion, + capabilities=capabilities, + serverInfo=types.Implementation( + name="FastApps", version=get_cli_version() + ), + ) + ) + + server.request_handlers[types.InitializeRequest] = initialize_handler + + @server.list_tools() + async def list_tools_handler() -> List[types.Tool]: + tools_list = [] + for w in widget_server.widgets_by_id.values(): + tool_meta = { + "ui/resourceUri": w.template_uri, + } + + if "securitySchemes" not in tool_meta and widget_server.server_requires_auth: + tool_meta["securitySchemes"] = [ + {"type": "oauth2", "scopes": widget_server.server_auth_scopes} + ] + + tools_list.append( + types.Tool( + name=w.identifier, + title=w.title, + description=w.description or w.title, + inputSchema=w.get_input_schema(), + _meta=tool_meta, + ) + ) + return tools_list + + @server.list_resources() + async def list_resources_handler() -> List[types.Resource]: + resources = [] + for w in widget_server.widgets_by_id.values(): + resources.append(self._build_ui_resource(w)) + return resources + + async def read_resource_handler( + req: types.ReadResourceRequest, + ) -> types.ServerResult: + widget = widget_server.widgets_by_uri.get(str(req.params.uri)) + if not widget: + return types.ServerResult( + types.ReadResourceResult( + contents=[], + _meta={"error": f"Unknown resource: {req.params.uri}"}, + ) + ) + + contents = [ + types.TextResourceContents( + uri=widget.template_uri, + mimeType="text/html+mcp", + text=widget.build_result.html, + _meta=self._build_ui_meta(widget), + ) + ] + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: + widget = widget_server.widgets_by_id.get(req.params.name) + if not widget: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", text=f"Unknown tool: {req.params.name}" + ) + ], + isError=True, + ) + ) + + try: + access_token = None + if hasattr(req, "context") and hasattr(req.context, "access_token"): + access_token = req.context.access_token + elif hasattr(req.params, "_meta"): + meta_token = req.params._meta.get("access_token") + if meta_token: + access_token = meta_token + + widget_requires_auth = getattr(widget, "_auth_required", None) + + if widget_requires_auth is None and widget_server.server_requires_auth: + widget_requires_auth = True + + if widget_requires_auth is True and not access_token: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="Authentication required for this tool", + ) + ], + isError=True, + ) + ) + + if ( + access_token + and hasattr(widget, "_auth_scopes") + and widget._auth_scopes + ): + user_scopes = getattr(access_token, "scopes", []) + missing_scopes = set(widget._auth_scopes) - set(user_scopes) + + if missing_scopes: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Missing required scopes: {', '.join(missing_scopes)}", + ) + ], + isError=True, + ) + ) + + arguments = req.params.arguments or {} + input_data = widget.input_schema.model_validate(arguments) + + meta = req.params._meta if hasattr(req.params, "_meta") else {} + requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") + if requested_locale: + widget.resolved_locale = widget.negotiate_locale(requested_locale) + + context = ClientContext(meta) + user = UserContext(access_token) + + result_data = await widget.execute(input_data, context, user) + except Exception as exc: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent(type="text", text=f"Error: {str(exc)}") + ], + isError=True, + ) + ) + + # MCP Apps relies on host rendering the referenced UI resource. + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=widget.invoked)], + structuredContent=result_data, + ) + ) + + server.request_handlers[types.ReadResourceRequest] = read_resource_handler + server.request_handlers[types.CallToolRequest] = call_tool_handler + + def _build_ui_resource(self, widget: BaseWidget) -> types.Resource: + """Create a Resource describing the UI for MCP Apps.""" + return types.Resource( + name=widget.title, + title=widget.title, + uri=widget.template_uri, + description=f"{widget.title} widget markup", + mimeType="text/html+mcp", + _meta=self._build_ui_meta(widget), + ) + + def _build_ui_meta(self, widget: BaseWidget) -> Dict[str, Any]: + """Map widget CSP/domain preferences to MCP Apps meta.""" + meta: Dict[str, Any] = {"ui": {}} + csp: Dict[str, Any] = {} + + widget_csp = widget.widget_csp or {} + resource_domains = widget_csp.get("resource_domains") or [] + connect_domains = widget_csp.get("connect_domains") or [] + + if connect_domains: + csp["connect_domains"] = connect_domains + if resource_domains: + csp["resource_domains"] = resource_domains + + if csp: + meta["ui"]["csp"] = csp + + if widget.widget_domain: + meta["ui"]["domain"] = widget.widget_domain + if widget.widget_prefers_border: + meta["ui"]["prefersBorder"] = True + + return meta From 4428da0f047dd165f0d280969c011ad00c5809c3 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:18:02 +0900 Subject: [PATCH 03/16] Allow selecting OpenAI or MCP Apps protocol in CLI --- fastapps/cli/commands/dev.py | 5 +++-- fastapps/cli/commands/init.py | 21 ++++++++++++++++++++- fastapps/cli/main.py | 10 ++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/fastapps/cli/commands/dev.py b/fastapps/cli/commands/dev.py index 19d8039..8a47a65 100644 --- a/fastapps/cli/commands/dev.py +++ b/fastapps/cli/commands/dev.py @@ -157,13 +157,14 @@ class ThreadedAssetServer(socketserver.ThreadingMixIn, socketserver.TCPServer): httpd.serve_forever() -def start_dev_server(port=8001, host="0.0.0.0", mode="hosted"): +def start_dev_server(port=8001, host="0.0.0.0", mode="hosted", protocol="openai-apps"): """Start development server with Cloudflare Tunnel. Args: port: Port for MCP server (default: 8001) host: Host to bind server (default: "0.0.0.0") mode: Build mode - "hosted" (default) or "inline" + protocol: UI protocol adapter ("openai-apps" | "mcp-apps") """ # Check if we're in a FastApps project @@ -221,7 +222,7 @@ def start_dev_server(port=8001, host="0.0.0.0", mode="hosted"): sys.path.insert(0, str(Path.cwd())) # Reset sys.argv to avoid argparse conflicts in server/main.py # Pass mode to server for builder - sys.argv = ["server/main.py", "--build", f"--mode={mode}"] + sys.argv = ["server/main.py", "--build", f"--mode={mode}", f"--protocol={protocol}"] from server.main import app # Create server config diff --git a/fastapps/cli/commands/init.py b/fastapps/cli/commands/init.py index ccadab6..832ebb8 100644 --- a/fastapps/cli/commands/init.py +++ b/fastapps/cli/commands/init.py @@ -25,7 +25,14 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # Import FastApps framework -from fastapps import WidgetBuilder, WidgetMCPServer, BaseWidget, WidgetBuildResult +from fastapps import ( + WidgetBuilder, + WidgetMCPServer, + BaseWidget, + WidgetBuildResult, + OpenAIAppsAdapter, + MCPAppsAdapter, +) import uvicorn PROJECT_ROOT = Path(__file__).parent.parent @@ -77,6 +84,12 @@ def auto_load_tools(build_results): default="hosted", help="Widget build mode: hosted (default) or inline" ) +parser.add_argument( + "--protocol", + choices=["openai-apps", "mcp-apps"], + default="openai-apps", + help="UI protocol adapter: OpenAI Apps SDK (default) or MCP Apps extension" +) args = parser.parse_args() # Load build results @@ -109,10 +122,16 @@ def load_csp_config(): csp_config = load_csp_config() +def select_adapter(protocol: str): + if protocol == "mcp-apps": + return MCPAppsAdapter() + return OpenAIAppsAdapter() + # Create MCP server with CSP configuration server = WidgetMCPServer( name="my-widgets", widgets=tools, + adapter=select_adapter(args.protocol), global_resource_domains=csp_config.get("resource_domains", []), global_connect_domains=csp_config.get("connect_domains", []), ) diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py index 715f47d..c659d5e 100644 --- a/fastapps/cli/main.py +++ b/fastapps/cli/main.py @@ -113,7 +113,13 @@ def create(widget_name, auth, public, optional_auth, scopes, template): default='hosted', help="Widget build mode: 'hosted' (default, external JS/CSS on port 4444) or 'inline' (self-contained HTML)" ) -def dev(port, host, mode): +@click.option( + "--protocol", + type=click.Choice(['openai-apps', 'mcp-apps'], case_sensitive=False), + default='openai-apps', + help="UI protocol adapter: OpenAI Apps SDK (default) or MCP Apps extension", +) +def dev(port, host, mode, protocol): """Start development server with Cloudflare Tunnel. This command will: @@ -134,7 +140,7 @@ def dev(port, host, mode): Note: Uses Cloudflare Tunnel (free, unlimited, no sign-up required) """ - start_dev_server(port=port, host=host, mode=mode) + start_dev_server(port=port, host=host, mode=mode, protocol=protocol) @cli.command() From 14834fe97720395c3ae9cc8a32f9df8480458bfa Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:18:13 +0900 Subject: [PATCH 04/16] Add MCP Apps postMessage client and host context hook --- js/src/hooks/useMcpAppsHostContext.ts | 35 ++++++ js/src/index.ts | 3 + js/src/mcp/appsClient.ts | 169 ++++++++++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 js/src/hooks/useMcpAppsHostContext.ts create mode 100644 js/src/mcp/appsClient.ts diff --git a/js/src/hooks/useMcpAppsHostContext.ts b/js/src/hooks/useMcpAppsHostContext.ts new file mode 100644 index 0000000..1c49436 --- /dev/null +++ b/js/src/hooks/useMcpAppsHostContext.ts @@ -0,0 +1,35 @@ +import { useEffect, useMemo, useState } from "react"; +import { HostContext, McpAppsClient } from "../mcp/appsClient"; + +/** + * Hook to initialize MCP Apps client and expose hostContext. + * + * Usage: + * const { client, hostContext, initialized, error } = useMcpAppsHostContext(); + */ +export function useMcpAppsHostContext(targetWindow?: Window) { + const client = useMemo(() => new McpAppsClient(targetWindow), [targetWindow]); + const [hostContext, setHostContext] = useState(); + const [initialized, setInitialized] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + client.connect(); + + client + .initialize() + .then((res) => { + setHostContext(res.hostContext); + setInitialized(true); + }) + .catch((e) => { + setError(e); + }); + + return () => { + client.disconnect(); + }; + }, [client]); + + return { client, hostContext, initialized, error }; +} diff --git a/js/src/index.ts b/js/src/index.ts index fc5b5ef..1394ce3 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -9,6 +9,8 @@ export { useWidgetProps } from './hooks/useWidgetProps'; export { useWidgetState } from './hooks/useWidgetState'; export { useDisplayMode } from './hooks/useDisplayMode'; export { useMaxHeight } from './hooks/useMaxHeight'; +export { useMcpAppsHostContext } from './hooks/useMcpAppsHostContext'; +export { McpAppsClient } from './mcp/appsClient'; export type { OpenAiGlobals, @@ -22,3 +24,4 @@ export type { CallToolResponse, } from './hooks/types'; +export type { HostContext, UiInitializeResult } from './mcp/appsClient'; diff --git a/js/src/mcp/appsClient.ts b/js/src/mcp/appsClient.ts new file mode 100644 index 0000000..1f1801c --- /dev/null +++ b/js/src/mcp/appsClient.ts @@ -0,0 +1,169 @@ +/** + * Minimal MCP Apps (SEP-1865 draft) postMessage client for UI iframes. + * + * The UI (guest) sends JSON-RPC 2.0 messages to its parent host. + * This client handles ui/initialize handshake and generic request/notification flows. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export type JsonRpcId = number; + +export type JsonRpcRequest = { + jsonrpc: "2.0"; + id: JsonRpcId; + method: string; + params?: any; +}; + +export type JsonRpcResponse = + | { + jsonrpc: "2.0"; + id: JsonRpcId; + result: any; + } + | { + jsonrpc: "2.0"; + id: JsonRpcId; + error: { code: number; message: string; data?: any }; + }; + +export type JsonRpcNotification = { + jsonrpc: "2.0"; + method: string; + params?: any; +}; + +export type HostContext = { + toolInfo?: { + id?: string | number; + tool?: any; + }; + theme?: "light" | "dark" | "system"; + displayMode?: "inline" | "fullscreen" | "pip" | "carousel"; + availableDisplayModes?: string[]; + viewport?: { + width: number; + height: number; + maxHeight?: number; + maxWidth?: number; + }; + locale?: string; + timeZone?: string; + userAgent?: string; + platform?: "web" | "desktop" | "mobile"; + deviceCapabilities?: { touch?: boolean; hover?: boolean }; + safeAreaInsets?: { top: number; right: number; bottom: number; left: number }; +}; + +export type UiInitializeResult = { + protocolVersion: string; + hostCapabilities?: any; + hostInfo?: { name: string; version: string }; + hostContext?: HostContext; +}; + +type Pending = { + resolve: (value: any) => void; + reject: (error: Error) => void; +}; + +export class McpAppsClient { + private target: Window; + private nextId: number; + private pending: Map; + private notificationHandlers: Map void)[]>; + private initialized = false; + + constructor(targetWindow?: Window) { + this.target = targetWindow ?? window.parent; + this.nextId = 1; + this.pending = new Map(); + this.notificationHandlers = new Map(); + this.handleMessage = this.handleMessage.bind(this); + } + + connect() { + window.addEventListener("message", this.handleMessage); + } + + disconnect() { + window.removeEventListener("message", this.handleMessage); + this.pending.clear(); + this.notificationHandlers.clear(); + } + + async initialize(): Promise { + const result = await this.sendRequest("ui/initialize", { + capabilities: {}, + clientInfo: { name: "fastapps-ui", version: "1.0.0" }, + protocolVersion: "2025-06-18", + }); + this.initialized = true; + return result as UiInitializeResult; + } + + async sendRequest(method: string, params?: any): Promise { + const id = this.nextId++; + const message: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { + this.target.postMessage(message, "*"); + } catch (e: any) { + this.pending.delete(id); + reject(e); + } + }); + + return promise; + } + + sendNotification(method: string, params?: any) { + const message: JsonRpcNotification = { + jsonrpc: "2.0", + method, + params, + }; + this.target.postMessage(message, "*"); + } + + onNotification(method: string, handler: (params: any) => void) { + const handlers = this.notificationHandlers.get(method) ?? []; + handlers.push(handler); + this.notificationHandlers.set(method, handlers); + } + + private handleMessage(event: MessageEvent) { + const data = event.data as JsonRpcResponse | JsonRpcNotification; + if (!data || data.jsonrpc !== "2.0") { + return; + } + + // Response + if ("id" in data && (data as any).id !== undefined) { + const pending = this.pending.get((data as any).id); + if (!pending) return; + this.pending.delete((data as any).id); + + if ("result" in data) { + pending.resolve((data as any).result); + } else if ("error" in data) { + pending.reject(new Error((data as any).error?.message ?? "Unknown error")); + } + return; + } + + // Notification + if ("method" in data) { + const handlers = this.notificationHandlers.get((data as any).method) ?? []; + handlers.forEach((fn) => fn((data as any).params)); + } + } +} From 81a7b76835196cff3caa694b349ef275be54e9d8 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:23:18 +0900 Subject: [PATCH 05/16] Handle MCP Apps tool notifications and expose hooks --- js/src/hooks/useMcpAppsToolInput.ts | 35 ++++++++++++++++++++++++++ js/src/hooks/useMcpAppsToolResult.ts | 37 ++++++++++++++++++++++++++++ js/src/index.ts | 2 ++ js/src/mcp/appsClient.ts | 32 +++++++++++++++++++++++- 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 js/src/hooks/useMcpAppsToolInput.ts create mode 100644 js/src/hooks/useMcpAppsToolResult.ts diff --git a/js/src/hooks/useMcpAppsToolInput.ts b/js/src/hooks/useMcpAppsToolInput.ts new file mode 100644 index 0000000..c9ba18f --- /dev/null +++ b/js/src/hooks/useMcpAppsToolInput.ts @@ -0,0 +1,35 @@ +import { useEffect, useMemo, useState } from "react"; +import { McpAppsClient } from "../mcp/appsClient"; + +/** + * Subscribe to MCP Apps tool-input notifications. + * Returns the latest params of `ui/notifications/tool-input`. + */ +export function useMcpAppsToolInput(targetWindow?: Window) { + const client = useMemo(() => new McpAppsClient(targetWindow), [targetWindow]); + const [toolInput, setToolInput] = useState(() => client.getLatestToolInput()); + + useEffect(() => { + client.connect(); + client.initialize().catch(() => { + /* ignore init errors for now */ + }); + + const handleInput = (params: any) => { + setToolInput(params); + }; + + client.onToolInput(handleInput); + + const latest = client.getLatestToolInput(); + if (latest) { + setToolInput(latest); + } + + return () => { + client.disconnect(); + }; + }, [client]); + + return toolInput; +} diff --git a/js/src/hooks/useMcpAppsToolResult.ts b/js/src/hooks/useMcpAppsToolResult.ts new file mode 100644 index 0000000..eeba2de --- /dev/null +++ b/js/src/hooks/useMcpAppsToolResult.ts @@ -0,0 +1,37 @@ +import { useEffect, useMemo, useState } from "react"; +import { McpAppsClient } from "../mcp/appsClient"; + +/** + * Subscribe to MCP Apps tool-result notifications. + * Returns the latest params of `ui/notifications/tool-result`. + */ +export function useMcpAppsToolResult(targetWindow?: Window) { + const client = useMemo(() => new McpAppsClient(targetWindow), [targetWindow]); + const [result, setResult] = useState(() => client.getLatestToolResult()); + + useEffect(() => { + client.connect(); + // Initialize handshake so host starts sending notifications + client.initialize().catch(() => { + /* ignore init errors for now */ + }); + + const handleResult = (params: any) => { + setResult(params); + }; + + client.onToolResult(handleResult); + + // Prime state if host already sent one before handler + const latest = client.getLatestToolResult(); + if (latest) { + setResult(latest); + } + + return () => { + client.disconnect(); + }; + }, [client]); + + return result; +} diff --git a/js/src/index.ts b/js/src/index.ts index 1394ce3..3004607 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -10,6 +10,8 @@ export { useWidgetState } from './hooks/useWidgetState'; export { useDisplayMode } from './hooks/useDisplayMode'; export { useMaxHeight } from './hooks/useMaxHeight'; export { useMcpAppsHostContext } from './hooks/useMcpAppsHostContext'; +export { useMcpAppsToolResult } from './hooks/useMcpAppsToolResult'; +export { useMcpAppsToolInput } from './hooks/useMcpAppsToolInput'; export { McpAppsClient } from './mcp/appsClient'; export type { diff --git a/js/src/mcp/appsClient.ts b/js/src/mcp/appsClient.ts index 1f1801c..e33e2cc 100644 --- a/js/src/mcp/appsClient.ts +++ b/js/src/mcp/appsClient.ts @@ -74,6 +74,10 @@ export class McpAppsClient { private pending: Map; private notificationHandlers: Map void)[]>; private initialized = false; + private latestToolResult: any = null; + private latestToolInput: any = null; + private toolResultListeners: ((result: any) => void)[] = []; + private toolInputListeners: ((params: any) => void)[] = []; constructor(targetWindow?: Window) { this.target = targetWindow ?? window.parent; @@ -140,6 +144,22 @@ export class McpAppsClient { this.notificationHandlers.set(method, handlers); } + onToolResult(handler: (result: any) => void) { + this.toolResultListeners.push(handler); + } + + onToolInput(handler: (params: any) => void) { + this.toolInputListeners.push(handler); + } + + getLatestToolResult(): T | null { + return this.latestToolResult as T | null; + } + + getLatestToolInput(): T | null { + return this.latestToolInput as T | null; + } + private handleMessage(event: MessageEvent) { const data = event.data as JsonRpcResponse | JsonRpcNotification; if (!data || data.jsonrpc !== "2.0") { @@ -162,8 +182,18 @@ export class McpAppsClient { // Notification if ("method" in data) { - const handlers = this.notificationHandlers.get((data as any).method) ?? []; + const method = (data as any).method as string; + const handlers = this.notificationHandlers.get(method) ?? []; handlers.forEach((fn) => fn((data as any).params)); + + // Special-case tool notifications per SEP-1865 + if (method === "ui/notifications/tool-result") { + this.latestToolResult = (data as any).params ?? null; + this.toolResultListeners.forEach((fn) => fn(this.latestToolResult)); + } else if (method === "ui/notifications/tool-input") { + this.latestToolInput = (data as any).params ?? null; + this.toolInputListeners.forEach((fn) => fn(this.latestToolInput)); + } } } } From 13b29c5e35bb00b64a134ffaefeb3b6b3aff8198 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:35:12 +0900 Subject: [PATCH 06/16] Add protocol-agnostic widget data/context/action hooks --- js/src/hooks/useHostActions.ts | 77 ++++++++++++++++++++++++++++ js/src/hooks/useHostContextCompat.ts | 48 +++++++++++++++++ js/src/hooks/useWidgetData.ts | 40 +++++++++++++++ js/src/index.ts | 3 ++ 4 files changed, 168 insertions(+) create mode 100644 js/src/hooks/useHostActions.ts create mode 100644 js/src/hooks/useHostContextCompat.ts create mode 100644 js/src/hooks/useWidgetData.ts diff --git a/js/src/hooks/useHostActions.ts b/js/src/hooks/useHostActions.ts new file mode 100644 index 0000000..e2d2783 --- /dev/null +++ b/js/src/hooks/useHostActions.ts @@ -0,0 +1,77 @@ +import { useCallback } from "react"; +import { McpAppsClient } from "../mcp/appsClient"; + +/** + * Host actions compatible with both OpenAI Apps and MCP Apps. + * + * - openLink: OpenAI openExternal or MCP ui/open-link + * - sendMessage: OpenAI sendFollowUpMessage or MCP ui/message + * - callTool: OpenAI callTool or MCP tools/call + * - requestDisplayMode: OpenAI requestDisplayMode (no MCP equivalent yet) + */ +export function useHostActions(targetWindow?: Window) { + const clientRef = useCallback(() => new McpAppsClient(targetWindow), [targetWindow]); + + const ensureMcpClient = useCallback(() => { + const client = clientRef(); + client.connect(); + client.initialize().catch(() => { + /* ignore init errors */ + }); + return client; + }, [clientRef]); + + const openLink = useCallback( + async (href: string) => { + if (window?.openai?.openExternal) { + return window.openai.openExternal({ href }); + } + const client = ensureMcpClient(); + return client.sendRequest("ui/open-link", { url: href }); + }, + [ensureMcpClient] + ); + + const sendMessage = useCallback( + async (text: string) => { + if (window?.openai?.sendFollowUpMessage) { + return window.openai.sendFollowUpMessage({ prompt: text }); + } + const client = ensureMcpClient(); + return client.sendRequest("ui/message", { + role: "user", + content: { type: "text", text }, + }); + }, + [ensureMcpClient] + ); + + const callTool = useCallback( + async (name: string, args: Record) => { + if (window?.openai?.callTool) { + return window.openai.callTool(name, args); + } + const client = ensureMcpClient(); + return client.sendRequest("tools/call", { name, arguments: args }); + }, + [ensureMcpClient] + ); + + const requestDisplayMode = useCallback( + async (mode: "inline" | "fullscreen" | "pip") => { + if (window?.openai?.requestDisplayMode) { + return window.openai.requestDisplayMode({ mode }); + } + // No MCP Apps equivalent defined yet; return a resolved promise. + return Promise.resolve({ mode }); + }, + [] + ); + + return { + openLink, + sendMessage, + callTool, + requestDisplayMode, + }; +} diff --git a/js/src/hooks/useHostContextCompat.ts b/js/src/hooks/useHostContextCompat.ts new file mode 100644 index 0000000..a71b75a --- /dev/null +++ b/js/src/hooks/useHostContextCompat.ts @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import { useOpenAiGlobal } from "./useOpenAiGlobal"; +import { useMcpAppsHostContext } from "./useMcpAppsHostContext"; + +/** + * Merge OpenAI globals and MCP Apps hostContext into a single object. + * OpenAI values take precedence if present. + */ +export function useHostContextCompat() { + const openaiTheme = useOpenAiGlobal("theme"); + const openaiDisplayMode = useOpenAiGlobal("displayMode"); + const openaiMaxHeight = useOpenAiGlobal("maxHeight"); + const openaiSafeArea = useOpenAiGlobal("safeArea"); + const openaiLocale = useOpenAiGlobal("locale"); + const openaiUserAgent = useOpenAiGlobal("userAgent"); + + const { hostContext } = useMcpAppsHostContext(); + + return useMemo( + () => ({ + theme: openaiTheme ?? hostContext?.theme ?? null, + displayMode: openaiDisplayMode ?? hostContext?.displayMode ?? null, + maxHeight: openaiMaxHeight ?? hostContext?.viewport?.maxHeight ?? null, + safeArea: openaiSafeArea ?? hostContext?.safeAreaInsets ?? null, + locale: openaiLocale ?? hostContext?.locale ?? null, + userAgent: openaiUserAgent ?? hostContext?.userAgent ?? null, + // raw accessors + _openai: { + theme: openaiTheme, + displayMode: openaiDisplayMode, + maxHeight: openaiMaxHeight, + safeArea: openaiSafeArea, + locale: openaiLocale, + userAgent: openaiUserAgent, + }, + _mcp: hostContext, + }), + [ + openaiTheme, + openaiDisplayMode, + openaiMaxHeight, + openaiSafeArea, + openaiLocale, + openaiUserAgent, + hostContext, + ] + ); +} diff --git a/js/src/hooks/useWidgetData.ts b/js/src/hooks/useWidgetData.ts new file mode 100644 index 0000000..20dbf18 --- /dev/null +++ b/js/src/hooks/useWidgetData.ts @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import { useWidgetProps } from "./useWidgetProps"; +import { useMcpAppsToolResult } from "./useMcpAppsToolResult"; + +type Options = { + /** + * Optional fallback if neither OpenAI toolOutput nor MCP Apps tool-result + * is available yet. + */ + defaultValue?: T | (() => T); +}; + +/** + * Protocol-agnostic widget data hook. + * + * - If window.openai.toolOutput exists (OpenAI Apps), return it. + * - Otherwise, subscribe to MCP Apps ui/notifications/tool-result and return structuredContent. + */ +export function useWidgetData(options?: Options): T | null { + // OpenAI Apps path + const openaiProps = useWidgetProps(); + + // MCP Apps path + const mcpResult = useMcpAppsToolResult(); + const mcpData = mcpResult?.structuredContent ?? null; + + // Choose OpenAI first if available, else MCP, else default + return useMemo(() => { + if (openaiProps != null) return openaiProps; + if (mcpData != null) return mcpData as T; + + const { defaultValue } = options ?? {}; + if (defaultValue !== undefined) { + return typeof defaultValue === "function" + ? (defaultValue as () => T)() + : defaultValue; + } + return null; + }, [openaiProps, mcpData, options]); +} diff --git a/js/src/index.ts b/js/src/index.ts index 3004607..d13680a 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -12,6 +12,9 @@ export { useMaxHeight } from './hooks/useMaxHeight'; export { useMcpAppsHostContext } from './hooks/useMcpAppsHostContext'; export { useMcpAppsToolResult } from './hooks/useMcpAppsToolResult'; export { useMcpAppsToolInput } from './hooks/useMcpAppsToolInput'; +export { useWidgetData } from './hooks/useWidgetData'; +export { useHostContextCompat } from './hooks/useHostContextCompat'; +export { useHostActions } from './hooks/useHostActions'; export { McpAppsClient } from './mcp/appsClient'; export type { From b4c06fd561d8b62331842b14987e83d9fbeec7d6 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:36:50 +0900 Subject: [PATCH 07/16] Switch templates to protocol-agnostic data/context hooks --- fastapps/templates/albums/widget/index.jsx | 24 ++++++++------------ fastapps/templates/carousel/widget/index.jsx | 5 ++-- fastapps/templates/default/widget/index.jsx | 5 ++-- fastapps/templates/list/widget/index.jsx | 5 ++-- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/fastapps/templates/albums/widget/index.jsx b/fastapps/templates/albums/widget/index.jsx index e4f6ab2..9a14fa3 100644 --- a/fastapps/templates/albums/widget/index.jsx +++ b/fastapps/templates/albums/widget/index.jsx @@ -2,7 +2,7 @@ import React from "react"; import { AppsSDKUIProvider } from "@openai/apps-sdk-ui/components/AppsSDKUIProvider"; import { Button } from "@openai/apps-sdk-ui/components/Button"; import { EmptyMessage } from "@openai/apps-sdk-ui/components/EmptyMessage"; -import { useWidgetProps, useOpenAiGlobal, useMaxHeight } from "fastapps"; +import { useWidgetData, useHostContextCompat } from "fastapps"; import useEmblaCarousel from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import FullscreenViewer from "./FullscreenViewer"; @@ -96,7 +96,8 @@ function AlbumsCarousel({ albums, onSelect }) { } function {ClassName}Inner() { - const { albums } = useWidgetProps() || {}; + const data = useWidgetData() || {}; + const { albums } = data; const normalizedAlbums = Array.isArray(albums) ? albums .filter((album) => album && album.cover) @@ -106,10 +107,10 @@ function {ClassName}Inner() { })) : []; const limitedAlbums = normalizedAlbums.slice(0, 8); - const displayMode = useOpenAiGlobal("displayMode"); - const isFullscreen = displayMode === "fullscreen"; + const hostContext = useHostContextCompat(); + const isFullscreen = hostContext.displayMode === "fullscreen"; const [selectedAlbum, setSelectedAlbum] = React.useState(null); - const maxHeight = useMaxHeight() ?? undefined; + const maxHeight = hostContext.maxHeight ?? undefined; React.useEffect(() => { if (!selectedAlbum) { @@ -118,25 +119,20 @@ function {ClassName}Inner() { const stillExists = limitedAlbums.some((album) => album.id === selectedAlbum.id); if (!stillExists) { setSelectedAlbum(null); - if (window?.openai?.requestDisplayMode) { - window.openai.requestDisplayMode({ mode: "inline" }); - } + // Graceful no-op if not supported + window?.openai?.requestDisplayMode?.({ mode: "inline" }); } }, [limitedAlbums, selectedAlbum]); const handleSelectAlbum = (album) => { if (!album) return; setSelectedAlbum(album); - if (window?.openai?.requestDisplayMode) { - window.openai.requestDisplayMode({ mode: "fullscreen" }); - } + window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); }; const handleBackToAlbums = () => { setSelectedAlbum(null); - if (window?.openai?.requestDisplayMode) { - window.openai.requestDisplayMode({ mode: "inline" }); - } + window?.openai?.requestDisplayMode?.({ mode: "inline" }); }; return ( diff --git a/fastapps/templates/carousel/widget/index.jsx b/fastapps/templates/carousel/widget/index.jsx index 7a20457..1d69979 100644 --- a/fastapps/templates/carousel/widget/index.jsx +++ b/fastapps/templates/carousel/widget/index.jsx @@ -2,14 +2,15 @@ import React from "react"; import { AppsSDKUIProvider } from "@openai/apps-sdk-ui/components/AppsSDKUIProvider"; import { Button } from "@openai/apps-sdk-ui/components/Button"; import { EmptyMessage } from "@openai/apps-sdk-ui/components/EmptyMessage"; -import { useWidgetProps } from "fastapps"; +import { useWidgetData } from "fastapps"; import useEmblaCarousel from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import Card from "./Card"; import "./index.css"; function {ClassName}Inner() { - const { cards } = useWidgetProps() || {}; + const data = useWidgetData() || {}; + const { cards } = data; const normalizedCards = Array.isArray(cards) ? cards : []; const limitedCards = normalizedCards.slice(0, 8); diff --git a/fastapps/templates/default/widget/index.jsx b/fastapps/templates/default/widget/index.jsx index 560b7c7..ca07d42 100644 --- a/fastapps/templates/default/widget/index.jsx +++ b/fastapps/templates/default/widget/index.jsx @@ -1,10 +1,11 @@ import React from "react"; import { AppsSDKUIProvider } from "@openai/apps-sdk-ui/components/AppsSDKUIProvider"; import { Badge } from "@openai/apps-sdk-ui/components/Badge"; -import { useWidgetProps } from "fastapps"; +import { useWidgetData } from "fastapps"; function {ClassName}Inner() { - const { message } = useWidgetProps() || {}; + const data = useWidgetData() || {}; + const { message } = data; return (
diff --git a/fastapps/templates/list/widget/index.jsx b/fastapps/templates/list/widget/index.jsx index a5330d9..3dabb06 100644 --- a/fastapps/templates/list/widget/index.jsx +++ b/fastapps/templates/list/widget/index.jsx @@ -3,12 +3,13 @@ import { AppsSDKUIProvider } from "@openai/apps-sdk-ui/components/AppsSDKUIProvi import { Button } from "@openai/apps-sdk-ui/components/Button"; import { EmptyMessage } from "@openai/apps-sdk-ui/components/EmptyMessage"; import { Image } from "@openai/apps-sdk-ui/components/Image"; -import { useWidgetProps } from "fastapps"; +import { useWidgetData } from "fastapps"; import { PlusCircle, Star } from "lucide-react"; import "./index.css"; function {ClassName}Inner() { - const { title, description, items } = useWidgetProps() || {}; + const data = useWidgetData() || {}; + const { title, description, items } = data; const normalizedItems = Array.isArray(items) ? items.slice(0, 7) : []; const hasItems = normalizedItems.length > 0; From 34c9bbead527087be1a9a08b8840c73e1b76023c Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:42:39 +0900 Subject: [PATCH 08/16] Add auto protocol adapter selection based on host capabilities --- fastapps/__init__.py | 2 + fastapps/cli/commands/init.py | 5 ++- fastapps/cli/main.py | 2 +- fastapps/core/adapters/auto.py | 80 ++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 fastapps/core/adapters/auto.py diff --git a/fastapps/__init__.py b/fastapps/__init__.py index d034e67..6273d8e 100644 --- a/fastapps/__init__.py +++ b/fastapps/__init__.py @@ -21,6 +21,7 @@ async def execute(self, input_data) -> Dict[str, Any]: from .core.protocol import ProtocolAdapter from .core.adapters.openai_apps import OpenAIAppsAdapter from .core.adapters.mcp_apps import MCPAppsAdapter +from .core.adapters.auto import AutoProtocolAdapter from .core.server import WidgetMCPServer from .core.widget import BaseWidget, ClientContext, UserContext from .types.schema import ConfigDict, Field @@ -51,6 +52,7 @@ async def execute(self, input_data) -> Dict[str, Any]: "ProtocolAdapter", "OpenAIAppsAdapter", "MCPAppsAdapter", + "AutoProtocolAdapter", "WidgetBuilder", "WidgetBuildResult", "Field", diff --git a/fastapps/cli/commands/init.py b/fastapps/cli/commands/init.py index 832ebb8..311e815 100644 --- a/fastapps/cli/commands/init.py +++ b/fastapps/cli/commands/init.py @@ -32,6 +32,7 @@ WidgetBuildResult, OpenAIAppsAdapter, MCPAppsAdapter, + AutoProtocolAdapter, ) import uvicorn @@ -86,7 +87,7 @@ def auto_load_tools(build_results): ) parser.add_argument( "--protocol", - choices=["openai-apps", "mcp-apps"], + choices=["openai-apps", "mcp-apps", "auto"], default="openai-apps", help="UI protocol adapter: OpenAI Apps SDK (default) or MCP Apps extension" ) @@ -125,6 +126,8 @@ def load_csp_config(): def select_adapter(protocol: str): if protocol == "mcp-apps": return MCPAppsAdapter() + if protocol == "auto": + return AutoProtocolAdapter() return OpenAIAppsAdapter() # Create MCP server with CSP configuration diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py index c659d5e..a2df57a 100644 --- a/fastapps/cli/main.py +++ b/fastapps/cli/main.py @@ -115,7 +115,7 @@ def create(widget_name, auth, public, optional_auth, scopes, template): ) @click.option( "--protocol", - type=click.Choice(['openai-apps', 'mcp-apps'], case_sensitive=False), + type=click.Choice(['openai-apps', 'mcp-apps', 'auto'], case_sensitive=False), default='openai-apps', help="UI protocol adapter: OpenAI Apps SDK (default) or MCP Apps extension", ) diff --git a/fastapps/core/adapters/auto.py b/fastapps/core/adapters/auto.py new file mode 100644 index 0000000..f54db8b --- /dev/null +++ b/fastapps/core/adapters/auto.py @@ -0,0 +1,80 @@ +"""Auto-select protocol adapter based on host capabilities.""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +from mcp import types + +from fastapps.core.protocol import ProtocolAdapter +from fastapps.core.adapters.openai_apps import OpenAIAppsAdapter +from fastapps.core.adapters.mcp_apps import MCPAppsAdapter + +if TYPE_CHECKING: + from fastapps.core.server import WidgetMCPServer + + +class AutoProtocolAdapter(ProtocolAdapter): + """ + Chooses OpenAI Apps or MCP Apps at runtime based on host capabilities. + + Logic: + - Pre-register OpenAI handlers (baseline). + - On initialize, inspect client capabilities.extensions["io.modelcontextprotocol/ui"]. + - If host advertises MCP Apps + supports text/html+mcp, switch to MCP handlers. + - Keep chosen adapter for the rest of the session. + """ + + def __init__(self): + self.active: Optional[ProtocolAdapter] = None + self.openai_adapter = OpenAIAppsAdapter() + self.mcp_adapter = MCPAppsAdapter() + + def register_handlers(self, widget_server: "WidgetMCPServer") -> None: + # Start with OpenAI handlers as baseline + self.openai_adapter.register_handlers(widget_server) + self.active = self.openai_adapter + + server = widget_server.mcp._mcp_server + original_initialize = server.request_handlers.get(types.InitializeRequest) + + async def initialize_handler( + req: types.InitializeRequest, + ) -> types.ServerResult: + chosen = self._decide_adapter(req) + if chosen is self.mcp_adapter and self.active is not self.mcp_adapter: + # Switch to MCP handlers (overwrites request_handlers) + self.mcp_adapter.register_handlers(widget_server) + self.active = self.mcp_adapter + + # After potential switch, get the current initialize handler + handler = server.request_handlers.get(types.InitializeRequest) + if handler is initialize_handler and original_initialize: + # Fallback to original if overwrite failed + handler = original_initialize + + if handler: + return await handler(req) + + # Should not happen, but fall back to minimal response + return types.ServerResult( + types.InitializeResult( + protocolVersion=req.params.protocolVersion, + capabilities=types.ServerCapabilities(), + serverInfo=types.Implementation(name="FastApps", version="auto"), + ) + ) + + server.request_handlers[types.InitializeRequest] = initialize_handler + + def _decide_adapter(self, req: types.InitializeRequest) -> ProtocolAdapter: + try: + caps = getattr(req.params, "capabilities", None) + extensions = getattr(caps, "extensions", {}) if caps else {} + ui_ext = extensions.get("io.modelcontextprotocol/ui") + mime_types = ui_ext.get("mimeTypes", []) if isinstance(ui_ext, dict) else [] + if any(mt.lower() == "text/html+mcp" for mt in mime_types): + return self.mcp_adapter + except Exception: + pass + return self.openai_adapter From adba194fc75f68a4b09a7f43819365bc2ec4e7f7 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 22:55:51 +0900 Subject: [PATCH 09/16] Inject protocol hint into HTML and use it in FE hooks --- fastapps/core/adapters/auto.py | 1 + fastapps/core/adapters/mcp_apps.py | 5 ++- fastapps/core/adapters/openai_apps.py | 5 ++- fastapps/core/adapters/utils.py | 17 ++++++++++ js/src/hooks/useHostActions.ts | 12 ++++--- js/src/hooks/useHostContextCompat.ts | 47 +++++++++++++++++++++++---- js/src/hooks/useWidgetData.ts | 12 +++++++ 7 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 fastapps/core/adapters/utils.py diff --git a/fastapps/core/adapters/auto.py b/fastapps/core/adapters/auto.py index f54db8b..75f5b03 100644 --- a/fastapps/core/adapters/auto.py +++ b/fastapps/core/adapters/auto.py @@ -9,6 +9,7 @@ from fastapps.core.protocol import ProtocolAdapter from fastapps.core.adapters.openai_apps import OpenAIAppsAdapter from fastapps.core.adapters.mcp_apps import MCPAppsAdapter +from fastapps.core.adapters.utils import _inject_protocol_hint # type: ignore[attr-defined] if TYPE_CHECKING: from fastapps.core.server import WidgetMCPServer diff --git a/fastapps/core/adapters/mcp_apps.py b/fastapps/core/adapters/mcp_apps.py index 9e9d12e..eb8aece 100644 --- a/fastapps/core/adapters/mcp_apps.py +++ b/fastapps/core/adapters/mcp_apps.py @@ -8,6 +8,7 @@ from fastapps.core.protocol import ProtocolAdapter from fastapps.core.utils import get_cli_version +from fastapps.core.adapters.utils import _inject_protocol_hint from fastapps.core.widget import BaseWidget, ClientContext, UserContext if TYPE_CHECKING: @@ -106,11 +107,13 @@ async def read_resource_handler( ) ) + html = _inject_protocol_hint(widget.build_result.html, "mcp-apps") + contents = [ types.TextResourceContents( uri=widget.template_uri, mimeType="text/html+mcp", - text=widget.build_result.html, + text=html, _meta=self._build_ui_meta(widget), ) ] diff --git a/fastapps/core/adapters/openai_apps.py b/fastapps/core/adapters/openai_apps.py index 53cad23..22d7077 100644 --- a/fastapps/core/adapters/openai_apps.py +++ b/fastapps/core/adapters/openai_apps.py @@ -8,6 +8,7 @@ from fastapps.core.protocol import ProtocolAdapter from fastapps.core.utils import get_cli_version +from fastapps.core.adapters.utils import _inject_protocol_hint from fastapps.core.widget import BaseWidget, ClientContext, UserContext if TYPE_CHECKING: @@ -114,11 +115,13 @@ async def read_resource_handler( ) ) + html = _inject_protocol_hint(widget.build_result.html, "openai-apps") + contents = [ types.TextResourceContents( uri=widget.template_uri, mimeType="text/html+skybridge", - text=widget.build_result.html, + text=html, _meta=widget.get_resource_meta(), ) ] diff --git a/fastapps/core/adapters/utils.py b/fastapps/core/adapters/utils.py new file mode 100644 index 0000000..887a0f8 --- /dev/null +++ b/fastapps/core/adapters/utils.py @@ -0,0 +1,17 @@ +"""Shared helper for protocol-specific HTML injections.""" + +def _inject_protocol_hint(html: str, protocol: str) -> str: + """ + Inject a small script that exposes the chosen protocol to the UI runtime. + + Args: + html: Original HTML string + protocol: One of "openai-apps" or "mcp-apps" + + Returns: + HTML string with protocol hint injected before or at top. + """ + hint = f'' + if "" in html: + return html.replace("", f"{hint}", 1) + return hint + html diff --git a/js/src/hooks/useHostActions.ts b/js/src/hooks/useHostActions.ts index e2d2783..cf26090 100644 --- a/js/src/hooks/useHostActions.ts +++ b/js/src/hooks/useHostActions.ts @@ -10,6 +10,10 @@ import { McpAppsClient } from "../mcp/appsClient"; * - requestDisplayMode: OpenAI requestDisplayMode (no MCP equivalent yet) */ export function useHostActions(targetWindow?: Window) { + const protocolHint = + typeof window !== "undefined" + ? (window as any).__FASTAPPS_PROTOCOL + : undefined; const clientRef = useCallback(() => new McpAppsClient(targetWindow), [targetWindow]); const ensureMcpClient = useCallback(() => { @@ -23,7 +27,7 @@ export function useHostActions(targetWindow?: Window) { const openLink = useCallback( async (href: string) => { - if (window?.openai?.openExternal) { + if (protocolHint !== "mcp-apps" && window?.openai?.openExternal) { return window.openai.openExternal({ href }); } const client = ensureMcpClient(); @@ -34,7 +38,7 @@ export function useHostActions(targetWindow?: Window) { const sendMessage = useCallback( async (text: string) => { - if (window?.openai?.sendFollowUpMessage) { + if (protocolHint !== "mcp-apps" && window?.openai?.sendFollowUpMessage) { return window.openai.sendFollowUpMessage({ prompt: text }); } const client = ensureMcpClient(); @@ -48,7 +52,7 @@ export function useHostActions(targetWindow?: Window) { const callTool = useCallback( async (name: string, args: Record) => { - if (window?.openai?.callTool) { + if (protocolHint !== "mcp-apps" && window?.openai?.callTool) { return window.openai.callTool(name, args); } const client = ensureMcpClient(); @@ -59,7 +63,7 @@ export function useHostActions(targetWindow?: Window) { const requestDisplayMode = useCallback( async (mode: "inline" | "fullscreen" | "pip") => { - if (window?.openai?.requestDisplayMode) { + if (protocolHint !== "mcp-apps" && window?.openai?.requestDisplayMode) { return window.openai.requestDisplayMode({ mode }); } // No MCP Apps equivalent defined yet; return a resolved promise. diff --git a/js/src/hooks/useHostContextCompat.ts b/js/src/hooks/useHostContextCompat.ts index a71b75a..1538bf1 100644 --- a/js/src/hooks/useHostContextCompat.ts +++ b/js/src/hooks/useHostContextCompat.ts @@ -15,15 +15,49 @@ export function useHostContextCompat() { const openaiUserAgent = useOpenAiGlobal("userAgent"); const { hostContext } = useMcpAppsHostContext(); + const protocolHint = + typeof window !== "undefined" + ? (window as any).__FASTAPPS_PROTOCOL + : undefined; return useMemo( () => ({ - theme: openaiTheme ?? hostContext?.theme ?? null, - displayMode: openaiDisplayMode ?? hostContext?.displayMode ?? null, - maxHeight: openaiMaxHeight ?? hostContext?.viewport?.maxHeight ?? null, - safeArea: openaiSafeArea ?? hostContext?.safeAreaInsets ?? null, - locale: openaiLocale ?? hostContext?.locale ?? null, - userAgent: openaiUserAgent ?? hostContext?.userAgent ?? null, + theme: + protocolHint === "openai-apps" + ? openaiTheme ?? null + : protocolHint === "mcp-apps" + ? hostContext?.theme ?? null + : openaiTheme ?? hostContext?.theme ?? null, + displayMode: + protocolHint === "openai-apps" + ? openaiDisplayMode ?? null + : protocolHint === "mcp-apps" + ? hostContext?.displayMode ?? null + : openaiDisplayMode ?? hostContext?.displayMode ?? null, + maxHeight: + protocolHint === "openai-apps" + ? openaiMaxHeight ?? null + : protocolHint === "mcp-apps" + ? hostContext?.viewport?.maxHeight ?? null + : openaiMaxHeight ?? hostContext?.viewport?.maxHeight ?? null, + safeArea: + protocolHint === "openai-apps" + ? openaiSafeArea ?? null + : protocolHint === "mcp-apps" + ? hostContext?.safeAreaInsets ?? null + : openaiSafeArea ?? hostContext?.safeAreaInsets ?? null, + locale: + protocolHint === "openai-apps" + ? openaiLocale ?? null + : protocolHint === "mcp-apps" + ? hostContext?.locale ?? null + : openaiLocale ?? hostContext?.locale ?? null, + userAgent: + protocolHint === "openai-apps" + ? openaiUserAgent ?? null + : protocolHint === "mcp-apps" + ? hostContext?.userAgent ?? null + : openaiUserAgent ?? hostContext?.userAgent ?? null, // raw accessors _openai: { theme: openaiTheme, @@ -43,6 +77,7 @@ export function useHostContextCompat() { openaiLocale, openaiUserAgent, hostContext, + protocolHint, ] ); } diff --git a/js/src/hooks/useWidgetData.ts b/js/src/hooks/useWidgetData.ts index 20dbf18..4c34e57 100644 --- a/js/src/hooks/useWidgetData.ts +++ b/js/src/hooks/useWidgetData.ts @@ -17,6 +17,11 @@ type Options = { * - Otherwise, subscribe to MCP Apps ui/notifications/tool-result and return structuredContent. */ export function useWidgetData(options?: Options): T | null { + const protocolHint = + typeof window !== "undefined" + ? (window as any).__FASTAPPS_PROTOCOL + : undefined; + // OpenAI Apps path const openaiProps = useWidgetProps(); @@ -26,6 +31,13 @@ export function useWidgetData(options?: Options): T | null { // Choose OpenAI first if available, else MCP, else default return useMemo(() => { + if (protocolHint === "openai-apps") { + return openaiProps ?? null; + } + if (protocolHint === "mcp-apps") { + return (mcpData as T) ?? null; + } + if (openaiProps != null) return openaiProps; if (mcpData != null) return mcpData as T; From d94a5d4ad735f97b6bf1b708232bc24b6969320e Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Tue, 25 Nov 2025 23:23:38 +0900 Subject: [PATCH 10/16] Log protocol selection in auto adapter --- fastapps/core/adapters/auto.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastapps/core/adapters/auto.py b/fastapps/core/adapters/auto.py index 75f5b03..99fb368 100644 --- a/fastapps/core/adapters/auto.py +++ b/fastapps/core/adapters/auto.py @@ -75,7 +75,9 @@ def _decide_adapter(self, req: types.InitializeRequest) -> ProtocolAdapter: ui_ext = extensions.get("io.modelcontextprotocol/ui") mime_types = ui_ext.get("mimeTypes", []) if isinstance(ui_ext, dict) else [] if any(mt.lower() == "text/html+mcp" for mt in mime_types): + print("[AutoAdapter] Selected MCP Apps based on client capabilities") return self.mcp_adapter except Exception: pass + print("[AutoAdapter] Selected OpenAI Apps (default)") return self.openai_adapter From 07c165f49ffe640b200ae4e532d791a81f65fb7d Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 26 Nov 2025 16:12:15 +0900 Subject: [PATCH 11/16] Fix JS hooks imports and MCP client cleanup --- js/package-lock.json | 4 ++-- js/src/hooks/useDisplayMode.ts | 3 +-- js/src/hooks/useHostContextCompat.ts | 2 +- js/src/hooks/useMaxHeight.ts | 3 +-- js/src/hooks/useWidgetData.ts | 17 ++++------------- js/src/hooks/useWidgetProps.ts | 3 +-- js/src/hooks/useWidgetState.ts | 3 +-- js/src/index.ts | 2 +- js/src/mcp/appsClient.ts | 2 -- 9 files changed, 12 insertions(+), 27 deletions(-) diff --git a/js/package-lock.json b/js/package-lock.json index 1c19b4e..5ebf0f3 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "fastapps", - "version": "1.1.1", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fastapps", - "version": "1.1.1", + "version": "1.1.2", "license": "MIT", "bin": { "fastapps-build": "build-all.mts" diff --git a/js/src/hooks/useDisplayMode.ts b/js/src/hooks/useDisplayMode.ts index 93d2f37..c8c946b 100644 --- a/js/src/hooks/useDisplayMode.ts +++ b/js/src/hooks/useDisplayMode.ts @@ -1,4 +1,4 @@ -import { useOpenAiGlobal } from "./useOpenAiGlobal"; +import { useOpenAiGlobal } from "./useOpenaiGlobal"; import { type DisplayMode } from "./types"; /** @@ -24,4 +24,3 @@ import { type DisplayMode } from "./types"; export const useDisplayMode = (): DisplayMode | null => { return useOpenAiGlobal("displayMode"); }; - diff --git a/js/src/hooks/useHostContextCompat.ts b/js/src/hooks/useHostContextCompat.ts index 1538bf1..c6b8c34 100644 --- a/js/src/hooks/useHostContextCompat.ts +++ b/js/src/hooks/useHostContextCompat.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useOpenAiGlobal } from "./useOpenAiGlobal"; +import { useOpenAiGlobal } from "./useOpenaiGlobal"; import { useMcpAppsHostContext } from "./useMcpAppsHostContext"; /** diff --git a/js/src/hooks/useMaxHeight.ts b/js/src/hooks/useMaxHeight.ts index 944fb14..683f079 100644 --- a/js/src/hooks/useMaxHeight.ts +++ b/js/src/hooks/useMaxHeight.ts @@ -1,4 +1,4 @@ -import { useOpenAiGlobal } from "./useOpenAiGlobal"; +import { useOpenAiGlobal } from "./useOpenaiGlobal"; /** * Hook to access the maximum height constraint from ChatGPT. @@ -23,4 +23,3 @@ import { useOpenAiGlobal } from "./useOpenAiGlobal"; export const useMaxHeight = (): number | null => { return useOpenAiGlobal("maxHeight"); }; - diff --git a/js/src/hooks/useWidgetData.ts b/js/src/hooks/useWidgetData.ts index 4c34e57..e2a2f95 100644 --- a/js/src/hooks/useWidgetData.ts +++ b/js/src/hooks/useWidgetData.ts @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useWidgetProps } from "./useWidgetProps"; import { useMcpAppsToolResult } from "./useMcpAppsToolResult"; +import type { UnknownObject } from "./types"; type Options = { /** @@ -16,12 +17,9 @@ type Options = { * - If window.openai.toolOutput exists (OpenAI Apps), return it. * - Otherwise, subscribe to MCP Apps ui/notifications/tool-result and return structuredContent. */ -export function useWidgetData(options?: Options): T | null { - const protocolHint = - typeof window !== "undefined" - ? (window as any).__FASTAPPS_PROTOCOL - : undefined; - +export function useWidgetData( + options?: Options +): T | null { // OpenAI Apps path const openaiProps = useWidgetProps(); @@ -31,13 +29,6 @@ export function useWidgetData(options?: Options): T | null { // Choose OpenAI first if available, else MCP, else default return useMemo(() => { - if (protocolHint === "openai-apps") { - return openaiProps ?? null; - } - if (protocolHint === "mcp-apps") { - return (mcpData as T) ?? null; - } - if (openaiProps != null) return openaiProps; if (mcpData != null) return mcpData as T; diff --git a/js/src/hooks/useWidgetProps.ts b/js/src/hooks/useWidgetProps.ts index d77259f..aed0091 100644 --- a/js/src/hooks/useWidgetProps.ts +++ b/js/src/hooks/useWidgetProps.ts @@ -1,4 +1,4 @@ -import { useOpenAiGlobal } from "./useOpenAiGlobal"; +import { useOpenAiGlobal } from "./useOpenaiGlobal"; /** * Hook to get widget props from ChatGPT tool output. @@ -28,4 +28,3 @@ export function useWidgetProps>( return props ?? fallback; } - diff --git a/js/src/hooks/useWidgetState.ts b/js/src/hooks/useWidgetState.ts index 52573c0..d6f6d26 100644 --- a/js/src/hooks/useWidgetState.ts +++ b/js/src/hooks/useWidgetState.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type SetStateAction } from "react"; -import { useOpenAiGlobal } from "./useOpenAiGlobal"; +import { useOpenAiGlobal } from "./useOpenaiGlobal"; import type { UnknownObject } from "./types"; /** @@ -67,4 +67,3 @@ export function useWidgetState( return [widgetState, setWidgetState] as const; } - diff --git a/js/src/index.ts b/js/src/index.ts index d13680a..4286441 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -4,7 +4,7 @@ * @packageDocumentation */ -export { useOpenAiGlobal } from './hooks/useOpenAiGlobal'; +export { useOpenAiGlobal } from './hooks/useOpenaiGlobal'; export { useWidgetProps } from './hooks/useWidgetProps'; export { useWidgetState } from './hooks/useWidgetState'; export { useDisplayMode } from './hooks/useDisplayMode'; diff --git a/js/src/mcp/appsClient.ts b/js/src/mcp/appsClient.ts index e33e2cc..958bda2 100644 --- a/js/src/mcp/appsClient.ts +++ b/js/src/mcp/appsClient.ts @@ -73,7 +73,6 @@ export class McpAppsClient { private nextId: number; private pending: Map; private notificationHandlers: Map void)[]>; - private initialized = false; private latestToolResult: any = null; private latestToolInput: any = null; private toolResultListeners: ((result: any) => void)[] = []; @@ -103,7 +102,6 @@ export class McpAppsClient { clientInfo: { name: "fastapps-ui", version: "1.0.0" }, protocolVersion: "2025-06-18", }); - this.initialized = true; return result as UiInitializeResult; } From 6954436d465503d8ee5d4bf91e7fca242116ab72 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 26 Nov 2025 16:32:39 +0900 Subject: [PATCH 12/16] Handle missing http_app on OpenAI adapter --- fastapps/core/adapters/openai_apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapps/core/adapters/openai_apps.py b/fastapps/core/adapters/openai_apps.py index 22d7077..01fd28b 100644 --- a/fastapps/core/adapters/openai_apps.py +++ b/fastapps/core/adapters/openai_apps.py @@ -240,7 +240,7 @@ async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: def _register_assets_proxy(self, widget_server: "WidgetMCPServer") -> None: """Proxy /assets requests to local asset server (same behavior as before).""" - app = widget_server.mcp._mcp_server.http_app + app = getattr(widget_server.mcp._mcp_server, "http_app", None) if app is None: app = widget_server.mcp.http_app() From da1480d7bc94c51fd9f60ca4b2774105b2bb27c6 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 26 Nov 2025 16:40:26 +0900 Subject: [PATCH 13/16] Normalize albums template data handling --- fastapps/templates/albums/widget/index.jsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/fastapps/templates/albums/widget/index.jsx b/fastapps/templates/albums/widget/index.jsx index 9a14fa3..e18d749 100644 --- a/fastapps/templates/albums/widget/index.jsx +++ b/fastapps/templates/albums/widget/index.jsx @@ -97,15 +97,15 @@ function AlbumsCarousel({ albums, onSelect }) { function {ClassName}Inner() { const data = useWidgetData() || {}; - const { albums } = data; - const normalizedAlbums = Array.isArray(albums) - ? albums - .filter((album) => album && album.cover) - .map((album) => ({ - ...album, - photos: Array.isArray(album.photos) ? album.photos : [], - })) - : []; + const albumsFromData = Array.isArray(data.albums) ? data.albums : []; + + const normalizedAlbums = albumsFromData + .filter((album) => album && album.cover) + .map((album) => ({ + ...album, + photos: Array.isArray(album.photos) ? album.photos : [], + })); + const limitedAlbums = normalizedAlbums.slice(0, 8); const hostContext = useHostContextCompat(); const isFullscreen = hostContext.displayMode === "fullscreen"; From 6152c55b1b01d05cd6ee02ef3f07966fb9ca092f Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 26 Nov 2025 17:02:13 +0900 Subject: [PATCH 14/16] Improve MCP Apps client sharing, origin safety, and albums display mode --- fastapps/templates/albums/widget/index.jsx | 11 +++-- js/src/hooks/useHostActions.ts | 30 ++++++------ js/src/hooks/useMcpAppsHostContext.ts | 5 +- js/src/hooks/useMcpAppsToolInput.ts | 11 +++-- js/src/hooks/useMcpAppsToolResult.ts | 11 +++-- js/src/mcp/appsClient.ts | 57 ++++++++++++++++------ 6 files changed, 82 insertions(+), 43 deletions(-) diff --git a/fastapps/templates/albums/widget/index.jsx b/fastapps/templates/albums/widget/index.jsx index e18d749..b6e39d5 100644 --- a/fastapps/templates/albums/widget/index.jsx +++ b/fastapps/templates/albums/widget/index.jsx @@ -2,7 +2,7 @@ import React from "react"; import { AppsSDKUIProvider } from "@openai/apps-sdk-ui/components/AppsSDKUIProvider"; import { Button } from "@openai/apps-sdk-ui/components/Button"; import { EmptyMessage } from "@openai/apps-sdk-ui/components/EmptyMessage"; -import { useWidgetData, useHostContextCompat } from "fastapps"; +import { useWidgetData, useHostContextCompat, useHostActions } from "fastapps"; import useEmblaCarousel from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import FullscreenViewer from "./FullscreenViewer"; @@ -111,6 +111,7 @@ function {ClassName}Inner() { const isFullscreen = hostContext.displayMode === "fullscreen"; const [selectedAlbum, setSelectedAlbum] = React.useState(null); const maxHeight = hostContext.maxHeight ?? undefined; + const hostActions = useHostActions(); React.useEffect(() => { if (!selectedAlbum) { @@ -120,19 +121,19 @@ function {ClassName}Inner() { if (!stillExists) { setSelectedAlbum(null); // Graceful no-op if not supported - window?.openai?.requestDisplayMode?.({ mode: "inline" }); + hostActions.requestDisplayMode("inline"); } - }, [limitedAlbums, selectedAlbum]); + }, [hostActions, limitedAlbums, selectedAlbum]); const handleSelectAlbum = (album) => { if (!album) return; setSelectedAlbum(album); - window?.openai?.requestDisplayMode?.({ mode: "fullscreen" }); + hostActions.requestDisplayMode("fullscreen"); }; const handleBackToAlbums = () => { setSelectedAlbum(null); - window?.openai?.requestDisplayMode?.({ mode: "inline" }); + hostActions.requestDisplayMode("inline"); }; return ( diff --git a/js/src/hooks/useHostActions.ts b/js/src/hooks/useHostActions.ts index cf26090..c295d2c 100644 --- a/js/src/hooks/useHostActions.ts +++ b/js/src/hooks/useHostActions.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { McpAppsClient } from "../mcp/appsClient"; +import { useEffect, useMemo } from "react"; /** * Host actions compatible with both OpenAI Apps and MCP Apps. @@ -14,26 +15,29 @@ export function useHostActions(targetWindow?: Window) { typeof window !== "undefined" ? (window as any).__FASTAPPS_PROTOCOL : undefined; - const clientRef = useCallback(() => new McpAppsClient(targetWindow), [targetWindow]); + const client = useMemo( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); - const ensureMcpClient = useCallback(() => { - const client = clientRef(); + useEffect(() => { client.connect(); - client.initialize().catch(() => { - /* ignore init errors */ + client.initialize().catch((e) => { + console.warn("MCP Apps initialize failed", e); }); - return client; - }, [clientRef]); + return () => { + client.disconnect(); + }; + }, [client]); const openLink = useCallback( async (href: string) => { if (protocolHint !== "mcp-apps" && window?.openai?.openExternal) { return window.openai.openExternal({ href }); } - const client = ensureMcpClient(); return client.sendRequest("ui/open-link", { url: href }); }, - [ensureMcpClient] + [client, protocolHint] ); const sendMessage = useCallback( @@ -41,13 +45,12 @@ export function useHostActions(targetWindow?: Window) { if (protocolHint !== "mcp-apps" && window?.openai?.sendFollowUpMessage) { return window.openai.sendFollowUpMessage({ prompt: text }); } - const client = ensureMcpClient(); return client.sendRequest("ui/message", { role: "user", content: { type: "text", text }, }); }, - [ensureMcpClient] + [client, protocolHint] ); const callTool = useCallback( @@ -55,10 +58,9 @@ export function useHostActions(targetWindow?: Window) { if (protocolHint !== "mcp-apps" && window?.openai?.callTool) { return window.openai.callTool(name, args); } - const client = ensureMcpClient(); return client.sendRequest("tools/call", { name, arguments: args }); }, - [ensureMcpClient] + [client, protocolHint] ); const requestDisplayMode = useCallback( @@ -69,7 +71,7 @@ export function useHostActions(targetWindow?: Window) { // No MCP Apps equivalent defined yet; return a resolved promise. return Promise.resolve({ mode }); }, - [] + [protocolHint] ); return { diff --git a/js/src/hooks/useMcpAppsHostContext.ts b/js/src/hooks/useMcpAppsHostContext.ts index 1c49436..b6a70ae 100644 --- a/js/src/hooks/useMcpAppsHostContext.ts +++ b/js/src/hooks/useMcpAppsHostContext.ts @@ -8,7 +8,10 @@ import { HostContext, McpAppsClient } from "../mcp/appsClient"; * const { client, hostContext, initialized, error } = useMcpAppsHostContext(); */ export function useMcpAppsHostContext(targetWindow?: Window) { - const client = useMemo(() => new McpAppsClient(targetWindow), [targetWindow]); + const client = useMemo( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); const [hostContext, setHostContext] = useState(); const [initialized, setInitialized] = useState(false); const [error, setError] = useState(null); diff --git a/js/src/hooks/useMcpAppsToolInput.ts b/js/src/hooks/useMcpAppsToolInput.ts index c9ba18f..9d2c39a 100644 --- a/js/src/hooks/useMcpAppsToolInput.ts +++ b/js/src/hooks/useMcpAppsToolInput.ts @@ -6,13 +6,16 @@ import { McpAppsClient } from "../mcp/appsClient"; * Returns the latest params of `ui/notifications/tool-input`. */ export function useMcpAppsToolInput(targetWindow?: Window) { - const client = useMemo(() => new McpAppsClient(targetWindow), [targetWindow]); + const client = useMemo( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); const [toolInput, setToolInput] = useState(() => client.getLatestToolInput()); useEffect(() => { client.connect(); - client.initialize().catch(() => { - /* ignore init errors for now */ + client.initialize().catch((e) => { + console.warn("MCP Apps initialize failed", e); }); const handleInput = (params: any) => { @@ -31,5 +34,5 @@ export function useMcpAppsToolInput(targetWindow?: Window) { }; }, [client]); - return toolInput; + return toolInput ?? null; } diff --git a/js/src/hooks/useMcpAppsToolResult.ts b/js/src/hooks/useMcpAppsToolResult.ts index eeba2de..2db0ebe 100644 --- a/js/src/hooks/useMcpAppsToolResult.ts +++ b/js/src/hooks/useMcpAppsToolResult.ts @@ -6,14 +6,17 @@ import { McpAppsClient } from "../mcp/appsClient"; * Returns the latest params of `ui/notifications/tool-result`. */ export function useMcpAppsToolResult(targetWindow?: Window) { - const client = useMemo(() => new McpAppsClient(targetWindow), [targetWindow]); + const client = useMemo( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); const [result, setResult] = useState(() => client.getLatestToolResult()); useEffect(() => { client.connect(); // Initialize handshake so host starts sending notifications - client.initialize().catch(() => { - /* ignore init errors for now */ + client.initialize().catch((e) => { + console.warn("MCP Apps initialize failed", e); }); const handleResult = (params: any) => { @@ -33,5 +36,5 @@ export function useMcpAppsToolResult(targetWindow?: Window) { }; }, [client]); - return result; + return result ?? null; } diff --git a/js/src/mcp/appsClient.ts b/js/src/mcp/appsClient.ts index 958bda2..8c0b8c4 100644 --- a/js/src/mcp/appsClient.ts +++ b/js/src/mcp/appsClient.ts @@ -69,17 +69,38 @@ type Pending = { }; export class McpAppsClient { + private static shared = new WeakMap(); private target: Window; + private targetOrigin: string; private nextId: number; private pending: Map; - private notificationHandlers: Map void)[]>; - private latestToolResult: any = null; - private latestToolInput: any = null; - private toolResultListeners: ((result: any) => void)[] = []; - private toolInputListeners: ((params: any) => void)[] = []; + private notificationHandlers: Map void)[]>; + private latestToolResult: unknown = null; + private latestToolInput: unknown = null; + private toolResultListeners: ((result: unknown) => void)[] = []; + private toolInputListeners: ((params: unknown) => void)[] = []; + private refCount = 0; + + /** + * Get a shared client for a target window (singleton per window). + */ + static getShared(targetWindow?: Window, hostOrigin?: string) { + const key = targetWindow ?? window.parent; + const existing = McpAppsClient.shared.get(key); + if (existing) return existing; + const created = new McpAppsClient(key, hostOrigin); + McpAppsClient.shared.set(key, created); + return created; + } - constructor(targetWindow?: Window) { + constructor(targetWindow?: Window, hostOrigin?: string) { this.target = targetWindow ?? window.parent; + // Prefer provided origin, else referrer origin, else wildcard (as last resort) + const referrerOrigin = + typeof document !== "undefined" && document.referrer + ? new URL(document.referrer).origin + : "*"; + this.targetOrigin = hostOrigin ?? referrerOrigin ?? "*"; this.nextId = 1; this.pending = new Map(); this.notificationHandlers = new Map(); @@ -87,13 +108,19 @@ export class McpAppsClient { } connect() { - window.addEventListener("message", this.handleMessage); + if (this.refCount === 0) { + window.addEventListener("message", this.handleMessage); + } + this.refCount += 1; } disconnect() { - window.removeEventListener("message", this.handleMessage); - this.pending.clear(); - this.notificationHandlers.clear(); + this.refCount = Math.max(0, this.refCount - 1); + if (this.refCount === 0) { + window.removeEventListener("message", this.handleMessage); + this.pending.clear(); + this.notificationHandlers.clear(); + } } async initialize(): Promise { @@ -117,7 +144,7 @@ export class McpAppsClient { const promise = new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); try { - this.target.postMessage(message, "*"); + this.target.postMessage(message, this.targetOrigin); } catch (e: any) { this.pending.delete(id); reject(e); @@ -133,20 +160,20 @@ export class McpAppsClient { method, params, }; - this.target.postMessage(message, "*"); + this.target.postMessage(message, this.targetOrigin); } - onNotification(method: string, handler: (params: any) => void) { + onNotification(method: string, handler: (params: unknown) => void) { const handlers = this.notificationHandlers.get(method) ?? []; handlers.push(handler); this.notificationHandlers.set(method, handlers); } - onToolResult(handler: (result: any) => void) { + onToolResult(handler: (result: unknown) => void) { this.toolResultListeners.push(handler); } - onToolInput(handler: (params: any) => void) { + onToolInput(handler: (params: unknown) => void) { this.toolInputListeners.push(handler); } From 7f4f74a32b311fc6fdea3daa922b3ffeb04047d6 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 26 Nov 2025 17:06:29 +0900 Subject: [PATCH 15/16] Refactor call_tool handling and use logging in auto adapter --- fastapps/core/adapters/auto.py | 9 ++- fastapps/core/adapters/mcp_apps.py | 87 ++------------------------ fastapps/core/adapters/openai_apps.py | 88 ++------------------------- fastapps/core/adapters/utils.py | 84 ++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 168 deletions(-) diff --git a/fastapps/core/adapters/auto.py b/fastapps/core/adapters/auto.py index 99fb368..544abb8 100644 --- a/fastapps/core/adapters/auto.py +++ b/fastapps/core/adapters/auto.py @@ -1,5 +1,7 @@ """Auto-select protocol adapter based on host capabilities.""" +import logging + from __future__ import annotations from typing import Optional, TYPE_CHECKING @@ -11,6 +13,9 @@ from fastapps.core.adapters.mcp_apps import MCPAppsAdapter from fastapps.core.adapters.utils import _inject_protocol_hint # type: ignore[attr-defined] + +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from fastapps.core.server import WidgetMCPServer @@ -75,9 +80,9 @@ def _decide_adapter(self, req: types.InitializeRequest) -> ProtocolAdapter: ui_ext = extensions.get("io.modelcontextprotocol/ui") mime_types = ui_ext.get("mimeTypes", []) if isinstance(ui_ext, dict) else [] if any(mt.lower() == "text/html+mcp" for mt in mime_types): - print("[AutoAdapter] Selected MCP Apps based on client capabilities") + logger.info("AutoAdapter selected MCP Apps based on client capabilities") return self.mcp_adapter except Exception: pass - print("[AutoAdapter] Selected OpenAI Apps (default)") + logger.info("AutoAdapter selected OpenAI Apps (default)") return self.openai_adapter diff --git a/fastapps/core/adapters/mcp_apps.py b/fastapps/core/adapters/mcp_apps.py index eb8aece..73ab3e2 100644 --- a/fastapps/core/adapters/mcp_apps.py +++ b/fastapps/core/adapters/mcp_apps.py @@ -8,7 +8,7 @@ from fastapps.core.protocol import ProtocolAdapter from fastapps.core.utils import get_cli_version -from fastapps.core.adapters.utils import _inject_protocol_hint +from fastapps.core.adapters.utils import _execute_widget_call, _inject_protocol_hint from fastapps.core.widget import BaseWidget, ClientContext, UserContext if TYPE_CHECKING: @@ -121,93 +121,16 @@ async def read_resource_handler( async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: widget = widget_server.widgets_by_id.get(req.params.name) - if not widget: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", text=f"Unknown tool: {req.params.name}" - ) - ], - isError=True, - ) - ) - - try: - access_token = None - if hasattr(req, "context") and hasattr(req.context, "access_token"): - access_token = req.context.access_token - elif hasattr(req.params, "_meta"): - meta_token = req.params._meta.get("access_token") - if meta_token: - access_token = meta_token - - widget_requires_auth = getattr(widget, "_auth_required", None) - - if widget_requires_auth is None and widget_server.server_requires_auth: - widget_requires_auth = True - - if widget_requires_auth is True and not access_token: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text="Authentication required for this tool", - ) - ], - isError=True, - ) - ) + result = await _execute_widget_call(widget_server, widget, req) - if ( - access_token - and hasattr(widget, "_auth_scopes") - and widget._auth_scopes - ): - user_scopes = getattr(access_token, "scopes", []) - missing_scopes = set(widget._auth_scopes) - set(user_scopes) - - if missing_scopes: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Missing required scopes: {', '.join(missing_scopes)}", - ) - ], - isError=True, - ) - ) - - arguments = req.params.arguments or {} - input_data = widget.input_schema.model_validate(arguments) - - meta = req.params._meta if hasattr(req.params, "_meta") else {} - requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") - if requested_locale: - widget.resolved_locale = widget.negotiate_locale(requested_locale) - - context = ClientContext(meta) - user = UserContext(access_token) - - result_data = await widget.execute(input_data, context, user) - except Exception as exc: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent(type="text", text=f"Error: {str(exc)}") - ], - isError=True, - ) - ) + if isinstance(result, types.ServerResult): + return result # MCP Apps relies on host rendering the referenced UI resource. return types.ServerResult( types.CallToolResult( content=[types.TextContent(type="text", text=widget.invoked)], - structuredContent=result_data, + structuredContent=result, ) ) diff --git a/fastapps/core/adapters/openai_apps.py b/fastapps/core/adapters/openai_apps.py index 01fd28b..d40e5f9 100644 --- a/fastapps/core/adapters/openai_apps.py +++ b/fastapps/core/adapters/openai_apps.py @@ -8,7 +8,7 @@ from fastapps.core.protocol import ProtocolAdapter from fastapps.core.utils import get_cli_version -from fastapps.core.adapters.utils import _inject_protocol_hint +from fastapps.core.adapters.utils import _execute_widget_call, _inject_protocol_hint from fastapps.core.widget import BaseWidget, ClientContext, UserContext if TYPE_CHECKING: @@ -129,88 +129,10 @@ async def read_resource_handler( async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: widget = widget_server.widgets_by_id.get(req.params.name) - if not widget: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", text=f"Unknown tool: {req.params.name}" - ) - ], - isError=True, - ) - ) - - try: - access_token = None - if hasattr(req, "context") and hasattr(req.context, "access_token"): - access_token = req.context.access_token - elif hasattr(req.params, "_meta"): - meta_token = req.params._meta.get("access_token") - if meta_token: - access_token = meta_token - - widget_requires_auth = getattr(widget, "_auth_required", None) - - if widget_requires_auth is None and widget_server.server_requires_auth: - widget_requires_auth = True - - if widget_requires_auth is True and not access_token: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text="Authentication required for this tool", - ) - ], - isError=True, - ) - ) - - if ( - access_token - and hasattr(widget, "_auth_scopes") - and widget._auth_scopes - ): - user_scopes = getattr(access_token, "scopes", []) - missing_scopes = set(widget._auth_scopes) - set(user_scopes) - - if missing_scopes: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Missing required scopes: {', '.join(missing_scopes)}", - ) - ], - isError=True, - ) - ) - - arguments = req.params.arguments or {} - input_data = widget.input_schema.model_validate(arguments) - - meta = req.params._meta if hasattr(req.params, "_meta") else {} - - requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") - if requested_locale: - widget.resolved_locale = widget.negotiate_locale(requested_locale) + result = await _execute_widget_call(widget_server, widget, req) - context = ClientContext(meta) - user = UserContext(access_token) - - result_data = await widget.execute(input_data, context, user) - except Exception as exc: - return types.ServerResult( - types.CallToolResult( - content=[ - types.TextContent(type="text", text=f"Error: {str(exc)}") - ], - isError=True, - ) - ) + if isinstance(result, types.ServerResult): + return result widget_resource = widget.get_embedded_resource() meta: Dict[str, Any] = { @@ -228,7 +150,7 @@ async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: return types.ServerResult( types.CallToolResult( content=[types.TextContent(type="text", text=widget.invoked)], - structuredContent=result_data, + structuredContent=result, _meta=meta, ) ) diff --git a/fastapps/core/adapters/utils.py b/fastapps/core/adapters/utils.py index 887a0f8..d905a03 100644 --- a/fastapps/core/adapters/utils.py +++ b/fastapps/core/adapters/utils.py @@ -1,4 +1,13 @@ -"""Shared helper for protocol-specific HTML injections.""" +"""Shared helpers for protocol-specific behavior.""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +from mcp import types + +from fastapps.core.widget import BaseWidget, ClientContext, UserContext + def _inject_protocol_hint(html: str, protocol: str) -> str: """ @@ -15,3 +24,76 @@ def _inject_protocol_hint(html: str, protocol: str) -> str: if "" in html: return html.replace("", f"{hint}", 1) return hint + html + + +def _error_call_tool(message: str) -> types.ServerResult: + """Create a standardized error response for call_tool failures.""" + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=message)], + isError=True, + ) + ) + + +async def _execute_widget_call( + widget_server: "WidgetMCPServer", + widget: Optional[BaseWidget], + req: types.CallToolRequest, +) -> Union[types.ServerResult, Any]: + """ + Shared call_tool execution pipeline: + - Lookup widget + - Auth inheritance + scope checks + - Input validation + - Locale negotiation + - Context construction + - Widget execution + + Returns: + - types.ServerResult on error, or + - widget.execute(...) result on success + """ + if not widget: + return _error_call_tool(f"Unknown tool: {req.params.name}") + + try: + access_token = None + if hasattr(req, "context") and hasattr(req.context, "access_token"): + access_token = req.context.access_token + elif hasattr(req.params, "_meta"): + meta_token = req.params._meta.get("access_token") + if meta_token: + access_token = meta_token + + widget_requires_auth = getattr(widget, "_auth_required", None) + + if widget_requires_auth is None and widget_server.server_requires_auth: + widget_requires_auth = True + + if widget_requires_auth is True and not access_token: + return _error_call_tool("Authentication required for this tool") + + if access_token and hasattr(widget, "_auth_scopes") and widget._auth_scopes: + user_scopes = getattr(access_token, "scopes", []) + missing_scopes = set(widget._auth_scopes) - set(user_scopes) + + if missing_scopes: + return _error_call_tool( + f"Missing required scopes: {', '.join(missing_scopes)}" + ) + + arguments = req.params.arguments or {} + input_data = widget.input_schema.model_validate(arguments) + + meta = req.params._meta if hasattr(req.params, "_meta") else {} + requested_locale = meta.get("openai/locale") or meta.get("webplus/i18n") + if requested_locale: + widget.resolved_locale = widget.negotiate_locale(requested_locale) + + context = ClientContext(meta) + user = UserContext(access_token) + + return await widget.execute(input_data, context, user) + except Exception as exc: + return _error_call_tool(f"Error: {str(exc)}") From 46194a018ac57a0c2f96df53cb67b5c8fa8482a3 Mon Sep 17 00:00:00 2001 From: chaewon-huh Date: Wed, 26 Nov 2025 17:43:32 +0900 Subject: [PATCH 16/16] Fix future import order in auto adapter --- fastapps/core/adapters/auto.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapps/core/adapters/auto.py b/fastapps/core/adapters/auto.py index 544abb8..5ac4e71 100644 --- a/fastapps/core/adapters/auto.py +++ b/fastapps/core/adapters/auto.py @@ -1,9 +1,9 @@ """Auto-select protocol adapter based on host capabilities.""" -import logging - from __future__ import annotations +import logging + from typing import Optional, TYPE_CHECKING from mcp import types