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