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.
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/CHANGELOG.md +19 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/DESIGN.md +7 -5
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/PKG-INFO +11 -10
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/README.md +10 -9
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/pyproject.toml +1 -1
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_cost.py +66 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_sso.py +14 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/__init__.py +1 -1
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/consts.py +9 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/models.py +7 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/server.py +18 -12
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/_common.py +1 -1
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/cost.py +90 -7
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.dockerignore +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/ci.yml +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/docker-publish.yml +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/publish.yml +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.gitignore +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.pre-commit-config.yaml +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/Dockerfile +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/LICENSE +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/README.md +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier0-diagnostics.json +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier1-cost.json +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier2-lifecycle.json +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier3-destructive.json +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/scripts/smoke_readonly.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/__init__.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_clients.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_destructive.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_diagnostics.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_governance.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_images.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_inventory.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_lifecycle.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_naming.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_no_embedded_secrets.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_performance.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_pricing.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_reporting.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_secure_browser.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/clients.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/sso.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/diagnostics.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/governance.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/images.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/performance.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
- {workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
- {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 **
|
|
98
|
-
(opens the browser) so the user needn't use a terminal
|
|
99
|
-
|
|
100
|
-
|
|
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` |
|
|
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.
|
|
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
|
|
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
|
-
> **
|
|
268
|
-
>
|
|
269
|
-
>
|
|
270
|
-
>
|
|
271
|
-
>
|
|
272
|
-
>
|
|
273
|
-
> *not* refresh the CLI/SSO token —
|
|
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` |
|
|
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
|
|
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
|
-
> **
|
|
238
|
-
>
|
|
239
|
-
>
|
|
240
|
-
>
|
|
241
|
-
>
|
|
242
|
-
>
|
|
243
|
-
> *not* refresh the CLI/SSO token —
|
|
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` |
|
|
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.
|
|
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
|
|
|
@@ -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 =
|
|
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
|
-
#
|
|
50
|
-
# (opens the browser) so the user never has to use a terminal.
|
|
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
|
-
|
|
55
|
-
|
|
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=
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
"
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
"(
|
|
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
|
|
302
|
-
"
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/ci.yml
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/.pre-commit-config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier0-diagnostics.json
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier2-lifecycle.json
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/iam/tier3-destructive.json
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/scripts/smoke_readonly.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_destructive.py
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_diagnostics.py
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_governance.py
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_inventory.py
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_performance.py
RENAMED
|
File without changes
|
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_reporting.py
RENAMED
|
File without changes
|
{workspaces_euc_mcp_server-0.1.8 → workspaces_euc_mcp_server-0.1.10}/tests/test_secure_browser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|