workspaces-euc-mcp-server 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,129 @@
1
+ # Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
2
+ # You may not use this file except in compliance with the License.
3
+ # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
+ """FastMCP application entry point for the WorkSpaces EUC MCP server."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import os
10
+ import sys
11
+
12
+ from loguru import logger
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from . import consts
16
+ from .clients import ClientFactory
17
+ from .tools import (
18
+ cost,
19
+ destructive,
20
+ diagnostics,
21
+ inventory,
22
+ lifecycle,
23
+ performance,
24
+ reporting,
25
+ secure_browser,
26
+ )
27
+
28
+
29
+ def create_server(
30
+ *,
31
+ region: str | None = None,
32
+ profile: str | None = None,
33
+ role_arn: str | None = None,
34
+ external_id: str | None = None,
35
+ enable_writes: bool = False,
36
+ enable_destructive: bool = False,
37
+ max_bulk_targets: int = consts.DEFAULT_MAX_BULK_TARGETS,
38
+ ) -> FastMCP:
39
+ """Build the FastMCP server, registering tools according to the safety flags."""
40
+ factory = ClientFactory(
41
+ region=region, profile=profile, role_arn=role_arn, external_id=external_id
42
+ )
43
+ mcp = FastMCP(consts.SERVER_NAME, instructions=consts.SERVER_INSTRUCTIONS)
44
+
45
+ # Phase 1 read-only tools are always registered.
46
+ inventory.register(mcp, factory)
47
+ diagnostics.register(mcp, factory)
48
+ cost.register(mcp, factory)
49
+ reporting.register(mcp, factory)
50
+ performance.register(mcp, factory)
51
+ secure_browser.register(mcp, factory)
52
+
53
+ if enable_writes:
54
+ logger.info(
55
+ "Write tools enabled (Tier 2). Mutations are dry-run unless confirm=true and are "
56
+ "capped at {} targets per bulk action.",
57
+ max_bulk_targets,
58
+ )
59
+ lifecycle.register(
60
+ mcp,
61
+ factory,
62
+ max_bulk_targets=max_bulk_targets,
63
+ enable_destructive=enable_destructive,
64
+ )
65
+
66
+ if enable_destructive:
67
+ logger.warning(
68
+ "Destructive tools enabled (Tier 3): terminate/rebuild/restore. These require "
69
+ "confirm=true AND an exact acknowledgement phrase to execute."
70
+ )
71
+ destructive.register(mcp, factory, max_bulk_targets=max_bulk_targets)
72
+
73
+ return mcp
74
+
75
+
76
+ def main() -> None:
77
+ parser = argparse.ArgumentParser(
78
+ prog=consts.SERVER_NAME,
79
+ description="Admin MCP server for the Amazon WorkSpaces EUC portfolio (read-only default).",
80
+ )
81
+ parser.add_argument("--region", help="AWS region (overrides AWS_REGION / profile default).")
82
+ parser.add_argument("--profile", help="AWS named profile to use.")
83
+ parser.add_argument(
84
+ "--assume-role-arn",
85
+ help="Cross-account IAM role ARN to assume (multi-account / MSP). The caller needs "
86
+ "sts:AssumeRole on it; the role needs the matching tier permissions.",
87
+ )
88
+ parser.add_argument(
89
+ "--external-id",
90
+ help="ExternalId to pass when assuming --assume-role-arn (if the role requires one).",
91
+ )
92
+ parser.add_argument(
93
+ "--enable-writes",
94
+ action="store_true",
95
+ help="Register Phase 2 lifecycle (write) tools. Off by default.",
96
+ )
97
+ parser.add_argument(
98
+ "--enable-destructive",
99
+ action="store_true",
100
+ help="Allow destructive ops (terminate/rebuild/restore). Requires --enable-writes.",
101
+ )
102
+ parser.add_argument(
103
+ "--max-bulk-targets",
104
+ type=int,
105
+ default=consts.DEFAULT_MAX_BULK_TARGETS,
106
+ help="Blast-radius cap for bulk mutations (Phase 2).",
107
+ )
108
+ args = parser.parse_args()
109
+
110
+ if args.enable_destructive and not args.enable_writes:
111
+ parser.error("--enable-destructive requires --enable-writes.")
112
+
113
+ logger.remove()
114
+ logger.add(sys.stderr, level=os.environ.get("FASTMCP_LOG_LEVEL", "INFO").upper())
115
+
116
+ mcp = create_server(
117
+ region=args.region,
118
+ profile=args.profile,
119
+ role_arn=args.assume_role_arn,
120
+ external_id=args.external_id,
121
+ enable_writes=args.enable_writes,
122
+ enable_destructive=args.enable_destructive,
123
+ max_bulk_targets=args.max_bulk_targets,
124
+ )
125
+ mcp.run()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,4 @@
1
+ # Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
2
+ # You may not use this file except in compliance with the License.
3
+ # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
+ """Tool implementations, grouped by capability area."""
@@ -0,0 +1,87 @@
1
+ # Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
2
+ # You may not use this file except in compliance with the License.
3
+ # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
+ """Shared best-effort AWS-call helpers used by the tool modules."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ from botocore.exceptions import BotoCoreError, ClientError
12
+ from loguru import logger
13
+ from mcp.types import ToolAnnotations
14
+
15
+ from ..models import ServiceError
16
+
17
+
18
+ def read_only(title: str) -> ToolAnnotations:
19
+ """Annotations for a read-only tool (closed-domain: the configured AWS account)."""
20
+ return ToolAnnotations(
21
+ title=title,
22
+ readOnlyHint=True,
23
+ destructiveHint=False,
24
+ idempotentHint=True,
25
+ openWorldHint=False,
26
+ )
27
+
28
+
29
+ def writes(title: str, *, idempotent: bool = False, destructive: bool = False) -> ToolAnnotations:
30
+ """Annotations for a mutating tool (lifecycle or destructive)."""
31
+ return ToolAnnotations(
32
+ title=title,
33
+ readOnlyHint=False,
34
+ destructiveHint=destructive,
35
+ idempotentHint=idempotent,
36
+ openWorldHint=False,
37
+ )
38
+
39
+
40
+ def try_call(
41
+ errors: list[ServiceError],
42
+ service: str,
43
+ operation: str,
44
+ fn: Callable[[], Any],
45
+ default: Any = None,
46
+ ) -> Any:
47
+ """Run an AWS call, recording (not raising) errors so collection can continue."""
48
+ try:
49
+ return fn()
50
+ except (ClientError, BotoCoreError) as exc:
51
+ logger.warning("AWS call failed: {} {} -> {}", service, operation, exc)
52
+ errors.append(ServiceError(service=service, operation=operation, message=str(exc)))
53
+ return default
54
+
55
+
56
+ def paginate(
57
+ operation: Callable[..., dict[str, Any]],
58
+ list_key: str,
59
+ pagination_in: str = "NextToken",
60
+ pagination_out: str = "NextToken",
61
+ **kwargs: Any,
62
+ ) -> list[dict[str, Any]]:
63
+ """Drain a paginated AWS list/describe operation into a flat list.
64
+
65
+ ``pagination_in`` / ``pagination_out`` are the request and response field names AWS uses for
66
+ the continuation marker (e.g. ``NextToken``, or ``nextToken`` for camelCase services).
67
+ """
68
+ items: list[dict[str, Any]] = []
69
+ marker: str | None = None
70
+ while True:
71
+ params = dict(kwargs)
72
+ if marker:
73
+ params[pagination_in] = marker
74
+ response = operation(**params)
75
+ items.extend(response.get(list_key, []))
76
+ marker = response.get(pagination_out)
77
+ if not marker:
78
+ return items
79
+
80
+
81
+ def count_by(items: list[dict[str, Any]], state_key: str) -> dict[str, int]:
82
+ """Count items grouped by a state-like field (missing values count as UNKNOWN)."""
83
+ counts: dict[str, int] = {}
84
+ for item in items:
85
+ state = item.get(state_key, "UNKNOWN")
86
+ counts[state] = counts.get(state, 0) + 1
87
+ return counts
@@ -0,0 +1,314 @@
1
+ # Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
2
+ # You may not use this file except in compliance with the License.
3
+ # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
+ """Cost & utilization optimization tools (read-only, IAM Tier 1).
5
+
6
+ These add Cost Explorer and Pricing access on top of Tier 0. They identify under-used WorkSpaces
7
+ Personal desktops, recommend running-mode changes, and summarize EUC spend — returning opinionated
8
+ findings rather than raw billing/metric data.
9
+
10
+ Utilization is derived from the standard ``AWS/WorkSpaces`` ``UserConnected`` metric (1 when a user
11
+ is connected during a period); no CloudWatch agent is required.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Any, Literal
18
+
19
+ from .. import consts
20
+ from ..clients import ClientFactory
21
+ from ..models import (
22
+ CostLineItem,
23
+ CostSummary,
24
+ Recommendation,
25
+ RecommendationReport,
26
+ ServiceError,
27
+ UtilizationReport,
28
+ WorkspaceUtilization,
29
+ )
30
+ from . import pricing
31
+ from ._common import paginate, read_only, try_call
32
+
33
+
34
+ def _daily_connected_values(cloudwatch: Any, workspace_id: str, lookback_days: int) -> list[float]:
35
+ """Per-day maximum of UserConnected (1 if the desktop had any connection that day)."""
36
+ end = datetime.now(UTC)
37
+ start = end - timedelta(days=lookback_days)
38
+ response = cloudwatch.get_metric_data(
39
+ MetricDataQueries=[
40
+ {
41
+ "Id": "uc",
42
+ "MetricStat": {
43
+ "Metric": {
44
+ "Namespace": "AWS/WorkSpaces",
45
+ "MetricName": "UserConnected",
46
+ "Dimensions": [{"Name": "WorkspaceId", "Value": workspace_id}],
47
+ },
48
+ "Period": 86400,
49
+ "Stat": "Maximum",
50
+ },
51
+ "ReturnData": True,
52
+ }
53
+ ],
54
+ StartTime=start,
55
+ EndTime=end,
56
+ )
57
+ return response.get("MetricDataResults", [{}])[0].get("Values", [])
58
+
59
+
60
+ def _classify(active_days: int | None, lookback_days: int, idle_threshold: int) -> str:
61
+ if active_days is None:
62
+ return "unknown"
63
+ if active_days == 0:
64
+ return "unused"
65
+ if active_days <= idle_threshold:
66
+ return "idle"
67
+ return "active"
68
+
69
+
70
+ def _collect_utilization(
71
+ factory: ClientFactory, region: str | None, lookback_days: int
72
+ ) -> tuple[list[WorkspaceUtilization], list[ServiceError]]:
73
+ errors: list[ServiceError] = []
74
+ workspaces_client = factory.client(consts.WORKSPACES_API, region=region)
75
+ cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
76
+
77
+ workspaces = try_call(
78
+ errors,
79
+ consts.PRODUCT_WORKSPACES_PERSONAL,
80
+ "DescribeWorkspaces",
81
+ lambda: paginate(workspaces_client.describe_workspaces, "Workspaces"),
82
+ default=[],
83
+ )
84
+
85
+ idle_threshold = max(1, lookback_days // 7)
86
+ items: list[WorkspaceUtilization] = []
87
+ for ws in workspaces or []:
88
+ wid = ws.get("WorkspaceId", "")
89
+ props = ws.get("WorkspaceProperties", {})
90
+ values = try_call(
91
+ errors,
92
+ "Amazon CloudWatch",
93
+ "GetMetricData",
94
+ lambda wid=wid: _daily_connected_values(cloudwatch, wid, lookback_days),
95
+ )
96
+ active_days = sum(1 for v in values if v >= 1) if values is not None else None
97
+ items.append(
98
+ WorkspaceUtilization(
99
+ workspace_id=wid,
100
+ running_mode=props.get("RunningMode"),
101
+ lookback_days=lookback_days,
102
+ active_days=active_days,
103
+ classification=_classify(active_days, lookback_days, idle_threshold),
104
+ compute_type=props.get("ComputeTypeName"),
105
+ operating_system=props.get("OperatingSystemName"),
106
+ root_volume_gib=props.get("RootVolumeSizeGib"),
107
+ user_volume_gib=props.get("UserVolumeSizeGib"),
108
+ )
109
+ )
110
+ return items, errors
111
+
112
+
113
+ def analyze_workspace_utilization_core(
114
+ factory: ClientFactory, region: str | None, lookback_days: int = 14
115
+ ) -> UtilizationReport:
116
+ items, errors = _collect_utilization(factory, region, lookback_days)
117
+ counts: dict[str, int] = {}
118
+ for item in items:
119
+ counts[item.classification] = counts.get(item.classification, 0) + 1
120
+ return UtilizationReport(
121
+ region=region,
122
+ lookback_days=lookback_days,
123
+ total=len(items),
124
+ counts=counts,
125
+ workspaces=items,
126
+ errors=errors,
127
+ notes=[
128
+ "Utilization is based on the AWS/WorkSpaces UserConnected metric; a desktop with no "
129
+ "datapoints over the window is reported as unused."
130
+ ],
131
+ )
132
+
133
+
134
+ def recommend_running_mode_core(
135
+ factory: ClientFactory, region: str | None, lookback_days: int = 14
136
+ ) -> RecommendationReport:
137
+ items, errors = _collect_utilization(factory, region, lookback_days)
138
+ recommendations: list[Recommendation] = []
139
+ for item in items:
140
+ if item.running_mode == "ALWAYS_ON" and item.classification in {"unused", "idle"}:
141
+ prices = pricing.get_workspace_prices(
142
+ factory,
143
+ region,
144
+ item.operating_system,
145
+ item.compute_type,
146
+ item.root_volume_gib,
147
+ item.user_volume_gib,
148
+ )
149
+ savings = pricing.estimate_alwayson_to_autostop_savings(
150
+ prices, item.active_days, lookback_days
151
+ )
152
+ recommendations.append(
153
+ Recommendation(
154
+ target_id=item.workspace_id,
155
+ kind="running_mode",
156
+ current="ALWAYS_ON",
157
+ recommended="AUTO_STOP",
158
+ rationale=(
159
+ f"Connected on {item.active_days} of the last {lookback_days} days "
160
+ f"({item.classification}); AlwaysOn bills the full month regardless of "
161
+ "use, so AutoStop typically costs less at low usage."
162
+ ),
163
+ estimated_monthly_savings_usd=savings,
164
+ confidence="high" if item.classification == "unused" else "medium",
165
+ )
166
+ )
167
+ elif (
168
+ item.running_mode == "AUTO_STOP"
169
+ and item.active_days is not None
170
+ and item.active_days >= lookback_days
171
+ ):
172
+ recommendations.append(
173
+ Recommendation(
174
+ target_id=item.workspace_id,
175
+ kind="running_mode",
176
+ current="AUTO_STOP",
177
+ recommended="evaluate ALWAYS_ON",
178
+ rationale=(
179
+ f"Connected every day in the last {lookback_days} days; if daily usage is "
180
+ "also long-duration, AlwaysOn may be cheaper than per-hour AutoStop."
181
+ ),
182
+ confidence="low",
183
+ )
184
+ )
185
+ return RecommendationReport(
186
+ region=region,
187
+ lookback_days=lookback_days,
188
+ recommendations=recommendations,
189
+ errors=errors,
190
+ notes=[
191
+ "estimated_monthly_savings_usd is a best-effort Price List estimate (Included license, "
192
+ "on-demand, matched on compute type / OS / volume sizes); it is null when prices can't "
193
+ "be matched or pricing:GetProducts is not permitted. Assumes ~8h per active day."
194
+ ],
195
+ )
196
+
197
+
198
+ def get_euc_cost_summary_core(
199
+ factory: ClientFactory, lookback_days: int = 30, granularity: str = "MONTHLY"
200
+ ) -> CostSummary:
201
+ errors: list[ServiceError] = []
202
+ # Cost Explorer is a global endpoint served from us-east-1, regardless of working region.
203
+ cost_explorer = factory.client(consts.COST_EXPLORER_API, region=consts.COST_EXPLORER_REGION)
204
+
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
+
228
+ totals_by_service: dict[str, float] = {}
229
+ 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
237
+
238
+ by_service = [
239
+ CostLineItem(service=s, amount=round(a, 2))
240
+ for s, a in sorted(totals_by_service.items(), key=lambda kv: kv[1], reverse=True)
241
+ ]
242
+ total = round(sum(item.amount for item in by_service), 2)
243
+
244
+ return CostSummary(
245
+ start=start,
246
+ end=end,
247
+ granularity=granularity,
248
+ currency=currency,
249
+ total=total,
250
+ by_service=by_service,
251
+ errors=errors,
252
+ 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."
255
+ ],
256
+ )
257
+
258
+
259
+ def register(mcp: Any, factory: ClientFactory) -> None:
260
+ """Register cost & utilization tools on the FastMCP app."""
261
+
262
+ async def analyze_workspace_utilization(
263
+ region: str | None = None, lookback_days: int = 14
264
+ ) -> dict[str, Any]:
265
+ """Classify WorkSpaces Personal desktops as unused / idle / active.
266
+
267
+ Uses the AWS/WorkSpaces UserConnected metric over the window to count active days per
268
+ desktop, returning per-desktop classifications and a rollup. Read-only.
269
+
270
+ Args:
271
+ region: AWS region. Defaults to the server's configured region.
272
+ lookback_days: Window for the usage analysis (default 14).
273
+ """
274
+ report = analyze_workspace_utilization_core(
275
+ factory, region or factory.region, lookback_days
276
+ )
277
+ return report.model_dump()
278
+
279
+ async def recommend_running_mode(
280
+ region: str | None = None, lookback_days: int = 14
281
+ ) -> dict[str, Any]:
282
+ """Recommend AlwaysOn -> AutoStop running-mode changes for under-used desktops.
283
+
284
+ Identifies AlwaysOn WorkSpaces Personal desktops with low usage that would typically cost
285
+ less on AutoStop (and flags the reverse, low-confidence, for daily-used AutoStop desktops).
286
+ Read-only.
287
+
288
+ Args:
289
+ region: AWS region. Defaults to the server's configured region.
290
+ lookback_days: Window for the usage analysis (default 14).
291
+ """
292
+ report = recommend_running_mode_core(factory, region or factory.region, lookback_days)
293
+ return report.model_dump()
294
+
295
+ async def get_euc_cost_summary(
296
+ lookback_days: int = 30, granularity: Literal["MONTHLY", "DAILY"] = "MONTHLY"
297
+ ) -> dict[str, Any]:
298
+ """Summarize EUC spend by service over a window (account-wide via Cost Explorer).
299
+
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.
302
+
303
+ Args:
304
+ lookback_days: How far back to total (default 30).
305
+ granularity: Cost Explorer granularity: MONTHLY or DAILY (default MONTHLY).
306
+ """
307
+ summary = get_euc_cost_summary_core(factory, lookback_days, granularity)
308
+ return summary.model_dump()
309
+
310
+ mcp.add_tool(
311
+ analyze_workspace_utilization, annotations=read_only("Analyze WorkSpace utilization")
312
+ )
313
+ mcp.add_tool(recommend_running_mode, annotations=read_only("Recommend running mode"))
314
+ mcp.add_tool(get_euc_cost_summary, annotations=read_only("EUC cost summary"))