Skip to content
Open
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
98 changes: 98 additions & 0 deletions core/camel_case_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""
Middleware that lets callers use camelCase argument names on snake_case tools.
"""

import logging
import re
from typing import Any, Dict, Optional

from fastmcp.exceptions import NotFoundError
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext

logger = logging.getLogger(__name__)

_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")


def to_snake_case(name: str) -> str:
"""Convert a camelCase argument name to snake_case (``timeMin`` -> ``time_min``)."""
return _CAMEL_BOUNDARY.sub("_", name).lower()


class CamelCaseArgumentsMiddleware(Middleware):
"""Translate camelCase tool arguments to their snake_case equivalents.

Every tool in this server declares snake_case parameters with
``additionalProperties: false``, so callers that mirror the Google API
field names (``calendarId``, ``timeMin``, ``maxResults``, ...) fail schema
validation before the request ever reaches Google. This middleware renames
an argument key when all of the following hold:

* the key is not itself a declared parameter of the tool,
* its snake_case conversion is a declared parameter, and
* the caller did not also pass the snake_case key explicitly.

Everything else passes through untouched, so existing snake_case callers
(and genuinely invalid arguments) behave exactly as before.
"""

async def on_call_tool(self, context: MiddlewareContext, call_next: CallNext):
arguments = context.message.arguments
if arguments:
normalized = await self._normalize_arguments(context, arguments)
if normalized is not None:
context = context.copy(
message=context.message.model_copy(update={"arguments": normalized})
)
return await call_next(context)

async def _normalize_arguments(
self, context: MiddlewareContext, arguments: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Return arguments with camelCase keys renamed, or None if unchanged."""
if not context.fastmcp_context:
return None

try:
tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name)
except NotFoundError:
# Unknown tool — let the normal "tool not found" handling run.
return None

properties = (tool.parameters or {}).get("properties")
if not properties:
return None

renames = {}
for key in arguments:
if key in properties:
continue
snake_key = to_snake_case(key)
if (
snake_key != key
and snake_key in properties
and snake_key not in arguments
):
renames[key] = snake_key

# If two distinct keys normalize to the same parameter (e.g. "iCalUid"
# and "iCalUID"), the request is ambiguous — leave those keys untouched
# so schema validation rejects them instead of silently picking one.
target_counts: Dict[str, int] = {}
for snake_key in renames.values():
target_counts[snake_key] = target_counts.get(snake_key, 0) + 1
renames = {
key: snake_key
for key, snake_key in renames.items()
if target_counts[snake_key] == 1
}

if not renames:
return None

logger.debug(
"[CamelCaseArgumentsMiddleware] Renaming arguments for tool '%s': %s",
context.message.name,
renames,
)
return {renames.get(key, key): value for key, value in arguments.items()}
6 changes: 6 additions & 0 deletions core/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
install_startup_warning_filters()

from auth.auth_info_middleware import AuthInfoMiddleware
from core.camel_case_middleware import CamelCaseArgumentsMiddleware
from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets
from auth.mcp_session_middleware import MCPSessionMiddleware
from auth.oauth21_session_store import set_auth_provider
Expand Down Expand Up @@ -300,6 +301,11 @@ async def call_tool(self, name: str, arguments: Optional[dict], *args, **kwargs)
auth_info_middleware = AuthInfoMiddleware()
server.add_middleware(auth_info_middleware)

# Accept camelCase argument names (calendarId, timeMin, ...) from callers that
# mirror the Google API field names, mapping them onto the snake_case tool
# parameters. See https://github.com/taylorwilsdon/google_workspace_mcp/issues/918
server.add_middleware(CamelCaseArgumentsMiddleware())


