From 8232ca12cc3194659b7ab75eab4c0e286cced956 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Mon, 2 Nov 2026 01:29:47 +0000 Subject: [PATCH] fix(ENG-15479): stop mcp start quietly when the client closes stdout The mcp stdio transport writes in an anyio task group, which raises the BrokenPipeError inside an ExceptionGroup. The error reached the top-level handler, which reported it and exited with status 1. Fixes CLOUDSMITH-CLI-20 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + cloudsmith_cli/cli/tests/commands/test_mcp.py | 38 +++++++++++++++++++ cloudsmith_cli/core/mcp/server.py | 5 ++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ea81a1..30e38188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - When the API or a proxy answers the SAML IdP request with an HTML page or a JSON value that is not an object, `cloudsmith auth` now shows "Failed to authenticate via SAML!" with the reason. Before this fix, the CLI stopped with a `JSONDecodeError` or an `AttributeError`. The 2FA code exchange and the SAML token refresh now also reject a response that is not a JSON object. The 2FA code exchange also rejects a response without an access token. - List commands that return no results now print `Results: 0 `, not `Results: 1-0 (0) of 0 (page: 1/1, ...)`. When the API response has no pagination headers, list commands print only the result count, not `of None`. - An API request no longer waits without a limit when the API host accepts the connection but does not reply. A request now fails when it cannot connect in 30 seconds, or when it gets no data for 90 seconds. The API CDN waits 60 seconds for the API, so the CLI does not stop a slow request before the CDN does. If a request fails, the CLI retries it up to 6 times by default (`--error-retry-max`). A POST request has only the 30-second connection timeout, because a retry after a read timeout can create a second entry. File uploads and downloads do not change. +- `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. ## [1.30.0] - 2026-10-22 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")