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.
- workspaces_euc_mcp_server/__init__.py +6 -0
- workspaces_euc_mcp_server/clients.py +101 -0
- workspaces_euc_mcp_server/consts.py +154 -0
- workspaces_euc_mcp_server/models.py +333 -0
- workspaces_euc_mcp_server/server.py +129 -0
- workspaces_euc_mcp_server/tools/__init__.py +4 -0
- workspaces_euc_mcp_server/tools/_common.py +87 -0
- workspaces_euc_mcp_server/tools/cost.py +314 -0
- workspaces_euc_mcp_server/tools/destructive.py +307 -0
- workspaces_euc_mcp_server/tools/diagnostics.py +799 -0
- workspaces_euc_mcp_server/tools/inventory.py +158 -0
- workspaces_euc_mcp_server/tools/lifecycle.py +564 -0
- workspaces_euc_mcp_server/tools/performance.py +620 -0
- workspaces_euc_mcp_server/tools/pricing.py +152 -0
- workspaces_euc_mcp_server/tools/reporting.py +529 -0
- workspaces_euc_mcp_server/tools/secure_browser.py +190 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/METADATA +270 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/RECORD +21 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/WHEEL +4 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/entry_points.txt +2 -0
- workspaces_euc_mcp_server-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -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"))
|