spreadsheet_read_mcp/
lib.rs

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