Skip to main content

spreadsheet_mcp/
lib.rs

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