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:
@@ -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.).
|
||||
@@ -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 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
|
||||
|
||||
</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 10–80 tools. Keep the list to what you actually need
|
||||
> to stay under your client's tool limit (typically 50–100 tools).
|
||||
|
||||
| Flag | Description |
|
||||
| ----------------------- | -------------------------------------------- |
|
||||
| `-s, --services <list>` | Comma-separated services to expose, or `all` |
|
||||
| `-w, --workflows` | Also expose workflow tools |
|
||||
| `-e, --helpers` | Also expose helper tools |
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Multipart Uploads
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -175,6 +175,7 @@ TIPS:
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ TIPS:
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ TIPS:
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ TIPS:
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ pub(super) async fn handle_send(
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ TIPS:
|
||||
None,
|
||||
&crate::helpers::modelarmor::SanitizeMode::Warn,
|
||||
&crate::formatter::OutputFormat::default(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶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<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
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user