workspaces-euc-mcp-server 0.1.2__tar.gz → 0.1.4__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 (48) hide show
  1. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/CHANGELOG.md +29 -0
  2. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/DESIGN.md +51 -35
  3. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/PKG-INFO +147 -15
  4. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/README.md +146 -14
  5. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/pyproject.toml +1 -1
  6. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_cost.py +144 -0
  7. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/__init__.py +1 -1
  8. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/consts.py +10 -8
  9. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/models.py +16 -0
  10. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/cost.py +108 -38
  11. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/.dockerignore +0 -0
  12. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/.github/workflows/ci.yml +0 -0
  13. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/.github/workflows/docker-publish.yml +0 -0
  14. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/.github/workflows/publish.yml +0 -0
  15. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/.gitignore +0 -0
  16. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/.pre-commit-config.yaml +0 -0
  17. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/Dockerfile +0 -0
  18. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/LICENSE +0 -0
  19. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/iam/README.md +0 -0
  20. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/iam/tier0-diagnostics.json +0 -0
  21. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/iam/tier1-cost.json +0 -0
  22. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/iam/tier2-lifecycle.json +0 -0
  23. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/iam/tier3-destructive.json +0 -0
  24. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/scripts/smoke_readonly.py +0 -0
  25. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/__init__.py +0 -0
  26. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_clients.py +0 -0
  27. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_destructive.py +0 -0
  28. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_diagnostics.py +0 -0
  29. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_inventory.py +0 -0
  30. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_lifecycle.py +0 -0
  31. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_naming.py +0 -0
  32. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_no_embedded_secrets.py +0 -0
  33. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_performance.py +0 -0
  34. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_pricing.py +0 -0
  35. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_reporting.py +0 -0
  36. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/tests/test_secure_browser.py +0 -0
  37. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/clients.py +0 -0
  38. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/server.py +0 -0
  39. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
  40. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/_common.py +0 -0
  41. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
  42. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/diagnostics.py +0 -0
  43. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
  44. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
  45. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/performance.py +0 -0
  46. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
  47. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
  48. {workspaces_euc_mcp_server-0.1.2 → workspaces_euc_mcp_server-0.1.4}/workspaces_euc_mcp_server/tools/secure_browser.py +0 -0
@@ -5,6 +5,35 @@ All notable changes to this project are documented here. The format is based on
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.4] - 2026-06-02
9
+
10
+ ### Added
11
+ - `get_euc_cost_summary` now returns `by_period` — a per-bucket time series (one entry per day for
12
+ `DAILY`, per month for `MONTHLY`), each with its own per-service split. Previously the tool
13
+ collapsed all periods into per-service totals, so a `DAILY` request lost the daily breakdown and
14
+ clients had to query each day individually to chart trends.
15
+
16
+ ## [0.1.3] - 2026-06-02
17
+
18
+ ### Fixed
19
+ - `get_euc_cost_summary` no longer silently drops spend for services whose Cost Explorer `SERVICE`
20
+ name isn't an exact match to a hardcoded list. In real accounts this **hid all WorkSpaces
21
+ Applications spend**, which bills under the name `Amazon WorkSpaces Applications` (the AppStream
22
+ rebrand) rather than `Amazon AppStream`. EUC services are now selected by keyword
23
+ (`workspaces` / `appstream`) against every service in the period, with `Amazon WorkSpaces Thin
24
+ Client` (out of scope) explicitly excluded, and results are paginated.
25
+
26
+ ### Added
27
+ - `get_euc_cost_summary` accepts optional `start_date` / `end_date` (YYYY-MM-DD, end exclusive) to
28
+ total an exact calendar month instead of only a rolling `lookback_days` window.
29
+
30
+ ### Docs
31
+ - README: a "not an official AWS product" disclaimer at the top; an **Amazon Quick (Desktop)**
32
+ client example; an **AWS authentication** section (SSO login + auto-refresh; console sign-in does
33
+ not produce the on-disk token); and an explicit four-gate **write/destructive safety** section
34
+ noting that the launch flag grants no AWS access (IAM is still required).
35
+ - DESIGN.md reconciled with the shipped code.
36
+
8
37
  ## [0.1.2] - 2026-06-01
9
38
 
10
39
  ### Added
@@ -4,12 +4,12 @@
4
4
  > inventory, troubleshooting, cost/utilization optimization, and guarded lifecycle management
5
5
  > across the Amazon WorkSpaces family of End User Computing services.
6
6
  >
7
- > **Shipped:** 10 read-only tools (Tier 0/1) + 10 guarded write tools (Tier 2) + 3 destructive
7
+ > **Shipped:** 18 read-only tools (Tier 0/1) + 10 guarded write tools (Tier 2) + 3 destructive
8
8
  > tools (Tier 3), all behind opt-in flags with dry-run/confirm/blast-radius/typed-acknowledgement
9
- > guards. All four IAM policy tiers ship in `iam/`. CI (ruff/format/bandit/pytest on Py 3.11–3.13)
10
- > is green. See `README.md` for the live tool catalog and `CHANGELOG.md` for per-phase detail.
11
- > **Still deferred:** `recommend_bundle_rightsizing` (needs the WorkSpaces CloudWatch agent for
12
- > CPU/memory) see §10.
9
+ > guards. All four IAM policy tiers ship in `iam/`. CI (ruff/format/pyright/bandit/pytest on
10
+ > Py 3.11–3.13) is green; published to PyPI and GHCR. **`README.md` is the source of truth for the
11
+ > live tool catalog and exact tool names** §5 below records the original design intent and is
12
+ > kept reconciled with what shipped. See `CHANGELOG.md` for per-version detail.
13
13
 
14
14
  ## 1. Principles
15
15
 
@@ -55,26 +55,33 @@
55
55
  - **Observability:** Loguru with env-controlled log level (`FASTMCP_LOG_LEVEL`). Per-signal errors
56
56
  are returned in the structured result payload (deliberate: each tool makes many AWS calls and
57
57
  synthesizes one result), rather than `ctx.error`.
58
- - **Repo layout:**
58
+ - **Repo layout** (independent package — **not** under an `awslabs/` namespace):
59
59
  ```
60
- awslabs/workspaces_euc_mcp_server/
60
+ workspaces_euc_mcp_server/
61
61
  __init__.py # version
62
- server.py # FastMCP app + tool registration
63
- consts.py # service/API constants, region maps
62
+ server.py # FastMCP app + tool registration + main()/argparse
63
+ consts.py # service/API constants, region→pricing-location maps
64
64
  models.py # Pydantic request/response models
65
- clients.py # boto3 client factory (region/profile aware)
65
+ clients.py # boto3 client factory (region/profile/assume-role aware)
66
66
  tools/
67
+ _common.py # read_only/writes annotation helpers, try_call, paginate
67
68
  inventory.py
68
69
  diagnostics.py
69
70
  cost.py
71
+ performance.py # perf + usage/history tools (native CloudWatch metrics)
70
72
  reporting.py
71
- lifecycle.py # Phase 2 (guarded writes)
72
- iam/ # shippable least-privilege policy docs per tier
73
- tests/ # pytest + pytest-asyncio + moto
73
+ secure_browser.py
74
+ pricing.py # AWS Price List lookups for $ estimates
75
+ lifecycle.py # Tier 2 (guarded writes)
76
+ destructive.py # Tier 3 (terminate/rebuild/restore)
77
+ iam/ # shippable least-privilege policy docs per tier (0–3)
78
+ tests/ # pytest + pytest-asyncio (no live AWS; calls are stubbed/monkeypatched)
79
+ Dockerfile, .dockerignore
74
80
  pyproject.toml, .pre-commit-config.yaml, README.md, CHANGELOG.md, LICENSE
75
81
  ```
