Files
goose/crates/goose-mcp/src/google_drive/mod.rs

3420 lines
140 KiB
Rust

mod google_labels;
mod oauth_pkce;
pub mod storage;
use anyhow::{Context, Error};
use base64::Engine;
use chrono::NaiveDate;
use indoc::indoc;
use lazy_static::lazy_static;
use mcp_core::protocol::JsonRpcMessage;
use mcp_core::tool::ToolAnnotations;
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 tokio::sync::mpsc;
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_docs1::{self, Docs};
use google_drive3::common::ReadSeek;
use google_drive3::{
self,
api::{
Comment, File, FileShortcutDetails, LabelFieldModification, LabelModification,
ModifyLabelsRequest, Permission, Reply, Scope,
},
hyper_rustls::{self, HttpsConnector},
hyper_util::{self, client::legacy::connect::HttpConnector},
DriveHub,
};
use google_labels::DriveLabelsHub;
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 },
}
#[derive(PartialEq)]
enum PaginationState {
Start,
Next(String),
End,
}
const PERMISSIONTYPE: &[&str] = &["user", "group", "domain", "anyone"];
const ROLES: &[&str] = &[
"owner",
"organizer",
"fileOrganizer",
"writer",
"commenter",
"reader",
];
lazy_static! {
static ref GOOGLE_DRIVE_ID_REGEX: Regex =
Regex::new(r"^(?:https:\/\/)(?:[\w-]+\.)?google\.com\/(?:[^\/]+\/)*d\/([a-zA-Z0-9_-]+)")
.unwrap();
}
fn extract_google_drive_id(url: &str) -> Option<&str> {
GOOGLE_DRIVE_ID_REGEX
.captures(url)
.and_then(|caps| caps.get(1).map(|m| m.as_str()))
}
pub struct GoogleDriveRouter {
tools: Vec<Tool>,
instructions: String,
drive: DriveHub<HttpsConnector<HttpConnector>>,
drive_labels: DriveLabelsHub<HttpsConnector<HttpConnector>>,
sheets: Sheets<HttpsConnector<HttpConnector>>,
docs: Docs<HttpsConnector<HttpConnector>>,
credentials_manager: Arc<CredentialsManager>,
}
impl GoogleDriveRouter {
async fn google_auth() -> (
DriveHub<HttpsConnector<HttpConnector>>,
DriveLabelsHub<HttpsConnector<HttpConnector>>,
Sheets<HttpsConnector<HttpConnector>>,
Docs<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,
};
// Use factory to create keyring backend consistently
let keyring = goose::keyring::create_default_keyring();
// 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(),
keyring,
));
// 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 drive_labels_hub = DriveLabelsHub::new(client.clone(), auth.clone());
let sheets_hub = Sheets::new(client.clone(), auth.clone());
let docs_hub = Docs::new(client, auth);
// Create and return the DriveHub, Sheets and our PKCE OAuth2 client
(
drive_hub,
drive_labels_hub,
sheets_hub,
docs_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 {
// handle auth
let (drive, drive_labels, sheets, docs, credentials_manager) = Self::google_auth().await;
let search_tool = Tool::new(
"search".to_string(),
indoc! {r#"
List or search for files or labels in google drive by name, given an input search query. At least one of ('name', 'mimeType', or 'parent') are required for file searches.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"driveType": {
"type": "string",
"description": "Required type of object to list or search (file, label)."
},
"name": {
"type": "string",
"description": "String to search for in the file's name.",
},
"mimeType": {
"type": "string",
"description": "Use when searching for a file to constrain the results to just this MIME type.",
},
"parent": {
"type": "string",
"description": "ID of a folder to limit the search to",
},
"driveId": {
"type": "string",
"description": "ID of a shared drive to constrain the search to when using the corpus 'drive'.",
},
"corpora": {
"type": "string",
"description": "Which corpus to search, either 'user' (default), 'drive' (requires a driveID) or 'allDrives'",
},
"pageSize": {
"type": "number",
"description": "How many items to return from the search query, default 10, max 100",
},
"includeLabels": {
"type": "boolean",
"description": "When searching or listing files, also get any applied labels.",
}
},
"required": ["driveType"],
}),
Some(ToolAnnotations {
title: Some("Search GDrive".to_string()),
read_only_hint: true,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let read_tool = Tool::new(
"read".to_string(),
indoc! {r#"
Read a file from google drive using the file URI or the full google drive URL.
One of URI or URL MUST is required.
Optionally include base64 encoded images, false by default.
Example extracting URIs from URLs:
Given "https://docs.google.com/document/d/1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc/edit?tab=t.0#heading=h.5v419d3h97tr"
Pass in "gdrive:///1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc"
Do not include any other path parameters when using URI.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"uri": {
"type": "string",
"description": "google drive uri of the file to read, use this when you have the file URI",
},
"url": {
"type": "string",
"description": "the full google drive URL to read the file from, use this when the user gives a full https url",
},
"includeImages": {
"type": "boolean",
"description": "Whether or not to include images as base64 encoded strings, defaults to false",
}
},
}),
Some(ToolAnnotations {
title: Some("Read GDrive".to_string()),
read_only_hint: true,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let create_file_tool = Tool::new(
"create_file".to_string(),
indoc! {r#"
Create a new file, including Document, Spreadsheet, Slides, folder, or shortcut, in Google Drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the file to create",
},
"mimeType": {
"type": "string",
"description": "The MIME type of the file.",
},
"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')",
},
"targetId": {
"type": "string",
"description": "ID of the file to target when creating a shortcut",
},
"allowSharedDrives": {
"type": "boolean",
"description": "Whether to allow access to shared drives or just your personal drive (default: false)",
}
},
"required": ["name", "mimeType"],
}),
Some(ToolAnnotations {
title: Some("Create new file in GDrive".to_string()),
read_only_hint: false,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let move_file_tool = Tool::new(
"move_file".to_string(),
indoc! {r#"
Move a Google Drive file, folder, or shortcut to a new parent folder. You cannot move a folder to a different drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "The ID of the file to update.",
},
"currentFolderId": {
"type": "string",
"description": "The ID of the current parent folder.",
},
"newFolderId": {
"type": "string",
"description": "The ID of the folder to move the file to.",
},
},
"required": ["fileId", "currentFolderId", "newFolderId"],
}),
Some(ToolAnnotations {
title: Some("Move file".to_string()),
read_only_hint: false,
destructive_hint: true,
idempotent_hint: false,
open_world_hint: false,
}),
);
let update_file_tool = Tool::new(
"update_file".to_string(),
indoc! {r#"
Update an existing file in Google Drive with new content or edit the file's labels.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "The ID of the file to update.",
},
"allowSharedDrives": {
"type": "boolean",
"description": "Whether to allow access to shared drives or just your personal drive (default: false)",
},
"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 (required for Google Document and Google Spreadsheet types).",
},
"path": {
"type": "string",
"description": "Path to a local file to use to update the Google Drive file. Mutually exclusive with body (required for Google Slides type)",
},
"updateLabels": {
"type": "array",
"description": "Array of label operations to perform on the file. Each operation may remove one label, unset one field, or update one field.",
"items": {
"type": "object",
"properties": {
"labelId": {
"type": "string",
"description": "The ID of the label to be operated upon."
},
"operation": {
"type": "string",
"enum": ["removeLabel", "unsetField", "addOrUpdateLabel"],
"description": "The operation to perform. You may 'removeLabel' to completely remove the label from the file, 'unsetField' to remove a field from an applied label, or 'addOrUpdateLabel' to add a new label (with or without fields), or change the value of a field on an applied label."
},
"fieldId": {
"type": "string",
"description": "The ID of the field to be operated upon."
},
"dateValue": {
"type": "array",
"description": "If updating a date field, an array of RFC 3339 dates (format YYYY-MM-DD) to update to.",
"items": {
"type": "string",
"description": "An RFC 3339 full-date format YYYY-MM-DD.",
}
},
"textValue": {
"type": "array",
"description": "If updating a text field, the string values to update to.",
"items": {
"type": "string",
"description": "Text field values.",
}
},
"choiceValue": {
"type": "array",
"description": "If updating a Choice field, the ID(s) of the desired choice field(s).",
"items": {
"type": "string",
"description": "Choice ID as a string",
}
},
"integerValue": {
"type": "array",
"description": "If updating an integer field, the integer values to use.",
"items": {
"type": "integer",
"description": "The integer value.",
}
},
"userValue": {
"type": "array",
"description": "If updating a user field, an array of the email address(es) of the user(s) to set as the field value.",
"items": {
"type": "string",
"description": "Email address as a string",
}
}
}
}
},
},
"required": ["fileId"],
"dependentRequired": {
"body": ["mimeType"],
"path": ["mimeType"]
}
}),
Some(ToolAnnotations {
title: Some("Update a file's contents or labels".to_string()),
read_only_hint: false,
destructive_hint: true,
idempotent_hint: false,
open_world_hint: false,
}),
);
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
- update_values: Update values in a range
- update_cell: Update a single cell value
- add_sheet: Add a new sheet (tab) to a spreadsheet
- clear_values: Clear 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", "update_values", "update_cell", "add_sheet", "clear_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 or update values (e.g., 'Sheet1!A1:D10')",
},
"values": {
"type": "string",
"description": "CSV formatted data for update operations (required for update_values)",
},
"cell": {
"type": "string",
"description": "The A1 notation of the cell to update (e.g., 'Sheet1!A1') for update_cell operation",
},
"value": {
"type": "string",
"description": "The value to set in the cell for update_cell operation",
},
"title": {
"type": "string",
"description": "Title for the new sheet (required for add_sheet)",
},
"valueInputOption": {
"type": "string",
"enum": ["RAW", "USER_ENTERED"],
"description": "How input data should be interpreted (default: USER_ENTERED)",
}
},
"required": ["spreadsheetId", "operation"],
}),
Some(ToolAnnotations {
title: Some("Work with Google Sheets data using various operations.".to_string()),
read_only_hint: false,
destructive_hint: true,
idempotent_hint: false,
open_world_hint: false,
}),
);
let docs_tool = Tool::new(
"docs_tool".to_string(),
indoc! {r#"
Work with Google Docs data using various operations.
Supports operations:
- get_document: Get the full document content
- insert_text: Insert text at a specific location
- append_text: Append text to the end of the document
- replace_text: Replace all instances of text
- create_paragraph: Create a new paragraph
- delete_content: Delete content between positions
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"documentId": {
"type": "string",
"description": "The ID of the document to work with",
},
"operation": {
"type": "string",
"enum": ["get_document", "insert_text", "append_text", "replace_text", "create_paragraph", "delete_content"],
"description": "The operation to perform on the document",
},
"text": {
"type": "string",
"description": "The text to insert, append, or use for replacement",
},
"replaceText": {
"type": "string",
"description": "The text to be replaced",
},
"position": {
"type": "number",
"description": "The position in the document (index) for operations that require a position",
},
"startPosition": {
"type": "number",
"description": "The start position for delete_content operation",
},
"endPosition": {
"type": "number",
"description": "The end position for delete_content operation",
}
},
"required": ["documentId", "operation"],
}),
Some(ToolAnnotations {
title: Some("Work with Google Docs data using various operations.".to_string()),
read_only_hint: false,
destructive_hint: true,
idempotent_hint: false,
open_world_hint: false,
}),
);
let get_comments_tool = Tool::new(
"get_comments".to_string(),
indoc! {r#"
List comments for a file in google drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "Id of the file to list comments for.",
}
},
"required": ["fileId"],
}),
Some(ToolAnnotations {
title: Some("List file comments".to_string()),
read_only_hint: true,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let manage_comment_tool = Tool::new(
"manage_comment".to_string(),
indoc! {r#"
Manage comment for a Google Drive file.
Supports the operations:
- create: Create a comment for the latest revision of a Google Drive file. The Google Drive API only supports unanchored comments (they don't refer to a specific location in the file).
- reply: Add a reply to a comment thread, or resolve a comment.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "Id of the file.",
},
"operation": {
"type": "string",
"description": "Desired comment management operation.",
"enum": ["create", "reply"],
},
"content": {
"type": "string",
"description": "Content of the comment to create or reply.",
},
"commentId": {
"type": "string",
"description": "Id of the comment to which you'd like to reply. ",
},
"resolveComment": {
"type": "boolean",
"description": "Whether to resolve the comment in reply. Defaults to false.",
}
},
"required": ["fileId", "operation", "content"],
}),
Some(ToolAnnotations {
title: Some("Manage file comment".to_string()),
read_only_hint: false,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let list_drives_tool = Tool::new(
"list_drives".to_string(),
indoc! {r#"
List shared Google drives.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"name_contains": {
"type": "string",
"description": "Optional name to search for when listing drives.",
}
},
}),
Some(ToolAnnotations {
title: Some("List shared google drives".to_string()),
read_only_hint: true,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let get_permissions_tool = Tool::new(
"get_permissions".to_string(),
indoc! {r#"
List sharing permissions for a file, folder, or shared drive.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "Id of the file, folder, or shared drive.",
}
},
"required": ["fileId"],
}),
Some(ToolAnnotations {
title: Some("List sharing permissions".to_string()),
read_only_hint: true,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let sharing_tool = Tool::new(
"sharing".to_string(),
indoc! {r#"
Manage sharing for a Google Drive file or folder.
Supports the operations:
- create: Create a new permission for a 'type' identified by the 'target' param to have the 'role' privileges.
- update: Update an existing permission to a different role. (You cannot change the type or to whom it is targeted).
- delete: Delete an existing permission.
"#}
.to_string(),
json!({
"type": "object",
"properties": {
"fileId": {
"type": "string",
"description": "Id of the file or folder.",
},
"operation": {
"type": "string",
"description": "Desired sharing operation.",
"enum": ["create", "update", "delete"],
},
"permissionId": {
"type": "string",
"description": "Permission Id for delete or update operations.",
},
"role": {
"type": "string",
"description": "Role to apply to permission for create or update operations.",
"enum": ["owner", "organizer", "fileOrganizer", "writer", "commenter", "reader"]
},
"type": {
"type": "string",
"description": "Type of permission to create or update.",
"enum": ["user", "group", "domain", "anyone"],
},
"target": {
"type": "string",
"description": "For the user and group types, the email address. For a domain type, the domain name. (The anyone type does not require a target). Required for the create operation.",
},
"emailMessage": {
"type": "string",
"description": "Email notification message to send to users and groups.",
},
},
"required": ["fileId", "operation"],
}),
Some(ToolAnnotations {
title: Some("Manage file sharing".to_string()),
read_only_hint: false,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false,
}),
);
let instructions = indoc::formatdoc! {r#"
Google Drive MCP Server Instructions
## Overview
The Google Drive MCP server provides tools for interacting with Google Drive files, Google Sheets, and Google Docs:
1. search - List or search for files or labels in your Google Drive
2. read - Read file contents directly using a uri in the `gdrive:///uri` format
3. move_file - Move a file to a new location in Google Drive
4. list_drives - List the shared drives to which you have access
5. get_permissions - List the permissions of a file or folder
6. sharing - Share a file or folder with others
7. get_comments - List a file or folder's comments
8. manage_comment - Manage comment for a Google Drive file.
9. create_file - Create a new file
10. update_file - Update an existing file's contents or labels
11. sheets_tool - Work with Google Sheets data using various operations
12. docs_tool - Work with Google Docs data using various operations
## Available Tools
### 1. Search Tool
Search for or list files or labels in Google Drive. Files are
searched 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 or a
list of labels and their fields.
### 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.
Example mappings for Google Drive resources to `gdrive:///$URI` format:
- Google Document File:
Example URL: https://docs.google.com/document/d/1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc/edit?tab=t.0#heading=h.5v419d3h97tr
URI Format: gdrive:///1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc
- Google Sheet:
Example URL: https://docs.google.com/spreadsheets/d/1J5KHqWsGFzweuiQboX7dlm8Ejv90Po16ocEBahzCt4W/edit?gid=1249300797#gid=1249300797
URI Format: gdrive:///1J5KHqWsGFzweuiQboX7dlm8Ejv90Po16ocEBahzCt4W
- Google Slides:
Example URL: https://docs.google.com/presentation/d/1zXWqsGpHJEu40oqb1omh68sW9liu7EKFBCdnPaJVoQ5et/edit#slide=id.p1
URI Format: gdrive:///1zXWqsGpHJEu40oqb1omh68sW9liu7EKFBCdnPaJVoQ5et
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).
#### File Format Handling
The read file tool's output will be converted:
- Google Docs → Markdown
- Google Sheets → CSV
- Google Presentations → Plain text
- Text/JSON files → UTF-8 text
- Binary files → Base64 encoded
### 3. Move File Tool
Move a file from its current folder to a new folder, including folders on another drive.
### 4. List Drives Tool
Lists the user's available Shared Drives.
### 5. Get Permissions Tool
Lists the permissions for a file or folder. Permissions in Google
Drive consist of a type ('user', 'group', 'domain', 'anyone') and a role
('owner', 'organizer', 'fileOrganizer', 'writer', 'commenter',
'reader').
### 6. Sharing Tool
Create a new permission, update the role on an existing permission,
or delete a permission. User, group, and domain permissions should
have a provided "target" email address or domain name.
### 7. Get Comments Tool
Lists the comments for a Google Workspace file.
### 8. Manage Comment Tool
Create or reply comment for a Google Drive file.
### 9. Create File Tool
Create any kind of file, including 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)
- Other: No file conversion.
*Note*: All updates overwrite the existing content with the new
content provided. To modify specific parts of the document, you must
include the changes as part of the entire document.
### 10. Update File Tool
Replace the entire contents of an existing file with new content,
including Google Workspace files (Docs, Sheets, or Slides), or
update the labels applied to a file.
- 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)
- Other: No file conversion.
Label operations include adding a new label, unsetting a field for
an already-applied label, removing a label, or changing the field
value for an applied label.
### 11. 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
- update_values: Update values in a range (requires CSV formatted data)
- update_cell: Update a single cell value
- add_sheet: Add a new sheet (tab) to a spreadsheet
- clear_values: Clear values from a range
For update_values operation, provide CSV formatted data in the values parameter.
Each line represents a row, with values separated by commas.
Example: "John,Doe,30\nJane,Smith,25"
For update_cell operation, provide the cell reference (e.g., 'Sheet1!A1') and the value to set.
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 or update values (e.g., 'Sheet1!A1:D10')
- values: CSV formatted data for update operations
- cell: The A1 notation of the cell to update (e.g., 'Sheet1!A1') for update_cell operation
- value: The value to set in the cell for update_cell operation
- title: Title for the new sheet (required for add_sheet operation)
- valueInputOption: How input data should be interpreted (RAW or USER_ENTERED)
### 12. Docs Tool
Work with Google Docs data using various operations:
- get_document: Get the full document content
- insert_text: Insert text at a specific location
- append_text: Append text to the end of the document
- replace_text: Replace all instances of text
- create_paragraph: Create a new paragraph
- delete_content: Delete content between positions
Parameters:
- documentId: The ID of the document (can be obtained from search results)
- operation: The operation to perform (one of the operations listed above)
- text: The text to insert, append, or use for replacement
- replaceText: The text to be replaced (for replace_text operation)
- position: The position in the document (index) for operations that require a position
- startPosition: The start position for delete_content operation
- endPosition: The end position for delete_content operation
## 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.
4. For Google Docs, use the docs_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. When updating sheet values, format the data as CSV with one row per line
## 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,
create_file_tool,
move_file_tool,
update_file_tool,
sheets_tool,
docs_tool,
get_comments_tool,
manage_comment_tool,
list_drives_tool,
get_permissions_tool,
sharing_tool,
],
instructions,
drive,
drive_labels,
sheets,
docs,
credentials_manager,
}
}
// Implement search tool functionality
async fn search(&self, params: Value) -> Result<Vec<Content>, ToolError> {
// To minimize tool growth, we search/list for a number of different
// objects in Gdrive with sub-funcs.
let drive_type = params.get("driveType").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The type is required".to_string()),
)?;
match drive_type {
"file" => return self.search_files(params).await,
"label" => return self.list_labels(params).await,
t => Err(ToolError::InvalidParameters(format!(
"type must be one of ('file', 'label'), got {}",
t
))),
}
}
async fn search_files(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let name = params.get("name").and_then(|q| q.as_str());
let mime_type = params.get("mimeType").and_then(|q| q.as_str());
let drive_id = params.get("driveId").and_then(|q| q.as_str());
let parent = params.get("parent").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 include_labels = params
.get("includeLabels")
.and_then(|b| b.as_bool())
.unwrap_or(false);
let mut query = Vec::new();
if let Some(n) = name {
query.push(
format!(
"name contains '{}'",
n.replace('\\', "\\\\").replace('\'', "\\'")
)
.to_string(),
);
}
if let Some(m) = mime_type {
query.push(format!("mimeType = '{}'", m).to_string());
}
if let Some(p) = parent {
query.push(format!("'{}' in parents", p).to_string());
}
let query_string = query.join(" and ");
if query_string.is_empty() {
return Err(ToolError::InvalidParameters(
"No query provided. Please include one of ('name', 'mimeType', 'parent')."
.to_string(),
));
}
let mut builder = self
.drive
.files()
.list()
.corpora(corpus)
.q(query_string.as_str())
.order_by("viewedByMeTime desc")
.param(
"fields",
&format!(
"files(id, name, mimeType, modifiedTime, size{})",
if include_labels { ", labelInfo" } else { "" }
),
)
.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);
// You can only use the drive_id param when the corpus is "drive".
if let (Some(d), "drive") = (drive_id, corpus) {
builder = builder.drive_id(d);
}
// If we want labels, we have to go look up the IDs first.
// let mut label_results: Vec<Label> = Vec::new();
if include_labels {
let label_builder = self
.drive_labels
.labels()
.list()
.param("view", "LABEL_VIEW_BASIC");
// .param("view", "LABEL_VIEW_FULL");
let label_results = match label_builder.doit().await {
Ok(r) => r.1.labels.unwrap_or_default(),
Err(e) => {
return Err(ToolError::ExecutionError(format!(
"Failed to execute google drive label list '{}'.",
e
)))
}
};
let label_ids = label_results
.iter()
.filter_map(|l| l.id.clone())
.collect::<Vec<_>>()
.join(",");
builder = builder.include_labels(&label_ids);
}
let result = builder.doit().await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute google drive search query '{}', {}.",
query_string.as_str(),
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(),
if include_labels {
format!(" (labels: {:?})", f.label_info.unwrap_or_default())
} else {
"".to_string()
}
)
})
})
.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,
mime_type: &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) => {
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 (maybe_uri, maybe_url) = (
params.get("uri").and_then(|q| q.as_str()),
params.get("url").and_then(|q| q.as_str()),
);
let drive_uri = match (maybe_uri, maybe_url) {
(Some(uri), None) => {
let drive_uri = uri.replace("gdrive:///", "");
// Validation: check for / path separators as invalid uris
if drive_uri.contains('/') {
return Err(ToolError::InvalidParameters(format!(
"The uri '{}' contains extra '/'. Only the base URI is allowed.",
uri
)));
}
drive_uri
}
(None, Some(url)) => {
if let Some(drive_uri) = extract_google_drive_id(url) {
drive_uri.to_string()
} else {
return Err(ToolError::InvalidParameters(format!(
"Failed to extract valid google drive URI from {}",
url
)));
}
}
(Some(_), Some(_)) => {
return Err(ToolError::InvalidParameters(
"Only one of 'uri' or 'url' should be provided".to_string(),
));
}
(None, None) => {
return Err(ToolError::InvalidParameters(
"Either 'uri' or 'url' must be provided".to_string(),
));
}
};
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 {}.",
drive_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, &mime_type, 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)])
}
}
},
"update_values" => {
let range = params
.get("range")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The range is required for update_values operation".to_string(),
))?;
let values_csv = params
.get("values")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The values parameter is required for update_values operation".to_string(),
))?;
// Parse the CSV data into a 2D array of values
let mut values: Vec<Vec<serde_json::Value>> = Vec::new();
for line in values_csv.lines() {
let row: Vec<serde_json::Value> = line
.split(',')
.map(|cell| serde_json::Value::String(cell.trim().to_string()))
.collect();
if !row.is_empty() {
values.push(row);
}
}
// Determine the input option (default to USER_ENTERED)
let value_input_option = params
.get("valueInputOption")
.and_then(|q| q.as_str())
.unwrap_or("USER_ENTERED");
// Create the ValueRange objec
let value_range = google_sheets4::api::ValueRange {
range: Some(range.to_string()),
values: Some(values),
major_dimension: None,
};
// Update the values
let result = self
.sheets
.spreadsheets()
.values_update(value_range, spreadsheet_id, range)
.value_input_option(value_input_option)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Sheets values_update query, {}.",
e
))),
Ok(r) => {
let update_response = r.1;
let updated_cells = update_response.updated_cells.unwrap_or(0);
let updated_rows = update_response.updated_rows.unwrap_or(0);
let updated_columns = update_response.updated_columns.unwrap_or(0);
let updated_range = update_response.updated_range.unwrap_or_default();
let response = format!(
"Successfully updated values in range '{}'. Updated {} cells across {} rows and {} columns.",
updated_range, updated_cells, updated_rows, updated_columns
);
Ok(vec![Content::text(response).with_priority(0.1)])
}
}
},
"update_cell" => {
let cell = params
.get("cell")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The cell parameter is required for update_cell operation".to_string(),
))?;
let value = params
.get("value")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The value parameter is required for update_cell operation".to_string(),
))?;
// Determine the input option (default to USER_ENTERED)
let value_input_option = params
.get("valueInputOption")
.and_then(|q| q.as_str())
.unwrap_or("USER_ENTERED");
// Create a single-cell ValueRange objec
let value_range = google_sheets4::api::ValueRange {
range: Some(cell.to_string()),
values: Some(vec![vec![serde_json::Value::String(value.to_string())]]),
major_dimension: None,
};
// Update the cell value
let result = self
.sheets
.spreadsheets()
.values_update(value_range, spreadsheet_id, cell)
.value_input_option(value_input_option)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Sheets update_cell operation, {}.",
e
))),
Ok(r) => {
let update_response = r.1;
let updated_range = update_response.updated_range.unwrap_or_default();
Ok(vec![Content::text(format!(
"Successfully updated cell '{}' with value '{}'.",
updated_range, value
)).with_priority(0.1)])
}
}
},
"add_sheet" => {
let title = params
.get("title")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The title parameter is required for add_sheet operation".to_string(),
))?;
// Create the AddSheetReques
let add_sheet_request = google_sheets4::api::AddSheetRequest {
properties: Some(google_sheets4::api::SheetProperties {
title: Some(title.to_string()),
sheet_id: None, // Google will auto-assign a sheet ID
index: None,
sheet_type: None,
grid_properties: None,
hidden: None,
tab_color: None,
right_to_left: None,
data_source_sheet_properties: None,
tab_color_style: None,
}),
};
// Create the BatchUpdateSpreadsheetReques
let batch_update_request = google_sheets4::api::BatchUpdateSpreadsheetRequest {
requests: Some(vec![google_sheets4::api::Request {
add_sheet: Some(add_sheet_request),
..google_sheets4::api::Request::default()
}]),
include_spreadsheet_in_response: Some(true),
response_ranges: None,
response_include_grid_data: None,
};
// Execute the batch update
let result = self
.sheets
.spreadsheets()
.batch_update(batch_update_request, spreadsheet_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Sheets add_sheet operation, {}.",
e
))),
Ok(r) => {
let response = r.1;
let replies = response.replies.unwrap_or_default();
if let Some(first_reply) = replies.first() {
if let Some(add_sheet_response) = &first_reply.add_sheet {
if let Some(properties) = &add_sheet_response.properties {
let sheet_id = properties.sheet_id.unwrap_or(0);
let title = properties.title.as_deref().unwrap_or("Unknown");
let response = format!(
"Successfully added new sheet '{}' with ID {}.",
title, sheet_id
);
return Ok(vec![Content::text(response).with_priority(0.1)]);
}
}
}
// Generic success message if we couldn't extract specific details
Ok(vec![Content::text(format!(
"Successfully added new sheet '{}'.",
title
)).with_priority(0.1)])
}
}
},
"clear_values" => {
let range = params
.get("range")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The range is required for clear_values operation".to_string(),
))?;
// Create the ClearValuesReques
let clear_values_request = google_sheets4::api::ClearValuesRequest::default();
// Execute the clear values reques
let result = self
.sheets
.spreadsheets()
.values_clear(clear_values_request, 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 clear_values operation, {}.",
e
))),
Ok(r) => {
let response = r.1;
let cleared_range = response.cleared_range.unwrap_or_default();
Ok(vec![Content::text(format!(
"Successfully cleared values in range '{}'.",
cleared_range
)).with_priority(0.1)])
}
}
},
_ => Err(ToolError::InvalidParameters(format!(
"Invalid operation: {}. Supported operations are: list_sheets, get_columns, get_values, update_values, update_cell, add_sheet, clear_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<_>>()
}
}
}
#[allow(clippy::too_many_arguments)]
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,
target_id: Option<&str>,
) -> 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()]);
}
if let Some(t) = target_id {
req.shortcut_details = Some(FileShortcutDetails {
target_id: Some(t.to_string()),
..Default::default()
});
}
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 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 mime_type =
params
.get("mimeType")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The mimeType param is required".to_string(),
))?;
let parent_id = params.get("parentId").and_then(|q| q.as_str());
let target_id = params.get("targetId").and_then(|q| q.as_str());
let body = params.get("body").and_then(|q| q.as_str());
let path = params.get("path").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 mime_type {
"application/vnd.google-apps.document" => {
if body.is_none() {
return Err(ToolError::InvalidParameters(
"The body param is required for google document file type".to_string(),
));
}
(
"text/markdown".to_string(),
mime_type.to_string(),
Box::new(Cursor::new(body.unwrap().as_bytes().to_owned())),
)
}
"application/vnd.google-apps.spreadsheet" => {
if body.is_none() {
return Err(ToolError::InvalidParameters(
"The body param is required for google spreadsheet file type"
.to_string(),
));
}
(
"text/csv".to_string(),
mime_type.to_string(),
Box::new(Cursor::new(body.unwrap().as_bytes().to_owned())),
)
}
"application/vnd.google-apps.presentation" => {
if path.is_none() {
return Err(ToolError::InvalidParameters(
"The path param is required for google slides file type".to_string(),
));
}
let file = std::fs::File::open(path.unwrap()).map_err(|e| {
ToolError::ExecutionError(
format!("Error opening {}: {}", path.unwrap(), e).to_string(),
)
})?;
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
.to_string(),
mime_type.to_string(),
Box::new(file),
)
}
"application/vnd.google-apps.folder" => {
let emptybuf: [u8; 0] = [];
let empty_stream = Cursor::new(emptybuf);
(
mime_type.to_string(),
mime_type.to_string(),
Box::new(empty_stream),
)
}
"application/vnd.google-apps.shortcut" => {
if target_id.is_none() {
return Err(ToolError::InvalidParameters(
"The targetId param is required when creating a shortcut".to_string(),
));
}
let emptybuf: [u8; 0] = [];
let empty_stream = Cursor::new(emptybuf);
(
mime_type.to_string(),
mime_type.to_string(),
Box::new(empty_stream),
)
}
_ => {
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(),
)
})?),
};
(mime_type.to_string(), mime_type.to_string(), reader)
}
};
// 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,
target_id,
)
.await
}
async fn move_file(&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 current_folder_id = params
.get("currentFolderId")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The currentFolderId param is required".to_string(),
))?;
let new_folder_id = params.get("newFolderId").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The newFolderId param is required".to_string()),
)?;
let req = File::default();
let result = self
.drive
.files()
.update(req, file_id)
.add_parents(new_folder_id)
.remove_parents(current_folder_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.supports_all_drives(true)
.doit_without_upload()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to move google drive file {}, {}.",
file_id, 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 update_file(&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 allow_shared_drives = params
.get("allowSharedDrives")
.and_then(|q| q.as_bool())
.unwrap_or_default();
let mime_type = params.get("mimeType").and_then(|q| q.as_str());
let body = params.get("body").and_then(|q| q.as_str());
let path = params.get("path").and_then(|q| q.as_str());
let mut final_result = vec![];
if mime_type.is_some() && (body.is_some() || path.is_some()) {
let update_result = self
.update_file_contents(file_id, mime_type.unwrap(), body, path, allow_shared_drives)
.await?;
final_result.extend(update_result);
};
if let Some(label_ops) = params.get("updateLabels").and_then(|q| q.as_array()) {
let label_result = self.update_label(file_id, label_ops).await?;
final_result.extend(label_result);
};
Ok(final_result)
}
async fn update_label(
&self,
file_id: &str,
label_ops: &Vec<Value>,
) -> Result<Vec<Content>, ToolError> {
let mut req = ModifyLabelsRequest::default();
let mut label_mods = vec![];
for op in label_ops {
if let Some(op) = op.as_object() {
let label_id = op.get("labelId").and_then(|o| o.as_str()).ok_or(
ToolError::InvalidParameters(
"The labelId param is required for label changes".to_string(),
),
)?;
match op.get("operation").and_then(|o| o.as_str()) {
Some("removeLabel") => {
let removal = LabelModification {
label_id: Some(label_id.to_string()),
remove_label: Some(true),
..Default::default()
};
label_mods.push(removal);
}
Some("unsetField") => {
let field_id = op.get("fieldId").and_then(|o| o.as_str()).ok_or(
ToolError::InvalidParameters(
"The fieldId param is required for unsetting a field.".to_string(),
),
)?;
let field_mods = LabelFieldModification {
field_id: Some(field_id.to_string()),
unset_values: Some(true),
..Default::default()
};
let unset_mod = LabelModification {
label_id: Some(label_id.to_string()),
field_modifications: Some(vec![field_mods]),
..Default::default()
};
label_mods.push(unset_mod);
}
Some("addOrUpdateLabel") => {
let mut field_mods = LabelFieldModification::default();
// Not all labels _have_ fields.
if let Some(field_id) = op.get("fieldId").and_then(|o| o.as_str()) {
field_mods.field_id = Some(field_id.to_string());
}
if let Some(date_value) = op.get("dateValue").and_then(|o| o.as_array()) {
let parsed_dates: Result<Vec<NaiveDate>, ToolError> = date_value
.iter()
.filter_map(|d| d.as_str())
.map(|d| {
NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|e| {
ToolError::InvalidParameters(format!(
"Error parsing field date: {}",
e
))
})
})
.collect();
field_mods.set_date_values = Some(parsed_dates?);
} else if let Some(text_value) =
op.get("textValue").and_then(|o| o.as_array())
{
field_mods.set_text_values = Some(
text_value
.iter()
.map(|s| s.as_str().unwrap_or_default().to_string())
.collect(),
);
} else if let Some(choice_value) =
op.get("choiceValue").and_then(|o| o.as_array())
{
field_mods.set_selection_values = Some(
choice_value
.iter()
.map(|s| s.as_str().unwrap_or_default().to_string())
.collect(),
);
} else if let Some(int_value) =
op.get("integerValue").and_then(|o| o.as_array())
{
field_mods.set_integer_values = Some(
int_value
.iter()
.map(|s| s.as_i64().unwrap_or_default())
.collect(),
);
} else if let Some(user_value) =
op.get("userValue").and_then(|o| o.as_array())
{
field_mods.set_user_values = Some(
user_value
.iter()
.map(|s| s.as_str().unwrap_or_default().to_string())
.collect(),
);
}
let update_mod = LabelModification {
label_id: Some(label_id.to_string()),
field_modifications: Some(vec![field_mods]),
..Default::default()
};
label_mods.push(update_mod);
}
_ => {
return Err(ToolError::InvalidParameters(format!(
"Label operation invalid: {:?}",
op.get("operation")
)))
}
}
};
}
req.label_modifications = Some(label_mods);
let result = self.drive.files().modify_labels(req, file_id).doit().await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to update label for google drive file {}, {}.",
file_id, e
))),
Ok(r) => Ok(vec![Content::text(format!(
"file URI: {}, labels modified: {:?}",
file_id,
r.1.modified_labels.unwrap_or_default()
))]),
}
}
async fn update_file_contents(
&self,
file_id: &str,
mime_type: &str,
body: Option<&str>,
path: Option<&str>,
allow_shared_drives: bool,
) -> Result<Vec<Content>, ToolError> {
// Determine source and target MIME types based on file_type
let (source_mime_type, target_mime_type, reader): (String, String, Box<dyn ReadSeek>) =
match mime_type {
"application/vnd.google-apps.document" => {
if body.is_none() {
return Err(ToolError::InvalidParameters(
"The body param is required for google document file type".to_string(),
));
}
(
"text/markdown".to_string(),
mime_type.to_string(),
Box::new(Cursor::new(body.unwrap().as_bytes().to_owned())),
)
}
"application/vnd.google-apps.spreadsheet" => {
if body.is_none() {
return Err(ToolError::InvalidParameters(
"The body param is required for google spreadsheet file type"
.to_string(),
));
}
(
"text/csv".to_string(),
mime_type.to_string(),
Box::new(Cursor::new(body.unwrap().as_bytes().to_owned())),
)
}
"application/vnd.google-apps.presentation" => {
if path.is_none() {
return Err(ToolError::InvalidParameters(
"The path param is required for google slides file type".to_string(),
));
}
let file = std::fs::File::open(path.unwrap()).map_err(|e| {
ToolError::ExecutionError(
format!("Error opening {}: {}", path.unwrap(), e).to_string(),
)
})?;
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
.to_string(),
mime_type.to_string(),
Box::new(file),
)
}
_ => {
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(),
)
})?),
};
(mime_type.to_string(), mime_type.to_string(), reader)
}
};
self.upload_to_drive(
FileOperation::Update {
file_id: file_id.to_string(),
},
reader,
&source_mime_type,
&target_mime_type,
None,
allow_shared_drives,
None,
)
.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 mut results: Vec<String> = Vec::new();
let mut state = PaginationState::Start;
while state != PaginationState::End {
let mut comment_list = self
.drive
.comments()
.list(file_id)
// 100 is the maximum according to the API.
.page_size(100)
.param("fields", "*")
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES);
if let PaginationState::Next(pt) = state {
comment_list = comment_list.page_token(&pt);
}
let result = comment_list.doit().await;
match result {
Err(e) => {
return Err(ToolError::ExecutionError(format!(
"Failed to execute google drive comment list, {}.",
e
)))
}
Ok(r) => {
let mut content =
r.1.comments
.map(|comments| {
comments.into_iter().map(|c| {
format!(
"Author:{:?} Quoted File Content: {:?} Content: {} Replies: {:?} (created time: {}) (modified time: {})(anchor: {}) (resolved: {}) (id: {})",
c.author.unwrap_or_default(),
c.quoted_file_content.unwrap_or_default(),
c.content.unwrap_or_default(),
c.replies.unwrap_or_default(),
c.created_time.unwrap_or_default(),
c.modified_time.unwrap_or_default(),
c.anchor.unwrap_or_default(),
c.resolved.unwrap_or_default(),
c.id.unwrap_or_default()
)
})
})
.into_iter()
.flatten()
.collect::<Vec<_>>();
results.append(&mut content);
state = match r.1.next_page_token {
Some(npt) => PaginationState::Next(npt),
None => PaginationState::End,
}
}
}
}
Ok(vec![Content::text(results.join("\n"))])
}
async fn manage_comment(&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 operation = params.get("operation").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The operation is required".to_string()),
)?;
let content =
params
.get("content")
.and_then(|q| q.as_str())
.ok_or(ToolError::InvalidParameters(
"The content param is required if the action is create".to_string(),
))?;
match operation {
"create" => {
let req = Comment {
content: Some(content.to_string()),
..Default::default()
};
let result = self
.drive
.comments()
.create(req, file_id)
.clear_scopes() // Scope::MeetReadonly is the default, remove it
.add_scope(GOOGLE_DRIVE_SCOPES)
.param("fields", "*")
// .param("fields", "action, author, content, createdTime, id")
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to add comment for google drive file {}, {}.",
file_id, e
))),
Ok(r) => Ok(vec![Content::text(format!(
"Author: {:?} Content: {} Created: {} uri: {} quoted_content: {:?}",
r.1.author.unwrap_or_default(),
r.1.content.unwrap_or_default(),
r.1.created_time.unwrap_or_default(),
r.1.id.unwrap_or_default(),
r.1.quoted_file_content.unwrap_or_default()
))]),
}
}
"reply" => {
let comment_id = params.get("commentId").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters(
"The commentId param is required for reply".to_string(),
),
)?;
let resolve_comment = params
.get("resolveComment")
.and_then(|q| q.as_bool())
.unwrap_or(false);
let mut req = Reply {
content: Some(content.to_string()),
..Default::default()
};
if resolve_comment {
req.action = Some("resolve".to_string());
}
let result = self
.drive
.replies()
.create(req, file_id, comment_id)
.clear_scopes() // Scope::MeetReadonly is the default, remove it
.add_scope(GOOGLE_DRIVE_SCOPES)
.param("fields", "action, author, content, createdTime, id")
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to manage reply to comment {} for google drive file {}, {}.",
comment_id, file_id, e
))),
Ok(r) => Ok(vec![Content::text(format!(
"Action: {} Author: {:?} Content: {} Created: {} uri: {}",
r.1.action.unwrap_or_default(),
r.1.author.unwrap_or_default(),
r.1.content.unwrap_or_default(),
r.1.created_time.unwrap_or_default(),
r.1.id.unwrap_or_default()
))]),
}
}
_ => Err(ToolError::InvalidParameters(format!(
"Invalid operation: {}. Supported operations are: create, reply",
operation
))),
}
}
async fn docs_tool(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let document_id = params.get("documentId").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The documentId 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 {
"get_document" => {
// Get the document content
let result = self
.docs
.documents()
.get(document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Docs get query, {}.",
e
))),
Ok(r) => {
let document = r.1;
let title = document.title.unwrap_or_default();
// Extract the document content as text
let mut content = String::new();
content.push_str(&format!("# {}\n\n", title));
if let Some(body) = document.body {
if let Some(content_items) = body.content {
for item in content_items {
if let Some(paragraph) = item.paragraph {
if let Some(elements) = paragraph.elements {
for element in elements {
if let Some(text_run) = element.text_run {
if let Some(text) = text_run.content {
content.push_str(&text);
}
}
}
}
}
}
}
}
Ok(vec![Content::text(content).with_priority(0.1)])
}
}
},
"insert_text" => {
let text = params.get("text").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The text parameter is required for insert_text operation".to_string()),
)?;
let position = params.get("position").and_then(|q| q.as_i64()).ok_or(
ToolError::InvalidParameters("The position parameter is required for insert_text operation".to_string()),
)?;
// Create the insert text request
let insert_text_request = google_docs1::api::InsertTextRequest {
text: Some(text.to_string()),
location: Some(google_docs1::api::Location {
index: Some(position.try_into().unwrap()),
segment_id: None,
}),
end_of_segment_location: None,
};
// Create the batch update request
let batch_update_request = google_docs1::api::BatchUpdateDocumentRequest {
requests: Some(vec![google_docs1::api::Request {
insert_text: Some(insert_text_request),
..google_docs1::api::Request::default()
}]),
write_control: None,
};
// Execute the batch update
let result = self
.docs
.documents()
.batch_update(batch_update_request, document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Docs insert_text operation, {}.",
e
))),
Ok(_) => {
Ok(vec![Content::text(format!(
"Successfully inserted text at position {}.",
position
)).with_priority(0.1)])
}
}
},
"append_text" => {
let text = params.get("text").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The text parameter is required for append_text operation".to_string()),
)?;
// First, get the document to find the end position
let get_result = self
.docs
.documents()
.get(document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
let end_index = match get_result {
Err(e) => {
return Err(ToolError::ExecutionError(format!(
"Failed to get document to determine end position, {}.",
e
)));
},
Ok(r) => {
let document = r.1;
if let Some(body) = document.body {
body.content.and_then(|content| {
content.last().and_then(|last_item| {
last_item.end_index
})
}).unwrap_or(1) // Default to 1 if we can't determine the end position
} else {
1 // Default to 1 if there's no body
}
}
};
// Create the insert text request at the end position
let insert_text_request = google_docs1::api::InsertTextRequest {
text: Some(text.to_string()),
location: Some(google_docs1::api::Location {
index: Some(end_index - 1), // -1 because end_index is one past the last character
segment_id: None,
}),
end_of_segment_location: None,
};
// Create the batch update request
let batch_update_request = google_docs1::api::BatchUpdateDocumentRequest {
requests: Some(vec![google_docs1::api::Request {
insert_text: Some(insert_text_request),
..google_docs1::api::Request::default()
}]),
write_control: None,
};
// Execute the batch update
let result = self
.docs
.documents()
.batch_update(batch_update_request, document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Docs append_text operation, {}.",
e
))),
Ok(_) => {
Ok(vec![Content::text("Successfully appended text to the document.").with_priority(0.1)])
}
}
},
"replace_text" => {
let text = params.get("text").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The text parameter is required for replace_text operation".to_string()),
)?;
let replace_text = params.get("replaceText").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The replaceText parameter is required for replace_text operation".to_string()),
)?;
// Create the replace all text request
let replace_all_text_request = google_docs1::api::ReplaceAllTextRequest {
contains_text: Some(google_docs1::api::SubstringMatchCriteria {
text: Some(replace_text.to_string()),
match_case: Some(true),
}),
replace_text: Some(text.to_string()),
};
// Create the batch update request
let batch_update_request = google_docs1::api::BatchUpdateDocumentRequest {
requests: Some(vec![google_docs1::api::Request {
replace_all_text: Some(replace_all_text_request),
..google_docs1::api::Request::default()
}]),
write_control: None,
};
// Execute the batch update
let result = self
.docs
.documents()
.batch_update(batch_update_request, document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Docs replace_text operation, {}.",
e
))),
Ok(r) => {
let response = r.1;
let replacements = response
.replies
.and_then(|replies| {
replies.first().and_then(|reply| {
reply.replace_all_text.as_ref().map(|replace_response| {
replace_response.occurrences_changed.unwrap_or(0)
})
})
})
.unwrap_or(0);
Ok(vec![Content::text(format!(
"Successfully replaced {} occurrences of '{}' with '{}'.",
replacements, replace_text, text
)).with_priority(0.1)])
}
}
},
"create_paragraph" => {
let text = params.get("text").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The text parameter is required for create_paragraph operation".to_string()),
)?;
// Get the end position of the document
let get_result = self
.docs
.documents()
.get(document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
let end_index = match get_result {
Err(e) => {
return Err(ToolError::ExecutionError(format!(
"Failed to get document to determine end position, {}.",
e
)));
},
Ok(r) => {
let document = r.1;
if let Some(body) = document.body {
body.content.and_then(|content| {
content.last().and_then(|last_item| {
last_item.end_index
})
}).unwrap_or(1) // Default to 1 if we can't determine the end position
} else {
1 // Default to 1 if there's no body
}
}
};
// Create the insert text request with a newline at the end
let insert_text_request = google_docs1::api::InsertTextRequest {
text: Some(format!("\n{}", text)),
location: Some(google_docs1::api::Location {
index: Some(end_index - 1), // -1 because end_index is one past the last character
segment_id: None,
}),
end_of_segment_location: None,
};
// Create the batch update request
let batch_update_request = google_docs1::api::BatchUpdateDocumentRequest {
requests: Some(vec![google_docs1::api::Request {
insert_text: Some(insert_text_request),
..google_docs1::api::Request::default()
}]),
write_control: None,
};
// Execute the batch update
let result = self
.docs
.documents()
.batch_update(batch_update_request, document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Docs create_paragraph operation, {}.",
e
))),
Ok(_) => {
Ok(vec![Content::text("Successfully created a new paragraph.").with_priority(0.1)])
}
}
},
"delete_content" => {
let start_position = params.get("startPosition").and_then(|q| q.as_i64()).ok_or(
ToolError::InvalidParameters("The startPosition parameter is required for delete_content operation".to_string()),
)?;
let end_position = params.get("endPosition").and_then(|q| q.as_i64()).ok_or(
ToolError::InvalidParameters("The endPosition parameter is required for delete_content operation".to_string()),
)?;
// Create the delete content range request
let delete_content_range_request = google_docs1::api::DeleteContentRangeRequest {
range: Some(google_docs1::api::Range {
start_index: Some(start_position.try_into().unwrap()),
end_index: Some(end_position.try_into().unwrap()),
segment_id: None,
}),
};
// Create the batch update request
let batch_update_request = google_docs1::api::BatchUpdateDocumentRequest {
requests: Some(vec![google_docs1::api::Request {
delete_content_range: Some(delete_content_range_request),
..google_docs1::api::Request::default()
}]),
write_control: None,
};
// Execute the batch update
let result = self
.docs
.documents()
.batch_update(batch_update_request, document_id)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to execute Google Docs delete_content operation, {}.",
e
))),
Ok(_) => {
Ok(vec![Content::text(format!(
"Successfully deleted content from position {} to {}.",
start_position, end_position
)).with_priority(0.1)])
}
}
},
_ => Err(ToolError::InvalidParameters(format!(
"Invalid operation: {}. Supported operations are: get_document, insert_text, append_text, replace_text, create_paragraph, delete_content",
operation
))),
}
}
async fn list_drives(&self, params: Value) -> Result<Vec<Content>, ToolError> {
let query = params.get("name_contains").and_then(|q| q.as_str());
let mut results: Vec<String> = Vec::new();
let mut state = PaginationState::Start;
while state != PaginationState::End {
let mut builder = self
.drive
.drives()
.list()
.page_size(100)
.clear_scopes() // Scope::MeetReadonly is the default, remove it
.add_scope(GOOGLE_DRIVE_SCOPES);
if let Some(q) = query {
builder = builder.q(format!("name contains '{}'", q).as_str());
}
if let PaginationState::Next(pt) = state {
builder = builder.page_token(&pt);
}
let result = builder.doit().await;
match result {
Err(e) => {
return Err(ToolError::ExecutionError(format!(
"Failed to execute google drive list, {}.",
e
)))
}
Ok(r) => {
let mut content =
r.1.drives
.map(|drives| {
drives.into_iter().map(|f| {
format!(
"{} (capabilities: {:?}) (uri: {})",
f.name.unwrap_or_default(),
f.capabilities.unwrap_or_default(),
f.id.unwrap_or_default()
)
})
})
.into_iter()
.flatten()
.collect::<Vec<_>>();
results.append(&mut content);
state = match r.1.next_page_token {
Some(npt) => PaginationState::Next(npt),
None => PaginationState::End,
}
}
}
}
Ok(vec![Content::text(results.join("\n"))])
}
fn output_permission(&self, p: Permission) -> String {
format!(
"(display_name: {}) (domain: {}) (email_address: {}) (expiration_time: {}) (permission_details: {:?}) (role: {}) (type: {}) (uri: {})",
p.display_name.unwrap_or_default(),
p.domain.unwrap_or_default(),
p.email_address.unwrap_or_default(),
p.expiration_time.unwrap_or_default(),
p.permission_details.unwrap_or_default(),
p.role.unwrap_or_default(),
p.type_.unwrap_or_default(),
p.id.unwrap_or_default())
}
async fn get_permissions(&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 mut results: Vec<String> = Vec::new();
let mut state = PaginationState::Start;
while state != PaginationState::End {
let mut builder = self
.drive
.permissions()
.list(file_id)
.param("fields", "permissions(displayName, domain, emailAddress, expirationTime, permissionDetails, role, type, id)")
.supports_all_drives(true)
.page_size(100)
.clear_scopes() // Scope::MeetReadonly is the default, remove it
.add_scope(GOOGLE_DRIVE_SCOPES);
if let PaginationState::Next(pt) = state {
builder = builder.page_token(&pt);
}
let result = builder.doit().await;
match result {
Err(e) => {
return Err(ToolError::ExecutionError(format!(
"Failed to execute google drive list, {}.",
e
)))
}
Ok(r) => {
let mut content =
r.1.permissions
.map(|perms| perms.into_iter().map(|p| self.output_permission(p)))
.into_iter()
.flatten()
.collect::<Vec<_>>();
results.append(&mut content);
state = match r.1.next_page_token {
Some(npt) => PaginationState::Next(npt),
None => PaginationState::End,
}
}
}
}
Ok(vec![Content::text(results.join("\n"))])
}
async fn sharing(&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 operation = params.get("operation").and_then(|q| q.as_str()).ok_or(
ToolError::InvalidParameters("The operation is required".to_string()),
)?;
let permission_id = params.get("permissionId").and_then(|q| q.as_str());
let role = params.get("role").and_then(|s| {
s.as_str().map(|s| {
if ROLES.contains(&s) {
Ok(s)
} else {
Err(ToolError::InvalidParameters("Invalid role: must be one of ('owner', 'organizer', 'fileOrganizer', 'writer', 'commenter', 'reader')".to_string()))
}
})
}).transpose()?;
let permission_type = params.get("type").and_then(|s|
s.as_str().map(|s| {
if PERMISSIONTYPE.contains(&s) {
Ok(s)
} else {
Err(ToolError::InvalidParameters("Invalid permission type: must be one of ('user', 'group', 'domain', 'anyone')".to_string()))
}
})
).transpose()?;
let target = params.get("target").and_then(|s| s.as_str());
let email_message = params.get("emailMessage").and_then(|s| s.as_str());
match operation {
"create" => {
let (role, permission_type) = match (role, permission_type) {
(Some(r), Some(t)) => (r, t),
_ => {
return Err(ToolError::InvalidParameters(
"The 'create' operation requires the 'role' and 'type' parameters."
.to_string(),
))
}
};
let mut req = Permission {
role: Some(role.to_string()),
type_: Some(permission_type.to_string()),
..Default::default()
};
match (permission_type, target) {
("user", Some(t)) | ("group", Some(t)) => {
req.email_address = Some(t.to_string())
}
("domain", Some(d)) => req.domain = Some(d.to_string()),
("anyone", None) => {}
(_, _) => {
return Err(ToolError::InvalidParameters(format!(
"The '{}' operation for type '{}' requires the 'target' parameter.",
operation, permission_type
)))
}
}
let mut builder = self
.drive
.permissions()
.create(req, file_id)
.supports_all_drives(true)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES);
if let Some(msg) = email_message {
builder = builder.email_message(msg);
}
let result = builder.doit().await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to manage sharing for google drive file {}, {}.",
file_id, e
))),
Ok(r) => Ok(vec![Content::text(self.output_permission(r.1))]),
}
}
"update" => {
let (permission_id, role) = match (permission_id, role) {
(Some(p), Some(r)) => (p, r),
_ => {
return Err(ToolError::InvalidParameters(
"The 'update' operation requires the 'permissionId', and 'role'."
.to_string(),
))
}
};
// A permission update requires a permissionId, which is also
// the ID for that user, group, or domain. We don't _use_ the
// permission type in the Permission req body, because the
// update uses "patch semantics", and you can't patch a
// permission from one user to another without changing its ID.
let req = Permission {
role: Some(role.to_string()),
..Default::default()
};
let result = self
.drive
.permissions()
.update(req, file_id, permission_id)
.supports_all_drives(true)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to manage sharing for google drive file {}, {}.",
file_id, e
))),
Ok(r) => Ok(vec![Content::text(self.output_permission(r.1))]),
}
}
"delete" => {
let permission_id = permission_id.ok_or(ToolError::InvalidParameters(
"The 'delete' operation requires the 'permissionId'.".to_string(),
))?;
let result = self
.drive
.permissions()
.delete(file_id, permission_id)
.supports_all_drives(true)
.clear_scopes()
.add_scope(GOOGLE_DRIVE_SCOPES)
.doit()
.await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to manage sharing for google drive file {}, {}.",
file_id, e
))),
Ok(_) => Ok(vec![Content::text(format!(
"Deleted permission: {} from file: {}",
file_id, permission_id
))]),
}
}
s => Err(ToolError::InvalidParameters(
format!(
"Parameter 'operation' must be one of ('create', 'update', 'delete'); given {}",
s
)
.to_string(),
)),
}
}
async fn list_labels(&self, _params: Value) -> Result<Vec<Content>, ToolError> {
let builder = self
.drive_labels
.labels()
.list()
.param("view", "LABEL_VIEW_FULL");
let result = builder.doit().await;
match result {
Err(e) => Err(ToolError::ExecutionError(format!(
"Failed to list labels for Google Drive {}",
e
))),
Ok(r) => {
let content =
r.1.labels
.map(|labels| {
labels.into_iter().map(|l| {
format!(
"name: {} label_type: {} properties: {:?} uri: {} fields: {:?}",
l.name.unwrap_or_default(),
l.label_type.unwrap_or_default(),
l.properties.unwrap_or_default(),
l.id.unwrap_or_default(),
l.fields.unwrap_or_default()
)
})
})
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n");
Ok(vec![Content::text(content.to_string()).with_priority(0.3)])
}
}
}
}
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,
_notifier: mpsc::Sender<JsonRpcMessage>,
) -> 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,
"create_file" => this.create_file(arguments).await,
"move_file" => this.move_file(arguments).await,
"update_file" => this.update_file(arguments).await,
"sheets_tool" => this.sheets_tool(arguments).await,
"docs_tool" => this.docs_tool(arguments).await,
"manage_comment" => this.manage_comment(arguments).await,
"get_comments" => this.get_comments(arguments).await,
"list_drives" => this.list_drives(arguments).await,
"get_permissions" => this.get_permissions(arguments).await,
"sharing" => this.sharing(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(),
drive_labels: self.drive_labels.clone(),
sheets: self.sheets.clone(),
docs: self.docs.clone(),
credentials_manager: self.credentials_manager.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_document_url() {
let url = "https://docs.google.com/document/d/1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc/edit?tab=t.0#heading=h.5v419d3h97tr";
assert_eq!(
extract_google_drive_id(url),
Some("1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc")
);
}
#[test]
fn test_spreadsheets_url() {
let url = "https://docs.google.com/spreadsheets/d/1J5KHqWsGFzweuiQboX7dlm8Ejv90Po16ocEBahzCt4W/edit?gid=1249300797#gid=1249300797";
assert_eq!(
extract_google_drive_id(url),
Some("1J5KHqWsGFzweuiQboX7dlm8Ejv90Po16ocEBahzCt4W")
);
}
#[test]
fn test_slides_url() {
let url = "https://docs.google.com/presentation/d/1zXWqsGpHJEu40oqb1omh68sW9liu7EKFBCdnPaJVoQ5et/edit#slide=id.p1";
assert_eq!(
extract_google_drive_id(url),
Some("1zXWqsGpHJEu40oqb1omh68sW9liu7EKFBCdnPaJVoQ5et")
);
}
#[test]
fn test_missing_scheme() {
let url = "docs.google.com/document/d/abcdef12345/edit";
assert_eq!(extract_google_drive_id(url), None);
}
#[test]
fn test_extra_path_segments() {
let url = "https://drive.google.com/file/d/1abcdEFGH_ijklMNOpqrstUVwxyz-1234/view";
assert_eq!(
extract_google_drive_id(url),
Some("1abcdEFGH_ijklMNOpqrstUVwxyz-1234")
);
}
#[test]
fn test_invalid_google_url() {
let url = "https://example.com/d/12345";
assert_eq!(extract_google_drive_id(url), None);
}
#[test]
fn test_no_d_segment() {
let url =
"https://docs.google.com/document/1QG8d8wtWe7ZfmG93sW-1h2WXDJDUkOi-9hDnvJLmWrc/edit";
assert_eq!(extract_google_drive_id(url), None);
}
}