workspaces-euc-mcp-server 0.1.7__tar.gz → 0.1.9__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.
Files changed (54) hide show
  1. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/CHANGELOG.md +19 -0
  2. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/DESIGN.md +7 -0
  3. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/PKG-INFO +11 -1
  4. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/README.md +10 -0
  5. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/pyproject.toml +1 -1
  6. workspaces_euc_mcp_server-0.1.9/tests/test_sso.py +116 -0
  7. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/__init__.py +1 -1
  8. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/server.py +30 -0
  9. workspaces_euc_mcp_server-0.1.9/workspaces_euc_mcp_server/sso.py +104 -0
  10. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/_common.py +32 -1
  11. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/.dockerignore +0 -0
  12. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/.github/workflows/ci.yml +0 -0
  13. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/.github/workflows/docker-publish.yml +0 -0
  14. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/.github/workflows/publish.yml +0 -0
  15. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/.gitignore +0 -0
  16. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/.pre-commit-config.yaml +0 -0
  17. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/Dockerfile +0 -0
  18. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/LICENSE +0 -0
  19. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/iam/README.md +0 -0
  20. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/iam/tier0-diagnostics.json +0 -0
  21. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/iam/tier1-cost.json +0 -0
  22. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/iam/tier2-lifecycle.json +0 -0
  23. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/iam/tier3-destructive.json +0 -0
  24. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/scripts/smoke_readonly.py +0 -0
  25. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/__init__.py +0 -0
  26. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_clients.py +0 -0
  27. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_cost.py +0 -0
  28. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_destructive.py +0 -0
  29. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_diagnostics.py +0 -0
  30. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_governance.py +0 -0
  31. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_images.py +0 -0
  32. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_inventory.py +0 -0
  33. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_lifecycle.py +0 -0
  34. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_naming.py +0 -0
  35. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_no_embedded_secrets.py +0 -0
  36. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_performance.py +0 -0
  37. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_pricing.py +0 -0
  38. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_reporting.py +0 -0
  39. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/tests/test_secure_browser.py +0 -0
  40. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/clients.py +0 -0
  41. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/consts.py +0 -0
  42. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/models.py +0 -0
  43. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
  44. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/cost.py +0 -0
  45. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
  46. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/diagnostics.py +0 -0
  47. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/governance.py +0 -0
  48. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/images.py +0 -0
  49. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
  50. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
  51. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/performance.py +0 -0
  52. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
  53. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
  54. {workspaces_euc_mcp_server-0.1.7 → workspaces_euc_mcp_server-0.1.9}/workspaces_euc_mcp_server/tools/secure_browser.py +0 -0
@@ -5,6 +5,25 @@ All notable changes to this project are documented here. The format is based on
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.9] - 2026-06-03
9
+
10
+ ### Changed
11
+ - **SSO auto-login is now ON by default** (previously opt-in via `--sso-auto-login`). When an AWS
12
+ call fails with an expired SSO token, the server automatically runs `aws sso login` (opens the
13
+ browser) and reports in the result that the token expired and that sign-in was launched — no flag
14
+ needed. Disable with **`--no-sso-auto-login`** or `WORKSPACES_EUC_SSO_AUTO_LOGIN=0` (e.g.
15
+ headless/CI). The browser approval is still required and credentials are still never stored.
16
+
17
+ ## [0.1.8] - 2026-06-03
18
+
19
+ ### Added
20
+ - **`--sso-auto-login`** (opt-in; also `WORKSPACES_EUC_SSO_AUTO_LOGIN=1`): when an AWS call fails
21
+ with an expired SSO token, the server automatically runs `aws sso login` — opening the browser to
22
+ the approval screen — so the user re-authenticates without opening a terminal. Debounced so a
23
+ burst of failing calls opens sign-in only once. Off by default; never stores credentials (it only
24
+ invokes the AWS CLI). Expired-token errors also now carry a clearer hint, including that signing
25
+ into the AWS Console does **not** refresh the CLI/SSO token.
26
+
8
27
  ## [0.1.7] - 2026-06-02
9
28
 
