lightnow-cli 1.0.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.
@@ -0,0 +1,3 @@
1
+ """LightNow CLI - A command-line tool for managing MCP servers."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,134 @@
1
+ """Authenticated HTTP helpers for LightNow API calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ from .commands.auth import (
10
+ async_refresh_current_session,
11
+ async_require_access_token,
12
+ refresh_current_session,
13
+ require_access_token,
14
+ )
15
+
16
+
17
+ def response_error_message(response: httpx.Response) -> str | None:
18
+ """Extract a safe, structured API error message when one is available."""
19
+ try:
20
+ payload = response.json()
21
+ except ValueError:
22
+ return None
23
+
24
+ if not isinstance(payload, dict):
25
+ return None
26
+
27
+ error = payload.get("error")
28
+ if isinstance(error, dict):
29
+ message = error.get("message")
30
+ if isinstance(message, str) and message.strip():
31
+ return message.strip()
32
+ code = error.get("code")
33
+ if isinstance(code, str) and code.strip():
34
+ return code.strip()
35
+
36
+ if isinstance(error, str) and error.strip():
37
+ return error.strip()
38
+
39
+ detail = payload.get("detail")
40
+ if isinstance(detail, str) and detail.strip():
41
+ return detail.strip()
42
+
43
+ title = payload.get("title")
44
+ if isinstance(title, str) and title.strip():
45
+ return title.strip()
46
+
47
+ return None
48
+
49
+
50
+ def authentication_error_from_response(response: httpx.Response) -> ValueError:
51
+ """Build an explicit auth/API error for a post-refresh 401 response."""
52
+ message = response_error_message(response)
53
+ if message:
54
+ return ValueError(f"Authentication failed: {message}")
55
+ return ValueError("Authentication failed: Registry API rejected the session.")
56
+
57
+
58
+ def authenticated_headers(
59
+ token: str,
60
+ *,
61
+ tenant: Optional[str] = None,
62
+ headers: Optional[dict[str, str]] = None,
63
+ ) -> dict[str, str]:
64
+ """Build API headers with bearer auth and optional tenant context."""
65
+ merged = dict(headers or {})
66
+ merged["Authorization"] = f"Bearer {token}"
67
+ if tenant:
68
+ merged["X-Tenant"] = tenant
69
+ return merged
70
+
71
+
72
+ def request_with_refresh(
73
+ method: str,
74
+ url: str,
75
+ *,
76
+ token: Optional[str] = None,
77
+ tenant: Optional[str] = None,
78
+ headers: Optional[dict[str, str]] = None,
79
+ allow_refresh: bool = True,
80
+ **kwargs: Any,
81
+ ) -> httpx.Response:
82
+ """Execute one authenticated request and retry once after a 401 refresh."""
83
+ current_token = token or require_access_token()
84
+ response = httpx.request(
85
+ method,
86
+ url,
87
+ headers=authenticated_headers(current_token, tenant=tenant, headers=headers),
88
+ **kwargs,
89
+ )
90
+ if response.status_code != 401 or not allow_refresh:
91
+ return response
92
+
93
+ current_token = refresh_current_session()
94
+ return httpx.request(
95
+ method,
96
+ url,
97
+ headers=authenticated_headers(current_token, tenant=tenant, headers=headers),
98
+ **kwargs,
99
+ )
100
+
101
+
102
+ async def async_request_with_refresh(
103
+ method: str,
104
+ url: str,
105
+ *,
106
+ token: Optional[str] = None,
107
+ tenant: Optional[str] = None,
108
+ headers: Optional[dict[str, str]] = None,
109
+ allow_refresh: bool = True,
110
+ **kwargs: Any,
111
+ ) -> httpx.Response:
112
+ """Execute one async authenticated request and retry once after 401 refresh."""
113
+ current_token = token or await async_require_access_token()
114
+ async with httpx.AsyncClient() as client:
115
+ response = await client.request(
116
+ method,
117
+ url,
118
+ headers=authenticated_headers(
119
+ current_token, tenant=tenant, headers=headers
120
+ ),
121
+ **kwargs,
122
+ )
123
+ if response.status_code != 401 or not allow_refresh:
124
+ return response
125
+
126
+ current_token = await async_refresh_current_session()
127
+ return await client.request(
128
+ method,
129
+ url,
130
+ headers=authenticated_headers(
131
+ current_token, tenant=tenant, headers=headers
132
+ ),
133
+ **kwargs,
134
+ )
lightnow_cli/client.py ADDED
@@ -0,0 +1,250 @@
1
+ """MCP Registry API client."""
2
+
3
+ from typing import Any, Dict, Optional, cast
4
+ from urllib.parse import quote
5
+
6
+ import httpx
7
+ from rich.console import Console
8
+
9
+ from . import __version__
10
+ from .authenticated_http import (
11
+ async_request_with_refresh,
12
+ authentication_error_from_response,
13
+ )
14
+ from .commands.auth import (
15
+ AccessTokenExpired,
16
+ AuthError,
17
+ )
18
+ from .config import config_manager
19
+
20
+ console = Console()
21
+
22
+
23
+ def validation_error_from_response(response: httpx.Response) -> ValueError:
24
+ """Build a validation error while preserving API details when present."""
25
+ try:
26
+ payload = response.json()
27
+ except ValueError:
28
+ return ValueError("Validation failed")
29
+ if not isinstance(payload, dict):
30
+ return ValueError("Validation failed")
31
+
32
+ detail = payload.get("detail")
33
+ errors = payload.get("errors")
34
+ messages: list[str] = []
35
+ if isinstance(errors, list):
36
+ for error in errors[:3]:
37
+ if not isinstance(error, dict):
38
+ continue
39
+ location = error.get("location")
40
+ message = error.get("message")
41
+ if isinstance(location, str) and isinstance(message, str):
42
+ messages.append(f"{location}: {message}")
43
+ elif isinstance(message, str):
44
+ messages.append(message)
45
+
46
+ if isinstance(detail, str) and detail:
47
+ if messages:
48
+ return ValueError(f"Validation failed: {detail} ({'; '.join(messages)})")
49
+ return ValueError(f"Validation failed: {detail}")
50
+
51
+ if messages:
52
+ return ValueError(f"Validation failed: {'; '.join(messages)}")
53
+ return ValueError("Validation failed")
54
+
55
+
56
+ class MCPRegistryClient:
57
+ """Client for interacting with MCP Registry API."""
58
+
59
+ def __init__(
60
+ self, base_url: Optional[str] = None, token: Optional[str] = None
61
+ ) -> None:
62
+ config = config_manager.load_config()
63
+ self.base_url = base_url or config.registry_api_url
64
+ self.token = token
65
+ self._refresh_on_unauthorized = token is None
66
+
67
+ if not self.base_url:
68
+ raise ValueError("Registry API URL not configured")
69
+
70
+ def _get_headers(self) -> Dict[str, str]:
71
+ """Get HTTP headers for API requests."""
72
+ headers = {
73
+ "Content-Type": "application/json",
74
+ "User-Agent": f"lightnow-cli/{__version__}",
75
+ }
76
+
77
+ if self.token:
78
+ headers["Authorization"] = f"Bearer {self.token}"
79
+
80
+ return headers
81
+
82
+ async def publish_server(
83
+ self,
84
+ server_json: Dict[str, Any],
85
+ docs_content: Optional[str] = None,
86
+ spec_content: Optional[str] = None,
87
+ tenant: Optional[str] = None,
88
+ ) -> Dict[str, Any]:
89
+ """Publish an MCP server to the registry."""
90
+ payload: Dict[str, Any] = dict(server_json)
91
+
92
+ if docs_content:
93
+ payload["_meta"] = {
94
+ **cast(Dict[str, Any], payload.get("_meta", {})),
95
+ "ai.lightnow/docs": docs_content,
96
+ }
97
+
98
+ if spec_content:
99
+ payload["_meta"] = {
100
+ **cast(Dict[str, Any], payload.get("_meta", {})),
101
+ "ai.lightnow/openapi": spec_content,
102
+ }
103
+
104
+ try:
105
+ response = await async_request_with_refresh(
106
+ "POST",
107
+ f"{self.base_url}/publish",
108
+ json=payload,
109
+ headers=self._get_headers(),
110
+ token=self.token,
111
+ tenant=tenant,
112
+ allow_refresh=self._refresh_on_unauthorized,
113
+ timeout=30.0,
114
+ )
115
+
116
+ if response.status_code == 401:
117
+ raise authentication_error_from_response(response)
118
+ elif response.status_code == 422:
119
+ raise validation_error_from_response(response)
120
+
121
+ response.raise_for_status()
122
+ return cast(Dict[str, Any], response.json())
123
+
124
+ except httpx.HTTPStatusError as e:
125
+ if e.response.status_code == 401:
126
+ raise authentication_error_from_response(e.response)
127
+ elif e.response.status_code == 422:
128
+ raise validation_error_from_response(e.response)
129
+ else:
130
+ raise ValueError(f"API request failed: {e.response.status_code}")
131
+ except httpx.RequestError as e:
132
+ raise ValueError(f"Network error: {e}")
133
+ except (AccessTokenExpired, AuthError) as e:
134
+ raise ValueError(str(e)) from e
135
+
136
+ async def list_servers(
137
+ self,
138
+ search: Optional[str] = None,
139
+ favorites: Optional[str] = None,
140
+ sort: Optional[str] = None,
141
+ tenant: Optional[str] = None,
142
+ limit: int = 10,
143
+ cursor: Optional[str] = None,
144
+ ) -> Dict[str, Any]:
145
+ """Query MCP servers from the registry."""
146
+ params: Dict[str, Any] = {"limit": limit}
147
+
148
+ if search:
149
+ params["search"] = search
150
+ if favorites:
151
+ params["favorites"] = favorites
152
+ if sort:
153
+ params["sort"] = sort
154
+ if tenant:
155
+ params["tenant"] = tenant
156
+ if cursor:
157
+ params["cursor"] = cursor
158
+
159
+ try:
160
+ response = await async_request_with_refresh(
161
+ "GET",
162
+ f"{self.base_url}/servers",
163
+ params=params,
164
+ headers=self._get_headers(),
165
+ token=self.token,
166
+ allow_refresh=self._refresh_on_unauthorized,
167
+ timeout=30.0,
168
+ )
169
+
170
+ if response.status_code == 401:
171
+ raise authentication_error_from_response(response)
172
+
173
+ response.raise_for_status()
174
+ return cast(Dict[str, Any], response.json())
175
+
176
+ except httpx.HTTPStatusError as e:
177
+ if e.response.status_code == 401:
178
+ raise authentication_error_from_response(e.response)
179
+ else:
180
+ raise ValueError(f"API request failed: {e.response.status_code}")
181
+ except httpx.RequestError as e:
182
+ raise ValueError(f"Network error: {e}")
183
+ except (AccessTokenExpired, AuthError) as e:
184
+ raise ValueError(str(e)) from e
185
+
186
+ async def get_server_info(
187
+ self,
188
+ server_id: str,
189
+ version: Optional[str] = None,
190
+ tenant: Optional[str] = None,
191
+ ) -> Dict[str, Any]:
192
+ """Get detailed information about a specific server."""
193
+ params: Dict[str, Any] = {}
194
+ if tenant:
195
+ params["tenant"] = tenant
196
+
197
+ encoded_server_id = quote(server_id, safe="")
198
+ if version:
199
+ encoded_version = quote(version, safe="")
200
+ url = f"{self.base_url}/servers/{encoded_server_id}/versions/{encoded_version}"
201
+ else:
202
+ url = f"{self.base_url}/servers/{encoded_server_id}/versions"
203
+ params["limit"] = 1
204
+
205
+ try:
206
+ response = await async_request_with_refresh(
207
+ "GET",
208
+ url,
209
+ params=params,
210
+ headers=self._get_headers(),
211
+ token=self.token,
212
+ allow_refresh=self._refresh_on_unauthorized,
213
+ timeout=30.0,
214
+ )
215
+
216
+ if response.status_code == 401:
217
+ raise authentication_error_from_response(response)
218
+ elif response.status_code == 404:
219
+ raise ValueError(f"Server '{server_id}' not found")
220
+
221
+ response.raise_for_status()
222
+ payload = cast(Dict[str, Any], response.json())
223
+ if version:
224
+ return payload
225
+
226
+ servers = payload.get("servers")
227
+ if not isinstance(servers, list) or not servers:
228
+ raise ValueError(f"Server '{server_id}' not found")
229
+ first = servers[0]
230
+ if not isinstance(first, dict):
231
+ raise ValueError("Registry API returned an invalid server version.")
232
+ return first
233
+
234
+ except httpx.HTTPStatusError as e:
235
+ if e.response.status_code == 401:
236
+ raise authentication_error_from_response(e.response)
237
+ elif e.response.status_code == 404:
238
+ raise ValueError(f"Server '{server_id}' not found")
239
+ else:
240
+ raise ValueError(f"API request failed: {e.response.status_code}")
241
+ except httpx.RequestError as e:
242
+ raise ValueError(f"Network error: {e}")
243
+ except (AccessTokenExpired, AuthError) as e:
244
+ raise ValueError(str(e)) from e
245
+
246
+
247
+ # Global client instance
248
+ def get_client() -> MCPRegistryClient:
249
+ """Get configured MCP Registry client."""
250
+ return MCPRegistryClient()
@@ -0,0 +1 @@
1
+ """Commands package."""