From d8d78396e0cc9b0ec0049e98e630ecc66cf5df34 Mon Sep 17 00:00:00 2001 From: Shea Craig Date: Fri, 23 May 2025 12:23:32 -0400 Subject: [PATCH] Shea/gdrive labels (#2537) Co-authored-by: Michael Neale Co-authored-by: Kalvin C --- crates/goose-mcp/Cargo.toml | 15 +- .../src/google_drive/google_labels.rs | 478 ++++++++++++++++++ crates/goose-mcp/src/google_drive/mod.rs | 434 ++++++++++++++-- .../goose-mcp/src/google_drive/oauth_pkce.rs | 75 ++- 4 files changed, 943 insertions(+), 59 deletions(-) create mode 100644 crates/goose-mcp/src/google_drive/google_labels.rs diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index eb3e961e..8bf405e7 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -35,6 +35,7 @@ chrono = { version = "0.4.38", features = ["serde"] } etcetera = "0.8.0" tempfile = "3.8" include_dir = "0.7.4" +google-apis-common = "7.0.0" google-drive3 = "6.0.0" google-sheets4 = "6.0.0" google-docs1 = "6.0.0" @@ -47,9 +48,21 @@ lopdf = "0.35.0" docx-rs = "0.4.7" image = "0.24.9" umya-spreadsheet = "2.2.3" -keyring = { version = "3.6.1", features = ["apple-native", "windows-native", "sync-secret-service", "vendored"] } +keyring = { version = "3.6.1", features = [ + "apple-native", + "windows-native", + "sync-secret-service", + "vendored", +] } oauth2 = { version = "5.0.0", features = ["reqwest"] } +utoipa = { version = "4.1", optional = true } +hyper = "1" +serde_with = "3" + [dev-dependencies] serial_test = "3.0.0" sysinfo = "0.32.1" + +[features] +utoipa = ["dep:utoipa"] diff --git a/crates/goose-mcp/src/google_drive/google_labels.rs b/crates/goose-mcp/src/google_drive/google_labels.rs new file mode 100644 index 00000000..a2272ce4 --- /dev/null +++ b/crates/goose-mcp/src/google_drive/google_labels.rs @@ -0,0 +1,478 @@ +#![allow(clippy::ptr_arg, dead_code, clippy::enum_variant_names)] + +use std::collections::{BTreeSet, HashMap}; + +use google_apis_common as common; +use tokio::time::sleep; + +/// A scope is needed when requesting an +/// [authorization token](https://developers.google.com/workspace/drive/labels/guides/authorize). +#[derive(PartialEq, Eq, Ord, PartialOrd, Hash, Debug, Clone, Copy)] +pub enum Scope { + /// View, use, and manage Drive labels. + DriveLabels, + + /// View and use Drive labels. + DriveLabelsReadonly, + + /// View, edit, create, and delete all Drive labels in your organization, + /// and view your organization's label-related administration policies. + DriveLabelsAdmin, + + /// View all Drive labels and label-related administration policies in your + /// organization. + DriveLabelsAdminReadonly, +} + +impl AsRef for Scope { + fn as_ref(&self) -> &str { + match *self { + Scope::DriveLabels => "https://www.googleapis.com/auth/drive.labels", + Scope::DriveLabelsReadonly => "https://www.googleapis.com/auth/drive.labels.readonly", + Scope::DriveLabelsAdmin => "https://www.googleapis.com/auth/drive.admin.labels", + Scope::DriveLabelsAdminReadonly => { + "https://www.googleapis.com/auth/drive.admin.labels.readonly" + } + } + } +} + +#[allow(clippy::derivable_impls)] +impl Default for Scope { + fn default() -> Scope { + Scope::DriveLabelsReadonly + } +} + +#[derive(Clone)] +pub struct DriveLabelsHub { + pub client: common::Client, + pub auth: Box, + _user_agent: String, + _base_url: String, +} + +impl common::Hub for DriveLabelsHub {} + +impl<'a, C> DriveLabelsHub { + pub fn new( + client: common::Client, + auth: A, + ) -> DriveLabelsHub { + DriveLabelsHub { + client, + auth: Box::new(auth), + _user_agent: "google-api-rust-client/6.0.0".to_string(), + _base_url: "https://drivelabels.googleapis.com/".to_string(), + } + } + + pub fn labels(&'a self) -> LabelMethods<'a, C> { + LabelMethods { hub: self } + } + + /// Set the user-agent header field to use in all requests to the server. + /// It defaults to `google-api-rust-client/6.0.0`. + /// + /// Returns the previously set user-agent. + pub fn user_agent(&mut self, agent_name: String) -> String { + std::mem::replace(&mut self._user_agent, agent_name) + } + + /// Set the base url to use in all requests to the server. + /// It defaults to `https://www.googleapis.com/drive/v3/`. + /// + /// Returns the previously set base url. + pub fn base_url(&mut self, new_base_url: String) -> String { + std::mem::replace(&mut self._base_url, new_base_url) + } +} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Label { + #[serde(rename = "name")] + pub name: Option, + #[serde(rename = "id")] + pub id: Option, + #[serde(rename = "revisionId")] + pub revision_id: Option, + #[serde(rename = "labelType")] + pub label_type: Option, + #[serde(rename = "creator")] + pub creator: Option, + #[serde(rename = "createTime")] + pub create_time: Option, + #[serde(rename = "revisionCreator")] + pub revision_creator: Option, + #[serde(rename = "revisionCreateTime")] + pub revision_create_time: Option, + #[serde(rename = "publisher")] + pub publisher: Option, + #[serde(rename = "publishTime")] + pub publish_time: Option, + #[serde(rename = "disabler")] + pub disabler: Option, + #[serde(rename = "disableTime")] + pub disable_time: Option, + #[serde(rename = "customer")] + pub customer: Option, + pub properties: Option, + pub fields: Option>, + // We ignore the remaining fields. +} + +impl common::Part for Label {} + +impl common::ResponseResult for Label {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct LabelProperty { + pub title: Option, + pub description: Option, +} + +impl common::Part for LabelProperty {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Field { + id: Option, + #[serde(rename = "queryKey")] + query_key: Option, + properties: Option, + #[serde(rename = "selectionOptions")] + selection_options: Option, +} + +impl common::Part for Field {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct FieldProperty { + #[serde(rename = "displayName")] + pub display_name: Option, + pub required: Option, +} + +impl common::Part for FieldProperty {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SelectionOption { + #[serde(rename = "listOptions")] + pub list_options: Option, + pub choices: Option>, +} + +impl common::Part for SelectionOption {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Choice { + id: Option, + properties: Option, + // We ignore the remaining fields. +} + +impl common::Part for Choice {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ChoiceProperties { + #[serde(rename = "displayName")] + display_name: Option, + description: Option, +} + +impl common::Part for ChoiceProperties {} + +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct LabelList { + pub labels: Option>, + #[serde(rename = "nextPageToken")] + pub next_page_token: Option, +} + +impl common::ResponseResult for LabelList {} + +/// Information about a Drive user. +/// +/// This type is not used in any activity, and only used as *part* of another schema. +/// +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde_with::serde_as] +#[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct User { + /// Output only. A plain text displayable name for this user. + #[serde(rename = "displayName")] + pub display_name: Option, + /// Output only. The email address of the user. This may not be present in certain contexts if the user has not made their email address visible to the requester. + #[serde(rename = "emailAddress")] + pub email_address: Option, + /// Output only. Identifies what kind of resource this is. Value: the fixed string `"drive#user"`. + pub kind: Option, + /// Output only. Whether this user is the requesting user. + pub me: Option, + /// Output only. The user's ID as visible in Permission resources. + #[serde(rename = "permissionId")] + pub permission_id: Option, + /// Output only. A link to the user's profile photo, if available. + #[serde(rename = "photoLink")] + pub photo_link: Option, +} + +impl common::Part for User {} + +pub struct LabelMethods<'a, C> +where + C: 'a, +{ + hub: &'a DriveLabelsHub, +} + +impl common::MethodsBuilder for LabelMethods<'_, C> {} + +impl<'a, C> LabelMethods<'a, C> { + /// Create a builder to help you perform the following tasks: + /// + /// List labels + pub fn list(&self) -> LabelListCall<'a, C> { + LabelListCall { + hub: self.hub, + _delegate: Default::default(), + _additional_params: Default::default(), + _scopes: Default::default(), + } + } +} + +/// Lists the workspace's labels. +pub struct LabelListCall<'a, C> +where + C: 'a, +{ + hub: &'a DriveLabelsHub, + _delegate: Option<&'a mut dyn common::Delegate>, + _additional_params: HashMap, + _scopes: BTreeSet, +} + +impl common::CallBuilder for LabelListCall<'_, C> {} + +impl<'a, C> LabelListCall<'a, C> +where + C: common::Connector, +{ + /// Perform the operation you have built so far. + pub async fn doit(mut self) -> common::Result<(common::Response, LabelList)> { + use common::url::Params; + use hyper::header::{AUTHORIZATION, CONTENT_LENGTH, USER_AGENT}; + + let mut dd = common::DefaultDelegate; + let dlg: &mut dyn common::Delegate = self._delegate.unwrap_or(&mut dd); + dlg.begin(common::MethodInfo { + id: "drivelabels.labels.list", + http_method: hyper::Method::GET, + }); + + for &field in ["alt"].iter() { + if self._additional_params.contains_key(field) { + dlg.finished(false); + return Err(common::Error::FieldClash(field)); + } + } + + // TODO: We don't handle any of the query params. + let mut params = Params::with_capacity(2 + self._additional_params.len()); + + params.extend(self._additional_params.iter()); + + params.push("alt", "json"); + let url = self.hub._base_url.clone() + "v2/labels"; + + if self._scopes.is_empty() { + self._scopes + .insert(Scope::DriveLabelsReadonly.as_ref().to_string()); + } + + let url = params.parse_with_url(&url); + + loop { + let token = match self + .hub + .auth + .get_token(&self._scopes.iter().map(String::as_str).collect::>()[..]) + .await + { + Ok(token) => token, + Err(e) => match dlg.token(e) { + Ok(token) => token, + Err(e) => { + dlg.finished(false); + return Err(common::Error::MissingToken(e)); + } + }, + }; + let req_result = { + let client = &self.hub.client; + dlg.pre_request(); + let mut req_builder = hyper::Request::builder() + .method(hyper::Method::GET) + .uri(url.as_str()) + .header(USER_AGENT, self.hub._user_agent.clone()); + + if let Some(token) = token.as_ref() { + req_builder = req_builder.header(AUTHORIZATION, format!("Bearer {}", token)); + } + + let request = req_builder + .header(CONTENT_LENGTH, 0_u64) + .body(common::to_body::(None)); + client.request(request.unwrap()).await + }; + + match req_result { + Err(err) => { + if let common::Retry::After(d) = dlg.http_error(&err) { + sleep(d).await; + continue; + } + dlg.finished(false); + return Err(common::Error::HttpError(err)); + } + Ok(res) => { + let (parts, body) = res.into_parts(); + let body = common::Body::new(body); + if !parts.status.is_success() { + let bytes = common::to_bytes(body).await.unwrap_or_default(); + let error = serde_json::from_str(&common::to_string(&bytes)); + let response = common::to_response(parts, bytes.into()); + + if let common::Retry::After(d) = + dlg.http_failure(&response, error.as_ref().ok()) + { + sleep(d).await; + continue; + } + + dlg.finished(false); + + return Err(match error { + Ok(value) => common::Error::BadRequest(value), + _ => common::Error::Failure(response), + }); + } + let response = { + let bytes = common::to_bytes(body).await.unwrap_or_default(); + let encoded = common::to_string(&bytes); + match serde_json::from_str(&encoded) { + Ok(decoded) => (common::to_response(parts, bytes.into()), decoded), + Err(error) => { + dlg.response_json_decode_error(&encoded, &error); + return Err(common::Error::JsonDecodeError( + encoded.to_string(), + error, + )); + } + } + }; + + dlg.finished(true); + return Ok(response); + } + } + } + } + + /// The delegate implementation is consulted whenever there is an intermediate result, or if something goes wrong + /// while executing the actual API request. + /// + /// ````text + /// It should be used to handle progress information, and to implement a certain level of resilience. + /// ```` + /// + /// Sets the *delegate* property to the given value. + pub fn delegate(mut self, new_value: &'a mut dyn common::Delegate) -> LabelListCall<'a, C> { + self._delegate = Some(new_value); + self + } + + /// Set any additional parameter of the query string used in the request. + /// It should be used to set parameters which are not yet available through their own + /// setters. + /// + /// Please note that this method must not be used to set any of the known parameters + /// which have their own setter method. If done anyway, the request will fail. + /// + /// # Additional Parameters + /// + /// * *$.xgafv* (query-string) - V1 error format. + /// * *access_token* (query-string) - OAuth access token. + /// * *alt* (query-string) - Data format for response. + /// * *callback* (query-string) - JSONP + /// * *fields* (query-string) - Selector specifying which fields to include in a partial response. + /// * *key* (query-string) - API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token. + /// * *oauth_token* (query-string) - OAuth 2.0 token for the current user. + /// * *prettyPrint* (query-boolean) - Returns response with indentations and line breaks. + /// * *quotaUser* (query-string) - Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. + /// * *uploadType* (query-string) - Legacy upload protocol for media (e.g. "media", "multipart"). + /// * *upload_protocol* (query-string) - Upload protocol for media (e.g. "raw", "multipart"). + pub fn param(mut self, name: T, value: T) -> LabelListCall<'a, C> + where + T: AsRef, + { + self._additional_params + .insert(name.as_ref().to_string(), value.as_ref().to_string()); + self + } + + /// Identifies the authorization scope for the method you are building. + /// + /// Use this method to actively specify which scope should be used, instead of the default [`Scope`] variant + /// [`Scope::DriveLabelsReadonly`]. + /// + /// The `scope` will be added to a set of scopes. This is important as one can maintain access + /// tokens for more than one scope. + /// + /// Usually there is more than one suitable scope to authorize an operation, some of which may + /// encompass more rights than others. For example, for listing resources, a *read-only* scope will be + /// sufficient, a read-write scope will do as well. + pub fn add_scope(mut self, scope: St) -> LabelListCall<'a, C> + where + St: AsRef, + { + self._scopes.insert(String::from(scope.as_ref())); + self + } + /// Identifies the authorization scope(s) for the method you are building. + /// + /// See [`Self::add_scope()`] for details. + pub fn add_scopes(mut self, scopes: I) -> LabelListCall<'a, C> + where + I: IntoIterator, + St: AsRef, + { + self._scopes + .extend(scopes.into_iter().map(|s| String::from(s.as_ref()))); + self + } + + /// Removes all scopes, and no default scope will be used either. + /// In this case, you have to specify your API-key using the `key` parameter (see [`Self::param()`] + /// for details). + pub fn clear_scopes(mut self) -> LabelListCall<'a, C> { + self._scopes.clear(); + self + } +} diff --git a/crates/goose-mcp/src/google_drive/mod.rs b/crates/goose-mcp/src/google_drive/mod.rs index ff90234f..710f42ae 100644 --- a/crates/goose-mcp/src/google_drive/mod.rs +++ b/crates/goose-mcp/src/google_drive/mod.rs @@ -1,8 +1,10 @@ +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::tool::ToolAnnotations; @@ -28,11 +30,15 @@ use google_docs1::{self, Docs}; use google_drive3::common::ReadSeek; use google_drive3::{ self, - api::{Comment, File, FileShortcutDetails, Permission, Reply, Scope}, + 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; @@ -80,6 +86,7 @@ pub struct GoogleDriveRouter { tools: Vec, instructions: String, drive: DriveHub>, + drive_labels: DriveLabelsHub>, sheets: Sheets>, docs: Docs>, credentials_manager: Arc, @@ -88,6 +95,7 @@ pub struct GoogleDriveRouter { impl GoogleDriveRouter { async fn google_auth() -> ( DriveHub>, + DriveLabelsHub>, Sheets>, Docs>, Arc, @@ -162,7 +170,7 @@ impl GoogleDriveRouter { // Read the OAuth credentials from the keyfile match fs::read_to_string(keyfile_path) { Ok(_) => { - // Create the PKCE OAuth2 clien + // Create the PKCE OAuth2 client let auth = PkceOAuth2Client::new(keyfile_path, credentials_manager.clone()) .expect("Failed to create OAuth2 client"); @@ -180,11 +188,18 @@ impl GoogleDriveRouter { ); 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, sheets_hub, docs_hub, credentials_manager) + ( + drive_hub, + drive_labels_hub, + sheets_hub, + docs_hub, + credentials_manager, + ) } Err(e) => { tracing::error!( @@ -199,24 +214,28 @@ impl GoogleDriveRouter { pub async fn new() -> Self { // handle auth - let (drive, sheets, docs, credentials_manager) = Self::google_auth().await; + let (drive, drive_labels, sheets, docs, credentials_manager) = Self::google_auth().await; let search_tool = Tool::new( "search".to_string(), indoc! {r#" - Search for files in google drive by name, given an input search query. At least one of ('name', 'mimeType', or 'parent') are required. + 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": "MIME type to constrain the search to.", + "description": "Use when searching for a file to constrain the results to just this MIME type.", }, "parent": { "type": "string", @@ -233,8 +252,13 @@ impl GoogleDriveRouter { "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()), @@ -370,7 +394,7 @@ impl GoogleDriveRouter { let update_file_tool = Tool::new( "update_file".to_string(), indoc! {r#" - Update an existing file in Google Drive with new content. + Update an existing file in Google Drive with new content or edit the file's labels. "#} .to_string(), json!({ @@ -380,6 +404,10 @@ impl GoogleDriveRouter { "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.", @@ -392,15 +420,77 @@ impl GoogleDriveRouter { "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)", }, - "allowSharedDrives": { - "type": "boolean", - "description": "Whether to allow access to shared drives or just your personal drive (default: false)", - } + "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", "mimeType"], + "required": ["fileId"], + "dependentRequired": { + "body": ["mimeType"], + "path": ["mimeType"] + } }), Some(ToolAnnotations { - title: Some("Update a file".to_string()), + title: Some("Update a file's contents or labels".to_string()), read_only_hint: false, destructive_hint: true, idempotent_hint: false, @@ -466,7 +556,13 @@ impl GoogleDriveRouter { }, "required": ["spreadsheetId", "operation"], }), - None, + 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( @@ -517,7 +613,13 @@ impl GoogleDriveRouter { }, "required": ["documentId", "operation"], }), - None, + 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( @@ -549,7 +651,7 @@ impl GoogleDriveRouter { "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. @@ -702,7 +804,7 @@ impl GoogleDriveRouter { ## Overview The Google Drive MCP server provides tools for interacting with Google Drive files, Google Sheets, and Google Docs: - 1. search - Find files in your Google Drive + 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 @@ -711,16 +813,18 @@ impl GoogleDriveRouter { 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 a existing 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 files in Google Drive, by name and ordered by most recently viewedByMeTime. + 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 + 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. @@ -775,7 +879,7 @@ impl GoogleDriveRouter { ### 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 @@ -788,12 +892,18 @@ impl GoogleDriveRouter { 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). + 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 @@ -879,6 +989,7 @@ impl GoogleDriveRouter { ], instructions, drive, + drive_labels, sheets, docs, credentials_manager, @@ -887,6 +998,22 @@ impl GoogleDriveRouter { // Implement search tool functionality async fn search(&self, params: Value) -> Result, 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, 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()); @@ -928,6 +1055,11 @@ impl GoogleDriveRouter { }) .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( @@ -958,7 +1090,13 @@ impl GoogleDriveRouter { .corpora(corpus) .q(query_string.as_str()) .order_by("viewedByMeTime desc") - .param("fields", "files(id, name, mimeType, modifiedTime, size)") + .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) @@ -968,8 +1106,34 @@ impl GoogleDriveRouter { if let (Some(d), "drive") = (drive_id, corpus) { builder = builder.drive_id(d); } - let result = builder.doit().await; + // If we want labels, we have to go look up the IDs first. + // let mut label_results: Vec