Skip to main content

gsheets_mcp/
lib.rs

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
8//! Google Sheets MCP Server — exposes Sheets API v4 as MCP tools.
9//!
10//! Tier: T3 (μ Mapping + σ Sequence + ∂ Boundary + ς State + π Persistence + ∃ Existence)
11
12pub 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/// MCP server for Google Sheets API v4.
35#[derive(Clone)]
36pub struct GSheetsMcpServer {
37    tool_router: ToolRouter<Self>,
38    client: Arc<SheetsClient>,
39}
40
41#[tool_router]
42impl GSheetsMcpServer {
43    /// Create the server, authenticating with Google on startup.
44    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    // -----------------------------------------------------------------------
53    // Tool: list_sheets
54    // -----------------------------------------------------------------------
55
56    #[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(&params.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    // -----------------------------------------------------------------------
86    // Tool: read_range
87    // -----------------------------------------------------------------------
88
89    #[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(&params.spreadsheet_id, &params.range)
99            .await
100            .map_err(sheets_err)?;
101
102        let formatted = format_value_range(&vr);
103        Ok(text_result(&formatted))
104    }
105
106    // -----------------------------------------------------------------------
107    // Tool: batch_read
108    // -----------------------------------------------------------------------
109
110    #[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(&params.spreadsheet_id, &params.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    // -----------------------------------------------------------------------
133    // Tool: write_range
134    // -----------------------------------------------------------------------
135
136    #[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(&params.spreadsheet_id, &params.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    // -----------------------------------------------------------------------
160    // Tool: append
161    // -----------------------------------------------------------------------
162
163    #[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(&params.spreadsheet_id, &params.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    // -----------------------------------------------------------------------
185    // Tool: metadata
186    // -----------------------------------------------------------------------
187
188    #[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(&params.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    // -----------------------------------------------------------------------
220    // Tool: search
221    // -----------------------------------------------------------------------
222
223    #[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        // If no range specified, get all sheet names and search each.
231        let ranges_to_search = if let Some(ref range) = params.range {
232            vec![range.clone()]
233        } else {
234            // Fetch sheet metadata to get all tab names.
235            let meta = self
236                .client
237                .get_spreadsheet(&params.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(&params.spreadsheet_id, range).await;
251
252            let vr = match vr {
253                Ok(v) => v,
254                Err(e) => {
255                    // Skip tabs that error (e.g., chart-only tabs).
256                    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
288// ---------------------------------------------------------------------------
289// ServerHandler impl (exact pattern from claude-fs-mcp)
290// ---------------------------------------------------------------------------
291
292impl 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
336// ---------------------------------------------------------------------------
337// Helpers
338// ---------------------------------------------------------------------------
339
340/// Format a `ValueRange` as a human-readable table.
341fn 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
359/// Convert a JSON cell value to a display string.
360fn 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
370/// Convert a 0-based column index to A1 notation letter(s).
371/// 0 → A, 1 → B, ..., 25 → Z, 26 → AA, etc.
372fn 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
385/// Convert a `ClientError` to an MCP error.
386fn sheets_err(e: crate::client::ClientError) -> McpError {
387    McpError::new(ErrorCode(500), e.to_string(), None)
388}
389
390/// Shorthand for a text-only `CallToolResult`.
391fn 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}