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