workspaces-euc-mcp-server 0.1.8__tar.gz → 0.1.10__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.8 → workspaces_euc_mcp_server-0.1.10}/CHANGELOG.md +19 -0
  2. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/DESIGN.md +7 -5
  3. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/PKG-INFO +11 -10
  4. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/README.md +10 -9
  5. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/pyproject.toml +1 -1
  6. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_cost.py +66 -0
  7. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_sso.py +14 -0
  8. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/__init__.py +1 -1
  9. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/consts.py +9 -0
  10. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/models.py +7 -0
  11. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/server.py +18 -12
  12. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/_common.py +1 -1
  13. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/cost.py +90 -7
  14. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.dockerignore +0 -0
  15. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/ci.yml +0 -0
  16. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/docker-publish.yml +0 -0
  17. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/publish.yml +0 -0
  18. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.gitignore +0 -0
  19. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.pre-commit-config.yaml +0 -0
  20. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/Dockerfile +0 -0
  21. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/LICENSE +0 -0
  22. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/README.md +0 -0
  23. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier0-diagnostics.json +0 -0
  24. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier1-cost.json +0 -0
  25. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier2-lifecycle.json +0 -0
  26. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier3-destructive.json +0 -0
  27. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/scripts/smoke_readonly.py +0 -0
  28. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/__init__.py +0 -0
  29. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_clients.py +0 -0
  30. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_destructive.py +0 -0
  31. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_diagnostics.py +0 -0
  32. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_governance.py +0 -0
  33. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_images.py +0 -0
  34. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_inventory.py +0 -0
  35. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_lifecycle.py +0 -0
  36. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_naming.py +0 -0
  37. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_no_embedded_secrets.py +0 -0
  38. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_performance.py +0 -0
  39. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_pricing.py +0 -0
  40. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_reporting.py +0 -0
  41. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_secure_browser.py +0 -0
  42. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/clients.py +0 -0
  43. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/sso.py +0 -0
  44. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
  45. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
  46. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/diagnostics.py +0 -0
  47. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/governance.py +0 -0
  48. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/images.py +0 -0
  49. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
  50. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
  51. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/performance.py +0 -0
  52. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
  53. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
  54. {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/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.10] - 2026-06-03
9
+
10
+ ### Added
11
+ - `get_euc_cost_summary` now returns **`workspaces_breakdown`** — the single "Amazon WorkSpaces"
12
+ Cost Explorer line split into **Personal / Pools / Core** via `USAGE_TYPE` (which the SERVICE
13
+ dimension cannot do; pool charges carry `Pools`, Core carries `ManagedInstances`, the rest is
14
+ Personal). This answers "is this WorkSpaces figure Personal-only or does it include Pools/Core?".
15
+ Controlled by `split_workspaces` (default true). A note also explains that AlwaysOn monthly bundle
16
+ charges post on the 1st, so day-1 of a DAILY series spikes legitimately.
17
+
18
+ ## [0.1.9] - 2026-06-03
19
+
20
+ ### Changed
21
+ - **SSO auto-login is now ON by default** (previously opt-in via `--sso-auto-login`). When an AWS
22
+ call fails with an expired SSO token, the server automatically runs `aws sso login` (opens the
23
+ browser) and reports in the result that the token expired and that sign-in was launched — no flag
24
+ needed. Disable with **`--no-sso-auto-login`** or `WORKSPACES_EUC_SSO_AUTO_LOGIN=0` (e.g.
25
+ headless/CI). The browser approval is still required and credentials are still never stored.
26
+
8
27
  ## [0.1.8] - 2026-06-03
9
28
 
10
29
  ### Added
@@ -94,10 +94,12 @@
94
94
  - `--enable-writes`: registers Phase-2 lifecycle tools (still dry-run/confirm gated).
95
95
  - `--enable-destructive`: separately gates terminate/rebuild/restore.
