Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions fastapps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,6 +49,10 @@ async def execute(self, input_data) -> Dict[str, Any]:
"ClientContext",
"UserContext",
"WidgetMCPServer",
"ProtocolAdapter",
"OpenAIAppsAdapter",
"MCPAppsAdapter",
"AutoProtocolAdapter",
"WidgetBuilder",
"WidgetBuildResult",
"Field",
Expand Down
5 changes: 3 additions & 2 deletions fastapps/cli/commands/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion fastapps/cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", []),
)
Expand Down
10 changes: 8 additions & 2 deletions fastapps/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions fastapps/core/adapters/auto.py
Original file line number Diff line number Diff line change
@@ -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
173 changes: 173 additions & 0 deletions fastapps/core/adapters/mcp_apps.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading