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