96
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.
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.
101
103
  - `AWS_REGION` / `AWS_PROFILE`: standard.
102
104
 
103
105
  ## 5. Tool inventory
@@ -134,7 +136,7 @@ and return a synthesized result, not raw API passthroughs.
134
136
  | `get_workspace_connection_history` | Per-WorkSpace connection timeline | `workspaces:DescribeWorkspaces*`, `cloudwatch:GetMetricData` |
135
137
  | `get_pool_session_history` | Pool session/capacity history | `workspaces:DescribeWorkspacesPool*`, `cloudwatch:GetMetricData` |
136
138
  | `get_application_fleet_usage` | Applications fleet utilization vs capacity | `appstream:DescribeFleets`, `cloudwatch:GetMetricData` |
137
- | `get_euc_cost_summary` | Spend rollup filtered to EUC services | `ce:GetCostAndUsage`, `ce:GetDimensionValues` |
139
+ | `get_euc_cost_summary` | EUC spend by service + WorkSpaces Personal/Pools/Core split (USAGE_TYPE) + daily/monthly time series | `ce:GetCostAndUsage` |
138
140
 
139
141
  **Secure Browser**
140
142
  | 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.8
3
+ Version: 0.1.10
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
@@ -88,7 +88,7 @@ audit, and governance tools:
88
88
  | `get_workspace_connection_history` | A desktop's connection/session **history** (UserConnected + connection attempts/failures) over a window, with a summary (Tier 0). |
89
89
  | `get_pool_session_history` | A WorkSpaces Pool's user-**session history** (active/available/utilization capacity time-series), flags idle pool capacity (Tier 0). |
90
90
  | `recommend_bundle_rightsizing` | Suggests smaller/larger compute types from CPU & memory headroom (general families; graphics excluded) (Tier 0). |
91
- | `get_euc_cost_summary` | EUC spend by service over a window (or an explicit `start_date`/`end_date` calendar month) via Cost Explorer, account-wide. Services are matched by keyword so naming variants (e.g. AppStream 2.0) aren't dropped; note Cost Explorer bills WorkSpaces Personal/Pools/Core together as one "Amazon WorkSpaces" line (Tier 1). |
91
+ | `get_euc_cost_summary` | EUC spend by service over a window (or an explicit `start_date`/`end_date` calendar month) via Cost Explorer, account-wide. Services are matched by keyword so naming variants (e.g. AppStream 2.0) aren't dropped. Cost Explorer bills WorkSpaces Personal/Pools/Core as one "Amazon WorkSpaces" line, so the tool **splits it into Personal / Pools / Core via USAGE_TYPE** (`workspaces_breakdown`); also returns a daily/monthly time series (`by_period`) for charts (Tier 1). |
92
92
  | `generate_inventory_report` | Detailed per-resource inventory (desktops **with assigned user / computer name / IP**, pools, fleets, **stacks + their associated fleets**, portals) with key attributes (Tier 0). |
93
93
  | `audit_security_posture` | Cross-service: flags unencrypted WorkSpace volumes, directories without IP access control groups, and **Secure Browser portals / Applications stacks that allow data egress** (clipboard/download/print) (Tier 0). |
94
94
  | `audit_application_images` | Audits WorkSpaces Applications (AppStream 2.0) **images and image builders** — flags stale base images (unpatched OS), pinned/old AppStream agents, non-AVAILABLE/errored images, SHARED cross-account visibility, and **image builders left RUNNING** (cost + admin surface) (Tier 0). |
@@ -264,13 +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.
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.
274
275
 
275
276
  ## Enabling write / destructive tools — the safety gates
276
277
 
@@ -367,7 +368,7 @@ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-target
367
368
  | `--enable-writes` | off | Register Phase 2 lifecycle (write) tools. |
368
369
  | `--enable-destructive` | off | Allow terminate/rebuild/restore (requires `--enable-writes`). |
369
370
  | `--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`. |
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). |
371
372
 
