Skip to content

Commit 2bef6d0

Browse files
committed
fix cli mode
1 parent 58a730f commit 2bef6d0

3 files changed

Lines changed: 122 additions & 31 deletions

File tree

.beads/issues.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
{"id":"google_workspace_mcp-ic8","title":"enh: support writing hyperlink URLs in modify_sheet_values","description":"Issue #434 also requested hyperlink creation/writes. Current implementation reads hyperlinks in read_sheet_values but modify_sheet_values does not expose first-class hyperlink writes.","status":"open","priority":3,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T17:42:10.590658-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T17:42:10.590658-05:00"}
1313
{"id":"google_workspace_mcp-jf2","title":"ci: make PyPI publish step rerun-safe with skip-existing","description":"GitHub Actions reruns on same tag fail because PyPI rejects duplicate file uploads. Add skip-existing=true to pypa/gh-action-pypi-publish so reruns proceed to MCP publish.","status":"closed","priority":2,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T20:59:58.461102-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T21:00:32.121469-05:00","closed_at":"2026-02-08T21:00:32.121469-05:00","close_reason":"Closed"}
1414
{"id":"google_workspace_mcp-qfl","title":"Fix stdio multi-account session binding","status":"in_progress","priority":1,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-07T13:27:09.466282-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-07T13:27:22.857227-05:00"}
15+
{"id":"google_workspace_mcp-xia","title":"fix: CLI should unwrap FastAPI Body defaults when invoking tools","description":"CLI mode invokes tool functions directly and currently passes FastAPI Body marker objects as defaults for omitted args. This breaks gmail send/draft with errors like Body has no attribute lower/len. Update CLI invocation to normalize Param defaults and return clear missing-required errors.","status":"closed","priority":1,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-10T12:33:06.83139-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-10T12:36:35.051947-05:00","closed_at":"2026-02-10T12:36:35.051947-05:00","close_reason":"Implemented CLI FastAPI default normalization + regression tests","labels":["cli","gmail"]}
1516
{"id":"google_workspace_mcp-xt2","title":"Implement CLI mode for Workspace MCP","description":"Create a CLI mode for the Workspace MCP server, activated via --cli flag, that allows all tools to be used directly from the command line without running as an MCP server.\n\n**Goals:**\n- Add --cli startup flag to enable CLI mode\n- Expose all existing tools as CLI subcommands\n- No changes to individual tool implementations - reuse existing code\n- Lightweight and flexible design\n- Enable usage by coding agents (Codex, Claude Code) without MCP server overhead\n\n**Requirements:**\n- Parse tool name and arguments from command line\n- Route to existing tool handlers\n- Output results to stdout (JSON or human-readable)\n- Maintain same authentication/credential flow\n- Support all current tools without modification","status":"in_progress","priority":0,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-01T10:57:29.920078-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-01T11:02:26.818109-05:00"}
1617
{"id":"google_workspace_mcp-y3j","title":"docs: add MCP registry publishing guidance for this server","status":"closed","priority":2,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T19:56:42.74673-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T20:00:56.253918-05:00","closed_at":"2026-02-08T20:00:56.253918-05:00","close_reason":"Closed"}
1718
{"id":"google_workspace_mcp-z0a","title":"Remove deprecated create_list alias from update_paragraph_style","status":"closed","priority":2,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T18:12:22.714628-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T18:13:12.931513-05:00","closed_at":"2026-02-08T18:13:12.931513-05:00","close_reason":"Closed"}

core/cli_handler.py

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515

1616
import asyncio
17+
import inspect
1718
import json
1819
import logging
1920
import sys
@@ -24,6 +25,88 @@
2425
logger = logging.getLogger(__name__)
2526

2627

28+
def _is_fastapi_param_marker(default: Any) -> bool:
29+
"""
30+
Check if a default value is a FastAPI parameter marker (Body, Query, etc.).
31+
32+
These markers are metadata for HTTP request parsing and should not be passed
33+
directly to tool functions in CLI mode.
34+
"""
35+
default_type = type(default)
36+
return default_type.__module__ == "fastapi.params" and hasattr(
37+
default, "get_default"
38+
)
39+
40+
41+
def _is_required_marker_default(value: Any) -> bool:
42+
"""Check whether a FastAPI/Pydantic default represents a required field."""
43+
return value is Ellipsis or type(value).__name__ == "PydanticUndefinedType"
44+
45+
46+
def _extract_fastapi_default(default_marker: Any) -> tuple[bool, Any]:
47+
"""
48+
Resolve the runtime default from a FastAPI marker.
49+
50+
Returns:
51+
Tuple of (is_required, resolved_default)
52+
"""
53+
try:
54+
resolved_default = default_marker.get_default(call_default_factory=True)
55+
except TypeError:
56+
# Compatibility path for implementations without call_default_factory kwarg
57+
resolved_default = default_marker.get_default()
58+
except Exception:
59+
resolved_default = getattr(default_marker, "default", inspect.Parameter.empty)
60+
61+
return _is_required_marker_default(resolved_default), resolved_default
62+
63+
64+
def _normalize_cli_args_for_tool(fn, args: Dict[str, Any]) -> Dict[str, Any]:
65+
"""
66+
Fill omitted CLI args for FastAPI markers with their real defaults.
67+
68+
When tools are invoked via HTTP, FastAPI resolves Body/Query/... defaults.
69+
In CLI mode we invoke functions directly, so we need to do that resolution.
70+
"""
71+
normalized_args = dict(args)
72+
signature = inspect.signature(fn)
73+
missing_required = []
74+
75+
for param in signature.parameters.values():
76+
if param.kind in (
77+
inspect.Parameter.VAR_POSITIONAL,
78+
inspect.Parameter.VAR_KEYWORD,
79+
):
80+
continue
81+
82+
if param.name in normalized_args:
83+
continue
84+
85+
if param.default is inspect.Parameter.empty:
86+
continue
87+
88+
if not _is_fastapi_param_marker(param.default):
89+
continue
90+
91+
is_required, resolved_default = _extract_fastapi_default(param.default)
92+
if is_required:
93+
missing_required.append(param.name)
94+
else:
95+
normalized_args[param.name] = resolved_default
96+
97+
if missing_required:
98+
if len(missing_required) == 1:
99+
missing = missing_required[0]
100+
raise TypeError(f"{fn.__name__}() missing 1 required argument: '{missing}'")
101+
102+
missing = ", ".join(f"'{name}'" for name in missing_required)
103+
raise TypeError(
104+
f"{fn.__name__}() missing {len(missing_required)} required arguments: {missing}"
105+
)
106+
107+
return normalized_args
108+
109+
27110
def get_registered_tools(server) -> Dict[str, Any]:
28111
"""
29112
Get all registered tools from the FastMCP server.
@@ -233,14 +316,19 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
233316
if fn is None:
234317
raise ValueError(f"Tool '{tool_name}' has no callable function")
235318

236-
logger.debug(f"[CLI] Executing tool: {tool_name} with args: {list(args.keys())}")
319+
call_args = dict(args)
237320

238321
try:
322+
call_args = _normalize_cli_args_for_tool(fn, args)
323+
logger.debug(
324+
f"[CLI] Executing tool: {tool_name} with args: {list(call_args.keys())}"
325+
)
326+
239327
# Call the tool function
240328
if asyncio.iscoroutinefunction(fn):
241-
result = await fn(**args)
329+
result = await fn(**call_args)
242330
else:
243-
result = fn(**args)
331+
result = fn(**call_args)
244332

245333
# Convert result to string if needed
246334
if isinstance(result, str):
@@ -257,7 +345,7 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
257345
return (
258346
f"Error calling {tool_name}: {error_msg}\n\n"
259347
f"Required parameters: {required}\n"
260-
f"Provided parameters: {list(args.keys())}"
348+
f"Provided parameters: {list(call_args.keys())}"
261349
)
262350
except Exception as e:
263351
logger.error(f"[CLI] Error executing {tool_name}: {e}", exc_info=True)

gmail/gmail_tools.py

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from email import encoders
2020
from email.utils import formataddr
2121

22-
from fastapi import Body
22+
from fastapi import Body as BodyParam
2323
from pydantic import Field
2424

2525
from auth.service_decorator import require_google_service
@@ -989,33 +989,33 @@ async def get_gmail_attachment_content(
989989
async def send_gmail_message(
990990
service,
991991
user_google_email: str,
992-
to: str = Body(..., description="Recipient email address."),
993-
subject: str = Body(..., description="Email subject."),
994-
body: str = Body(..., description="Email body content (plain text or HTML)."),
995-
body_format: Literal["plain", "html"] = Body(
992+
to: str = BodyParam(..., description="Recipient email address."),
993+
subject: str = BodyParam(..., description="Email subject."),
994+
body: str = BodyParam(..., description="Email body content (plain text or HTML)."),
995+
body_format: Literal["plain", "html"] = BodyParam(
996996
"plain",
997997
description="Email body format. Use 'plain' for plaintext or 'html' for HTML content.",
998998
),
999-
cc: Optional[str] = Body(None, description="Optional CC email address."),
1000-
bcc: Optional[str] = Body(None, description="Optional BCC email address."),
1001-
from_name: Optional[str] = Body(
999+
cc: Optional[str] = BodyParam(None, description="Optional CC email address."),
1000+
bcc: Optional[str] = BodyParam(None, description="Optional BCC email address."),
1001+
from_name: Optional[str] = BodyParam(
10021002
None,
10031003
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
10041004
),
1005-
from_email: Optional[str] = Body(
1005+
from_email: Optional[str] = BodyParam(
10061006
None,
10071007
description="Optional 'Send As' alias email address. Must be configured in Gmail settings (Settings > Accounts > Send mail as). If not provided, uses the authenticated user's email.",
10081008
),
1009-
thread_id: Optional[str] = Body(
1009+
thread_id: Optional[str] = BodyParam(
10101010
None, description="Optional Gmail thread ID to reply within."
10111011
),
1012-
in_reply_to: Optional[str] = Body(
1012+
in_reply_to: Optional[str] = BodyParam(
10131013
None, description="Optional Message-ID of the message being replied to."
10141014
),
1015-
references: Optional[str] = Body(
1015+
references: Optional[str] = BodyParam(
10161016
None, description="Optional chain of Message-IDs for proper threading."
10171017
),
1018-
attachments: Optional[List[Dict[str, str]]] = Body(
1018+
attachments: Optional[List[Dict[str, str]]] = BodyParam(
10191019
None,
10201020
description='Optional list of attachments. Each can have: "path" (file path, auto-encodes), OR "content" (standard base64, not urlsafe) + "filename". Optional "mime_type". Example: [{"path": "/path/to/file.pdf"}] or [{"filename": "doc.pdf", "content": "base64data", "mime_type": "application/pdf"}]',
10211021
),
@@ -1161,33 +1161,35 @@ async def send_gmail_message(
11611161
async def draft_gmail_message(
11621162
service,
11631163
user_google_email: str,
1164-
subject: str = Body(..., description="Email subject."),
1165-
body: str = Body(..., description="Email body (plain text)."),
1166-
body_format: Literal["plain", "html"] = Body(
1164+
subject: str = BodyParam(..., description="Email subject."),
1165+
body: str = BodyParam(..., description="Email body (plain text)."),
1166+
body_format: Literal["plain", "html"] = BodyParam(
11671167
"plain",
11681168
description="Email body format. Use 'plain' for plaintext or 'html' for HTML content.",
11691169
),
1170-
to: Optional[str] = Body(None, description="Optional recipient email address."),
1171-
cc: Optional[str] = Body(None, description="Optional CC email address."),
1172-
bcc: Optional[str] = Body(None, description="Optional BCC email address."),
1173-
from_name: Optional[str] = Body(
1170+
to: Optional[str] = BodyParam(
1171+
None, description="Optional recipient email address."
1172+
),
1173+
cc: Optional[str] = BodyParam(None, description="Optional CC email address."),
1174+
bcc: Optional[str] = BodyParam(None, description="Optional BCC email address."),
1175+
from_name: Optional[str] = BodyParam(
11741176
None,
11751177
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
11761178
),
1177-
from_email: Optional[str] = Body(
1179+
from_email: Optional[str] = BodyParam(
11781180
None,
11791181
description="Optional 'Send As' alias email address. Must be configured in Gmail settings (Settings > Accounts > Send mail as). If not provided, uses the authenticated user's email.",
11801182
),
1181-
thread_id: Optional[str] = Body(
1183+
thread_id: Optional[str] = BodyParam(
11821184
None, description="Optional Gmail thread ID to reply within."
11831185
),
1184-
in_reply_to: Optional[str] = Body(
1186+
in_reply_to: Optional[str] = BodyParam(
11851187
None, description="Optional Message-ID of the message being replied to."
11861188
),
1187-
references: Optional[str] = Body(
1189+
references: Optional[str] = BodyParam(
11881190
None, description="Optional chain of Message-IDs for proper threading."
11891191
),
1190-
attachments: Optional[List[Dict[str, str]]] = Body(
1192+
attachments: Optional[List[Dict[str, str]]] = BodyParam(
11911193
None,
11921194
description="Optional list of attachments. Each can have: 'path' (file path, auto-encodes), OR 'content' (standard base64, not urlsafe) + 'filename'. Optional 'mime_type' (auto-detected from path if not provided).",
11931195
),
@@ -1737,10 +1739,10 @@ async def list_gmail_filters(service, user_google_email: str) -> str:
17371739
async def create_gmail_filter(
17381740
service,
17391741
user_google_email: str,
1740-
criteria: Dict[str, Any] = Body(
1742+
criteria: Dict[str, Any] = BodyParam(
17411743
..., description="Filter criteria object as defined in the Gmail API."
17421744
),
1743-
action: Dict[str, Any] = Body(
1745+
action: Dict[str, Any] = BodyParam(
17441746
..., description="Filter action object as defined in the Gmail API."
17451747
),
17461748
) -> str:

0 commit comments

Comments
 (0)