outline_mcp_rs/
lib.rs

1//! # Outline MCP Server
2//!
3//! MCP (Model Context Protocol) server for Outline knowledge base interaction
4//! with focus on simplicity and performance.
5//!
6//! ## Design Principles
7//!
8//! - **Simplicity**: Direct functions instead of complex abstractions
9//! - **Performance**: Static builds and minimal dependencies
10//! - **Elegance**: One file for each area of responsibility
11//!
12//! ## Usage Example
13//!
14//! ```no_run
15//! use outline_mcp_rs::{Config, run_stdio, run_http};
16//!
17//! #[tokio::main]
18//! async fn main() -> outline_mcp_rs::Result<()> {
19//!     let config = Config::from_env()?;
20//!     
21//!     // STDIO mode
22//!     run_stdio(config.clone()).await?;
23//!     
24//!     // Or HTTP mode
25//!     run_http(config).await
26//! }
27//! ```
28
29#![deny(missing_docs)]
30#![deny(unsafe_code)]
31#![warn(clippy::all)]
32#![warn(clippy::pedantic)]
33#![warn(clippy::nursery)]
34#![allow(clippy::module_name_repetitions)]
35
36// Public exports
37pub use config::Config;
38pub use error::{Error, Result};
39
40// Modules
41pub mod cli;
42pub mod config;
43pub mod error;
44mod mcp;
45mod outline;
46mod tools;
47
48/// Run server in STDIO mode
49///
50/// Used for integration with MCP clients through standard input/output streams.
51///
52/// # Errors
53///
54/// Returns error on initialization or request processing problems.
55pub async fn run_stdio(config: Config) -> Result<()> {
56    use std::io::{self, Write};
57    use tracing::{debug, error};
58
59    let stdin = io::stdin();
60    let mut stdout = io::stdout();
61
62    // Initialize Outline API client
63    let outline_client = outline::Client::new(config.outline_api_key, config.outline_api_url)?;
64
65    debug!("✅ STDIO server ready");
66
67    // Main STDIO processing loop
68    loop {
69        let input = {
70            let mut line = String::new();
71            match stdin.read_line(&mut line) {
72                Ok(0) => break, // EOF
73                Ok(_) => line.trim_end().to_string(),
74                Err(e) => {
75                    error!("Error reading STDIN: {}", e);
76                    break;
77                }
78            }
79        };
80
81        if input.trim().is_empty() {
82            continue;
83        }
84
85        // Process JSON-RPC request
86        match mcp::handle_request(&input, &outline_client).await {
87            Ok(Some(response)) => {
88                writeln!(stdout, "{response}")?;
89                stdout.flush()?;
90            }
91            Ok(None) => {
92                // No response needed (notification), just continue
93            }
94            Err(e) => {
95                error!("Error processing request: {}", e);
96                let error_response = mcp::create_error_response(&e);
97                writeln!(stdout, "{error_response}")?;
98                stdout.flush()?;
99            }
100        }
101    }
102
103    Ok(())
104}
105
106/// Run server in HTTP mode
107///
108/// Creates web server on host and port specified in configuration.
109///
110/// # Errors
111///
112/// Returns error if there are problems binding to port or HTTP transport.
113pub async fn run_http(config: Config) -> Result<()> {
114    use tokio::net::TcpListener;
115    use tracing::{debug, error, info};
116
117    let addr = format!("{}:{}", config.http_host, config.http_port.as_u16());
118    let listener = TcpListener::bind(&addr).await?;
119
120    info!("🌐 HTTP server started on {}", addr);
121    info!("📡 Available at /mcp for MCP requests");
122
123    // Initialize Outline API client
124    let outline_client = outline::Client::new(config.outline_api_key, config.outline_api_url)?;
125
126    loop {
127        match listener.accept().await {
128            Ok((stream, addr)) => {
129                debug!("🔗 New connection: {}", addr);
130                let client = outline_client.clone();
131
132                tokio::spawn(async move {
133                    if let Err(e) = handle_http_connection(stream, client).await {
134                        error!("Error handling HTTP connection: {}", e);
135                    }
136                });
137            }
138            Err(e) => {
139                error!("Error accepting connection: {}", e);
140            }
141        }
142    }
143}
144
145/// Handle HTTP connection
146async fn handle_http_connection(
147    mut stream: tokio::net::TcpStream,
148    outline_client: outline::Client,
149) -> Result<()> {
150    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
151
152    let mut reader = BufReader::new(&mut stream);
153    let mut request_line = String::new();
154    reader.read_line(&mut request_line).await?;
155
156    // Simple HTTP handling
157    if request_line.starts_with("POST /mcp") {
158        // Read headers
159        let mut content_length = 0;
160        loop {
161            let mut line = String::new();
162            reader.read_line(&mut line).await?;
163
164            if line.trim().is_empty() {
165                break;
166            }
167
168            if line.to_lowercase().starts_with("content-length:") {
169                if let Some(len_str) = line.split(':').nth(1) {
170                    content_length = len_str.trim().parse().unwrap_or(0);
171                }
172            }
173        }
174
175        // Read request body
176        if content_length > 0 {
177            let mut buffer = vec![0; content_length];
178            tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buffer).await?;
179            let body = String::from_utf8(buffer)?;
180
181            // Process MCP request
182            match mcp::handle_request(&body, &outline_client).await {
183                Ok(Some(response)) => {
184                    let http_response = format!(
185                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
186                        response.len(),
187                        response
188                    );
189                    stream.write_all(http_response.as_bytes()).await?;
190                }
191                Ok(None) => {
192                    // No response needed (notification), send 204 No Content
193                    let http_response = "HTTP/1.1 204 No Content\r\n\r\n";
194                    stream.write_all(http_response.as_bytes()).await?;
195                }
196                Err(e) => {
197                    let error_response = mcp::create_error_response(&e);
198                    let http_response = format!(
199                        "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
200                        error_response.len(),
201                        error_response
202                    );
203                    stream.write_all(http_response.as_bytes()).await?;
204                }
205            }
206        }
207    } else {
208        // 404 for other paths
209        let response = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
210        stream.write_all(response.as_bytes()).await?;
211    }
212
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_config_creation() {
222        let config = Config::for_testing();
223        assert!(config.validate().is_ok());
224    }
225
226    #[test]
227    fn test_error_types() {
228        let _error = Error::Config {
229            message: "test error".to_string(),
230            source: None,
231        };
232
233        // Test that error types work correctly
234        // Test passes if error creation doesn't panic
235    }
236}