def _parse_bool_env(value: str) -> bool:
"""Parse environment variable string to boolean."""
Expand Down
144 changes: 144 additions & 0 deletions tests/core/test_camel_case_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Tests for CamelCaseArgumentsMiddleware (issue #918)."""

import json
from typing import Optional

import pytest
from fastmcp import Client, FastMCP

from core.camel_case_middleware import CamelCaseArgumentsMiddleware, to_snake_case


def test_to_snake_case_conversions():
assert to_snake_case("calendarId") == "calendar_id"
assert to_snake_case("timeMin") == "time_min"
assert to_snake_case("timeMax") == "time_max"
assert to_snake_case("maxResults") == "max_results"
assert to_snake_case("singleEvents") == "single_events"
assert to_snake_case("orderBy") == "order_by"
# Already snake_case or single-word names are untouched
assert to_snake_case("calendar_id") == "calendar_id"
assert to_snake_case("query") == "query"


def _build_server() -> FastMCP:
server = FastMCP("camel-case-test")
server.add_middleware(CamelCaseArgumentsMiddleware())

@server.tool
def get_events(
calendar_id: str = "primary",
time_min: Optional[str] = None,
time_max: Optional[str] = None,
max_results: int = 25,
) -> str:
return json.dumps(
{
"calendar_id": calendar_id,
"time_min": time_min,
"time_max": time_max,
"max_results": max_results,
}
)

return server


@pytest.mark.asyncio
async def test_camel_case_arguments_are_accepted():
"""The exact failure mode from #918: camelCase keys on a snake_case tool."""
server = _build_server()
async with Client(server) as client:
result = await client.call_tool(
"get_events",
{
"calendarId": "primary",
"timeMin": "2026-07-12T00:00:00Z",
"timeMax": "2026-07-13T00:00:00Z",
"maxResults": 10,
},
)
payload = json.loads(result.content[0].text)
assert payload == {
"calendar_id": "primary",
"time_min": "2026-07-12T00:00:00Z",
"time_max": "2026-07-13T00:00:00Z",
"max_results": 10,
}


@pytest.mark.asyncio
async def test_snake_case_arguments_pass_through_unchanged():
server = _build_server()
async with Client(server) as client:
result = await client.call_tool(
"get_events",
{"calendar_id": "team", "time_min": "2026-07-12T00:00:00Z"},
)
payload = json.loads(result.content[0].text)
assert payload["calendar_id"] == "team"
assert payload["time_min"] == "2026-07-12T00:00:00Z"


@pytest.mark.asyncio
async def test_explicit_snake_case_key_is_never_overridden():
"""If a caller sends both spellings, the snake_case value wins and the
camelCase duplicate is still rejected by schema validation."""
server = _build_server()
async with Client(server) as client:
with pytest.raises(Exception):
await client.call_tool(
"get_events",
{"calendar_id": "team", "calendarId": "primary"},
)


@pytest.mark.asyncio
async def test_unknown_arguments_are_still_rejected():
"""Arguments with no snake_case equivalent keep failing validation."""
server = _build_server()
async with Client(server) as client:
with pytest.raises(Exception):
await client.call_tool("get_events", {"notARealParam": "x"})


@pytest.mark.asyncio
async def test_declared_camel_case_parameter_is_untouched():
"""A tool that genuinely declares a camelCase parameter keeps working."""
server = FastMCP("camel-case-edge")
server.add_middleware(CamelCaseArgumentsMiddleware())

@server.tool
def echo(calendarId: str) -> str: # noqa: N803 - intentional camelCase
return calendarId

async with Client(server) as client:
result = await client.call_tool("echo", {"calendarId": "kept"})
assert result.content[0].text == "kept"


@pytest.mark.asyncio
async def test_unknown_tool_error_still_propagates():
server = _build_server()
async with Client(server) as client:
with pytest.raises(Exception):
await client.call_tool("does_not_exist", {"calendarId": "primary"})


@pytest.mark.asyncio
async def test_colliding_camel_case_keys_are_rejected():
"""Two spellings that normalize to the same parameter stay invalid rather
than one silently overwriting the other."""
server = FastMCP("camel-case-collision")
server.add_middleware(CamelCaseArgumentsMiddleware())

@server.tool
def find_event(i_cal_uid: str = "") -> str:
return i_cal_uid

async with Client(server) as client:
with pytest.raises(Exception):
await client.call_tool("find_event", {"iCalUid": "a", "iCalUID": "b"})
# A single unambiguous spelling still works
result = await client.call_tool("find_event", {"iCalUid": "ok"})
assert result.content[0].text == "ok"
Loading