10
29
  ### 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,12 @@
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` / `--no-sso-auto-login` (default **on**): on an expired-SSO-token error,
98
+ launch `aws sso login` (opens the browser) so the user needn't use a terminal, and report in the
99
+ result that the token expired and sign-in was launched. Never stores credentials — it only
100
+ invokes the AWS CLI, which writes the standard token cache. Debounced so a burst of failing calls
101
+ opens the browser once. Disable with `--no-sso-auto-login` / `WORKSPACES_EUC_SSO_AUTO_LOGIN=0`
102
+ for headless/CI.
96
103
  - `AWS_REGION` / `AWS_PROFILE`: standard.
97
104
 
98
105
  ## 5. Tool inventory
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workspaces-euc-mcp-server
3
- Version: 0.1.7
3
+ Version: 0.1.9
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,15 @@ 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
+ > **Auto re-login (no terminal) — on by default:** when an AWS call fails with an expired SSO
268
+ > token, the server **automatically runs `aws sso login` for you** — opening your browser to the
269
+ > approval screen — and tells you in chat that the token expired and that sign-in was launched. You
270
+ > just click *Allow* and re-ask, without ever opening a terminal. The browser approval itself is
271
+ > still required (inherent to SSO), and the server still never stores credentials (it just invokes
272
+ > the AWS CLI). Disable with **`--no-sso-auto-login`** (or `WORKSPACES_EUC_SSO_AUTO_LOGIN=0`) for
273
+ > headless/CI use. Note: signing into the AWS **Console** does *not* refresh the CLI/SSO token —
274
+ > only `aws sso login` does.
275
+
267
276
  ## Enabling write / destructive tools — the safety gates
268
277
 
269
278
  The config above is **read-only**: the write and destructive tools are not even registered, so
@@ -359,6 +368,7 @@ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-target
359
368
  | `--enable-writes` | off | Register Phase 2 lifecycle (write) tools. |
360
369
  | `--enable-destructive` | off | Allow terminate/rebuild/restore (requires `--enable-writes`). |
361
370
  | `--max-bulk-targets` | 25 | Blast-radius cap for bulk mutations (Phase 2). |
371
+ | `--sso-auto-login` / `--no-sso-auto-login` | **on** | On an expired SSO token, auto-launch `aws sso login` (opens your browser) instead of requiring a manual terminal command. **On by default**; disable with `--no-sso-auto-login` or `WORKSPACES_EUC_SSO_AUTO_LOGIN=0` (e.g. headless/CI). |
362
372
 
363
373
  The server starts **read-only**; mutating tools require both the launch flag **and** the matching
364
374
  IAM tier.
@@ -234,6 +234,15 @@ 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
+ > **Auto re-login (no terminal) — on by default:** when an AWS call fails with an expired SSO
238
+ > token, the server **automatically runs `aws sso login` for you** — opening your browser to the
239
+ > approval screen — and tells you in chat that the token expired and that sign-in was launched. You
240
+ > just click *Allow* and re-ask, without ever opening a terminal. The browser approval itself is
241
+ > still required (inherent to SSO), and the server still never stores credentials (it just invokes
242
+ > the AWS CLI). Disable with **`--no-sso-auto-login`** (or `WORKSPACES_EUC_SSO_AUTO_LOGIN=0`) for
243
+ > headless/CI use. Note: signing into the AWS **Console** does *not* refresh the CLI/SSO token —
244
+ > only `aws sso login` does.
245
+
237
246
  ## Enabling write / destructive tools — the safety gates
238
247
 
239
248
  The config above is **read-only**: the write and destructive tools are not even registered, so
@@ -329,6 +338,7 @@ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-target
329
338
  | `--enable-writes` | off | Register Phase 2 lifecycle (write) tools. |
330
339
  | `--enable-destructive` | off | Allow terminate/rebuild/restore (requires `--enable-writes`). |
331
340
  | `--max-bulk-targets` | 25 | Blast-radius cap for bulk mutations (Phase 2). |
341
+ | `--sso-auto-login` / `--no-sso-auto-login` | **on** | On an expired SSO token, auto-launch `aws sso login` (opens your browser) instead of requiring a manual terminal command. **On by default**; disable with `--no-sso-auto-login` or `WORKSPACES_EUC_SSO_AUTO_LOGIN=0` (e.g. headless/CI). |
332
342
 
333
343
  The server starts **read-only**; mutating tools require both the launch flag **and** the matching
334
344
  IAM tier.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workspaces-euc-mcp-server"
3
- version = "0.1.7"
3
+ version = "0.1.9"
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,116 @@
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
+ def test_create_server_enables_sso_auto_login_by_default():
96
+ from workspaces_euc_mcp_server.server import create_server
97
+
98
+ try:
99
+ create_server(region="us-east-1")
100
+ assert _common._SSO_HANDLER is not None
101
+ assert _common._SSO_HANDLER.enabled is True
102
+ # Opt-out still works.
103
+ create_server(region="us-east-1", sso_auto_login=False)
104
+ assert _common._SSO_HANDLER is None
105
+ finally:
106
+ _common.register_sso_handler(None)
107
+
108
+
109
+ class _FakeTokenError(BotoCoreError):
110
+ """A BotoCoreError subclass whose str() is a controllable token-error message."""
111
+
112
+ def __init__(self, message: str) -> None:
113
+ self._message = message
114
+
115
+ def __str__(self) -> str:
116
+ return self._message
@@ -3,4 +3,4 @@
3
3
  # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
4
  """MCP server for administering the Amazon WorkSpaces End User Computing portfolio."""
5
5
 
6
- __version__ = "0.1.7"
6
+ __version__ = "0.1.9"
@@ -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,24 @@ 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 = True,
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
+ # On by default: 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. Disable for headless/CI.
51
+ _common.register_sso_handler(
52
+ SsoAutoLogin(profile=profile, enabled=sso_auto_login) if sso_auto_login else None
53
+ )
54
+ logger.info(
55
+ "SSO auto-login {}: expired tokens {} trigger `aws sso login`.",
56
+ "enabled" if sso_auto_login else "disabled",
57
+ "will" if sso_auto_login else "will NOT",
58
+ )
59
+
45
60
  mcp = FastMCP(consts.SERVER_NAME, instructions=consts.SERVER_INSTRUCTIONS)
46
61
 
47
62
  # Phase 1 read-only tools are always registered.
@@ -109,11 +124,25 @@ def main() -> None:
109
124
  default=consts.DEFAULT_MAX_BULK_TARGETS,
110
125
  help="Blast-radius cap for bulk mutations (Phase 2).",
111
126
  )
127
+ parser.add_argument(
128
+ "--sso-auto-login",
129
+ action=argparse.BooleanOptionalAction,
130
+ default=True,
131
+ help="On an expired SSO token, auto-launch `aws sso login` (opens your browser) so you "
132
+ "re-authenticate without a terminal. ON by default; disable with --no-sso-auto-login "
133
+ "(or WORKSPACES_EUC_SSO_AUTO_LOGIN=0) for headless/CI environments.",
134
+ )
112
135
  args = parser.parse_args()
113
136
 
114
137
  if args.enable_destructive and not args.enable_writes:
115
138
  parser.error("--enable-destructive requires --enable-writes.")
116
139
 
140
+ # On by default; an explicit env var (if set) overrides the flag, so it can force-disable too.
141
+ sso_auto_login = args.sso_auto_login
142
+ _sso_env = os.environ.get("WORKSPACES_EUC_SSO_AUTO_LOGIN")
143
+ if _sso_env is not None:
144
+ sso_auto_login = _sso_env.strip().lower() in ("1", "true", "yes", "on")
145
+
117
146
  logger.remove()
118
147
  logger.add(sys.stderr, level=os.environ.get("FASTMCP_LOG_LEVEL", "INFO").upper())
119
148
 
@@ -125,6 +154,7 @@ def main() -> None:
125
154
  enable_writes=args.enable_writes,
126
155
  enable_destructive=args.enable_destructive,
127
156
  max_bulk_targets=args.max_bulk_targets,
157
+ sso_auto_login=sso_auto_login,
128
158
  )
129
159
  mcp.run()
130
160
 
@@ -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
- errors.append(ServiceError(service=service, operation=operation, message=str(exc)))
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
+ "(automatic sign-in is disabled via --no-sso-auto-login). "
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,