workspaces-euc-mcp-server 0.1.6__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.
Files changed (54) hide show
  1. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/CHANGELOG.md +20 -0
  2. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/DESIGN.md +6 -1
  3. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/PKG-INFO +11 -2
  4. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/README.md +10 -1
  5. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/iam/tier0-diagnostics.json +2 -1
  6. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/iam/tier1-cost.json +2 -1
  7. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/iam/tier2-lifecycle.json +2 -1
  8. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/iam/tier3-destructive.json +2 -1
  9. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/pyproject.toml +1 -1
  10. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_secure_browser.py +24 -8
  11. workspaces_euc_mcp_server-0.1.8/tests/test_sso.py +102 -0
  12. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/__init__.py +1 -1
  13. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/models.py +29 -0
  14. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/server.py +24 -0
  15. workspaces_euc_mcp_server-0.1.8/workspaces_euc_mcp_server/sso.py +104 -0
  16. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/_common.py +32 -1
  17. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/secure_browser.py +67 -16
  18. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/.dockerignore +0 -0
  19. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/ci.yml +0 -0
  20. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/docker-publish.yml +0 -0
  21. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/.github/workflows/publish.yml +0 -0
  22. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/.gitignore +0 -0
  23. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/.pre-commit-config.yaml +0 -0
  24. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/Dockerfile +0 -0
  25. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/LICENSE +0 -0
  26. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/iam/README.md +0 -0
  27. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/scripts/smoke_readonly.py +0 -0
  28. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/__init__.py +0 -0
  29. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_clients.py +0 -0
  30. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_cost.py +0 -0
  31. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_destructive.py +0 -0
  32. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_diagnostics.py +0 -0
  33. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_governance.py +0 -0
  34. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_images.py +0 -0
  35. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_inventory.py +0 -0
  36. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_lifecycle.py +0 -0
  37. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_naming.py +0 -0
  38. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_no_embedded_secrets.py +0 -0
  39. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_performance.py +0 -0
  40. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_pricing.py +0 -0
  41. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/tests/test_reporting.py +0 -0
  42. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/clients.py +0 -0
  43. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/consts.py +0 -0
  44. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
  45. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/cost.py +0 -0
  46. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
  47. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/diagnostics.py +0 -0
  48. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/governance.py +0 -0
  49. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/images.py +0 -0
  50. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
  51. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
  52. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/performance.py +0 -0
  53. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
  54. {workspaces_euc_mcp_server-0.1.6 → workspaces_euc_mcp_server-0.1.8}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
