diff --git a/fastapps/__init__.py b/fastapps/__init__.py index 737f3e1..6273d8e 100644 --- a/fastapps/__init__.py +++ b/fastapps/__init__.py @@ -18,6 +18,10 @@ 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.adapters.auto import AutoProtocolAdapter from .core.server import WidgetMCPServer from .core.widget import BaseWidget, ClientContext, UserContext from .types.schema import ConfigDict, Field @@ -45,6 +49,10 @@ async def execute(self, input_data) -> Dict[str, Any]: "ClientContext", "UserContext", "WidgetMCPServer", + "ProtocolAdapter", + "OpenAIAppsAdapter", + "MCPAppsAdapter", + "AutoProtocolAdapter", "WidgetBuilder", "WidgetBuildResult", "Field", 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..311e815 100644 --- a/fastapps/cli/commands/init.py +++ b/fastapps/cli/commands/init.py @@ -25,7 +25,15 @@ 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, + AutoProtocolAdapter, +) import uvicorn PROJECT_ROOT = Path(__file__).parent.parent @@ -77,6 +85,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", "auto"], + default="openai-apps", + help="UI protocol adapter: OpenAI Apps SDK (default) or MCP Apps extension" +) args = parser.parse_args() # Load build results @@ -109,10 +123,18 @@ def load_csp_config(): csp_config = 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 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..a2df57a 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', 'auto'], 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() diff --git a/fastapps/core/adapters/auto.py b/fastapps/core/adapters/auto.py new file mode 100644 index 0000000..5ac4e71 --- /dev/null +++ b/fastapps/core/adapters/auto.py @@ -0,0 +1,88 @@ +"""Auto-select protocol adapter based on host capabilities.""" + +from __future__ import annotations + +import logging + +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 +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 + + +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): + logger.info("AutoAdapter selected MCP Apps based on client capabilities") + return self.mcp_adapter + except Exception: + pass + 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 new file mode 100644 index 0000000..73ab3e2 --- /dev/null +++ b/fastapps/core/adapters/mcp_apps.py @@ -0,0 +1,173 @@ +"""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.adapters.utils import _execute_widget_call, _inject_protocol_hint +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}"}, + ) + ) + + html = _inject_protocol_hint(widget.build_result.html, "mcp-apps") + + contents = [ + types.TextResourceContents( + uri=widget.template_uri, + mimeType="text/html+mcp", + text=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) + result = await _execute_widget_call(widget_server, widget, req) + + 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, + ) + ) + + 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 diff --git a/fastapps/core/adapters/openai_apps.py b/fastapps/core/adapters/openai_apps.py new file mode 100644 index 0000000..d40e5f9 --- /dev/null +++ b/fastapps/core/adapters/openai_apps.py @@ -0,0 +1,231 @@ +"""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.adapters.utils import _execute_widget_call, _inject_protocol_hint +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}"}, + ) + ) + + html = _inject_protocol_hint(widget.build_result.html, "openai-apps") + + contents = [ + types.TextResourceContents( + uri=widget.template_uri, + mimeType="text/html+skybridge", + text=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) + result = await _execute_widget_call(widget_server, widget, req) + + if isinstance(result, types.ServerResult): + return result + + 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, + _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 = getattr(widget_server.mcp._mcp_server, "http_app", None) + 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/adapters/utils.py b/fastapps/core/adapters/utils.py new file mode 100644 index 0000000..d905a03 --- /dev/null +++ b/fastapps/core/adapters/utils.py @@ -0,0 +1,99 @@ +"""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: + """ + 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 + + +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)}") 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]): """ diff --git a/fastapps/templates/albums/widget/index.jsx b/fastapps/templates/albums/widget/index.jsx index e4f6ab2..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 { useWidgetProps, useOpenAiGlobal, useMaxHeight } from "fastapps"; +import { useWidgetData, useHostContextCompat, useHostActions } from "fastapps"; import useEmblaCarousel from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import FullscreenViewer from "./FullscreenViewer"; @@ -96,20 +96,22 @@ function AlbumsCarousel({ albums, onSelect }) { } function {ClassName}Inner() { - const { albums } = useWidgetProps() || {}; - const normalizedAlbums = Array.isArray(albums) - ? albums - .filter((album) => album && album.cover) - .map((album) => ({ - ...album, - photos: Array.isArray(album.photos) ? album.photos : [], - })) - : []; + const data = useWidgetData() || {}; + 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 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; + const hostActions = useHostActions(); React.useEffect(() => { if (!selectedAlbum) { @@ -118,25 +120,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 + hostActions.requestDisplayMode("inline"); } - }, [limitedAlbums, selectedAlbum]); + }, [hostActions, limitedAlbums, selectedAlbum]); const handleSelectAlbum = (album) => { if (!album) return; setSelectedAlbum(album); - if (window?.openai?.requestDisplayMode) { - window.openai.requestDisplayMode({ mode: "fullscreen" }); - } + hostActions.requestDisplayMode("fullscreen"); }; const handleBackToAlbums = () => { setSelectedAlbum(null); - if (window?.openai?.requestDisplayMode) { - window.openai.requestDisplayMode({ mode: "inline" }); - } + hostActions.requestDisplayMode("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 (