Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
{"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"}
{"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"}
{"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"}
{"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"]}

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The close_reason claims “+ regression tests”, but this PR diff doesn’t include any test changes. Either add the referenced regression tests in this PR or adjust close_reason to avoid documenting tests that weren’t actually implemented here.

Suggested change
{"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"]}
{"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 for Body defaults","labels":["cli","gmail"]}

Copilot uses AI. Check for mistakes.
{"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"}
{"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"}
{"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"}
108 changes: 104 additions & 4 deletions core/cli_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,110 @@
"""

import asyncio
import inspect
import json
import logging
import sys
from typing import Any, Dict, List, Optional

from auth.oauth_config import set_transport_mode
from pydantic_core import PydanticUndefined as _PYDANTIC_UNDEFINED

logger = logging.getLogger(__name__)

_PYDANTIC_UNDEFINED_TYPE = type(_PYDANTIC_UNDEFINED)


def _is_fastapi_param_marker(default: Any) -> bool:
"""
Check if a default value is a FastAPI parameter marker (Body, Query, etc.).

These markers are metadata for HTTP request parsing and should not be passed
directly to tool functions in CLI mode.
"""
default_type = type(default)
return default_type.__module__ == "fastapi.params" and hasattr(
default, "get_default"
)
Comment on lines +38 to +41

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking FastAPI marker types via default_type.__module__ == \"fastapi.params\" is fairly fragile (module paths and wrappers can change). Prefer an isinstance check against FastAPI's param base type (if available) or a more direct duck-typing check (e.g., hasattr(default, \"in_\") + hasattr(default, \"get_default\")) without depending on an exact module string.

Suggested change
default_type = type(default)
return default_type.__module__ == "fastapi.params" and hasattr(
default, "get_default"
)
# Use duck-typing instead of fragile module-name checks; FastAPI param
# markers (Query, Body, etc.) expose both `in_` and `get_default`.
return hasattr(default, "in_") and hasattr(default, "get_default")

Copilot uses AI. Check for mistakes.


def _is_required_marker_default(value: Any) -> bool:
"""Check whether a FastAPI/Pydantic default represents a required field."""
if value is Ellipsis or value is inspect.Parameter.empty:
return True

return value is _PYDANTIC_UNDEFINED or isinstance(value, _PYDANTIC_UNDEFINED_TYPE)


def _extract_fastapi_default(default_marker: Any) -> tuple[bool, Any]:
"""
Resolve the runtime default from a FastAPI marker.

Returns:
Tuple of (is_required, resolved_default)
"""
try:
resolved_default = default_marker.get_default(call_default_factory=True)
except TypeError:
# Compatibility path for implementations without call_default_factory kwarg
resolved_default = default_marker.get_default()
except Exception:
resolved_default = getattr(default_marker, "default", inspect.Parameter.empty)

return _is_required_marker_default(resolved_default), resolved_default
Comment on lines +64 to +67

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If _extract_fastapi_default falls back to inspect.Parameter.empty, _is_required_marker_default will currently treat that as not-required, and _normalize_cli_args_for_tool may pass inspect.Parameter.empty into the tool function as a real argument value. Consider treating inspect.Parameter.empty as 'required/unresolved' (or raising) so CLI calls fail fast with a clear missing-arg error instead of passing a sentinel to user code.

Copilot uses AI. Check for mistakes.


def _normalize_cli_args_for_tool(fn, args: Dict[str, Any]) -> Dict[str, Any]:
"""
Fill omitted CLI args for FastAPI markers with their real defaults.

