From 6b5204e0bcf497beeebedeec7c5e5037a88b8325 Mon Sep 17 00:00:00 2001 From: agupta-cruiser Date: Wed, 9 Sep 2026 02:34:50 +0530 Subject: [PATCH 1/6] Fixed the package analytics endpoint generation by mcp, included v2 in it --- cloudsmith_cli/cli/tests/commands/test_mcp.py | 62 +++++++++++++++++++ cloudsmith_cli/core/mcp/server.py | 28 ++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index 724240a0..833b3b09 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -387,6 +387,68 @@ def test_server_reports_cli_version(self): assert server.mcp.version == get_cli_version() +class TestMCPServerVersionPrefix: + """The version prefix (/v1, /v2) is declared out-of-band from the path + keys (servers[].url for v2, basePath for v1), so tool URLs must prepend + it or v2 requests hit the wrong path and 404. + """ + + def _server(self): + import cloudsmith_api + + api_config = cloudsmith_api.Configuration() + api_config.host = "https://api.cloudsmith.io" + api_config.api_key = {"X-Api-Key": "test-key"} + return DynamicMCPServer(api_config=api_config, force_all_tools=True) + + def test_v2_spec_base_url_includes_version_from_servers(self): + server = self._server() + server.spec = {"servers": [{"url": "https://api.cloudsmith.io/v2/"}]} + assert server._spec_base_url() == "https://api.cloudsmith.io/v2" + + def test_v1_spec_base_url_falls_back_to_basepath(self): + server = self._server() + server.spec = {"basePath": "/"} + assert server._spec_base_url() == "https://api.cloudsmith.io" + + def test_spec_base_url_keeps_configured_host_not_spec_host(self): + """A custom --api-host must win; only the version path is borrowed.""" + import cloudsmith_api + + api_config = cloudsmith_api.Configuration() + api_config.host = "https://api.eu.cloudsmith.example" + api_config.api_key = {"X-Api-Key": "test-key"} + server = DynamicMCPServer(api_config=api_config, force_all_tools=True) + server.spec = {"servers": [{"url": "https://api.cloudsmith.io/v2/"}]} + assert server._spec_base_url() == "https://api.eu.cloudsmith.example/v2" + + def test_v2_tools_are_generated_under_v2_path(self): + import asyncio + + server = self._server() + server.spec = { + "servers": [{"url": "https://api.cloudsmith.io/v2/"}], + "paths": { + "/analytics/logs/package/{workspace}/": { + "get": { + "operationId": "analytics_logs_package_list", + "summary": "List package log events", + } + } + }, + } + asyncio.run( + server._generate_tools_from_spec( # pylint: disable=protected-access + server._spec_base_url() # pylint: disable=protected-access + ) + ) + tool = server.tools["analytics_logs_package_list"] + assert ( + tool.base_url + tool.path + == "https://api.cloudsmith.io/v2/analytics/logs/package/{workspace}/" + ) + + 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..53b9e52a 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -213,7 +213,26 @@ 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(self._spec_base_url()) + + def _spec_base_url(self) -> str: + """Base URL for the currently-loaded spec, including its version path. + + The OpenAPI spec declares its version prefix out-of-band from the + path keys: OpenAPI 3 (v2) puts it in ``servers[].url`` (e.g. + ``https://api.cloudsmith.io/v2/``) while Swagger 2 (v1) uses + ``basePath``. The path keys themselves are version-relative, so we + must prepend that prefix or v2 requests hit ``/analytics/...`` instead + of ``/v2/analytics/...`` and 404. We keep the configured host (so a + custom ``--api-host`` still wins) and only borrow the version path. + """ + version_path = "" + servers = self.spec.get("servers") + if servers and servers[0].get("url"): + version_path = parse.urlsplit(servers[0]["url"]).path + else: + version_path = self.spec.get("basePath", "") or "" + return f"{self.api_base_url.rstrip('/')}{version_path}".rstrip("/") def _get_tool_groups(self, tool_name: str) -> list[str]: """ @@ -287,12 +306,15 @@ 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 | None = None): """Generate MCP tools from OpenAPI specification""" if not self.spec: raise ValueError("OpenAPI spec not loaded") + if base_url is None: + base_url = self.api_base_url + # Parse paths and generate tools for path, path_item in self.spec.get("paths", {}).items(): for method, operation in path_item.items(): @@ -304,7 +326,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 From 5ee42b823580c7598cf630439fd0dd35eb4034a5 Mon Sep 17 00:00:00 2001 From: agupta-cruiser Date: Wed, 30 Sep 2026 03:14:24 +0530 Subject: [PATCH 2/6] Added changelog, and fixed the doc string --- CHANGELOG.md | 1 + cloudsmith_cli/core/mcp/server.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a61e03ee..fd47403c 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 backed by the v2 API (e.g. `analytics_logs_package_list`) no longer 404. Dynamically-generated tool URLs now include the `/v2` version prefix, which the OpenAPI spec declares separately from its path keys. Previously every v2-backed MCP tool call failed regardless of authentication or plan. - 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/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 53b9e52a..c3314e61 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -220,14 +220,22 @@ def _spec_base_url(self) -> str: The OpenAPI spec declares its version prefix out-of-band from the path keys: OpenAPI 3 (v2) puts it in ``servers[].url`` (e.g. - ``https://api.cloudsmith.io/v2/``) while Swagger 2 (v1) uses - ``basePath``. The path keys themselves are version-relative, so we - must prepend that prefix or v2 requests hit ``/analytics/...`` instead - of ``/v2/analytics/...`` and 404. We keep the configured host (so a - custom ``--api-host`` still wins) and only borrow the version path. + ``https://api.cloudsmith.io/v2/``). The path keys themselves are + version-relative, so we must prepend that prefix or v2 requests hit + ``/analytics/...`` instead of ``/v2/analytics/...`` and 404. We keep + the configured host (so a custom ``--api-host`` still wins) and only + borrow the version path. + + The ``basePath`` branch is a no-op fallback in practice: the v1 + (Swagger 2) spec is generated with ``basePath`` resolving to ``"/"``, + so it contributes no prefix. That is harmless because the v1 API also + answers unprefixed. It is kept only as a defensive fallback. """ + version_path = "" servers = self.spec.get("servers") + # Only a single server entry is expected today, so we take the first; + # revisit this if a spec ever declares multiple servers. if servers and servers[0].get("url"): version_path = parse.urlsplit(servers[0]["url"]).path else: From 0f4336808416d29f9339b42caae5ea9b728b2a63 Mon Sep 17 00:00:00 2001 From: agupta-cruiser Date: Wed, 30 Sep 2026 04:48:40 +0530 Subject: [PATCH 3/6] Added unit test for load_openapi_spec --- cloudsmith_cli/cli/tests/commands/test_mcp.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index 833b3b09..264840d0 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -448,6 +448,65 @@ def test_v2_tools_are_generated_under_v2_path(self): == "https://api.cloudsmith.io/v2/analytics/logs/package/{workspace}/" ) + def test_load_openapi_spec_wires_version_aware_base_url(self): + """The real ``load_openapi_spec()`` wiring end-to-end. + ``create_mcp_http_client`` returns a real ``httpx.AsyncClient``, + so we feed it an ``httpx.MockTransport`` instead of hand-faking the + async client: the real client's ``async with`` and ``raise_for_status()`` + run for free, so a URL that 404s would raise rather than silently pass. + """ + import asyncio + + import httpx + + server = self._server() + server.api_config.headers = {} + + specs = { + "v1": { + "basePath": "/", + "paths": { + "/repos/": { + "get": { + "operationId": "repos_list", + "summary": "List repositories", + } + } + }, + }, + "v2": { + "servers": [{"url": "https://api.cloudsmith.io/v2/"}], + "paths": { + "/analytics/logs/package/{workspace}/": { + "get": { + "operationId": "analytics_logs_package_list", + "summary": "List package log events", + } + } + }, + }, + } + + def handler(request: httpx.Request) -> httpx.Response: + spec = specs["v2"] if "/v2/" in str(request.url) else specs["v1"] + return httpx.Response(200, json=spec) + + mock_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + with patch( + "cloudsmith_cli.core.mcp.server.create_mcp_http_client", + return_value=mock_client, + ): + asyncio.run(server.load_openapi_spec()) + + v2_tool = server.tools["analytics_logs_package_list"] + assert ( + v2_tool.base_url + v2_tool.path + == "https://api.cloudsmith.io/v2/analytics/logs/package/{workspace}/" + ) + v1_tool = server.tools["repos_list"] + assert v1_tool.base_url + v1_tool.path == "https://api.cloudsmith.io/repos/" + SERVER_CONFIG = {"command": "cloudsmith", "args": ["mcp", "start"]} From 73473e5887e9dbf9bf4657ed5438f7e0f2744389 Mon Sep 17 00:00:00 2001 From: agupta-cruiser Date: Wed, 30 Sep 2026 05:24:41 +0530 Subject: [PATCH 4/6] Replaced httpx with newer one httpx2 --- cloudsmith_cli/cli/tests/commands/test_mcp.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index 264840d0..e88f545e 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -450,14 +450,14 @@ def test_v2_tools_are_generated_under_v2_path(self): def test_load_openapi_spec_wires_version_aware_base_url(self): """The real ``load_openapi_spec()`` wiring end-to-end. - ``create_mcp_http_client`` returns a real ``httpx.AsyncClient``, - so we feed it an ``httpx.MockTransport`` instead of hand-faking the + ``create_mcp_http_client`` returns a real ``httpx2.AsyncClient``, + so we feed it an ``httpx2.MockTransport`` instead of hand-faking the async client: the real client's ``async with`` and ``raise_for_status()`` run for free, so a URL that 404s would raise rather than silently pass. """ import asyncio - import httpx + import httpx2 server = self._server() server.api_config.headers = {} @@ -487,11 +487,11 @@ def test_load_openapi_spec_wires_version_aware_base_url(self): }, } - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: spec = specs["v2"] if "/v2/" in str(request.url) else specs["v1"] - return httpx.Response(200, json=spec) + return httpx2.Response(200, json=spec) - mock_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + mock_client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) with patch( "cloudsmith_cli.core.mcp.server.create_mcp_http_client", From ab0be96845d464961edcca9c854ff33ddf50083a Mon Sep 17 00:00:00 2001 From: agupta-cruiser Date: Wed, 30 Sep 2026 05:30:22 +0530 Subject: [PATCH 5/6] Fixed whitespaces that were causing pre-commit getting failed --- cloudsmith_cli/cli/tests/commands/test_mcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index e88f545e..88d712d5 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -450,9 +450,9 @@ def test_v2_tools_are_generated_under_v2_path(self): def test_load_openapi_spec_wires_version_aware_base_url(self): """The real ``load_openapi_spec()`` wiring end-to-end. - ``create_mcp_http_client`` returns a real ``httpx2.AsyncClient``, - so we feed it an ``httpx2.MockTransport`` instead of hand-faking the - async client: the real client's ``async with`` and ``raise_for_status()`` + ``create_mcp_http_client`` returns a real ``httpx2.AsyncClient``, + so we feed it an ``httpx2.MockTransport`` instead of hand-faking the + async client: the real client's ``async with`` and ``raise_for_status()`` run for free, so a URL that 404s would raise rather than silently pass. """ import asyncio From 0dfefad17eb9979899249c6b8e130b9f454171f1 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 1 Oct 2026 15:06:42 +0100 Subject: [PATCH 6/6] Simplify the MCP API version prefix fix Use the version from the spec discovery loop as the tool base URL. The spec is fetched from the same prefix, so the host serves it. Remove the _spec_base_url helper and the optional base_url fallback. Replace the five new tests with one test of load_openapi_spec. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- cloudsmith_cli/cli/tests/commands/test_mcp.py | 124 +++--------------- cloudsmith_cli/core/mcp/server.py | 34 +---- 3 files changed, 20 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd47403c..e39c5331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed -- MCP tools backed by the v2 API (e.g. `analytics_logs_package_list`) no longer 404. Dynamically-generated tool URLs now include the `/v2` version prefix, which the OpenAPI spec declares separately from its path keys. Previously every v2-backed MCP tool call failed regardless of authentication or plan. +- 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 88d712d5..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,125 +391,31 @@ def test_server_reports_cli_version(self): assert server.mcp.version == get_cli_version() -class TestMCPServerVersionPrefix: - """The version prefix (/v1, /v2) is declared out-of-band from the path - keys (servers[].url for v2, basePath for v1), so tool URLs must prepend - it or v2 requests hit the wrong path and 404. - """ +class TestMCPServerLoadOpenAPISpec: + def test_tool_urls_include_api_version(self): + import asyncio - def _server(self): import cloudsmith_api + import httpx2 api_config = cloudsmith_api.Configuration() api_config.host = "https://api.cloudsmith.io" - api_config.api_key = {"X-Api-Key": "test-key"} - return DynamicMCPServer(api_config=api_config, force_all_tools=True) - - def test_v2_spec_base_url_includes_version_from_servers(self): - server = self._server() - server.spec = {"servers": [{"url": "https://api.cloudsmith.io/v2/"}]} - assert server._spec_base_url() == "https://api.cloudsmith.io/v2" - - def test_v1_spec_base_url_falls_back_to_basepath(self): - server = self._server() - server.spec = {"basePath": "/"} - assert server._spec_base_url() == "https://api.cloudsmith.io" - - def test_spec_base_url_keeps_configured_host_not_spec_host(self): - """A custom --api-host must win; only the version path is borrowed.""" - import cloudsmith_api - - api_config = cloudsmith_api.Configuration() - api_config.host = "https://api.eu.cloudsmith.example" - api_config.api_key = {"X-Api-Key": "test-key"} + api_config.headers = {} server = DynamicMCPServer(api_config=api_config, force_all_tools=True) - server.spec = {"servers": [{"url": "https://api.cloudsmith.io/v2/"}]} - assert server._spec_base_url() == "https://api.eu.cloudsmith.example/v2" - - def test_v2_tools_are_generated_under_v2_path(self): - import asyncio - - server = self._server() - server.spec = { - "servers": [{"url": "https://api.cloudsmith.io/v2/"}], - "paths": { - "/analytics/logs/package/{workspace}/": { - "get": { - "operationId": "analytics_logs_package_list", - "summary": "List package log events", - } - } - }, - } - asyncio.run( - server._generate_tools_from_spec( # pylint: disable=protected-access - server._spec_base_url() # pylint: disable=protected-access - ) - ) - tool = server.tools["analytics_logs_package_list"] - assert ( - tool.base_url + tool.path - == "https://api.cloudsmith.io/v2/analytics/logs/package/{workspace}/" - ) - def test_load_openapi_spec_wires_version_aware_base_url(self): - """The real ``load_openapi_spec()`` wiring end-to-end. - ``create_mcp_http_client`` returns a real ``httpx2.AsyncClient``, - so we feed it an ``httpx2.MockTransport`` instead of hand-faking the - async client: the real client's ``async with`` and ``raise_for_status()`` - run for free, so a URL that 404s would raise rather than silently pass. - """ - import asyncio - - import httpx2 - - server = self._server() - server.api_config.headers = {} - - specs = { - "v1": { - "basePath": "/", - "paths": { - "/repos/": { - "get": { - "operationId": "repos_list", - "summary": "List repositories", - } - } - }, - }, - "v2": { - "servers": [{"url": "https://api.cloudsmith.io/v2/"}], - "paths": { - "/analytics/logs/package/{workspace}/": { - "get": { - "operationId": "analytics_logs_package_list", - "summary": "List package log events", - } - } - }, - }, - } - - def handler(request: httpx2.Request) -> httpx2.Response: - spec = specs["v2"] if "/v2/" in str(request.url) else specs["v1"] + 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) - mock_client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) - with patch( "cloudsmith_cli.core.mcp.server.create_mcp_http_client", - return_value=mock_client, + return_value=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)), ): asyncio.run(server.load_openapi_spec()) - v2_tool = server.tools["analytics_logs_package_list"] - assert ( - v2_tool.base_url + v2_tool.path - == "https://api.cloudsmith.io/v2/analytics/logs/package/{workspace}/" - ) - v1_tool = server.tools["repos_list"] - assert v1_tool.base_url + v1_tool.path == "https://api.cloudsmith.io/repos/" + 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 c3314e61..eedbf673 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -213,34 +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(self._spec_base_url()) - - def _spec_base_url(self) -> str: - """Base URL for the currently-loaded spec, including its version path. - - The OpenAPI spec declares its version prefix out-of-band from the - path keys: OpenAPI 3 (v2) puts it in ``servers[].url`` (e.g. - ``https://api.cloudsmith.io/v2/``). The path keys themselves are - version-relative, so we must prepend that prefix or v2 requests hit - ``/analytics/...`` instead of ``/v2/analytics/...`` and 404. We keep - the configured host (so a custom ``--api-host`` still wins) and only - borrow the version path. - - The ``basePath`` branch is a no-op fallback in practice: the v1 - (Swagger 2) spec is generated with ``basePath`` resolving to ``"/"``, - so it contributes no prefix. That is harmless because the v1 API also - answers unprefixed. It is kept only as a defensive fallback. - """ - - version_path = "" - servers = self.spec.get("servers") - # Only a single server entry is expected today, so we take the first; - # revisit this if a spec ever declares multiple servers. - if servers and servers[0].get("url"): - version_path = parse.urlsplit(servers[0]["url"]).path - else: - version_path = self.spec.get("basePath", "") or "" - return f"{self.api_base_url.rstrip('/')}{version_path}".rstrip("/") + await self._generate_tools_from_spec(f"{self.api_base_url}/{version}") def _get_tool_groups(self, tool_name: str) -> list[str]: """ @@ -314,15 +287,12 @@ 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, base_url: str | None = None): + async def _generate_tools_from_spec(self, base_url: str): """Generate MCP tools from OpenAPI specification""" if not self.spec: raise ValueError("OpenAPI spec not loaded") - if base_url is None: - base_url = self.api_base_url - # Parse paths and generate tools for path, path_item in self.spec.get("paths", {}).items(): for method, operation in path_item.items():