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)]
25pub struct OAuthPreviewConfig {
26 pub client_id: String,
28 pub authorization_endpoint: String,
30 pub token_endpoint: String,
32 pub scopes: Vec<String>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq)]
38pub enum PreviewMode {
39 #[default]
41 Standard,
42 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#[derive(Debug, Clone)]
57pub struct PreviewConfig {
58 pub mcp_url: String,
60 pub port: u16,
62 pub initial_tool: Option<String>,
64 pub theme: String,
66 pub locale: String,
68 pub widgets_dir: Option<PathBuf>,
75 pub mode: PreviewMode,
77 pub auth_header: Option<String>,
82 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
102pub struct AppState {
104 pub config: PreviewConfig,
105 pub proxy: McpProxy,
106 pub wasm_builder: WasmBuilder,
107}
108
109pub struct PreviewServer;
111
112impl PreviewServer {
113 pub async fn start(config: PreviewConfig) -> Result<()> {
115 let proxy = McpProxy::new_with_auth(&config.mcp_url, config.auth_header.clone());
116
117 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 let cors = CorsLayer::new()
132 .allow_origin(Any)
133 .allow_methods(Any)
134 .allow_headers(Any);
135
136 let app = Router::new()
138 .route("/", get(handlers::page::index))
140 .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 .route("/api/resources", get(handlers::api::list_resources))
146 .route("/api/resources/read", get(handlers::api::read_resource))
147 .route("/api/reconnect", post(handlers::api::reconnect))
149 .route("/api/status", get(handlers::api::status))
150 .route("/api/mcp", post(handlers::api::forward_mcp))
152 .route("/api/wasm/build", post(handlers::wasm::trigger_build))
154 .route("/api/wasm/status", get(handlers::wasm::build_status))
155 .route("/wasm/{*path}", get(handlers::wasm::serve_artifact))
157 .route("/assets/{*path}", get(handlers::assets::serve))
159 .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 .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}