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.
- lightnow_cli/__init__.py +3 -0
- lightnow_cli/authenticated_http.py +134 -0
- lightnow_cli/client.py +250 -0
- lightnow_cli/commands/__init__.py +1 -0
- lightnow_cli/commands/auth.py +500 -0
- lightnow_cli/commands/integrations.py +658 -0
- lightnow_cli/commands/publish.py +111 -0
- lightnow_cli/commands/query.py +268 -0
- lightnow_cli/commands/runner.py +399 -0
- lightnow_cli/commands/validate.py +78 -0
- lightnow_cli/config.py +152 -0
- lightnow_cli/main.py +54 -0
- lightnow_cli/tls.py +8 -0
- lightnow_cli/validation.py +238 -0
- lightnow_cli-1.0.0.dist-info/METADATA +143 -0
- lightnow_cli-1.0.0.dist-info/RECORD +19 -0
- lightnow_cli-1.0.0.dist-info/WHEEL +4 -0
- lightnow_cli-1.0.0.dist-info/entry_points.txt +2 -0
- lightnow_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
lightnow_cli/__init__.py
ADDED
|
@@ -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."""
|