Skip to main content

codanna/mcp/
https_server.rs

1//! HTTPS server implementation for MCP using streamable HTTP transport with TLS
2//!
3//! Provides a secure HTTPS server with TLS support for MCP communication.
4//! Uses streamable HTTP transport which is compatible with Claude Code.
5
6#[cfg(feature = "https-server")]
7pub async fn serve_https(config: crate::Settings, watch: bool, bind: String) -> anyhow::Result<()> {
8    use crate::IndexPersistence;
9    use crate::indexing::facade::IndexFacade;
10    use crate::mcp::{CodeIntelligenceServer, notifications::NotificationBroadcaster};
11    use crate::watcher::HotReloadWatcher;
12    use anyhow::Context;
13    use axum::Router;
14    use axum_server::tls_rustls::RustlsConfig;
15    use rmcp::transport::streamable_http_server::{
16        StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
17    };
18    use std::net::SocketAddr;
19    use std::path::PathBuf;
20    use std::sync::Arc;
21    use std::time::Duration;
22    use tokio::sync::RwLock;
23    use tokio_util::sync::CancellationToken;
24
25    // Initialize logging with config
26    crate::logging::init_with_config(&config.logging);
27
28    crate::log_event!("https", "starting", "MCP server on {bind}");
29
30    // Create notification broadcaster for file change events
31    let broadcaster = Arc::new(NotificationBroadcaster::new(100));
32
33    // Create shared facade
34    let settings = Arc::new(config.clone());
35    let persistence = IndexPersistence::new(config.index_path.clone());
36
37    let facade = if persistence.exists() {
38        match persistence.load_facade(settings.clone()) {
39            Ok(loaded) => {
40                let symbol_count = loaded.symbol_count();
41                crate::log_event!("https", "loaded", "{symbol_count} symbols");
42                loaded
43            }
44            Err(e) => {
45                tracing::warn!("[https] failed to load index: {e}");
46                crate::log_event!("https", "starting", "empty index");
47                IndexFacade::new(settings.clone())?
48            }
49        }
50    } else {
51        crate::log_event!("https", "starting", "no existing index");
52        IndexFacade::new(settings.clone())?
53    };
54    let indexer = Arc::new(RwLock::new(facade));
55
56    // Create cancellation token for graceful shutdown
57    let ct = CancellationToken::new();
58
59    // Load document store once (shared between MCP server and watcher)
60    let document_store_arc = crate::documents::load_from_settings(&config);
61    if document_store_arc.is_some() {
62        tracing::debug!(target: "mcp", "document store loaded for MCP server");
63    }
64
65    // Start unified file watcher if enabled
66    if watch || config.file_watch.enabled {
67        use crate::watcher::UnifiedWatcher;
68        use crate::watcher::handlers::{CodeFileHandler, ConfigFileHandler, DocumentFileHandler};
69
70        let workspace_root = config
71            .workspace_root
72            .clone()
73            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
74
75        let settings_path = workspace_root.join(".codanna/settings.toml");
76        let debounce_ms = config.file_watch.debounce_ms;
77
78        // Build unified watcher with handlers
79        let mut builder = UnifiedWatcher::builder()
80            .broadcaster(broadcaster.clone())
81            .indexer(indexer.clone())
82            .index_path(config.index_path.clone())
83            .workspace_root(workspace_root.clone())
84            .debounce_ms(debounce_ms);
85
86        // Add code file handler
87        builder = builder.handler(CodeFileHandler::new(
88            indexer.clone(),
89            workspace_root.clone(),
90        ));
91
92        // Add config file handler
93        match ConfigFileHandler::new(settings_path.clone()) {
94            Ok(config_handler) => {
95                builder = builder.handler(config_handler);
96            }
97            Err(e) => {
98                tracing::warn!("[config] failed to create handler: {e}");
99            }
100        }
101
102        // Add document handler using shared document store
103        if let Some(ref store_arc) = document_store_arc {
104            tracing::debug!(target: "mcp", "adding document handler to watcher");
105            builder = builder
106                .document_store(store_arc.clone())
107                .chunking_config(config.documents.defaults.clone())
108                .handler(DocumentFileHandler::new(
109                    store_arc.clone(),
110                    workspace_root.clone(),
111                ));
112        }
113
114        // Build and start the unified watcher
115        match builder.build() {
116            Ok(unified_watcher) => {
117                let watcher_ct = ct.clone();
118                tokio::spawn(async move {
119                    tokio::select! {
120                        result = unified_watcher.watch() => {
121                            if let Err(e) = result {
122                                tracing::error!("[watcher] error: {e}");
123                            }
124                        }
125                        _ = watcher_ct.cancelled() => {
126                            crate::log_event!("watcher", "stopped");
127                        }
128                    }
129                });
130                crate::log_event!(
131                    "watcher",
132                    "started",
133                    "debounce: {debounce_ms}ms, config: {}",
134                    settings_path.display()
135                );
136            }
137            Err(e) => {
138                tracing::warn!("[watcher] failed to start: {e}");
139                tracing::warn!("[watcher] continuing without file watching");
140            }
141        }
142    }
143
144    // Start index watcher if watch mode is enabled
145    if watch {
146        let hot_reload_indexer = indexer.clone();
147        let hot_reload_settings = Arc::new(config.clone());
148        let hot_reload_broadcaster = broadcaster.clone();
149        let hot_reload_ct = ct.clone();
150
151        // Default to 5 second interval
152        let watch_interval = 5u64;
153
154        let hot_reload_watcher = HotReloadWatcher::new(
155            hot_reload_indexer,
156            hot_reload_settings,
157            Duration::from_secs(watch_interval),
158        )
159        .with_broadcaster(hot_reload_broadcaster);
160
161        tokio::spawn(async move {
162            tokio::select! {
163                _ = hot_reload_watcher.watch() => {
164                    crate::log_event!("hot-reload", "ended");
165                }
166                _ = hot_reload_ct.cancelled() => {
167                    crate::log_event!("hot-reload", "stopped");
168                }
169            }
170        });
171
172        crate::log_event!("hot-reload", "started", "polling every {watch_interval}s");
173    }
174
175    // Create streamable HTTP service for MCP connections
176    // Important: We share the SAME indexer instance across all connections
177    // to ensure hot reload works properly. The indexer is already Arc<RwLock<_>>
178    // so it's safe to share across connections.
179    let indexer_for_service = indexer.clone();
180    let config_for_service = Arc::new(config.clone());
181
182    // Create a shared service instance that all connections will use
183    let shared_service =
184        CodeIntelligenceServer::new_with_facade(indexer_for_service, config_for_service);
185
186    // Attach document store if available
187    let shared_service = if let Some(store_arc) = document_store_arc {
188        tracing::debug!(target: "mcp", "attaching document store to MCP server");
189        shared_service.with_document_store_arc(store_arc)
190    } else {
191        shared_service
192    };
193
194    // Start notification listener to forward file change events to MCP clients
195    let notification_receiver = broadcaster.subscribe();
196    let notification_server = shared_service.clone();
197    tokio::spawn(async move {
198        notification_server
199            .start_notification_listener(notification_receiver)
200            .await;
201    });
202
203    let mcp_service = StreamableHttpService::new(
204        move || {
205            // Return a clone of the shared service
206            // Since CodeIntelligenceServer derives Clone and the indexer is Arc<RwLock<_>>,
207            // all clones will share the same underlying indexer
208            Ok(shared_service.clone())
209        },
210        LocalSessionManager::default().into(),
211        {
212            let cfg = StreamableHttpServerConfig::default()
213                .with_cancellation_token(ct.child_token())
214                .with_sse_keep_alive(Some(Duration::from_secs(15)))
215                .with_sse_retry(None)
216                .with_stateful_mode(true)
217                .with_json_response(false);
218            let cfg = match config.mcp.allowed_hosts.clone() {
219                Some(hosts) => cfg.with_allowed_hosts(hosts),
220                None => cfg,
221            };
222            match config.mcp.allowed_origins.clone() {
223                Some(origins) => cfg.with_allowed_origins(origins),
224                None => cfg,
225            }
226        },
227    );
228
229    // Create OAuth metadata handler with the bind address
230    let bind_for_metadata = bind.clone();
231    let oauth_metadata = move || async move {
232        eprintln!("OAuth metadata endpoint called");
233        axum::Json(serde_json::json!({
234            "issuer": format!("https://{}", bind_for_metadata.clone()),
235            "authorization_endpoint": format!("https://{}/oauth/authorize", bind_for_metadata.clone()),
236            "token_endpoint": format!("https://{}/oauth/token", bind_for_metadata.clone()),
237            "registration_endpoint": format!("https://{}/oauth/register", bind_for_metadata),
238            "scopes_supported": ["mcp"],
239            "response_types_supported": ["code"],
240            "grant_types_supported": ["authorization_code", "refresh_token"],
241            "code_challenge_methods_supported": ["S256", "plain"],
242            "token_endpoint_auth_methods_supported": ["none"]
243        }))
244    };
245
246    // Request logging middleware (OAuth authentication is optional for HTTPS)
247    async fn log_requests(
248        req: axum::extract::Request,
249        next: axum::middleware::Next,
250    ) -> Result<axum::response::Response, axum::http::StatusCode> {
251        let path = req.uri().path();
252        eprintln!("Request to: {path}");
253
254        // Debug: Print all headers
255        eprintln!("Headers received:");
256        for (name, value) in req.headers() {
257            if let Ok(v) = value.to_str() {
258                eprintln!("  {name}: {v}");
259            }
260        }
261
262        // Pass through - TLS provides transport security
263        Ok(next.run(req).await)
264    }
265
266    // Create MCP router with logging middleware
267    let mcp_router_with_logging = Router::new()
268        .nest_service("/mcp", mcp_service)
269        .layer(axum::middleware::from_fn(log_requests));
270
271    // Create main router - OAuth endpoints available but optional for HTTPS
272    let router = Router::new()
273        // OAuth endpoints - NO authentication required
274        .route(
275            "/.well-known/oauth-authorization-server",
276            axum::routing::get(oauth_metadata),
277        )
278        .route("/oauth/register", axum::routing::post(oauth_register))
279        .route("/oauth/token", axum::routing::post(oauth_token))
280        .route("/oauth/authorize", axum::routing::get(oauth_authorize))
281        // Health check - NO authentication required
282        .route("/health", axum::routing::get(health_check))
283        // MCP endpoint - No authentication required (TLS provides transport security)
284        .merge(mcp_router_with_logging);
285
286    // Get or create TLS certificates
287    let (cert_pem, key_pem) = get_or_create_certificate(&bind)
288        .await
289        .context("Failed to get or create TLS certificate")?;
290
291    // Configure TLS
292    let tls_config = RustlsConfig::from_pem(cert_pem, key_pem)
293        .await
294        .context("Failed to configure TLS")?;
295
296    // Parse bind address
297    let addr: SocketAddr = bind.parse().context("Failed to parse bind address")?;
298
299    eprintln!("HTTPS MCP server listening on https://{bind}");
300    eprintln!("MCP endpoint: https://{bind}/mcp");
301    eprintln!("Health check: https://{bind}/health");
302    eprintln!();
303    eprintln!("Using self-signed certificate. Clients will show security warnings.");
304    eprintln!("To trust the certificate, visit https://{bind} in your browser first");
305    eprintln!();
306    eprintln!("Press Ctrl+C to stop the server");
307
308    // Serve with TLS
309    let server = axum_server::bind_rustls(addr, tls_config).serve(router.into_make_service());
310
311    // Handle graceful shutdown
312    tokio::select! {
313        result = server => {
314            result?;
315        }
316        _ = shutdown_signal() => {
317            eprintln!("Shutting down HTTPS server...");
318            ct.cancel();
319        }
320    }
321
322    eprintln!("HTTPS server shut down gracefully");
323    Ok(())
324}
325
326/// Helper function for health check endpoint
327#[cfg(feature = "https-server")]
328async fn health_check() -> &'static str {
329    eprintln!("Health check endpoint called");
330    "OK"
331}
332
333/// OAuth register endpoint - accepts any registration
334#[cfg(feature = "https-server")]
335async fn oauth_register(
336    axum::Json(payload): axum::Json<serde_json::Value>,
337) -> axum::Json<serde_json::Value> {
338    eprintln!("OAuth register endpoint called with: {payload:?}");
339    // Return a dummy client registration response that matches the request
340    // Use empty string for public clients (Claude Code expects a string, not null)
341    axum::Json(serde_json::json!({
342        "client_id": "dummy-client-id",
343        "client_secret": "",  // Empty string for public client
344        "client_id_issued_at": 1234567890,
345        "grant_types": ["authorization_code", "refresh_token"],
346        "response_types": ["code"],
347        "redirect_uris": payload.get("redirect_uris").unwrap_or(&serde_json::json!([])).clone(),
348        "client_name": payload.get("client_name").unwrap_or(&serde_json::json!("MCP Client")).clone(),
349        "token_endpoint_auth_method": "none"
350    }))
351}
352
353/// OAuth token endpoint - exchanges authorization code for access token
354#[cfg(feature = "https-server")]
355async fn oauth_token(body: String) -> axum::Json<serde_json::Value> {
356    eprintln!("OAuth token endpoint called with body: {body}");
357
358    // Parse form-encoded data (OAuth uses application/x-www-form-urlencoded)
359    let params: std::collections::HashMap<String, String> =
360        serde_urlencoded::from_str(&body).unwrap_or_default();
361
362    eprintln!("Token request params: {params:?}");
363
364    // Check grant type
365    let grant_type = params.get("grant_type").cloned().unwrap_or_default();
366    let code = params.get("code").cloned().unwrap_or_default();
367
368    // IMPORTANT: Reject refresh_token grant type (like the SDK example)
369    if grant_type == "refresh_token" {
370        eprintln!("Rejecting refresh_token grant type");
371        return axum::Json(serde_json::json!({
372            "error": "unsupported_grant_type",
373            "error_description": "only authorization_code is supported"
374        }));
375    }
376
377    // For authorization_code grant, verify the code
378    if grant_type == "authorization_code" && code == "dummy-auth-code" {
379        // Return access token WITHOUT refresh token
380        axum::Json(serde_json::json!({
381            "access_token": "mcp-access-token-dummy",
382            "token_type": "Bearer",
383            "expires_in": 3600,
384            "scope": "mcp"
385        }))
386    } else {
387        // Invalid request
388        eprintln!("Invalid token request: grant_type={grant_type}, code={code}");
389        axum::Json(serde_json::json!({
390            "error": "invalid_grant",
391            "error_description": "Invalid authorization code or grant type"
392        }))
393    }
394}
395
396/// OAuth authorize endpoint - redirects back with auth code
397#[cfg(feature = "https-server")]
398async fn oauth_authorize(
399    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
400) -> impl axum::response::IntoResponse {
401    eprintln!("OAuth authorize endpoint called with params: {params:?}");
402
403    // Extract redirect_uri and state from query params
404    let redirect_uri = params
405        .get("redirect_uri")
406        .cloned()
407        .unwrap_or_else(|| "http://localhost:3118/callback".to_string());
408    let state = params.get("state").cloned().unwrap_or_default();
409
410    // Build the callback URL with authorization code
411    let callback_url = format!("{redirect_uri}?code=dummy-auth-code&state={state}");
412
413    // Return HTML with auto-redirect and manual button
414    let html = format!(
415        r#"
416<!DOCTYPE html>
417<html>
418<head>
419    <title>Authorize Codanna</title>
420    <meta charset="utf-8">
421    <style>
422        body {{
423            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
424            display: flex;
425            justify-content: center;
426            align-items: center;
427            height: 100vh;
428            margin: 0;
429            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
430        }}
431        .container {{
432            background: white;
433            padding: 2rem;
434            border-radius: 10px;
435            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
436            text-align: center;
437            max-width: 400px;
438        }}
439        h1 {{
440            color: #333;
441            margin-bottom: 1rem;
442        }}
443        p {{
444            color: #666;
445            margin-bottom: 2rem;
446        }}
447        button {{
448            background: #667eea;
449            color: white;
450            border: none;
451            padding: 12px 30px;
452            border-radius: 5px;
453            font-size: 16px;
454            cursor: pointer;
455            transition: background 0.3s;
456        }}
457        button:hover {{
458            background: #764ba2;
459        }}
460        .spinner {{
461            margin: 20px auto;
462            width: 50px;
463            height: 50px;
464            border: 3px solid #f3f3f3;
465            border-top: 3px solid #667eea;
466            border-radius: 50%;
467            animation: spin 1s linear infinite;
468        }}
469        @keyframes spin {{
470            0% {{ transform: rotate(0deg); }}
471            100% {{ transform: rotate(360deg); }}
472        }}
473    </style>
474    <script>
475        // Auto-redirect after a short delay
476        setTimeout(function() {{
477            window.location.href = "{callback_url}";
478        }}, 1500);
479    </script>
480</head>
481<body>
482    <div class="container">
483        <h1>🔐 Authorize Codanna</h1>
484        <div class="spinner"></div>
485        <p>Authorizing access to Codanna MCP Server...</p>
486        <p>You will be redirected automatically.</p>
487        <button onclick="window.location.href='{callback_url}'">
488            Continue Manually
489        </button>
490    </div>
491</body>
492</html>
493"#
494    );
495
496    axum::response::Html(html)
497}
498
499/// Helper function for shutdown signal
500#[cfg(feature = "https-server")]
501async fn shutdown_signal() {
502    tokio::signal::ctrl_c()
503        .await
504        .expect("failed to listen for ctrl+c");
505    eprintln!("Received shutdown signal");
506}
507
508/// Get or create self-signed certificate for HTTPS
509#[cfg(feature = "https-server")]
510async fn get_or_create_certificate(bind: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
511    use anyhow::Context;
512    use rcgen::generate_simple_self_signed;
513
514    // Determine certificate storage directory
515    let cert_dir = dirs::config_dir()
516        .context("Failed to get config directory")?
517        .join("codanna")
518        .join("certs");
519
520    let cert_path = cert_dir.join("server.pem");
521    let key_path = cert_dir.join("server.key");
522
523    // Create directory if it doesn't exist
524    tokio::fs::create_dir_all(&cert_dir)
525        .await
526        .context("Failed to create certificate directory")?;
527
528    // Check if server certificate already exists
529    if cert_path.exists() && key_path.exists() {
530        eprintln!("Loading existing certificates from {cert_dir:?}");
531        let cert = tokio::fs::read(&cert_path)
532            .await
533            .context("Failed to read certificate file")?;
534        let key = tokio::fs::read(&key_path)
535            .await
536            .context("Failed to read key file")?;
537        return Ok((cert, key));
538    }
539
540    eprintln!("Generating new enhanced self-signed certificate...");
541
542    // Build list of Subject Alternative Names
543    let mut subject_alt_names = vec![
544        "localhost".to_string(),
545        "127.0.0.1".to_string(),
546        "::1".to_string(),
547    ];
548
549    // If binding to 0.0.0.0, include local network IP
550    if bind.starts_with("0.0.0.0") {
551        if let Ok(local_ip) = local_ip_address::local_ip() {
552            eprintln!("Including local network IP in certificate: {local_ip}");
553            subject_alt_names.push(local_ip.to_string());
554        }
555    }
556
557    // Generate certificate using the simpler API but with better parameters
558    let cert = generate_simple_self_signed(subject_alt_names.clone())
559        .context("Failed to generate self-signed certificate")?;
560
561    let cert_pem = cert.cert.pem().into_bytes();
562    let key_pem = cert.signing_key.serialize_pem().into_bytes();
563
564    // Save certificate and key
565    tokio::fs::write(&cert_path, &cert_pem)
566        .await
567        .context("Failed to write server certificate")?;
568    tokio::fs::write(&key_path, &key_pem)
569        .await
570        .context("Failed to write server key")?;
571
572    // Calculate fingerprint
573    use std::collections::hash_map::DefaultHasher;
574    use std::hash::{Hash, Hasher};
575
576    let mut hasher = DefaultHasher::new();
577    cert.cert.der().hash(&mut hasher);
578    let fingerprint = hasher.finish();
579    let fingerprint_hex = format!("{fingerprint:016X}");
580
581    eprintln!();
582    eprintln!("🔐 Certificate Details:");
583    eprintln!("   - Type: Self-Signed TLS Certificate");
584    eprintln!("   - Location: {}", cert_path.display());
585    eprintln!("   - Fingerprint: {fingerprint_hex}");
586    eprintln!("   - Valid for: {}", subject_alt_names.join(", "));
587    eprintln!();
588    eprintln!("🔧 To trust this certificate on macOS:");
589    eprintln!();
590    eprintln!("   Option 1: Command line (requires sudo):");
591    eprintln!(
592        "   sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {}",
593        cert_path.display()
594    );
595    eprintln!();
596    eprintln!("   Option 2: GUI (recommended):");
597    eprintln!("   1. Open Finder and navigate to: {}", cert_dir.display());
598    eprintln!("   2. Double-click 'server.pem'");
599    eprintln!("   3. Add to 'System' keychain");
600    eprintln!("   4. Set to 'Always Trust' for SSL");
601    eprintln!();
602    eprintln!("   Option 3: Open in browser first:");
603    eprintln!("   1. Visit https://127.0.0.1:8443/health in Safari/Chrome");
604    eprintln!("   2. Click 'Advanced' and proceed anyway");
605    eprintln!("   3. This may help some clients accept the certificate");
606    eprintln!();
607    eprintln!("⚠️  After trusting the certificate, restart Claude Code to reconnect");
608    eprintln!();
609
610    Ok((cert_pem, key_pem))
611}
612
613/// Helper function to detect local IP address
614#[cfg(feature = "https-server")]
615mod local_ip_address {
616    use std::net::{IpAddr, UdpSocket};
617
618    pub fn local_ip() -> Result<IpAddr, Box<dyn std::error::Error>> {
619        // Connect to a dummy address to determine local IP
620        // This doesn't actually send any packets, just determines
621        // which network interface would be used for external traffic
622        let socket = UdpSocket::bind("0.0.0.0:0")?;
623        socket.connect("8.8.8.8:80")?;
624        let addr = socket.local_addr()?;
625        Ok(addr.ip())
626    }
627}
628
629#[cfg(not(feature = "https-server"))]
630pub async fn serve_https(
631    _config: crate::Settings,
632    _watch: bool,
633    _bind: String,
634) -> anyhow::Result<()> {
635    eprintln!("HTTPS server support is not compiled in.");
636    eprintln!("Please rebuild with: cargo build --features https-server");
637    std::process::exit(1);
638}