372
373
  The server starts **read-only**; mutating tools require both the launch flag **and** the matching
373
374
  IAM tier.
@@ -58,7 +58,7 @@ audit, and governance tools:
58
58
  | `get_workspace_connection_history` | A desktop's connection/session **history** (UserConnected + connection attempts/failures) over a window, with a summary (Tier 0). |
59
59
  | `get_pool_session_history` | A WorkSpaces Pool's user-**session history** (active/available/utilization capacity time-series), flags idle pool capacity (Tier 0). |
60
60
  | `recommend_bundle_rightsizing` | Suggests smaller/larger compute types from CPU & memory headroom (general families; graphics excluded) (Tier 0). |
61
- | `get_euc_cost_summary` | EUC spend by service over a window (or an explicit `start_date`/`end_date` calendar month) via Cost Explorer, account-wide. Services are matched by keyword so naming variants (e.g. AppStream 2.0) aren't dropped; note Cost Explorer bills WorkSpaces Personal/Pools/Core together as one "Amazon WorkSpaces" line (Tier 1). |
61
+ | `get_euc_cost_summary` | EUC spend by service over a window (or an explicit `start_date`/`end_date` calendar month) via Cost Explorer, account-wide. Services are matched by keyword so naming variants (e.g. AppStream 2.0) aren't dropped. Cost Explorer bills WorkSpaces Personal/Pools/Core as one "Amazon WorkSpaces" line, so the tool **splits it into Personal / Pools / Core via USAGE_TYPE** (`workspaces_breakdown`); also returns a daily/monthly time series (`by_period`) for charts (Tier 1). |
62
62
  | `generate_inventory_report` | Detailed per-resource inventory (desktops **with assigned user / computer name / IP**, pools, fleets, **stacks + their associated fleets**, portals) with key attributes (Tier 0). |
63
63
  | `audit_security_posture` | Cross-service: flags unencrypted WorkSpace volumes, directories without IP access control groups, and **Secure Browser portals / Applications stacks that allow data egress** (clipboard/download/print) (Tier 0). |
64
64
  | `audit_application_images` | Audits WorkSpaces Applications (AppStream 2.0) **images and image builders** — flags stale base images (unpatched OS), pinned/old AppStream agents, non-AVAILABLE/errored images, SHARED cross-account visibility, and **image builders left RUNNING** (cost + admin surface) (Tier 0). |
@@ -234,13 +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.
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.
244
245
 
245
246
  ## Enabling write / destructive tools — the safety gates
246
247
 
@@ -337,7 +338,7 @@ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-target
337
338
  | `--enable-writes` | off | Register Phase 2 lifecycle (write) tools. |
338
339
  | `--enable-destructive` | off | Allow terminate/rebuild/restore (requires `--enable-writes`). |
339
340
  | `--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`. |
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). |
341
342
 
342
343
  The server starts **read-only**; mutating tools require both the launch flag **and** the matching
343
344
  IAM tier.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workspaces-euc-mcp-server"
3
- version = "0.1.8"
3
+ version = "0.1.10"
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"
@@ -215,6 +215,72 @@ def test_cost_summary_daily_returns_per_period_time_series():
215
215
  assert summary.by_period[0].by_service[0].service == "Amazon WorkSpaces Applications"
216
216
 
217
217
 
218
+ def test_cost_summary_splits_workspaces_personal_pools_core():
219
+ # First CE call = SERVICE grouping; second = USAGE_TYPE grouping (the breakdown).
220
+ service_resp = {
221
+ "ResultsByTime": [
222
+ {
223
+ "Groups": [
224
+ {
225
+ "Keys": ["Amazon WorkSpaces"],
226
+ "Metrics": {"UnblendedCost": {"Amount": "547.53", "Unit": "USD"}},
227
+ }
228
+ ]
229
+ }
230
+ ]
231
+ }
232
+ usage_resp = {
233
+ "ResultsByTime": [
234
+ {
235
+ "Groups": [
236
+ {
237
+ "Keys": ["APS1-AW-HWB5-0"], # Personal AlwaysOn monthly
238
+ "Metrics": {"UnblendedCost": {"Amount": "386.25", "Unit": "USD"}},
239
+ },
240
+ {
241
+ "Keys": ["APS1-AW-HW-Pools-Stopped-Usage"], # Pools
242
+ "Metrics": {"UnblendedCost": {"Amount": "146.68", "Unit": "USD"}},
243
+ },
244
+ {
245
+ "Keys": ["APS1-WH-ManagedInstances-Usage"], # Core
246
+ "Metrics": {"UnblendedCost": {"Amount": "14.60", "Unit": "USD"}},
247
+ },
248
+ ]
249
+ }
250
+ ]
251
+ }
252
+ calls = {"n": 0}
253
+
254
+ def get_cost_and_usage(**kwargs):
255
+ # The breakdown call carries a SERVICE filter + USAGE_TYPE grouping.
256
+ is_breakdown = kwargs.get("GroupBy", [{}])[0].get("Key") == "USAGE_TYPE"
257
+ calls["n"] += 1
258
+ return usage_resp if is_breakdown else service_resp
259
+
260
+ ce = types.SimpleNamespace(get_cost_and_usage=get_cost_and_usage)
261
+ factory = FakeFactory({consts.COST_EXPLORER_API: ce})
262
+
263
+ summary = cost.get_euc_cost_summary_core(factory, lookback_days=30)
264
+
265
+ assert summary.workspaces_breakdown == {
266
+ "WorkSpaces Personal": 386.25,
267
+ "WorkSpaces Pools": 146.68,
268
+ "WorkSpaces Core (Managed Instances)": 14.6,
269
+ }
270
+ assert calls["n"] == 2 # one SERVICE call + one USAGE_TYPE breakdown call
271
+
272
+
273
+ def test_cost_summary_classify_usage_type():
274
+ assert (
275
+ cost._classify_workspaces_usage_type("APS1-AW-HW-Pools-Stopped-Usage") == "WorkSpaces Pools"
276
+ )
277
+ assert (
278
+ cost._classify_workspaces_usage_type("APS1-WH-ManagedInstances-Usage")
279
+ == "WorkSpaces Core (Managed Instances)"
280
+ )
281
+ assert cost._classify_workspaces_usage_type("APS1-AW-HWB5-0") == "WorkSpaces Personal"
282
+
283
+
218
284
  def test_cost_summary_explicit_date_range_overrides_lookback():
219
285
  captured: dict = {}
220
286
 
@@ -92,6 +92,20 @@ def test_try_call_hint_when_auto_login_disabled():
92
92
  assert "Console does NOT refresh" in errors[0].message
93
93
 
94
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
+
95
109
  class _FakeTokenError(BotoCoreError):
96
110
  """A BotoCoreError subclass whose str() is a controllable token-error message."""
97
111
 
@@ -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.8"
6
+ __version__ = "0.1.10"
@@ -34,6 +34,15 @@ COST_EXPLORER_REGION = "us-east-1"
34
34
  # silently dropped. "workspaces" covers Personal/Pools/Core/Web/Secure Browser; "appstream" covers
35
35
  # WorkSpaces Applications (AppStream 2.0).
36
36
  EUC_COST_EXPLORER_SERVICE_TOKENS = ["workspaces", "appstream"]
37
+ # Cost Explorer bills WorkSpaces Personal, Pools, and Core under one "Amazon WorkSpaces" SERVICE
38
+ # line. They can still be split by USAGE_TYPE: pool charges carry "Pools", Core Managed Instances
39
+ # carry "ManagedInstances", everything else under that service is Personal. Matched lowercased,
40
+ # first hit wins.
41
+ WORKSPACES_USAGE_TYPE_CLASSES = (
42
+ ("pools", "WorkSpaces Pools"),
43
+ ("managedinstances", "WorkSpaces Core (Managed Instances)"),
44
+ )
45
+ WORKSPACES_USAGE_TYPE_DEFAULT_CLASS = "WorkSpaces Personal"
37
46
  # Substrings that EXCLUDE a service even when an include token matches. WorkSpaces Thin Client is
38
47
  # out of scope for this server, but its Cost Explorer name ("Amazon WorkSpaces Thin Client")
39
48
  # contains "workspaces" — so exclude it explicitly.
@@ -289,6 +289,13 @@ class CostSummary(BaseModel):
289
289
  currency: str = "USD"
290
290
  total: float
291
291
  by_service: list[CostLineItem] = Field(default_factory=list)
292
+ workspaces_breakdown: dict[str, float] = Field(
293
+ default_factory=dict,
294
+ description=(
295
+ "The single 'Amazon WorkSpaces' SERVICE line split into Personal / Pools / Core via "
296
+ "USAGE_TYPE (which SERVICE cannot do). Populated when WorkSpaces has spend."
297
+ ),
298
+ )
292
299
  by_period: list[CostPeriod] = Field(
293
300
  default_factory=list,
294
301
  description=(
@@ -39,20 +39,23 @@ def create_server(
39
39
  enable_writes: bool = False,
40
40
  enable_destructive: bool = False,
41
41
  max_bulk_targets: int = consts.DEFAULT_MAX_BULK_TARGETS,
42
- sso_auto_login: bool = False,
42
+ sso_auto_login: bool = True,
43
43
  ) -> FastMCP:
44
44
  """Build the FastMCP server, registering tools according to the safety flags."""
45
45
  factory = ClientFactory(
46
46
  region=region, profile=profile, role_arn=role_arn, external_id=external_id
47
47
  )
48
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.
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
51
  _common.register_sso_handler(
52
52
  SsoAutoLogin(profile=profile, enabled=sso_auto_login) if sso_auto_login else None
53
53
  )
54
- if sso_auto_login:
55
- logger.info("SSO auto-login enabled: expired tokens will trigger `aws sso login`.")
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
+ )
56
59
 
57
60
  mcp = FastMCP(consts.SERVER_NAME, instructions=consts.SERVER_INSTRUCTIONS)
58
61
 
@@ -123,19 +126,22 @@ def main() -> None:
123
126
  )
124
127
  parser.add_argument(
125
128
  "--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.",
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.",
130
134
  )
131
135
  args = parser.parse_args()
132
136
 
133
137
  if args.enable_destructive and not args.enable_writes:
134
138
  parser.error("--enable-destructive requires --enable-writes.")
135
139
 
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")
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")
139
145
 
140
146
  logger.remove()
141
147
  logger.add(sys.stderr, level=os.environ.get("FASTMCP_LOG_LEVEL", "INFO").upper())
@@ -79,7 +79,7 @@ def _sso_hint() -> str:
79
79
  )
80
80
  return (
81
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). "
82
+ "(automatic sign-in is disabled via --no-sso-auto-login). "
83
83
  "Note: signing into the AWS Console does NOT refresh the CLI/SSO token.]"
84
84
  )
85
85
 
@@ -208,12 +208,73 @@ def _is_euc_service(service_name: str) -> bool:
208
208
  return any(token in name for token in consts.EUC_COST_EXPLORER_SERVICE_TOKENS)
209
209
 
210
210
 
