Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/custom-api-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

Add `GOOGLE_WORKSPACE_CLI_API_BASE_URL` env var to redirect API requests to a custom endpoint (e.g., mock server). Authentication is automatically skipped when set.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,5 @@ When adding a new helper or CLI command:
- `GOOGLE_WORKSPACE_CLI_TOKEN` — Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading)
- `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` — Path to OAuth credentials JSON (no default; if unset, falls back to credentials secured by the OS Keyring and encrypted in `~/.config/gws/`)
- `GOOGLE_WORKSPACE_CLI_ACCOUNT` — Default account email for multi-account usage (overridden by `--account` flag)
- `GOOGLE_WORKSPACE_CLI_API_BASE_URL` — Redirects all API requests to a custom endpoint (e.g., `http://localhost:8099`). Authentication is automatically disabled. **Security: never set this in production** — it silently disables OAuth and sends all requests to an arbitrary endpoint.
- Supports `.env` files via `dotenvy`
88 changes: 86 additions & 2 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,21 @@ pub struct JsonSchemaProperty {
pub additional_properties: Option<Box<JsonSchemaProperty>>,
}

static CUSTOM_API_BASE_URL: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
let url = std::env::var("GOOGLE_WORKSPACE_CLI_API_BASE_URL")
.ok()
.filter(|s| !s.is_empty());
if let Some(ref u) = url {
eprintln!("[gws] Custom API endpoint active: {u}");
eprintln!("[gws] Authentication is disabled. Requests will NOT go to Google APIs.");
}
url
});

pub fn custom_api_base_url() -> Option<&'static str> {
CUSTOM_API_BASE_URL.as_deref()
}

/// Fetches and caches a Google Discovery Document.
pub async fn fetch_discovery_document(
service: &str,
Expand All @@ -209,7 +224,8 @@ pub async fn fetch_discovery_document(
if let Ok(modified) = metadata.modified() {
if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) {
let data = std::fs::read_to_string(&cache_file)?;
let doc: RestDescription = serde_json::from_str(&data)?;
let mut doc: RestDescription = serde_json::from_str(&data)?;
apply_base_url_override(&mut doc);
return Ok(doc);
}
}
Expand Down Expand Up @@ -250,10 +266,27 @@ pub async fn fetch_discovery_document(
let _ = e;
}

let doc: RestDescription = serde_json::from_str(&body)?;
let mut doc: RestDescription = serde_json::from_str(&body)?;
apply_base_url_override(&mut doc);
Ok(doc)
}

fn apply_base_url_override(doc: &mut RestDescription) {
if let Some(base) = custom_api_base_url() {
rewrite_base_url(doc, base);
}
}

fn rewrite_base_url(doc: &mut RestDescription, base: &str) {
let base_trimmed = base.trim_end_matches('/');
let new_root_url = format!("{base_trimmed}/");
let original_root_url = std::mem::replace(&mut doc.root_url, new_root_url);

if let Some(base_url) = &mut doc.base_url {
*base_url = base_url.replace(&original_root_url, &doc.root_url);
}
}
Comment on lines +280 to +288
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of rewrite_base_url incorrectly rewrites base_url. It replaces the entire base_url with the custom endpoint, which causes the service path (e.g., /gmail/v1/) to be lost. This will result in incorrect API request URLs.

For example, a request to gmail.users.getProfile would be sent to http://localhost:8001/users/me/profile instead of the correct http://localhost:8001/gmail/v1/users/me/profile.

The fix is to replace only the host part of the base_url, preserving the path. Here is a suggested implementation:

fn rewrite_base_url(doc: &mut RestDescription, base: &str) {
    let base_trimmed = base.trim_end_matches('/');
    let new_root_url = format!("{base_trimmed}/");
    let original_root_url = std::mem::replace(&mut doc.root_url, new_root_url);

    if let Some(base_url) = &mut doc.base_url {
        *base_url = base_url.replace(&original_root_url, &doc.root_url);
    }
}


#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -323,4 +356,55 @@ mod tests {
assert!(doc.resources.is_empty());
assert!(doc.schemas.is_empty());
}

#[test]
fn test_rewrite_base_url_empty_service_path() {
let mut doc = RestDescription {
name: "gmail".to_string(),
version: "v1".to_string(),
root_url: "https://gmail.googleapis.com/".to_string(),
base_url: Some("https://gmail.googleapis.com/".to_string()),
service_path: "".to_string(),
..Default::default()
};

rewrite_base_url(&mut doc, "http://localhost:8099");
assert_eq!(doc.root_url, "http://localhost:8099/");
assert_eq!(doc.base_url.as_deref(), Some("http://localhost:8099/"));
}

