From 057fb617b152715fdc00fbeeef3bbb30ef1254a8 Mon Sep 17 00:00:00 2001 From: Xavier Enahoro Date: Wed, 29 Jul 2026 22:10:37 -0500 Subject: [PATCH 1/3] fix(cli): stop json.loads from silently stripping quotes on string args workspace-cli _call_tool ran json.loads on every key=value value and only fell back to the raw string on JSONDecodeError. A quoted value like query='"exact phrase"' is itself valid JSON, so json.loads stripped the quotes and the server received an unquoted string, breaking Gmail phrase queries with no error. Add _coerce_cli_value(), which only JSON-decodes values that look like JSON (containers, true/false/null, numbers) and returns everything else raw. page_size=3 and values=[["a"]] still coerce; quoted phrases survive verbatim. Adds unit tests. Co-Authored-By: Claude Opus 4.8 --- core/cli.py | 41 ++++++++++++++++++++--- tests/core/test_cli_value_coercion.py | 48 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 tests/core/test_cli_value_coercion.py diff --git a/core/cli.py b/core/cli.py index a57f0b78f..1065f80cc 100644 --- a/core/cli.py +++ b/core/cli.py @@ -16,6 +16,7 @@ import json import logging import os +import re import stat import sys from typing import Any @@ -68,6 +69,41 @@ def _build_oauth() -> OAuth: return OAuth(token_storage=storage) +# Guarded CLI value coercion. +# +# The previous parser ran json.loads on every value and only fell back to the +# raw string on a JSONDecodeError. That silently mangled ordinary quoted +# strings: a value like '"grow therapy"' is valid JSON, so json.loads stripped +# the surrounding quotes and the server received grow therapy (unquoted), +# breaking Gmail queries that rely on the literal quotes for phrase matching. +# +# This helper only attempts json.loads when the value actually looks like JSON +# (an empty string maps to ''; a container starting with { or [; the JSON +# scalars true/false/null; or a numeric literal). Anything else is returned +# unchanged as a raw string, so quotes and other characters survive verbatim. +# page_size=3 still coerces to int(3) and values=[["a"]] still parses to a list. +_JSON_NUMBER_RE = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$") + + +def _coerce_cli_value(v: str) -> Any: + """Coerce a CLI value to a JSON type only when it clearly looks like JSON.""" + if v == "": + return "" + + stripped = v.strip() + looks_like_json = ( + stripped[:1] in "{[" + or stripped in {"true", "false", "null"} + or bool(_JSON_NUMBER_RE.match(stripped)) + ) + if looks_like_json: + try: + return json.loads(v) + except json.JSONDecodeError: + return v + return v + + async def _list_tools(url: str) -> None: """Connect, authenticate once, and print available tools.""" try: @@ -97,10 +133,7 @@ async def _call_tool(url: str, tool_name: str, raw_args: list[str]) -> None: print(f"Error: argument '{arg}' must be in key=value form", file=sys.stderr) sys.exit(1) k, v = arg.split("=", 1) - try: - kwargs[k] = json.loads(v) - except json.JSONDecodeError: - kwargs[k] = v + kwargs[k] = _coerce_cli_value(v) async with Client(url, auth=_build_oauth()) as client: result = await client.call_tool(tool_name, kwargs) diff --git a/tests/core/test_cli_value_coercion.py b/tests/core/test_cli_value_coercion.py new file mode 100644 index 000000000..b7be71285 --- /dev/null +++ b/tests/core/test_cli_value_coercion.py @@ -0,0 +1,48 @@ +"""Tests for workspace-cli key=value argument coercion (core.cli._coerce_cli_value). + +Regression coverage for a parser that ran json.loads on every value: a quoted +string such as '"grow therapy"' is valid JSON, so the quotes were silently +stripped and Gmail phrase queries broke. Values should only be JSON-decoded when +they actually look like JSON; everything else must survive verbatim. +""" + +import pytest + +from core.cli import _coerce_cli_value + + +@pytest.mark.parametrize( + "raw, expected", + [ + # Plain strings survive unchanged... + ("is:unread", "is:unread"), + ("gmail drive calendar", "gmail drive calendar"), + # ...including strings that merely contain quotes (Gmail phrase queries). + ('"grow therapy"', '"grow therapy"'), + ('"grow therapy" newer_than:15mo', '"grow therapy" newer_than:15mo'), + # Date-like strings are not numbers and must not be coerced. + ("2026/04/16", "2026/04/16"), + # Empty string stays an empty string. + ("", ""), + ], +) +def test_strings_are_preserved_verbatim(raw, expected): + assert _coerce_cli_value(raw) == expected + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("3", 3), + ("-2", -2), + ("3.5", 3.5), + ("true", True), + ("false", False), + ("null", None), + ('["a", "b"]', ["a", "b"]), + ('[["a", "b"]]', [["a", "b"]]), + ('{"k": 1}', {"k": 1}), + ], +) +def test_json_like_values_are_coerced(raw, expected): + assert _coerce_cli_value(raw) == expected From a4c9a7a0b3ea6b7d444a63ee5221915b5f53351e Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 2 Aug 2026 16:36:32 -0400 Subject: [PATCH 2/3] refactor(cli): simplify value coercion to a single JSON round-trip Replace the looks-like-JSON pre-check (number regex, literal set, empty-string special case) with the rule it was approximating: decode as JSON, and keep the raw text whenever the result is a string. Same behavior, no regex, and the quote-stripping fix is expressed directly. --- core/cli.py | 46 +++++++-------------------- tests/core/test_cli_value_coercion.py | 13 ++------ 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/core/cli.py b/core/cli.py index 1065f80cc..2a350076e 100644 --- a/core/cli.py +++ b/core/cli.py @@ -16,7 +16,6 @@ import json import logging import os -import re import stat import sys from typing import Any @@ -69,39 +68,18 @@ def _build_oauth() -> OAuth: return OAuth(token_storage=storage) -# Guarded CLI value coercion. -# -# The previous parser ran json.loads on every value and only fell back to the -# raw string on a JSONDecodeError. That silently mangled ordinary quoted -# strings: a value like '"grow therapy"' is valid JSON, so json.loads stripped -# the surrounding quotes and the server received grow therapy (unquoted), -# breaking Gmail queries that rely on the literal quotes for phrase matching. -# -# This helper only attempts json.loads when the value actually looks like JSON -# (an empty string maps to ''; a container starting with { or [; the JSON -# scalars true/false/null; or a numeric literal). Anything else is returned -# unchanged as a raw string, so quotes and other characters survive verbatim. -# page_size=3 still coerces to int(3) and values=[["a"]] still parses to a list. -_JSON_NUMBER_RE = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$") - - -def _coerce_cli_value(v: str) -> Any: - """Coerce a CLI value to a JSON type only when it clearly looks like JSON.""" - if v == "": - return "" - - stripped = v.strip() - looks_like_json = ( - stripped[:1] in "{[" - or stripped in {"true", "false", "null"} - or bool(_JSON_NUMBER_RE.match(stripped)) - ) - if looks_like_json: - try: - return json.loads(v) - except json.JSONDecodeError: - return v - return v +def _coerce_cli_value(value: str) -> Any: + """Parse a CLI value as JSON, but keep the raw text whenever it is a string. + + ``max_results=5`` still becomes ``int(5)``, while a quoted value such as + ``query='"grow therapy"'`` keeps its quotes instead of being unwrapped into + ``grow therapy``, which would break Gmail phrase matching. + """ + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return value + return value if isinstance(parsed, str) else parsed async def _list_tools(url: str) -> None: diff --git a/tests/core/test_cli_value_coercion.py b/tests/core/test_cli_value_coercion.py index b7be71285..8ce86f62c 100644 --- a/tests/core/test_cli_value_coercion.py +++ b/tests/core/test_cli_value_coercion.py @@ -1,10 +1,4 @@ -"""Tests for workspace-cli key=value argument coercion (core.cli._coerce_cli_value). - -Regression coverage for a parser that ran json.loads on every value: a quoted -string such as '"grow therapy"' is valid JSON, so the quotes were silently -stripped and Gmail phrase queries broke. Values should only be JSON-decoded when -they actually look like JSON; everything else must survive verbatim. -""" +"""Tests for workspace-cli key=value argument coercion.""" import pytest @@ -14,15 +8,12 @@ @pytest.mark.parametrize( "raw, expected", [ - # Plain strings survive unchanged... ("is:unread", "is:unread"), ("gmail drive calendar", "gmail drive calendar"), - # ...including strings that merely contain quotes (Gmail phrase queries). + # Quotes must survive for Gmail phrase queries. ('"grow therapy"', '"grow therapy"'), ('"grow therapy" newer_than:15mo', '"grow therapy" newer_than:15mo'), - # Date-like strings are not numbers and must not be coerced. ("2026/04/16", "2026/04/16"), - # Empty string stays an empty string. ("", ""), ], ) From 9f9bb842f500088b6213cfd46407d2295e8c5ca3 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 2 Aug 2026 16:43:16 -0400 Subject: [PATCH 3/3] test dep cleanup --- auth/oauth_callback_server.py | 4 +++ main.py | 9 +++++- pyproject.toml | 4 +++ uv.lock | 52 +++++++++++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/auth/oauth_callback_server.py b/auth/oauth_callback_server.py index 5c30535ad..c7c488497 100644 --- a/auth/oauth_callback_server.py +++ b/auth/oauth_callback_server.py @@ -299,6 +299,10 @@ def run_server(): port=self.port, log_level="warning", access_log=False, + # Match FastMCP's own uvicorn config: uvicorn's "auto" + # default resolves to the deprecated legacy-websockets + # implementation whenever `websockets` is installed. + ws="websockets-sansio", ) self.server = uvicorn.Server(config) asyncio.run(self.server.serve()) diff --git a/main.py b/main.py index aef16b221..ac1fff82f 100644 --- a/main.py +++ b/main.py @@ -926,7 +926,14 @@ async def _run_dual() -> None: if http_available: app = server.http_app(path="/mcp") config = uvicorn.Config( - app, host=http_host, port=http_port, log_level="warning" + app, + host=http_host, + port=http_port, + log_level="warning", + # Match FastMCP's own uvicorn config: uvicorn's "auto" + # default resolves to the deprecated legacy-websockets + # implementation whenever `websockets` is installed. + ws="websockets-sansio", ) http_srv = uvicorn.Server(config) http_task = asyncio.create_task(http_srv.serve()) diff --git a/pyproject.toml b/pyproject.toml index 6b5160817..63a9caaf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ test = [ "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0", "requests>=2.32.3", + "httpx2>=2.0.0", ] release = [ "tomlkit>=0.13.3", @@ -96,6 +97,7 @@ dev = [ "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0", "requests>=2.32.3", + "httpx2>=2.0.0", "ruff>=0.15.22,<0.16", "tomlkit>=0.13.3", "twine>=5.0.0", @@ -115,6 +117,7 @@ test = [ "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0", "requests>=2.32.3", + "httpx2>=2.0.0", ] release = [ "tomlkit>=0.13.3", @@ -127,6 +130,7 @@ dev = [ "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0", "requests>=2.32.3", + "httpx2>=2.0.0", "ruff>=0.15.22,<0.16", "tomlkit>=0.13.3", "twine>=5.0.0", diff --git a/uv.lock b/uv.lock index 55faea20c..c45dd45d4 100644 --- a/uv.lock +++ b/uv.lock @@ -829,6 +829,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809 }, +] + [[package]] name = "httplib2" version = "0.32.0" @@ -865,6 +878,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, ] +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191 }, +] + [[package]] name = "id" version = "1.6.1" @@ -879,11 +908,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, ] [[package]] @@ -2088,6 +2117,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310 }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660 }, +] + [[package]] name = "twine" version = "6.2.0" @@ -2410,6 +2448,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "google-cloud-storage" }, + { name = "httpx2" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, { name = "pytest" }, @@ -2435,6 +2474,7 @@ release = [ ] test = [ { name = "google-cloud-storage" }, + { name = "httpx2" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, { name = "pytest" }, @@ -2448,6 +2488,7 @@ valkey = [ [package.dev-dependencies] dev = [ { name = "google-cloud-storage" }, + { name = "httpx2" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, { name = "pytest" }, @@ -2466,6 +2507,7 @@ release = [ ] test = [ { name = "google-cloud-storage" }, + { name = "httpx2" }, { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, { name = "pytest" }, @@ -2490,6 +2532,8 @@ requires-dist = [ { name = "google-cloud-storage", marker = "extra == 'gcs'", specifier = ">=2.18.0" }, { name = "google-cloud-storage", marker = "extra == 'test'", specifier = ">=2.18.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.0.0" }, + { name = "httpx2", marker = "extra == 'test'", specifier = ">=2.0.0" }, { name = "markdown-it-py", specifier = ">=3.0.0" }, { name = "opentelemetry-exporter-otlp", marker = "extra == 'dev'", specifier = ">=1.20.0" }, { name = "opentelemetry-exporter-otlp", marker = "extra == 'otel'", specifier = ">=1.20.0" }, @@ -2523,6 +2567,7 @@ provides-extras = ["gcs", "disk", "valkey", "otel", "test", "release", "dev"] [package.metadata.requires-dev] dev = [ { name = "google-cloud-storage", specifier = ">=2.18.0" }, + { name = "httpx2", specifier = ">=2.0.0" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.20.0" }, { name = "opentelemetry-sdk", specifier = ">=1.20.0" }, { name = "pytest", specifier = ">=8.3.0" }, @@ -2539,6 +2584,7 @@ release = [ ] test = [ { name = "google-cloud-storage", specifier = ">=2.18.0" }, + { name = "httpx2", specifier = ">=2.0.0" }, { name = "opentelemetry-exporter-otlp", specifier = ">=1.20.0" }, { name = "opentelemetry-sdk", specifier = ">=1.20.0" }, { name = "pytest", specifier = ">=8.3.0" },