# 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 |
```