spreadsheet_mcp/
lib.rs

1pub mod analysis;
2pub mod caps;
3pub mod config;
4#[cfg(feature = "recalc")]
5pub mod diff;
6#[cfg(feature = "recalc")]
7pub mod fork;
8pub mod formula;
9pub mod model;
10#[cfg(feature = "recalc")]
11pub mod recalc;
12pub mod server;
13pub mod state;
14pub mod styles;
15pub mod tools;
16pub mod utils;
17pub mod workbook;
18
19pub use config::{CliArgs, ServerConfig, TransportKind};
20pub use server::SpreadsheetServer;
21
22use anyhow::Result;
23use axum::Router;
24use model::WorkbookListResponse;
25use rmcp::transport::streamable_http_server::{
26    StreamableHttpService, session::local::LocalSessionManager,
27};
28use state::AppState;
29use std::{future::IntoFuture, sync::Arc};
30use tokio::{
31    net::TcpListener,
32    time::{Duration, timeout},
33};
34use tools::filters::WorkbookFilter;
35
36const HTTP_SERVICE_PATH: &str = "/mcp";
37
38pub async fn run_server(config: ServerConfig) -> Result<()> {
39    let config = Arc::new(config);
40    config.ensure_workspace_root()?;
41    let state = Arc::new(AppState::new(config.clone()));
42
43    tracing::info!(
44        transport = %config.transport,
45        workspace = %config.workspace_root.display(),
46        "starting spreadsheet MCP server",
47    );
48
49    match startup_scan(&state) {
50        Ok(response) => {
51            let count = response.workbooks.len();
52            if count == 0 {
53                tracing::info!("startup scan complete: no workbooks discovered");
54            } else {
55                let sample = response
56                    .workbooks
57                    .iter()
58                    .take(3)
59                    .map(|descriptor| descriptor.path.as_str())
60                    .collect::<Vec<_>>()
61                    .join(", ");
62                tracing::info!(
63                    workbook_count = count,
64                    sample = %sample,
65                    "startup scan discovered workbooks"
66                );
67            }
68        }
69        Err(error) => {
70            tracing::warn!(?error, "startup scan failed");
71        }
72    }
73
74    match config.transport {
75        TransportKind::Stdio => {
76            let server = SpreadsheetServer::from_state(state);
77            server.run_stdio().await
78        }
79        TransportKind::Http => run_stream_http_transport(config, state).await,
80    }
81}
82
83async fn run_stream_http_transport(config: Arc<ServerConfig>, state: Arc<AppState>) -> Result<()> {
84    let bind_addr = config.http_bind_address;
85    let service_state = state.clone();
86    let service = StreamableHttpService::new(
87        move || Ok(SpreadsheetServer::from_state(service_state.clone())),
88        LocalSessionManager::default().into(),
89        Default::default(),
90    );
91
92    let router = Router::new().nest_service(HTTP_SERVICE_PATH, service);
93    let listener = TcpListener::bind(bind_addr).await?;
94    let actual_addr = listener.local_addr()?;
95    tracing::info!(transport = "http", bind = %actual_addr, path = HTTP_SERVICE_PATH, "listening" );
96
97    let server_future = axum::serve(listener, router).into_future();
98    tokio::pin!(server_future);
99
100    tokio::select! {
101        result = server_future.as_mut() => {
102            tracing::info!("http transport stopped");
103            result.map_err(anyhow::Error::from)?;
104            return Ok(());
105        }
106        ctrl = tokio::signal::ctrl_c() => {
107            match ctrl {
108                Ok(_) => tracing::info!("shutdown signal received"),
109                Err(error) => tracing::warn!(?error, "ctrl_c listener exited unexpectedly"),
110            };
111        }
112    }
113
114    if timeout(Duration::from_secs(5), server_future.as_mut())
115        .await
116        .is_err()
117    {
118        tracing::warn!("forcing http transport shutdown after timeout");
119        return Ok(());
120    }
121
122    server_future.as_mut().await.map_err(anyhow::Error::from)?;
123    tracing::info!("http transport stopped");
124    Ok(())
125}
126
127pub fn startup_scan(state: &Arc<AppState>) -> Result<WorkbookListResponse> {
128    state.list_workbooks(WorkbookFilter::default())
129}