lightnow-cli 1.0.0__tar.gz → 1.0.2__tar.gz
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-1.0.0 → lightnow_cli-1.0.2}/PKG-INFO +3 -1
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/README.md +2 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/docs/release.md +2 -2
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/__init__.py +1 -1
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/client.py +2 -4
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/auth.py +26 -9
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/pyproject.toml +1 -1
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_auth_flow.py +20 -3
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_cli.py +98 -1
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_client.py +30 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_integrations.py +78 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/.github/workflows/ci.yml +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/.github/workflows/release.yml +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/.gitignore +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/CHANGELOG.md +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/CONTRIBUTING.md +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/LICENSE +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/Makefile +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/SECURITY.md +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/docs/development.md +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/examples/README.md +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/examples/docs.md +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/examples/openapi.json +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/examples/server.json +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/authenticated_http.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/__init__.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/integrations.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/publish.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/query.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/runner.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/commands/validate.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/config.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/main.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/tls.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/lightnow_cli/validation.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/__init__.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_config.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_tls.py +0 -0
- {lightnow_cli-1.0.0 → lightnow_cli-1.0.2}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightnow-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows
|
|
5
5
|
Project-URL: Homepage, https://github.com/lightnow-ai/lightnow-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/lightnow-ai/lightnow-cli
|
|
@@ -42,6 +42,8 @@ Description-Content-Type: text/markdown
|
|
|
42
42
|
|
|
43
43
|
# LightNow CLI
|
|
44
44
|
|
|
45
|
+
[](https://pypi.org/project/lightnow-cli/)
|
|
46
|
+
|
|
45
47
|
The **LightNow CLI** connects your local MCP clients with LightNow.
|
|
46
48
|
|
|
47
49
|
Use it to discover MCP servers, sync client configurations, and run
|
|
@@ -31,8 +31,8 @@ Before publishing a release:
|
|
|
31
31
|
- Environment: `pypi`
|
|
32
32
|
6. Create a signed or otherwise traceable Git tag:
|
|
33
33
|
```bash
|
|
34
|
-
git tag
|
|
35
|
-
git push origin
|
|
34
|
+
git tag vX.Y.Z
|
|
35
|
+
git push origin vX.Y.Z
|
|
36
36
|
```
|
|
37
37
|
7. Verify the GitHub Actions `Release` workflow:
|
|
38
38
|
- tests pass,
|
|
@@ -151,8 +151,6 @@ class MCPRegistryClient:
|
|
|
151
151
|
params["favorites"] = favorites
|
|
152
152
|
if sort:
|
|
153
153
|
params["sort"] = sort
|
|
154
|
-
if tenant:
|
|
155
|
-
params["tenant"] = tenant
|
|
156
154
|
if cursor:
|
|
157
155
|
params["cursor"] = cursor
|
|
158
156
|
|
|
@@ -163,6 +161,7 @@ class MCPRegistryClient:
|
|
|
163
161
|
params=params,
|
|
164
162
|
headers=self._get_headers(),
|
|
165
163
|
token=self.token,
|
|
164
|
+
tenant=tenant,
|
|
166
165
|
allow_refresh=self._refresh_on_unauthorized,
|
|
167
166
|
timeout=30.0,
|
|
168
167
|
)
|
|
@@ -191,8 +190,6 @@ class MCPRegistryClient:
|
|
|
191
190
|
) -> Dict[str, Any]:
|
|
192
191
|
"""Get detailed information about a specific server."""
|
|
193
192
|
params: Dict[str, Any] = {}
|
|
194
|
-
if tenant:
|
|
195
|
-
params["tenant"] = tenant
|
|
196
193
|
|
|
197
194
|
encoded_server_id = quote(server_id, safe="")
|
|
198
195
|
if version:
|
|
@@ -209,6 +206,7 @@ class MCPRegistryClient:
|
|
|
209
206
|
params=params,
|
|
210
207
|
headers=self._get_headers(),
|
|
211
208
|
token=self.token,
|
|
209
|
+
tenant=tenant,
|
|
212
210
|
allow_refresh=self._refresh_on_unauthorized,
|
|
213
211
|
timeout=30.0,
|
|
214
212
|
)
|
|
@@ -25,6 +25,7 @@ from ..config import (
|
|
|
25
25
|
console = Console()
|
|
26
26
|
app = typer.Typer(help="Authentication commands")
|
|
27
27
|
|
|
28
|
+
AUTH_HTTP_TIMEOUT = 30.0
|
|
28
29
|
ACCESS_TOKEN_EXPIRED_MESSAGE = (
|
|
29
30
|
"Access token has expired. Please run 'lightnow login' again."
|
|
30
31
|
)
|
|
@@ -41,6 +42,14 @@ class AccessTokenExpired(AuthError):
|
|
|
41
42
|
"""The stored access token is expired and cannot be refreshed."""
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def describe_http_error(error: httpx.HTTPError) -> str:
|
|
46
|
+
"""Return a useful message for httpx errors that may stringify empty."""
|
|
47
|
+
message = str(error).strip()
|
|
48
|
+
if message:
|
|
49
|
+
return message
|
|
50
|
+
return error.__class__.__name__
|
|
51
|
+
|
|
52
|
+
|
|
44
53
|
def open_authorization_url(verification_uri: str) -> bool:
|
|
45
54
|
"""Open the device authorization URL in the user's browser."""
|
|
46
55
|
return webbrowser.open(verification_uri, new=2, autoraise=True)
|
|
@@ -59,7 +68,7 @@ def pkce_challenge(verifier: str) -> str:
|
|
|
59
68
|
|
|
60
69
|
async def discover_oidc_endpoints(issuer: str) -> Dict[str, str]:
|
|
61
70
|
"""Discover the OIDC endpoints required by the CLI."""
|
|
62
|
-
async with httpx.AsyncClient() as client:
|
|
71
|
+
async with httpx.AsyncClient(timeout=AUTH_HTTP_TIMEOUT) as client:
|
|
63
72
|
try:
|
|
64
73
|
discovery_response = await client.get(
|
|
65
74
|
f"{issuer}/.well-known/openid-configuration"
|
|
@@ -69,7 +78,9 @@ async def discover_oidc_endpoints(issuer: str) -> Dict[str, str]:
|
|
|
69
78
|
if not isinstance(discovery, dict):
|
|
70
79
|
raise AuthError("OIDC discovery response is not a JSON object")
|
|
71
80
|
except httpx.HTTPError as e:
|
|
72
|
-
raise AuthError(
|
|
81
|
+
raise AuthError(
|
|
82
|
+
f"Failed to discover OIDC endpoints: {describe_http_error(e)}"
|
|
83
|
+
) from e
|
|
73
84
|
|
|
74
85
|
device_authorization_endpoint = discovery.get("device_authorization_endpoint")
|
|
75
86
|
token_endpoint = discovery.get("token_endpoint")
|
|
@@ -91,7 +102,7 @@ async def device_code_flow(issuer: str, client_id: str) -> Dict[str, Any]:
|
|
|
91
102
|
"""Perform OIDC Device Code Flow authentication."""
|
|
92
103
|
endpoints = await discover_oidc_endpoints(issuer)
|
|
93
104
|
code_verifier = generate_pkce_verifier()
|
|
94
|
-
async with httpx.AsyncClient() as client:
|
|
105
|
+
async with httpx.AsyncClient(timeout=AUTH_HTTP_TIMEOUT) as client:
|
|
95
106
|
# Start device authorization
|
|
96
107
|
try:
|
|
97
108
|
device_response = await client.post(
|
|
@@ -107,7 +118,9 @@ async def device_code_flow(issuer: str, client_id: str) -> Dict[str, Any]:
|
|
|
107
118
|
device_data = device_response.json()
|
|
108
119
|
|
|
109
120
|
except httpx.HTTPError as e:
|
|
110
|
-
raise AuthError(
|
|
121
|
+
raise AuthError(
|
|
122
|
+
f"Failed to start device authorization: {describe_http_error(e)}"
|
|
123
|
+
)
|
|
111
124
|
|
|
112
125
|
# Show user instructions
|
|
113
126
|
console.print("\n[bold blue]Device Authorization Required[/bold blue]")
|
|
@@ -165,7 +178,7 @@ async def device_code_flow(issuer: str, client_id: str) -> Dict[str, Any]:
|
|
|
165
178
|
raise AuthError(f"Authorization failed: {error}")
|
|
166
179
|
|
|
167
180
|
except httpx.HTTPError as e:
|
|
168
|
-
raise AuthError(f"Failed to poll for token: {e}")
|
|
181
|
+
raise AuthError(f"Failed to poll for token: {describe_http_error(e)}")
|
|
169
182
|
|
|
170
183
|
raise AuthError("Authorization timed out")
|
|
171
184
|
|
|
@@ -175,7 +188,7 @@ async def refresh_access_token(
|
|
|
175
188
|
) -> Dict[str, Any]:
|
|
176
189
|
"""Refresh the access token using the stored refresh token."""
|
|
177
190
|
endpoints = await discover_oidc_endpoints(issuer)
|
|
178
|
-
async with httpx.AsyncClient() as client:
|
|
191
|
+
async with httpx.AsyncClient(timeout=AUTH_HTTP_TIMEOUT) as client:
|
|
179
192
|
try:
|
|
180
193
|
token_response = await client.post(
|
|
181
194
|
endpoints["token_endpoint"],
|
|
@@ -186,7 +199,9 @@ async def refresh_access_token(
|
|
|
186
199
|
},
|
|
187
200
|
)
|
|
188
201
|
except httpx.HTTPError as e:
|
|
189
|
-
raise AuthError(
|
|
202
|
+
raise AuthError(
|
|
203
|
+
f"Failed to refresh access token: {describe_http_error(e)}"
|
|
204
|
+
) from e
|
|
190
205
|
|
|
191
206
|
if token_response.status_code == 200:
|
|
192
207
|
token_data = token_response.json()
|
|
@@ -239,14 +254,16 @@ async def fetch_user_info(issuer: str, token: str) -> Dict[str, Any]:
|
|
|
239
254
|
if not userinfo_endpoint:
|
|
240
255
|
raise AuthError("OIDC provider does not expose a userinfo endpoint.")
|
|
241
256
|
|
|
242
|
-
async with httpx.AsyncClient() as client:
|
|
257
|
+
async with httpx.AsyncClient(timeout=AUTH_HTTP_TIMEOUT) as client:
|
|
243
258
|
try:
|
|
244
259
|
response = await client.get(
|
|
245
260
|
userinfo_endpoint,
|
|
246
261
|
headers={"Authorization": f"Bearer {token}"},
|
|
247
262
|
)
|
|
248
263
|
except httpx.HTTPError as exc:
|
|
249
|
-
raise AuthError(
|
|
264
|
+
raise AuthError(
|
|
265
|
+
f"Failed to fetch user information: {describe_http_error(exc)}"
|
|
266
|
+
) from exc
|
|
250
267
|
|
|
251
268
|
if response.status_code == 401:
|
|
252
269
|
raise AccessTokenExpired(ACCESS_TOKEN_EXPIRED_MESSAGE)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lightnow-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.2"
|
|
8
8
|
description = "Official LightNow CLI for MCP registry discovery, client config sync, and local MCP runner workflows"
|
|
9
9
|
authors = [{name = "LightNow Team"}]
|
|
10
10
|
license = {text = "Apache-2.0"}
|
|
@@ -26,7 +26,8 @@ class FakeResponse:
|
|
|
26
26
|
class FakeDeviceClient:
|
|
27
27
|
"""AsyncClient stub that completes device authorization after one poll."""
|
|
28
28
|
|
|
29
|
-
def __init__(self) -> None:
|
|
29
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
30
|
+
assert kwargs.get("timeout") == auth.AUTH_HTTP_TIMEOUT
|
|
30
31
|
self.polls = 0
|
|
31
32
|
|
|
32
33
|
async def __aenter__(self) -> "FakeDeviceClient":
|
|
@@ -103,6 +104,11 @@ async def test_device_code_flow_opens_browser_and_returns_token(monkeypatch) ->
|
|
|
103
104
|
assert opened == ["https://auth.lightnow.local/device?user_code=TEST"]
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
def test_describe_http_error_names_empty_errors() -> None:
|
|
108
|
+
"""HTTP errors with empty string output still produce useful messages."""
|
|
109
|
+
assert auth.describe_http_error(auth.httpx.ReadTimeout("")) == "ReadTimeout"
|
|
110
|
+
|
|
111
|
+
|
|
106
112
|
def test_get_user_info_decodes_unverified_jwt_claims_for_refresh_hints() -> None:
|
|
107
113
|
"""The CLI can inspect local JWT metadata without treating it as identity."""
|
|
108
114
|
token = (
|
|
@@ -135,6 +141,9 @@ async def test_fetch_user_info_uses_oidc_userinfo_endpoint(monkeypatch) -> None:
|
|
|
135
141
|
"""Identity is fetched from the issuer instead of trusting local JWT claims."""
|
|
136
142
|
|
|
137
143
|
class FakeUserInfoClient:
|
|
144
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
145
|
+
assert kwargs.get("timeout") == auth.AUTH_HTTP_TIMEOUT
|
|
146
|
+
|
|
138
147
|
async def __aenter__(self) -> "FakeUserInfoClient":
|
|
139
148
|
return self
|
|
140
149
|
|
|
@@ -209,7 +218,11 @@ async def test_refresh_access_token_returns_new_tokens(monkeypatch) -> None:
|
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
monkeypatch.setattr(auth, "discover_oidc_endpoints", endpoints)
|
|
212
|
-
monkeypatch.setattr(
|
|
221
|
+
monkeypatch.setattr(
|
|
222
|
+
auth.httpx,
|
|
223
|
+
"AsyncClient",
|
|
224
|
+
lambda *args, **kwargs: fake,
|
|
225
|
+
)
|
|
213
226
|
|
|
214
227
|
token_data = await auth.refresh_access_token(
|
|
215
228
|
"https://auth.lightnow.local/realms/lightnow",
|
|
@@ -234,7 +247,11 @@ async def test_refresh_access_token_invalid_grant_is_expired(monkeypatch) -> Non
|
|
|
234
247
|
}
|
|
235
248
|
|
|
236
249
|
monkeypatch.setattr(auth, "discover_oidc_endpoints", endpoints)
|
|
237
|
-
monkeypatch.setattr(
|
|
250
|
+
monkeypatch.setattr(
|
|
251
|
+
auth.httpx,
|
|
252
|
+
"AsyncClient",
|
|
253
|
+
lambda *args, **kwargs: fake,
|
|
254
|
+
)
|
|
238
255
|
|
|
239
256
|
with pytest.raises(auth.AccessTokenExpired, match="Access token has expired"):
|
|
240
257
|
await auth.refresh_access_token(
|
|
@@ -51,7 +51,7 @@ def test_cli_version(runner):
|
|
|
51
51
|
"""Test CLI version command."""
|
|
52
52
|
result = runner.invoke(app, ["--version"])
|
|
53
53
|
assert result.exit_code == 0
|
|
54
|
-
assert "LightNow CLI 1.0.
|
|
54
|
+
assert "LightNow CLI 1.0.2" in result.stdout
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def test_cli_help(runner):
|
|
@@ -175,6 +175,103 @@ def test_run_command_starts_profile_server(runner):
|
|
|
175
175
|
assert run_process.call_args.kwargs["env"]["SONARQUBE_TOKEN"] == "secret"
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
def test_run_command_passes_tenant_to_profile_and_context_requests(runner):
|
|
179
|
+
"""The local runner keeps organization context for lookup and secret context."""
|
|
180
|
+
with (
|
|
181
|
+
patch(
|
|
182
|
+
"lightnow_cli.commands.runner.require_access_token",
|
|
183
|
+
return_value="token",
|
|
184
|
+
),
|
|
185
|
+
patch(
|
|
186
|
+
"lightnow_cli.commands.runner.resolve_profile_server",
|
|
187
|
+
return_value={
|
|
188
|
+
"alias": "sonarqube",
|
|
189
|
+
"server_name": "io.github.sonarsource/sonarqube-mcp-server",
|
|
190
|
+
"version": "1.2.3",
|
|
191
|
+
"status": "linked",
|
|
192
|
+
},
|
|
193
|
+
) as resolve,
|
|
194
|
+
patch(
|
|
195
|
+
"lightnow_cli.commands.runner.fetch_runtime_context",
|
|
196
|
+
return_value={"probe_request": {"transport": "stdio", "stdio": {}}},
|
|
197
|
+
) as fetch_context,
|
|
198
|
+
patch(
|
|
199
|
+
"lightnow_cli.commands.runner.launch_config_from_context",
|
|
200
|
+
return_value=("python", ["-m", "server"], {}, None),
|
|
201
|
+
),
|
|
202
|
+
patch(
|
|
203
|
+
"lightnow_cli.commands.runner.subprocess.run",
|
|
204
|
+
return_value=MagicMock(returncode=0),
|
|
205
|
+
),
|
|
206
|
+
):
|
|
207
|
+
result = runner.invoke(
|
|
208
|
+
app,
|
|
209
|
+
[
|
|
210
|
+
"run",
|
|
211
|
+
"--server",
|
|
212
|
+
"sonarqube",
|
|
213
|
+
"--profile",
|
|
214
|
+
"default",
|
|
215
|
+
"--tenant",
|
|
216
|
+
"acme",
|
|
217
|
+
"--api-url",
|
|
218
|
+
"https://registry-api.lightnow.local/v0.1",
|
|
219
|
+
],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
assert result.exit_code == 0
|
|
223
|
+
assert resolve.call_args.kwargs["tenant"] == "acme"
|
|
224
|
+
assert fetch_context.call_args.kwargs["tenant"] == "acme"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_search_command_passes_tenant_context(runner):
|
|
228
|
+
"""Search supports organization context from the public CLI surface."""
|
|
229
|
+
fake_client = MagicMock()
|
|
230
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
231
|
+
|
|
232
|
+
with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
|
|
233
|
+
result = runner.invoke(
|
|
234
|
+
app, ["search", "redis", "--tenant", "acme", "--limit", "5"]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert result.exit_code == 0
|
|
238
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_favorites_command_passes_tenant_context(runner):
|
|
242
|
+
"""Favorites supports organization-scoped favorites from the CLI."""
|
|
243
|
+
fake_client = MagicMock()
|
|
244
|
+
fake_client.list_servers = AsyncMock(return_value={"servers": []})
|
|
245
|
+
|
|
246
|
+
with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
|
|
247
|
+
result = runner.invoke(
|
|
248
|
+
app, ["favorites", "--scope", "tenant", "--tenant", "acme"]
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
assert result.exit_code == 0
|
|
252
|
+
assert fake_client.list_servers.call_args.kwargs["favorites"] == "tenant"
|
|
253
|
+
assert fake_client.list_servers.call_args.kwargs["tenant"] == "acme"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_info_command_passes_tenant_context(runner):
|
|
257
|
+
"""Server info supports organization context from the public CLI surface."""
|
|
258
|
+
fake_client = MagicMock()
|
|
259
|
+
fake_client.get_server_info = AsyncMock(
|
|
260
|
+
return_value={
|
|
261
|
+
"name": "io.github.test/server",
|
|
262
|
+
"version": "1.0.0",
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
with patch("lightnow_cli.commands.query.get_client", return_value=fake_client):
|
|
267
|
+
result = runner.invoke(
|
|
268
|
+
app, ["info", "io.github.test/server", "--tenant", "acme"]
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
assert result.exit_code == 0
|
|
272
|
+
assert fake_client.get_server_info.call_args.kwargs["tenant"] == "acme"
|
|
273
|
+
|
|
274
|
+
|
|
178
275
|
def test_run_command_reports_expired_token(runner):
|
|
179
276
|
"""Runner commands use the shared expired-token message."""
|
|
180
277
|
from lightnow_cli.commands.auth import (
|
|
@@ -221,6 +221,21 @@ async def test_client_lists_servers(monkeypatch) -> None:
|
|
|
221
221
|
assert fake.last_params["cursor"] == "cursor-token"
|
|
222
222
|
|
|
223
223
|
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_client_lists_servers_with_tenant_header(monkeypatch) -> None:
|
|
226
|
+
"""Tenant context for list queries is sent as X-Tenant, not as a query param."""
|
|
227
|
+
fake = FakeHttpClient()
|
|
228
|
+
monkeypatch.setattr("lightnow_cli.client.httpx.AsyncClient", lambda: fake)
|
|
229
|
+
|
|
230
|
+
client = MCPRegistryClient(
|
|
231
|
+
base_url="https://registry-api.lightnow.local/v0.1", token="token"
|
|
232
|
+
)
|
|
233
|
+
await client.list_servers(favorites="tenant", tenant="tenant-uuid")
|
|
234
|
+
|
|
235
|
+
assert fake.last_headers["X-Tenant"] == "tenant-uuid"
|
|
236
|
+
assert "tenant" not in fake.last_params
|
|
237
|
+
|
|
238
|
+
|
|
224
239
|
@pytest.mark.asyncio
|
|
225
240
|
async def test_client_gets_server_info(monkeypatch) -> None:
|
|
226
241
|
"""Server info lookup returns the decoded registry payload."""
|
|
@@ -236,6 +251,21 @@ async def test_client_gets_server_info(monkeypatch) -> None:
|
|
|
236
251
|
assert fake.last_url.endswith("/servers/github/versions/1.0.0")
|
|
237
252
|
|
|
238
253
|
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_client_gets_server_info_with_tenant_header(monkeypatch) -> None:
|
|
256
|
+
"""Tenant context for server detail lookup is sent as X-Tenant."""
|
|
257
|
+
fake = FakeHttpClient()
|
|
258
|
+
monkeypatch.setattr("lightnow_cli.client.httpx.AsyncClient", lambda: fake)
|
|
259
|
+
|
|
260
|
+
client = MCPRegistryClient(
|
|
261
|
+
base_url="https://registry-api.lightnow.local/v0.1", token="token"
|
|
262
|
+
)
|
|
263
|
+
await client.get_server_info("github", version="1.0.0", tenant="tenant-uuid")
|
|
264
|
+
|
|
265
|
+
assert fake.last_headers["X-Tenant"] == "tenant-uuid"
|
|
266
|
+
assert "tenant" not in fake.last_params
|
|
267
|
+
|
|
268
|
+
|
|
239
269
|
@pytest.mark.asyncio
|
|
240
270
|
async def test_client_encodes_registry_server_names(monkeypatch) -> None:
|
|
241
271
|
"""Registry server names with slashes are encoded in path parameters."""
|
|
@@ -173,6 +173,39 @@ def test_sync_defaults_to_plaintext_export_mode() -> None:
|
|
|
173
173
|
assert fetch.call_args.kwargs["secret_mode"] == "plaintext"
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
def test_sync_passes_tenant_to_export_request() -> None:
|
|
177
|
+
"""Client export sync uses the selected organization context."""
|
|
178
|
+
runner = CliRunner()
|
|
179
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
180
|
+
target = Path(tmp) / "config.toml"
|
|
181
|
+
with (
|
|
182
|
+
patch(
|
|
183
|
+
"lightnow_cli.commands.integrations.require_access_token",
|
|
184
|
+
return_value="token",
|
|
185
|
+
),
|
|
186
|
+
patch(
|
|
187
|
+
"lightnow_cli.commands.integrations.fetch_export",
|
|
188
|
+
return_value='[mcp_servers.github]\ncommand = "docker"\n',
|
|
189
|
+
) as fetch,
|
|
190
|
+
):
|
|
191
|
+
result = runner.invoke(
|
|
192
|
+
app,
|
|
193
|
+
[
|
|
194
|
+
"sync",
|
|
195
|
+
"--client",
|
|
196
|
+
"codex",
|
|
197
|
+
"--tenant",
|
|
198
|
+
"acme",
|
|
199
|
+
"--config-path",
|
|
200
|
+
str(target),
|
|
201
|
+
],
|
|
202
|
+
input="y\n",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert result.exit_code == 0
|
|
206
|
+
assert fetch.call_args.kwargs["tenant"] == "acme"
|
|
207
|
+
|
|
208
|
+
|
|
176
209
|
def test_sync_json_writes_manifest_and_preserves_user_config() -> None:
|
|
177
210
|
"""JSON sync stores the LightNow-owned aliases in a sibling manifest."""
|
|
178
211
|
runner = CliRunner()
|
|
@@ -509,6 +542,51 @@ def test_sync_runner_dry_run_does_not_fetch_plaintext_export() -> None:
|
|
|
509
542
|
assert not target.exists()
|
|
510
543
|
|
|
511
544
|
|
|
545
|
+
def test_sync_runner_passes_tenant_to_profile_server_lookup() -> None:
|
|
546
|
+
"""Runner sync builds organization-aware client commands from tenant profiles."""
|
|
547
|
+
runner = CliRunner()
|
|
548
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
549
|
+
target = Path(tmp) / "config.toml"
|
|
550
|
+
with (
|
|
551
|
+
patch(
|
|
552
|
+
"lightnow_cli.commands.integrations.require_access_token",
|
|
553
|
+
return_value="token",
|
|
554
|
+
),
|
|
555
|
+
patch(
|
|
556
|
+
"lightnow_cli.commands.integrations.fetch_profile_servers",
|
|
557
|
+
return_value={
|
|
558
|
+
"servers": [
|
|
559
|
+
{
|
|
560
|
+
"alias": "sonarqube",
|
|
561
|
+
"server_name": "io.github.sonarsource/sonarqube-mcp-server",
|
|
562
|
+
"version": "1.2.3",
|
|
563
|
+
"status": "linked",
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
},
|
|
567
|
+
) as fetch_profile,
|
|
568
|
+
):
|
|
569
|
+
result = runner.invoke(
|
|
570
|
+
app,
|
|
571
|
+
[
|
|
572
|
+
"sync",
|
|
573
|
+
"--client",
|
|
574
|
+
"codex",
|
|
575
|
+
"--tenant",
|
|
576
|
+
"acme",
|
|
577
|
+
"--runner",
|
|
578
|
+
"--config-path",
|
|
579
|
+
str(target),
|
|
580
|
+
"--dry-run",
|
|
581
|
+
],
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
assert result.exit_code == 0
|
|
585
|
+
assert fetch_profile.call_args.kwargs["tenant"] == "acme"
|
|
586
|
+
assert "--tenant" in result.stdout
|
|
587
|
+
assert "acme" in result.stdout
|
|
588
|
+
|
|
589
|
+
|
|
512
590
|
def test_fetch_runtime_context_requests_local_runner_secret_context() -> None:
|
|
513
591
|
"""The runner asks for secret context with consumer=local-runner."""
|
|
514
592
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|