Skip to main content

mcp_preview/
server.rs

1//! Preview server implementation
2
3use anyhow::Result;
4use axum::{
5    routing::{get, post},
6    Router,
7};
8use std::net::SocketAddr;
9use std::path::PathBuf;
10use std::sync::Arc;
11use tokio::net::TcpListener;
12use tower_http::cors::{Any, CorsLayer};
13use tracing::info;
14
15use crate::handlers;
16use crate::proxy::McpProxy;
17use crate::wasm_builder::{find_workspace_root, WasmBuilder};
18
19/// OAuth configuration for browser-based PKCE flow.
20///
21/// When present, the preview server exposes `/api/auth/*` endpoints and
22/// includes this config in the `/api/config` response so the browser JS
23/// can initiate the OAuth popup flow.
24#[derive(Debug, Clone, Default)]
25pub struct OAuthPreviewConfig {
26    /// OAuth client ID for the preview app.
27    pub client_id: String,
28    /// Authorization endpoint URL (for user login redirect).
29    pub authorization_endpoint: String,
30    /// Token endpoint URL (for code-to-token exchange).
31    pub token_endpoint: String,
32    /// Requested OAuth scopes.
33    pub scopes: Vec<String>,
34}
35
36/// Preview mode controlling protocol validation strictness
37#[derive(Debug, Clone, Default, PartialEq)]
38pub enum PreviewMode {
39    /// Standard MCP preview (default)
40    #[default]
41    Standard,
42    /// ChatGPT strict protocol validation
43    ChatGpt,
44}
45
46impl std::fmt::Display for PreviewMode {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::Standard => write!(f, "standard"),
50            Self::ChatGpt => write!(f, "chatgpt"),
51        }
52    }
53}
54
55/// Configuration for the preview server
56#[derive(Debug, Clone)]
57pub struct PreviewConfig {
58    /// URL of the target MCP server
59    pub mcp_url: String,
60    /// Port for the preview server
61    pub port: u16,
62    /// Initial tool to select
63    pub initial_tool: Option<String>,
64    /// Initial theme (light/dark)
65    pub theme: String,
66    /// Initial locale
67    pub locale: String,
68    /// Optional directory containing widget `.html` files for file-based authoring.
69    ///
70    /// When set, the preview server reads widget HTML directly from disk on each
71    /// request (hot-reload without file watchers). Widgets are discovered by
72    /// scanning this directory for `.html` files and mapping each to a
73    /// `ui://app/{stem}` resource URI.
74    pub widgets_dir: Option<PathBuf>,
75    /// Preview mode (standard or chatgpt)
76    pub mode: PreviewMode,
77    /// Optional `Authorization` header value for authenticated MCP servers.
78    ///
79    /// When set, the proxy attaches this header to every outbound request
80    /// to the target MCP server.
81    pub auth_header: Option<String>,
82    /// OAuth configuration for browser-based PKCE flow (None = no browser OAuth).
83    pub oauth_config: Option<OAuthPreviewConfig>,
84}
85
86impl Default for PreviewConfig {
87    fn default() -> Self {
88        Self {
89            mcp_url: "http://localhost:3000".to_string(),
90            port: 8765,
91            initial_tool: None,
92            theme: "light".to_string(),
93            locale: "en-US".to_string(),
94            widgets_dir: None,
95            mode: PreviewMode::default(),
96            auth_header: None,
97            oauth_config: None,
98        }
99    }
100}
101
102/// Shared application state
103pub struct AppState {
104    pub config: PreviewConfig,
105    pub proxy: McpProxy,
106    pub wasm_builder: WasmBuilder,
107}
108
109/// MCP Preview Server
110pub struct PreviewServer;
111
112impl PreviewServer {
113    /// Start the preview server
114    pub async fn start(config: PreviewConfig) -> Result<()> {
115        let proxy = McpProxy::new_with_auth(&config.mcp_url, config.auth_header.clone());
116
117        // Locate the workspace root to find the WASM client source
118        let cwd = std::env::current_dir().unwrap_or_default();
119        let workspace_root = find_workspace_root(&cwd).unwrap_or_else(|| cwd.clone());
120        let wasm_source_dir = workspace_root.join("examples").join("wasm-client");
121        let wasm_cache_dir = workspace_root.join("target").join("wasm-bridge");
122        let wasm_builder = WasmBuilder::new(wasm_source_dir, wasm_cache_dir);
123
124        let state = Arc::new(AppState {
125            config: config.clone(),
126            proxy,
127            wasm_builder,
128        });
129
130        // Build CORS layer
131        let cors = CorsLayer::new()
132            .allow_origin(Any)
133            .allow_methods(Any)
134            .allow_headers(Any);
135
136        // Build router
137        let app = Router::new()
138            // Main preview page
139            .route("/", get(handlers::page::index))
140            // API endpoints - tools
141            .route("/api/config", get(handlers::api::get_config))
142            .route("/api/tools", get(handlers::api::list_tools))
143            .route("/api/tools/call", post(handlers::api::call_tool))
144            // API endpoints - resources
145            .route("/api/resources", get(handlers::api::list_resources))
146            .route("/api/resources/read", get(handlers::api::read_resource))
147            // API endpoints - session management
148            .route("/api/reconnect", post(handlers::api::reconnect))
149            .route("/api/status", get(handlers::api::status))
150            // API endpoints - MCP proxy (same-origin forward for WASM client)
151            .route("/api/mcp", post(handlers::api::forward_mcp))
152            // API endpoints - WASM bridge
153            .route("/api/wasm/build", post(handlers::wasm::trigger_build))
154            .route("/api/wasm/status", get(handlers::wasm::build_status))
155            // WASM artifact serving (catch-all for nested snippets/ paths)
156            .route("/wasm/{*path}", get(handlers::wasm::serve_artifact))
157            // Static assets
158            .route("/assets/{*path}", get(handlers::assets::serve))
159            // API endpoints - OAuth
160            .route("/api/auth/token-exchange", post(handlers::auth::token_exchange))
161            .route("/api/auth/callback", get(handlers::auth::callback))
162            .route("/api/auth/status", get(handlers::auth::status))
163            // WebSocket for live updates
164            .route("/ws", get(handlers::websocket::handler))
165            .layer(cors)
166            .with_state(state);
167
168        let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
169
170        println!();
171        println!("\x1b[1;36m╔══════════════════════════════════════════════════╗\x1b[0m");
172        println!("\x1b[1;36m║          MCP Apps Preview Server                 ║\x1b[0m");
173        println!("\x1b[1;36m╠══════════════════════════════════════════════════╣\x1b[0m");
174        println!(
175            "\x1b[1;36m║\x1b[0m  Preview:    \x1b[1;33mhttp://localhost:{:<5}\x1b[0m             \x1b[1;36m║\x1b[0m",
176            config.port
177        );
178        println!(
179            "\x1b[1;36m║\x1b[0m  MCP Server: \x1b[1;32m{:<30}\x1b[0m   \x1b[1;36m║\x1b[0m",
180            truncate_url(&config.mcp_url, 30)
181        );
182        if let Some(ref widgets_dir) = config.widgets_dir {
183            println!(
184                "\x1b[1;36m║\x1b[0m  Widgets:    \x1b[1;35m{:<30}\x1b[0m   \x1b[1;36m║\x1b[0m",
185                truncate_url(&widgets_dir.display().to_string(), 30)
186            );
187            info!(
188                "Widgets directory: {} (hot-reload enabled)",
189                widgets_dir.display()
190            );
191        }
192        println!(
193            "\x1b[1;36m║\x1b[0m  Mode:       {:<30}   \x1b[1;36m║\x1b[0m",
194            match config.mode {
195                PreviewMode::ChatGpt => "\x1b[1;31mChatGPT Strict\x1b[0m",
196                PreviewMode::Standard => "\x1b[1;32mStandard MCP Apps\x1b[0m",
197            }
198        );
199        println!("\x1b[1;36m╠══════════════════════════════════════════════════╣\x1b[0m");
200        println!(
201            "\x1b[1;36m║\x1b[0m  Press Ctrl+C to stop                           \x1b[1;36m║\x1b[0m"
202        );
203        println!("\x1b[1;36m╚══════════════════════════════════════════════════╝\x1b[0m");
204        println!();
205
206        info!("Preview server starting on http://{}", addr);
207
208        let listener = TcpListener::bind(addr).await?;
209        axum::serve(listener, app).await?;
210
211        Ok(())
212    }
213}
214
215fn truncate_url(url: &str, max_len: usize) -> String {
216    if url.len() <= max_len {
217        url.to_string()
218    } else {
219        format!("{}...", &url[..max_len - 3])
220    }
221}