#[test]
fn test_rewrite_base_url_preserves_service_path() {
let mut doc = RestDescription {
name: "drive".to_string(),
version: "v3".to_string(),
root_url: "https://www.googleapis.com/".to_string(),
base_url: Some("https://www.googleapis.com/drive/v3/".to_string()),
service_path: "drive/v3/".to_string(),
..Default::default()
};

rewrite_base_url(&mut doc, "http://localhost:8099/");
assert_eq!(doc.root_url, "http://localhost:8099/");
assert_eq!(
doc.base_url.as_deref(),
Some("http://localhost:8099/drive/v3/")
);
}

#[test]
fn test_rewrite_base_url_none() {
let mut doc = RestDescription {
name: "customsearch".to_string(),
version: "v1".to_string(),
root_url: "https://www.googleapis.com/".to_string(),
base_url: None,
service_path: "customsearch/v1/".to_string(),
..Default::default()
};

rewrite_base_url(&mut doc, "http://localhost:8099");
assert_eq!(doc.root_url, "http://localhost:8099/");
assert!(doc.base_url.is_none());
}
Comment on lines +360 to +409
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

These tests need to be updated to reflect the correct behavior of rewrite_base_url. The assertions should check that the service path is preserved in the rewritten base_url.

    #[test]
    fn test_rewrite_base_url() {
        let mut doc = RestDescription {
            name: "gmail".to_string(),
            version: "v1".to_string(),
            root_url: "https://gmail.googleapis.com/".to_string(),
            base_url: Some("https://gmail.googleapis.com/gmail/v1/".to_string()),
            service_path: "gmail/v1/".to_string(),
            ..Default::default()
        };

        rewrite_base_url(&mut doc, "http://localhost:8099");
        assert_eq!(doc.root_url, "http://localhost:8099/");
        assert_eq!(
            doc.base_url.as_deref(),
            Some("http://localhost:8099/gmail/v1/")
        );
    }

    #[test]
    fn test_rewrite_base_url_trailing_slash() {
        let mut doc = RestDescription {
            name: "drive".to_string(),
            version: "v3".to_string(),
            root_url: "https://www.googleapis.com/".to_string(),
            base_url: Some("https://www.googleapis.com/drive/v3/".to_string()),
            ..Default::default()
        };

        rewrite_base_url(&mut doc, "http://localhost:8099/");
        assert_eq!(doc.root_url, "http://localhost:8099/");
        assert_eq!(
            doc.base_url.as_deref(),
            Some("http://localhost:8099/drive/v3/")
        );
    }

}
14 changes: 14 additions & 0 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ pub enum AuthMethod {
None,
}

/// Resolve authentication, skipping OAuth when a custom API endpoint is set.
pub async fn resolve_auth(
scopes: &[&str],
account: Option<&str>,
) -> anyhow::Result<(Option<String>, AuthMethod)> {
if crate::discovery::custom_api_base_url().is_some() {
return Ok((None, AuthMethod::None));
}
match crate::auth::get_token(scopes, account).await {
Ok(t) => Ok((Some(t), AuthMethod::OAuth)),
Err(e) => Err(e),
}
}

/// Configuration for auto-pagination.
#[derive(Debug, Clone)]
pub struct PaginationConfig {
Expand Down
13 changes: 9 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,12 @@ async fn run() -> Result<(), GwsError> {
// Get scopes from the method
let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();

// Authenticate: try OAuth, otherwise proceed unauthenticated
let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
let (token, auth_method) = match executor::resolve_auth(&scopes, account.as_deref()).await {
Ok(auth) => auth,
Err(e) => {
eprintln!("[gws] Warning: Authentication failed, proceeding without credentials: {e}");
(None, executor::AuthMethod::None)
}
};

// Execute
Expand Down Expand Up @@ -430,6 +432,9 @@ fn print_usage() {
println!(
" GOOGLE_WORKSPACE_CLI_ACCOUNT Default account email for multi-account"
);
println!(
" GOOGLE_WORKSPACE_CLI_API_BASE_URL Custom API endpoint (e.g., mock server); disables auth"
);
println!();
println!("COMMUNITY:");
println!(" Star the repo: https://github.com/googleworkspace/cli");
Expand Down
4 changes: 2 additions & 2 deletions src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,8 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result<Valu
};

let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match crate::auth::get_token(&scopes, None).await {
Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth),
let (token, auth_method) = match crate::executor::resolve_auth(&scopes, None).await {
Ok(auth) => auth,
Err(e) => {
eprintln!(
"[gws mcp] Warning: Authentication failed, proceeding without credentials: {e}"
Expand Down