76
- - **Quality gates:** ruff (format/lint), pyright (types), bandit (security), moto (AWS mocks),
77
- pre-commit, Apache-2.0 headers.
82
+ - **Quality gates:** ruff (format/lint), pyright (basic type-checking), bandit (security),
83
+ pre-commit (incl. `detect-private-key`), Apache-2.0 headers. Tests stub the boto3 layer rather
84
+ than hitting AWS.
78
85
 
79
86
  ## 4. Configuration / safety flags
80
87
 
@@ -91,50 +98,59 @@ and return a synthesized result, not raw API passthroughs.
91
98
 
92
99
  ### Phase 1 — Read / Diagnose / Optimize (read-only, Tiers 0–1)
93
100
 
101
+ > Tool names below are the **shipped** names. Where the original plan used a different working
102
+ > name, the shipped name is what appears here.
103
+
94
104
  **Inventory & discovery**
95
105
  | Tool | Purpose | IAM actions |
96
106
  |---|---|---|
97
- | `list_workspaces_personal` | Personal desktops + live connection status | `workspaces:DescribeWorkspaces`, `workspaces:DescribeWorkspacesConnectionStatus`, `workspaces:DescribeWorkspaceDirectories` |
98
- | `list_workspaces_pools` | Pools + active sessions | `workspaces:DescribeWorkspacesPools`, `workspaces:DescribeWorkspacesPoolSessions` |
99
- | `list_application_fleets` | WorkSpaces Applications fleets/stacks/associations | `appstream:DescribeFleets`, `appstream:DescribeStacks`, `appstream:DescribeFleetAssociations` |
100
- | `list_secure_browser_portals` | Secure Browser portals + settings | `workspaces-web:ListPortals`, `workspaces-web:GetPortal`, `workspaces-web:List*Settings` |
101
- | `get_euc_inventory_summary` | Cross-service rollup (counts, states, regions) | union of the above describes |
107
+ | `get_euc_inventory_summary` | Cross-service rollup (counts, states, regions) across Personal, Pools, Applications, Secure Browser, Core | union of the in-scope describes |
108
+ | `generate_inventory_report` | Structured per-resource inventory across all in-scope services | Phase-1 describes |
102
109
 
103
110
  **Troubleshooting & triage** (the flagship value)
104
111
  | Tool | Purpose | IAM actions |
105
112
  |---|---|---|
106
113
  | `diagnose_workspace_connectivity` | Correlate a Personal WorkSpace's state + connection status + directory health + CloudWatch into a root-cause narrative | `workspaces:Describe*`, `ds:DescribeDirectories`, `cloudwatch:GetMetricData` |
107
- | `diagnose_pool_session` | Why a Pools session failed/queued — capacity, errors, scaling | `workspaces:DescribeWorkspacesPool*`, `cloudwatch:GetMetricData` |
108
- | `diagnose_application_fleet` | Fleet state, capacity, scaling activity, fleet errors | `appstream:DescribeFleets`, `appstream:DescribeFleetAssociations`, `cloudwatch:GetMetricData`, `application-autoscaling:DescribeScalingActivities` |
109
- | `check_directory_health` | Shared dependency: directory reachability/registration | `ds:DescribeDirectories`, `workspaces:DescribeWorkspaceDirectories` |
114
+ | `diagnose_pool` | Why a Pool is unhealthy/queued — capacity status, sessions, errors, scaling | `workspaces:DescribeWorkspacesPool*`, `cloudwatch:GetMetricData` |
115
+ | `diagnose_application_fleet` | Fleet state, capacity, scaling activity, fleet errors | `appstream:DescribeFleets`, `appstream:ListAssociatedStacks`, `cloudwatch:GetMetricData`, `application-autoscaling:DescribeScalingActivities` |
116
+ | `check_directory_health` | Shared dependency: directory reachability/registration (skips `ds` for WorkSpaces-managed `wsd-` directories) | `ds:DescribeDirectories`, `workspaces:DescribeWorkspaceDirectories` |
110
117
 
111
- **Cost & utilization optimization**
118
+ **Cost, utilization & performance**
112
119
  | Tool | Purpose | IAM actions |
113
120
  |---|---|---|
114
121
  | `analyze_workspace_utilization` | Find idle/unused Personal WorkSpaces from connection metrics | `workspaces:DescribeWorkspaces*`, `cloudwatch:GetMetricData` |
115
- | `recommend_running_mode` | AlwaysOn → AutoStop candidates with $ estimate | `workspaces:DescribeWorkspaces`, `cloudwatch:GetMetricData`, `pricing:GetProducts` |
116
- | `recommend_bundle_rightsizing` | Over/under-sized bundles from CPU/mem metrics | `workspaces:DescribeWorkspaces`, `workspaces:DescribeWorkspaceBundles`, `cloudwatch:GetMetricData` |
117
- | `analyze_pool_capacity` | Pools over/under-provisioning | `workspaces:DescribeWorkspacesPool*`, `cloudwatch:GetMetricData` |
118
- | `analyze_fleet_capacity` | Applications fleet capacity vs demand | `appstream:DescribeFleets`, `cloudwatch:GetMetricData` |
122
+ | `recommend_running_mode` | AlwaysOn → AutoStop candidates with a $ estimate | `workspaces:DescribeWorkspaces`, `cloudwatch:GetMetricData`, `pricing:GetProducts` |
123
+ | `recommend_bundle_rightsizing` | Over/under-sized bundles from native CPU/mem metrics (see §10) | `workspaces:DescribeWorkspaces`, `workspaces:DescribeWorkspaceBundles`, `cloudwatch:GetMetricData` |
124
+ | `get_workspace_performance` | Per-WorkSpace CPU/mem/GPU/FPS/latency from native `AWS/WorkSpaces` metrics | `workspaces:DescribeWorkspaces`, `cloudwatch:GetMetricData` |
125
+ | `get_workspace_connection_history` | Per-WorkSpace connection timeline | `workspaces:DescribeWorkspaces*`, `cloudwatch:GetMetricData` |
126
+ | `get_pool_session_history` | Pool session/capacity history | `workspaces:DescribeWorkspacesPool*`, `cloudwatch:GetMetricData` |
127
+ | `get_application_fleet_usage` | Applications fleet utilization vs capacity | `appstream:DescribeFleets`, `cloudwatch:GetMetricData` |
119
128
  | `get_euc_cost_summary` | Spend rollup filtered to EUC services | `ce:GetCostAndUsage`, `ce:GetDimensionValues` |
120
129
 
130
+ **Secure Browser**
131
+ | Tool | Purpose | IAM actions |
132
+ |---|---|---|
133
+ | `get_secure_browser_portal_details` | Portal config + associated settings (browser/network/user/IP-access) | `workspaces-web:GetPortal`, `workspaces-web:List*`, `workspaces-web:Get*Settings` |
134
+ | `get_secure_browser_portal_usage` | Portal session/usage metrics | `workspaces-web:ListPortals`, `cloudwatch:GetMetricData` |
135
+
121
136
  **Reporting & audit**
122
137
  | Tool | Purpose | IAM actions |
123
138
  |---|---|---|
124
- | `generate_inventory_report` | Structured inventory across all in-scope services | Phase-1 describes |
125
139
  | `audit_security_posture` | Encryption at rest, IP access control groups, directory config, portal policies | `workspaces:Describe*`, `workspaces-web:Get*/List*`, `appstream:Describe*` |
