Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 33 additions & 2 deletions cloudsmith_cli/cli/tests/commands/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"]}


Expand Down
6 changes: 3 additions & 3 deletions cloudsmith_cli/core/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading