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}