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()).expect("Failed to create IndexFacade")
48            }
49        }
50    } else {
51        crate::log_event!("https", "starting", "no existing index");
52        IndexFacade::new(settings.clone()).expect("Failed to create IndexFacade")
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        StreamableHttpServerConfig {
212            cancellation_token: ct.child_token(),
213            sse_keep_alive: Some(Duration::from_secs(15)),
214            sse_retry: None,
215            stateful_mode: true,
216            json_response: false,
217        },
218    );
219
220    // Create OAuth metadata handler with the bind address
221    let bind_for_metadata = bind.clone();
222    let oauth_metadata = move || async move {
223        eprintln!("OAuth metadata endpoint called");
224        axum::Json(serde_json::json!({
225            "issuer": format!("https://{}", bind_for_metadata.clone()),
226            "authorization_endpoint": format!("https://{}/oauth/authorize", bind_for_metadata.clone()),
227            "token_endpoint": format!("https://{}/oauth/token", bind_for_metadata.clone()),
228            "registration_endpoint": format!("https://{}/oauth/register", bind_for_metadata),
229            "scopes_supported": ["mcp"],
230            "response_types_supported": ["code"],
231            "grant_types_supported": ["authorization_code", "refresh_token"],
232            "code_challenge_methods_supported": ["S256", "plain"],
233            "token_endpoint_auth_methods_supported": ["none"]
234        }))
235    };
236
237    // Request logging middleware (OAuth authentication is optional for HTTPS)
238    async fn log_requests(
239        req: axum::extract::Request,
240        next: axum::middleware::Next,
241    ) -> Result<axum::response::Response, axum::http::StatusCode> {
242        let path = req.uri().path();
243        eprintln!("Request to: {path}");
244
245        // Debug: Print all headers
246        eprintln!("Headers received:");
247        for (name, value) in req.headers() {
248            if let Ok(v) = value.to_str() {
249                eprintln!("  {name}: {v}");
250            }
251        }
252
253        // Pass through - TLS provides transport security
254        Ok(next.run(req).await)
255    }
256
257    // Create MCP router with logging middleware
258    let mcp_router_with_logging = Router::new()
259        .nest_service("/mcp", mcp_service)
260        .layer(axum::middleware::from_fn(log_requests));
261
262    // Create main router - OAuth endpoints available but optional for HTTPS
263    let router = Router::new()
264        // OAuth endpoints - NO authentication required
265        .route(
266            "/.well-known/oauth-authorization-server",
267            axum::routing::get(oauth_metadata),
268        )
269        .route("/oauth/register", axum::routing::post(oauth_register))
270        .route("/oauth/token", axum::routing::post(oauth_token))
271        .route("/oauth/authorize", axum::routing::get(oauth_authorize))
272        // Health check - NO authentication required
273        .route("/health", axum::routing::get(health_check))
274        // MCP endpoint - No authentication required (TLS provides transport security)
275        .merge(mcp_router_with_logging);
276
277    // Get or create TLS certificates
278    let (cert_pem, key_pem) = get_or_create_certificate(&bind)
279        .await
280        .context("Failed to get or create TLS certificate")?;
281
282    // Configure TLS
283    let tls_config = RustlsConfig::from_pem(cert_pem, key_pem)
284        .await
285        .context("Failed to configure TLS")?;
286
287    // Parse bind address
288    let addr: SocketAddr = bind.parse().context("Failed to parse bind address")?;
289
290    eprintln!("HTTPS MCP server listening on https://{bind}");
291    eprintln!("MCP endpoint: https://{bind}/mcp");
292    eprintln!("Health check: https://{bind}/health");
293    eprintln!();
294    eprintln!("Using self-signed certificate. Clients will show security warnings.");
295    eprintln!("To trust the certificate, visit https://{bind} in your browser first");
296    eprintln!();
297    eprintln!("Press Ctrl+C to stop the server");
298
299    // Serve with TLS
300    let server = axum_server::bind_rustls(addr, tls_config).serve(router.into_make_service());
301
302    // Handle graceful shutdown
303    tokio::select! {
304        result = server => {
305            result?;
306        }
307        _ = shutdown_signal() => {
308            eprintln!("Shutting down HTTPS server...");
309            ct.cancel();
310        }
311    }
312
313    eprintln!("HTTPS server shut down gracefully");
314    Ok(())
315}
316
317/// Helper function for health check endpoint
318#[cfg(feature = "https-server")]
319async fn health_check() -> &'static str {
320    eprintln!("Health check endpoint called");
321    "OK"
322}
323
324/// OAuth register endpoint - accepts any registration
325#[cfg(feature = "https-server")]
326async fn oauth_register(
327    axum::Json(payload): axum::Json<serde_json::Value>,
328) -> axum::Json<serde_json::Value> {
329    eprintln!("OAuth register endpoint called with: {payload:?}");
330    // Return a dummy client registration response that matches the request
331    // Use empty string for public clients (Claude Code expects a string, not null)
332    axum::Json(serde_json::json!({
333        "client_id": "dummy-client-id",
334        "client_secret": "",  // Empty string for public client
335        "client_id_issued_at": 1234567890,
336        "grant_types": ["authorization_code", "refresh_token"],
337        "response_types": ["code"],
338        "redirect_uris": payload.get("redirect_uris").unwrap_or(&serde_json::json!([])).clone(),
339        "client_name": payload.get("client_name").unwrap_or(&serde_json::json!("MCP Client")).clone(),
340        "token_endpoint_auth_method": "none"
341    }))
342}
343
344/// OAuth token endpoint - exchanges authorization code for access token
345#[cfg(feature = "https-server")]
346async fn oauth_token(body: String) -> axum::Json<serde_json::Value> {
347    eprintln!("OAuth token endpoint called with body: {body}");
348
349    // Parse form-encoded data (OAuth uses application/x-www-form-urlencoded)
350    let params: std::collections::HashMap<String, String> =
351        serde_urlencoded::from_str(&body).unwrap_or_default();
352
353    eprintln!("Token request params: {params:?}");
354
355    // Check grant type
356    let grant_type = params.get("grant_type").cloned().unwrap_or_default();
357    let code = params.get("code").cloned().unwrap_or_default();
358
359    // IMPORTANT: Reject refresh_token grant type (like the SDK example)
360    if grant_type == "refresh_token" {
361        eprintln!("Rejecting refresh_token grant type");
362        return axum::Json(serde_json::json!({
363            "error": "unsupported_grant_type",
364            "error_description": "only authorization_code is supported"
365        }));
366    }
367
368    // For authorization_code grant, verify the code
369    if grant_type == "authorization_code" && code == "dummy-auth-code" {
370        // Return access token WITHOUT refresh token
371        axum::Json(serde_json::json!({
372            "access_token": "mcp-access-token-dummy",
373            "token_type": "Bearer",
374            "expires_in": 3600,
375            "scope": "mcp"
376        }))
377    } else {
378        // Invalid request
379        eprintln!("Invalid token request: grant_type={grant_type}, code={code}");
380        axum::Json(serde_json::json!({
381            "error": "invalid_grant",
382            "error_description": "Invalid authorization code or grant type"
383        }))
384    }
385}
386
387/// OAuth authorize endpoint - redirects back with auth code
388#[cfg(feature = "https-server")]
389async fn oauth_authorize(
390    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
391) -> impl axum::response::IntoResponse {
392    eprintln!("OAuth authorize endpoint called with params: {params:?}");
393
394    // Extract redirect_uri and state from query params
395    let redirect_uri = params
396        .get("redirect_uri")
397        .cloned()
398        .unwrap_or_else(|| "http://localhost:3118/callback".to_string());
399    let state = params.get("state").cloned().unwrap_or_default();
400
401    // Build the callback URL with authorization code
402    let callback_url = format!("{redirect_uri}?code=dummy-auth-code&state={state}");
403
404    // Return HTML with auto-redirect and manual button
405    let html = format!(
406        r#"
407<!DOCTYPE html>
408<html>
409<head>
410    <title>Authorize Codanna</title>
411    <meta charset="utf-8">
412    <style>
413        body {{
414            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
415            display: flex;
416            justify-content: center;
417            align-items: center;
418            height: 100vh;
419            margin: 0;
420            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
421        }}
422        .container {{
423            background: white;
424            padding: 2rem;
425            border-radius: 10px;
426            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
427            text-align: center;
428            max-width: 400px;
429        }}
430        h1 {{
431            color: #333;
432            margin-bottom: 1rem;
433        }}
434        p {{
435            color: #666;
436            margin-bottom: 2rem;
437        }}
438        button {{
439            background: #667eea;
440            color: white;
441            border: none;
442            padding: 12px 30px;
443            border-radius: 5px;
444            font-size: 16px;
445            cursor: pointer;
446            transition: background 0.3s;
447        }}
448        button:hover {{
449            background: #764ba2;
450        }}
451        .spinner {{
452            margin: 20px auto;
453            width: 50px;
454            height: 50px;
455            border: 3px solid #f3f3f3;
456            border-top: 3px solid #667eea;
457            border-radius: 50%;
458            animation: spin 1s linear infinite;
459        }}
460        @keyframes spin {{
461            0% {{ transform: rotate(0deg); }}
462            100% {{ transform: rotate(360deg); }}
463        }}
464    </style>
465    <script>
466        // Auto-redirect after a short delay
467        setTimeout(function() {{
468            window.location.href = "{callback_url}";
469        }}, 1500);
470    </script>
471</head>
472<body>
473    <div class="container">
474        <h1>🔐 Authorize Codanna</h1>
475        <div class="spinner"></div>
476        <p>Authorizing access to Codanna MCP Server...</p>
477        <p>You will be redirected automatically.</p>
478        <button onclick="window.location.href='{callback_url}'">
479            Continue Manually
480        </button>
481    </div>
482</body>
483</html>
484"#
485    );
486
487    axum::response::Html(html)
488}
489
490/// Helper function for shutdown signal
491#[cfg(feature = "https-server")]
492async fn shutdown_signal() {
493    tokio::signal::ctrl_c()
494        .await
495        .expect("failed to listen for ctrl+c");
496    eprintln!("Received shutdown signal");
497}
498
499/// Get or create self-signed certificate for HTTPS
500#[cfg(feature = "https-server")]
501async fn get_or_create_certificate(bind: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
502    use anyhow::Context;
503    use rcgen::generate_simple_self_signed;
504
505    // Determine certificate storage directory
506    let cert_dir = dirs::config_dir()
507        .context("Failed to get config directory")?
508        .join("codanna")
509        .join("certs");
510
511    let cert_path = cert_dir.join("server.pem");
512    let key_path = cert_dir.join("server.key");
513
514    // Create directory if it doesn't exist
515    tokio::fs::create_dir_all(&cert_dir)
516        .await
517        .context("Failed to create certificate directory")?;
518
519    // Check if server certificate already exists
520    if cert_path.exists() && key_path.exists() {
521        eprintln!("Loading existing certificates from {cert_dir:?}");
522        let cert = tokio::fs::read(&cert_path)
523            .await
524            .context("Failed to read certificate file")?;
525        let key = tokio::fs::read(&key_path)
526            .await
527            .context("Failed to read key file")?;
528        return Ok((cert, key));
529    }
530
531    eprintln!("Generating new enhanced self-signed certificate...");
532
533    // Build list of Subject Alternative Names
534    let mut subject_alt_names = vec![
535        "localhost".to_string(),
536        "127.0.0.1".to_string(),
537        "::1".to_string(),
538    ];
539
540    // If binding to 0.0.0.0, include local network IP
541    if bind.starts_with("0.0.0.0") {
542        if let Ok(local_ip) = local_ip_address::local_ip() {
543            eprintln!("Including local network IP in certificate: {local_ip}");
544            subject_alt_names.push(local_ip.to_string());
545        }
546    }
547
548    // Generate certificate using the simpler API but with better parameters
549    let cert = generate_simple_self_signed(subject_alt_names.clone())
550        .context("Failed to generate self-signed certificate")?;
551
552    let cert_pem = cert.cert.pem().into_bytes();
553    let key_pem = cert.signing_key.serialize_pem().into_bytes();
554
555    // Save certificate and key
556    tokio::fs::write(&cert_path, &cert_pem)
557        .await
558        .context("Failed to write server certificate")?;
559    tokio::fs::write(&key_path, &key_pem)
560        .await
561        .context("Failed to write server key")?;
562
563    // Calculate fingerprint
564    use std::collections::hash_map::DefaultHasher;
565    use std::hash::{Hash, Hasher};
566
567    let mut hasher = DefaultHasher::new();
568    cert.cert.der().hash(&mut hasher);
569    let fingerprint = hasher.finish();
570    let fingerprint_hex = format!("{fingerprint:016X}");
571
572    eprintln!();
573    eprintln!("🔐 Certificate Details:");
574    eprintln!("   - Type: Self-Signed TLS Certificate");
575    eprintln!("   - Location: {}", cert_path.display());
576    eprintln!("   - Fingerprint: {fingerprint_hex}");
577    eprintln!("   - Valid for: {}", subject_alt_names.join(", "));
578    eprintln!();
579    eprintln!("🔧 To trust this certificate on macOS:");
580    eprintln!();
581    eprintln!("   Option 1: Command line (requires sudo):");
582    eprintln!(
583        "   sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain {}",
584        cert_path.display()
585    );
586    eprintln!();
587    eprintln!("   Option 2: GUI (recommended):");
588    eprintln!("   1. Open Finder and navigate to: {}", cert_dir.display());
589    eprintln!("   2. Double-click 'server.pem'");
590    eprintln!("   3. Add to 'System' keychain");
591    eprintln!("   4. Set to 'Always Trust' for SSL");
592    eprintln!();
593    eprintln!("   Option 3: Open in browser first:");
594    eprintln!("   1. Visit https://127.0.0.1:8443/health in Safari/Chrome");
595    eprintln!("   2. Click 'Advanced' and proceed anyway");
596    eprintln!("   3. This may help some clients accept the certificate");
597    eprintln!();
598    eprintln!("⚠️  After trusting the certificate, restart Claude Code to reconnect");
599    eprintln!();
600
601    Ok((cert_pem, key_pem))
602}
603
604/// Helper function to detect local IP address
605#[cfg(feature = "https-server")]
606mod local_ip_address {
607    use std::net::{IpAddr, UdpSocket};
608
609    pub fn local_ip() -> Result<IpAddr, Box<dyn std::error::Error>> {
610        // Connect to a dummy address to determine local IP
611        // This doesn't actually send any packets, just determines
612        // which network interface would be used for external traffic
613        let socket = UdpSocket::bind("0.0.0.0:0")?;
614        socket.connect("8.8.8.8:80")?;
615        let addr = socket.local_addr()?;
616        Ok(addr.ip())
617    }
618}
619
620#[cfg(not(feature = "https-server"))]
621pub async fn serve_https(
622    _config: crate::Settings,
623    _watch: bool,
624    _bind: String,
625) -> anyhow::Result<()> {
626    eprintln!("HTTPS server support is not compiled in.");
627    eprintln!("Please rebuild with: cargo build --features https-server");
628    std::process::exit(1);
629}