Files
tkmind_go/crates/goose-mcp/src/google_drive/mod.rs
T
2025-03-18 10:48:49 -07:00

1632 lines
62 KiB
Rust

mod oauth_pkce;
pub mod storage;
use anyhow::{Context, Error};
use base64::Engine;
use indoc::indoc;
use oauth_pkce::PkceOAuth2Client;
use regex::Regex;
use serde_json::{json, Value};
use std::io::Cursor;
use std::{env, fs, future::Future, path::Path, pin::Pin, sync::Arc};
use storage::CredentialsManager;
use mcp_core::content::Content;
use mcp_core::{
handler::{PromptError, ResourceError, ToolError},
prompt::Prompt,
protocol::ServerCapabilities,
resource::Resource,
tool::Tool,
};
use mcp_server::router::CapabilitiesBuilder;
use mcp_server::Router;
use google_drive3::common::ReadSeek;
use google_drive3::{
self,
api::{File, Scope},
hyper_rustls::{self, HttpsConnector},
hyper_util::{self, client::legacy::connect::HttpConnector},
DriveHub,
};
use google_sheets4::{self, Sheets};
use http_body_util::BodyExt;
// Constants for credential storage
pub const KEYCHAIN_SERVICE: &str = "mcp_google_drive";
pub const KEYCHAIN_USERNAME: &str = "oauth_credentials";
pub const KEYCHAIN_DISK_FALLBACK_ENV: &str = "GOOGLE_DRIVE_DISK_FALLBACK";
const GOOGLE_DRIVE_SCOPES: Scope = Scope::Full;
#[derive(Debug)]
enum FileOperation {
Create { name: String },
Update { file_id: String },
}
pub struct GoogleDriveRouter {
tools: Vec<Tool>,
instructions: String,
drive: DriveHub<HttpsConnector<HttpConnector>>,
sheets: Sheets<HttpsConnector<HttpConnector>>,
credentials_manager: Arc<CredentialsManager>,
}
impl GoogleDriveRouter {
async fn google_auth() -> (
DriveHub<HttpsConnector<HttpConnector>>,
Sheets<HttpsConnector<HttpConnector>>,
Arc<CredentialsManager>,
) {
let keyfile_path_str = env::var("GOOGLE_DRIVE_OAUTH_PATH")
.unwrap_or_else(|_| "./gcp-oauth.keys.json".to_string());
let credentials_path_str = env::var("GOOGLE_DRIVE_CREDENTIALS_PATH")
.unwrap_or_else(|_| "./gdrive-server-credentials.json".to_string());
let expanded_keyfile = shellexpand::tilde(keyfile_path_str.as_str());
let keyfile_path = Path::new(expanded_keyfile.as_ref());
let expanded_credentials = shellexpand::tilde(credentials_path_str.as_str());
let credentials_path = expanded_credentials.to_string();
tracing::info!(
credentials_path = credentials_path_str,
keyfile_path = keyfile_path_str,
"Google Drive MCP server authentication config paths"
);
if let Ok(oauth_config) = env::var("GOOGLE_DRIVE_OAUTH_CONFIG") {
// Ensure the parent directory exists (create_dir_all is idempotent)
if let Some(parent) = keyfile_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
tracing::error!(
"Failed to create parent directories for {}: {}",
keyfile_path.display(),
e
);
}
}
// Check if the file exists and whether its content matches
// in every other case we attempt to overwrite
let need_to_write = match fs::read_to_string(keyfile_path) {
Ok(existing) if existing == oauth_config => false,
Ok(_) | Err(_) => true,
};
// Overwrite the file if needed
if need_to_write {
if let Err(e) = fs::write(keyfile_path, &oauth_config) {
tracing::error!(
"Failed to write OAuth config to {}: {}",
keyfile_path.display(),
e
);
} else {
tracing::debug!(
"Wrote Google Drive MCP server OAuth config to {}",
keyfile_path.display()
);
}
}
}
// Check if we should fall back to disk, must be explicitly enabled
let fallback_to_disk = match env::var(KEYCHAIN_DISK_FALLBACK_ENV) {
Ok(value) => value.to_lowercase() == "true",
Err(_) => false,
};
// Create a credentials manager for storing tokens securely
let credentials_manager = Arc::new(CredentialsManager::new(
credentials_path.clone(),
fallback_to_disk,
KEYCHAIN_SERVICE.to_string(),
KEYCHAIN_USERNAME.to_string(),
));
// Read the OAuth credentials from the keyfile
match fs::read_to_string(keyfile_path) {
Ok(_) => {
// Create the PKCE OAuth2 client
let auth = PkceOAuth2Client::new(keyfile_path, credentials_manager.clone())
.expect("Failed to create OAuth2 client");
// Create the HTTP client
let client = hyper_util::client::legacy::Client::builder(
hyper_util::rt::TokioExecutor::new(),
)
.build(
hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.unwrap()
.https_or_http()
.enable_http1()
.build(),
);
let drive_hub = DriveHub::new(client.clone(), auth.clone());
let sheets_hub = Sheets::new(client, auth);
// Create and return the DriveHub, Sheets and our PKCE OAuth2 client
(drive_hub, sheets_hub, credentials_manager)
}
Err(e) => {
tracing::error!(
"Failed to read OAuth config from {}: {}",
keyfile_path.display(),
e
);
panic!("Failed to read OAuth config: {}", e);
}
}
}
pub async fn new() -> Self {
let (drive, sheets, credentials_manager) = Self::google_auth().await;
// handle auth
let search_tool = Tool::new(
"search".to_string(),
indoc! {r#"
Search for files in google drive by name, given an input search query.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "String to search for in the file's name.",
},
"mimeType": {
"type": "string",
"description": "MIME type to constrain the search to.",
},
"corpora": {
"type": "string",
"description": "Which corpus to search, either 'user' (default), 'drive' or 'allDrives'",
},
"pageSize": {
"type": "number",
"description": "How many items to return from the search query, default 10, max 100",
}
},
"required": ["query"],
}),
);
let read_tool = Tool::new(
"read".to_string(),
indoc! {r#"
Read a file from google drive using the file uri.
Optionally include base64 encoded images, false by default.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"uri": {
"type": "string",
"description": "google drive uri of the file to read",
},
"includeImages": {
"type": "boolean",
"description": "Whether or not to include images as base64 encoded strings, defaults to false",
}
},
"required": ["uri"],
}),
);
let upload_tool = Tool::new(
"upload".to_string(),
indoc! {r#"
Upload a file to Google Drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The desired filename to use for the uploaded file.",
},
"mimeType": {
"type": "string",
"description": "The MIME type of the file.",
},
"body": {
"type": "string",
"description": "Plain text body of the file to upload. Mutually exclusive with path.",
},
"path": {
"type": "string",
"description": "Path to the file to upload. Mutually exclusive with body.",
},
"parentId": {
"type": "string",
"description": "ID of the parent folder in which to create the file. (default: creates files in the root of 'My Drive')",
},
"allowSharedDrives": {
"type": "boolean",
"description": "Whether to allow access to shared drives or just your personal drive (default: false)",
}
},
"required": ["name", "mimeType"],
}),
);
let create_file_tool = Tool::new(
"create_file".to_string(),
indoc! {r#"
Create a Google file (Document, Spreadsheet, or Slides) in Google Drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the file to create",
},
"fileType": {
"type": "string",
"enum": ["document", "spreadsheet", "slides"],
"description": "Type of Google file to create (document, spreadsheet, or slides)",
},
"body": {
"type": "string",
"description": "Text content for the file (required for document and spreadsheet types)",
},
"path": {
"type": "string",
"description": "Path to a file to upload (required for slides type)",
},
"parentId": {
"type": "string",
"description": "ID of the parent folder in which to create the file (default: creates files in the root of 'My Drive')",
},
"allowSharedDrives": {
"type": "boolean",
"description": "Whether to allow access to shared drives or just your personal drive (default: false)",
}
},
"required": ["name", "fileType"],
}),
);
let update_tool = Tool::new(
"update".to_string(),
indoc! {r#"
Update a Google Drive file with new content.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "The ID of the file to update.",
},
"mimeType": {
"type": "string",
"description": "The MIME type of the file.",
},
"body": {
"type": "string",
"description": "Plain text body of the file to upload. Mutually exclusive with path.",
},
"path": {
"type": "string",
"description": "Path to a local file to use to update the Google Drive file. Mutually exclusive with body.",
},
"allowSharedDrives": {
"type": "boolean",
"description": "Whether to allow access to shared drives or just your personal drive (default: false)",
}
},
"required": ["fileId", "mimeType"],
}),
);
let update_file_tool = Tool::new(
"update_file".to_string(),
indoc! {r#"
Update a Google file (Document, Spreadsheet, or Slides) in Google Drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "ID of the file to update",
},
"fileType": {
"type": "string",
"enum": ["document", "spreadsheet", "slides"],
"description": "Type of Google file to update (document, spreadsheet, or slides)",
},
"body": {
"type": "string",
"description": "Text content for the file (required for document and spreadsheet types)",
},
"path": {
"type": "string",
"description": "Path to a file to upload (required for slides type)",
},
"allowSharedDrives": {
"type": "boolean",
"description": "Whether to allow access to shared drives or just your personal drive (default: false)",
}
},
"required": ["fileId", "fileType"],
}),
);
let sheets_tool = Tool::new(
"sheets_tool".to_string(),
indoc! {r#"
Work with Google Sheets data using various operations.
Supports operations:
- list_sheets: List all sheets in a spreadsheet
- get_columns: Get column headers from a specific sheet
- get_values: Get values from a range
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"spreadsheetId": {
"type": "string",
"description": "The ID of the spreadsheet to work with",
},
"operation": {
"type": "string",
"enum": ["list_sheets", "get_columns", "get_values"],
"description": "The operation to perform on the spreadsheet",
},
"sheetName": {
"type": "string",
"description": "The name of the sheet to work with (optional for some operations)",
},
"range": {
"type": "string",
"description": "The A1 notation of the range to retrieve values (e.g., 'Sheet1!A1:D10')",
}
},
"required": ["spreadsheetId", "operation"],
}),
);
let get_comments_tool = Tool::new(
"get_comments".to_string(),
indoc! {r#"
List comments for a file in google drive, or get one comment and all of its replies.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "Id of the file to list comments for.",
},
"commentId": {
"type": "string",
"description": "Optional ID of the single comment to read in full.",
},
"pageSize": {
"type": "number",
"description": "How many items to return from the search query, default 20, max 100",
}
},
"required": ["fileId"],
}),
);
let instructions = indoc::formatdoc! {r#"
Google Drive MCP Server Instructions
## Overview
The Google Drive MCP server provides tools for interacting with Google Drive files and Google Sheets:
1. search - Find files in your Google Drive
2. read - Read file contents directly using a uri in the `gdrive:///uri` format
3. sheets_tool - Work with Google Sheets data using various operations
## Available Tools
### 1. Search Tool
Search for files in Google Drive, by name and ordered by most recently viewedByMeTime.
A corpora parameter controls which corpus is searched.
Returns: List of files with their names, MIME types, and IDs
### 2. Read File Tool
Read a file's contents using its ID, and optionally include images as base64 encoded data.
The default is to exclude images, to include images set includeImages to true in the query.
Images take up a large amount of context, this should only be used if a
user explicity needs the image data.
Limitations: Google Sheets exporting only supports reading the first sheet. This is an important limitation that should
be communicated to the user whenever dealing with a Google Sheet (mimeType: application/vnd.google-apps.spreadsheet).
### 3. Sheets Tool
Work with Google Sheets data using various operations:
- list_sheets: List all sheets in a spreadsheet
- get_columns: Get column headers from a specific sheet
- get_values: Get values from a range
### 4. Create File Tool
Create Google Workspace files (Docs, Sheets, or Slides) directly in Google Drive.
- For Google Docs: Converts Markdown text to a Google Document
- For Google Sheets: Converts CSV text to a Google Spreadsheet
- For Google Slides: Converts a PowerPoint file to Google Slides (requires a path to the powerpoint file)
### 5. Update File Tool
Update existing Google Workspace files (Docs, Sheets, or Slides) in Google Drive.
- For Google Docs: Updates with new Markdown text
- For Google Sheets: Updates with new CSV text
- For Google Slides: Updates with a new PowerPoint file (requires a path to the powerpoint file)
- Note: This functionally is an overwrite to the slides, warn the user before using this tool.
Parameters:
- spreadsheetId: The ID of the spreadsheet (can be obtained from search results)
- operation: The operation to perform (one of the operations listed above)
- sheetName: The name of the sheet to work with (optional for some operations)
- range: The A1 notation of the range to retrieve values (e.g., 'Sheet1!A1:D10')
## File Format Handling
The server automatically handles different file types:
- Google Docs → Markdown
- Google Sheets → CSV
- Google Presentations → Plain text
- Text/JSON files → UTF-8 text
- Binary files → Base64 encoded
## Common Usage Pattern
1. First, search for the file you want to read, searching by name.
2. Then, use the file URI from the search results to read its contents.
3. For Google Sheets, use the sheets_tool with the appropriate operation.
## Best Practices
1. Always use search first to find the correct file URI
2. Search results include file types (MIME types) to help identify the right file
3. Search is limited to 10 results per query, so use specific search terms
4. The server has read-only access to Google Drive
## Error Handling
If you encounter errors:
1. Verify the file URI is correct
2. Ensure you have access to the file
3. Check if the file format is supported
4. Verify the server is properly configured
Remember: Always use the tools in sequence - search first to get the file URI, then read to access the contents.
"#};
Self {
tools: vec![
search_tool,
read_tool,
upload_tool,
create_file_tool,
update_tool,
update_file_tool,
sheets_tool,
get_comments_tool,
],
instructions,
drive,
sheets,
credentials_manager,
}
}
// Implement search tool functionality
async fn search(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let query = params
.get("query")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The query string is required".to_string(),
))?
.replace('\\', "\\\\")
.replace('\'', "\\'");
let mime_type = params.get("mimeType").and_then(|q| q.as_str());
// extract corpora query parameter, validate options, or default to "user"
let corpus = params
.get("corpora")
.and_then(|c| c.as_str())
.map(|s| {
if ["user", "drive", "allDrives"].contains(&s) {
Ok(s)
} else {
Err(ToolError::InvalidParameters(format!(
"corpora must be either 'user', 'drive', or 'allDrives', got {}",
s
)))
}
})
.unwrap_or(Ok("user"))?;
// extract pageSize, and convert it to an i32, default to 10
let page_size: i32 = params
.get("pageSize")
.map(|s| {
s.as_i64()
.and_then(|n| i32::try_from(n).ok())
.ok_or_else(|| ToolError::InvalidParameters(format!("Invalid pageSize: {}", s)))
.and_then(|n| {
if (0..=100).contains(&n) {
Ok(n)
} else {
Err(ToolError::InvalidParameters(format!(
"pageSize must be between 0 and 100, got {}",
n
)))
}
})
})
.unwrap_or(Ok(10))?;
let mut query_string = format!("name contains '{}'", query);
if let Some(m) = mime_type {
query_string.push_str(&format!(" and mimeType = '{}'", m));
}
let result = self
.drive
.files()
.list()
.corpora(corpus)
.q(query_string.as_str())
.order_by("viewedByMeTime desc")
.param("fields", "files(id, name, mimeType, modifiedTime, size)")
.page_size(page_size)
.supports_all_drives(true)
.include_items_from_all_drives(true)
.clear_scopes() // Scope::MeetReadonly is the default, remove it
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute google drive search query, {}.",
e
))),
Ok(r) => {
let content =
r.1.files
.map(|files| {
files.into_iter().map(|f| {
format!(
"{} ({}) (uri: {})",
f.name.unwrap_or_default(),
f.mime_type.unwrap_or_default(),
f.id.unwrap_or_default()
)
})
})
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n");
Ok(vec![Content::text(content.to_string()).with_priority(0.3)])
}
}
}
async fn fetch_file_metadata(&self, uri: &str) -> Result<File, ToolError> {
self.drive
.files()
.get(uri)
.param("fields", "mimeType")
.supports_all_drives(true)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await
.map_err(|e| {
ToolError::ExecutionError(format!(
"Failed to execute Google Drive get query, {}.",
e
))
})
.map(|r| r.1)
}
fn strip_image_body(&self, input: &str) -> String {
let image_regex = Regex::new(r"<data:image/[a-zA-Z0-9.-]+;base64,[^>]+>").unwrap();
image_regex.replace_all(input, "").to_string()
}
// Helper function that processes one captured image.
// It decodes the base64 data, resizes the image if its width exceeds `max_width`,
// and then returns a new image tag (always output as PNG).
// logic copied from developer/mod.rs
fn process_image(&self, caps: &regex::Captures, max_width: u32) -> Result<Content, Error> {
let base64_data = &caps["data"];
// Decode the Base64 data.
let image_bytes = base64::prelude::BASE64_STANDARD
.decode(base64_data)
.context("Failed to decode base64 image data")?;
// Load the image from the decoded bytes.
let img = xcap::image::load_from_memory(&image_bytes)
.context("Failed to load image from memory")?;
// Resize the image if necessary.
let mut processed_image = img;
if processed_image.width() > max_width {
let scale = max_width as f32 / processed_image.width() as f32;
let new_height = (processed_image.height() as f32 * scale) as u32;
processed_image = xcap::image::DynamicImage::ImageRgba8(xcap::image::imageops::resize(
&processed_image,
max_width,
new_height,
xcap::image::imageops::FilterType::Lanczos3,
));
}
// Write the processed image to an in-memory buffer in PNG format.
let mut buffer: Vec<u8> = Vec::new();
processed_image
.write_to(&mut Cursor::new(&mut buffer), xcap::image::ImageFormat::Png)
.context("Failed to write processed image to buffer")?;
// Re-encode the buffer back into a Base64 string.
let data = base64::prelude::BASE64_STANDARD.encode(&buffer);
Ok(Content::image(data, "image/png"))
}
/// Resizes all base64-encoded images found in the input string.
/// If any image fails to process, an error is returned.
fn resize_images(&self, input: &str) -> Result<Vec<Content>, Error> {
// Regex to match and capture the MIME type and Base64 data.
let image_regex =
Regex::new(r"<data:image/(?P<mime>[a-zA-Z0-9.+-]+);base64,(?P<data>[^>]+)>")
.context("Failed to compile regex")?;
let mut result: Vec<Content> = Vec::new();
// Iterate over all matches, process them, and rebuild the output string.
for caps in image_regex.captures_iter(input) {
let processed_tag = self
.process_image(&caps, 768)
.context("Failed to process one of the images")?;
result.push(processed_tag);
}
Ok(result)
}
// Downloading content with alt=media only works if the file is stored in Drive.
// To download Google Docs, Sheets, and Slides use files.export instead.
async fn export_google_file(
&self,
uri: &str,
mime_type: &str,
include_images: bool,
) -> Result<Vec<Content>, ToolError> {
let export_mime_type = match mime_type {
"application/vnd.google-apps.document" => "text/markdown",
"application/vnd.google-apps.spreadsheet" => "text/csv",
"application/vnd.google-apps.presentation" => "text/plain",
_ => "text/plain",
};
let result = self
.drive
.files()
.export(uri, export_mime_type)
.param("alt", "media")
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute google drive export for {}, {}.",
uri, e
))),
Ok(r) => {
if let Ok(body) = r.into_body().collect().await {
if let Ok(response) = String::from_utf8(body.to_bytes().to_vec()) {
if !include_images {
let content = self.strip_image_body(&response);
Ok(vec![Content::text(content).with_priority(0.1)])
} else {
let images = self.resize_images(&response).map_err(|e| {
ToolError::ExecutionError(format!(
"Failed to resize image(s): {}",
e
))
})?;
let content = self.strip_image_body(&response);
Ok(std::iter::once(Content::text(content).with_priority(0.1))
.chain(images.iter().cloned())
.collect::<Vec<Content>>())
}
} else {
Err(ToolError::ExecutionError(format!(
"Failed to export google drive to string, {}.",
uri,
)))
}
} else {
Err(ToolError::ExecutionError(format!(
"Failed to export google drive document, {}.",
uri,
)))
}
}
}
}
// handle for files we can use files.get on
async fn get_google_file(
&self,
uri: &str,
include_images: bool,
) -> Result<Vec<Content>, ToolError> {
let result = self
.drive
.files()
.get(uri)
.param("alt", "media")
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute google drive export for {}, {}.",
uri, e
))),
Ok(r) => {
let file = r.1;
let mime_type = file
.mime_type
.unwrap_or("application/octet-stream".to_string());
if mime_type.starts_with("text/") || mime_type == "application/json" {
if let Ok(body) = r.0.into_body().collect().await {
if let Ok(response) = String::from_utf8(body.to_bytes().to_vec()) {
if !include_images {
let content = self.strip_image_body(&response);
Ok(vec![Content::text(content).with_priority(0.1)])
} else {
let images = self.resize_images(&response).map_err(|e| {
ToolError::ExecutionError(format!(
"Failed to resize image(s): {}",
e
))
})?;
let content = self.strip_image_body(&response);
Ok(std::iter::once(Content::text(content).with_priority(0.1))
.chain(images.iter().cloned())
.collect::<Vec<Content>>())
}
} else {
Err(ToolError::ExecutionError(format!(
"Failed to convert google drive to string, {}.",
uri,
)))
}
} else {
Err(ToolError::ExecutionError(format!(
"Failed to get google drive document, {}.",
uri,
)))
}
} else {
//TODO: handle base64 image case, see typscript mcp-gdrive
Err(ToolError::ExecutionError(format!(
"Suported mimeType {}, for {}",
mime_type, uri,
)))
}
}
}
}
async fn read(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let uri =
params
.get("uri")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The uri of the file is required".to_string(),
))?;
let drive_uri = uri.replace("gdrive:///", "");
let include_images = params
.get("includeImages")
.and_then(|i| i.as_bool())
.unwrap_or(false);
let metadata = self.fetch_file_metadata(&drive_uri).await?;
let mime_type = metadata.mime_type.ok_or_else(|| {
ToolError::ExecutionError(format!("Missing mime type in file metadata for {}.", uri))
})?;
// Handle Google Docs export
if mime_type.starts_with("application/vnd.google-apps") {
self.export_google_file(&drive_uri, &mime_type, include_images)
.await
} else {
self.get_google_file(&drive_uri, include_images).await
}
}
// Implement sheets_tool functionality
async fn sheets_tool(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let spreadsheet_id = params.get("spreadsheetId").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The spreadsheetId is required".to_string()),
)?;
let operation = params.get("operation").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The operation is required".to_string()),
)?;
match operation {
"list_sheets" => {
// Get spreadsheet metadata to list all sheets
let result = self
.sheets
.spreadsheets()
.get(spreadsheet_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Sheets get query, {}.",
e
))),
Ok(r) => {
let spreadsheet = r.1;
let sheets = spreadsheet.sheets.unwrap_or_default();
let sheets_info = sheets
.into_iter()
.filter_map(|sheet| {
let properties = sheet.properties?;
let title = properties.title?;
let sheet_id = properties.sheet_id?;
let grid_properties = properties.grid_properties?;
Some(format!(
"Sheet: {} (ID: {}, Rows: {}, Columns: {})",
title,
sheet_id,
grid_properties.row_count.unwrap_or(0),
grid_properties.column_count.unwrap_or(0)
))
})
.collect::<Vec<String>>()
.join("\n");
Ok(vec![Content::text(sheets_info).with_priority(0.1)])
}
}
},
"get_columns" => {
// Get the sheet name if provided, otherwise we'll use the first sheet
let sheet_name = params
.get("sheetName")
.and_then(|q| q.as_str())
.map(|s| format!("{}!1:1", s))
.unwrap_or_else(|| "1:1".to_string()); // Default to first row of first sheet
let result = self
.sheets
.spreadsheets()
.values_get(spreadsheet_id, &sheet_name)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Sheets get_columns query, {}.",
e
))),
Ok(r) => {
let value_range = r.1;
// Extract just the headers (first row)
let headers = match value_range.values {
Some(mut values) if !values.is_empty() => {
// Take the first row only
let headers = values.remove(0);
let header_values: Vec<String> = headers
.into_iter()
.map(|cell| cell.as_str().unwrap_or_default().to_string())
.collect();
header_values.join(", ")
}
_ => "No headers found".to_string(),
};
Ok(vec![Content::text(headers).with_priority(0.1)])
}
}
},
"get_values" => {
let range = params
.get("range")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The range is required for get_values operation".to_string(),
))?;
let result = self
.sheets
.spreadsheets()
.values_get(spreadsheet_id, range)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Sheets values_get query, {}.",
e
))),
Ok(r) => {
let value_range = r.1;
// Convert the values to a CSV string
let csv_content = match value_range.values {
Some(values) => {
let mut csv_string = String::new();
for row in values {
let row_values: Vec<String> = row
.into_iter()
.map(|cell| cell.as_str().unwrap_or_default().to_string())
.collect();
csv_string.push_str(&row_values.join(","));
csv_string.push('\n');
}
csv_string
}
None => "No data found".to_string(),
};
Ok(vec![Content::text(csv_content).with_priority(0.1)])
}
}
},
_ => Err(ToolError::InvalidParameters(format!(
"Invalid operation: {}. Supported operations are: list_sheets, get_columns, get_values",
operation
))),
}
}
async fn read_google_resource(&self, uri: String) -> Result<String, ResourceError> {
self.read(json!({"uri": uri}))
.await
.map_err(|e| ResourceError::ExecutionError(e.to_string()))
.map(|contents| {
contents
.into_iter()
.map(|content| content.as_text().unwrap_or_default().to_string())
.collect::<Vec<_>>()
.join("\n")
})
}
async fn list_google_resources(&self, params: Value) -> Vec<Resource> {
let next_page_token = params.get("cursor").and_then(|q| q.as_str());
let mut query = self
.drive
.files()
.list()
.order_by("viewedByMeTime desc")
.page_size(10)
.param("fields", "nextPageToken, files(id, name, mimeType)")
.supports_all_drives(true)
.include_items_from_all_drives(true)
.clear_scopes() // Scope::MeetReadonly is the default, remove it
.add_scope(GOOGLE_DRIVE_SCOPES);
// add a next token if we have one
if let Some(token) = next_page_token {
query = query.page_token(token)
}
let result = query.doit().await;
match result {
Err(_) => {
//Err(ResourceError::ExecutionError(format!(
// "Failed to execute google drive list query, {}.",
// e,
//)));
vec![]
}
Ok(r) => {
r.1.files
.map(|files| {
files.into_iter().map(|f| Resource {
uri: f.id.unwrap_or_default(),
mime_type: f.mime_type.unwrap_or_default(),
name: f.name.unwrap_or_default(),
description: None,
annotations: None,
})
})
.into_iter()
.flatten()
.collect::<Vec<_>>()
}
}
}
async fn upload_to_drive(
&self,
operation: FileOperation,
content: Box<dyn ReadSeek>,
source_mime_type: &str,
target_mime_type: &str,
parent: Option<&str>,
support_all_drives: bool,
) -> Result<Vec<Content>, ToolError> {
let mut req = File {
mime_type: Some(target_mime_type.to_string()),
..Default::default()
};
let builder = self.drive.files();
let result = match operation {
FileOperation::Create { ref name } => {
req.name = Some(name.to_string());
// we only accept parent_id from create tool calls
if let Some(p) = parent {
req.parents = Some(vec![p.to_string()]);
}
builder
.create(req)
.use_content_as_indexable_text(true)
.supports_all_drives(support_all_drives)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.upload(content, source_mime_type.parse().unwrap())
.await
}
FileOperation::Update { ref file_id } => {
builder
.update(req, file_id)
.use_content_as_indexable_text(true)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.supports_all_drives(support_all_drives)
.upload(content, source_mime_type.parse().unwrap())
.await
}
};
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to upload google drive file {:?}, {}.",
operation, e
))),
Ok(r) => Ok(vec![Content::text(format!(
"{} ({}) (uri: {})",
r.1.name.unwrap_or_default(),
r.1.mime_type.unwrap_or_default(),
r.1.id.unwrap_or_default()
))]),
}
}
async fn upload(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let filename =
params
.get("name")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The name param is required".to_string(),
))?;
let mime_type =
params
.get("mimeType")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The mimeType param is required".to_string(),
))?;
let body = params.get("body").and_then(|q| q.as_str());
let path = params.get("path").and_then(|q| q.as_str());
let reader: Box<dyn ReadSeek> = match (body, path) {
(None, None) | (Some(_), Some(_)) => {
return Err(ToolError::InvalidParameters(
"Either the body or path param is required".to_string(),
))
}
(Some(b), None) => Box::new(Cursor::new(b.as_bytes().to_owned())),
(None, Some(p)) => Box::new(std::fs::File::open(p).map_err(|e| {
ToolError::ExecutionError(format!("Error opening {}: {}", p, e).to_string())
})?),
};
let parent_id = params.get("parentId").and_then(|q| q.as_str());
let allow_shared_drives = params
.get("allowSharedDrives")
.and_then(|q| q.as_bool())
.unwrap_or_default();
self.upload_to_drive(
FileOperation::Create {
name: filename.to_string(),
},
reader,
mime_type,
mime_type,
parent_id,
allow_shared_drives,
)
.await
}
async fn create_file(&self, params: Value) -> Result<Vec<Content>, ToolError> {
// Extract common parameters
let filename =
params
.get("name")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The name param is required".to_string(),
))?;
let file_type =
params
.get("fileType")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The fileType param is required".to_string(),
))?;
let parent_id = params.get("parentId").and_then(|q| q.as_str());
let allow_shared_drives = params
.get("allowSharedDrives")
.and_then(|q| q.as_bool())
.unwrap_or_default();
// Determine source and target MIME types based on file_type
let (source_mime_type, target_mime_type, reader): (String, String, Box<dyn ReadSeek>) =
match file_type {
"document" => {
let body = params.get("body").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The body param is required for document file type".to_string(),
),
)?;
(
"text/markdown".to_string(),
"application/vnd.google-apps.document".to_string(),
Box::new(Cursor::new(body.as_bytes().to_owned())),
)
}
"spreadsheet" => {
let body = params.get("body").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The body param is required for spreadsheet file type".to_string(),
),
)?;
(
"text/csv".to_string(),
"application/vnd.google-apps.spreadsheet".to_string(),
Box::new(Cursor::new(body.as_bytes().to_owned())),
)
}
"slides" => {
let path = params.get("path").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The path param is required for slides file type".to_string(),
),
)?;
let file = std::fs::File::open(path).map_err(|e| {
ToolError::ExecutionError(
format!("Error opening {}: {}", path, e).to_string(),
)
})?;
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
.to_string(),
"application/vnd.google-apps.presentation".to_string(),
Box::new(file),
)
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"Invalid fileType: {}. Supported types are: document, spreadsheet, slides",
file_type
)))
}
};
// Upload the file to Google Drive
self.upload_to_drive(
FileOperation::Create {
name: filename.to_string(),
},
reader,
&source_mime_type,
&target_mime_type,
parent_id,
allow_shared_drives,
)
.await
}
async fn update(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let file_id =
params
.get("fileId")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The fileId param is required".to_string(),
))?;
let mime_type =
params
.get("mimeType")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The mimeType param is required".to_string(),
))?;
let body = params.get("body").and_then(|q| q.as_str());
let path = params.get("path").and_then(|q| q.as_str());
let reader: Box<dyn ReadSeek> = match (body, path) {
(None, None) | (Some(_), Some(_)) => {
return Err(ToolError::InvalidParameters(
"Either the body or path param is required".to_string(),
))
}
(Some(b), None) => Box::new(Cursor::new(b.as_bytes().to_owned())),
(None, Some(p)) => Box::new(std::fs::File::open(p).map_err(|e| {
ToolError::ExecutionError(format!("Error opening {}: {}", p, e).to_string())
})?),
};
let allow_shared_drives = params
.get("allowSharedDrives")
.and_then(|q| q.as_bool())
.unwrap_or_default();
self.upload_to_drive(
FileOperation::Update {
file_id: file_id.to_string(),
},
reader,
mime_type,
mime_type,
None,
allow_shared_drives,
)
.await
}
async fn update_file(&self, params: Value) -> Result<Vec<Content>, ToolError> {
// Extract common parameters
let file_id =
params
.get("fileId")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The fileId param is required".to_string(),
))?;
let file_type =
params
.get("fileType")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The fileType param is required".to_string(),
))?;
let allow_shared_drives = params
.get("allowSharedDrives")
.and_then(|q| q.as_bool())
.unwrap_or_default();
// Determine source and target MIME types based on file_type
let (source_mime_type, target_mime_type, reader): (String, String, Box<dyn ReadSeek>) =
match file_type {
"document" => {
let body = params.get("body").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The body param is required for document file type".to_string(),
),
)?;
(
"text/markdown".to_string(),
"application/vnd.google-apps.document".to_string(),
Box::new(Cursor::new(body.as_bytes().to_owned())),
)
}
"spreadsheet" => {
let body = params.get("body").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The body param is required for spreadsheet file type".to_string(),
),
)?;
(
"text/csv".to_string(),
"application/vnd.google-apps.spreadsheet".to_string(),
Box::new(Cursor::new(body.as_bytes().to_owned())),
)
}
"slides" => {
let path = params.get("path").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The path param is required for slides file type".to_string(),
),
)?;
let file = std::fs::File::open(path).map_err(|e| {
ToolError::ExecutionError(
format!("Error opening {}: {}", path, e).to_string(),
)
})?;
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
.to_string(),
"application/vnd.google-apps.presentation".to_string(),
Box::new(file),
)
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"Invalid fileType: {}. Supported types are: document, spreadsheet, slides",
file_type
)))
}
};
// Upload the file to Google Drive
self.upload_to_drive(
FileOperation::Update {
file_id: file_id.to_string(),
},
reader,
&source_mime_type,
&target_mime_type,
None,
allow_shared_drives,
)
.await
}
async fn get_comments(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let file_id =
params
.get("fileId")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The fileId param is required".to_string(),
))?;
let comment_id = params.get("commentId").and_then(|q| q.as_str());
// extract pageSize, and convert it to an i32, default to 20
let page_size: i32 = params
.get("pageSize")
.map(|s| {
s.as_i64()
.and_then(|n| i32::try_from(n).ok())
.ok_or_else(|| ToolError::InvalidParameters(format!("Invalid pageSize: {}", s)))
.and_then(|n| {
if (1..=100).contains(&n) {
Ok(n)
} else {
Err(ToolError::InvalidParameters(format!(
"pageSize must be between 1 and 100, got {}",
n
)))
}
})
})
.unwrap_or(Ok(20))?;
if let Some(comment) = comment_id {
// Use the get comment method to read a single comment
let result = self
.drive
.comments()
.get(file_id, comment)
.param("fields", "*")
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute google drive comment read, {}.",
e
))),
Ok(r) => {
let content = format!(
"Author:{:?} Quoted File Content: {:?} Content: {} Replies: {:?} (created time: {}) (modified time: {})(anchor: {}) (resolved: {}) (id: {})",
r.1.author.unwrap_or_default(),
r.1.quoted_file_content.unwrap_or_default(),
r.1.content.unwrap_or_default(),
r.1.replies.unwrap_or_default(),
r.1.created_time.unwrap_or_default(),
r.1.modified_time.unwrap_or_default(),
r.1.anchor.unwrap_or_default(),
r.1.resolved.unwrap_or_default(),
r.1.id.unwrap_or_default()
);
Ok(vec![Content::text(content.to_string())])
}
}
} else {
let result = self
.drive
.comments()
.list(file_id)
.page_size(page_size)
.param(
"fields",
"comments(author, content, createdTime, modifiedTime, id, resolved)",
)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute google drive comment list, {}.",
e
))),
Ok(r) => {
let content =
r.1.comments
.map(|comments| {
comments.into_iter().map(|c| {
format!(
"Author:{:?} Content: {} (created time: {}) (modified time: {}) (resolved: {}) (id: {})",
c.author.unwrap_or_default(),
c.content.unwrap_or_default(),
c.created_time.unwrap_or_default(),
c.modified_time.unwrap_or_default(),
c.resolved.unwrap_or_default(),
c.id.unwrap_or_default()
)
})
})
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n");
Ok(vec![Content::text(content.to_string())])
}
}
}
}
}
impl Router for GoogleDriveRouter {
fn name(&self) -> String {
"google_drive".to_string()
}
fn instructions(&self) -> String {
self.instructions.clone()
}
fn capabilities(&self) -> ServerCapabilities {
CapabilitiesBuilder::new()
.with_tools(false)
.with_resources(false, false)
.build()
}
fn list_tools(&self) -> Vec<Tool> {
self.tools.clone()
}
fn call_tool(
&self,
tool_name: &str,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Vec<Content>, ToolError>> + Send + 'static>> {
let this = self.clone();
let tool_name = tool_name.to_string();
Box::pin(async move {
match tool_name.as_str() {
"search" => this.search(arguments).await,
"read" => this.read(arguments).await,
"upload" => this.upload(arguments).await,
"create_file" => this.create_file(arguments).await,
"update" => this.update(arguments).await,
"update_file" => this.update_file(arguments).await,
"sheets_tool" => this.sheets_tool(arguments).await,
"get_comments" => this.get_comments(arguments).await,
_ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))),
}
})
}
fn list_resources(&self) -> Vec<Resource> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(async { self.list_google_resources(json!({})).await })
})
}
fn read_resource(
&self,
uri: &str,
) -> Pin<Box<dyn Future<Output = Result<String, ResourceError>> + Send + 'static>> {
let this = self.clone();
let uri_clone = uri.to_string();
Box::pin(async move { this.read_google_resource(uri_clone).await })
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
fn get_prompt(
&self,
prompt_name: &str,
) -> Pin<Box<dyn Future<Output = Result<String, PromptError>> + Send + 'static>> {
let prompt_name = prompt_name.to_string();
Box::pin(async move {
Err(PromptError::NotFound(format!(
"Prompt {} not found",
prompt_name
)))
})
}
}
impl Clone for GoogleDriveRouter {
fn clone(&self) -> Self {
Self {
tools: self.tools.clone(),
instructions: self.instructions.clone(),
drive: self.drive.clone(),
sheets: self.sheets.clone(),
credentials_manager: self.credentials_manager.clone(),
}
}
}