# 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: -------------------------------------------------------------------------------- ``` 1 | /target 2 | .env 3 | ``` -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- ``` 1 | GOOGLE_ACCESS_TOKEN=sdfssdfdssf 2 | GOOGLE_REFRESH_TOKEN=sdfssdfdssf 3 | GOOGLE_CLIENT_ID=sdfssdfdssf 4 | GOOGLE_CLIENT_SECRET=sdfssdfdssf 5 | 6 | 7 | TEST_SPREADSHEET_ID="1yO2ZVWb-EEhv-xxxxxxx" ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Google Drive & Sheets MCP Server 2 | 3 | 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. 4 | 5 | ## Features 6 | 7 | ### Google Drive Operations 8 | - List files in Google Drive with filtering options: 9 | - Filter by MIME type 10 | - Custom search queries 11 | - Configurable page size 12 | - Custom ordering 13 | 14 | ### Google Sheets Operations 15 | - Read data from Google Sheets with options: 16 | - Specify range 17 | - Choose major dimension (ROWS or COLUMNS) 18 | - Write data to Google Sheets 19 | - Create new spreadsheets with: 20 | - Custom title 21 | - Multiple sheets 22 | - Clear values from ranges in spreadsheets 23 | 24 | ## MCP Integration 25 | 26 | 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: 27 | 28 | ### Drive Tools 29 | - `list_files`: List and filter Drive files with customizable parameters 30 | - Available capabilities exposed via `resources/list` endpoint 31 | 32 | ### Sheets Tools 33 | - `read_values`: Read spreadsheet data with dimension control 34 | - `write_values`: Write data to spreadsheets 35 | - `create_spreadsheet`: Create new spreadsheets 36 | - `clear_values`: Clear ranges in spreadsheets 37 | - Available capabilities exposed via `resources/list` endpoint 38 | 39 | ## Prerequisites 40 | 41 | - Rust (latest stable version) 42 | - Google Cloud Project with Drive and Sheets APIs enabled 43 | - OAuth 2.0 credentials configured for your Google Cloud Project 44 | 45 | ## Installation 46 | 47 | Install the `mcp-google` binary directly from GitHub using Cargo: 48 | ```bash 49 | cargo install --git https://github.com/distrihub/mcp-google-workspace.git 50 | ``` 51 | 52 | This will install the `mcp-google` command to your system. 53 | 54 | ## Configuration 55 | 56 | Before using the server, you need to: 57 | 58 | 1. Set up a Google Cloud Project 59 | 2. Enable Google Drive and Google Sheets APIss 60 | 3. Create OAuth 2.0 credentials 61 | 4. Set up your environment variables: 62 | - `ACCESS_TOKEN`: Your Google OAuth access token 63 | - `GOOGLE_CLIENT_ID`: Your OAuth client ID 64 | - `GOOGLE_CLIENT_SECRET`: Your OAuth client secret 65 | - `GOOGLE_REFRESH_TOKEN`: Your OAuth refresh token 66 | 67 | ## Usage 68 | 69 | ### As MCP Server 70 | 71 | The servers can be started independently and will communicate using the MCP protocol over stdio: 72 | 73 | Start the Drive MCP server: 74 | ```bash 75 | mcp-google drive --access-token <your-access-token> 76 | ``` 77 | 78 | Start the Sheets MCP server: 79 | ```bash 80 | mcp-google sheets --access-token <your-access-token> 81 | ``` 82 | 83 | ### Using with Distri 84 | 85 | This server can be used as part of a Distri agent configuration: 86 | 87 | ```yaml 88 | agents: 89 | google_workspace: 90 | drive: 91 | type: mcp 92 | command: ["mcp-google", "drive", "--access-token", "${ACCESS_TOKEN}"] 93 | sheets: 94 | type: mcp 95 | command: ["mcp-google", "sheets", "--access-token", "${ACCESS_TOKEN}"] 96 | ``` 97 | 98 | ### Token Management 99 | 100 | Refresh your OAuth token: 101 | ```bash 102 | mcp-google refresh \ 103 | --client-id <your-client-id> \ 104 | --client-secret <your-client-secret> \ 105 | --refresh-token <your-refresh-token> 106 | ``` 107 | 108 | ## License 109 | 110 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 111 | 112 | ## Contributing 113 | 114 | Contributions are welcome! Please feel free to submit a Pull Request. 115 | 116 | ## Note 117 | 118 | 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 1 | pub mod drive; 2 | pub mod sheets; 3 | ``` -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- ```rust 1 | pub mod drive; 2 | pub mod sheets; 3 | ``` -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- ```rust 1 | mod auth; 2 | pub mod client; 3 | pub mod logging; 4 | pub mod servers; 5 | 6 | #[cfg(test)] 7 | mod tests; 8 | 9 | // Re-export servers 10 | pub use auth::GoogleAuthService; 11 | use thiserror::Error; 12 | 13 | #[derive(Debug, Error)] 14 | pub enum InvokeError { 15 | #[error("Serde error: {0}")] 16 | Serde(#[from] serde_json::Error), 17 | 18 | #[error("Environment variable missing: {0}")] 19 | EnvVarMissing(String), 20 | 21 | #[error("Google API error: {0}")] 22 | GoogleApi(String), 23 | 24 | #[error("Token parse error: {0}")] 25 | TokenParse(String), 26 | 27 | #[error("User info error: {0}")] 28 | UserInfo(String), 29 | 30 | #[error("JWT error: {0}")] 31 | Jwt(String), 32 | } 33 | ``` -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- ```toml 1 | [package] 2 | name = "mcp-google-workspace" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | async-mcp = { version = "0.1.0" } 8 | tokio = { version = "1.0", features = ["full"] } 9 | serde = { version = "1.0", features = ["derive"] } 10 | serde_json = "1.0" 11 | anyhow = "1.0" 12 | async-trait = "0.1" 13 | tracing-subscriber = { version = "0.3.0", features = [ 14 | "env-filter", 15 | "tracing-log", 16 | ] } 17 | tracing = "0.1" 18 | url = "2.5.4" 19 | google-sheets4 = { version = "6.0", features = ["default"] } 20 | google-drive3 = "6.0.0" 21 | clap = { version = "4.4", features = ["derive", "env"] } 22 | reqwest = { version = "0.12.2", default-features = false, features = [ 23 | "json", 24 | "stream", 25 | ] } 26 | chrono = { version = "0.4", features = ["serde"] } 27 | thiserror = "2.0.11" 28 | rand = "0.8" 29 | base64 = "0.21" 30 | urlencoding = "2.1.0" 31 | 32 | [dev-dependencies] 33 | dotenv = "0.15" 34 | 35 | [[bin]] 36 | name = "mcp-google" 37 | path = "src/main.rs" 38 | ``` -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- ```rust 1 | use tracing_subscriber::{filter::FilterFn, prelude::*, EnvFilter}; 2 | 3 | /// Initialize logging with sensible defaults for the agents library. 4 | /// This will: 5 | /// - Set up logging with the specified log level 6 | /// - Filter out noisy logs from dependencies like hyper 7 | /// - Format logs in a human-readable format 8 | pub fn init_logging(level: &str) { 9 | let filter = EnvFilter::try_from_default_env() 10 | .unwrap_or_else(|_| EnvFilter::new(level)) 11 | // Filter out noisy hyper logs 12 | .add_directive("hyper=off".parse().unwrap()) 13 | .add_directive("rustyline=off".parse().unwrap()) 14 | .add_directive("h2=off".parse().unwrap()) 15 | .add_directive("rustls=off".parse().unwrap()); 16 | 17 | // Only show our crate's logs and any errors from other crates 18 | let _crate_filter = FilterFn::new(|metadata| { 19 | metadata.target().starts_with("agents") 20 | || metadata.target().starts_with("app") 21 | || metadata.level() <= &tracing::Level::ERROR 22 | }); 23 | 24 | tracing_subscriber::registry() 25 | .with(tracing_subscriber::fmt::layer().with_filter(filter)) 26 | // .with(filter) 27 | .init(); 28 | } 29 | ``` -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- ```rust 1 | use google_drive3::DriveHub; 2 | use google_sheets4::Sheets; 3 | 4 | pub fn get_drive_client( 5 | access_token: &str, 6 | ) -> DriveHub< 7 | google_drive3::hyper_rustls::HttpsConnector< 8 | google_drive3::hyper_util::client::legacy::connect::HttpConnector, 9 | >, 10 | > { 11 | let hub = DriveHub::new( 12 | google_drive3::hyper_util::client::legacy::Client::builder( 13 | google_drive3::hyper_util::rt::TokioExecutor::new(), 14 | ) 15 | .build( 16 | google_sheets4::hyper_rustls::HttpsConnectorBuilder::new() 17 | .with_native_roots() 18 | .unwrap() 19 | .https_or_http() 20 | .enable_http1() 21 | .build(), 22 | ), 23 | access_token.to_string(), 24 | ); 25 | hub 26 | } 27 | 28 | pub fn get_sheets_client( 29 | access_token: &str, 30 | ) -> Sheets< 31 | google_sheets4::hyper_rustls::HttpsConnector< 32 | google_sheets4::hyper_util::client::legacy::connect::HttpConnector, 33 | >, 34 | > { 35 | let hub = Sheets::new( 36 | google_sheets4::hyper_util::client::legacy::Client::builder( 37 | google_sheets4::hyper_util::rt::TokioExecutor::new(), 38 | ) 39 | .build( 40 | google_sheets4::hyper_rustls::HttpsConnectorBuilder::new() 41 | .with_native_roots() 42 | .unwrap() 43 | .https_or_http() 44 | .enable_http1() 45 | .build(), 46 | ), 47 | access_token.to_string(), 48 | ); 49 | hub 50 | } 51 | ``` -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- ```rust 1 | use anyhow::Result; 2 | use async_mcp::transport::ServerStdioTransport; 3 | use clap::{Parser, Subcommand}; 4 | use mcp_google_workspace::{ 5 | logging::init_logging, 6 | servers::{drive, sheets}, 7 | GoogleAuthService, 8 | }; 9 | 10 | #[derive(Parser)] 11 | #[command(author, version, about, long_about = None)] 12 | struct Cli { 13 | #[command(subcommand)] 14 | command: Commands, 15 | } 16 | 17 | #[derive(Subcommand)] 18 | enum Commands { 19 | /// Start the Google Drive server 20 | Drive, 21 | /// Start the Google Sheets server 22 | Sheets, 23 | Refresh { 24 | /// Google OAuth client ID 25 | #[arg(long, env = "GOOGLE_CLIENT_ID")] 26 | client_id: String, 27 | /// Google OAuth client secret 28 | #[arg(long, env = "GOOGLE_CLIENT_SECRET")] 29 | client_secret: String, 30 | /// Refresh token 31 | #[arg(long, env = "GOOGLE_REFRESH_TOKEN")] 32 | refresh_token: String, 33 | }, 34 | } 35 | 36 | #[tokio::main] 37 | async fn main() -> Result<()> { 38 | init_logging("debug"); 39 | 40 | let cli = Cli::parse(); 41 | 42 | match cli.command { 43 | Commands::Drive => { 44 | let server = drive::build(ServerStdioTransport)?; 45 | let server_handle = tokio::spawn(async move { server.listen().await }); 46 | 47 | server_handle 48 | .await? 49 | .map_err(|e| anyhow::anyhow!("Drive server error: {:#?}", e))?; 50 | } 51 | Commands::Sheets => { 52 | let server = sheets::build(ServerStdioTransport)?; 53 | let server_handle = tokio::spawn(async move { server.listen().await }); 54 | 55 | server_handle 56 | .await? 57 | .map_err(|e| anyhow::anyhow!("Sheets server error: {:#?}", e))?; 58 | } 59 | Commands::Refresh { 60 | client_id, 61 | client_secret, 62 | refresh_token, 63 | } => { 64 | let auth_service = GoogleAuthService::new(client_id, client_secret).unwrap(); 65 | let token_response = auth_service.refresh_token(&refresh_token).await.unwrap(); 66 | println!("Token response: {:#?}", token_response); 67 | } 68 | } 69 | 70 | Ok(()) 71 | } 72 | ``` -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- ```rust 1 | use reqwest::Client; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::json; 4 | use tracing::debug; 5 | 6 | use crate::InvokeError; 7 | 8 | #[derive(Serialize, Deserialize, Debug, Clone)] 9 | pub struct TokenResponse { 10 | pub access_token: String, 11 | pub expires_in: i32, 12 | pub refresh_token: Option<String>, 13 | pub scope: String, 14 | pub token_type: String, 15 | } 16 | 17 | #[derive(Clone)] 18 | pub struct GoogleAuthService { 19 | pub client: Client, 20 | pub google_client_id: String, 21 | pub google_client_secret: String, 22 | } 23 | impl Default for GoogleAuthService { 24 | fn default() -> Self { 25 | let google_client_id = std::env::var("GOOGLE_CLIENT_ID") 26 | .map_err(|_| InvokeError::EnvVarMissing("GOOGLE_CLIENT_ID".to_string())) 27 | .unwrap(); 28 | let google_client_secret = std::env::var("GOOGLE_CLIENT_SECRET") 29 | .map_err(|_| InvokeError::EnvVarMissing("GOOGLE_CLIENT_SECRET".to_string())) 30 | .unwrap(); 31 | 32 | Self::new(google_client_id, google_client_secret).unwrap() 33 | } 34 | } 35 | impl GoogleAuthService { 36 | pub fn new(client_id: String, client_secret: String) -> Result<Self, InvokeError> { 37 | Ok(Self { 38 | client: Client::new(), 39 | google_client_id: client_id, 40 | google_client_secret: client_secret, 41 | }) 42 | } 43 | 44 | pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse, InvokeError> { 45 | let payload = json!({ 46 | "client_id": self.google_client_id, 47 | "client_secret": self.google_client_secret, 48 | "refresh_token": refresh_token, 49 | "grant_type": "refresh_token" 50 | }); 51 | 52 | self.exchange_token(&payload).await 53 | } 54 | 55 | async fn exchange_token( 56 | &self, 57 | payload: &serde_json::Value, 58 | ) -> Result<TokenResponse, InvokeError> { 59 | debug!("Token exchange payload: {:?}", payload); 60 | 61 | let response = self 62 | .client 63 | .post("https://oauth2.googleapis.com/token") 64 | .json(payload) 65 | .send() 66 | .await 67 | .map_err(|e| InvokeError::GoogleApi(e.to_string()))?; 68 | 69 | if !response.status().is_success() { 70 | let error = response 71 | .text() 72 | .await 73 | .unwrap_or_else(|_| "Unknown error".to_string()); 74 | return Err(InvokeError::GoogleApi(error)); 75 | } 76 | 77 | response 78 | .json::<TokenResponse>() 79 | .await 80 | .map_err(|e| InvokeError::TokenParse(e.to_string())) 81 | } 82 | } 83 | ``` -------------------------------------------------------------------------------- /src/tests/drive.rs: -------------------------------------------------------------------------------- ```rust 1 | use crate::{client::get_drive_client, logging::init_logging, servers::drive}; 2 | use async_mcp::{ 3 | protocol::RequestOptions, 4 | transport::{ClientInMemoryTransport, ServerInMemoryTransport, Transport}, 5 | types::CallToolRequest, 6 | }; 7 | use dotenv::dotenv; 8 | use serde_json::json; 9 | use std::{collections::HashMap, env, time::Duration}; 10 | 11 | async fn async_drive_server(transport: ServerInMemoryTransport) { 12 | let server = drive::build(transport).unwrap(); 13 | server.listen().await.unwrap(); 14 | } 15 | 16 | #[tokio::test] 17 | async fn test_drive_operations() -> anyhow::Result<()> { 18 | dotenv::dotenv().ok(); 19 | let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 20 | 21 | let client_transport = ClientInMemoryTransport::new(move |t| { 22 | tokio::spawn(async move { async_drive_server(t).await }) 23 | }); 24 | client_transport.open().await?; 25 | 26 | let client = async_mcp::client::ClientBuilder::new(client_transport.clone()).build(); 27 | let client_clone = client.clone(); 28 | let _client_handle = tokio::spawn(async move { client_clone.start().await }); 29 | 30 | let params = CallToolRequest { 31 | name: "list_files".to_string(), 32 | arguments: Some(HashMap::from([ 33 | ( 34 | "mime_type".to_string(), 35 | "application/vnd.google-apps.folder".to_string().into(), 36 | ), 37 | ("page_size".to_string(), 5.into()), 38 | ])), 39 | meta: Some(json!({ 40 | "access_token": access_token 41 | })), 42 | }; 43 | // Test list files 44 | let response = client 45 | .request( 46 | "list_files", 47 | Some(serde_json::to_value(¶ms).unwrap()), 48 | RequestOptions::default().timeout(Duration::from_secs(5)), 49 | ) 50 | .await?; 51 | println!("List files response:\n{response}"); 52 | 53 | Ok(()) 54 | } 55 | 56 | #[tokio::test] 57 | async fn test_list_spreadsheets() -> Result<(), Box<dyn std::error::Error>> { 58 | init_logging("debug"); 59 | dotenv().ok(); 60 | 61 | let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 62 | let drive = get_drive_client(&access_token); 63 | 64 | // Add more detailed query parameters and debug output 65 | let result = drive 66 | .files() 67 | .list() 68 | .q("mimeType='application/vnd.google-apps.spreadsheet'") 69 | .order_by("modifiedTime desc") 70 | .page_size(10) // Limit to 10 results for testing 71 | .doit() 72 | .await?; 73 | 74 | if let Some(files) = result.1.files { 75 | for file in files { 76 | println!( 77 | "Spreadsheet: {} (ID: {})", 78 | file.name.unwrap_or_default(), 79 | file.id.unwrap_or_default() 80 | ); 81 | println!( 82 | "Last modified: {:?}", 83 | file.modified_time.unwrap_or_default() 84 | ); 85 | println!("-------------------"); 86 | } 87 | } 88 | 89 | Ok(()) 90 | } 91 | ``` -------------------------------------------------------------------------------- /src/servers/drive.rs: -------------------------------------------------------------------------------- ```rust 1 | use anyhow::Result; 2 | use async_mcp::{ 3 | server::Server, 4 | transport::Transport, 5 | types::{ 6 | CallToolRequest, CallToolResponse, ListRequest, Resource, ResourcesListResponse, 7 | ServerCapabilities, Tool, ToolResponseContent, 8 | }, 9 | }; 10 | use serde_json::json; 11 | use url::Url; 12 | 13 | use crate::client::get_drive_client; 14 | 15 | fn get_access_token(req: &CallToolRequest) -> Result<&str> { 16 | req.meta 17 | .as_ref() 18 | .and_then(|v| v.get("access_token")) 19 | .and_then(|v| v.as_str()) 20 | .ok_or_else(|| anyhow::anyhow!("Missing or invalid access_token")) 21 | } 22 | 23 | pub fn build<T: Transport>(transport: T) -> Result<Server<T>> { 24 | let mut server = Server::builder(transport) 25 | .capabilities(ServerCapabilities { 26 | tools: Some(json!({ 27 | "drive": { 28 | "version": "v3", 29 | "description": "Google Drive API operations" 30 | } 31 | })), 32 | ..Default::default() 33 | }) 34 | .request_handler("resources/list", |_req: ListRequest| { 35 | Box::pin(async move { Ok(list_drive_resources()) }) 36 | }); 37 | 38 | // List files 39 | server.register_tool( 40 | Tool { 41 | name: "list_files".to_string(), 42 | description: Some("List files in Google Drive with filters".to_string()), 43 | input_schema: json!({ 44 | "type": "object", 45 | "properties": { 46 | "mime_type": {"type": "string"}, 47 | "query": {"type": "string"}, 48 | "page_size": {"type": "integer", "default": 10}, 49 | "order_by": {"type": "string", "default": "modifiedTime desc"} 50 | } 51 | }), 52 | }, 53 | move |req: CallToolRequest| { 54 | Box::pin(async move { 55 | let access_token = get_access_token(&req)?; 56 | let args = req.arguments.clone().unwrap_or_default(); 57 | 58 | let result = async { 59 | let drive = get_drive_client(access_token); 60 | 61 | let mut query = String::new(); 62 | if let Some(mime_type) = args.get("mime_type").and_then(|v| v.as_str()) { 63 | query.push_str(&format!("mimeType='{}'", mime_type)); 64 | } 65 | 66 | let result = drive 67 | .files() 68 | .list() 69 | .q(&query) 70 | .page_size( 71 | args.get("page_size").and_then(|v| v.as_u64()).unwrap_or(10) as i32 72 | ) 73 | .order_by( 74 | args.get("order_by") 75 | .and_then(|v| v.as_str()) 76 | .unwrap_or("modifiedTime desc"), 77 | ) 78 | .doit() 79 | .await?; 80 | 81 | Ok(CallToolResponse { 82 | content: vec![ToolResponseContent::Text { 83 | text: serde_json::to_string(&result.1)?, 84 | }], 85 | is_error: None, 86 | meta: None, 87 | }) 88 | } 89 | .await; 90 | 91 | handle_result(result) 92 | }) 93 | }, 94 | ); 95 | 96 | Ok(server.build()) 97 | } 98 | 99 | fn list_drive_resources() -> ResourcesListResponse { 100 | let base = Url::parse("https://www.googleapis.com/drive/v3/").unwrap(); 101 | ResourcesListResponse { 102 | resources: vec![Resource { 103 | uri: base, 104 | name: "drive".to_string(), 105 | description: Some("Google Drive API".to_string()), 106 | mime_type: Some("application/json".to_string()), 107 | }], 108 | next_cursor: None, 109 | meta: None, 110 | } 111 | } 112 | 113 | fn handle_result(result: Result<CallToolResponse>) -> Result<CallToolResponse> { 114 | match result { 115 | Ok(response) => Ok(response), 116 | Err(e) => Ok(CallToolResponse { 117 | content: vec![ToolResponseContent::Text { 118 | text: format!("Error: {}", e), 119 | }], 120 | is_error: Some(true), 121 | meta: None, 122 | }), 123 | } 124 | } 125 | ``` -------------------------------------------------------------------------------- /src/tests/sheets.rs: -------------------------------------------------------------------------------- ```rust 1 | use crate::{ 2 | client::{get_drive_client, get_sheets_client}, 3 | servers::sheets, 4 | }; 5 | use async_mcp::{ 6 | protocol::RequestOptions, 7 | transport::{ClientInMemoryTransport, ServerInMemoryTransport, Transport}, 8 | types::CallToolRequest, 9 | }; 10 | use dotenv::dotenv; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_json::json; 13 | use std::{collections::HashMap, env, time::Duration}; 14 | 15 | #[derive(Debug, Serialize, Deserialize)] 16 | #[serde(rename_all = "camelCase")] 17 | struct Sheet { 18 | a1_notation: String, 19 | sheet_id: u64, 20 | sheet_name: String, 21 | } 22 | 23 | async fn async_sheets_server(transport: ServerInMemoryTransport) { 24 | println!("Starting sheets server..."); 25 | let server = sheets::build(transport).unwrap(); 26 | println!("Server built successfully"); 27 | server.listen().await.unwrap(); 28 | } 29 | 30 | #[tokio::test] 31 | async fn test_sheets_operations() -> anyhow::Result<()> { 32 | dotenv::dotenv().ok(); 33 | let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 34 | let spreadsheet_id = env::var("TEST_SPREADSHEET_ID").unwrap(); 35 | 36 | let client_transport = ClientInMemoryTransport::new(move |t| { 37 | tokio::spawn(async move { async_sheets_server(t).await }) 38 | }); 39 | client_transport.open().await?; 40 | 41 | let client = async_mcp::client::ClientBuilder::new(client_transport.clone()).build(); 42 | let client_clone = client.clone(); 43 | let _client_handle = tokio::spawn(async move { client_clone.start().await }); 44 | 45 | // Add a small delay to ensure server is ready 46 | tokio::time::sleep(std::time::Duration::from_millis(100)).await; 47 | 48 | let params = CallToolRequest { 49 | name: "read_values".to_string(), 50 | arguments: Some(HashMap::new()), 51 | meta: Some(json!({ 52 | "access_token": access_token, 53 | "spreadsheet_id": spreadsheet_id, 54 | "sheet": "Sheet6" 55 | })), 56 | }; 57 | 58 | // Test read values 59 | let response = client 60 | .request( 61 | "tools/call", 62 | Some(serde_json::to_value(¶ms).unwrap()), 63 | RequestOptions::default().timeout(Duration::from_secs(5)), 64 | ) 65 | .await?; 66 | 67 | // Add better error handling 68 | let response_obj: serde_json::Value = serde_json::from_str(&response.to_string())?; 69 | if let Some(error) = response_obj.get("error") { 70 | println!("Error reading sheet: {}", error); 71 | anyhow::bail!("Failed to read sheet: {}", error); 72 | } 73 | 74 | println!("Read values response:\n{response}"); 75 | 76 | Ok(()) 77 | } 78 | 79 | #[tokio::test] 80 | async fn test_google_sheets() -> Result<(), Box<dyn std::error::Error>> { 81 | dotenv().ok(); 82 | // let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 83 | // let auth_service = GoogleAuthService::new( 84 | // env::var("GOOGLE_CLIENT_ID").unwrap(), 85 | // env::var("GOOGLE_CLIENT_SECRET").unwrap(), 86 | // )?; 87 | // let token_response = auth_service.refresh_token(&access_token).await?; 88 | // println!("Access token: {:?}", token_response); 89 | // let access_token = token_response.access_token; 90 | 91 | let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 92 | let sheets = get_sheets_client(&access_token); 93 | 94 | let spreadsheet_id = env::var("TEST_SPREADSHEET_ID").unwrap(); 95 | 96 | // Try to read the spreadsheet 97 | let result = sheets.spreadsheets().get(&spreadsheet_id).doit().await?; 98 | // Extract sheet names and ranges 99 | if let Some(sheets) = result.1.sheets { 100 | for sheet in sheets { 101 | if let Some(properties) = sheet.properties { 102 | let sheet_title = properties.title.unwrap_or_default(); 103 | let grid_props = properties.grid_properties.unwrap_or_default(); 104 | let row_count = grid_props.row_count.unwrap_or(0); 105 | let column_count = grid_props.column_count.unwrap_or(0); 106 | 107 | println!( 108 | "Sheet: {}\nRange: {}!A1:{}{}\n", 109 | sheet_title, 110 | sheet_title, 111 | (b'A' + (column_count as u8) - 1) as char, 112 | row_count 113 | ); 114 | } 115 | } 116 | } else { 117 | println!("No sheets found."); 118 | } 119 | 120 | Ok(()) 121 | } 122 | 123 | #[tokio::test] 124 | async fn test_list_spreadsheet_details() -> Result<(), Box<dyn std::error::Error>> { 125 | dotenv().ok(); 126 | let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 127 | 128 | let drive = get_drive_client(&access_token); 129 | let sheets = get_sheets_client(&access_token); 130 | 131 | let result = drive 132 | .files() 133 | .list() 134 | .q("mimeType='application/vnd.google-apps.spreadsheet'") 135 | .order_by("modifiedTime desc") 136 | .page_size(10) 137 | .doit() 138 | .await?; 139 | 140 | if let Some(files) = result.1.files { 141 | for file in files { 142 | let id = file.id.clone().unwrap_or_default(); 143 | println!( 144 | "Spreadsheet: {} (ID: {})", 145 | file.name.unwrap_or_default(), 146 | id 147 | ); 148 | 149 | // Get the content of each spreadsheet 150 | let spreadsheet = sheets.spreadsheets().get(&id).doit().await?; 151 | 152 | println!("Sheets in this spreadsheet:"); 153 | for sheet in spreadsheet.1.sheets.unwrap_or_default() { 154 | if let Some(props) = sheet.properties { 155 | println!("- Sheet name: {}", props.title.unwrap_or_default()); 156 | } 157 | } 158 | println!("-------------------"); 159 | } 160 | } 161 | 162 | Ok(()) 163 | } 164 | 165 | #[tokio::test] 166 | async fn test_sheet_operations() -> anyhow::Result<()> { 167 | dotenv().ok(); 168 | let access_token = env::var("GOOGLE_ACCESS_TOKEN").unwrap(); 169 | let spreadsheet_id = env::var("TEST_SPREADSHEET_ID").unwrap(); 170 | 171 | let client_transport = ClientInMemoryTransport::new(move |t| { 172 | tokio::spawn(async move { async_sheets_server(t).await }) 173 | }); 174 | client_transport.open().await?; 175 | 176 | let client = async_mcp::client::ClientBuilder::new(client_transport.clone()).build(); 177 | let client_clone = client.clone(); 178 | let _client_handle = tokio::spawn(async move { client_clone.start().await }); 179 | 180 | // Add a small delay to ensure server is ready 181 | tokio::time::sleep(Duration::from_millis(100)).await; 182 | 183 | // First get sheet info 184 | let get_info_params = CallToolRequest { 185 | name: "get_sheet_info".to_string(), 186 | arguments: None, 187 | meta: Some(json!({ 188 | "access_token": access_token, 189 | "spreadsheet_id": spreadsheet_id, 190 | })), 191 | }; 192 | 193 | let info_response = client 194 | .request( 195 | "tools/call", 196 | Some(serde_json::to_value(&get_info_params).unwrap()), 197 | RequestOptions::default().timeout(Duration::from_secs(5)), 198 | ) 199 | .await?; 200 | 201 | println!("Sheet info:\n{}", info_response); 202 | 203 | // Read the current value from A1 204 | let read_params = CallToolRequest { 205 | name: "read_values".to_string(), 206 | arguments: None, 207 | meta: Some(json!({ 208 | "access_token": access_token, 209 | "spreadsheet_id": spreadsheet_id, 210 | "sheet": "Sheet1", 211 | "range": "A1" 212 | })), 213 | }; 214 | 215 | let read_response = client 216 | .request( 217 | "tools/call", 218 | Some(serde_json::to_value(&read_params).unwrap()), 219 | RequestOptions::default().timeout(Duration::from_secs(5)), 220 | ) 221 | .await?; 222 | 223 | // After read_response 224 | println!("Initial read response:\n{}", read_response); 225 | 226 | // Parse the current value and increment it 227 | let read_value = serde_json::from_str::<serde_json::Value>(&read_response.to_string())?; 228 | println!("Parsed read value: {:?}", read_value); 229 | 230 | let current_value = read_value["content"][0]["text"] 231 | .as_str() 232 | .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()) 233 | .and_then(|v| v["values"][0][0].as_str().map(String::from)) 234 | .and_then(|s| s.parse::<i32>().ok()) 235 | .unwrap_or(0); 236 | let new_value = current_value + 1; 237 | 238 | // Write the incremented value back 239 | let mut args = HashMap::new(); 240 | args.insert("values".to_string(), json!([[new_value.to_string()]])); 241 | args.insert("range".to_string(), json!("A1")); 242 | 243 | let write_params = CallToolRequest { 244 | name: "write_values".to_string(), 245 | arguments: Some(args), 246 | meta: Some(json!({ 247 | "access_token": access_token, 248 | "spreadsheet_id": spreadsheet_id, 249 | "sheet": "Sheet1" 250 | })), 251 | }; 252 | 253 | let write_response = client 254 | .request( 255 | "tools/call", 256 | Some(serde_json::to_value(&write_params).unwrap()), 257 | RequestOptions::default().timeout(Duration::from_secs(5)), 258 | ) 259 | .await?; 260 | 261 | println!("Write response:\n{}", write_response); 262 | 263 | // Verify the new value 264 | let verify_response = client 265 | .request( 266 | "tools/call", 267 | Some(serde_json::to_value(&read_params).unwrap()), 268 | RequestOptions::default().timeout(Duration::from_secs(5)), 269 | ) 270 | .await?; 271 | 272 | // After verify_response 273 | println!("Verify response:\n{}", verify_response); 274 | 275 | let verify_value = serde_json::from_str::<serde_json::Value>(&verify_response.to_string())?; 276 | println!("Parsed verify value: {:?}", verify_value); 277 | 278 | let updated_value = verify_value["content"][0]["text"] 279 | .as_str() 280 | .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok()) 281 | .and_then(|v| v["values"][0][0].as_str().map(String::from)) 282 | .and_then(|s| s.parse::<i32>().ok()) 283 | .unwrap_or(0); 284 | 285 | assert_eq!( 286 | updated_value, new_value, 287 | "Value was not updated correctly. Expected {}, got {}", 288 | new_value, updated_value 289 | ); 290 | 291 | println!( 292 | "Successfully incremented value from {} to {}", 293 | current_value, new_value 294 | ); 295 | 296 | Ok(()) 297 | } 298 | ``` -------------------------------------------------------------------------------- /src/servers/sheets.rs: -------------------------------------------------------------------------------- ```rust 1 | use anyhow::{Context, Result}; 2 | use async_mcp::{ 3 | server::{Server, ServerBuilder}, 4 | transport::Transport, 5 | types::{ 6 | CallToolRequest, CallToolResponse, ListRequest, Resource, ResourcesListResponse, 7 | ServerCapabilities, Tool, ToolResponseContent, 8 | }, 9 | }; 10 | use serde_json::json; 11 | use url::Url; 12 | 13 | use crate::client::get_sheets_client; 14 | 15 | fn get_access_token(req: &CallToolRequest) -> Result<&str> { 16 | req.meta 17 | .as_ref() 18 | .and_then(|v| v.get("access_token")) 19 | .and_then(|v| v.as_str()) 20 | .ok_or_else(|| anyhow::anyhow!("Missing or invalid access_token")) 21 | } 22 | 23 | pub fn build<T: Transport>(transport: T) -> Result<Server<T>> { 24 | let mut server = Server::builder(transport) 25 | .capabilities(ServerCapabilities { 26 | tools: Some(json!({ 27 | "sheets": { 28 | "version": "v4", 29 | "description": "Google Sheets API operations" 30 | } 31 | })), 32 | ..Default::default() 33 | }) 34 | .request_handler("resources/list", |_req: ListRequest| { 35 | Box::pin(async move { Ok(list_sheets_resources()) }) 36 | }); 37 | 38 | register_tools(&mut server)?; 39 | 40 | Ok(server.build()) 41 | } 42 | 43 | fn register_tools<T: Transport>(server: &mut ServerBuilder<T>) -> Result<()> { 44 | // Tool Definitions 45 | let read_values_tool = Tool { 46 | name: "read_values".to_string(), 47 | description: Some("Read values from a Google Sheet".to_string()), 48 | input_schema: json!({ 49 | "type": "object", 50 | "properties": { 51 | "sheet": {"type": "string", "description": "Sheet name"}, 52 | "range": {"type": "string", "description": "Range to read (e.g. 'A1:B2')", "default": "A1:ZZ"}, 53 | "major_dimension": {"type": "string", "enum": ["ROWS", "COLUMNS"], "default": "ROWS"} 54 | }, 55 | "required": ["sheet"] 56 | }), 57 | }; 58 | 59 | let write_values_tool = Tool { 60 | name: "write_values".to_string(), 61 | description: Some("Write values to a Google Sheet".to_string()), 62 | input_schema: json!({ 63 | "type": "object", 64 | "properties": { 65 | "sheet": {"type": "string", "description": "Sheet name"}, 66 | "range": {"type": "string", "description": "Range to write to (e.g. 'A1:B2')"}, 67 | "values": { 68 | "description": "2D array of values to write", 69 | "type": "array", 70 | "items": { 71 | "type": "array", 72 | "items": { 73 | "type": ["string", "number", "boolean", "null"], 74 | "description": "A single cell value" 75 | } 76 | } 77 | }, 78 | "major_dimension": {"type": "string", "enum": ["ROWS", "COLUMNS"], "default": "ROWS"} 79 | }, 80 | "required": ["values", "range", "sheet"] 81 | }), 82 | }; 83 | 84 | let create_spreadsheet_tool = Tool { 85 | name: "create_spreadsheet".to_string(), 86 | description: Some("Create a new Google Sheet".to_string()), 87 | input_schema: json!({ 88 | "type": "object", 89 | "properties": { 90 | "title": {"type": "string"}, 91 | "sheets": { 92 | "type": "array", 93 | "items": { 94 | "type": "object", 95 | "properties": { 96 | "title": {"type": "string"} 97 | } 98 | } 99 | } 100 | }, 101 | "required": ["title"] 102 | }), 103 | }; 104 | 105 | let clear_values_tool = Tool { 106 | name: "clear_values".to_string(), 107 | description: Some("Clear values from a range in a Google Sheet".to_string()), 108 | input_schema: json!({ 109 | "type": "object", 110 | "properties": { 111 | "sheet": {"type": "string", "description": "Sheet name", "default": "Sheet1"}, 112 | "range": {"type": "string", "description": "Range to clear (e.g. 'A1:B2')", "default": "A1:ZZ"} 113 | }, 114 | "required": ["sheet", "range"] 115 | }), 116 | }; 117 | 118 | let get_sheet_info_tool = Tool { 119 | name: "get_sheet_info".to_string(), 120 | 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()), 121 | input_schema: json!({ 122 | "type": "object", 123 | "properties": {}, 124 | "required": [] 125 | }), 126 | }; 127 | 128 | // Tool Implementations 129 | server.register_tool(read_values_tool, move |req: CallToolRequest| { 130 | Box::pin(async move { 131 | let access_token = get_access_token(&req)?; 132 | let args = req.arguments.clone().unwrap_or_default(); 133 | let context = req.meta.clone().unwrap_or_default(); 134 | 135 | let result = async { 136 | let sheets = get_sheets_client(access_token); 137 | 138 | let spreadsheet_id = context 139 | .get("spreadsheet_id") 140 | .and_then(|v| v.as_str()) 141 | .context("spreadsheet_id required in context")?; 142 | 143 | let sheet = args["sheet"].as_str().context("sheet name required")?; 144 | let user_range = args["range"].as_str().unwrap_or("A1:ZZ"); 145 | let range = format!("{}!{}", sheet, user_range); 146 | 147 | let major_dimension = args 148 | .get("major_dimension") 149 | .and_then(|v| v.as_str()) 150 | .unwrap_or("ROWS"); 151 | 152 | let result = sheets 153 | .spreadsheets() 154 | .values_get(spreadsheet_id, &range) 155 | .major_dimension(major_dimension) 156 | .doit() 157 | .await?; 158 | 159 | Ok(CallToolResponse { 160 | content: vec![ToolResponseContent::Text { 161 | text: serde_json::to_string(&result.1)?, 162 | }], 163 | is_error: None, 164 | meta: None, 165 | }) 166 | } 167 | .await; 168 | 169 | handle_result(result) 170 | }) 171 | }); 172 | 173 | server.register_tool(write_values_tool, move |req: CallToolRequest| { 174 | Box::pin(async move { 175 | let access_token = get_access_token(&req)?; 176 | let args = req.arguments.clone().unwrap_or_default(); 177 | let context = req.meta.clone().unwrap_or_default(); 178 | 179 | let result = async { 180 | let sheets = get_sheets_client(access_token); 181 | 182 | let spreadsheet_id = context 183 | .get("spreadsheet_id") 184 | .and_then(|v| v.as_str()) 185 | .context("spreadsheet_id required in context")?; 186 | 187 | let sheet = args["sheet"].as_str().context("sheet name required")?; 188 | let user_range = args["range"].as_str().context("range is required")?; 189 | let range = format!("{}!{}", sheet, user_range); 190 | 191 | let values = args 192 | .get("values") 193 | .and_then(|v| v.as_array()) 194 | .context("values required")?; 195 | let major_dimension = args 196 | .get("major_dimension") 197 | .and_then(|v| v.as_str()) 198 | .unwrap_or("ROWS"); 199 | 200 | let mut value_range = google_sheets4::api::ValueRange::default(); 201 | value_range.major_dimension = Some(major_dimension.to_string()); 202 | value_range.values = Some( 203 | values 204 | .iter() 205 | .map(|row| { 206 | row.as_array() 207 | .unwrap_or(&vec![]) 208 | .iter() 209 | .map(|v| v.as_str().unwrap_or_default().to_string().into()) 210 | .collect::<Vec<serde_json::Value>>() 211 | }) 212 | .collect(), 213 | ); 214 | 215 | let result = sheets 216 | .spreadsheets() 217 | .values_update(value_range, spreadsheet_id, &range) 218 | .value_input_option("RAW") 219 | .doit() 220 | .await?; 221 | 222 | Ok(CallToolResponse { 223 | content: vec![ToolResponseContent::Text { 224 | text: serde_json::to_string(&result.1)?, 225 | }], 226 | is_error: None, 227 | meta: None, 228 | }) 229 | } 230 | .await; 231 | 232 | handle_result(result) 233 | }) 234 | }); 235 | 236 | server.register_tool(create_spreadsheet_tool, move |req: CallToolRequest| { 237 | Box::pin(async move { 238 | let access_token = get_access_token(&req)?; 239 | let args = req.arguments.clone().unwrap_or_default(); 240 | let result = async { 241 | let sheets = get_sheets_client(access_token); 242 | 243 | let title = args["title"].as_str().context("title required")?; 244 | 245 | let mut spreadsheet = google_sheets4::api::Spreadsheet::default(); 246 | spreadsheet.properties = Some(google_sheets4::api::SpreadsheetProperties { 247 | title: Some(title.to_string()), 248 | ..Default::default() 249 | }); 250 | 251 | // Add sheets if specified 252 | if let Some(sheet_configs) = args["sheets"].as_array() { 253 | let sheets = sheet_configs 254 | .iter() 255 | .map(|config| { 256 | let title = config["title"].as_str().unwrap_or("Sheet1").to_string(); 257 | google_sheets4::api::Sheet { 258 | properties: Some(google_sheets4::api::SheetProperties { 259 | title: Some(title), 260 | ..Default::default() 261 | }), 262 | ..Default::default() 263 | } 264 | }) 265 | .collect(); 266 | spreadsheet.sheets = Some(sheets); 267 | } 268 | 269 | let result = sheets.spreadsheets().create(spreadsheet).doit().await?; 270 | 271 | Ok(CallToolResponse { 272 | content: vec![ToolResponseContent::Text { 273 | text: serde_json::to_string(&result.1)?, 274 | }], 275 | is_error: None, 276 | meta: None, 277 | }) 278 | } 279 | .await; 280 | 281 | handle_result(result) 282 | }) 283 | }); 284 | 285 | server.register_tool(clear_values_tool, move |req: CallToolRequest| { 286 | Box::pin(async move { 287 | let access_token = get_access_token(&req)?; 288 | let args = req.arguments.clone().unwrap_or_default(); 289 | let context = req.meta.clone().unwrap_or_default(); 290 | 291 | let result = async { 292 | let sheets = get_sheets_client(access_token); 293 | 294 | let spreadsheet_id = context 295 | .get("spreadsheet_id") 296 | .and_then(|v| v.as_str()) 297 | .context("spreadsheet_id required in context")?; 298 | 299 | let sheet = args 300 | .get("sheet") 301 | .and_then(|v| v.as_str()) 302 | .unwrap_or("Sheet1"); 303 | let user_range = args 304 | .get("range") 305 | .and_then(|v| v.as_str()) 306 | .unwrap_or("A1:ZZ"); 307 | let range = format!("{}!{}", sheet, user_range); 308 | 309 | let clear_request = google_sheets4::api::ClearValuesRequest::default(); 310 | let result = sheets 311 | .spreadsheets() 312 | .values_clear(clear_request, spreadsheet_id, &range) 313 | .doit() 314 | .await?; 315 | 316 | Ok(CallToolResponse { 317 | content: vec![ToolResponseContent::Text { 318 | text: serde_json::to_string(&result.1)?, 319 | }], 320 | is_error: None, 321 | meta: None, 322 | }) 323 | } 324 | .await; 325 | 326 | handle_result(result) 327 | }) 328 | }); 329 | 330 | server.register_tool(get_sheet_info_tool, move |req: CallToolRequest| { 331 | Box::pin(async move { 332 | let access_token = get_access_token(&req)?; 333 | let context = req.meta.clone().unwrap_or_default(); 334 | 335 | let result = async { 336 | let sheets = get_sheets_client(access_token); 337 | 338 | let spreadsheet_id = context 339 | .get("spreadsheet_id") 340 | .and_then(|v| v.as_str()) 341 | .context("spreadsheet_id required in context")?; 342 | 343 | let result = sheets.spreadsheets().get(spreadsheet_id).doit().await?; 344 | 345 | let spreadsheet = result.1; 346 | 347 | // Extract sheet information 348 | let sheet_info = spreadsheet 349 | .sheets 350 | .unwrap_or_default() 351 | .into_iter() 352 | .filter_map(|sheet| { 353 | let props = sheet.properties?; 354 | let title = props.title?; 355 | let grid_props = props.grid_properties?; 356 | 357 | // Calculate the maximum range based on grid properties 358 | let max_col = grid_props.column_count.unwrap_or(26) as u8; 359 | let max_row = grid_props.row_count.unwrap_or(1000); 360 | let max_range = format!("A1:{}{}", (b'A' + max_col - 1) as char, max_row); 361 | 362 | Some(serde_json::json!({ 363 | "title": title, 364 | "maxRange": max_range, 365 | })) 366 | }) 367 | .collect::<Vec<_>>(); 368 | 369 | Ok(CallToolResponse { 370 | content: vec![ToolResponseContent::Text { 371 | text: serde_json::to_string(&sheet_info)?, 372 | }], 373 | is_error: None, 374 | meta: None, 375 | }) 376 | } 377 | .await; 378 | 379 | handle_result(result) 380 | }) 381 | }); 382 | 383 | Ok(()) 384 | } 385 | 386 | fn list_sheets_resources() -> ResourcesListResponse { 387 | let base = Url::parse("https://sheets.googleapis.com/v4/").unwrap(); 388 | ResourcesListResponse { 389 | resources: vec![Resource { 390 | uri: base, 391 | name: "sheets".to_string(), 392 | description: Some("Google Sheets API".to_string()), 393 | mime_type: Some("application/json".to_string()), 394 | }], 395 | next_cursor: None, 396 | meta: None, 397 | } 398 | } 399 | 400 | fn handle_result(result: Result<CallToolResponse>) -> Result<CallToolResponse> { 401 | match result { 402 | Ok(response) => Ok(response), 403 | Err(e) => Ok(CallToolResponse { 404 | content: vec![ToolResponseContent::Text { 405 | text: format!("Error: {}", e), 406 | }], 407 | is_error: Some(true), 408 | meta: None, 409 | }), 410 | } 411 | } 412 | ```