1use 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#[derive(Debug, Clone, Default, PartialEq)]
21pub enum PreviewMode {
22 #[default]
24 Standard,
25 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#[derive(Debug, Clone)]
40pub struct PreviewConfig {
41 pub mcp_url: String,
43 pub port: u16,
45 pub initial_tool: Option<String>,
47 pub theme: String,
49 pub locale: String,
51 pub widgets_dir: Option<PathBuf>,
58 pub mode: PreviewMode,
60 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
82pub struct AppState {
84 pub config: PreviewConfig,
85 pub proxy: McpProxy,
86 pub wasm_builder: WasmBuilder,
87}
88
89pub struct PreviewServer;
91
92impl PreviewServer {
93 pub async fn start(config: PreviewConfig) -> Result<()> {
95 let proxy = McpProxy::new_with_auth(&config.mcp_url, config.auth_header.clone());
96
97 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 let cors = CorsLayer::new()
112 .allow_origin(Any)
113 .allow_methods(Any)
114 .allow_headers(Any);
115
116 let app = Router::new()
118 .route("/", get(handlers::page::index))
120 .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 .route("/api/resources", get(handlers::api::list_resources))
126 .route("/api/resources/read", get(handlers::api::read_resource))
127 .route("/api/reconnect", post(handlers::api::reconnect))
129 .route("/api/status", get(handlers::api::status))
130 .route("/api/mcp", post(handlers::api::forward_mcp))
132 .route("/api/wasm/build", post(handlers::wasm::trigger_build))
134 .route("/api/wasm/status", get(handlers::wasm::build_status))
135 .route("/wasm/{*path}", get(handlers::wasm::serve_artifact))
137 .route("/assets/{*path}", get(handlers::assets::serve))
139 .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}