feat: add gws mcp server (#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
This commit is contained in:
Justin Poehnelt
2026-03-04 11:58:28 -07:00
committed by GitHub
parent 8c1042afc1
commit 670267f6ec
12 changed files with 574 additions and 40 deletions
+9
View File
@@ -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.).
+50 -24
View File
@@ -15,14 +15,12 @@ Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JS
</p>
<br>
```bash
npm install -g @googleworkspace/cli
```
`gws` doesn't ship a static list of commands. It reads Google's own [Discovery Service](https://developers.google.com/discovery) at runtime and builds its entire command surface dynamically. When Google Workspace adds an API endpoint or method, `gws` picks it up automatically.
> [!IMPORTANT]
> This project is under active development. Expect breaking changes as we march toward v1.0.
@@ -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 tabcompletion, `--help` on every resource, `--dry-run` to preview requests, and autopagination.
@@ -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
</details>
## Gemini CLI Extension
1. Authenticate the CLI first:
```bash
gws auth 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 1080 tools. Keep the list to what you actually need
> to stay under your client's tool limit (typically 50100 tools).
| Flag | Description |
| ----------------------- | -------------------------------------------- |
| `-s, --services <list>` | Comma-separated services to expose, or `all` |
| `-w, --workflows` | Also expose workflow tools |
| `-e, --helpers` | Also expose helper tools |
## Advanced Usage
### Multipart Uploads
@@ -230,11 +259,11 @@ gws drive files create --json '{"name": "report.pdf"}' --upload ./report.pdf
### Pagination
| Flag | Description | Default |
|------|-------------|---------|
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
| `--page-limit <N>` | Max pages to fetch | 10 |
| `--page-delay <MS>` | Delay between pages | 100 ms |
| Flag | Description | Default |
| ------------------- | ---------------------------------------------- | ------- |
| `--page-all` | Auto-paginate, one JSON line per page (NDJSON) | off |
| `--page-limit <N>` | Max pages to fetch | 10 |
| `--page-delay <MS>` | Delay between pages | 100 ms |
### Model Armor (Response Sanitization)
@@ -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 auth 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
+52 -16
View File
@@ -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,
@@ -207,6 +208,8 @@ async fn handle_json_response(
output_format: &crate::formatter::OutputFormat,
pages_fetched: &mut u32,
page_token: &mut Option<String>,
capture_output: bool,
captured: &mut Vec<Value>,
) -> Result<bool, GwsError> {
if let Ok(mut json_val) = serde_json::from_str::<Value>(body_text) {
*pages_fetched += 1;
@@ -249,7 +252,9 @@ async fn handle_json_response(
}
}
if pagination.page_all {
if capture_output {
captured.push(json_val.clone());
} else if pagination.page_all {
let is_first_page = *pages_fetched == 1;
println!(
"{}",
@@ -279,7 +284,7 @@ async fn handle_json_response(
}
} else {
// Not valid JSON, output as-is
if !body_text.is_empty() {
if !capture_output && !body_text.is_empty() {
println!("{body_text}");
}
}
@@ -293,7 +298,8 @@ async fn handle_binary_response(
content_type: &str,
output_path: Option<&str>,
output_format: &crate::formatter::OutputFormat,
) -> Result<(), GwsError> {
capture_output: bool,
) -> Result<Option<Value>, GwsError> {
let file_path = if let Some(p) = output_path {
PathBuf::from(p)
} else {
@@ -324,9 +330,14 @@ async fn handle_binary_response(
"mimeType": content_type,
"bytes": total_bytes,
});
if capture_output {
return Ok(Some(result));
}
println!("{}", crate::formatter::format_value(&result, output_format));
Ok(())
Ok(None)
}
/// Executes an API method call.
@@ -354,7 +365,8 @@ pub async fn execute_method(
sanitize_template: Option<&str>,
sanitize_mode: &crate::helpers::modelarmor::SanitizeMode,
output_format: &crate::formatter::OutputFormat,
) -> Result<(), GwsError> {
capture_output: bool,
) -> Result<Option<Value>, GwsError> {
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?;
if dry_run {
@@ -366,15 +378,19 @@ pub async fn execute_method(
"body": input.body,
"is_multipart_upload": input.is_upload,
});
if capture_output {
return Ok(Some(dry_run_info));
}
println!(
"{}",
crate::formatter::format_value(&dry_run_info, output_format)
);
return Ok(());
return Ok(None);
}
let mut page_token: Option<String> = None;
let mut pages_fetched: u32 = 0;
let mut captured_values = Vec::new();
loop {
let client = crate::client::build_client()?;
@@ -422,20 +438,38 @@ pub async fn execute_method(
output_format,
&mut pages_fetched,
&mut page_token,
capture_output,
&mut captured_values,
)
.await?;
if should_continue {
continue;
}
} else {
handle_binary_response(response, &content_type, output_path, output_format).await?;
} else if let Some(res) = handle_binary_response(
response,
&content_type,
output_path,
output_format,
capture_output,
)
.await?
{
captured_values.push(res);
}
break;
}
Ok(())
if capture_output && !captured_values.is_empty() {
if captured_values.len() == 1 {
return Ok(Some(captured_values.pop().unwrap()));
} else {
return Ok(Some(Value::Array(captured_values)));
}
}
Ok(None)
}
fn build_url(
@@ -522,11 +556,11 @@ pub fn extract_enable_url(message: &str) -> Option<String> {
Some(url.to_string())
}
fn handle_error_response(
fn handle_error_response<T>(
status: reqwest::StatusCode,
error_body: &str,
auth_method: &AuthMethod,
) -> Result<(), GwsError> {
) -> Result<T, GwsError> {
// If 401/403 and no auth was provided, give a helpful message
if (status.as_u16() == 401 || status.as_u16() == 403) && *auth_method == AuthMethod::None {
return Err(GwsError::Auth(
@@ -1130,7 +1164,7 @@ mod tests {
#[test]
fn test_handle_error_response_401() {
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::UNAUTHORIZED,
"Unauthorized",
&AuthMethod::None,
@@ -1153,7 +1187,7 @@ mod tests {
})
.to_string();
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::BAD_REQUEST,
&json_err,
&AuthMethod::OAuth,
@@ -1245,6 +1279,7 @@ async fn test_execute_method_dry_run() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
false,
)
.await;
@@ -1287,6 +1322,7 @@ async fn test_execute_method_missing_path_param() {
None,
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
false,
)
.await;
@@ -1299,7 +1335,7 @@ async fn test_execute_method_missing_path_param() {
#[test]
fn test_handle_error_response_non_json() {
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error Text",
&AuthMethod::OAuth,
@@ -1366,7 +1402,7 @@ fn test_handle_error_response_access_not_configured_with_url() {
})
.to_string();
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::FORBIDDEN,
&json_err,
&AuthMethod::OAuth,
@@ -1403,7 +1439,7 @@ fn test_handle_error_response_access_not_configured_errors_array() {
})
.to_string();
let err = handle_error_response(
let err = handle_error_response::<()>(
reqwest::StatusCode::FORBIDDEN,
&json_err,
&AuthMethod::OAuth,
+1
View File
@@ -175,6 +175,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+1
View File
@@ -114,6 +114,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+1
View File
@@ -104,6 +104,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+1
View File
@@ -115,6 +115,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+1
View File
@@ -52,6 +52,7 @@ pub(super) async fn handle_send(
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+1
View File
@@ -127,6 +127,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+2
View File
@@ -141,6 +141,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
@@ -182,6 +183,7 @@ TIPS:
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
+8
View File
@@ -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 {
+447
View File
@@ -0,0 +1,447 @@
// 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::discovery::RestResource;
use crate::error::GwsError;
use crate::services;
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<String>,
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::<String>("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 <service> (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::<Value>(&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, &params, &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<Vec<Value>>,
) -> Result<Value, GwsError> {
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<Vec<Value>, 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(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 {
eprintln!("[gws mcp] Warning: Failed to load discovery document for service '{}'. It will not be available as a tool.", svc_name);
}
}
// 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<String, RestResource>, tools: &mut Vec<Value>) {
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<Value, GwsError> {
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()) {
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;
// 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
)));
}
}
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());
// Security: validate upload path to prevent arbitrary local file reads.
// Only allow paths within the current working directory.
let upload_path = if let Some(raw) = arguments.get("upload").and_then(|v| v.as_str()) {
let p = std::path::Path::new(raw);
// Reject absolute paths and any path that escapes cwd via "../"
if p.is_absolute() || p.components().any(|c| c == std::path::Component::ParentDir) {
return Err(GwsError::Validation(format!(
"Upload path '{}' is not allowed. Paths must be relative and within the current directory.",
raw
)));
}
Some(raw)
} else {
None
};
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
}))
}