From 47f99b9ff1ae8a97b93d8b8eaca0c28898ec2673 Mon Sep 17 00:00:00 2001
From: Justin Poehnelt
Date: Wed, 4 Mar 2026 09:16:25 -0700
Subject: [PATCH 1/3] 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 ` 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` instead of printing to stdout) so the MCP
transport is not corrupted
- Updated README.md with MCP Server section and usage examples
---
README.md | 74 ++++---
src/executor.rs | 51 ++++-
src/helpers/calendar.rs | 3 +-
src/helpers/chat.rs | 3 +-
src/helpers/docs.rs | 3 +-
src/helpers/drive.rs | 3 +-
src/helpers/gmail/send.rs | 1 +
src/helpers/script.rs | 3 +-
src/helpers/sheets.rs | 6 +-
src/main.rs | 8 +
src/mcp_server.rs | 417 ++++++++++++++++++++++++++++++++++++++
11 files changed, 531 insertions(+), 41 deletions(-)
create mode 100644 src/mcp_server.rs
diff --git a/README.md b/README.md
index f302075c..7f47a207 100644
--- a/README.md
+++ b/README.md
@@ -15,14 +15,12 @@ Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JS
-
```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.
@@ -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)
@@ -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.
@@ -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.
@@ -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.
@@ -205,10 +201,10 @@ The `gws-shared` skill includes an `install` block so OpenClaw auto-installs the
-
## Gemini CLI Extension
1. Authenticate the CLI first:
+
```bash
gws setup
```
@@ -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 ` | Comma-separated services to expose, or `all` |
+| `-w, --workflows` | Also expose workflow tools |
+| `-e, --helpers` | Also expose helper tools |
+
## Advanced Usage
### Multipart Uploads
@@ -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 ` | Max pages to fetch | 10 |
-| `--page-delay ` | Delay between pages | 100 ms |
+| Flag | Description | Default |
+| ------------------- | ---------------------------------------------- | ------- |
+| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
+| `--page-limit ` | Max pages to fetch | 10 |
+| `--page-delay ` | Delay between pages | 100 ms |
### Model Armor (Response Sanitization)
@@ -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
@@ -263,7 +291,6 @@ gws gmail users messages get --params '...' \
All output — success, errors, download metadata — is structured JSON.
-
## Troubleshooting
### API not enabled — `accessNotConfigured`
@@ -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.
@@ -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
@@ -309,7 +336,6 @@ cargo test # unit tests
./scripts/coverage.sh # HTML coverage report → target/llvm-cov/html/
```
-
## License
Apache-2.0
diff --git a/src/executor.rs b/src/executor.rs
index 73e531a7..5f91a3f7 100644
--- a/src/executor.rs
+++ b/src/executor.rs
@@ -207,6 +207,8 @@ async fn handle_json_response(
output_format: &crate::formatter::OutputFormat,
pages_fetched: &mut u32,
page_token: &mut Option,
+ capture_output: bool,
+ captured: &mut Vec,
) -> Result {
if let Ok(mut json_val) = serde_json::from_str::(body_text) {
*pages_fetched += 1;
@@ -249,7 +251,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!(
"{}",
@@ -279,7 +283,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}");
}
}
@@ -293,7 +297,8 @@ async fn handle_binary_response(
content_type: &str,
output_path: Option<&str>,
output_format: &crate::formatter::OutputFormat,
-) -> Result<(), GwsError> {
+ capture_output: bool,
+) -> Result, GwsError> {
let file_path = if let Some(p) = output_path {
PathBuf::from(p)
} else {
@@ -324,9 +329,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.
@@ -354,7 +364,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 , GwsError> {
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?;
if dry_run {
@@ -366,15 +377,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 = None;
let mut pages_fetched: u32 = 0;
+ let mut captured_values = Vec::new();
loop {
let client = crate::client::build_client()?;
@@ -422,6 +437,8 @@ pub async fn execute_method(
output_format,
&mut pages_fetched,
&mut page_token,
+ capture_output,
+ &mut captured_values,
)
.await?;
@@ -429,13 +446,25 @@ pub async fn execute_method(
continue;
}
} else {
- handle_binary_response(response, &content_type, output_path, output_format).await?;
+ if let Some(res) = handle_binary_response(response, &content_type, output_path, output_format, capture_output).await? {
+ if capture_output {
+ 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(
@@ -522,11 +551,11 @@ pub fn extract_enable_url(message: &str) -> Option {
Some(url.to_string())
}
-fn handle_error_response(
+fn handle_error_response(
status: reqwest::StatusCode,
error_body: &str,
auth_method: &AuthMethod,
-) -> Result<(), GwsError> {
+) -> Result {
// 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(
@@ -1245,6 +1274,7 @@ async fn test_execute_method_dry_run() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
+ false
)
.await;
@@ -1287,6 +1317,7 @@ async fn test_execute_method_missing_path_param() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
+ false
)
.await;
diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs
index 1db64aa2..4f56cbf8 100644
--- a/src/helpers/calendar.rs
+++ b/src/helpers/calendar.rs
@@ -175,7 +175,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs
index 8cc469da..107c7d2f 100644
--- a/src/helpers/chat.rs
+++ b/src/helpers/chat.rs
@@ -114,7 +114,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs
index b54e053d..72229a0f 100644
--- a/src/helpers/docs.rs
+++ b/src/helpers/docs.rs
@@ -104,7 +104,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs
index 0476a9da..8130b14e 100644
--- a/src/helpers/drive.rs
+++ b/src/helpers/drive.rs
@@ -115,7 +115,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs
index 078c379a..e9fd4a31 100644
--- a/src/helpers/gmail/send.rs
+++ b/src/helpers/gmail/send.rs
@@ -52,6 +52,7 @@ pub(super) async fn handle_send(
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
+ false
)
.await?;
diff --git a/src/helpers/script.rs b/src/helpers/script.rs
index 13aa4b34..21d9247e 100644
--- a/src/helpers/script.rs
+++ b/src/helpers/script.rs
@@ -127,7 +127,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs
index e35c61cd..90b0ddf4 100644
--- a/src/helpers/sheets.rs
+++ b/src/helpers/sheets.rs
@@ -141,7 +141,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
@@ -182,7 +183,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- )
+ false
+ )
.await?;
return Ok(true);
diff --git a/src/main.rs b/src/main.rs
index 6875e9a0..6ee6cd28 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,6 +31,7 @@ mod formatter;
mod fs_util;
mod generate_skills;
mod helpers;
+mod mcp_server;
mod oauth_config;
mod schema;
mod services;
@@ -103,6 +104,11 @@ async fn run() -> Result<(), GwsError> {
return auth_commands::handle_auth_command(&auth_args).await;
}
+ // Handle the `mcp` command
+ if first_arg == "mcp" {
+ return mcp_server::start(&args[1..]).await;
+ }
+
// Parse service name and optional version override
let (api_name, version) = parse_service_and_version(&args, first_arg)?;
@@ -217,8 +223,10 @@ async fn run() -> Result<(), GwsError> {
sanitize_config.template.as_deref(),
&sanitize_config.mode,
&output_format,
+ false
)
.await
+ .map(|_| ())
}
fn parse_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig {
diff --git a/src/mcp_server.rs b/src/mcp_server.rs
new file mode 100644
index 00000000..1f82c569
--- /dev/null
+++ b/src/mcp_server.rs
@@ -0,0 +1,417 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Model Context Protocol (MCP) server implementation.
+//! Provides a stdio JSON-RPC server exposing Google Workspace APIs as MCP tools.
+
+use crate::error::GwsError;
+use crate::services;
+use crate::discovery::RestResource;
+use clap::{Arg, Command};
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+
+#[derive(Debug, Clone)]
+struct ServerConfig {
+ services: Vec,
+ workflows: bool,
+ _helpers: bool,
+}
+
+fn build_mcp_cli() -> Command {
+ Command::new("mcp")
+ .about("Starts the MCP server over stdio")
+ .arg(
+ Arg::new("services")
+ .long("services")
+ .short('s')
+ .help("Comma separated list of services to expose (e.g., drive,gmail,all)")
+ .default_value(""),
+ )
+ .arg(
+ Arg::new("workflows")
+ .long("workflows")
+ .short('w')
+ .action(clap::ArgAction::SetTrue)
+ .help("Expose workflows as tools"),
+ )
+ .arg(
+ Arg::new("helpers")
+ .long("helpers")
+ .short('e')
+ .action(clap::ArgAction::SetTrue)
+ .help("Expose service-specific helpers as tools"),
+ )
+}
+
+pub async fn start(args: &[String]) -> Result<(), GwsError> {
+ // Parse args
+ let matches = build_mcp_cli().get_matches_from(args);
+ let mut config = ServerConfig {
+ services: Vec::new(),
+ workflows: matches.get_flag("workflows"),
+ _helpers: matches.get_flag("helpers"),
+ };
+
+ let svc_str = matches.get_one::("services").unwrap();
+ if !svc_str.is_empty() {
+ if svc_str == "all" {
+ config.services = services::SERVICES.iter().map(|s| s.aliases[0].to_string()).collect();
+ } else {
+ config.services = svc_str.split(',').map(|s| s.trim().to_string()).collect();
+ }
+ }
+
+ if config.services.is_empty() {
+ eprintln!("[gws mcp] Warning: No services configured. Zero tools will be exposed.");
+ eprintln!("[gws mcp] Re-run with: gws mcp -s (e.g., -s drive,gmail,calendar)");
+ eprintln!("[gws mcp] Use -s all to expose all available services.");
+ } else {
+ eprintln!("[gws mcp] Starting with services: {}", config.services.join(", "));
+ }
+
+ let mut stdin = BufReader::new(tokio::io::stdin()).lines();
+ let mut stdout = tokio::io::stdout();
+
+ // Cache to hold generated tools configuration so we do not spam fetch from Google discovery
+ let mut tools_cache = None;
+
+ while let Ok(Some(line)) = stdin.next_line().await {
+ if line.trim().is_empty() {
+ continue;
+ }
+
+ match serde_json::from_str::(&line) {
+ Ok(req) => {
+ let is_notification = req.get("id").is_none();
+ let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
+ let params = req.get("params").cloned().unwrap_or_else(|| json!({}));
+
+ let result = handle_request(method, ¶ms, &config, &mut tools_cache).await;
+
+ if !is_notification {
+ let id = req.get("id").unwrap();
+ let response = match result {
+ Ok(res) => json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": res
+ }),
+ Err(e) => json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": {
+ "code": -32603,
+ "message": e.to_string()
+ }
+ })
+ };
+
+ let mut out = serde_json::to_string(&response).unwrap();
+ out.push('\n');
+ let _ = stdout.write_all(out.as_bytes()).await;
+ let _ = stdout.flush().await;
+ }
+ }
+ Err(_) => {
+ let response = json!({
+ "jsonrpc": "2.0",
+ "id": Value::Null,
+ "error": {
+ "code": -32700,
+ "message": "Parse error"
+ }
+ });
+ let mut out = serde_json::to_string(&response).unwrap();
+ out.push('\n');
+ let _ = stdout.write_all(out.as_bytes()).await;
+ let _ = stdout.flush().await;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+async fn handle_request(
+ method: &str,
+ params: &Value,
+ config: &ServerConfig,
+ tools_cache: &mut Option>,
+) -> Result {
+ match method {
+ "initialize" => Ok(json!({
+ "protocolVersion": "2024-11-05",
+ "serverInfo": {
+ "name": "gws-mcp",
+ "version": env!("CARGO_PKG_VERSION")
+ },
+ "capabilities": {
+ "tools": {}
+ }
+ })),
+ "notifications/initialized" => {
+ // Do nothing
+ Ok(json!({}))
+ }
+ "tools/list" => {
+ if tools_cache.is_none() {
+ *tools_cache = Some(build_tools_list(config).await?);
+ }
+ Ok(json!({
+ "tools": tools_cache.as_ref().unwrap()
+ }))
+ }
+ "tools/call" => {
+ handle_tools_call(params, config).await
+ }
+ _ => Err(GwsError::Validation(format!(
+ "Method not supported: {}",
+ method
+ ))),
+ }
+}
+
+async fn build_tools_list(config: &ServerConfig) -> Result, GwsError> {
+ let mut tools = Vec::new();
+
+ // 1. Walk core services
+ for svc_name in &config.services {
+ let (api_name, version) = crate::parse_service_and_version(&[svc_name.clone()], svc_name)?;
+ if let Ok(doc) = crate::discovery::fetch_discovery_document(&api_name, &version).await {
+ walk_resources(&doc.name, &doc.resources, &mut tools);
+ }
+ }
+
+ // 2. Helpers and Workflows (Not fully mapped yet, but structure is here)
+ if config.workflows {
+ // Expose workflows
+ tools.push(json!({
+ "name": "workflow_standup_report",
+ "description": "Today's meetings + open tasks as a standup summary",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "format": { "type": "string", "description": "Output format: json, table, yaml, csv" }
+ }
+ }
+ }));
+ tools.push(json!({
+ "name": "workflow_meeting_prep",
+ "description": "Prepare for your next meeting: agenda, attendees, and linked docs",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "calendar": { "type": "string", "description": "Calendar ID (default: primary)" }
+ }
+ }
+ }));
+ tools.push(json!({
+ "name": "workflow_email_to_task",
+ "description": "Convert a Gmail message into a Google Tasks entry",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "message_id": { "type": "string", "description": "Gmail message ID" },
+ "tasklist": { "type": "string", "description": "Task list ID" }
+ },
+ "required": ["message_id"]
+ }
+ }));
+ tools.push(json!({
+ "name": "workflow_weekly_digest",
+ "description": "Weekly summary: this week's meetings + unread email count",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "format": { "type": "string", "description": "Output format" }
+ }
+ }
+ }));
+ tools.push(json!({
+ "name": "workflow_file_announce",
+ "description": "Announce a Drive file in a Chat space",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "file_id": { "type": "string", "description": "Drive file ID" },
+ "space": { "type": "string", "description": "Chat space name" },
+ "message": { "type": "string", "description": "Custom message" }
+ },
+ "required": ["file_id", "space"]
+ }
+ }));
+ }
+
+ Ok(tools)
+}
+
+fn walk_resources(
+ prefix: &str,
+ resources: &HashMap,
+ tools: &mut Vec,
+) {
+ for (res_name, res) in resources {
+ let new_prefix = format!("{}_{}", prefix, res_name);
+
+ for (method_name, method) in &res.methods {
+ let tool_name = format!("{}_{}", new_prefix, method_name);
+ let mut description = method.description.clone().unwrap_or_default();
+ if description.is_empty() {
+ description = format!("Execute the {} Google API method", tool_name);
+ }
+
+ // Generate JSON Schema for MCP input
+ let input_schema = json!({
+ "type": "object",
+ "properties": {
+ "params": {
+ "type": "object",
+ "description": "Query or path parameters (e.g. fileId, q, pageSize)"
+ },
+ "body": {
+ "type": "object",
+ "description": "Request body API object"
+ },
+ "upload": {
+ "type": "string",
+ "description": "Local file path to upload as media content"
+ },
+ "page_all": {
+ "type": "boolean",
+ "description": "Auto-paginate, returning all pages"
+ }
+ }
+ });
+
+ tools.push(json!({
+ "name": tool_name,
+ "description": description,
+ "inputSchema": input_schema
+ }));
+ }
+
+ // Recurse into sub-resources
+ if !res.resources.is_empty() {
+ walk_resources(&new_prefix, &res.resources, tools);
+ }
+ }
+}
+
+async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result {
+ let tool_name = params
+ .get("name")
+ .and_then(|n| n.as_str())
+ .ok_or_else(|| GwsError::Validation("Missing 'name' in tools/call".to_string()))?;
+
+ let default_args = json!({});
+ let arguments = params.get("arguments").unwrap_or(&default_args);
+
+ if tool_name.starts_with("workflow_") {
+ return Err(GwsError::Other(anyhow::anyhow!("Workflows are not yet fully implemented via MCP")));
+ }
+
+ let parts: Vec<&str> = tool_name.split('_').collect();
+ if parts.len() < 3 {
+ return Err(GwsError::Validation(format!("Invalid API tool name: {}", tool_name)));
+ }
+
+ let svc_alias = parts[0];
+
+ if !config.services.contains(&svc_alias.to_string()) && !config.services.contains(&"all".to_string()) {
+ return Err(GwsError::Validation(format!("Service '{}' is not enabled in this MCP session", svc_alias)));
+ }
+
+ let (api_name, version) = crate::parse_service_and_version(&[svc_alias.to_string()], svc_alias)?;
+ let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?;
+
+ let mut current_resources = &doc.resources;
+ let mut current_res = None;
+
+ // e.g. ["drive", "files", "list"]
+ // i goes from 1 to len - 2. For len=3, i=1.
+ for i in 1..parts.len() - 1 {
+ let res_name = parts[i];
+ if let Some(res) = current_resources.get(res_name) {
+ current_res = Some(res);
+ current_resources = &res.resources;
+ } else {
+ return Err(GwsError::Validation(format!("Resource '{}' not found in Discovery Document", res_name)));
+ }
+ }
+
+ let method_name = parts.last().unwrap();
+ let method = if let Some(res) = current_res {
+ res.methods
+ .get(*method_name)
+ .ok_or_else(|| GwsError::Validation(format!("Method '{}' not found", method_name)))?
+ } else {
+ return Err(GwsError::Validation("Resource not found".to_string()));
+ };
+
+ let params_json_val = arguments.get("params");
+ let params_str = params_json_val.map(|v| serde_json::to_string(v).unwrap());
+
+ let body_json_val = arguments.get("body");
+ let body_str = body_json_val.map(|v| serde_json::to_string(v).unwrap());
+
+ let upload_path = arguments.get("upload").and_then(|v| v.as_str());
+ let page_all = arguments.get("page_all").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ let pagination = crate::executor::PaginationConfig {
+ page_all,
+ page_limit: 100, // Safe default for MCP
+ page_delay_ms: 100,
+ };
+
+ let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect();
+ let (token, auth_method) = match crate::auth::get_token(&scopes).await {
+ Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth),
+ Err(_) => (None, crate::executor::AuthMethod::None),
+ };
+
+ let result = crate::executor::execute_method(
+ &doc,
+ method,
+ params_str.as_deref(),
+ body_str.as_deref(),
+ token.as_deref(),
+ auth_method,
+ None,
+ upload_path,
+ false,
+ &pagination,
+ None,
+ &crate::helpers::modelarmor::SanitizeMode::Warn,
+ &crate::formatter::OutputFormat::default(),
+ true, // capture_output = true!
+ ).await?;
+
+ let text_content = match result {
+ Some(val) => serde_json::to_string_pretty(&val).unwrap_or_else(|_| "[]".to_string()),
+ None => "Execution completed with no output.".to_string(),
+ };
+
+ Ok(json!({
+ "content": [
+ {
+ "type": "text",
+ "text": text_content
+ }
+ ],
+ "isError": false
+ }))
+}
+
From a02f36518de5c5106cb1a76001ccc901f08b5fc2 Mon Sep 17 00:00:00 2001
From: Justin Poehnelt
Date: Wed, 4 Mar 2026 09:51:17 -0700
Subject: [PATCH 2/3] 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
---
.changeset/add-mcp-server.md | 9 +++++++++
src/executor.rs | 4 +---
src/mcp_server.rs | 20 ++++++++++++++++++--
3 files changed, 28 insertions(+), 5 deletions(-)
create mode 100644 .changeset/add-mcp-server.md
diff --git a/.changeset/add-mcp-server.md b/.changeset/add-mcp-server.md
new file mode 100644
index 00000000..a686737a
--- /dev/null
+++ b/.changeset/add-mcp-server.md
@@ -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.).
diff --git a/src/executor.rs b/src/executor.rs
index 5f91a3f7..b1655277 100644
--- a/src/executor.rs
+++ b/src/executor.rs
@@ -447,9 +447,7 @@ pub async fn execute_method(
}
} else {
if let Some(res) = handle_binary_response(response, &content_type, output_path, output_format, capture_output).await? {
- if capture_output {
- captured_values.push(res);
- }
+ captured_values.push(res);
}
}
diff --git a/src/mcp_server.rs b/src/mcp_server.rs
index 1f82c569..fa8f4ecb 100644
--- a/src/mcp_server.rs
+++ b/src/mcp_server.rs
@@ -192,6 +192,8 @@ async fn build_tools_list(config: &ServerConfig) -> Result, GwsError>
let (api_name, version) = crate::parse_service_and_version(&[svc_name.clone()], svc_name)?;
if let Ok(doc) = crate::discovery::fetch_discovery_document(&api_name, &version).await {
walk_resources(&doc.name, &doc.resources, &mut tools);
+ } else {
+ eprintln!("[gws mcp] Warning: Failed to load discovery document for service '{}'. It will not be available as a tool.", svc_name);
}
}
@@ -330,7 +332,7 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result Result
Date: Wed, 4 Mar 2026 10:00:33 -0700
Subject: [PATCH 3/3] 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
---
src/executor.rs | 33 ++++++++++-------
src/helpers/calendar.rs | 4 +--
src/helpers/chat.rs | 4 +--
src/helpers/docs.rs | 4 +--
src/helpers/drive.rs | 4 +--
src/helpers/gmail/send.rs | 2 +-
src/helpers/script.rs | 4 +--
src/helpers/sheets.rs | 8 ++---
src/main.rs | 2 +-
src/mcp_server.rs | 76 +++++++++++++++++++++++----------------
10 files changed, 81 insertions(+), 60 deletions(-)
diff --git a/src/executor.rs b/src/executor.rs
index b1655277..5a900a08 100644
--- a/src/executor.rs
+++ b/src/executor.rs
@@ -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,
@@ -329,11 +330,11 @@ 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(None)
@@ -445,10 +446,16 @@ pub async fn execute_method(
if should_continue {
continue;
}
- } else {
- if let Some(res) = handle_binary_response(response, &content_type, output_path, output_format, capture_output).await? {
- captured_values.push(res);
- }
+ } else if let Some(res) = handle_binary_response(
+ response,
+ &content_type,
+ output_path,
+ output_format,
+ capture_output,
+ )
+ .await?
+ {
+ captured_values.push(res);
}
break;
@@ -1157,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,
@@ -1180,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,
@@ -1272,7 +1279,7 @@ async fn test_execute_method_dry_run() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
- false
+ false,
)
.await;
@@ -1315,7 +1322,7 @@ async fn test_execute_method_missing_path_param() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
- false
+ false,
)
.await;
@@ -1328,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,
@@ -1395,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,
@@ -1432,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,
diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs
index 4f56cbf8..e300592f 100644
--- a/src/helpers/calendar.rs
+++ b/src/helpers/calendar.rs
@@ -175,8 +175,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs
index 107c7d2f..fce51ed5 100644
--- a/src/helpers/chat.rs
+++ b/src/helpers/chat.rs
@@ -114,8 +114,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs
index 72229a0f..e847dfbf 100644
--- a/src/helpers/docs.rs
+++ b/src/helpers/docs.rs
@@ -104,8 +104,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs
index 8130b14e..f23aa555 100644
--- a/src/helpers/drive.rs
+++ b/src/helpers/drive.rs
@@ -115,8 +115,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/gmail/send.rs b/src/helpers/gmail/send.rs
index e9fd4a31..20a2605c 100644
--- a/src/helpers/gmail/send.rs
+++ b/src/helpers/gmail/send.rs
@@ -52,7 +52,7 @@ pub(super) async fn handle_send(
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
+ false,
)
.await?;
diff --git a/src/helpers/script.rs b/src/helpers/script.rs
index 21d9247e..8685afb2 100644
--- a/src/helpers/script.rs
+++ b/src/helpers/script.rs
@@ -127,8 +127,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs
index 90b0ddf4..5c2a9e90 100644
--- a/src/helpers/sheets.rs
+++ b/src/helpers/sheets.rs
@@ -141,8 +141,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
@@ -183,8 +183,8 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
- false
- )
+ false,
+ )
.await?;
return Ok(true);
diff --git a/src/main.rs b/src/main.rs
index 6ee6cd28..0236d221 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -223,7 +223,7 @@ async fn run() -> Result<(), GwsError> {
sanitize_config.template.as_deref(),
&sanitize_config.mode,
&output_format,
- false
+ false,
)
.await
.map(|_| ())
diff --git a/src/mcp_server.rs b/src/mcp_server.rs
index fa8f4ecb..d4271c00 100644
--- a/src/mcp_server.rs
+++ b/src/mcp_server.rs
@@ -15,9 +15,9 @@
//! Model Context Protocol (MCP) server implementation.
//! Provides a stdio JSON-RPC server exposing Google Workspace APIs as MCP tools.
+use crate::discovery::RestResource;
use crate::error::GwsError;
use crate::services;
-use crate::discovery::RestResource;
use clap::{Arg, Command};
use serde_json::{json, Value};
use std::collections::HashMap;
@@ -68,7 +68,10 @@ pub async fn start(args: &[String]) -> Result<(), GwsError> {
let svc_str = matches.get_one::("services").unwrap();
if !svc_str.is_empty() {
if svc_str == "all" {
- config.services = services::SERVICES.iter().map(|s| s.aliases[0].to_string()).collect();
+ config.services = services::SERVICES
+ .iter()
+ .map(|s| s.aliases[0].to_string())
+ .collect();
} else {
config.services = svc_str.split(',').map(|s| s.trim().to_string()).collect();
}
@@ -79,7 +82,10 @@ pub async fn start(args: &[String]) -> Result<(), GwsError> {
eprintln!("[gws mcp] Re-run with: gws mcp -s (e.g., -s drive,gmail,calendar)");
eprintln!("[gws mcp] Use -s all to expose all available services.");
} else {
- eprintln!("[gws mcp] Starting with services: {}", config.services.join(", "));
+ eprintln!(
+ "[gws mcp] Starting with services: {}",
+ config.services.join(", ")
+ );
}
let mut stdin = BufReader::new(tokio::io::stdin()).lines();
@@ -116,7 +122,7 @@ pub async fn start(args: &[String]) -> Result<(), GwsError> {
"code": -32603,
"message": e.to_string()
}
- })
+ }),
};
let mut out = serde_json::to_string(&response).unwrap();
@@ -141,7 +147,7 @@ pub async fn start(args: &[String]) -> Result<(), GwsError> {
}
}
}
-
+
Ok(())
}
@@ -174,9 +180,7 @@ async fn handle_request(
"tools": tools_cache.as_ref().unwrap()
}))
}
- "tools/call" => {
- handle_tools_call(params, config).await
- }
+ "tools/call" => handle_tools_call(params, config).await,
_ => Err(GwsError::Validation(format!(
"Method not supported: {}",
method
@@ -189,7 +193,8 @@ async fn build_tools_list(config: &ServerConfig) -> Result, GwsError>
// 1. Walk core services
for svc_name in &config.services {
- let (api_name, version) = crate::parse_service_and_version(&[svc_name.clone()], svc_name)?;
+ let (api_name, version) =
+ crate::parse_service_and_version(std::slice::from_ref(svc_name), svc_name)?;
if let Ok(doc) = crate::discovery::fetch_discovery_document(&api_name, &version).await {
walk_resources(&doc.name, &doc.resources, &mut tools);
} else {
@@ -260,21 +265,17 @@ async fn build_tools_list(config: &ServerConfig) -> Result, GwsError>
Ok(tools)
}
-fn walk_resources(
- prefix: &str,
- resources: &HashMap,
- tools: &mut Vec,
-) {
+fn walk_resources(prefix: &str, resources: &HashMap, tools: &mut Vec) {
for (res_name, res) in resources {
let new_prefix = format!("{}_{}", prefix, res_name);
-
+
for (method_name, method) in &res.methods {
let tool_name = format!("{}_{}", new_prefix, method_name);
let mut description = method.description.clone().unwrap_or_default();
if description.is_empty() {
description = format!("Execute the {} Google API method", tool_name);
}
-
+
// Generate JSON Schema for MCP input
let input_schema = json!({
"type": "object",
@@ -322,35 +323,45 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result = tool_name.split('_').collect();
if parts.len() < 3 {
- return Err(GwsError::Validation(format!("Invalid API tool name: {}", tool_name)));
+ return Err(GwsError::Validation(format!(
+ "Invalid API tool name: {}",
+ tool_name
+ )));
}
let svc_alias = parts[0];
-
+
if !config.services.contains(&svc_alias.to_string()) {
- return Err(GwsError::Validation(format!("Service '{}' is not enabled in this MCP session", svc_alias)));
+ return Err(GwsError::Validation(format!(
+ "Service '{}' is not enabled in this MCP session",
+ svc_alias
+ )));
}
- let (api_name, version) = crate::parse_service_and_version(&[svc_alias.to_string()], svc_alias)?;
+ let (api_name, version) =
+ crate::parse_service_and_version(&[svc_alias.to_string()], svc_alias)?;
let doc = crate::discovery::fetch_discovery_document(&api_name, &version).await?;
let mut current_resources = &doc.resources;
let mut current_res = None;
-
- // e.g. ["drive", "files", "list"]
- // i goes from 1 to len - 2. For len=3, i=1.
- for i in 1..parts.len() - 1 {
- let res_name = parts[i];
- if let Some(res) = current_resources.get(res_name) {
+
+ // Walk: ["drive", "files", "list"] — iterate resource path segments between service and method
+ for res_name in &parts[1..parts.len() - 1] {
+ if let Some(res) = current_resources.get(*res_name) {
current_res = Some(res);
current_resources = &res.resources;
} else {
- return Err(GwsError::Validation(format!("Resource '{}' not found in Discovery Document", res_name)));
+ return Err(GwsError::Validation(format!(
+ "Resource '{}' not found in Discovery Document",
+ res_name
+ )));
}
}
@@ -384,7 +395,10 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result Result serde_json::to_string_pretty(&val).unwrap_or_else(|_| "[]".to_string()),
@@ -430,4 +445,3 @@ async fn handle_tools_call(params: &Value, config: &ServerConfig) -> Result