#
tokens: 9803/50000 15/15 files
lines: off (toggle) GitHub
raw markdown copy
# 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(&params).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(&params).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,
        }),
    }
}

```