# Directory Structure ``` ├── .env.sample ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── auth.rs ├── client.rs ├── lib.rs ├── logging.rs ├── main.rs ├── servers │ ├── drive.rs │ ├── mod.rs │ └── sheets.rs └── tests ├── drive.rs ├── mod.rs └── sheets.rs ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` /target .env ``` -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- ``` GOOGLE_ACCESS_TOKEN=sdfssdfdssf GOOGLE_REFRESH_TOKEN=sdfssdfdssf GOOGLE_CLIENT_ID=sdfssdfdssf GOOGLE_CLIENT_SECRET=sdfssdfdssf TEST_SPREADSHEET_ID="1yO2ZVWb-EEhv-xxxxxxx" ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Google Drive & Sheets MCP Server A Model Context Protocol (MCP) server built in Rust for interacting with Google Drive and Google Sheets. This tool provides MCP-compatible interfaces for Google Workspace services, allowing it to be used as part of larger agent workflows and compositions. ## Features ### Google Drive Operations - List files in Google Drive with filtering options: - Filter by MIME type - Custom search queries - Configurable page size - Custom ordering ### Google Sheets Operations - Read data from Google Sheets with options: - Specify range - Choose major dimension (ROWS or COLUMNS) - Write data to Google Sheets - Create new spreadsheets with: - Custom title - Multiple sheets - Clear values from ranges in spreadsheets ## MCP Integration This server implements the Model Context Protocol (MCP), making it compatible with agent frameworks like [Distri](https://github.com/distrihub/distri). Each service exposes its capabilities as MCP tools: ### Drive Tools - `list_files`: List and filter Drive files with customizable parameters - Available capabilities exposed via `resources/list` endpoint ### Sheets Tools - `read_values`: Read spreadsheet data with dimension control - `write_values`: Write data to spreadsheets - `create_spreadsheet`: Create new spreadsheets - `clear_values`: Clear ranges in spreadsheets - Available capabilities exposed via `resources/list` endpoint ## Prerequisites - Rust (latest stable version) - Google Cloud Project with Drive and Sheets APIs enabled - OAuth 2.0 credentials configured for your Google Cloud Project ## Installation Install the `mcp-google` binary directly from GitHub using Cargo: ```bash cargo install --git https://github.com/distrihub/mcp-google-workspace.git ``` This will install the `mcp-google` command to your system. ## Configuration Before using the server, you need to: 1. Set up a Google Cloud Project 2. Enable Google Drive and Google Sheets APIss 3. Create OAuth 2.0 credentials 4. Set up your environment variables: - `ACCESS_TOKEN`: Your Google OAuth access token - `GOOGLE_CLIENT_ID`: Your OAuth client ID - `GOOGLE_CLIENT_SECRET`: Your OAuth client secret - `GOOGLE_REFRESH_TOKEN`: Your OAuth refresh token ## Usage ### As MCP Server The servers can be started independently and will communicate using the MCP protocol over stdio: Start the Drive MCP server: ```bash mcp-google drive --access-token <your-access-token> ``` Start the Sheets MCP server: ```bash mcp-google sheets --access-token <your-access-token> ``` ### Using with Distri This server can be used as part of a Distri agent configuration: ```yaml agents: google_workspace: drive: type: mcp command: ["mcp-google", "drive", "--access-token", "${ACCESS_TOKEN}"] sheets: type: mcp command: ["mcp-google", "sheets", "--access-token", "${ACCESS_TOKEN}"] ``` ### Token Management Refresh your OAuth token: ```bash mcp-google refresh \ --client-id <your-client-id> \ --client-secret <your-client-secret> \ --refresh-token <your-refresh-token> ``` ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## Note This is an MCP-compatible server that interacts with Google services. Make sure you have appropriate permissions and credentials before using the tool. ``` -------------------------------------------------------------------------------- /src/servers/mod.rs: -------------------------------------------------------------------------------- ```rust pub mod drive; pub mod sheets; ``` -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- ```rust pub mod drive; pub mod sheets; ``` -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- ```rust mod auth; pub mod client; pub mod logging; pub mod servers; #[cfg(test)] mod tests; // Re-export servers pub use auth::GoogleAuthService; use thiserror::Error; #[derive(Debug, Error)] pub enum InvokeError { #[error("Serde error: {0}")] Serde(#[from] serde_json::Error), #[error("Environment variable missing: {0}")] EnvVarMissing(String), #[error("Google API error: {0}")] GoogleApi(String), #[error("Token parse error: {0}")] TokenParse(String), #[error("User info error: {0}")] UserInfo(String), #[error("JWT error: {0}")] Jwt(String), } ``` -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- ```toml [package] name = "mcp-google-workspace" version = "0.1.0" edition = "2021" [dependencies] async-mcp = { version = "0.1.0" } tokio = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" async-trait = "0.1" tracing-subscriber = { version = "0.3.0", features = [ "env-filter", "tracing-log", ] } tracing = "0.1" url = "2.5.4" google-sheets4 = { version = "6.0", features = ["default"] } google-drive3 = "6.0.0" clap = { version = "4.4", features = ["derive", "env"] } reqwest = { version = "0.12.2", default-features = false, features = [ "json", "stream", ] } chrono = { version = "0.4", features = ["serde"] } thiserror = "2.0.11" rand = "0.8" base64 = "0.21" urlencoding = "2.1.0" [dev-dependencies] dotenv = "0.15" [[bin]] name = "mcp-google" path = "src/main.rs" ``` -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- ```rust use tracing_subscriber::{filter::FilterFn, prelude::*, EnvFilter}; /// Initialize logging with sensible defaults for the agents library. /// This will: /// - Set up logging with the specified log level /// - Filter out noisy logs from dependencies like hyper /// - Format logs in a human-readable format pub fn init_logging(level: &str) { let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(level)) // Filter out noisy hyper logs .add_directive("hyper=off".parse().unwrap()) .add_directive("rustyline=off".parse().unwrap()) .add_directive("h2=off".parse().unwrap()) .add_directive("rustls=off".parse().unwrap()); // Only show our crate's logs and any errors from other crates let _crate_filter = FilterFn::new(|metadata| { metadata.target().starts_with("agents") || metadata.target().starts_with("app") || metadata.level() <= &tracing::Level::ERROR }); tracing_subscriber::registry() .with(tracing_subscriber::fmt::layer().with_filter(filter)) // .with(filter) .init(); } ``` -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- ```rust use google_drive3::DriveHub; use google_sheets4::Sheets; pub fn get_drive_client( access_token: &str, ) -> DriveHub< google_drive3::hyper_rustls::HttpsConnector< google_drive3::hyper_util::client::legacy::connect::HttpConnector, >, > { let hub = DriveHub::new( google_drive3::hyper_util::client::legacy::Client::builder( google_drive3::hyper_util::rt::TokioExecutor::new(), ) .build( google_sheets4::hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() .unwrap() .https_or_http() .enable_http1() .build(), ), access_token.to_string(), ); hub } pub fn get_sheets_client( access_token: &str, ) -> Sheets< google_sheets4::hyper_rustls::HttpsConnector< google_sheets4::hyper_util::client::legacy::connect::HttpConnector, >, > { let hub = Sheets::new( google_sheets4::hyper_util::client::legacy::Client::builder( google_sheets4::hyper_util::rt::TokioExecutor::new(), ) .build( google_sheets4::hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() .unwrap() .https_or_http() .enable_http1() .build(), ), access_token.to_string(), ); hub } ``` -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- ```rust use anyhow::Result; use async_mcp::transport::ServerStdioTransport; use clap::{Parser, Subcommand}; use mcp_google_workspace::{ logging::init_logging, servers::{drive, sheets}, GoogleAuthService, }; #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Start the Google Drive server Drive, /// Start the Google Sheets server Sheets, Refresh { /// Google OAuth client ID #[arg(long, env = "GOOGLE_CLIENT_ID")] client_id: String, /// Google OAuth client secret #[arg(long, env = "GOOGLE_CLIENT_SECRET")] client_secret: String, /// Refresh token #[arg(long, env = "GOOGLE_REFRESH_TOKEN")] refresh_token: String, }, } #[tokio::main] async fn main() -> Result<()> { init_logging("debug"); let cli = Cli::parse(); match cli.command { Commands::Drive => { let server = drive::build(ServerStdioTransport)?; let server_handle = tokio::spawn(async move { server.listen().await }); server_handle .await? .map_err(|e| anyhow::anyhow!("Drive server error: {:#?}", e))?; } Commands::Sheets => { let server = sheets::build(ServerStdioTransport)?; let server_handle = tokio::spawn(async move { server.listen().await }); server_handle .await? .map_err(|e| anyhow::anyhow!("Sheets server error: {:#?}", e))?; } Commands::Refresh { client_id, client_secret, refresh_token, } => { let auth_service = GoogleAuthService::new(client_id, client_secret).unwrap(); let token_response = auth_service.refresh_token(&refresh_token).await.unwrap(); println!("Token response: {:#?}", token_response); } } Ok(()) } ``` -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- ```rust use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::debug; use crate::InvokeError; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct TokenResponse { pub access_token: String, pub expires_in: i32, pub refresh_token: Option<String>, pub scope: String, pub token_type: String, } #[derive(Clone)] pub struct GoogleAuthService { pub client: Client, pub google_client_id: String, pub google_client_secret: String, } impl Default for GoogleAuthService { fn default() -> Self { let google_client_id = std::env::var("GOOGLE_CLIENT_ID") .map_err(|_| InvokeError::EnvVarMissing("GOOGLE_CLIENT_ID".to_string())) .unwrap(); let google_client_secret = std::env::var("GOOGLE_CLIENT_SECRET") .map_err(|_| InvokeError::EnvVarMissing("GOOGLE_CLIENT_SECRET".to_string())) .unwrap(); Self::new(google_client_id, google_client_secret).unwrap() } } impl GoogleAuthService { pub fn new(client_id: String, client_secret: String) -> Result<Self, InvokeError> { Ok(Self { client: Client::new(), google_client_id: client_id, google_client_secret: client_secret, }) } pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse, InvokeError> { let payload = json!({ "client_id": self.google_client_id, "client_secret": self.google_client_secret, "refresh_token": refresh_token, "grant_type": "refresh_token" }); self.exchange_token(&payload).await } async fn exchange_token( &self, payload: &serde_json::Value, ) -> Result<TokenResponse, InvokeError> { debug!("Token exchange payload: {:?}", payload); let response = self .client .post("https://oauth2.googleapis.com/token") .json(payload) .send() .await .map_err(|e| InvokeError::GoogleApi(e.to_string()))?; if !response.status().is_success() { let error = response .text() .await .unwrap_or_else(|_| "Unknown error".to_string()); return Err(InvokeError::GoogleApi(error)); } response .json::<TokenResponse>() .await .map_err(|e| InvokeError::TokenParse(e.to_string())) } } ``` -------------------------------------------------------------------------------- /src/tests/drive.rs: -------------------------------------------------------------------------------- ```rust use crate::{client::get_drive_client, logging::init_logging, servers::drive}; use async_mcp::{ protocol::RequestOptions, transport::{ClientInMemoryTransport, ServerInMemoryTransport, Transport}, types::CallToolRequest, }; use dotenv::dotenv; use serde_json::json; use std::{collections::HashMap, env, time::Duration}; async fn async_drive_server(transport: ServerInMemoryTransport) { let server = drive::build(transport).unwrap(); server.listen().await.unwrap(); } #[tokio::test] async fn test_drive_operations() -> anyhow::Result<()> { dotenv::dotenv().ok(); let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); let client_transport = ClientInMemoryTransport::new(move |t| { tokio::spawn(async move { async_drive_server(t).await }) }); client_transport.open().await?; let client = async_mcp::client::ClientBuilder::new(client_transport.clone()).build(); let client_clone = client.clone(); let _client_handle = tokio::spawn(async move { client_clone.start().await }); let params = CallToolRequest { name: "list_files".to_string(), arguments: Some(HashMap::from([ ( "mime_type".to_string(), "application/vnd.google-apps.folder".to_string().into(), ), ("page_size".to_string(), 5.into()), ])), meta: Some(json!({ "access_token": access_token })), }; // Test list files let response = client .request( "list_files", Some(serde_json::to_value(¶ms).unwrap()), RequestOptions::default().timeout(Duration::from_secs(5)), ) .await?; println!("List files response:\n{response}"); Ok(()) } #[tokio::test] async fn test_list_spreadsheets() -> Result<(), Box<dyn std::error::Error>> { init_logging("debug"); dotenv().ok(); let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); let drive = get_drive_client(&access_token); // Add more detailed query parameters and debug output let result = drive .files() .list() .q("mimeType='application/vnd.google-apps.spreadsheet'") .order_by("modifiedTime desc") .page_size(10) // Limit to 10 results for testing .doit() .await?; if let Some(files) = result.1.files { for file in files { println!( "Spreadsheet: {} (ID: {})", file.name.unwrap_or_default(), file.id.unwrap_or_default() ); println!( "Last modified: {:?}", file.modified_time.unwrap_or_default() ); println!("-------------------"); } } Ok(()) } ``` -------------------------------------------------------------------------------- /src/servers/drive.rs: -------------------------------------------------------------------------------- ```rust use anyhow::Result; use async_mcp::{ server::Server, transport::Transport, types::{ CallToolRequest, CallToolResponse, ListRequest, Resource, ResourcesListResponse, ServerCapabilities, Tool, ToolResponseContent, }, }; use serde_json::json; use url::Url; use crate::client::get_drive_client; fn get_access_token(req: &CallToolRequest) -> Result<&str> { req.meta .as_ref() .and_then(|v| v.get("access_token")) .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing or invalid access_token")) } pub fn build<T: Transport>(transport: T) -> Result<Server<T>> { let mut server = Server::builder(transport) .capabilities(ServerCapabilities { tools: Some(json!({ "drive": { "version": "v3", "description": "Google Drive API operations" } })), ..Default::default() }) .request_handler("resources/list", |_req: ListRequest| { Box::pin(async move { Ok(list_drive_resources()) }) }); // List files server.register_tool( Tool { name: "list_files".to_string(), description: Some("List files in Google Drive with filters".to_string()), input_schema: json!({ "type": "object", "properties": { "mime_type": {"type": "string"}, "query": {"type": "string"}, "page_size": {"type": "integer", "default": 10}, "order_by": {"type": "string", "default": "modifiedTime desc"} } }), }, move |req: CallToolRequest| { Box::pin(async move { let access_token = get_access_token(&req)?; let args = req.arguments.clone().unwrap_or_default(); let result = async { let drive = get_drive_client(access_token); let mut query = String::new(); if let Some(mime_type) = args.get("mime_type").and_then(|v| v.as_str()) { query.push_str(&format!("mimeType='{}'", mime_type)); } let result = drive .files() .list() .q(&query) .page_size( args.get("page_size").and_then(|v| v.as_u64()).unwrap_or(10) as i32 ) .order_by( args.get("order_by") .and_then(|v| v.as_str()) .unwrap_or("modifiedTime desc"), ) .doit() .await?; Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: serde_json::to_string(&result.1)?, }], is_error: None, meta: None, }) } .await; handle_result(result) }) }, ); Ok(server.build()) } fn list_drive_resources() -> ResourcesListResponse { let base = Url::parse("https://www.googleapis.com/drive/v3/").unwrap(); ResourcesListResponse { resources: vec![Resource { uri: base, name: "drive".to_string(), description: Some("Google Drive API".to_string()), mime_type: Some("application/json".to_string()), }], next_cursor: None, meta: None, } } fn handle_result(result: Result<CallToolResponse>) -> Result<CallToolResponse> { match result { Ok(response) => Ok(response), Err(e) => Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: format!("Error: {}", e), }], is_error: Some(true), meta: None, }), } } ``` -------------------------------------------------------------------------------- /src/tests/sheets.rs: -------------------------------------------------------------------------------- ```rust use crate::{ client::{get_drive_client, get_sheets_client}, servers::sheets, }; use async_mcp::{ protocol::RequestOptions, transport::{ClientInMemoryTransport, ServerInMemoryTransport, Transport}, types::CallToolRequest, }; use dotenv::dotenv; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{collections::HashMap, env, time::Duration}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct Sheet { a1_notation: String, sheet_id: u64, sheet_name: String, } async fn async_sheets_server(transport: ServerInMemoryTransport) { println!("Starting sheets server..."); let server = sheets::build(transport).unwrap(); println!("Server built successfully"); server.listen().await.unwrap(); } #[tokio::test] async fn test_sheets_operations() -> anyhow::Result<()> { dotenv::dotenv().ok(); let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); let spreadsheet_id = env::var("TEST_SPREADSHEET_ID").unwrap(); let client_transport = ClientInMemoryTransport::new(move |t| { tokio::spawn(async move { async_sheets_server(t).await }) }); client_transport.open().await?; let client = async_mcp::client::ClientBuilder::new(client_transport.clone()).build(); let client_clone = client.clone(); let _client_handle = tokio::spawn(async move { client_clone.start().await }); // Add a small delay to ensure server is ready tokio::time::sleep(std::time::Duration::from_millis(100)).await; let params = CallToolRequest { name: "read_values".to_string(), arguments: Some(HashMap::new()), meta: Some(json!({ "access_token": access_token, "spreadsheet_id": spreadsheet_id, "sheet": "Sheet6" })), }; // Test read values let response = client .request( "tools/call", Some(serde_json::to_value(¶ms).unwrap()), RequestOptions::default().timeout(Duration::from_secs(5)), ) .await?; // Add better error handling let response_obj: serde_json::Value = serde_json::from_str(&response.to_string())?; if let Some(error) = response_obj.get("error") { println!("Error reading sheet: {}", error); anyhow::bail!("Failed to read sheet: {}", error); } println!("Read values response:\n{response}"); Ok(()) } #[tokio::test] async fn test_google_sheets() -> Result<(), Box<dyn std::error::Error>> { dotenv().ok(); // let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); // let auth_service = GoogleAuthService::new( // env::var("GOOGLE_CLIENT_ID").unwrap(), // env::var("GOOGLE_CLIENT_SECRET").unwrap(), // )?; // let token_response = auth_service.refresh_token(&access_token).await?; // println!("Access token: {:?}", token_response); // let access_token = token_response.access_token; let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); let sheets = get_sheets_client(&access_token); let spreadsheet_id = env::var("TEST_SPREADSHEET_ID").unwrap(); // Try to read the spreadsheet let result = sheets.spreadsheets().get(&spreadsheet_id).doit().await?; // Extract sheet names and ranges if let Some(sheets) = result.1.sheets { for sheet in sheets { if let Some(properties) = sheet.properties { let sheet_title = properties.title.unwrap_or_default(); let grid_props = properties.grid_properties.unwrap_or_default(); let row_count = grid_props.row_count.unwrap_or(0); let column_count = grid_props.column_count.unwrap_or(0); println!( "Sheet: {}\nRange: {}!A1:{}{}\n", sheet_title, sheet_title, (b'A' + (column_count as u8) - 1) as char, row_count ); } } } else { println!("No sheets found."); } Ok(()) } #[tokio::test] async fn test_list_spreadsheet_details() -> Result<(), Box<dyn std::error::Error>> { dotenv().ok(); let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); let drive = get_drive_client(&access_token); let sheets = get_sheets_client(&access_token); let result = drive .files() .list() .q("mimeType='application/vnd.google-apps.spreadsheet'") .order_by("modifiedTime desc") .page_size(10) .doit() .await?; if let Some(files) = result.1.files { for file in files { let id = file.id.clone().unwrap_or_default(); println!( "Spreadsheet: {} (ID: {})", file.name.unwrap_or_default(), id ); // Get the content of each spreadsheet let spreadsheet = sheets.spreadsheets().get(&id).doit().await?; println!("Sheets in this spreadsheet:"); for sheet in spreadsheet.1.sheets.unwrap_or_default() { if let Some(props) = sheet.properties { println!("- Sheet name: {}", props.title.unwrap_or_default()); } } println!("-------------------"); } } Ok(()) } #[tokio::test] async fn test_sheet_operations() -> anyhow::Result<()> { dotenv().ok(); let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); let spreadsheet_id = env::var("TEST_SPREADSHEET_ID").unwrap(); let client_transport = ClientInMemoryTransport::new(move |t| { tokio::spawn(async move { async_sheets_server(t).await }) }); client_transport.open().await?; let client = async_mcp::client::ClientBuilder::new(client_transport.clone()).build(); let client_clone = client.clone(); let _client_handle = tokio::spawn(async move { client_clone.start().await }); // Add a small delay to ensure server is ready tokio::time::sleep(Duration::from_millis(100)).await; // First get sheet info let get_info_params = CallToolRequest { name: "get_sheet_info".to_string(), arguments: None, meta: Some(json!({ "access_token": access_token, "spreadsheet_id": spreadsheet_id, })), }; let info_response = client .request( "tools/call", Some(serde_json::to_value(&get_info_params).unwrap()), RequestOptions::default().timeout(Duration::from_secs(5)), ) .await?; println!("Sheet info:\n{}", info_response); // Read the current value from A1 let read_params = CallToolRequest { name: "read_values".to_string(), arguments: None, meta: Some(json!({ "access_token": access_token, "spreadsheet_id": spreadsheet_id, "sheet": "Sheet1", "range": "A1" })), }; let read_response = client .request( "tools/call", Some(serde_json::to_value(&read_params).unwrap()), RequestOptions::default().timeout(Duration::from_secs(5)), ) .await?; // After read_response println!("Initial read response:\n{}", read_response); // Parse the current value and increment it let read_value = serde_json::from_str::<serde_json::Value>(&read_response.to_string())?; println!("Parsed read value: {:?}", read_value); let current_value = read_value["content"][0]["text"] .as_str() .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()) .and_then(|v| v["values"][0][0].as_str().map(String::from)) .and_then(|s| s.parse::<i32>().ok()) .unwrap_or(0); let new_value = current_value + 1; // Write the incremented value back let mut args = HashMap::new(); args.insert("values".to_string(), json!([[new_value.to_string()]])); args.insert("range".to_string(), json!("A1")); let write_params = CallToolRequest { name: "write_values".to_string(), arguments: Some(args), meta: Some(json!({ "access_token": access_token, "spreadsheet_id": spreadsheet_id, "sheet": "Sheet1" })), }; let write_response = client .request( "tools/call", Some(serde_json::to_value(&write_params).unwrap()), RequestOptions::default().timeout(Duration::from_secs(5)), ) .await?; println!("Write response:\n{}", write_response); // Verify the new value let verify_response = client .request( "tools/call", Some(serde_json::to_value(&read_params).unwrap()), RequestOptions::default().timeout(Duration::from_secs(5)), ) .await?; // After verify_response println!("Verify response:\n{}", verify_response); let verify_value = serde_json::from_str::<serde_json::Value>(&verify_response.to_string())?; println!("Parsed verify value: {:?}", verify_value); let updated_value = verify_value["content"][0]["text"] .as_str() .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()) .and_then(|v| v["values"][0][0].as_str().map(String::from)) .and_then(|s| s.parse::<i32>().ok()) .unwrap_or(0); assert_eq!( updated_value, new_value, "Value was not updated correctly. Expected {}, got {}", new_value, updated_value ); println!( "Successfully incremented value from {} to {}", current_value, new_value ); Ok(()) } ``` -------------------------------------------------------------------------------- /src/servers/sheets.rs: -------------------------------------------------------------------------------- ```rust use anyhow::{Context, Result}; use async_mcp::{ server::{Server, ServerBuilder}, transport::Transport, types::{ CallToolRequest, CallToolResponse, ListRequest, Resource, ResourcesListResponse, ServerCapabilities, Tool, ToolResponseContent, }, }; use serde_json::json; use url::Url; use crate::client::get_sheets_client; fn get_access_token(req: &CallToolRequest) -> Result<&str> { req.meta .as_ref() .and_then(|v| v.get("access_token")) .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing or invalid access_token")) } pub fn build<T: Transport>(transport: T) -> Result<Server<T>> { let mut server = Server::builder(transport) .capabilities(ServerCapabilities { tools: Some(json!({ "sheets": { "version": "v4", "description": "Google Sheets API operations" } })), ..Default::default() }) .request_handler("resources/list", |_req: ListRequest| { Box::pin(async move { Ok(list_sheets_resources()) }) }); register_tools(&mut server)?; Ok(server.build()) } fn register_tools<T: Transport>(server: &mut ServerBuilder<T>) -> Result<()> { // Tool Definitions let read_values_tool = Tool { name: "read_values".to_string(), description: Some("Read values from a Google Sheet".to_string()), input_schema: json!({ "type": "object", "properties": { "sheet": {"type": "string", "description": "Sheet name"}, "range": {"type": "string", "description": "Range to read (e.g. 'A1:B2')", "default": "A1:ZZ"}, "major_dimension": {"type": "string", "enum": ["ROWS", "COLUMNS"], "default": "ROWS"} }, "required": ["sheet"] }), }; let write_values_tool = Tool { name: "write_values".to_string(), description: Some("Write values to a Google Sheet".to_string()), input_schema: json!({ "type": "object", "properties": { "sheet": {"type": "string", "description": "Sheet name"}, "range": {"type": "string", "description": "Range to write to (e.g. 'A1:B2')"}, "values": { "description": "2D array of values to write", "type": "array", "items": { "type": "array", "items": { "type": ["string", "number", "boolean", "null"], "description": "A single cell value" } } }, "major_dimension": {"type": "string", "enum": ["ROWS", "COLUMNS"], "default": "ROWS"} }, "required": ["values", "range", "sheet"] }), }; let create_spreadsheet_tool = Tool { name: "create_spreadsheet".to_string(), description: Some("Create a new Google Sheet".to_string()), input_schema: json!({ "type": "object", "properties": { "title": {"type": "string"}, "sheets": { "type": "array", "items": { "type": "object", "properties": { "title": {"type": "string"} } } } }, "required": ["title"] }), }; let clear_values_tool = Tool { name: "clear_values".to_string(), description: Some("Clear values from a range in a Google Sheet".to_string()), input_schema: json!({ "type": "object", "properties": { "sheet": {"type": "string", "description": "Sheet name", "default": "Sheet1"}, "range": {"type": "string", "description": "Range to clear (e.g. 'A1:B2')", "default": "A1:ZZ"} }, "required": ["sheet", "range"] }), }; let get_sheet_info_tool = Tool { name: "get_sheet_info".to_string(), description: Some("Get information about all sheets in a spreadsheet, including their titles and maximum ranges (e.g. 'A1:Z1000'). This is useful for discovering what sheets exist and their dimensions.".to_string()), input_schema: json!({ "type": "object", "properties": {}, "required": [] }), }; // Tool Implementations server.register_tool(read_values_tool, move |req: CallToolRequest| { Box::pin(async move { let access_token = get_access_token(&req)?; let args = req.arguments.clone().unwrap_or_default(); let context = req.meta.clone().unwrap_or_default(); let result = async { let sheets = get_sheets_client(access_token); let spreadsheet_id = context .get("spreadsheet_id") .and_then(|v| v.as_str()) .context("spreadsheet_id required in context")?; let sheet = args["sheet"].as_str().context("sheet name required")?; let user_range = args["range"].as_str().unwrap_or("A1:ZZ"); let range = format!("{}!{}", sheet, user_range); let major_dimension = args .get("major_dimension") .and_then(|v| v.as_str()) .unwrap_or("ROWS"); let result = sheets .spreadsheets() .values_get(spreadsheet_id, &range) .major_dimension(major_dimension) .doit() .await?; Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: serde_json::to_string(&result.1)?, }], is_error: None, meta: None, }) } .await; handle_result(result) }) }); server.register_tool(write_values_tool, move |req: CallToolRequest| { Box::pin(async move { let access_token = get_access_token(&req)?; let args = req.arguments.clone().unwrap_or_default(); let context = req.meta.clone().unwrap_or_default(); let result = async { let sheets = get_sheets_client(access_token); let spreadsheet_id = context .get("spreadsheet_id") .and_then(|v| v.as_str()) .context("spreadsheet_id required in context")?; let sheet = args["sheet"].as_str().context("sheet name required")?; let user_range = args["range"].as_str().context("range is required")?; let range = format!("{}!{}", sheet, user_range); let values = args .get("values") .and_then(|v| v.as_array()) .context("values required")?; let major_dimension = args .get("major_dimension") .and_then(|v| v.as_str()) .unwrap_or("ROWS"); let mut value_range = google_sheets4::api::ValueRange::default(); value_range.major_dimension = Some(major_dimension.to_string()); value_range.values = Some( values .iter() .map(|row| { row.as_array() .unwrap_or(&vec![]) .iter() .map(|v| v.as_str().unwrap_or_default().to_string().into()) .collect::<Vec<serde_json::Value>>() }) .collect(), ); let result = sheets .spreadsheets() .values_update(value_range, spreadsheet_id, &range) .value_input_option("RAW") .doit() .await?; Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: serde_json::to_string(&result.1)?, }], is_error: None, meta: None, }) } .await; handle_result(result) }) }); server.register_tool(create_spreadsheet_tool, move |req: CallToolRequest| { Box::pin(async move { let access_token = get_access_token(&req)?; let args = req.arguments.clone().unwrap_or_default(); let result = async { let sheets = get_sheets_client(access_token); let title = args["title"].as_str().context("title required")?; let mut spreadsheet = google_sheets4::api::Spreadsheet::default(); spreadsheet.properties = Some(google_sheets4::api::SpreadsheetProperties { title: Some(title.to_string()), ..Default::default() }); // Add sheets if specified if let Some(sheet_configs) = args["sheets"].as_array() { let sheets = sheet_configs .iter() .map(|config| { let title = config["title"].as_str().unwrap_or("Sheet1").to_string(); google_sheets4::api::Sheet { properties: Some(google_sheets4::api::SheetProperties { title: Some(title), ..Default::default() }), ..Default::default() } }) .collect(); spreadsheet.sheets = Some(sheets); } let result = sheets.spreadsheets().create(spreadsheet).doit().await?; Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: serde_json::to_string(&result.1)?, }], is_error: None, meta: None, }) } .await; handle_result(result) }) }); server.register_tool(clear_values_tool, move |req: CallToolRequest| { Box::pin(async move { let access_token = get_access_token(&req)?; let args = req.arguments.clone().unwrap_or_default(); let context = req.meta.clone().unwrap_or_default(); let result = async { let sheets = get_sheets_client(access_token); let spreadsheet_id = context .get("spreadsheet_id") .and_then(|v| v.as_str()) .context("spreadsheet_id required in context")?; let sheet = args .get("sheet") .and_then(|v| v.as_str()) .unwrap_or("Sheet1"); let user_range = args .get("range") .and_then(|v| v.as_str()) .unwrap_or("A1:ZZ"); let range = format!("{}!{}", sheet, user_range); let clear_request = google_sheets4::api::ClearValuesRequest::default(); let result = sheets .spreadsheets() .values_clear(clear_request, spreadsheet_id, &range) .doit() .await?; Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: serde_json::to_string(&result.1)?, }], is_error: None, meta: None, }) } .await; handle_result(result) }) }); server.register_tool(get_sheet_info_tool, move |req: CallToolRequest| { Box::pin(async move { let access_token = get_access_token(&req)?; let context = req.meta.clone().unwrap_or_default(); let result = async { let sheets = get_sheets_client(access_token); let spreadsheet_id = context .get("spreadsheet_id") .and_then(|v| v.as_str()) .context("spreadsheet_id required in context")?; let result = sheets.spreadsheets().get(spreadsheet_id).doit().await?; let spreadsheet = result.1; // Extract sheet information let sheet_info = spreadsheet .sheets .unwrap_or_default() .into_iter() .filter_map(|sheet| { let props = sheet.properties?; let title = props.title?; let grid_props = props.grid_properties?; // Calculate the maximum range based on grid properties let max_col = grid_props.column_count.unwrap_or(26) as u8; let max_row = grid_props.row_count.unwrap_or(1000); let max_range = format!("A1:{}{}", (b'A' + max_col - 1) as char, max_row); Some(serde_json::json!({ "title": title, "maxRange": max_range, })) }) .collect::<Vec<_>>(); Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: serde_json::to_string(&sheet_info)?, }], is_error: None, meta: None, }) } .await; handle_result(result) }) }); Ok(()) } fn list_sheets_resources() -> ResourcesListResponse { let base = Url::parse("https://sheets.googleapis.com/v4/").unwrap(); ResourcesListResponse { resources: vec![Resource { uri: base, name: "sheets".to_string(), description: Some("Google Sheets API".to_string()), mime_type: Some("application/json".to_string()), }], next_cursor: None, meta: None, } } fn handle_result(result: Result<CallToolResponse>) -> Result<CallToolResponse> { match result { Ok(response) => Ok(response), Err(e) => Ok(CallToolResponse { content: vec![ToolResponseContent::Text { text: format!("Error: {}", e), }], is_error: Some(true), meta: None, }), } } ```