diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ed7fb3..ad63325c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith credential-helper install` and `uninstall` no longer rewrite a config file when they make no change to its data. Previously, the CLI always rewrote a file in a different formatting, such as the tab-indented `config.json` that Docker writes. The command also made a `.bak` copy and reported changes that it did not make. - When the API host name or the proxy host name does not exist, for example because of a typo in `--api-host`, commands now fail immediately. Previously, they retried for approximately 62 seconds without output. The CLI still retries a DNS failure that the operating system reports as temporary. - When the CLI waits before it retries a failed connection or a 5xx response, it now writes `Request failed: retrying in N second(s) ...` to stderr. Previously, it waited without output. +- `cloudsmith mcp start` now stops without an error when the MCP client closes the connection while the server writes a reply. Previously, the command stopped with `BrokenPipeError` and exit status 1. - The CLI now exits with status 1 when you interrupt it with Ctrl+C after the reader of its stderr has closed the pipe. Before, the CLI sent an error report and exited with status 120. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index bbe20296..f3b0ac8e 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -1,6 +1,7 @@ import json import os import stat +import subprocess import sys from pathlib import Path from unittest.mock import patch @@ -740,3 +741,40 @@ def test_configure_prints_on_cp1252_console(self, tmp_path): assert result.exit_code == 0, result.output assert "Configured Cursor" in result.stdout assert config_path.exists() + + +def test_server_stops_quietly_when_the_client_closes_stdout(): + """The stdio transport raises the closed pipe inside an ExceptionGroup.""" + code = """ +import os +from unittest.mock import patch + +import cloudsmith_api + +from cloudsmith_cli.core.mcp.server import DynamicMCPServer + +read_end, write_end = os.pipe() +os.close(read_end) +os.dup2(write_end, 1) +with patch.object(DynamicMCPServer, "load_openapi_spec"): + DynamicMCPServer(api_config=cloudsmith_api.Configuration()).run() +""" + initialize = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1"}, + }, + } + result = subprocess.run( + [sys.executable, "-c", code], + input=json.dumps(initialize) + "\n", + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 26fa8b55..323ef829 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -883,7 +883,10 @@ def run(self): """Initialize and run the server""" asyncio.run(self.load_openapi_spec()) try: - self.mcp.run(transport="stdio") + try: + self.mcp.run(transport="stdio") + except* BrokenPipeError: + pass # The MCP client closed the connection. except asyncio.CancelledError: print("Server shutdown requested")