@@ -5,6 +5,26 @@ 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
+
18
+ ## [0.1.7] - 2026-06-02
19
+
20
+ ### Fixed
21
+ - `get_secure_browser_portal_usage` now reports **current active sessions live via
22
+ `workspaces-web:ListSessions`** (the same source as the console's active-sessions view) instead
23
+ of inferring "active" from CloudWatch. CloudWatch (`AWS/WorkSpacesWeb`) is now used **only for
24
+ historic** metrics, and the summary clearly separates live vs historic. Adds
25
+ `workspaces-web:ListSessions` to every IAM tier. The tool now returns a `SecureBrowserPortalUsage`
26
+ result (`active_session_count`, `active_sessions`, `historic_metrics`).
27
+
8
28
  ## [0.1.6] - 2026-06-02
9
29
 
10
30
  ### Added
@@ -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
@@ -135,7 +140,7 @@ and return a synthesized result, not raw API passthroughs.
135
140
  | Tool | Purpose | IAM actions |
136
141
  |---|---|---|
137
142
  | `get_secure_browser_portal_details` | Portal config + associated settings (browser/network/user/IP-access) + resolved data-protection redaction config | `workspaces-web:GetPortal`, `workspaces-web:List*`, `workspaces-web:Get*Settings`, `workspaces-web:GetDataProtectionSettings` |
138
- | `get_secure_browser_portal_usage` | Portal session/usage metrics | `workspaces-web:ListPortals`, `cloudwatch:GetMetricData` |
143
+ | `get_secure_browser_portal_usage` | Current active sessions (live, `ListSessions`) + historic CloudWatch metrics | `workspaces-web:ListPortals`, `workspaces-web:ListSessions`, `cloudwatch:GetMetricData` |
139
144
 
140
145
  **Reporting & audit**
141
146
  | Tool | Purpose | IAM actions |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workspaces-euc-mcp-server
3
- Version: 0.1.6
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
@@ -95,7 +95,7 @@ audit, and governance tools:
95
95
  | `get_euc_audit_trail` | **"Who changed what"** — recent EUC management events from CloudTrail (last 90 days, no trail required) across all services; mutations-only by default, flags destructive actions and errors (e.g. AccessDenied) (Tier 0). |
96
96
  | `get_euc_service_quotas` | **Service-quota limits + usage headroom** per EUC service; pairs limits with current usage (where AWS publishes a usage metric) to flag quotas approaching their limit — capacity planning (Tier 0). |
97
97
  | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, attached policies, and — when configured — the **data-protection redaction config** (which built-in/custom patterns are redacted, confidence level, enforced/exempt URLs) (Tier 0). |
98
- | `get_secure_browser_portal_usage` | A Secure Browser portal's `AWS/WorkSpacesWeb` session metrics over a window (empty until the portal has sessions; Session Logger gives detail) (Tier 0). |
98
+ | `get_secure_browser_portal_usage` | A Secure Browser portal's **current active sessions** (live, via `ListSessions` — same as the console) plus **historic** `AWS/WorkSpacesWeb` metrics over a window (CloudWatch is historic-only; idle portals publish none) (Tier 0). |
99
99
  | `list_unused_resources` | Unused WorkSpaces desktops and stopped/zero-capacity fleets worth reclaiming (Tier 0). |
100
100
 
101
101
  Cost/utilization tools need the **Tier 1** IAM policy ([`iam/tier1-cost.json`](iam/tier1-cost.json));
@@ -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.
@@ -65,7 +65,7 @@ audit, and governance tools:
65
65
  | `get_euc_audit_trail` | **"Who changed what"** — recent EUC management events from CloudTrail (last 90 days, no trail required) across all services; mutations-only by default, flags destructive actions and errors (e.g. AccessDenied) (Tier 0). |
66
66
  | `get_euc_service_quotas` | **Service-quota limits + usage headroom** per EUC service; pairs limits with current usage (where AWS publishes a usage metric) to flag quotas approaching their limit — capacity planning (Tier 0). |
67
67
  | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, attached policies, and — when configured — the **data-protection redaction config** (which built-in/custom patterns are redacted, confidence level, enforced/exempt URLs) (Tier 0). |
68
- | `get_secure_browser_portal_usage` | A Secure Browser portal's `AWS/WorkSpacesWeb` session metrics over a window (empty until the portal has sessions; Session Logger gives detail) (Tier 0). |
68
+ | `get_secure_browser_portal_usage` | A Secure Browser portal's **current active sessions** (live, via `ListSessions` — same as the console) plus **historic** `AWS/WorkSpacesWeb` metrics over a window (CloudWatch is historic-only; idle portals publish none) (Tier 0). |
69
69
  | `list_unused_resources` | Unused WorkSpaces desktops and stopped/zero-capacity fleets worth reclaiming (Tier 0). |
70
70
 
71
71
  Cost/utilization tools need the **Tier 1** IAM policy ([`iam/tier1-cost.json`](iam/tier1-cost.json));
@@ -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.
@@ -40,7 +40,8 @@
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
42
  "workspaces-web:GetNetworkSettings",
43
- "workspaces-web:GetDataProtectionSettings"
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
44
45
  ],
45
46
  "Resource": "*"
46
47
  },
@@ -40,7 +40,8 @@
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
42
  "workspaces-web:GetNetworkSettings",
43
- "workspaces-web:GetDataProtectionSettings"
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
44
45
  ],
45
46
  "Resource": "*"
46
47
  },
@@ -40,7 +40,8 @@
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
42
  "workspaces-web:GetNetworkSettings",
43
- "workspaces-web:GetDataProtectionSettings"
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
44
45
  ],
45
46
  "Resource": "*"
46
47
  },
@@ -40,7 +40,8 @@
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
42
  "workspaces-web:GetNetworkSettings",
43
- "workspaces-web:GetDataProtectionSettings"
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
44
45
  ],
45
46
  "Resource": "*"
46
47
  },
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workspaces-euc-mcp-server"
3
- version = "0.1.6"
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"
@@ -108,19 +108,35 @@ def test_portal_details_resolves_data_protection_config():
108
108
  assert dp["global_enforced_urls"] == ["*"]
109
109
 
110
110
 
