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}