diff --git a/CHANGELOG.md b/CHANGELOG.md index a61e03ee..e39c5331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- MCP tools that use the v2 API, such as `analytics_logs_package_list`, no longer fail with a 404 error. Previously, the MCP server sent these requests without the `/v2` prefix. - The CLI no longer prompts for a keyring password when no user can answer. A run is non-interactive when `CI` is set or when no controlling terminal is available. In such a run, a keyring call that needs a password that was not supplied fails without a prompt. OIDC tokens then use the on-disk cache, and `cloudsmith auth` prints why it did not store the SSO tokens. Previously, headless Linux agents printed `GetPassWarning: Can not control echo on the terminal` after each OIDC exchange. To keep a keyring in CI, set `CLOUDSMITH_KEYRING_BACKEND` to a backend that does not prompt, or to an encrypted backend together with `CLOUDSMITH_KEYRING_KEY`. - `CLOUDSMITH_KEYRING_KEY`, `CLOUDSMITH_KEYRING_DIR`, and `CLOUDSMITH_KEYRING_FILE_PATH` now apply to the default keyring on headless Linux. That keyring is a chain of backends. Previously, the CLI applied these settings to the chain and not to the encrypted backend that stores the values, so the settings had no effect. - A `CLOUDSMITH_KEYRING_KEY` that does not unlock the keyring file no longer stops commands such as `logout` and `whoami` with a `ValueError`. The CLI treats the keyring as not available. The CLI also unlocks an encrypted keyring file once for each run, not once for each keyring call. diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index 724240a0..f2137d93 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -308,7 +308,9 @@ def test_server_generates_tools_from_openapi_spec(self): import asyncio asyncio.run( - server._generate_tools_from_spec() # pylint: disable=protected-access + server._generate_tools_from_spec( # pylint: disable=protected-access + "https://api.cloudsmith.io/v1" + ) ) # Verify tools were created @@ -367,7 +369,9 @@ def test_server_respects_tool_filtering(self): import asyncio asyncio.run( - server._generate_tools_from_spec() # pylint: disable=protected-access + server._generate_tools_from_spec( # pylint: disable=protected-access + "https://api.cloudsmith.io/v1" + ) ) # Verify only repos tools were created @@ -387,6 +391,33 @@ def test_server_reports_cli_version(self): assert server.mcp.version == get_cli_version() +class TestMCPServerLoadOpenAPISpec: + def test_tool_urls_include_api_version(self): + import asyncio + + import cloudsmith_api + import httpx2 + + api_config = cloudsmith_api.Configuration() + api_config.host = "https://api.cloudsmith.io" + api_config.headers = {} + server = DynamicMCPServer(api_config=api_config, force_all_tools=True) + + def handler(request): + version = "v2" if "/v2/" in str(request.url) else "v1" + spec = {"paths": {"/repos/": {"get": {"operationId": f"{version}_list"}}}} + return httpx2.Response(200, json=spec) + + with patch( + "cloudsmith_cli.core.mcp.server.create_mcp_http_client", + return_value=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), + ): + asyncio.run(server.load_openapi_spec()) + + assert server.tools["v1_list"].base_url == "https://api.cloudsmith.io/v1" + assert server.tools["v2_list"].base_url == "https://api.cloudsmith.io/v2" + + SERVER_CONFIG = {"command": "cloudsmith", "args": ["mcp", "start"]} diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 9c59b6d2..eedbf673 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -213,7 +213,7 @@ async def load_openapi_spec(self): response = await http_client.get(spec_url) response.raise_for_status() self.spec = response.json() - await self._generate_tools_from_spec() + await self._generate_tools_from_spec(f"{self.api_base_url}/{version}") def _get_tool_groups(self, tool_name: str) -> list[str]: """ @@ -287,7 +287,7 @@ def _is_tool_allowed(self, tool_name: str) -> bool: # Otherwise disable all categories in the default list return not any(group in DEFAULT_DISABLED_CATEGORIES for group in tool_groups) - async def _generate_tools_from_spec(self): + async def _generate_tools_from_spec(self, base_url: str): """Generate MCP tools from OpenAPI specification""" if not self.spec: @@ -304,7 +304,7 @@ async def _generate_tools_from_spec(self): path, operation, path_parameters, - self.api_base_url, + base_url, ) if tool and self._is_tool_allowed(tool.name): self.tools[tool.name] = tool