Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/add-mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@googleworkspace/cli": minor
---

feat: add `gws mcp` Model Context Protocol server

Adds a new `gws mcp` subcommand that starts an MCP server over stdio,
exposing Google Workspace APIs as structured tools to any MCP-compatible
client (Claude Desktop, Gemini CLI, VS Code, etc.).
74 changes: 50 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,12 @@ Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JS
</p>
<br>


```bash
npm install -g @googleworkspace/cli
```

`gws` doesn't ship a static list of commands. It reads Google's own [Discovery Service](https://developers.google.com/discovery) at runtime and builds its entire command surface dynamically. When Google Workspace adds an API endpoint or method, `gws` picks it up automatically.


> [!IMPORTANT]
> This project is under active development. Expect breaking changes as we march toward v1.0.

Expand All @@ -36,6 +34,7 @@ npm install -g @googleworkspace/cli
- [Why gws?](#why-gws)
- [Authentication](#authentication)
- [AI Agent Skills](#ai-agent-skills)
- [MCP Server](#mcp-server)
- [Advanced Usage](#advanced-usage)
- [Architecture](#architecture)
- [Development](#development)
Expand All @@ -55,7 +54,6 @@ Or build from source:
cargo install --path .
```


## Why gws?

**For humans** — stop writing `curl` calls against REST docs. `gws` gives you tab‑completion, `--help` on every resource, `--dry-run` to preview requests, and auto‑pagination.
Expand All @@ -82,7 +80,6 @@ gws schema drive.files.list
gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name'
```


## Authentication

The CLI supports multiple auth workflows so it works on your laptop, in CI, and on a server.
Expand Down Expand Up @@ -167,16 +164,15 @@ export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token)

### Precedence

| Priority | Source | Set via |
|----------|--------|---------|
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
| 3 | Encrypted credentials (OS keyring) | `gws auth login` |
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
| Priority | Source | Set via |
| -------- | ---------------------------------- | --------------------------------------- |
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
| 3 | Encrypted credentials (OS keyring) | `gws auth login` |
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |

Environment variables can also live in a `.env` file.


## AI Agent Skills

The repo ships 100+ Agent Skills (`SKILL.md` files) — one for every supported API, plus higher-level helpers for common workflows and 50 curated recipes for Gmail, Drive, Docs, Calendar, and Sheets. See the full [Skills Index](docs/skills.md) for the complete list.
Expand Down Expand Up @@ -205,10 +201,10 @@ The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the

</details>


## Gemini CLI Extension

1. Authenticate the CLI first:

```bash
gws setup
```
Expand All @@ -220,6 +216,39 @@ The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the

Installing this extension gives your Gemini CLI agent direct access to all `gws` commands and Google Workspace agent skills. Because `gws` handles its own authentication securely, you simply need to authenticate your terminal once prior to using the agent, and the extension will automatically inherit your credentials.

## MCP Server

`gws mcp` starts a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, exposing Google Workspace APIs as structured tools that any MCP-compatible client (Claude Desktop, Gemini CLI, VS Code, etc.) can call.

```bash
gws mcp -s drive # expose Drive tools
gws mcp -s drive,gmail,calendar # expose multiple services
gws mcp -s all # expose all services (many tools!)
```

Configure in your MCP client:

```json
{
"mcpServers": {
"gws": {
"command": "gws",
"args": ["mcp", "-s", "drive,gmail,calendar"]
}
}
}
```

> [!TIP]
> Each service adds roughly 10–80 tools. Keep the list to what you actually need
> to stay under your client's tool limit (typically 50–100 tools).

| Flag | Description |
| ----------------------- | -------------------------------------------- |
| `-s, --services <list>` | Comma-separated services to expose, or `all` |
| `-w, --workflows` | Also expose workflow tools |
| `-e, --helpers` | Also expose helper tools |

## Advanced Usage

### Multipart Uploads
Expand All @@ -230,11 +259,11 @@ gws drive files create --json '{"name": "report.pdf"}' --upload ./report.pdf

### Pagination

| Flag | Description | Default |
|------|-------------|---------|
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
| `--page-limit <N>` | Max pages to fetch | 10 |
| `--page-delay <MS>` | Delay between pages | 100 ms |
| Flag | Description | Default |
| ------------------- | ---------------------------------------------- | ------- |
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
| `--page-limit <N>` | Max pages to fetch | 10 |
| `--page-delay <MS>` | Delay between pages | 100 ms |

### Model Armor (Response Sanitization)

Expand All @@ -245,11 +274,10 @@ gws gmail users messages get --params '...' \
--sanitize "projects/P/locations/L/templates/T"
```

| Variable | Description |
|----------|-------------|
| Variable | Description |
| ---------------------------------------- | ---------------------------- |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |

| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |

## Architecture

Expand All @@ -263,7 +291,6 @@ gws gmail users messages get --params '...' \

All output — success, errors, download metadata — is structured JSON.


## Troubleshooting

### API not enabled — `accessNotConfigured`
Expand Down Expand Up @@ -291,6 +318,7 @@ If a required Google API is not enabled for your GCP project, you will see a
```

**Steps to fix:**

1. Click the `enable_url` link (or copy it from the `enable_url` JSON field).
2. In the GCP Console, click **Enable**.
3. Wait ~10 seconds, then retry your `gws` command.
Expand All @@ -299,7 +327,6 @@ If a required Google API is not enabled for your GCP project, you will see a
> You can also run `gws setup` which walks you through enabling all required
> APIs for your project automatically.


## Development

```bash
Expand All @@ -309,7 +336,6 @@ cargo test # unit tests
./scripts/coverage.sh # HTML coverage report → target/llvm-cov/html/
```


## License

Apache-2.0
Expand Down
68 changes: 52 additions & 16 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ async fn build_http_request(

/// Handle a JSON response: parse, sanitize via Model Armor, output, and check pagination.
/// Returns `Ok(true)` if the pagination loop should continue.
#[allow(clippy::too_many_arguments)]
async fn handle_json_response(
body_text: &str,
pagination: &PaginationConfig,
Expand All @@ -207,6 +208,8 @@ async fn handle_json_response(
output_format: &crate::formatter::OutputFormat,
pages_fetched: &mut u32,
page_token: &mut Option<String>,
capture_output: bool,
captured: &mut Vec<Value>,
) -> Result<bool, GwsError> {
if let Ok(mut json_val) = serde_json::from_str::<Value>(body_text) {
*pages_fetched += 1;
Expand Down Expand Up @@ -249,7 +252,9 @@ async fn handle_json_response(
}
}

if pagination.page_all {
if capture_output {
captured.push(json_val.clone());
} else if pagination.page_all {
let is_first_page = *pages_fetched == 1;
println!(
"{}",
Expand Down Expand Up @@ -279,7 +284,7 @@ async fn handle_json_response(
}
} else {
// Not valid JSON, output as-is
if !body_text.is_empty() {
if !capture_output && !body_text.is_empty() {
println!("{body_text}");
}
}
Expand All @@ -293,7 +298,8 @@ async fn handle_binary_response(
content_type: &str,
output_path: Option<&str>,
output_format: &crate::formatter::OutputFormat,
) -> Result<(), GwsError> {
capture_output: bool,
) -> Result<Option<Value>, GwsError> {
let file_path = if let Some(p) = output_path {
PathBuf::from(p)
} else {
Expand Down Expand Up @@ -324,9 +330,14 @@ async fn handle_binary_response(
"mimeType": content_type,
"bytes": total_bytes,
});

if capture_output {
return Ok(Some(result));
}

println!("{}", crate::formatter::format_value(&result, output_format));

Ok(())
Ok(None)
}

/// Executes an API method call.
Expand Down Expand Up @@ -354,7 +365,8 @@ pub async fn execute_method(
sanitize_template: Option<&str>,
sanitize_mode: &crate::helpers::modelarmor::SanitizeMode,
output_format: &crate::formatter::OutputFormat,
) -> Result<(), GwsError> {
capture_output: bool,
) -> Result<Option<Value>, GwsError> {
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?;

if dry_run {
Expand All @@ -366,15 +378,19 @@ pub async fn execute_method(
"body": input.body,
"is_multipart_upload": input.is_upload,
});
if capture_output {
return Ok(Some(dry_run_info));
}
println!(
"{}",
crate::formatter::format_value(&dry_run_info, output_format)
);
return Ok(());
return Ok(None);
}

let mut page_token: Option<String> = None;
let mut pages_fetched: u32 = 0;
let mut captured_values = Vec::new();

loop {
let client = crate::client::build_client()?;
Expand Down Expand Up @@ -422,20 +438,38 @@ pub async fn execute_method(
output_format,
&mut pages_fetched,
&mut page_token,
capture_output,
&mut captured_values,
)
.await?;

if should_continue {
continue;
}
} else {
handle_binary_response(response, &content_type, output_path, output_format).await?;
} else if let Some(res) = handle_binary_response(
response,
&content_type,
output_path,
output_format,
capture_output,
)
.await?
{
captured_values.push(res);
}

break;
}

Ok(())
if capture_output && !captured_values.is_empty() {
if captured_values.len() == 1 {
return Ok(Some(captured_values.pop().unwrap()));
} else {
return Ok(Some(Value::Array(captured_values)));
}
}

Ok(None)
}

fn build_url(
Expand Down Expand Up @@ -522,11 +556,11 @@ pub fn extract_enable_url(message: &str) -> Option<String> {
Some(url.to_string())
}

fn handle_error_response(
fn handle_error_response<T>(
status: reqwest::StatusCode,
error_body: &str,
auth_method: &AuthMethod,
) -> Result<(), GwsError> {
) -> Result<T, GwsError> {
// If 401/403 and no auth was provided, give a helpful message
if (status.as_u16() == 401 || status.as_u16() == 403) && *auth_method == AuthMethod::None {
return Err(GwsError::Auth(
Expand Down Expand Up @@ -1130,7 +1164,7 @@ mod tests {

#[test]
fn test_handle_error_response_401() {
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::UNAUTHORIZED,
"Unauthorized",
&AuthMethod::None,
Expand All @@ -1153,7 +1187,7 @@ mod tests {
})
.to_string();

let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::BAD_REQUEST,
&json_err,
&AuthMethod::OAuth,
Expand Down Expand Up @@ -1245,6 +1279,7 @@ async fn test_execute_method_dry_run() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
false,
)
.await;

Expand Down Expand Up @@ -1287,6 +1322,7 @@ async fn test_execute_method_missing_path_param() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
false,
)
.await;

Expand All @@ -1299,7 +1335,7 @@ async fn test_execute_method_missing_path_param() {

#[test]
fn test_handle_error_response_non_json() {
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error Text",
&AuthMethod::OAuth,
Expand Down Expand Up @@ -1366,7 +1402,7 @@ fn test_handle_error_response_access_not_configured_with_url() {
})
.to_string();

let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::FORBIDDEN,
&json_err,
&AuthMethod::OAuth,
Expand Down Expand Up @@ -1403,7 +1439,7 @@ fn test_handle_error_response_access_not_configured_errors_array() {
})
.to_string();

let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::FORBIDDEN,
&json_err,
&AuthMethod::OAuth,
Expand Down
Loading
Loading