From 9a166a8a56908920464f4a56655e9d71f483b2cc Mon Sep 17 00:00:00 2001 From: tytv2 Date: Sat, 20 Jun 2026 21:20:01 +0700 Subject: [PATCH] fix(mcp): auto-discover project_id and fix cluster_versions_list crash - discovery: _require_project_id now async; when project_id is unset it fetches GET /v1/projects from vServer, uses the single project, and caches it on the config (no portal-user-id needed). Configured GRN_PROJECT_ID/credentials still take precedence. All discovery call sites updated to await. - version: fix cluster_versions_list crashing with "'list' object has no attribute 'get'" on a top-level array response; normalise dict/list/empty shapes robustly. - tests: bare-array version response; project_id configured/auto-discover+cache/ empty-error. Full suite 137 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + .../vks_mcp_server/discovery_handler.py | 39 ++++++++++++----- .../vks_mcp_server/version_handler.py | 19 +++++--- .../tests/test_discovery_tools.py | 43 +++++++++++++++++++ .../tests/test_version_tools.py | 21 +++++++++ 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 3c2a182..a15d6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build/ .ruff_cache/ .DS_Store .superpowers/ +.claude/ diff --git a/src/vks-mcp-server/greennode/vks_mcp_server/discovery_handler.py b/src/vks-mcp-server/greennode/vks_mcp_server/discovery_handler.py index 5418eea..516a872 100644 --- a/src/vks-mcp-server/greennode/vks_mcp_server/discovery_handler.py +++ b/src/vks-mcp-server/greennode/vks_mcp_server/discovery_handler.py @@ -8,13 +8,32 @@ from pydantic import Field -def _require_project_id(config: VksConfig) -> str: - """Return the configured project_id or raise a clear error.""" - if not config.project_id: +async def _require_project_id( + config: VksConfig, client: VksClient, region: str | None = None +) -> str: + """Return the project_id, auto-discovering it from vServer when not configured. + + Resolution order: configured value (GRN_PROJECT_ID / credentials file) first; + otherwise fetch it from vServer ``GET /v1/projects``. Each user has exactly one + project, so the single returned project is used and cached on the config so + later tool calls don't refetch. + """ + if config.project_id: + return config.project_id + + data = await client.vserver_get("/v1/projects", region=region) + projects = _as_list(data, "projects") + if not projects or not isinstance(projects[0], dict): raise ValueError( - "project_id is not configured. Run 'grn configure' or set GRN_PROJECT_ID." + "Could not determine project_id: vServer returned no project. " + "Set GRN_PROJECT_ID or run 'grn configure'." ) - return config.project_id + pid = projects[0].get("projectId") + if not pid: + raise ValueError("Could not determine project_id from the vServer response.") + + config.project_id = pid # cache for subsequent tool calls + return pid def _as_list(data, *wrapper_keys): @@ -41,7 +60,7 @@ def _table(title: str, header: str, sep: str, rows: list[str], empty: str) -> st async def _vpc_list(config: VksConfig, client: VksClient, region: str | None = None) -> str: """Fetch VPCs/networks, return a markdown table.""" - pid = _require_project_id(config) + pid = await _require_project_id(config, client, region) data = await client.vserver_get(f"/v2/{pid}/networks", region=region) items = _as_list(data, "listData") rows = [] @@ -64,7 +83,7 @@ async def _subnet_list( ) -> str: """Fetch subnets of a VPC, return a markdown table.""" validate_id(vpc_id, "vpc_id") - pid = _require_project_id(config) + pid = await _require_project_id(config, client, region) data = await client.vserver_get(f"/v2/{pid}/networks/{vpc_id}/subnets", region=region) items = _as_list(data, "listData") rows = [] @@ -105,7 +124,7 @@ async def _flavor_list( need: str | None = None, ) -> str: """Fetch cluster flavors as a markdown table, each row tagged with a suggested deployment-need group, optionally filtered to one group via *need*.""" - pid = _require_project_id(config) + pid = await _require_project_id(config, client, region) data = await client.vserver_get(f"/v1/{pid}/flavors/customs/clusters", region=region) items = _as_list(data, "listData") rows = [] @@ -135,7 +154,7 @@ async def _flavor_list( async def _sshkey_list(config: VksConfig, client: VksClient, region: str | None = None) -> str: """Fetch SSH keys, return a markdown table.""" - pid = _require_project_id(config) + pid = await _require_project_id(config, client, region) data = await client.vserver_get( f"/v2/{pid}/sshKeys", region=region, params={"page": 1, "size": 100} ) @@ -154,7 +173,7 @@ async def _sshkey_list(config: VksConfig, client: VksClient, region: str | None async def _secgroup_list(config: VksConfig, client: VksClient, region: str | None = None) -> str: """Fetch security groups, return a markdown table.""" - pid = _require_project_id(config) + pid = await _require_project_id(config, client, region) data = await client.vserver_get(f"/v2/{pid}/secgroups", region=region) items = _as_list(data, "listData") rows = [ diff --git a/src/vks-mcp-server/greennode/vks_mcp_server/version_handler.py b/src/vks-mcp-server/greennode/vks_mcp_server/version_handler.py index 4f18801..e138540 100644 --- a/src/vks-mcp-server/greennode/vks_mcp_server/version_handler.py +++ b/src/vks-mcp-server/greennode/vks_mcp_server/version_handler.py @@ -20,12 +20,19 @@ async def _cluster_versions_list( ) -> list[types.TextContent]: """Fetch and format available Kubernetes cluster versions.""" data = await client.get("/v1/cluster-versions", region=region) - items = data.get("items", data) if isinstance(data, list) else data - if isinstance(items, dict): - items = items.get("items", items) - - # Filter enabled versions only - items = [v for v in items if v.get("enable", True)] + # Normalise to a list of version dicts. The endpoint may return a bare array + # or an object wrapping it under "items"/"data". + if isinstance(data, dict): + items = data.get("items", data.get("data", [])) + elif isinstance(data, list): + items = data + else: + items = [] + if not isinstance(items, list): + items = [] + + # Filter enabled versions only (ignore any non-dict entries defensively) + items = [v for v in items if isinstance(v, dict) and v.get("enable", True)] # Find stable versions for the "recommended" marker stable_versions = [ diff --git a/src/vks-mcp-server/tests/test_discovery_tools.py b/src/vks-mcp-server/tests/test_discovery_tools.py index d94bc58..22c7098 100644 --- a/src/vks-mcp-server/tests/test_discovery_tools.py +++ b/src/vks-mcp-server/tests/test_discovery_tools.py @@ -10,6 +10,7 @@ from greennode.vks_mcp_server.config import load_config from greennode.vks_mcp_server.discovery_handler import ( _flavor_list, + _require_project_id, _secgroup_list, _sshkey_list, _subnet_list, @@ -208,3 +209,45 @@ async def test_secgroup_list(config, client): result = await _secgroup_list(config, client) assert "default" in result assert "secg-1" in result + + +@respx.mock +@pytest.mark.asyncio +async def test_require_project_id_uses_configured(config, client): + """A configured project_id is returned without calling vServer.""" + config.project_id = "pro-test-0001" + pid = await _require_project_id(config, client, region=None) + assert pid == "pro-test-0001" + + +@respx.mock +@pytest.mark.asyncio +async def test_require_project_id_autodiscovers(config, client): + """When project_id is unset, it is fetched from /v1/projects and cached.""" + config.project_id = None + _mock_iam(respx.mock) + route = respx.get(f"{VSERVER_BASE}/v1/projects").mock( + return_value=httpx.Response( + 200, json={"projects": [{"projectId": "pro-disc-9999", "userId": "u1"}]} + ) + ) + pid = await _require_project_id(config, client, region=None) + assert pid == "pro-disc-9999" + assert config.project_id == "pro-disc-9999" # cached + # second call must not hit the API again + pid2 = await _require_project_id(config, client, region=None) + assert pid2 == "pro-disc-9999" + assert route.call_count == 1 + + +@respx.mock +@pytest.mark.asyncio +async def test_require_project_id_no_project_errors(config, client): + """An empty project list yields a clear error.""" + config.project_id = None + _mock_iam(respx.mock) + respx.get(f"{VSERVER_BASE}/v1/projects").mock( + return_value=httpx.Response(200, json={"projects": []}) + ) + with pytest.raises(ValueError, match="project_id"): + await _require_project_id(config, client, region=None) diff --git a/src/vks-mcp-server/tests/test_version_tools.py b/src/vks-mcp-server/tests/test_version_tools.py index 9cdeb6a..19c5049 100644 --- a/src/vks-mcp-server/tests/test_version_tools.py +++ b/src/vks-mcp-server/tests/test_version_tools.py @@ -57,3 +57,24 @@ async def test_cluster_versions_list(config_and_client): assert "v1.28.0" in text assert "v1.27.0" not in text assert "recommended" in text.lower() + + +CLUSTER_VERSIONS_BARE_ARRAY = [ + {"version": "v1.30.0", "stage": "STABLE", "deprecatedAt": None, "enable": True}, + {"version": "v1.29.0", "stage": "RAPID", "deprecatedAt": None, "enable": True}, +] + + +@respx.mock +@pytest.mark.asyncio +async def test_cluster_versions_list_bare_array(config_and_client): + """A top-level array response must not crash with 'list' object has no attribute 'get'.""" + config, client = config_and_client + _mock_iam(respx.mock) + respx.get(f"{VKS_BASE}/v1/cluster-versions").mock( + return_value=httpx.Response(200, json=CLUSTER_VERSIONS_BARE_ARRAY), + ) + result = await _cluster_versions_list(config, client, region=None) + text = result[0].text + assert "v1.30.0" in text + assert "v1.29.0" in text