111
- def test_portal_usage_empty_explains_session_model():
112
- cw = types.SimpleNamespace(
113
- get_metric_data=lambda **_: {"MetricDataResults": []} # no data
114
- )
115
- factory = FakeFactory({consts.CLOUDWATCH_API: cw})
111
+ def test_portal_usage_returns_live_active_sessions_and_historic():
112
+ # Active sessions must come LIVE from ListSessions (status=Active), not from CloudWatch.
113
+ captured = {}
114
+
115
+ def list_sessions(**kwargs):
116
+ captured.update(kwargs)
117
+ return {
118
+ "sessions": [
119
+ {"sessionId": "s-1", "username": "alice", "status": "Active"},
120
+ {"sessionId": "s-2", "username": "bob", "status": "Active"},
121
+ ]
122
+ }
123
+
124
+ web = types.SimpleNamespace(list_sessions=list_sessions)
125
+ cw = types.SimpleNamespace(get_metric_data=lambda **_: {"MetricDataResults": []}) # no historic
126
+ factory = FakeFactory({consts.SECURE_BROWSER_API: web, consts.CLOUDWATCH_API: cw})
116
127
 
117
128
  usage = secure_browser.get_secure_browser_portal_usage_core(
118
129
  factory, "arn:aws:workspaces-web:r:a:portal/abc123", "us-east-1"
119
130
  )
120
131
 
121
- assert usage.target_type == consts.PRODUCT_SECURE_BROWSER
122
- assert usage.target_id.endswith("portal/abc123")
123
- assert "Session Logger" in (usage.summary or "")
132
+ # Queried ListSessions for the portal id with status=Active.
133
+ assert captured["portalId"] == "abc123"
134
+ assert captured["status"] == "Active"
135
+ # Live active sessions populated; CloudWatch reserved for (empty) historic.
136
+ assert usage.active_session_count == 2
137
+ assert {s.username for s in usage.active_sessions} == {"alice", "bob"}
138
+ assert usage.historic_metrics == {}
139
+ assert "active session(s) right now" in (usage.summary or "")
124
140
 
125
141
 
126
142
  def test_portal_id_extracted_from_arn():
@@ -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
@@ -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.6"
6
+ __version__ = "0.1.8"
@@ -166,6 +166,35 @@ class SecureBrowserPortalDetails(BaseModel):
166
166
  errors: list[ServiceError] = Field(default_factory=list)
167
167
 
168
168
 
169
+ class SecureBrowserSession(BaseModel):
170
+ """A single Secure Browser portal session (from ListSessions)."""
171
+
172
+ session_id: str
173
+ username: str | None = None
174
+ status: str | None = None
175
+ start_time: str | None = None
176
+
177
+
178
+ class SecureBrowserPortalUsage(BaseModel):
179
+ """Live active sessions (ListSessions) plus historic metrics (CloudWatch) for a portal."""
180
+
181
+ portal: str
182
+ active_session_count: int = 0
183
+ active_sessions: list[SecureBrowserSession] = Field(
184
+ default_factory=list,
185
+ description="Sessions currently Active, retrieved live from ListSessions (like the "
186
+ "console).",
187
+ )
188
+ lookback_days: int
189
+ period_hours: int
190
+ historic_metrics: dict[str, FleetMetricSeries] = Field(
191
+ default_factory=dict,
192
+ description="Historic session metrics from CloudWatch (AWS/WorkSpacesWeb) over the window.",
193
+ )
194
+ summary: str | None = None
195
+ errors: list[ServiceError] = Field(default_factory=list)
196
+
197
+
169
198
  class UsageHistory(BaseModel):
170
199
  """Generic metric time-series history for a single EUC resource over a window."""
171
200
 
@@ -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
- 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
+ "(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,
@@ -18,8 +18,13 @@ from typing import Any
18
18
 
19
19
  from .. import consts
20
20
  from ..clients import ClientFactory
21
- from ..models import SecureBrowserPortalDetails, ServiceError, UsageHistory
22
- from ._common import read_only, try_call
21
+ from ..models import (
22
+ SecureBrowserPortalDetails,
23
+ SecureBrowserPortalUsage,
24
+ SecureBrowserSession,
25
+ ServiceError,
26
+ )
27
+ from ._common import paginate, read_only, try_call
23
28
  from .performance import _fetch_metric_series
24
29
 
25
30
 
@@ -139,14 +144,54 @@ def _portal_id(portal: str) -> str:
139
144
  return portal.rsplit("/", 1)[-1] if "/" in portal else portal
140
145
 
141
146
 
147
+ def _list_active_sessions(
148
+ web: Any, portal_id: str, errors: list[ServiceError]
149
+ ) -> list[SecureBrowserSession]:
150
+ """Current Active sessions for a portal, live from ListSessions (what the console shows)."""
151
+ raw = try_call(
152
+ errors,
153
+ consts.PRODUCT_SECURE_BROWSER,
154
+ "ListSessions",
155
+ lambda: paginate(
156
+ web.list_sessions,
157
+ "sessions",
158
+ pagination_in="nextToken",
159
+ pagination_out="nextToken",
160
+ portalId=portal_id,
161
+ status="Active",
162
+ ),
163
+ default=[],
164
+ )
165
+ sessions: list[SecureBrowserSession] = []
166
+ for s in raw or []:
167
+ start = s.get("startTime")
168
+ sessions.append(
169
+ SecureBrowserSession(
170
+ session_id=s.get("sessionId", ""),
171
+ username=s.get("username"),
172
+ status=s.get("status"),
173
+ start_time=start.isoformat() if hasattr(start, "isoformat") else None,
174
+ )
175
+ )
176
+ return sessions
177
+
178
+
142
179
  def get_secure_browser_portal_usage_core(
143
180
  factory: ClientFactory,
144
181
  portal: str,
145
182
  region: str | None,
146
183
  lookback_days: int = 7,
147
184
  period_hours: int = 24,
148
- ) -> UsageHistory:
185
+ ) -> SecureBrowserPortalUsage:
149
186
  errors: list[ServiceError] = []
187
+ portal_id = _portal_id(portal)
188
+ web = factory.client(consts.SECURE_BROWSER_API, region=region)
189
+
190
+ # LIVE: current active sessions come from ListSessions (the real-time source the console uses),
191
+ # NOT from CloudWatch.
192
+ active = _list_active_sessions(web, portal_id, errors)
193
+
194
+ # HISTORIC: CloudWatch (AWS/WorkSpacesWeb) over the window — only meaningful for past activity.
150
195
  cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
151
196
  metrics = try_call(
152
197
  errors,
@@ -156,26 +201,30 @@ def get_secure_browser_portal_usage_core(
156
201
  cloudwatch,
157
202
  consts.SECURE_BROWSER_NAMESPACE,
158
203
  consts.SECURE_BROWSER_PORTAL_DIMENSION,
159
- _portal_id(portal),
204
+ portal_id,
160
205
  consts.SECURE_BROWSER_SESSION_METRICS,
161
206
  lookback_days,
162
207
  period_hours,
163
208
  ),
164
209
  default={},
165
210
  )
211
+
212
+ hist = (
213
+ f"historic metrics over {lookback_days}d available (see historic_metrics)"
214
+ if metrics
215
+ else f"no historic session metrics in the last {lookback_days}d (idle portals publish "
216
+ "none to CloudWatch; enable the portal's Session Logger for detail)"
217
+ )
166
218
  summary = (
167
- "No session metrics in the window. Secure Browser only emits CloudWatch metrics when "
168
- "sessions occur (idle portals publish nothing); for detailed usage enable the portal's "
169
- "Session Logger."
170
- if not metrics
171
- else f"Session metrics over {lookback_days}d: see series."
219
+ f"{len(active)} active session(s) right now (live via ListSessions). Historic: {hist}."
172
220
  )
173
- return UsageHistory(
174
- target_type=consts.PRODUCT_SECURE_BROWSER,
175
- target_id=portal,
221
+ return SecureBrowserPortalUsage(
222
+ portal=portal,
223
+ active_session_count=len(active),
224
+ active_sessions=active,
176
225
  lookback_days=lookback_days,
177
226
  period_hours=period_hours,
178
- metrics=metrics or {},
227
+ historic_metrics=metrics or {},
179
228
  summary=summary,
180
229
  errors=errors,
181
230
  )
@@ -210,10 +259,12 @@ def register(mcp: Any, factory: ClientFactory) -> None:
210
259
  lookback_days: int = 7,
211
260
  period_hours: int = 24,
212
261
  ) -> dict[str, Any]:
213
- """Get a Secure Browser portal's session metrics (AWS/WorkSpacesWeb) over a window.
262
+ """Get a Secure Browser portal's CURRENT active sessions plus historic session metrics.
214
263
 
215
- NOTE: Secure Browser only emits these metrics when sessions occur, so idle portals return
216
- nothing; richer per-session data is available via the portal's Session Logger. Read-only.
264
+ Active sessions are retrieved **live** from ListSessions (the same source as the console's
265
+ active-sessions view). Historic usage comes from CloudWatch (AWS/WorkSpacesWeb) over the
266
+ window — CloudWatch is only meaningful for *past* activity, and idle portals publish none.
267
+ Read-only.
217
268
 
218
269
  Args:
219
270
  portal: The portal id or ARN.