spreadsheet_read_mcp/
lib.rs1pub 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}