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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ build/
.ruff_cache/
.DS_Store
.superpowers/
.claude/
39 changes: 29 additions & 10 deletions src/vks-mcp-server/greennode/vks_mcp_server/discovery_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 = []
Expand All @@ -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 = []
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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}
)
Expand All @@ -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 = [
Expand Down
19 changes: 13 additions & 6 deletions src/vks-mcp-server/greennode/vks_mcp_server/version_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
43 changes: 43 additions & 0 deletions src/vks-mcp-server/tests/test_discovery_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
21 changes: 21 additions & 0 deletions src/vks-mcp-server/tests/test_version_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading