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 (
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; 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/useHostActions.ts b/js/src/hooks/useHostActions.ts new file mode 100644 index 0000000..c295d2c --- /dev/null +++ b/js/src/hooks/useHostActions.ts @@ -0,0 +1,83 @@ +import { useCallback } from "react"; +import { McpAppsClient } from "../mcp/appsClient"; +import { useEffect, useMemo } from "react"; + +/** + * 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 protocolHint = + typeof window !== "undefined" + ? (window as any).__FASTAPPS_PROTOCOL + : undefined; + const client = useMemo( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); + + useEffect(() => { + client.connect(); + client.initialize().catch((e) => { + console.warn("MCP Apps initialize failed", e); + }); + return () => { + client.disconnect(); + }; + }, [client]); + + const openLink = useCallback( + async (href: string) => { + if (protocolHint !== "mcp-apps" && window?.openai?.openExternal) { + return window.openai.openExternal({ href }); + } + return client.sendRequest("ui/open-link", { url: href }); + }, + [client, protocolHint] + ); + + const sendMessage = useCallback( + async (text: string) => { + if (protocolHint !== "mcp-apps" && window?.openai?.sendFollowUpMessage) { + return window.openai.sendFollowUpMessage({ prompt: text }); + } + return client.sendRequest("ui/message", { + role: "user", + content: { type: "text", text }, + }); + }, + [client, protocolHint] + ); + + const callTool = useCallback( + async (name: string, args: Record) => { + if (protocolHint !== "mcp-apps" && window?.openai?.callTool) { + return window.openai.callTool(name, args); + } + return client.sendRequest("tools/call", { name, arguments: args }); + }, + [client, protocolHint] + ); + + const requestDisplayMode = useCallback( + async (mode: "inline" | "fullscreen" | "pip") => { + if (protocolHint !== "mcp-apps" && window?.openai?.requestDisplayMode) { + return window.openai.requestDisplayMode({ mode }); + } + // No MCP Apps equivalent defined yet; return a resolved promise. + return Promise.resolve({ mode }); + }, + [protocolHint] + ); + + 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..c6b8c34 --- /dev/null +++ b/js/src/hooks/useHostContextCompat.ts @@ -0,0 +1,83 @@ +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(); + const protocolHint = + typeof window !== "undefined" + ? (window as any).__FASTAPPS_PROTOCOL + : undefined; + + return useMemo( + () => ({ + 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, + displayMode: openaiDisplayMode, + maxHeight: openaiMaxHeight, + safeArea: openaiSafeArea, + locale: openaiLocale, + userAgent: openaiUserAgent, + }, + _mcp: hostContext, + }), + [ + openaiTheme, + openaiDisplayMode, + openaiMaxHeight, + openaiSafeArea, + openaiLocale, + openaiUserAgent, + hostContext, + protocolHint, + ] + ); +} 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/useMcpAppsHostContext.ts b/js/src/hooks/useMcpAppsHostContext.ts new file mode 100644 index 0000000..b6a70ae --- /dev/null +++ b/js/src/hooks/useMcpAppsHostContext.ts @@ -0,0 +1,38 @@ +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( + () => McpAppsClient.getShared(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/hooks/useMcpAppsToolInput.ts b/js/src/hooks/useMcpAppsToolInput.ts new file mode 100644 index 0000000..9d2c39a --- /dev/null +++ b/js/src/hooks/useMcpAppsToolInput.ts @@ -0,0 +1,38 @@ +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( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); + const [toolInput, setToolInput] = useState(() => client.getLatestToolInput()); + + useEffect(() => { + client.connect(); + client.initialize().catch((e) => { + console.warn("MCP Apps initialize failed", e); + }); + + const handleInput = (params: any) => { + setToolInput(params); + }; + + client.onToolInput(handleInput); + + const latest = client.getLatestToolInput(); + if (latest) { + setToolInput(latest); + } + + return () => { + client.disconnect(); + }; + }, [client]); + + return toolInput ?? null; +} diff --git a/js/src/hooks/useMcpAppsToolResult.ts b/js/src/hooks/useMcpAppsToolResult.ts new file mode 100644 index 0000000..2db0ebe --- /dev/null +++ b/js/src/hooks/useMcpAppsToolResult.ts @@ -0,0 +1,40 @@ +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( + () => McpAppsClient.getShared(targetWindow), + [targetWindow] + ); + const [result, setResult] = useState(() => client.getLatestToolResult()); + + useEffect(() => { + client.connect(); + // Initialize handshake so host starts sending notifications + client.initialize().catch((e) => { + console.warn("MCP Apps initialize failed", e); + }); + + 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 ?? null; +} diff --git a/js/src/hooks/useWidgetData.ts b/js/src/hooks/useWidgetData.ts new file mode 100644 index 0000000..e2a2f95 --- /dev/null +++ b/js/src/hooks/useWidgetData.ts @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import { useWidgetProps } from "./useWidgetProps"; +import { useMcpAppsToolResult } from "./useMcpAppsToolResult"; +import type { UnknownObject } from "./types"; + +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/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 fc5b5ef..4286441 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -4,11 +4,18 @@ * @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'; 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 { OpenAiGlobals, @@ -22,3 +29,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..8c0b8c4 --- /dev/null +++ b/js/src/mcp/appsClient.ts @@ -0,0 +1,224 @@ +/** + * 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 static shared = new WeakMap(); + private target: Window; + private targetOrigin: string; + private nextId: number; + private pending: Map; + 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, 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(); + this.handleMessage = this.handleMessage.bind(this); + } + + connect() { + if (this.refCount === 0) { + window.addEventListener("message", this.handleMessage); + } + this.refCount += 1; + } + + disconnect() { + 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 { + const result = await this.sendRequest("ui/initialize", { + capabilities: {}, + clientInfo: { name: "fastapps-ui", version: "1.0.0" }, + protocolVersion: "2025-06-18", + }); + 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, this.targetOrigin); + } 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, this.targetOrigin); + } + + onNotification(method: string, handler: (params: unknown) => void) { + const handlers = this.notificationHandlers.get(method) ?? []; + handlers.push(handler); + this.notificationHandlers.set(method, handlers); + } + + onToolResult(handler: (result: unknown) => void) { + this.toolResultListeners.push(handler); + } + + onToolInput(handler: (params: unknown) => 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") { + 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 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)); + } + } + } +}