# 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,
}),
}
}
```