When tools are invoked via HTTP, FastAPI resolves Body/Query/... defaults.
In CLI mode we invoke functions directly, so we need to do that resolution.
"""
normalized_args = dict(args)
signature = inspect.signature(fn)
missing_required = []

for param in signature.parameters.values():
if param.kind in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
):
continue

if param.name in normalized_args:
continue

if param.default is inspect.Parameter.empty:
continue

if not _is_fastapi_param_marker(param.default):
continue

is_required, resolved_default = _extract_fastapi_default(param.default)
if is_required or resolved_default is inspect.Parameter.empty:
missing_required.append(param.name)
else:
normalized_args[param.name] = resolved_default
Comment on lines +44 to +101

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved_default can become inspect.Parameter.empty in _extract_fastapi_default() (line 59). Currently _is_required_marker_default() does not treat inspect.Parameter.empty as required, so _normalize_cli_args_for_tool() may inject inspect.Parameter.empty into normalized_args (line 95) and pass that sentinel into the tool, producing confusing downstream errors. Treat inspect.Parameter.empty as required (or avoid adding it to normalized_args) so the CLI raises a “missing required argument” error instead of passing the sentinel value through.

Copilot uses AI. Check for mistakes.

if missing_required:
if len(missing_required) == 1:
missing = missing_required[0]
raise TypeError(
f"{fn.__name__}() missing 1 required positional argument: '{missing}'"
)
Comment on lines +106 to +108

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raised TypeError message says “required positional argument(s)”, but these tool calls are invoked via keyword arguments (fn(**call_args)), so the wording is misleading (and differs from Python’s typical “required keyword-only argument” phrasing when applicable). Consider emitting a message that matches Python’s conventions (e.g., “required keyword-only argument(s)” for keyword-only params, otherwise “required argument(s)”) or at least “required argument(s)” to avoid incorrect classification.

Copilot uses AI. Check for mistakes.

missing_names = [f"'{name}'" for name in missing_required]
if len(missing_names) == 2:
missing = " and ".join(missing_names)
else:
missing = ", ".join(missing_names[:-1]) + f" and {missing_names[-1]}"
raise TypeError(
f"{fn.__name__}() missing {len(missing_required)} required positional arguments: {missing}"
)
Comment on lines +115 to +117

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raised TypeError message says “required positional argument(s)”, but these tool calls are invoked via keyword arguments (fn(**call_args)), so the wording is misleading (and differs from Python’s typical “required keyword-only argument” phrasing when applicable). Consider emitting a message that matches Python’s conventions (e.g., “required keyword-only argument(s)” for keyword-only params, otherwise “required argument(s)”) or at least “required argument(s)” to avoid incorrect classification.

Copilot uses AI. Check for mistakes.

return normalized_args


def get_registered_tools(server) -> Dict[str, Any]:
"""
Expand Down Expand Up @@ -233,14 +328,19 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
if fn is None:
raise ValueError(f"Tool '{tool_name}' has no callable function")

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

try:
call_args = _normalize_cli_args_for_tool(fn, args)
logger.debug(
f"[CLI] Executing tool: {tool_name} with args: {list(call_args.keys())}"
)

# Call the tool function
if asyncio.iscoroutinefunction(fn):
result = await fn(**args)
result = await fn(**call_args)
else:
result = fn(**args)
result = fn(**call_args)

# Convert result to string if needed
if isinstance(result, str):
Expand All @@ -257,7 +357,7 @@ async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str:
return (
f"Error calling {tool_name}: {error_msg}\n\n"
f"Required parameters: {required}\n"
f"Provided parameters: {list(args.keys())}"
f"Provided parameters: {list(call_args.keys())}"
)
except Exception as e:
logger.error(f"[CLI] Error executing {tool_name}: {e}", exc_info=True)
Expand Down
56 changes: 29 additions & 27 deletions gmail/gmail_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from email import encoders
from email.utils import formataddr

from fastapi import Body
from fastapi import Body as BodyParam
from pydantic import Field

