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/core/cli.py b/core/cli.py index a57f0b78f..2a350076e 100644 --- a/core/cli.py +++ b/core/cli.py @@ -68,6 +68,20 @@ def _build_oauth() -> OAuth: return OAuth(token_storage=storage) +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: """Connect, authenticate once, and print available tools.""" try: @@ -97,10 +111,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/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/tests/core/test_cli_value_coercion.py b/tests/core/test_cli_value_coercion.py new file mode 100644 index 000000000..8ce86f62c --- /dev/null +++ b/tests/core/test_cli_value_coercion.py @@ -0,0 +1,39 @@ +"""Tests for workspace-cli key=value argument coercion.""" + +import pytest + +from core.cli import _coerce_cli_value + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("is:unread", "is:unread"), + ("gmail drive calendar", "gmail drive calendar"), + # Quotes must survive for Gmail phrase queries. + ('"grow therapy"', '"grow therapy"'), + ('"grow therapy" newer_than:15mo', '"grow therapy" newer_than:15mo'), + ("2026/04/16", "2026/04/16"), + ("", ""), + ], +) +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 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" },