126
- | `list_unused_resources` | Idle desktops / empty fleets / orphaned associations | describes + `cloudwatch:GetMetricData` |
140
+ | `list_unused_resources` | Idle desktops / empty fleets / orphaned resources | describes + `cloudwatch:GetMetricData` |
127
141
 
128
142
  ### Phase 2 — Guarded lifecycle (writes; Tier 2, `--enable-writes`)
129
143
  All support `dry_run`, return a plan + blast-radius before acting, and honor `--max-bulk-targets`.
130
144
 
131
145
  | Tool | Purpose | IAM actions |
132
146
  |---|---|---|
133
- | `start_workspaces` / `stop_workspaces` / `reboot_workspaces` | Power ops | `workspaces:Start/Stop/RebootWorkspaces` |
147
+ | `start_workspaces` / `stop_workspaces` / `reboot_workspaces` | Personal power ops | `workspaces:Start/Stop/RebootWorkspaces` |
134
148
  | `modify_workspace_running_mode` | Apply AutoStop/AlwaysOn recommendation | `workspaces:ModifyWorkspaceProperties` |
135
- | `modify_workspace_compute_type` | Apply right-sizing recommendation | `workspaces:ModifyWorkspaceProperties` |
136
- | `update_pool_capacity` | Resize a Pool | `workspaces:UpdateWorkspacesPool` |
137
- | `start_application_fleet` / `stop_application_fleet` / `update_fleet_capacity` | Applications fleet ops | `appstream:Start/Stop/UpdateFleet` |
149
+ | `start_workspaces_pool` / `stop_workspaces_pool` / `update_workspaces_pool_capacity` | Pool power + resize | `workspaces:Start/Stop/UpdateWorkspacesPool` |
150
+ | `start_application_fleet` / `stop_application_fleet` / `update_application_fleet_capacity` | Applications fleet ops | `appstream:Start/Stop/UpdateFleet` |
151
+
152
+ > Note: bundle right-sizing is **recommend-only** (`recommend_bundle_rightsizing`); there is no
153
+ > `modify_workspace_compute_type` write tool — applying a compute change is left to the operator.
138
154
 
139
155
  ### Phase 3 — Destructive (Tier 3, `--enable-destructive`, hardest gating)
140
156
  | Tool | Purpose | IAM actions |
@@ -177,7 +193,7 @@ human operator** (the pattern the AWS MCP Server uses).
177
193
  ## 8. Phased roadmap
178
194
 
179
195
  - **Phase 0 — Scaffold:** repo per awslabs layout, client factory, auth, `--readonly` flag,
180
- one end-to-end tool (`get_euc_inventory_summary`), tests with moto, CI/pre-commit. Ship to
196
+ one end-to-end tool (`get_euc_inventory_summary`), tests (stubbed boto3), CI/pre-commit. Ship to
181
197
  internal users.
182
198
  - **Phase 1 — Read/Diagnose/Optimize:** full inventory + diagnostics + cost tools (Tiers 0–1).
183
199
  This is the demonstrable-value milestone.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workspaces-euc-mcp-server
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -32,6 +32,14 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  [![CI](https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp/actions/workflows/ci.yml)
34
34
 
35
+ > **Disclaimer:** This project is not an official AWS product, service, or solution, and is not
36
+ > affiliated with or endorsed by Amazon Web Services. It is an independent, community example
37
+ > implementation intended to demonstrate what is possible when combining the Amazon WorkSpaces End
38
+ > User Computing APIs with the Model Context Protocol. It is provided as a starting point to learn
39
+ > from, adapt, and iterate on — not as a supported or production-certified offering. Use it at your
40
+ > own discretion and always validate it against your organisation's security, compliance, and
41
+ > operational requirements before deploying to production.
42
+
35
43
  An [MCP](https://modelcontextprotocol.io) server that gives administrators AI-assisted
36
44
  **inventory, troubleshooting, and cost/utilization optimization** across the Amazon WorkSpaces
37
45
  End User Computing (EUC) portfolio:
@@ -78,7 +86,7 @@ diagnosis, a right-sizing recommendation) instead of returning raw API output. S
78
86
  | `get_workspace_connection_history` | A desktop's connection/session **history** (UserConnected + connection attempts/failures) over a window, with a summary (Tier 0). |
79
87
  | `get_pool_session_history` | A WorkSpaces Pool's user-**session history** (active/available/utilization capacity time-series), flags idle pool capacity (Tier 0). |
80
88
  | `recommend_bundle_rightsizing` | Suggests smaller/larger compute types from CPU & memory headroom (general families; graphics excluded) (Tier 0). |
81
- | `get_euc_cost_summary` | EUC spend by service over a window via Cost Explorer, account-wide (Tier 1). |
89
+ | `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). |
82
90
  | `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). |
83
91
  | `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). |
84
92
  | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, and attached policies (Tier 0). |
@@ -201,6 +209,140 @@ Running from a source checkout instead of `uvx`:
201
209
  }
202
210
  ```
203
211
 
212
+ The two blocks above are the standard `mcpServers` shape used by most clients (Claude Desktop,
213
+ Cursor, etc.). Some clients use a **form** instead of raw JSON — see the example below.
214
+
215
+ ### Example: Amazon Quick (Desktop)
216
+
217
+ Amazon Quick's **Capabilities → MCP → Add MCP** uses a form rather than JSON. Choose connection
218
+ type **Local** ("Run a command on your machine") and fill in:
219
+
220
+ | Field | Value |
221
+ |-------|-------|
222
+ | **Name** | `WorkSpaces EUC` |
223
+ | **Command** | `uvx` |
224
+ | **Arguments** | `workspaces-euc-mcp-server@latest --region us-east-1` |
225
+ | **Description** | `Admin tools for Amazon WorkSpaces EUC — inventory, troubleshooting, cost/utilization. Read-only.` |
226
+ | **Environment variables** | `AWS_PROFILE` = `your-euc-admin-profile` · `AWS_REGION` = `us-east-1` |
227
+ | **Timeout (seconds)** | `60` |
228
+
229
+ Notes:
230
+ - Set `--region` (in Arguments) and `AWS_REGION` to where your WorkSpaces actually live; keep the
231
+ two in sync.
232
+ - **Bump the timeout from the default 30 → 60–120 for the first run** — `uvx` downloads the package
233
+ on first launch, which can exceed 30 s and look like a failed connection. Later starts are fast.
234
+ - If `uvx` isn't on your `PATH`, use Command `workspaces-euc-mcp-server` (after
235
+ `pip install workspaces-euc-mcp-server`) with Arguments `--region us-east-1`, or Command `python`
236
+ with Arguments `-m workspaces_euc_mcp_server.server --region us-east-1`.
237
+ - To enable writes/destructive, append the flags to **Arguments** (e.g.
238
+ `… --enable-writes --max-bulk-targets 10`) — and attach the matching IAM tier (see
239
+ [the safety gates](#enabling-write--destructive-tools--the-safety-gates)).
240
+ - A local MCP server has **no "Sign in" button** (that's a Quick *Connections* feature). It uses
241
+ your AWS credential chain, so authenticate first — e.g. `aws sso login --profile your-profile` for
242
+ SSO — then the server connects. See [AWS authentication](#aws-authentication).
243
+
244
+ ## AWS authentication
245
+
246
+ The server uses the **standard AWS credential chain** — it does not handle sign-in itself. Provide
247
+ credentials however boto3 expects them:
248
+
249
+ - **IAM Identity Center (SSO):** configure an `sso-session` profile, then `aws sso login --profile
250
+ <name>`. With the modern `sso-session` format, botocore **auto-refreshes** the token for the
251
+ session window, so you log in once rather than every few hours. (Signing into the AWS Console or
252
+ access portal in a browser does **not** create the on-disk token the server needs — only
253
+ `aws sso login` does.)
254
+ - **Static / temporary keys:** a named profile in `~/.aws/credentials`, or `AWS_ACCESS_KEY_ID` /
255
+ `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` env vars.
256
+ - **Assumed role / multi-account:** see [Multi-account / MSP](#multi-account--msp).
257
+
258
+ If calls fail with an expired-token / `UnauthorizedException` error, your session has lapsed —
259
+ re-authenticate (for SSO, `aws sso login --profile <name>`) and retry. Nothing in the MCP config
260
+ changes.
261
+
262
+ ## Enabling write / destructive tools — the safety gates
263
+
264
+ The config above is **read-only**: the write and destructive tools are not even registered, so
265
+ they cannot be called no matter what the model or the IAM policy allows. Enabling them is a
266
+ deliberate act, and **IAM permissions alone are not enough**. A destructive call (e.g.
267
+ `terminate_workspaces`) only runs after clearing **all four** of these independent gates:
268
+
269
+ | # | Gate | Where it lives | What it does |
270
+ |---|------|----------------|--------------|
271
+ | 1 | **Launch flag** | The `args` in your MCP-client config | Destructive tools aren't registered unless the server was started with **both** `--enable-writes` **and** `--enable-destructive`. Default launch = read-only. |
272
+ | 2 | **IAM tier** | The AWS profile / assumed role | The credentials must carry the matching tier ([`iam/tier2-lifecycle.json`](iam/tier2-lifecycle.json) for writes, [`iam/tier3-destructive.json`](iam/tier3-destructive.json) for destructive). Without it, AWS denies the call. |
273
+ | 3 | **`confirm=true`** | The tool call | Every mutation is **dry-run by default** — it returns a plan and changes nothing. You must explicitly pass `confirm=true`. |
274
+ | 4 | **Typed acknowledgement + blast-radius cap** | The tool call | Destructive ops also require the **exact** phrase (`acknowledge="TERMINATE"` / `"REBUILD"` / `"RESTORE"`) and must stay within `--max-bulk-targets`. Wrong phrase or too many targets → refused, nothing changes. |
275
+
276
+ > **The launch flag grants no AWS permission.** Gate 1 (the `--enable-*` flag) and Gate 2 (IAM) are
277
+ > two *separate* switches and you need **both**. Turning on `--enable-destructive` only *exposes* the
278
+ > tool in the client — it does not give your AWS profile any access. If the flag is on but the
279
+ > profile/role is **missing the matching tier policy**, the tool is callable and its dry-run works,
280
+ > but the real `confirm=true` call **fails with an AWS `AccessDenied` error and nothing is deleted**.
281
+ > So enabling writes/destructive in your MCP client is necessary but **not** sufficient: you must
282
+ > *also* attach [`iam/tier2-lifecycle.json`](iam/tier2-lifecycle.json) /
283
+ > [`iam/tier3-destructive.json`](iam/tier3-destructive.json) to the AWS profile (or assumed role)
284
+ > the server runs as.
285
+ >
286
+ > Gates **1–2** are the real security boundary (config + AWS-enforced IAM). Gates **3–4** are
287
+ > in-tool guardrails against an over-eager agent or a fat-fingered single call — they are **not** a
288
+ > substitute for IAM. The genuine least-privilege control is: **don't grant Tier 2/3 and don't pass
289
+ > the flags unless you truly want those operations available.** For an extra backstop, scope the
290
+ > Tier 2/3 policy with resource tags / conditions so even an enabled server can only touch a bounded
291
+ > set of resources.
292
+
293
+ **MCP-client config — writes enabled (Tier 2):** add the flag to `args` and attach the Tier 2 policy.
294
+
295
+ ```json
296
+ {
297
+ "mcpServers": {
298
+ "workspaces-euc": {
299
+ "command": "uvx",
300
+ "args": ["workspaces-euc-mcp-server@latest", "--enable-writes", "--max-bulk-targets", "10"],
301
+ "env": {
302
+ "AWS_PROFILE": "your-euc-admin-profile",
303
+ "AWS_REGION": "us-east-1"
304
+ }
305
+ }
306
+ }
307
+ }
308
+ ```
309
+
310
+ **MCP-client config — destructive enabled (Tier 3):** both flags, and attach the Tier 3 policy. Use
311
+ a tightly-scoped profile.
312
+
313
+ ```json
314
+ {
315
+ "mcpServers": {
316
+ "workspaces-euc": {
317
+ "command": "uvx",
318
+ "args": [
319
+ "workspaces-euc-mcp-server@latest",
320
+ "--enable-writes",
321
+ "--enable-destructive",
322
+ "--max-bulk-targets", "5"
323
+ ],
324
+ "env": {
325
+ "AWS_PROFILE": "your-euc-admin-profile",
326
+ "AWS_REGION": "us-east-1"
327
+ }
328
+ }
329
+ }
330
+ }
331
+ ```
332
+
333
+ Even with the destructive config above, a call still does nothing until gates 3–4 are satisfied:
334
+
335
+ ```text
336
+ terminate_workspaces(workspace_ids=["ws-abc123"], confirm=true, acknowledge="TERMINATE")
337
+ ```
338
+
339
+ Equivalent CLI launches (e.g. for the Docker entrypoint or a shell):
340
+
341
+ ```bash
342
+ workspaces-euc-mcp-server --enable-writes --max-bulk-targets 10 # Tier 2 only
343
+ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-targets 5 # + Tier 3
344
+ ```
345
+
204
346
  ## Command-line flags
205
347
 
206
348
  | Flag | Default | Purpose |
@@ -256,19 +398,9 @@ pick the right tool. A few examples:
256
398
  | "Reboot these three stuck desktops." | `reboot_workspaces` (dry-run, then `confirm=true`) |
257
399
 
258
400
  **Write/destructive tools are off unless enabled.** Mutations show a dry-run plan first; to execute
259
- you pass `confirm=true` (and, for destructive ops, the exact acknowledge phrase). For example, a
260
- full destructive run looks like:
261
-
262
- ```text
263
- terminate_workspaces(workspace_ids=["ws-abc123"], confirm=true, acknowledge="TERMINATE")
264
- ```
265
-
266
- Launch with writes/destructive enabled:
267
-
268
- ```bash
269
- workspaces-euc-mcp-server --enable-writes # Tier 2 power/capacity tools
270
- workspaces-euc-mcp-server --enable-writes --enable-destructive # + Tier 3 terminate/rebuild/restore
271
- ```
401
+ you pass `confirm=true` (and, for destructive ops, the exact acknowledge phrase). See
402
+ [Enabling write / destructive tools the safety gates](#enabling-write--destructive-tools--the-safety-gates)
403
+ for how to turn them on (MCP-client config + IAM) and the four gates each call must clear.
272
404
 
273
405
  ## Development
274
406
 
@@ -2,6 +2,14 @@
2
2
 
3
3
  [![CI](https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp/actions/workflows/ci.yml)
4
4
 
5
+ > **Disclaimer:** This project is not an official AWS product, service, or solution, and is not
6
+ > affiliated with or endorsed by Amazon Web Services. It is an independent, community example
7
+ > implementation intended to demonstrate what is possible when combining the Amazon WorkSpaces End
8
+ > User Computing APIs with the Model Context Protocol. It is provided as a starting point to learn
9
+ > from, adapt, and iterate on — not as a supported or production-certified offering. Use it at your
10
+ > own discretion and always validate it against your organisation's security, compliance, and
11
+ > operational requirements before deploying to production.
12
+
5
13
  An [MCP](https://modelcontextprotocol.io) server that gives administrators AI-assisted
6
14
  **inventory, troubleshooting, and cost/utilization optimization** across the Amazon WorkSpaces
7
15
  End User Computing (EUC) portfolio:
@@ -48,7 +56,7 @@ diagnosis, a right-sizing recommendation) instead of returning raw API output. S
48
56
  | `get_workspace_connection_history` | A desktop's connection/session **history** (UserConnected + connection attempts/failures) over a window, with a summary (Tier 0). |
49
57
  | `get_pool_session_history` | A WorkSpaces Pool's user-**session history** (active/available/utilization capacity time-series), flags idle pool capacity (Tier 0). |
50
58
  | `recommend_bundle_rightsizing` | Suggests smaller/larger compute types from CPU & memory headroom (general families; graphics excluded) (Tier 0). |
51
- | `get_euc_cost_summary` | EUC spend by service over a window via Cost Explorer, account-wide (Tier 1). |
59
+ | `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). |
52
60
  | `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). |
53
61
  | `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). |
54
62
  | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, and attached policies (Tier 0). |
@@ -171,6 +179,140 @@ Running from a source checkout instead of `uvx`:
171
179
  }
172
180
  ```
173
181
 
182
+ The two blocks above are the standard `mcpServers` shape used by most clients (Claude Desktop,
183
+ Cursor, etc.). Some clients use a **form** instead of raw JSON — see the example below.
184
+
185
+ ### Example: Amazon Quick (Desktop)
186
+
187
+ Amazon Quick's **Capabilities → MCP → Add MCP** uses a form rather than JSON. Choose connection
188
+ type **Local** ("Run a command on your machine") and fill in:
189
+
190
+ | Field | Value |
191
+ |-------|-------|
192
+ | **Name** | `WorkSpaces EUC` |
193
+ | **Command** | `uvx` |
194
+ | **Arguments** | `workspaces-euc-mcp-server@latest --region us-east-1` |
195
+ | **Description** | `Admin tools for Amazon WorkSpaces EUC — inventory, troubleshooting, cost/utilization. Read-only.` |
196
+ | **Environment variables** | `AWS_PROFILE` = `your-euc-admin-profile` · `AWS_REGION` = `us-east-1` |
197
+ | **Timeout (seconds)** | `60` |
198
+
199
+ Notes:
200
+ - Set `--region` (in Arguments) and `AWS_REGION` to where your WorkSpaces actually live; keep the
201
+ two in sync.
202
+ - **Bump the timeout from the default 30 → 60–120 for the first run** — `uvx` downloads the package
203
+ on first launch, which can exceed 30 s and look like a failed connection. Later starts are fast.
204
+ - If `uvx` isn't on your `PATH`, use Command `workspaces-euc-mcp-server` (after
205
+ `pip install workspaces-euc-mcp-server`) with Arguments `--region us-east-1`, or Command `python`
206
+ with Arguments `-m workspaces_euc_mcp_server.server --region us-east-1`.
207
+ - To enable writes/destructive, append the flags to **Arguments** (e.g.
208
+ `… --enable-writes --max-bulk-targets 10`) — and attach the matching IAM tier (see
209
+ [the safety gates](#enabling-write--destructive-tools--the-safety-gates)).
210
+ - A local MCP server has **no "Sign in" button** (that's a Quick *Connections* feature). It uses
211
+ your AWS credential chain, so authenticate first — e.g. `aws sso login --profile your-profile` for
212
+ SSO — then the server connects. See [AWS authentication](#aws-authentication).
213
+
214
+ ## AWS authentication
215
+
216
+ The server uses the **standard AWS credential chain** — it does not handle sign-in itself. Provide
217
+ credentials however boto3 expects them:
218
+
219
+ - **IAM Identity Center (SSO):** configure an `sso-session` profile, then `aws sso login --profile
220
+ <name>`. With the modern `sso-session` format, botocore **auto-refreshes** the token for the
221
+ session window, so you log in once rather than every few hours. (Signing into the AWS Console or
222
+ access portal in a browser does **not** create the on-disk token the server needs — only
223
+ `aws sso login` does.)
224
+ - **Static / temporary keys:** a named profile in `~/.aws/credentials`, or `AWS_ACCESS_KEY_ID` /
225
+ `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` env vars.
226
+ - **Assumed role / multi-account:** see [Multi-account / MSP](#multi-account--msp).
227
+
228
+ If calls fail with an expired-token / `UnauthorizedException` error, your session has lapsed —
229
+ re-authenticate (for SSO, `aws sso login --profile <name>`) and retry. Nothing in the MCP config
230
+ changes.
231
+
232
+ ## Enabling write / destructive tools — the safety gates
233
+
234
+ The config above is **read-only**: the write and destructive tools are not even registered, so
235
+ they cannot be called no matter what the model or the IAM policy allows. Enabling them is a
236
+ deliberate act, and **IAM permissions alone are not enough**. A destructive call (e.g.
237
+ `terminate_workspaces`) only runs after clearing **all four** of these independent gates:
238
+
239
+ | # | Gate | Where it lives | What it does |
240
+ |---|------|----------------|--------------|
241
+ | 1 | **Launch flag** | The `args` in your MCP-client config | Destructive tools aren't registered unless the server was started with **both** `--enable-writes` **and** `--enable-destructive`. Default launch = read-only. |
242
+ | 2 | **IAM tier** | The AWS profile / assumed role | The credentials must carry the matching tier ([`iam/tier2-lifecycle.json`](iam/tier2-lifecycle.json) for writes, [`iam/tier3-destructive.json`](iam/tier3-destructive.json) for destructive). Without it, AWS denies the call. |
243
+ | 3 | **`confirm=true`** | The tool call | Every mutation is **dry-run by default** — it returns a plan and changes nothing. You must explicitly pass `confirm=true`. |
244
+ | 4 | **Typed acknowledgement + blast-radius cap** | The tool call | Destructive ops also require the **exact** phrase (`acknowledge="TERMINATE"` / `"REBUILD"` / `"RESTORE"`) and must stay within `--max-bulk-targets`. Wrong phrase or too many targets → refused, nothing changes. |
245
+
246
+ > **The launch flag grants no AWS permission.** Gate 1 (the `--enable-*` flag) and Gate 2 (IAM) are
247
+ > two *separate* switches and you need **both**. Turning on `--enable-destructive` only *exposes* the
248
+ > tool in the client — it does not give your AWS profile any access. If the flag is on but the
249
+ > profile/role is **missing the matching tier policy**, the tool is callable and its dry-run works,
250
+ > but the real `confirm=true` call **fails with an AWS `AccessDenied` error and nothing is deleted**.
251
+ > So enabling writes/destructive in your MCP client is necessary but **not** sufficient: you must
252
+ > *also* attach [`iam/tier2-lifecycle.json`](iam/tier2-lifecycle.json) /
253
+ > [`iam/tier3-destructive.json`](iam/tier3-destructive.json) to the AWS profile (or assumed role)
254
+ > the server runs as.
255
+ >
256
+ > Gates **1–2** are the real security boundary (config + AWS-enforced IAM). Gates **3–4** are
257
+ > in-tool guardrails against an over-eager agent or a fat-fingered single call — they are **not** a
258
+ > substitute for IAM. The genuine least-privilege control is: **don't grant Tier 2/3 and don't pass
259
+ > the flags unless you truly want those operations available.** For an extra backstop, scope the
260
+ > Tier 2/3 policy with resource tags / conditions so even an enabled server can only touch a bounded
261
+ > set of resources.
262
+
263
+ **MCP-client config — writes enabled (Tier 2):** add the flag to `args` and attach the Tier 2 policy.
264
+
265
+ ```json
266
+ {
267
+ "mcpServers": {
268
+ "workspaces-euc": {
269
+ "command": "uvx",
270
+ "args": ["workspaces-euc-mcp-server@latest", "--enable-writes", "--max-bulk-targets", "10"],
271
+ "env": {
272
+ "AWS_PROFILE": "your-euc-admin-profile",
273
+ "AWS_REGION": "us-east-1"
274
+ }
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ **MCP-client config — destructive enabled (Tier 3):** both flags, and attach the Tier 3 policy. Use
281
+ a tightly-scoped profile.
282
+
283
+ ```json
284
+ {
285
+ "mcpServers": {
286
+ "workspaces-euc": {
287
+ "command": "uvx",
288
+ "args": [
289
+ "workspaces-euc-mcp-server@latest",
290
+ "--enable-writes",
291
+ "--enable-destructive",
292
+ "--max-bulk-targets", "5"
293
+ ],
294
+ "env": {
295
+ "AWS_PROFILE": "your-euc-admin-profile",
296
+ "AWS_REGION": "us-east-1"
297
+ }
298
+ }
299
+ }
300
+ }
301
+ ```
302
+
303
+ Even with the destructive config above, a call still does nothing until gates 3–4 are satisfied:
304
+
305
+ ```text
306
+ terminate_workspaces(workspace_ids=["ws-abc123"], confirm=true, acknowledge="TERMINATE")
307
+ ```
308
+
309
+ Equivalent CLI launches (e.g. for the Docker entrypoint or a shell):
310
+
311
+ ```bash
312
+ workspaces-euc-mcp-server --enable-writes --max-bulk-targets 10 # Tier 2 only
313
+ workspaces-euc-mcp-server --enable-writes --enable-destructive --max-bulk-targets 5 # + Tier 3
314
+ ```
315
+
174
316
  ## Command-line flags
175
317
 
176
318
  | Flag | Default | Purpose |
@@ -226,19 +368,9 @@ pick the right tool. A few examples:
226
368
  | "Reboot these three stuck desktops." | `reboot_workspaces` (dry-run, then `confirm=true`) |
227
369
 
228
370
  **Write/destructive tools are off unless enabled.** Mutations show a dry-run plan first; to execute
229
- you pass `confirm=true` (and, for destructive ops, the exact acknowledge phrase). For example, a
230
- full destructive run looks like:
231
-
232
- ```text
233
- terminate_workspaces(workspace_ids=["ws-abc123"], confirm=true, acknowledge="TERMINATE")
234
- ```
235
-
236
- Launch with writes/destructive enabled:
237
-
238
- ```bash
239
- workspaces-euc-mcp-server --enable-writes # Tier 2 power/capacity tools
240
- workspaces-euc-mcp-server --enable-writes --enable-destructive # + Tier 3 terminate/rebuild/restore
241
- ```
371
+ you pass `confirm=true` (and, for destructive ops, the exact acknowledge phrase). See
372
+ [Enabling write / destructive tools the safety gates](#enabling-write--destructive-tools--the-safety-gates)
373
+ for how to turn them on (MCP-client config + IAM) and the four gates each call must clear.
242
374
 
243
375
  ## Development
244
376
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workspaces-euc-mcp-server"
3
- version = "0.1.2"
3
+ version = "0.1.4"
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"
@@ -129,6 +129,150 @@ def test_cost_summary_aggregates_by_service():
129
129
  assert summary.by_service[1].amount == 40.0
130
130
 
131
131
 
132
+ def test_cost_summary_keyword_matches_variants_and_excludes_noneuc():
133
+ # Real-world Cost Explorer SERVICE names: AppStream now bills as "Amazon WorkSpaces
134
+ # Applications" (the rebrand that the old exact-match list missed) and must be captured;
135
+ # "Amazon WorkSpaces Thin Client" contains "workspaces" but is OUT of scope and must be
136
+ # excluded; non-EUC services (EC2) are excluded.
137
+ ce = types.SimpleNamespace(
138
+ get_cost_and_usage=lambda **_: {
139
+ "ResultsByTime": [
140
+ {
141
+ "Groups": [
142
+ {
143
+ "Keys": ["Amazon WorkSpaces Applications"],
144
+ "Metrics": {"UnblendedCost": {"Amount": "1006.85", "Unit": "USD"}},
145
+ },
146
+ {
147
+ "Keys": ["Amazon WorkSpaces"],
148
+ "Metrics": {"UnblendedCost": {"Amount": "694.97", "Unit": "USD"}},
149
+ },
150
+ {
151
+ "Keys": ["Amazon WorkSpaces Thin Client"],
152
+ "Metrics": {"UnblendedCost": {"Amount": "6.00", "Unit": "USD"}},
153
+ },
154
+ {
155
+ "Keys": ["Amazon Elastic Compute Cloud - Compute"],
156
+ "Metrics": {"UnblendedCost": {"Amount": "485.58", "Unit": "USD"}},
157
+ },
158
+ ]
159
+ }
160
+ ]
161
+ }
162
+ )
163
+ factory = FakeFactory({consts.COST_EXPLORER_API: ce})
164
+
165
+ summary = cost.get_euc_cost_summary_core(factory, lookback_days=30)
166
+
167
+ # WorkSpaces Applications captured, Thin Client + EC2 excluded.
168
+ assert {li.service for li in summary.by_service} == {
169
+ "Amazon WorkSpaces Applications",
170
+ "Amazon WorkSpaces",
171
+ }
172
+ assert summary.total == 1701.82
173
+
174
+
175
+ def test_cost_summary_daily_returns_per_period_time_series():
176
+ # DAILY granularity must preserve the per-day breakdown in by_period (for charts), not just
177
+ # collapse everything into by_service totals.
178
+ ce = types.SimpleNamespace(
179
+ get_cost_and_usage=lambda **_: {
180
+ "ResultsByTime": [
181
+ {
182
+ "TimePeriod": {"Start": "2026-05-01", "End": "2026-05-02"},
183
+ "Groups": [
184
+ {
185
+ "Keys": ["Amazon WorkSpaces Applications"],
186
+ "Metrics": {"UnblendedCost": {"Amount": "30.00", "Unit": "USD"}},
187
+ }
188
+ ],
189
+ },
190
+ {
191
+ "TimePeriod": {"Start": "2026-05-02", "End": "2026-05-03"},
192
+ "Groups": [
193
+ {
194
+ "Keys": ["Amazon WorkSpaces Applications"],
195
+ "Metrics": {"UnblendedCost": {"Amount": "45.00", "Unit": "USD"}},
196
+ }
197
+ ],
198
+ },
199
+ ]
200
+ }
201
+ )
202
+ factory = FakeFactory({consts.COST_EXPLORER_API: ce})
203
+
204
+ summary = cost.get_euc_cost_summary_core(
205
+ factory, granularity="DAILY", start_date="2026-05-01", end_date="2026-05-03"
206
+ )
207
+
208
+ # Aggregate total preserved...
209
+ assert summary.total == 75.0
210
+ # ...and the daily series is available, ordered by date.
211
+ assert [(p.start, p.total) for p in summary.by_period] == [
212
+ ("2026-05-01", 30.0),
213
+ ("2026-05-02", 45.0),
214
+ ]
215
+ assert summary.by_period[0].by_service[0].service == "Amazon WorkSpaces Applications"
216
+
217
+
218
+ def test_cost_summary_explicit_date_range_overrides_lookback():
219
+ captured: dict = {}
220
+
221
+ def get_cost_and_usage(**kwargs):
222
+ captured.update(kwargs)
223
+ return {"ResultsByTime": []}
224
+
225
+ ce = types.SimpleNamespace(get_cost_and_usage=get_cost_and_usage)
226
+ factory = FakeFactory({consts.COST_EXPLORER_API: ce})
227
+
228
+ summary = cost.get_euc_cost_summary_core(
229
+ factory, start_date="2026-05-01", end_date="2026-06-01"
230
+ )
231
+
232
+ assert captured["TimePeriod"] == {"Start": "2026-05-01", "End": "2026-06-01"}
233
+ assert summary.start == "2026-05-01"
234
+ assert summary.end == "2026-06-01"
235
+
236
+
237
+ def test_cost_summary_follows_pagination():
238
+ page1 = {
239
+ "ResultsByTime": [
240
+ {
241
+ "Groups": [
242
+ {
243
+ "Keys": ["Amazon WorkSpaces"],
244
+ "Metrics": {"UnblendedCost": {"Amount": "10.00", "Unit": "USD"}},
245
+ }
246
+ ]
247
+ }
248
+ ],
249
+ "NextPageToken": "p2",
250
+ }
251
+ page2 = {
252
+ "ResultsByTime": [
253
+ {
254
+ "Groups": [
255
+ {
256
+ "Keys": ["Amazon AppStream"],
257
+ "Metrics": {"UnblendedCost": {"Amount": "20.00", "Unit": "USD"}},
258
+ }
259
+ ]
260
+ }
261
+ ]
262
+ }
263
+
264
+ def get_cost_and_usage(**kwargs):
265
+ return page2 if kwargs.get("NextPageToken") == "p2" else page1
266
+
267
+ ce = types.SimpleNamespace(get_cost_and_usage=get_cost_and_usage)
268
+ factory = FakeFactory({consts.COST_EXPLORER_API: ce})
269
+
270
+ summary = cost.get_euc_cost_summary_core(factory, lookback_days=30)
271
+
272
+ assert summary.total == 30.0
273
+ assert {li.service for li in summary.by_service} == {"Amazon WorkSpaces", "Amazon AppStream"}
274
+
275
+
132
276
  def test_cost_summary_records_errors_gracefully():
133
277
  from botocore.exceptions import ClientError
134
278
 
@@ -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.2"
6
+ __version__ = "0.1.4"
@@ -26,14 +26,16 @@ PRICING_API = "pricing" # AWS Price List (global).
26
26
  # Cost Explorer is a global endpoint served from us-east-1 regardless of the working region.
27
27
  COST_EXPLORER_REGION = "us-east-1"
28
28
 
29
- # Cost Explorer SERVICE dimension values that map to the EUC portfolio. Names can vary by
30
- # account/era; the filter simply ignores values that produce no results.
31
- EUC_COST_EXPLORER_SERVICES = [
32
- "Amazon WorkSpaces",
33
- "Amazon AppStream",
34
- "Amazon WorkSpaces Web",
35
- "Amazon WorkSpaces Secure Browser",
36
- ]
29
+ # Substrings (lowercased) that identify an EUC service in the Cost Explorer SERVICE dimension.
30
+ # Matched in code against every service returned NOT used as an exact-name server-side filter —
31
+ # so account/era naming variants (e.g. "Amazon AppStream 2.0") and casing differences can never be
32
+ # silently dropped. "workspaces" covers Personal/Pools/Core/Web/Secure Browser; "appstream" covers
33
+ # WorkSpaces Applications (AppStream 2.0).
34
+ EUC_COST_EXPLORER_SERVICE_TOKENS = ["workspaces", "appstream"]
35
+ # Substrings that EXCLUDE a service even when an include token matches. WorkSpaces Thin Client is
36
+ # out of scope for this server, but its Cost Explorer name ("Amazon WorkSpaces Thin Client")
37
+ # contains "workspaces" — so exclude it explicitly.
38
+ EUC_COST_EXPLORER_EXCLUDE_TOKENS = ["thin client"]
37
39
 
38
40
  # Current official product names (used in all human-facing output).
39
41
  PRODUCT_WORKSPACES_PERSONAL = "Amazon WorkSpaces Personal"
@@ -231,6 +231,15 @@ class CostLineItem(BaseModel):
231
231
  amount: float
232
232
 
233
233
 
234
+ class CostPeriod(BaseModel):
235
+ """One time bucket (a day or a month, per the requested granularity)."""
236
+
237
+ start: str
238
+ end: str
239
+ total: float
240
+ by_service: list[CostLineItem] = Field(default_factory=list)
241
+
242
+
234
243
  class CostSummary(BaseModel):
235
244
  """Cost rollup for the EUC portfolio over a time window (account-wide)."""
236
245
 
@@ -244,6 +253,13 @@ class CostSummary(BaseModel):
244
253
  currency: str = "USD"
245
254
  total: float
246
255
  by_service: list[CostLineItem] = Field(default_factory=list)
256
+ by_period: list[CostPeriod] = Field(
257
+ default_factory=list,
258
+ description=(
259
+ "Per-bucket time series (one entry per day for DAILY, per month for MONTHLY). "
260
+ "Use this for charts / trend analysis."
261
+ ),
262
+ )
247
263
  errors: list[ServiceError] = Field(default_factory=list)
248
264
  notes: list[str] = Field(default_factory=list)
249
265
 
@@ -20,6 +20,7 @@ from .. import consts
20
20
  from ..clients import ClientFactory
21
21
  from ..models import (
22
22
  CostLineItem,
23
+ CostPeriod,
23
24
  CostSummary,
24
25
  Recommendation,
25
26
  RecommendationReport,
@@ -195,51 +196,95 @@ def recommend_running_mode_core(
195
196
  )
196
197
 
197
198
 
199
+ def _is_euc_service(service_name: str) -> bool:
200
+ """True if a Cost Explorer SERVICE value belongs to the EUC portfolio.
201
+
202
+ Matches by keyword rather than exact name, so account/era naming variants
203
+ (e.g. "Amazon AppStream 2.0") are never silently excluded.
204
+ """
205
+ name = service_name.lower()
206
+ if any(token in name for token in consts.EUC_COST_EXPLORER_EXCLUDE_TOKENS):
207
+ return False
208
+ return any(token in name for token in consts.EUC_COST_EXPLORER_SERVICE_TOKENS)
209
+
210
+
198
211
  def get_euc_cost_summary_core(
199
- factory: ClientFactory, lookback_days: int = 30, granularity: str = "MONTHLY"
212
+ factory: ClientFactory,
213
+ lookback_days: int = 30,
214
+ granularity: str = "MONTHLY",
215
+ start_date: str | None = None,
216
+ end_date: str | None = None,
200
217
  ) -> CostSummary:
201
218
  errors: list[ServiceError] = []
202
219
  # Cost Explorer is a global endpoint served from us-east-1, regardless of working region.
203
220
  cost_explorer = factory.client(consts.COST_EXPLORER_API, region=consts.COST_EXPLORER_REGION)
204
221
 
205
- end_date = datetime.now(UTC).date()
206
- start_date = end_date - timedelta(days=lookback_days)
207
- start, end = start_date.isoformat(), end_date.isoformat()
208
-
209
- response = try_call(
210
- errors,
211
- "AWS Cost Explorer",
212
- "GetCostAndUsage",
213
- lambda: cost_explorer.get_cost_and_usage(
214
- TimePeriod={"Start": start, "End": end},
215
- Granularity=granularity,
216
- Metrics=["UnblendedCost"],
217
- Filter={
218
- "Dimensions": {
219
- "Key": "SERVICE",
220
- "Values": consts.EUC_COST_EXPLORER_SERVICES,
221
- }
222
- },
223
- GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
224
- ),
225
- default={},
226
- )
227
-
222
+ if start_date and end_date:
223
+ start, end = start_date, end_date
224
+ else:
225
+ end_d = datetime.now(UTC).date()
226
+ start_d = end_d - timedelta(days=lookback_days)
227
+ start, end = start_d.isoformat(), end_d.isoformat()
228
+
229
+ # Group by SERVICE across ALL spend and select EUC services in code (see _is_euc_service).
230
+ # A server-side exact-name SERVICE filter would silently drop any naming variant — the very
231
+ # bug that hid AppStream / WorkSpaces Applications spend. Page through all results, keeping both
232
+ # the overall per-service totals and the per-period (daily/monthly) breakdown for charts.
228
233
  totals_by_service: dict[str, float] = {}
234
+ per_period: dict[tuple[str, str], dict[str, float]] = {}
229
235
  currency = "USD"
230
- for period in (response or {}).get("ResultsByTime", []):
231
- for group in period.get("Groups", []):
232
- service = (group.get("Keys") or ["Unknown"])[0]
233
- metric = group.get("Metrics", {}).get("UnblendedCost", {})
234
- amount = float(metric.get("Amount", 0.0))
235
- currency = metric.get("Unit", currency)
236
- totals_by_service[service] = totals_by_service.get(service, 0.0) + amount
236
+ next_token: str | None = None
237
+ while True:
238
+ kwargs: dict[str, Any] = {
239
+ "TimePeriod": {"Start": start, "End": end},
240
+ "Granularity": granularity,
241
+ "Metrics": ["UnblendedCost"],
242
+ "GroupBy": [{"Type": "DIMENSION", "Key": "SERVICE"}],
243
+ }
244
+ if next_token:
245
+ kwargs["NextPageToken"] = next_token
246
+ response = try_call(
247
+ errors,
248
+ "AWS Cost Explorer",
249
+ "GetCostAndUsage",
250
+ lambda kwargs=kwargs: cost_explorer.get_cost_and_usage(**kwargs),
251
+ default={},
252
+ )
253
+ if not response:
254
+ break
255
+ for period in response.get("ResultsByTime", []):
256
+ tp = period.get("TimePeriod", {})
257
+ bucket = per_period.setdefault((tp.get("Start", ""), tp.get("End", "")), {})
258
+ for group in period.get("Groups", []):
259
+ service = (group.get("Keys") or ["Unknown"])[0]
260
+ if not _is_euc_service(service):
261
+ continue
262
+ metric = group.get("Metrics", {}).get("UnblendedCost", {})
263
+ amount = float(metric.get("Amount", 0.0))
264
+ currency = metric.get("Unit", currency)
265
+ totals_by_service[service] = totals_by_service.get(service, 0.0) + amount
266
+ bucket[service] = bucket.get(service, 0.0) + amount
267
+ next_token = response.get("NextPageToken")
268
+ if not next_token:
269
+ break
237
270
 
238
271
  by_service = [
239
272
  CostLineItem(service=s, amount=round(a, 2))
240
273
  for s, a in sorted(totals_by_service.items(), key=lambda kv: kv[1], reverse=True)
241
274
  ]
242
275
  total = round(sum(item.amount for item in by_service), 2)
276
+ by_period = [
277
+ CostPeriod(
278
+ start=p_start,
279
+ end=p_end,
280
+ total=round(sum(svc.values()), 2),
281
+ by_service=[
282
+ CostLineItem(service=s, amount=round(a, 2))
283
+ for s, a in sorted(svc.items(), key=lambda kv: kv[1], reverse=True)
284
+ ],
285
+ )
286
+ for (p_start, p_end), svc in sorted(per_period.items())
287
+ ]
243
288
 
244
289
  return CostSummary(
245
290
  start=start,
@@ -248,10 +293,15 @@ def get_euc_cost_summary_core(
248
293
  currency=currency,
249
294
  total=total,
250
295
  by_service=by_service,
296
+ by_period=by_period,
251
297
  errors=errors,
252
298
  notes=[
253
- "Filtered to EUC SERVICE dimension values; some products (e.g. Secure Browser) may "
254
- "bill under a different service name depending on the account."
299
+ "EUC services are selected by matching the Cost Explorer SERVICE name against the EUC "
300
+ "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.",
303
+ "by_period gives the per-bucket time series (per day for DAILY, per month for MONTHLY) "
304
+ "for charts/trends; by_service is the total across the whole window.",
255
305
  ],
256
306
  )
257
307
 
@@ -293,18 +343,38 @@ def register(mcp: Any, factory: ClientFactory) -> None:
293
343
  return report.model_dump()
294
344
 
295
345
  async def get_euc_cost_summary(
296
- lookback_days: int = 30, granularity: Literal["MONTHLY", "DAILY"] = "MONTHLY"
346
+ lookback_days: int = 30,
347
+ granularity: Literal["MONTHLY", "DAILY"] = "MONTHLY",
348
+ start_date: str | None = None,
349
+ end_date: str | None = None,
297
350
  ) -> dict[str, Any]:
298
351
  """Summarize EUC spend by service over a window (account-wide via Cost Explorer).
299
352
 
300
- Returns unblended cost grouped by service for the EUC portfolio. Cost Explorer is not
301
- region-scoped, so figures are account-wide. Read-only.
353
+ Returns unblended cost grouped by service for the EUC portfolio (WorkSpaces, including
354
+ Personal/Pools/Core which Cost Explorer bills together as "Amazon WorkSpaces"; WorkSpaces
355
+ Applications/AppStream; and Secure Browser). Services are matched by keyword, so naming
356
+ variants are never dropped. Cost Explorer is not region-scoped, so figures are account-wide.
357
+ Read-only.
358
+
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.
363
+
364
+ For a specific calendar month, pass start_date/end_date instead of lookback_days — Cost
365
+ Explorer's end is EXCLUSIVE, so for May 2026 use start_date="2026-05-01",
366
+ end_date="2026-06-01".
302
367
 
303
368
  Args:
304
- lookback_days: How far back to total (default 30).
369
+ lookback_days: How far back to total when start_date/end_date are omitted (default 30).
305
370
  granularity: Cost Explorer granularity: MONTHLY or DAILY (default MONTHLY).
371
+ start_date: Optional inclusive start, "YYYY-MM-DD". Use with end_date; overrides
372
+ lookback_days.
373
+ end_date: Optional EXCLUSIVE end, "YYYY-MM-DD". Use with start_date.
306
374
  """
307
- summary = get_euc_cost_summary_core(factory, lookback_days, granularity)
375
+ summary = get_euc_cost_summary_core(
376
+ factory, lookback_days, granularity, start_date, end_date
377
+ )
308
378
  return summary.model_dump()
309
379
 
310
380
  mcp.add_tool(