workspaces-euc-mcp-server 0.1.7__tar.gz → 0.1.8__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.
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/CHANGELOG.md +10 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/DESIGN.md +5 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/PKG-INFO +10 -1
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/README.md +9 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/pyproject.toml +1 -1
- workspaces_euc_mcp_server-0.1.8/tests/test_sso.py +102 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/__init__.py +1 -1
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/server.py +24 -0
- workspaces_euc_mcp_server-0.1.8/workspaces_euc_mcp_server/sso.py +104 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/_common.py +32 -1
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.dockerignore +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/ci.yml +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/docker-publish.yml +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/publish.yml +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.gitignore +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.pre-commit-config.yaml +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/Dockerfile +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/LICENSE +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/README.md +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier0-diagnostics.json +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier1-cost.json +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier2-lifecycle.json +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier3-destructive.json +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/scripts/smoke_readonly.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/__init__.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_clients.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_cost.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_destructive.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_diagnostics.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_governance.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_images.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_inventory.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_lifecycle.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_naming.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_no_embedded_secrets.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_performance.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_pricing.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_reporting.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_secure_browser.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/clients.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/consts.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/models.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/cost.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/diagnostics.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/governance.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/images.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/performance.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
- {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/secure_browser.py +0 -0
|
@@ -5,6 +5,16 @@ All notable changes to this project are documented here. The format is based on
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.1.8] - 2026-06-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`--sso-auto-login`** (opt-in; also `WORKSPACES_EUC_SSO_AUTO_LOGIN=1`): when an AWS call fails
|
|
12
|
+
with an expired SSO token, the server automatically runs `aws sso login` — opening the browser to
|
|
13
|
+
the approval screen — so the user re-authenticates without opening a terminal. Debounced so a
|
|
14
|
+
burst of failing calls opens sign-in only once. Off by default; never stores credentials (it only
|
|
15
|
+
invokes the AWS CLI). Expired-token errors also now carry a clearer hint, including that signing
|
|
16
|
+
into the AWS Console does **not** refresh the CLI/SSO token.
|
|
17
|
+
|
|
8
18
|
## [0.1.7] - 2026-06-02
|
|
9
19
|
|
|
10
20
|
### Fixed
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
consts.py # service/API constants, region→pricing-location maps
|
|
66
66
|
models.py # Pydantic request/response models
|
|
67
67
|
clients.py # boto3 client factory (region/profile/assume-role aware)
|
|
68
|
+
sso.py # opt-in SSO auto-login (launches `aws sso login` on token expiry)
|
|
68
69
|
tools/
|
|
69
70
|
_common.py # read_only/writes annotation helpers, try_call, paginate
|
|
70
71
|
inventory.py
|
|
@@ -93,6 +94,10 @@
|
|
|
93
94
|
- `--enable-writes`: registers Phase-2 lifecycle tools (still dry-run/confirm gated).
|
|
94
95
|
- `--enable-destructive`: separately gates terminate/rebuild/restore.
|
|
95
96
|
- `--max-bulk-targets N`: blast-radius cap for any bulk mutation.
|
|
97
|
+
- `--sso-auto-login` (default **off**): on an expired-SSO-token error, launch `aws sso login`
|
|
98
|
+
(opens the browser) so the user needn't use a terminal. Opt-in; never stores credentials — it
|
|
99
|
+
only invokes the AWS CLI, which writes the standard token cache. Debounced so a burst of failing
|
|
100
|
+
calls opens the browser once.
|
|
96
101
|
- `AWS_REGION` / `AWS_PROFILE`: standard.
|
|
97
102
|
|
|
98
103
|
## 5. Tool inventory
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workspaces-euc-mcp-server
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: MCP server for administering the Amazon WorkSpaces family of End User Computing services (Personal, Pools, Applications, Secure Browser, Core).
|
|
5
5
|
Project-URL: Homepage, https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp
|
|
@@ -264,6 +264,14 @@ If calls fail with an expired-token / `UnauthorizedException` error, your sessio
|
|
|
264
264
|
re-authenticate (for SSO, `aws sso login --profile <name>`) and retry. Nothing in the MCP config
|
|
265
265
|
changes.
|
|
266
266
|
|
|
267
|
+
> **Tip — auto re-login (no terminal):** launch the server with **`--sso-auto-login`** (or set
|
|
268
|
+
> `WORKSPACES_EUC_SSO_AUTO_LOGIN=1`). When an AWS call then fails with an expired SSO token, the
|
|
269
|
+
> server **automatically runs `aws sso login` for you** — opening your browser to the approval
|
|
270
|
+
> screen — so you just click *Allow* and re-ask, without ever opening a terminal. Off by default;
|
|
271
|
+
> the browser approval itself is still required (inherent to SSO), and the server still never
|
|
272
|
+
> stores credentials (it just invokes the AWS CLI). Note: signing into the AWS **Console** does
|
|
273
|
+
> *not* refresh the CLI/SSO token — only `aws sso login` does.
|
|
274
|
+
|
|
267
275
|
## Enabling write / destructive tools — the safety gates
|
|
268
276
|
|
|
269
277
|
The config above is **read-only**: the write and destructive tools are not even registered, so
|
|
@@ -359,6 +367,7 @@ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-target
|
|
|
359
367
|
| `--enable-writes` | off | Register Phase 2 lifecycle (write) tools. |
|
|
360
368
|
| `--enable-destructive` | off | Allow terminate/rebuild/restore (requires `--enable-writes`). |
|
|
361
369
|
| `--max-bulk-targets` | 25 | Blast-radius cap for bulk mutations (Phase 2). |
|
|
370
|
+
| `--sso-auto-login` | off | On an expired SSO token, auto-launch `aws sso login` (opens your browser) instead of requiring a manual terminal command. Also via `WORKSPACES_EUC_SSO_AUTO_LOGIN=1`. |
|
|
362
371
|
|
|
363
372
|
The server starts **read-only**; mutating tools require both the launch flag **and** the matching
|
|
364
373
|
IAM tier.
|
|
@@ -234,6 +234,14 @@ If calls fail with an expired-token / `UnauthorizedException` error, your sessio
|
|
|
234
234
|
re-authenticate (for SSO, `aws sso login --profile <name>`) and retry. Nothing in the MCP config
|
|
235
235
|
changes.
|
|
236
236
|
|
|
237
|
+
> **Tip — auto re-login (no terminal):** launch the server with **`--sso-auto-login`** (or set
|
|
238
|
+
> `WORKSPACES_EUC_SSO_AUTO_LOGIN=1`). When an AWS call then fails with an expired SSO token, the
|
|
239
|
+
> server **automatically runs `aws sso login` for you** — opening your browser to the approval
|
|
240
|
+
> screen — so you just click *Allow* and re-ask, without ever opening a terminal. Off by default;
|
|
241
|
+
> the browser approval itself is still required (inherent to SSO), and the server still never
|
|
242
|
+
> stores credentials (it just invokes the AWS CLI). Note: signing into the AWS **Console** does
|
|
243
|
+
> *not* refresh the CLI/SSO token — only `aws sso login` does.
|
|
244
|
+
|
|
237
245
|
## Enabling write / destructive tools — the safety gates
|
|
238
246
|
|
|
239
247
|
The config above is **read-only**: the write and destructive tools are not even registered, so
|
|
@@ -329,6 +337,7 @@ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-target
|
|
|
329
337
|
| `--enable-writes` | off | Register Phase 2 lifecycle (write) tools. |
|
|
330
338
|
| `--enable-destructive` | off | Allow terminate/rebuild/restore (requires `--enable-writes`). |
|
|
331
339
|
| `--max-bulk-targets` | 25 | Blast-radius cap for bulk mutations (Phase 2). |
|
|
340
|
+
| `--sso-auto-login` | off | On an expired SSO token, auto-launch `aws sso login` (opens your browser) instead of requiring a manual terminal command. Also via `WORKSPACES_EUC_SSO_AUTO_LOGIN=1`. |
|
|
332
341
|
|
|
333
342
|
The server starts **read-only**; mutating tools require both the launch flag **and** the matching
|
|
334
343
|
IAM tier.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "workspaces-euc-mcp-server"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.8"
|
|
4
4
|
description = "MCP server for administering the Amazon WorkSpaces family of End User Computing services (Personal, Pools, Applications, Secure Browser, Core)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
|
|
2
|
+
# You may not use this file except in compliance with the License.
|
|
3
|
+
# A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
|
|
4
|
+
"""Tests for opt-in SSO auto-login and its integration with try_call."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from botocore.exceptions import BotoCoreError
|
|
9
|
+
|
|
10
|
+
from workspaces_euc_mcp_server.models import ServiceError
|
|
11
|
+
from workspaces_euc_mcp_server.sso import SsoAutoLogin, looks_like_sso_token_error
|
|
12
|
+
from workspaces_euc_mcp_server.tools import _common
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Clock:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.t = 1000.0
|
|
18
|
+
|
|
19
|
+
def __call__(self) -> float:
|
|
20
|
+
return self.t
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_detects_sso_token_errors():
|
|
24
|
+
assert looks_like_sso_token_error(Exception("Token has expired and refresh failed"))
|
|
25
|
+
assert looks_like_sso_token_error(Exception("Error when retrieving token from sso: ..."))
|
|
26
|
+
assert not looks_like_sso_token_error(Exception("AccessDenied"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_disabled_handler_never_logs_in():
|
|
30
|
+
calls: list[str | None] = []
|
|
31
|
+
h = SsoAutoLogin(enabled=False, runner=lambda p: calls.append(p) or "ran")
|
|
32
|
+
assert h.maybe_login() is None
|
|
33
|
+
assert calls == []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_enabled_handler_debounces_burst_then_allows_after_cooldown():
|
|
37
|
+
clock = _Clock()
|
|
38
|
+
calls: list[str | None] = []
|
|
39
|
+
h = SsoAutoLogin(
|
|
40
|
+
enabled=True,
|
|
41
|
+
profile="ben-euc",
|
|
42
|
+
cooldown_seconds=60.0,
|
|
43
|
+
runner=lambda p: calls.append(p) or "opened browser",
|
|
44
|
+
clock=clock,
|
|
45
|
+
)
|
|
46
|
+
# First failure triggers a login...
|
|
47
|
+
assert h.maybe_login() == "opened browser"
|
|
48
|
+
# ...a burst within the cooldown does NOT open another browser.
|
|
49
|
+
assert h.maybe_login() is None
|
|
50
|
+
assert h.maybe_login() is None
|
|
51
|
+
# ...but after the cooldown elapses it triggers again.
|
|
52
|
+
clock.t += 61.0
|
|
53
|
+
assert h.maybe_login() == "opened browser"
|
|
54
|
+
assert calls == ["ben-euc", "ben-euc"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_try_call_triggers_auto_login_on_token_error():
|
|
58
|
+
triggered: list[str | None] = []
|
|
59
|
+
handler = SsoAutoLogin(
|
|
60
|
+
enabled=True, profile="p", runner=lambda p: triggered.append(p) or "opened sign-in"
|
|
61
|
+
)
|
|
62
|
+
_common.register_sso_handler(handler)
|
|
63
|
+
try:
|
|
64
|
+
errors: list[ServiceError] = []
|
|
65
|
+
|
|
66
|
+
def boom():
|
|
67
|
+
raise BotoCoreError(error="Token has expired and refresh failed")
|
|
68
|
+
|
|
69
|
+
# BotoCoreError formats its message from the template; force a token-like message.
|
|
70
|
+
def boom2():
|
|
71
|
+
raise _FakeTokenError("Token has expired and refresh failed")
|
|
72
|
+
|
|
73
|
+
_common.try_call(errors, "svc", "op", boom2, default=None)
|
|
74
|
+
|
|
75
|
+
assert triggered == ["p"] # browser sign-in launched
|
|
76
|
+
assert len(errors) == 1
|
|
77
|
+
assert "SSO session expired" in errors[0].message
|
|
78
|
+
assert "opened sign-in" in errors[0].message
|
|
79
|
+
finally:
|
|
80
|
+
_common.register_sso_handler(None)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_try_call_hint_when_auto_login_disabled():
|
|
84
|
+
_common.register_sso_handler(None)
|
|
85
|
+
errors: list[ServiceError] = []
|
|
86
|
+
|
|
87
|
+
def boom():
|
|
88
|
+
raise _FakeTokenError("the sso session associated with this profile has expired")
|
|
89
|
+
|
|
90
|
+
_common.try_call(errors, "svc", "op", boom, default=None)
|
|
91
|
+
assert "aws sso login" in errors[0].message
|
|
92
|
+
assert "Console does NOT refresh" in errors[0].message
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _FakeTokenError(BotoCoreError):
|
|
96
|
+
"""A BotoCoreError subclass whose str() is a controllable token-error message."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, message: str) -> None:
|
|
99
|
+
self._message = message
|
|
100
|
+
|
|
101
|
+
def __str__(self) -> str:
|
|
102
|
+
return self._message
|
|
@@ -14,7 +14,9 @@ from mcp.server.fastmcp import FastMCP
|
|
|
14
14
|
|
|
15
15
|
from . import consts
|
|
16
16
|
from .clients import ClientFactory
|
|
17
|
+
from .sso import SsoAutoLogin
|
|
17
18
|
from .tools import (
|
|
19
|
+
_common,
|
|
18
20
|
cost,
|
|
19
21
|
destructive,
|
|
20
22
|
diagnostics,
|
|
@@ -37,11 +39,21 @@ def create_server(
|
|
|
37
39
|
enable_writes: bool = False,
|
|
38
40
|
enable_destructive: bool = False,
|
|
39
41
|
max_bulk_targets: int = consts.DEFAULT_MAX_BULK_TARGETS,
|
|
42
|
+
sso_auto_login: bool = False,
|
|
40
43
|
) -> FastMCP:
|
|
41
44
|
"""Build the FastMCP server, registering tools according to the safety flags."""
|
|
42
45
|
factory = ClientFactory(
|
|
43
46
|
region=region, profile=profile, role_arn=role_arn, external_id=external_id
|
|
44
47
|
)
|
|
48
|
+
|
|
49
|
+
# Opt-in: when an AWS call fails with an expired SSO token, auto-launch `aws sso login`
|
|
50
|
+
# (opens the browser) so the user never has to use a terminal. No-op unless enabled.
|
|
51
|
+
_common.register_sso_handler(
|
|
52
|
+
SsoAutoLogin(profile=profile, enabled=sso_auto_login) if sso_auto_login else None
|
|
53
|
+
)
|
|
54
|
+
if sso_auto_login:
|
|
55
|
+
logger.info("SSO auto-login enabled: expired tokens will trigger `aws sso login`.")
|
|
56
|
+
|
|
45
57
|
mcp = FastMCP(consts.SERVER_NAME, instructions=consts.SERVER_INSTRUCTIONS)
|
|
46
58
|
|
|
47
59
|
# Phase 1 read-only tools are always registered.
|
|
@@ -109,11 +121,22 @@ def main() -> None:
|
|
|
109
121
|
default=consts.DEFAULT_MAX_BULK_TARGETS,
|
|
110
122
|
help="Blast-radius cap for bulk mutations (Phase 2).",
|
|
111
123
|
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--sso-auto-login",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="When an AWS call fails with an expired SSO token, auto-launch `aws sso login` "
|
|
128
|
+
"(opens your browser) instead of requiring a manual terminal command. Off by default; "
|
|
129
|
+
"can also be enabled with WORKSPACES_EUC_SSO_AUTO_LOGIN=1.",
|
|
130
|
+
)
|
|
112
131
|
args = parser.parse_args()
|
|
113
132
|
|
|
114
133
|
if args.enable_destructive and not args.enable_writes:
|
|
115
134
|
parser.error("--enable-destructive requires --enable-writes.")
|
|
116
135
|
|
|
136
|
+
sso_auto_login = args.sso_auto_login or os.environ.get(
|
|
137
|
+
"WORKSPACES_EUC_SSO_AUTO_LOGIN", ""
|
|
138
|
+
).strip().lower() in ("1", "true", "yes")
|
|
139
|
+
|
|
117
140
|
logger.remove()
|
|
118
141
|
logger.add(sys.stderr, level=os.environ.get("FASTMCP_LOG_LEVEL", "INFO").upper())
|
|
119
142
|
|
|
@@ -125,6 +148,7 @@ def main() -> None:
|
|
|
125
148
|
enable_writes=args.enable_writes,
|
|
126
149
|
enable_destructive=args.enable_destructive,
|
|
127
150
|
max_bulk_targets=args.max_bulk_targets,
|
|
151
|
+
sso_auto_login=sso_auto_login,
|
|
128
152
|
)
|
|
129
153
|
mcp.run()
|
|
130
154
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
|
|
2
|
+
# You may not use this file except in compliance with the License.
|
|
3
|
+
# A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
|
|
4
|
+
"""Opt-in AWS SSO auto-login.
|
|
5
|
+
|
|
6
|
+
When enabled (``--sso-auto-login``), and a tool call fails because the SSO token has expired, the
|
|
7
|
+
server launches ``aws sso login`` for the configured profile — which opens the user's browser to
|
|
8
|
+
the approval screen — so the user never has to drop to a terminal. The interactive browser approval
|
|
9
|
+
itself is still required (that is inherent to the OAuth flow). The server never stores credentials;
|
|
10
|
+
it only invokes the AWS CLI, which writes to the standard SSO token cache.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
|
|
19
|
+
# `subprocess` is used solely to launch `aws sso login` with a fixed argv (shell=False) below.
|
|
20
|
+
import subprocess # nosec B404
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from loguru import logger
|
|
25
|
+
|
|
26
|
+
# Marker phrases (lowercased) that indicate an expired / unretrievable SSO token.
|
|
27
|
+
_SSO_TOKEN_ERROR_MARKERS = (
|
|
28
|
+
"token has expired",
|
|
29
|
+
"expired and refresh failed",
|
|
30
|
+
"error when retrieving token from sso",
|
|
31
|
+
"ssotokenload",
|
|
32
|
+
"unauthorizedssotoken",
|
|
33
|
+
"the sso session associated with this profile has expired",
|
|
34
|
+
"sso session",
|
|
35
|
+
"tokenretrievalerror",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Conservative profile-name charset so a configured profile can never be argv-injected.
|
|
39
|
+
_PROFILE_RE = re.compile(r"^[A-Za-z0-9_.:@/-]+$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def looks_like_sso_token_error(exc: BaseException) -> bool:
|
|
43
|
+
"""True if an exception message looks like an expired/unretrievable SSO token."""
|
|
44
|
+
msg = str(exc).lower()
|
|
45
|
+
return any(marker in msg for marker in _SSO_TOKEN_ERROR_MARKERS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_runner(profile: str | None) -> str:
|
|
49
|
+
"""Launch `aws sso login` (opens the browser). Returns a human-readable status string."""
|
|
50
|
+
aws = shutil.which("aws")
|
|
51
|
+
if not aws:
|
|
52
|
+
return "AWS CLI not found on PATH — run `aws sso login` manually to re-authenticate."
|
|
53
|
+
args = [aws, "sso", "login"]
|
|
54
|
+
if profile:
|
|
55
|
+
if not _PROFILE_RE.match(profile):
|
|
56
|
+
return "the configured AWS profile name is invalid — sign in manually."
|
|
57
|
+
args += ["--profile", profile]
|
|
58
|
+
try:
|
|
59
|
+
# Fixed argv, shell=False, profile validated against _PROFILE_RE — no injection surface.
|
|
60
|
+
subprocess.Popen( # nosec B603
|
|
61
|
+
args,
|
|
62
|
+
stdin=subprocess.DEVNULL,
|
|
63
|
+
stdout=subprocess.DEVNULL,
|
|
64
|
+
stderr=subprocess.DEVNULL,
|
|
65
|
+
start_new_session=True,
|
|
66
|
+
)
|
|
67
|
+
except OSError as exc: # pragma: no cover - environment dependent
|
|
68
|
+
return f"could not launch `aws sso login`: {exc}"
|
|
69
|
+
suffix = f" --profile {profile}" if profile else ""
|
|
70
|
+
return f"opened browser sign-in (`aws sso login{suffix}`) — approve it, then re-run."
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SsoAutoLogin:
|
|
74
|
+
"""Debounced launcher for `aws sso login`, triggered on detected token expiry."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
profile: str | None = None,
|
|
80
|
+
enabled: bool = False,
|
|
81
|
+
cooldown_seconds: float = 60.0,
|
|
82
|
+
runner: Callable[[str | None], str] | None = None,
|
|
83
|
+
clock: Callable[[], float] | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.enabled = enabled
|
|
86
|
+
self._profile = profile
|
|
87
|
+
self._cooldown = cooldown_seconds
|
|
88
|
+
self._runner = runner or _default_runner
|
|
89
|
+
self._clock = clock or time.monotonic
|
|
90
|
+
self._last: float | None = None
|
|
91
|
+
|
|
92
|
+
def maybe_login(self) -> str | None:
|
|
93
|
+
"""Trigger a login if enabled and not within the cooldown; returns a status or None."""
|
|
94
|
+
if not self.enabled:
|
|
95
|
+
return None
|
|
96
|
+
now = self._clock()
|
|
97
|
+
if self._last is not None and (now - self._last) < self._cooldown:
|
|
98
|
+
# A burst of failing calls (e.g. one report) should open the browser only once.
|
|
99
|
+
return None
|
|
100
|
+
self._last = now
|
|
101
|
+
profile = self._profile or os.environ.get("AWS_PROFILE")
|
|
102
|
+
status = self._runner(profile)
|
|
103
|
+
logger.info("SSO auto-login triggered: {}", status)
|
|
104
|
+
return status
|
|
@@ -13,6 +13,16 @@ from loguru import logger
|
|
|
13
13
|
from mcp.types import ToolAnnotations
|
|
14
14
|
|
|
15
15
|
from ..models import ServiceError
|
|
16
|
+
from ..sso import SsoAutoLogin, looks_like_sso_token_error
|
|
17
|
+
|
|
18
|
+
# Optional process-wide SSO auto-login handler, installed by the server when --sso-auto-login is on.
|
|
19
|
+
_SSO_HANDLER: SsoAutoLogin | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_sso_handler(handler: SsoAutoLogin | None) -> None:
|
|
23
|
+
"""Install the process-wide SSO auto-login handler (called once at server start)."""
|
|
24
|
+
global _SSO_HANDLER
|
|
25
|
+
_SSO_HANDLER = handler
|
|
16
26
|
|
|
17
27
|
|
|
18
28
|
def read_only(title: str) -> ToolAnnotations:
|
|
@@ -49,10 +59,31 @@ def try_call(
|
|
|
49
59
|
return fn()
|
|
50
60
|
except (ClientError, BotoCoreError) as exc:
|
|
51
61
|
logger.warning("AWS call failed: {} {} -> {}", service, operation, exc)
|
|
52
|
-
|
|
62
|
+
message = str(exc)
|
|
63
|
+
if looks_like_sso_token_error(exc):
|
|
64
|
+
message += _sso_hint()
|
|
65
|
+
errors.append(ServiceError(service=service, operation=operation, message=message))
|
|
53
66
|
return default
|
|
54
67
|
|
|
55
68
|
|
|
69
|
+
def _sso_hint() -> str:
|
|
70
|
+
"""Append an actionable hint to SSO-token errors, auto-launching sign-in if enabled."""
|
|
71
|
+
handler = _SSO_HANDLER
|
|
72
|
+
if handler is not None and handler.enabled:
|
|
73
|
+
status = handler.maybe_login()
|
|
74
|
+
if status:
|
|
75
|
+
return f" [SSO session expired — {status}]"
|
|
76
|
+
return (
|
|
77
|
+
" [SSO session expired — a browser sign-in is already in progress; approve it, "
|
|
78
|
+
"then retry]"
|
|
79
|
+
)
|
|
80
|
+
return (
|
|
81
|
+
" [SSO session expired — run `aws sso login --profile <your-profile>` to re-authenticate "
|
|
82
|
+
"(or launch the server with --sso-auto-login to open sign-in automatically). "
|
|
83
|
+
"Note: signing into the AWS Console does NOT refresh the CLI/SSO token.]"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
56
87
|
def paginate(
|
|
57
88
|
operation: Callable[..., dict[str, Any]],
|
|
58
89
|
list_key: str,
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/ci.yml
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier0-diagnostics.json
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier2-lifecycle.json
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/iam/tier3-destructive.json
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/scripts/smoke_readonly.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_destructive.py
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_diagnostics.py
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_governance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_performance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.8}/tests/test_secure_browser.py
RENAMED
|
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
|