from auth.service_decorator import require_google_service
Expand Down Expand Up @@ -989,33 +989,33 @@ async def get_gmail_attachment_content(
async def send_gmail_message(
service,
user_google_email: str,
to: str = Body(..., description="Recipient email address."),
subject: str = Body(..., description="Email subject."),
body: str = Body(..., description="Email body content (plain text or HTML)."),
body_format: Literal["plain", "html"] = Body(
to: str = BodyParam(..., description="Recipient email address."),
subject: str = BodyParam(..., description="Email subject."),
body: str = BodyParam(..., description="Email body content (plain text or HTML)."),
body_format: Literal["plain", "html"] = BodyParam(
"plain",
description="Email body format. Use 'plain' for plaintext or 'html' for HTML content.",
),
cc: Optional[str] = Body(None, description="Optional CC email address."),
bcc: Optional[str] = Body(None, description="Optional BCC email address."),
from_name: Optional[str] = Body(
cc: Optional[str] = BodyParam(None, description="Optional CC email address."),
bcc: Optional[str] = BodyParam(None, description="Optional BCC email address."),
from_name: Optional[str] = BodyParam(
None,
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
),
from_email: Optional[str] = Body(
from_email: Optional[str] = BodyParam(
None,
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.",
),
thread_id: Optional[str] = Body(
thread_id: Optional[str] = BodyParam(
None, description="Optional Gmail thread ID to reply within."
),
in_reply_to: Optional[str] = Body(
in_reply_to: Optional[str] = BodyParam(
None, description="Optional Message-ID of the message being replied to."
),
references: Optional[str] = Body(
references: Optional[str] = BodyParam(
None, description="Optional chain of Message-IDs for proper threading."
),
attachments: Optional[List[Dict[str, str]]] = Body(
attachments: Optional[List[Dict[str, str]]] = BodyParam(
None,
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"}]',
),
Expand Down Expand Up @@ -1161,33 +1161,35 @@ async def send_gmail_message(
async def draft_gmail_message(
service,
user_google_email: str,
subject: str = Body(..., description="Email subject."),
body: str = Body(..., description="Email body (plain text)."),
body_format: Literal["plain", "html"] = Body(
subject: str = BodyParam(..., description="Email subject."),
body: str = BodyParam(..., description="Email body (plain text)."),
body_format: Literal["plain", "html"] = BodyParam(
"plain",
description="Email body format. Use 'plain' for plaintext or 'html' for HTML content.",
),
to: Optional[str] = Body(None, description="Optional recipient email address."),
cc: Optional[str] = Body(None, description="Optional CC email address."),
bcc: Optional[str] = Body(None, description="Optional BCC email address."),
from_name: Optional[str] = Body(
to: Optional[str] = BodyParam(
None, description="Optional recipient email address."
),
cc: Optional[str] = BodyParam(None, description="Optional CC email address."),
bcc: Optional[str] = BodyParam(None, description="Optional BCC email address."),
from_name: Optional[str] = BodyParam(
None,
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
),
from_email: Optional[str] = Body(
from_email: Optional[str] = BodyParam(
None,
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.",
),
thread_id: Optional[str] = Body(
thread_id: Optional[str] = BodyParam(
None, description="Optional Gmail thread ID to reply within."
),
in_reply_to: Optional[str] = Body(
in_reply_to: Optional[str] = BodyParam(
None, description="Optional Message-ID of the message being replied to."
),
references: Optional[str] = Body(
references: Optional[str] = BodyParam(
None, description="Optional chain of Message-IDs for proper threading."
),
attachments: Optional[List[Dict[str, str]]] = Body(
attachments: Optional[List[Dict[str, str]]] = BodyParam(
None,
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).",
),
Expand Down Expand Up @@ -1737,10 +1739,10 @@ async def list_gmail_filters(service, user_google_email: str) -> str:
async def create_gmail_filter(
service,
user_google_email: str,
criteria: Dict[str, Any] = Body(
criteria: Dict[str, Any] = BodyParam(
..., description="Filter criteria object as defined in the Gmail API."
),
action: Dict[str, Any] = Body(
action: Dict[str, Any] = BodyParam(
..., description="Filter action object as defined in the Gmail API."
),
) -> str:
Expand Down