Skip to content

Commit 670267f

Browse files
author
Justin Poehnelt
authored
feat: add gws mcp server (npm#58)
* feat: add gws mcp server Adds a new `gws mcp` subcommand that starts a Model Context Protocol (MCP) server over stdio, exposing Google Workspace APIs as structured tools to any MCP-compatible client. - New `src/mcp_server.rs`: JSON-RPC stdio transport, handles `initialize`, `tools/list`, and `tools/call` - Tool discovery dynamically builds schemas from Google Discovery Docs - Filtering via `-s <services>` flag (e.g. `-s drive,gmail` or `-s all`) - `-w/--workflows` and `-e/--helpers` flags for optional extras - stderr startup warning when no services are configured - Refactored `executor::execute_method` to support output capture (returns `Option<Value>` instead of printing to stdout) so the MCP transport is not corrupted - Updated README.md with MCP Server section and usage examples * fix: address PR review comments - Add stderr warning when discovery doc fails to load (mcp_server.rs) - Remove redundant 'all' string check in service validation (mcp_server.rs) - Validate upload path to prevent arbitrary file reads - security fix (mcp_server.rs) - Remove redundant inner capture_output check in handle_binary_response (executor.rs) - Add changeset for minor version bump * fix: resolve CI lint, fmt, and test failures - cargo fmt: format all changed files - clippy: add #[allow(clippy::too_many_arguments)] on private handle_json_response - clippy: collapse else { if } to else if in executor.rs - clippy: replace svc_name.clone() with std::slice::from_ref in mcp_server.rs - clippy: replace index-based loop with iterator in walk path resolution - test: add ::<()> turbofish annotation to handle_error_response test calls to fix E0282 type inference errors
1 parent 8c1042a commit 670267f

12 files changed

Lines changed: 574 additions & 40 deletions

File tree

.changeset/add-mcp-server.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
feat: add `gws mcp` Model Context Protocol server
6+
7+
Adds a new `gws mcp` subcommand that starts an MCP server over stdio,
8+
exposing Google Workspace APIs as structured tools to any MCP-compatible
9+
client (Claude Desktop, Gemini CLI, VS Code, etc.).

README.md

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,12 @@ Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JS
1515
</p>
1616
<br>
1717

18-
1918
```bash
2019
npm install -g @googleworkspace/cli
2120
```
2221

2322
`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.
2423

25-
2624
> [!IMPORTANT]
2725
> This project is under active development. Expect breaking changes as we march toward v1.0.
2826
@@ -36,6 +34,7 @@ npm install -g @googleworkspace/cli
3634
- [Why gws?](#why-gws)
3735
- [Authentication](#authentication)
3836
- [AI Agent Skills](#ai-agent-skills)
37+
- [MCP Server](#mcp-server)
3938
- [Advanced Usage](#advanced-usage)
4039
- [Architecture](#architecture)
4140
- [Development](#development)
@@ -55,7 +54,6 @@ Or build from source:
5554
cargo install --path .
5655
```
5756

58-
5957
## Why gws?
6058

6159
**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.
@@ -82,7 +80,6 @@ gws schema drive.files.list
8280
gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name'
8381
```
8482

85-
8683
## Authentication
8784

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

168165
### Precedence
169166

170-
| Priority | Source | Set via |
171-
|----------|--------|---------|
172-
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
173-
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
174-
| 3 | Encrypted credentials (OS keyring) | `gws auth login` |
175-
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
167+
| Priority | Source | Set via |
168+
| -------- | ---------------------------------- | --------------------------------------- |
169+
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
170+
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
171+
| 3 | Encrypted credentials (OS keyring) | `gws auth login` |
172+
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
176173

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

179-
180176
## AI Agent Skills
181177

182178
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.
@@ -205,10 +201,10 @@ The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the
205201

206202
</details>
207203

208-
209204
## Gemini CLI Extension
210205

211206
1. Authenticate the CLI first:
207+
212208
```bash
213209
gws auth setup
214210
```
@@ -220,6 +216,39 @@ The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the
220216

221217
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.
222218

219+
## MCP Server
220+
221+
`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.
222+
223+
```bash
224+
gws mcp -s drive # expose Drive tools
225+
gws mcp -s drive,gmail,calendar # expose multiple services
226+
gws mcp -s all # expose all services (many tools!)
227+
```
228+
229+
Configure in your MCP client:
230+
231+
```json
232+
{
233+
"mcpServers": {
234+
"gws": {
235+
"command": "gws",
236+
"args": ["mcp", "-s", "drive,gmail,calendar"]
237+
}
238+
}
239+
}
240+
```
241+
242+
> [!TIP]
243+
> Each service adds roughly 10–80 tools. Keep the list to what you actually need
244+
> to stay under your client's tool limit (typically 50–100 tools).
245+
246+
| Flag | Description |
247+
| ----------------------- | -------------------------------------------- |
248+
| `-s, --services <list>` | Comma-separated services to expose, or `all` |
249+
| `-w, --workflows` | Also expose workflow tools |
250+
| `-e, --helpers` | Also expose helper tools |
251+
223252
## Advanced Usage
224253

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

231260
### Pagination
232261

233-
| Flag | Description | Default |
234-
|------|-------------|---------|
235-
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
236-
| `--page-limit <N>` | Max pages to fetch | 10 |
237-
| `--page-delay <MS>` | Delay between pages | 100 ms |
262+
| Flag | Description | Default |
263+
| ------------------- | ---------------------------------------------- | ------- |
264+
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
265+
| `--page-limit <N>` | Max pages to fetch | 10 |
266+
| `--page-delay <MS>` | Delay between pages | 100 ms |
238267

239268
### Model Armor (Response Sanitization)
240269

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

248-
| Variable | Description |
249-
|----------|-------------|
277+
| Variable | Description |
278+
| ---------------------------------------- | ---------------------------- |
250279
| `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template |
251-
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
252-
280+
| `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` |
253281

254282
## Architecture
255283

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

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

266-
267294
## Troubleshooting
268295

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

293320
**Steps to fix:**
321+
294322
1. Click the `enable_url` link (or copy it from the `enable_url` JSON field).
295323
2. In the GCP Console, click **Enable**.
296324
3. Wait ~10 seconds, then retry your `gws` command.
@@ -299,7 +327,6 @@ If a required Google API is not enabled for your GCP project, you will see a
299327
> You can also run `gws auth setup` which walks you through enabling all required
300328
> APIs for your project automatically.
301329
302-
303330
## Development
304331

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

312-
313339
## License
314340

315341
Apache-2.0

src/executor.rs

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ async fn build_http_request(
199199

200200
/// Handle a JSON response: parse, sanitize via Model Armor, output, and check pagination.
201201
/// Returns `Ok(true)` if the pagination loop should continue.
202+
#[allow(clippy::too_many_arguments)]
202203
async fn handle_json_response(
203204
body_text: &str,
204205
pagination: &PaginationConfig,
@@ -207,6 +208,8 @@ async fn handle_json_response(
207208
output_format: &crate::formatter::OutputFormat,
208209
pages_fetched: &mut u32,
209210
page_token: &mut Option<String>,
211+
capture_output: bool,
212+
captured: &mut Vec<Value>,
210213
) -> Result<bool, GwsError> {
211214
if let Ok(mut json_val) = serde_json::from_str::<Value>(body_text) {
212215
*pages_fetched += 1;
@@ -249,7 +252,9 @@ async fn handle_json_response(
249252
}
250253
}
251254

252-
if pagination.page_all {
255+
if capture_output {
256+
captured.push(json_val.clone());
257+
} else if pagination.page_all {
253258
let is_first_page = *pages_fetched == 1;
254259
println!(
255260
"{}",
@@ -279,7 +284,7 @@ async fn handle_json_response(
279284
}
280285
} else {
281286
// Not valid JSON, output as-is
282-
if !body_text.is_empty() {
287+
if !capture_output && !body_text.is_empty() {
283288
println!("{body_text}");
284289
}
285290
}
@@ -293,7 +298,8 @@ async fn handle_binary_response(
293298
content_type: &str,
294299
output_path: Option<&str>,
295300
output_format: &crate::formatter::OutputFormat,
296-
) -> Result<(), GwsError> {
301+
capture_output: bool,
302+
) -> Result<Option<Value>, GwsError> {
297303
let file_path = if let Some(p) = output_path {
298304
PathBuf::from(p)
299305
} else {
@@ -324,9 +330,14 @@ async fn handle_binary_response(
324330
"mimeType": content_type,
325331
"bytes": total_bytes,
326332
});
333+
334+
if capture_output {
335+
return Ok(Some(result));
336+
}
337+
327338
println!("{}", crate::formatter::format_value(&result, output_format));
328339

329-
Ok(())
340+
Ok(None)
330341
}
331342

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

360372
if dry_run {
@@ -366,15 +378,19 @@ pub async fn execute_method(
366378
"body": input.body,
367379
"is_multipart_upload": input.is_upload,
368380
});
381+
if capture_output {
382+
return Ok(Some(dry_run_info));
383+
}
369384
println!(
370385
"{}",
371386
crate::formatter::format_value(&dry_run_info, output_format)
372387
);
373-
return Ok(());
388+
return Ok(None);
374389
}
375390

376391
let mut page_token: Option<String> = None;
377392
let mut pages_fetched: u32 = 0;
393+
let mut captured_values = Vec::new();
378394

379395
loop {
380396
let client = crate::client::build_client()?;
@@ -422,20 +438,38 @@ pub async fn execute_method(
422438
output_format,
423439
&mut pages_fetched,
424440
&mut page_token,
441+
capture_output,
442+
&mut captured_values,
425443
)
426444
.await?;
427445

428446
if should_continue {
429447
continue;
430448
}
431-
} else {
432-
handle_binary_response(response, &content_type, output_path, output_format).await?;
449+
} else if let Some(res) = handle_binary_response(
450+
response,
451+
&content_type,
452+
output_path,
453+
output_format,
454+
capture_output,
455+
)
456+
.await?
457+
{
458+
captured_values.push(res);
433459
}
434460

435461
break;
436462
}
437463

438-
Ok(())
464+
if capture_output && !captured_values.is_empty() {
465+
if captured_values.len() == 1 {
466+
return Ok(Some(captured_values.pop().unwrap()));
467+
} else {
468+
return Ok(Some(Value::Array(captured_values)));
469+
}
470+
}
471+
472+
Ok(None)
439473
}
440474

441475
fn build_url(
@@ -522,11 +556,11 @@ pub fn extract_enable_url(message: &str) -> Option<String> {
522556
Some(url.to_string())
523557
}
524558

525-
fn handle_error_response(
559+
fn handle_error_response<T>(
526560
status: reqwest::StatusCode,
527561
error_body: &str,
528562
auth_method: &AuthMethod,
529-
) -> Result<(), GwsError> {
563+
) -> Result<T, GwsError> {
530564
// If 401/403 and no auth was provided, give a helpful message
531565
if (status.as_u16() == 401 || status.as_u16() == 403) && *auth_method == AuthMethod::None {
532566
return Err(GwsError::Auth(
@@ -1130,7 +1164,7 @@ mod tests {
11301164

11311165
#[test]
11321166
fn test_handle_error_response_401() {
1133-
let err = handle_error_response(
1167+
let err = handle_error_response::<()>(
11341168
reqwest::StatusCode::UNAUTHORIZED,
11351169
"Unauthorized",
11361170
&AuthMethod::None,
@@ -1153,7 +1187,7 @@ mod tests {
11531187
})
11541188
.to_string();
11551189

1156-
let err = handle_error_response(
1190+
let err = handle_error_response::<()>(
11571191
reqwest::StatusCode::BAD_REQUEST,
11581192
&json_err,
11591193
&AuthMethod::OAuth,
@@ -1245,6 +1279,7 @@ async fn test_execute_method_dry_run() {
12451279
None,
12461280
&sanitize_mode,
12471281
&crate::formatter::OutputFormat::default(),
1282+
false,
12481283
)
12491284
.await;
12501285

@@ -1287,6 +1322,7 @@ async fn test_execute_method_missing_path_param() {
12871322
None,
12881323
&sanitize_mode,
12891324
&crate::formatter::OutputFormat::default(),
1325+
false,
12901326
)
12911327
.await;
12921328

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

13001336
#[test]
13011337
fn test_handle_error_response_non_json() {
1302-
let err = handle_error_response(
1338+
let err = handle_error_response::<()>(
13031339
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
13041340
"Internal Server Error Text",
13051341
&AuthMethod::OAuth,
@@ -1366,7 +1402,7 @@ fn test_handle_error_response_access_not_configured_with_url() {
13661402
})
13671403
.to_string();
13681404

1369-
let err = handle_error_response(
1405+
let err = handle_error_response::<()>(
13701406
reqwest::StatusCode::FORBIDDEN,
13711407
&json_err,
13721408
&AuthMethod::OAuth,
@@ -1403,7 +1439,7 @@ fn test_handle_error_response_access_not_configured_errors_array() {
14031439
})
14041440
.to_string();
14051441

1406-
let err = handle_error_response(
1442+
let err = handle_error_response::<()>(
14071443
reqwest::StatusCode::FORBIDDEN,
14081444
&json_err,
14091445
&AuthMethod::OAuth,

0 commit comments

Comments
 (0)