fix(mcp): conditionally include body/upload in tool schemas, drop empty body on execution (#213)

* fix(mcp): conditionally include body/upload in full-mode tool schemas and drop empty body on execution

Full-mode tool schemas now only include `body` when the Discovery Document
method defines a request body, and `upload` when `supportsMediaUpload` is
true. This prevents LLMs from hallucinating these fields on GET-only methods.

Additionally, empty body objects (`{}`) are filtered out before execution
in both compact and full modes, and empty upload strings are ignored. LLMs
commonly send "body": {} on read-only methods, which causes Google APIs to
return HTTP 400.

* style: cargo fmt and add changeset for MCP tool schema fix

* fix(mcp): conditionally include page_all only for paginated methods

Only include the page_all property in full-mode tool schemas when the
method has a pageToken parameter, preventing LLMs from attempting
pagination on non-paginable methods.

* docs: update changeset to include page_all conditional change
This commit is contained in:
Shreyas Karnik
2026-03-05 15:37:17 -08:00
committed by GitHub
parent 8a897e955b
commit 6daf90d331
2 changed files with 49 additions and 17 deletions
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
Fix MCP tool schemas to conditionally include `body`, `upload`, and `page_all` properties only when the underlying Discovery Document method supports them. `body` is included only when a request body is defined, `upload` only when `supportsMediaUpload` is true, and `page_all` only when the method has a `pageToken` parameter. Also drops empty `body: {}` objects that LLMs commonly send on GET methods, preventing 400 errors from Google APIs.
+44 -17
View File
@@ -426,27 +426,46 @@ fn walk_resources(prefix: &str, resources: &HashMap<String, RestResource>, tools
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": {
// Generate JSON Schema for MCP input — only include body/upload
// when the Discovery Document method actually supports them.
let mut properties = serde_json::Map::new();
properties.insert(
"params".to_string(),
json!({
"type": "object",
"description": "Query or path parameters (e.g. fileId, q, pageSize)"
}),
);
if method.request.is_some() {
properties.insert(
"body".to_string(),
json!({
"type": "object",
"description": "Request body API object"
},
"upload": {
}),
);
}
if method.supports_media_upload {
properties.insert(
"upload".to_string(),
json!({
"type": "string",
"description": "Local file path to upload as media content"
},
"page_all": {
}),
);
}
if method.parameters.contains_key("pageToken") {
properties.insert(
"page_all".to_string(),
json!({
"type": "boolean",
"description": "Auto-paginate, returning all pages"
}
}
}),
);
}
let input_schema = json!({
"type": "object",
"properties": properties
});
tools.push(json!({
@@ -756,13 +775,21 @@ async fn execute_mcp_method(
.transpose()
.map_err(|e| GwsError::Validation(format!("Failed to serialize params: {e}")))?;
let body_json_val = arguments.get("body");
// Drop empty body objects — LLMs commonly send "body": {} even on GET
// methods, which causes Google APIs to return 400.
let body_json_val = arguments
.get("body")
.filter(|v| !v.as_object().is_some_and(|m| m.is_empty()));
let body_str = body_json_val
.map(serde_json::to_string)
.transpose()
.map_err(|e| GwsError::Validation(format!("Failed to serialize body: {e}")))?;
let upload_path = if let Some(raw) = arguments.get("upload").and_then(|v| v.as_str()) {
let upload_path = if let Some(raw) = arguments
.get("upload")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
let p = std::path::Path::new(raw);
if p.is_absolute() || p.components().any(|c| c == std::path::Component::ParentDir) {
return Err(GwsError::Validation(format!(