211
+ def _is_core_workspaces_service(name: str) -> bool:
212
+ """True for the 'Amazon WorkSpaces' SERVICE line (Personal/Pools/Core), not Applications/Web."""
213
+ n = name.lower()
214
+ if "workspaces" not in n:
215
+ return False
216
+ return not any(t in n for t in ("applications", "secure browser", "web", "thin client"))
217
+
218
+
219
+ def _classify_workspaces_usage_type(usage_type: str) -> str:
220
+ """Map a WorkSpaces Cost Explorer USAGE_TYPE to Personal / Pools / Core."""
221
+ u = usage_type.lower()
222
+ for token, label in consts.WORKSPACES_USAGE_TYPE_CLASSES:
223
+ if token in u:
224
+ return label
225
+ return consts.WORKSPACES_USAGE_TYPE_DEFAULT_CLASS
226
+
227
+
228
+ def _fetch_workspaces_breakdown(
229
+ cost_explorer: Any,
230
+ start: str,
231
+ end: str,
232
+ granularity: str,
233
+ service_names: list[str],
234
+ errors: list[ServiceError],
235
+ ) -> dict[str, float]:
236
+ """Split the 'Amazon WorkSpaces' line into Personal/Pools/Core via a USAGE_TYPE query."""
237
+ out: dict[str, float] = {}
238
+ next_token: str | None = None
239
+ while True:
240
+ kwargs: dict[str, Any] = {
241
+ "TimePeriod": {"Start": start, "End": end},
242
+ "Granularity": granularity,
243
+ "Metrics": ["UnblendedCost"],
244
+ # Exact names taken from the SERVICE results above — safe to filter on (no guessing).
245
+ "Filter": {"Dimensions": {"Key": "SERVICE", "Values": service_names}},
246
+ "GroupBy": [{"Type": "DIMENSION", "Key": "USAGE_TYPE"}],
247
+ }
248
+ if next_token:
249
+ kwargs["NextPageToken"] = next_token
250
+ resp = try_call(
251
+ errors,
252
+ "AWS Cost Explorer",
253
+ "GetCostAndUsage",
254
+ lambda kwargs=kwargs: cost_explorer.get_cost_and_usage(**kwargs),
255
+ default={},
256
+ )
257
+ if not resp:
258
+ break
259
+ for period in resp.get("ResultsByTime", []):
260
+ for group in period.get("Groups", []):
261
+ usage_type = (group.get("Keys") or [""])[0]
262
+ amount = float(group.get("Metrics", {}).get("UnblendedCost", {}).get("Amount", 0.0))
263
+ label = _classify_workspaces_usage_type(usage_type)
264
+ out[label] = out.get(label, 0.0) + amount
265
+ next_token = resp.get("NextPageToken")
266
+ if not next_token:
267
+ break
268
+ return {k: round(v, 2) for k, v in out.items() if round(v, 2) != 0.0}
269
+
270
+
211
271
  def get_euc_cost_summary_core(
212
272
  factory: ClientFactory,
213
273
  lookback_days: int = 30,
214
274
  granularity: str = "MONTHLY",
215
275
  start_date: str | None = None,
216
276
  end_date: str | None = None,
277
+ split_workspaces: bool = True,
217
278
  ) -> CostSummary:
218
279
  errors: list[ServiceError] = []
219
280
  # Cost Explorer is a global endpoint served from us-east-1, regardless of working region.
@@ -273,6 +334,17 @@ def get_euc_cost_summary_core(
273
334
  for s, a in sorted(totals_by_service.items(), key=lambda kv: kv[1], reverse=True)
274
335
  ]
275
336
  total = round(sum(item.amount for item in by_service), 2)
