1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![cfg_attr(
4 not(test),
5 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
6)]
7
8pub mod auth;
13pub mod client;
14pub mod types;
15
16use std::sync::Arc;
17
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::tool::ToolCallContext;
20use rmcp::handler::server::wrapper::Parameters;
21use rmcp::model::{
22 CallToolRequestParams, CallToolResult, ErrorCode, Implementation, ListToolsResult,
23 PaginatedRequestParams, ServerCapabilities, ServerInfo,
24};
25use rmcp::service::{RequestContext, RoleServer};
26use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_router};
27use serde_json::json;
28
29use crate::client::SheetsClient;
30use crate::types::{
31 AppendParam, BatchReadParam, ReadRangeParam, SearchParam, SpreadsheetIdParam, WriteRangeParam,
32};
33
34#[derive(Clone)]
36pub struct GSheetsMcpServer {
37 tool_router: ToolRouter<Self>,
38 client: Arc<SheetsClient>,
39}
40
41#[tool_router]
42impl GSheetsMcpServer {
43 pub async fn new() -> Result<Self, nexcore_error::NexError> {
45 let client = SheetsClient::new().await?;
46 Ok(Self {
47 tool_router: Self::tool_router(),
48 client: Arc::new(client),
49 })
50 }
51
52 #[tool(
57 description = "List all sheet tabs in a Google Spreadsheet. Returns tab names, IDs, and indices."
58 )]
59 async fn gsheets_list_sheets(
60 &self,
61 Parameters(params): Parameters<SpreadsheetIdParam>,
62 ) -> Result<CallToolResult, McpError> {
63 let meta = self
64 .client
65 .get_spreadsheet(¶ms.spreadsheet_id)
66 .await
67 .map_err(sheets_err)?;
68
69 let mut lines = Vec::new();
70 lines.push(format!("Spreadsheet: {}", meta.properties.title));
71 lines.push(format!("Tabs ({}): ", meta.sheets.len()));
72 for sheet in &meta.sheets {
73 let p = &sheet.properties;
74 lines.push(format!(
75 " [{}] {} (id={}, type={})",
76 p.index,
77 p.title,
78 p.sheet_id,
79 p.sheet_type.as_deref().unwrap_or("GRID")
80 ));
81 }
82 Ok(text_result(&lines.join("\n")))
83 }
84
85 #[tool(
90 description = "Read a cell range from a Google Spreadsheet. Use A1 notation, e.g. 'Sheet1!A1:C10'."
91 )]
92 async fn gsheets_read_range(
93 &self,
94 Parameters(params): Parameters<ReadRangeParam>,
95 ) -> Result<CallToolResult, McpError> {
96 let vr = self
97 .client
98 .read_range(¶ms.spreadsheet_id, ¶ms.range)
99 .await
100 .map_err(sheets_err)?;
101
102 let formatted = format_value_range(&vr);
103 Ok(text_result(&formatted))
104 }
105
106 #[tool(description = "Read multiple cell ranges in one call. Returns all ranges with headers.")]
111 async fn gsheets_batch_read(
112 &self,
113 Parameters(params): Parameters<BatchReadParam>,
114 ) -> Result<CallToolResult, McpError> {
115 let batch = self
116 .client
117 .batch_read(¶ms.spreadsheet_id, ¶ms.ranges)
118 .await
119 .map_err(sheets_err)?;
120
121 let mut out = Vec::new();
122 for vr in &batch.value_ranges {
123 if let Some(ref range) = vr.range {
124 out.push(format!("--- {range} ---"));
125 }
126 out.push(format_value_range(vr));
127 out.push(String::new());
128 }
129 Ok(text_result(&out.join("\n")))
130 }
131
132 #[tool(
137 description = "Write values to a cell range. Provide a 2D array of strings in row-major order."
138 )]
139 async fn gsheets_write_range(
140 &self,
141 Parameters(params): Parameters<WriteRangeParam>,
142 ) -> Result<CallToolResult, McpError> {
143 let resp = self
144 .client
145 .write_range(¶ms.spreadsheet_id, ¶ms.range, params.values)
146 .await
147 .map_err(sheets_err)?;
148
149 let summary = json!({
150 "updatedRange": resp.updated_range,
151 "updatedRows": resp.updated_rows,
152 "updatedCells": resp.updated_cells,
153 });
154 Ok(text_result(
155 &serde_json::to_string_pretty(&summary).unwrap_or_default(),
156 ))
157 }
158
159 #[tool(description = "Append rows to the end of a table in a spreadsheet.")]
164 async fn gsheets_append(
165 &self,
166 Parameters(params): Parameters<AppendParam>,
167 ) -> Result<CallToolResult, McpError> {
168 let resp = self
169 .client
170 .append_values(¶ms.spreadsheet_id, ¶ms.range, params.values)
171 .await
172 .map_err(sheets_err)?;
173
174 let summary = json!({
175 "tableRange": resp.table_range,
176 "updatedRows": resp.updates.as_ref().and_then(|u| u.updated_rows),
177 "updatedCells": resp.updates.as_ref().and_then(|u| u.updated_cells),
178 });
179 Ok(text_result(
180 &serde_json::to_string_pretty(&summary).unwrap_or_default(),
181 ))
182 }
183
184 #[tool(
189 description = "Get spreadsheet metadata including title, locale, timezone, and sheet list."
190 )]
191 async fn gsheets_metadata(
192 &self,
193 Parameters(params): Parameters<SpreadsheetIdParam>,
194 ) -> Result<CallToolResult, McpError> {
195 let meta = self
196 .client
197 .get_spreadsheet(¶ms.spreadsheet_id)
198 .await
199 .map_err(sheets_err)?;
200
201 let payload = json!({
202 "spreadsheetId": meta.spreadsheet_id,
203 "title": meta.properties.title,
204 "locale": meta.properties.locale,
205 "timeZone": meta.properties.time_zone,
206 "sheetCount": meta.sheets.len(),
207 "sheets": meta.sheets.iter().map(|s| json!({
208 "title": s.properties.title,
209 "sheetId": s.properties.sheet_id,
210 "index": s.properties.index,
211 "type": s.properties.sheet_type,
212 })).collect::<Vec<_>>(),
213 });
214 Ok(text_result(
215 &serde_json::to_string_pretty(&payload).unwrap_or_default(),
216 ))
217 }
218
219 #[tool(
224 description = "Search for a substring across all cells in a range. Returns matching cell locations and values."
225 )]
226 async fn gsheets_search(
227 &self,
228 Parameters(params): Parameters<SearchParam>,
229 ) -> Result<CallToolResult, McpError> {
230 let ranges_to_search = if let Some(ref range) = params.range {
232 vec![range.clone()]
233 } else {
234 let meta = self
236 .client
237 .get_spreadsheet(¶ms.spreadsheet_id)
238 .await
239 .map_err(sheets_err)?;
240 meta.sheets
241 .iter()
242 .map(|s| s.properties.title.clone())
243 .collect()
244 };
245
246 let query_lower = params.query.to_lowercase();
247 let mut matches = Vec::new();
248
249 for range in &ranges_to_search {
250 let vr = self.client.read_range(¶ms.spreadsheet_id, range).await;
251
252 let vr = match vr {
253 Ok(v) => v,
254 Err(e) => {
255 tracing::debug!(range = %range, error = %e, "Skipping range in search");
257 continue;
258 }
259 };
260
261 for (row_idx, row) in vr.values.iter().enumerate() {
262 for (col_idx, cell) in row.iter().enumerate() {
263 let cell_str = cell_to_string(cell);
264 if cell_str.to_lowercase().contains(&query_lower) {
265 let col_letter = col_index_to_letter(col_idx);
266 matches.push(format!("{range}!{col_letter}{}: {}", row_idx + 1, cell_str));
267 }
268 }
269 }
270 }
271
272 if matches.is_empty() {
273 Ok(text_result(&format!(
274 "No matches found for '{}'",
275 params.query
276 )))
277 } else {
278 let header = format!(
279 "Found {} match(es) for '{}':\n",
280 matches.len(),
281 params.query
282 );
283 Ok(text_result(&format!("{header}{}", matches.join("\n"))))
284 }
285 }
286}
287
288impl ServerHandler for GSheetsMcpServer {
293 fn get_info(&self) -> ServerInfo {
294 ServerInfo {
295 instructions: Some(
296 "Google Sheets MCP Server\n\nRead, write, search, and manage Google Spreadsheets via service account auth."
297 .into(),
298 ),
299 capabilities: ServerCapabilities::builder().enable_tools().build(),
300 server_info: Implementation {
301 name: "gsheets-mcp".into(),
302 version: env!("CARGO_PKG_VERSION").into(),
303 title: Some("Google Sheets MCP Server".into()),
304 icons: None,
305 website_url: None,
306 },
307 ..Default::default()
308 }
309 }
310
311 fn call_tool(
312 &self,
313 request: CallToolRequestParams,
314 context: RequestContext<RoleServer>,
315 ) -> impl std::future::Future<Output = Result<CallToolResult, McpError>> + Send + '_ {
316 async move {
317 let tcc = ToolCallContext::new(self, request, context);
318 let result = self.tool_router.call(tcc).await?;
319 Ok(result)
320 }
321 }
322
323 fn list_tools(
324 &self,
325 _request: Option<PaginatedRequestParams>,
326 _context: RequestContext<RoleServer>,
327 ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
328 std::future::ready(Ok(ListToolsResult {
329 tools: self.tool_router.list_all(),
330 meta: None,
331 next_cursor: None,
332 }))
333 }
334}
335
336fn format_value_range(vr: &crate::types::ValueRange) -> String {
342 if vr.values.is_empty() {
343 return "(empty range)".to_string();
344 }
345
346 let mut lines = Vec::new();
347 if let Some(ref range) = vr.range {
348 lines.push(format!("Range: {range}"));
349 }
350 lines.push(format!("Rows: {}", vr.values.len()));
351
352 for (i, row) in vr.values.iter().enumerate() {
353 let cells: Vec<String> = row.iter().map(cell_to_string).collect();
354 lines.push(format!(" [{}] {}", i + 1, cells.join(" | ")));
355 }
356 lines.join("\n")
357}
358
359fn cell_to_string(v: &serde_json::Value) -> String {
361 match v {
362 serde_json::Value::String(s) => s.clone(),
363 serde_json::Value::Number(n) => n.to_string(),
364 serde_json::Value::Bool(b) => b.to_string(),
365 serde_json::Value::Null => String::new(),
366 other => other.to_string(),
367 }
368}
369
370fn col_index_to_letter(idx: usize) -> String {
373 let mut result = String::new();
374 let mut n = idx;
375 loop {
376 result.insert(0, (b'A' + (n % 26) as u8) as char);
377 if n < 26 {
378 break;
379 }
380 n = n / 26 - 1;
381 }
382 result
383}
384
385fn sheets_err(e: crate::client::ClientError) -> McpError {
387 McpError::new(ErrorCode(500), e.to_string(), None)
388}
389
390fn text_result(s: &str) -> CallToolResult {
392 CallToolResult::success(vec![rmcp::model::Content::text(s)])
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn col_index_to_letter_basic() {
401 assert_eq!(col_index_to_letter(0), "A");
402 assert_eq!(col_index_to_letter(1), "B");
403 assert_eq!(col_index_to_letter(25), "Z");
404 assert_eq!(col_index_to_letter(26), "AA");
405 assert_eq!(col_index_to_letter(27), "AB");
406 assert_eq!(col_index_to_letter(51), "AZ");
407 assert_eq!(col_index_to_letter(52), "BA");
408 assert_eq!(col_index_to_letter(701), "ZZ");
409 assert_eq!(col_index_to_letter(702), "AAA");
410 }
411
412 #[test]
413 fn cell_to_string_variants() {
414 assert_eq!(
415 cell_to_string(&serde_json::Value::String("hello".into())),
416 "hello"
417 );
418 assert_eq!(cell_to_string(&serde_json::json!(42)), "42");
419 assert_eq!(cell_to_string(&serde_json::json!(true)), "true");
420 assert_eq!(cell_to_string(&serde_json::Value::Null), "");
421 }
422
423 #[test]
424 fn format_empty_value_range() {
425 let vr = crate::types::ValueRange {
426 range: Some("Sheet1!A1:B2".into()),
427 major_dimension: None,
428 values: vec![],
429 };
430 assert_eq!(format_value_range(&vr), "(empty range)");
431 }
432
433 #[test]
434 fn format_value_range_with_data() {
435 let vr = crate::types::ValueRange {
436 range: Some("Sheet1!A1:B2".into()),
437 major_dimension: None,
438 values: vec![
439 vec![serde_json::json!("Name"), serde_json::json!("Score")],
440 vec![serde_json::json!("Alice"), serde_json::json!(95)],
441 ],
442 };
443 let formatted = format_value_range(&vr);
444 assert!(formatted.contains("Range: Sheet1!A1:B2"));
445 assert!(formatted.contains("Rows: 2"));
446 assert!(formatted.contains("Name | Score"));
447 assert!(formatted.contains("Alice | 95"));
448 }
449}