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}