Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
.env

# Deployment artifacts
.fastapps-deploy-*.tar.gz
.fastapps-deploy-*.tar.gz

# examples will be uploaded soon
examples/
13 changes: 11 additions & 2 deletions fastapps/builder/build-all.mts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ for (const file of entries) {
console.groupEnd();
builtNames.push(name);
console.log(`Built ${name}`);

// Ensure CSS file exists (create empty one if not generated)
const cssFile = path.join(outDir, `${name}.css`);
if (!fs.existsSync(cssFile)) {
fs.writeFileSync(cssFile, "", "utf8");
console.log(`Created empty CSS file for ${name}`);
}
}

const outputs = fs
Expand Down Expand Up @@ -218,7 +225,9 @@ if (MODE === "inline") {
console.log(`${htmlPath} (generated inline)`);
}
} else {
const defaultBaseUrl = "http://localhost:4444";
// Use relative /assets path to work with the proxy server
// This allows assets to be served through the same origin, avoiding CORS/PNA/mixed content issues
const defaultBaseUrl = "/assets";
const baseUrlCandidate = process.env.BASE_URL?.trim() ?? "";
const baseUrlRaw = baseUrlCandidate || defaultBaseUrl;
const normalizedBaseUrl = baseUrlRaw.replace(/\/+$/, "");
Expand All @@ -238,6 +247,6 @@ if (MODE === "inline") {
</html>
`;
fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
console.log(`${htmlPath} (generated with external references)`);
console.log(`${htmlPath} (generated)`);
}
}
10 changes: 8 additions & 2 deletions fastapps/builder/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,18 @@ def build_all(self, mode: str = "hosted") -> Dict[str, WidgetBuildResult]:
npx_cmd = "npx.cmd" if platform.system() == "Windows" else "npx"
build_script = "build-all.mts"

# Pass mode and BASE_URL (for hosted) via environment
# Pass mode and asset URLs via environment
env = os.environ.copy()
# Explicit mode for the script
env["MODE"] = mode
if mode == "hosted":
env["BASE_URL"] = env.get("BASE_URL", "http://localhost:4444")
# Use PUBLIC_URL if available (for absolute URLs in iframes)
# Otherwise fall back to relative /assets path
public_url = env.get("PUBLIC_URL", "")
if public_url:
env["BASE_URL"] = f"{public_url}/assets"
else:
env["BASE_URL"] = "/assets"

subprocess.run(
[npx_cmd, "tsx", build_script],
Expand Down
22 changes: 19 additions & 3 deletions fastapps/cli/commands/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,28 @@ def __init__(self, *args, **kwargs):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.send_header('Access-Control-Allow-Headers', '*')
self.send_header('Cache-Control', 'no-cache')
super().end_headers()

def do_OPTIONS(self):
self.send_response(200)
self.end_headers()

def log_message(self, format, *args):
# Suppress request logs to keep output clean
pass

handler = CORSHTTPRequestHandler
with socketserver.TCPServer(("", port), handler) as httpd:

# Use ThreadingTCPServer for concurrent requests
class ThreadedAssetServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
# Allow socket reuse to prevent "Address already in use" errors
allow_reuse_address = True
# Set daemon threads so server shuts down cleanly
daemon_threads = True

with ThreadedAssetServer(("", port), handler) as httpd:
console.print(f"[green]✓ Asset server running on http://localhost:{port}[/green]")
httpd.serve_forever()

Expand Down Expand Up @@ -194,6 +207,9 @@ def start_dev_server(port=8001, host="0.0.0.0", mode="hosted"):

console.print()

# Set PUBLIC_URL environment variable for builder
os.environ["PUBLIC_URL"] = public_url

# Import and start server (shows uvicorn boot logs first)
console.print("[cyan]Starting FastApps server...[/cyan]\n")

Expand Down Expand Up @@ -237,7 +253,7 @@ def run_server():
# Display MCP endpoint info
mcp_panel = Panel(
f"[bold]MCP Server Endpoint:[/bold]\n"
f"[green]{public_url}[/green]\n\n"
f"[green]{public_url}/mcp[/green]\n\n"
f"[dim]Use this URL in your MCP client configuration[/dim]",
title="Model Context Protocol",
border_style="blue",
Expand Down
35 changes: 8 additions & 27 deletions fastapps/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,18 @@ def init(project_name):
@click.option("--public", is_flag=True, help="Add no_auth decorator (public widget)")
@click.option("--optional-auth", is_flag=True, help="Add optional_auth decorator")
@click.option("--scopes", help="OAuth scopes (comma-separated, e.g., 'user,read:data')")
@click.option("--list", "use_list_template", is_flag=True, help="Create a list widget using the list template")
@click.option("--carousel", "use_carousel_template", is_flag=True, help="Create a carousel widget using the carousel template")
@click.option("--albums", "use_albums_template", is_flag=True, help="Create an albums widget using the albums template")
def create(widget_name, auth, public, optional_auth, scopes, use_list_template, use_carousel_template, use_albums_template):
@click.option("--template", type=click.Choice(['list', 'carousel', 'albums'], case_sensitive=False), help="Widget template to use (list, carousel, or albums)")
def create(widget_name, auth, public, optional_auth, scopes, template):
"""Create a new widget with tool and component files.

Examples:
fastapps create mywidget
fastapps create mywidget --auth --scopes user,read:data
fastapps create mywidget --public
fastapps create mywidget --optional-auth --scopes user
fastapps create mywidget --list
fastapps create mywidget --carousel
fastapps create mywidget --albums
fastapps create mywidget --template list
fastapps create mywidget --template carousel
fastapps create mywidget --template albums

Authentication options:
--auth: Require OAuth authentication
Expand All @@ -72,9 +70,9 @@ def create(widget_name, auth, public, optional_auth, scopes, use_list_template,
--scopes: OAuth scopes to require

Template options:
--list: Use the list widget template (includes Tailwind CSS styling)
--carousel: Use the carousel widget template (includes Embla Carousel)
--albums: Use the albums widget template (includes fullscreen photo viewer)
--template list: Use the list widget template (includes Tailwind CSS styling)
--template carousel: Use the carousel widget template (includes Embla Carousel)
--template albums: Use the albums widget template (includes fullscreen photo viewer)
"""
# Parse scopes
scope_list = scopes.split(",") if scopes else None
Expand All @@ -96,23 +94,6 @@ def create(widget_name, auth, public, optional_auth, scopes, use_list_template,
elif optional_auth:
auth_type = "optional"

# Validate template options
template_count = sum([use_list_template, use_carousel_template, use_albums_template])
if template_count > 1:
console.print(
"[red]Error: Only one template option allowed (--list, --carousel, or --albums)[/red]"
)
return

# Determine template
template = None
if use_list_template:
template = "list"
elif use_carousel_template:
template = "carousel"
elif use_albums_template:
template = "albums"

create_widget(widget_name, auth_type=auth_type, scopes=scope_list, template=template)


Expand Down
98 changes: 92 additions & 6 deletions fastapps/core/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ def __init__(
self.widgets_by_uri = {w.template_uri: w for w in widgets}
self.client_locale: Optional[str] = None

# Auto-configure widget CSP based on PUBLIC_URL environment variable
self._configure_widget_csp(widgets)

# Store server auth configuration for per-widget inheritance
self.server_requires_auth = bool(auth_issuer_url and auth_resource_server_url)
self.server_auth_scopes = auth_required_scopes or []
Expand Down Expand Up @@ -122,6 +125,29 @@ def __init__(

self._register_handlers()

def _configure_widget_csp(self, widgets: List[BaseWidget]):
"""Auto-configure widget CSP based on PUBLIC_URL environment variable."""
import os

public_url = os.environ.get("PUBLIC_URL", "").strip()

if not public_url:
return

# Configure CSP for all widgets that don't have custom CSP
for widget in widgets:
# Check if CSP is not configured (None or empty resource_domains)
needs_csp = (
widget.widget_csp is None or
not widget.widget_csp.get("resource_domains")
)

if needs_csp:
widget.widget_csp = {
"resource_domains": [public_url],
"connect_domains": []
}

def _register_handlers(self):
"""Register all MCP handlers for widget support."""
server = self.mcp._mcp_server
Expand Down Expand Up @@ -185,17 +211,19 @@ async def list_tools_handler() -> List[types.Tool]:

@server.list_resources()
async def list_resources_handler() -> List[types.Resource]:
return [
types.Resource(
resources = []
for w in self.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=w.get_resource_meta(),
_meta=meta,
)
for w in self.widgets_by_id.values()
]
resources.append(resource)
return resources

@server.list_resource_templates()
async def list_resource_templates_handler() -> List[types.ResourceTemplate]:
Expand Down Expand Up @@ -356,7 +384,7 @@ async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult:
server.request_handlers[types.CallToolRequest] = call_tool_handler

def get_app(self):
"""Get FastAPI app with CORS enabled."""
"""Get FastAPI app with CORS enabled and /assets proxy."""
app = self.mcp.http_app()

try:
Expand All @@ -372,4 +400,62 @@ def get_app(self):
except Exception:
pass

# Add /assets proxy route to forward requests to local asset server
# This eliminates CORS/PNA/mixed content issues by serving assets from the same origin
try:
import httpx
from starlette.responses import Response
from starlette.routing import Route

async def proxy_assets(request):
"""Proxy asset requests to local asset server (port 4444)."""
# Extract path from 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:
# Forward the request to the asset server
upstream_response = await client.get(upstream_url)

# Filter headers to only include content-related ones
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
}

# Ensure content-type is set
if "content-type" not in response_headers:
response_headers["content-type"] = "application/octet-stream"

# Add CORS headers for cross-origin access
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"}
)

# Add route to Starlette app
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

return app
6 changes: 1 addition & 5 deletions fastapps/core/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ class BaseWidget(ABC):
widget_csp: Optional[Dict[str, List[str]]] = None
widget_prefers_border: bool = False
widget_domain: Optional[str] = None
read_only: bool = True

# Localization support
supported_locales: Optional[List[str]] = (
Expand Down Expand Up @@ -202,10 +201,7 @@ def get_tool_meta(self) -> Dict[str, Any]:
"openai/toolInvocation/invoking": self.invoking,
"openai/toolInvocation/invoked": self.invoked,
"openai/widgetAccessible": self.widget_accessible,
"openai/resultCanProduceWidget": True,
"annotations": {
"readOnlyHint": self.read_only,
},
"openai/resultCanProduceWidget": True
}

# Add security schemes if defined (per MCP spec)
Expand Down
Loading