greennode.vks-mcp-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
greennode/__init__.py ADDED
File without changes
@@ -0,0 +1 @@
1
+ """GreenNode MCP Server — MCP tools for VNG Kubernetes Service."""
@@ -0,0 +1,56 @@
1
+ """IAM token management for GreenNode MCP Server."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import time
6
+
7
+ import httpx
8
+
9
+ from greennode.vks_mcp_server.config import VksConfig
10
+
11
+ IAM_TOKEN_URL = "https://iamapis.vngcloud.vn/accounts-api/v1/auth/token"
12
+
13
+ DEFAULT_TIMEOUT = 30 # seconds
14
+
15
+
16
+ class TokenManager:
17
+ """Manages IAM access tokens with auto-refresh via client credentials."""
18
+
19
+ def __init__(self, config: VksConfig) -> None:
20
+ self._config = config
21
+ self._access_token = None
22
+ self._expires_at = 0
23
+
24
+ async def get_token(self) -> str:
25
+ """Return a valid access token, refreshing if needed."""
26
+ if self._access_token and time.time() < self._expires_at - 60:
27
+ return self._access_token
28
+
29
+ await self._fetch_token()
30
+ return self._access_token
31
+
32
+ async def _fetch_token(self) -> None:
33
+ """Fetch a new token from the IAM API."""
34
+ credentials = f"{self._config.client_id}:{self._config.client_secret}"
35
+ encoded = base64.b64encode(credentials.encode()).decode()
36
+
37
+ headers = {"Authorization": f"Basic {encoded}"}
38
+ body = {"grantType": "client_credentials"}
39
+
40
+ try:
41
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
42
+ response = await client.post(IAM_TOKEN_URL, json=body, headers=headers)
43
+ except httpx.ConnectError as exc:
44
+ raise RuntimeError(
45
+ f"Cannot connect to IAM API: {exc}"
46
+ ) from exc
47
+
48
+ if response.status_code != 200:
49
+ raise RuntimeError(
50
+ f"Authentication failed: HTTP {response.status_code} from IAM API."
51
+ )
52
+
53
+ data = response.json()
54
+ self._access_token = data["accessToken"]
55
+ expires_in = data.get("expiresIn", 1800)
56
+ self._expires_at = time.time() + expires_in
@@ -0,0 +1,200 @@
1
+ """HTTP client for VKS API with retry on 5xx/timeout and auto-refresh on 401."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from greennode.vks_mcp_server.auth import TokenManager
11
+ from greennode.vks_mcp_server.config import VksConfig
12
+
13
+ LOG = logging.getLogger(__name__)
14
+
15
+ # Retry configuration
16
+ MAX_RETRIES = 3
17
+ RETRY_BASE_DELAY = 1 # seconds
18
+ RETRYABLE_STATUS_CODES = {500, 502, 503, 504}
19
+ DEFAULT_TIMEOUT = 30 # seconds
20
+
21
+
22
+ class VksClient:
23
+ """Thin async HTTP client for the VKS API."""
24
+
25
+ def __init__(self, config: VksConfig, token_manager: TokenManager) -> None:
26
+ self._config = config
27
+ self._token_manager = token_manager
28
+
29
+ async def _request(
30
+ self,
31
+ method: str,
32
+ path: str,
33
+ region: str | None = None,
34
+ params: dict[str, Any] | None = None,
35
+ json: Any = None,
36
+ raw_response: bool = False,
37
+ _retried_auth: bool = False,
38
+ ) -> Any:
39
+ """Send an HTTP request to the VKS API.
40
+
41
+ Retries up to ``MAX_RETRIES`` times on 5xx errors and network
42
+ timeouts with exponential backoff (1s, 2s, 4s). Automatically
43
+ retries once on 401 by refreshing the access token.
44
+
45
+ Args:
46
+ method: HTTP method (GET, POST, PUT, DELETE).
47
+ path: API path (e.g. ``/v1/clusters``).
48
+ region: Region override; ``None`` uses the default region.
49
+ params: Optional query parameters.
50
+ json: Optional JSON body.
51
+ raw_response: If ``True``, return the raw response text
52
+ instead of parsed JSON.
53
+ _retried_auth: Internal flag to prevent infinite 401 retry loops.
54
+ """
55
+ endpoints = self._config.get_endpoints(region)
56
+ url = f"{endpoints.vks}{path}"
57
+
58
+ last_error: Exception | None = None
59
+
60
+ for attempt in range(MAX_RETRIES + 1):
61
+ token = await self._token_manager.get_token()
62
+ headers = {"Authorization": f"Bearer {token}"}
63
+
64
+ try:
65
+ async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
66
+ resp = await client.request(
67
+ method,
68
+ url,
69
+ headers=headers,
70
+ params=params,
71
+ json=json,
72
+ )
73
+ except (httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError) as exc:
74
+ last_error = exc
75
+ if attempt < MAX_RETRIES:
76
+ delay = RETRY_BASE_DELAY * (2 ** attempt)
77
+ LOG.debug(
78
+ "Request timeout/error (attempt %d/%d), retrying in %ds: %s",
79
+ attempt + 1, MAX_RETRIES + 1, delay, exc,
80
+ )
81
+ await asyncio.sleep(delay)
82
+ continue
83
+ raise RuntimeError(
84
+ f"Request failed after {MAX_RETRIES + 1} attempts: {exc}"
85
+ ) from exc
86
+
87
+ # 401 — refresh token and retry once
88
+ if resp.status_code == 401:
89
+ if _retried_auth:
90
+ self._raise_error(resp)
91
+ self._token_manager._expires_at = 0
92
+ return await self._request(
93
+ method,
94
+ path,
95
+ region=region,
96
+ params=params,
97
+ json=json,
98
+ raw_response=raw_response,
99
+ _retried_auth=True,
100
+ )
101
+
102
+ # Retryable server errors (5xx)
103
+ if resp.status_code in RETRYABLE_STATUS_CODES:
104
+ if attempt < MAX_RETRIES:
105
+ delay = RETRY_BASE_DELAY * (2 ** attempt)
106
+ LOG.debug(
107
+ "Server error %d (attempt %d/%d), retrying in %ds",
108
+ resp.status_code, attempt + 1, MAX_RETRIES + 1, delay,
109
+ )
110
+ await asyncio.sleep(delay)
111
+ continue
112
+
113
+ if not resp.is_success:
114
+ self._raise_error(resp)
115
+
116
+ if raw_response:
117
+ return resp.text
118
+
119
+ return resp.json()
120
+
121
+ # Should not reach here, but just in case
122
+ raise RuntimeError(f"Request failed after {MAX_RETRIES + 1} attempts")
123
+
124
+ def _raise_error(self, resp: httpx.Response) -> None:
125
+ """Raise a ``RuntimeError`` with a descriptive error message."""
126
+ try:
127
+ body = resp.json()
128
+
129
+ msg = (
130
+ body.get("message")
131
+ or body.get("error")
132
+ or body.get("errors", [{}])[0].get("message", "")
133
+ or str(body)
134
+ )
135
+ except Exception:
136
+ msg = resp.text or "unknown error"
137
+
138
+ status = resp.status_code
139
+
140
+ if status == 400:
141
+ raise RuntimeError(f"Bad request: {msg}")
142
+ if status == 401:
143
+ raise RuntimeError(
144
+ "Token expired or invalid. Please check your authentication configuration."
145
+ )
146
+ if status == 404:
147
+ raise RuntimeError(f"Resource not found: {msg}")
148
+ if status == 409:
149
+ raise RuntimeError("Resource is being processed. Please wait and try again.")
150
+
151
+ raise RuntimeError(f"API error ({status}): {msg}")
152
+
153
+ async def get(
154
+ self,
155
+ path: str,
156
+ region: str | None = None,
157
+ params: dict[str, Any] | None = None,
158
+ ) -> Any:
159
+ """Send a GET request."""
160
+ return await self._request("GET", path, region=region, params=params)
161
+
162
+ async def post(
163
+ self,
164
+ path: str,
165
+ region: str | None = None,
166
+ params: dict[str, Any] | None = None,
167
+ json: Any = None,
168
+ ) -> Any:
169
+ """Send a POST request."""
170
+ return await self._request("POST", path, region=region, params=params, json=json)
171
+
172
+ async def put(
173
+ self,
174
+ path: str,
175
+ region: str | None = None,
176
+ params: dict[str, Any] | None = None,
177
+ json: Any = None,
178
+ ) -> Any:
179
+ """Send a PUT request."""
180
+ return await self._request("PUT", path, region=region, params=params, json=json)
181
+
182
+ async def delete(
183
+ self,
184
+ path: str,
185
+ region: str | None = None,
186
+ params: dict[str, Any] | None = None,
187
+ ) -> Any:
188
+ """Send a DELETE request."""
189
+ return await self._request("DELETE", path, region=region, params=params)
190
+
191
+ async def get_raw(
192
+ self,
193
+ path: str,
194
+ region: str | None = None,
195
+ params: dict[str, Any] | None = None,
196
+ ) -> str:
197
+ """Send a GET request and return the raw response text."""
198
+ return await self._request(
199
+ "GET", path, region=region, params=params, raw_response=True
200
+ )