337
+
338
+ # Split the single "Amazon WorkSpaces" line into Personal/Pools/Core via USAGE_TYPE.
339
+ workspaces_breakdown: dict[str, float] = {}
340
+ core_ws = [
341
+ it.service for it in by_service if _is_core_workspaces_service(it.service) and it.amount
342
+ ]
343
+ if split_workspaces and core_ws:
344
+ workspaces_breakdown = _fetch_workspaces_breakdown(
345
+ cost_explorer, start, end, granularity, core_ws, errors
346
+ )
347
+
276
348
  by_period = [
277
349
  CostPeriod(
278
350
  start=p_start,
@@ -293,15 +365,20 @@ def get_euc_cost_summary_core(
293
365
  currency=currency,
294
366
  total=total,
295
367
  by_service=by_service,
368
+ workspaces_breakdown=workspaces_breakdown,
296
369
  by_period=by_period,
297
370
  errors=errors,
298
371
  notes=[
299
372
  "EUC services are selected by matching the Cost Explorer SERVICE name against the EUC "
300
373
  "keyword set (workspaces / appstream), so naming variants are not dropped.",
301
- "Cost Explorer bills WorkSpaces Personal, Pools, and Core together under the single "
302
- "'Amazon WorkSpaces' service; they cannot be separated via the SERVICE dimension.",
374
+ "Cost Explorer bills WorkSpaces Personal, Pools, and Core under one 'Amazon "
375
+ "WorkSpaces' SERVICE line; workspaces_breakdown splits them via USAGE_TYPE (Personal "
376
+ "vs Pools vs Core) — a heuristic on usage-type names, so treat sub-totals as "
377
+ "estimates.",
303
378
  "by_period gives the per-bucket time series (per day for DAILY, per month for MONTHLY) "
304
379
  "for charts/trends; by_service is the total across the whole window.",
380
+ "On MONTHLY granularity, AlwaysOn monthly bundle charges post on the 1st of the month, "
381
+ "so day-1 of a DAILY series legitimately spikes — it is not a Pools/Core artefact.",
305
382
  ],
306
383
  )
307
384
 
@@ -347,6 +424,7 @@ def register(mcp: Any, factory: ClientFactory) -> None:
347
424
  granularity: Literal["MONTHLY", "DAILY"] = "MONTHLY",
348
425
  start_date: str | None = None,
349
426
  end_date: str | None = None,
427
+ split_workspaces: bool = True,
350
428
  ) -> dict[str, Any]:
351
429
  """Summarize EUC spend by service over a window (account-wide via Cost Explorer).
352
430
 
@@ -356,10 +434,14 @@ def register(mcp: Any, factory: ClientFactory) -> None:
356
434
  variants are never dropped. Cost Explorer is not region-scoped, so figures are account-wide.
357
435
  Read-only.
358
436
 
359
- Returns both `by_service` (totals across the whole window) and `by_period` (a per-bucket
360
- time series — one entry per day for DAILY, per month for MONTHLY — each with its own
361
- per-service split). Use `by_period` with granularity="DAILY" to chart daily trends in a
362
- single call.
437
+ Returns `by_service` (totals per service), `by_period` (a per-bucket time series — one entry
438
+ per day for DAILY, per month for MONTHLY — for charts), and `workspaces_breakdown`: the
439
+ single "Amazon WorkSpaces" line split into **Personal / Pools / Core** via USAGE_TYPE
440
+ (which the SERVICE dimension cannot do). Use this when you need to know whether a WorkSpaces
441
+ figure is Personal-only or includes Pools/Core.
442
+
443
+ Note: on MONTHLY granularity, AlwaysOn monthly bundle charges post on the 1st, so day-1 of a
444
+ DAILY series spikes legitimately (not a Pools/Core artefact).
363
445
 
364
446
  For a specific calendar month, pass start_date/end_date instead of lookback_days — Cost
365
447
  Explorer's end is EXCLUSIVE, so for May 2026 use start_date="2026-05-01",
@@ -371,9 +453,10 @@ def register(mcp: Any, factory: ClientFactory) -> None:
371
453
  start_date: Optional inclusive start, "YYYY-MM-DD". Use with end_date; overrides
372
454
  lookback_days.
373
455
  end_date: Optional EXCLUSIVE end, "YYYY-MM-DD". Use with start_date.
456
+ split_workspaces: Split the WorkSpaces line into Personal/Pools/Core (default True).
374
457
  """
375
458
  summary = get_euc_cost_summary_core(
376
- factory, lookback_days, granularity, start_date, end_date
459
+ factory, lookback_days, granularity, start_date, end_date, split_workspaces
377
460
  )
378
461
  return summary.model_dump()
379
462