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,6 @@
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
+ """MCP server for administering the Amazon WorkSpaces End User Computing portfolio."""
5
+
6
+ __version__ = "0.1.1"
@@ -0,0 +1,101 @@
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
+ """Region/profile-aware boto3 client factory.
5
+
6
+ Clients are built from the standard boto3 credential chain (``AWS_PROFILE`` / ``AWS_REGION`` /
7
+ instance role / SSO). For multi-account / MSP use, pass ``role_arn`` (and optional ``external_id``)
8
+ to transparently ``sts:AssumeRole`` into another account — the assumed credentials auto-refresh
9
+ before expiry, and no tool code changes (every tool just calls ``factory.client(...)``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import boto3
17
+ from botocore.config import Config
18
+ from botocore.credentials import RefreshableCredentials
19
+ from botocore.session import get_session as _botocore_get_session
20
+
21
+ from . import consts
22
+
23
+
24
+ class ClientFactory:
25
+ """Builds and caches boto3 clients for the in-scope EUC services."""
26
+
27
+ def __init__(
28
+ self,
29
+ region: str | None = None,
30
+ profile: str | None = None,
31
+ role_arn: str | None = None,
32
+ external_id: str | None = None,
33
+ role_session_name: str = "workspaces-euc-mcp-server",
34
+ ) -> None:
35
+ self._region = region
36
+ self._profile = profile
37
+ self._role_arn = role_arn
38
+ self._external_id = external_id
39
+ self._role_session_name = role_session_name
40
+ # The base session holds the caller's own credentials (profile / env / SSO / instance role).
41
+ self._base_session = boto3.Session(profile_name=profile, region_name=region)
42
+ # The effective session: the base one, or a cross-account assumed-role one (MSP / multi-
43
+ # account). Assumed credentials auto-refresh before expiry, so clients keep working.
44
+ self._session = self._build_assumed_session(role_arn) if role_arn else self._base_session
45
+ self._cache: dict[tuple[str, str | None], Any] = {}
46
+
47
+ def _build_assumed_session(self, role_arn: str) -> boto3.Session:
48
+ """Build a boto3 Session backed by auto-refreshing sts:AssumeRole credentials."""
49
+
50
+ external_id = self._external_id
51
+ session_name = self._role_session_name
52
+
53
+ def _refresh() -> dict[str, str]:
54
+ sts = self._base_session.client("sts", config=self._config())
55
+ kwargs: dict[str, str] = {"RoleArn": role_arn, "RoleSessionName": session_name}
56
+ if external_id:
57
+ kwargs["ExternalId"] = external_id
58
+ creds = sts.assume_role(**kwargs)["Credentials"]
59
+ return {
60
+ "access_key": creds["AccessKeyId"],
61
+ "secret_key": creds["SecretAccessKey"],
62
+ "token": creds["SessionToken"],
63
+ "expiry_time": creds["Expiration"].isoformat(),
64
+ }
65
+
66
+ refreshable = RefreshableCredentials.create_from_metadata(
67
+ metadata=_refresh(),
68
+ refresh_using=_refresh,
69
+ method="sts-assume-role",
70
+ )
71
+ botocore_session = _botocore_get_session()
72
+ botocore_session._credentials = refreshable
73
+ if self._region:
74
+ botocore_session.set_config_variable("region", self._region)
75
+ return boto3.Session(botocore_session=botocore_session)
76
+
77
+ @property
78
+ def region(self) -> str | None:
79
+ """Effective region (explicit override, else whatever the base session resolved)."""
80
+ return self._region or self._base_session.region_name
81
+
82
+ def _config(self) -> Config:
83
+ return Config(
84
+ user_agent_extra=f"{consts.SERVER_NAME}/{consts.SERVER_VERSION}",
85
+ retries={"max_attempts": 3, "mode": "standard"},
86
+ )
87
+
88
+ def client(self, service_name: str, region: str | None = None) -> Any:
89
+ """Return a cached boto3 client for ``service_name`` in the target region.
90
+
91
+ Typed ``Any`` because boto3 clients are dynamically generated per service.
92
+ """
93
+ target_region = region or self._region
94
+ key = (service_name, target_region)
95
+ if key not in self._cache:
96
+ self._cache[key] = self._session.client(
97
+ service_name,
98
+ region_name=target_region,
99
+ config=self._config(),
100
+ )
101
+ return self._cache[key]
@@ -0,0 +1,154 @@
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
+ """Constants for the WorkSpaces EUC MCP server.
5
+
6
+ Note the distinction between **product names** (current official AWS branding, used everywhere a
7
+ human reads) and **API identifiers** (the legacy boto3 client names the SDK still requires).
8
+ """
9
+
10
+ from . import __version__
11
+
12
+ SERVER_NAME = "workspaces-euc-mcp-server"
13
+ SERVER_VERSION = __version__
14
+
15
+ # boto3 client names (API identifiers — NOT product names).
16
+ WORKSPACES_API = "workspaces" # Amazon WorkSpaces Personal, Pools, and Core all use this client.
17
+ APPSTREAM_API = "appstream" # Amazon WorkSpaces Applications (formerly AppStream 2.0).
18
+ SECURE_BROWSER_API = "workspaces-web" # Amazon WorkSpaces Secure Browser (formerly WorkSpaces Web).
19
+ WORKSPACES_INSTANCES_API = "workspaces-instances" # Amazon WorkSpaces Core Managed Instances.
20
+ DIRECTORY_API = "ds" # AWS Directory Service (shared dependency).
21
+ CLOUDWATCH_API = "cloudwatch" # Telemetry for diagnostics/cost tools.
22
+ EC2_API = "ec2" # Used to enrich WorkSpaces Core Managed Instances with EC2 details.
23
+ COST_EXPLORER_API = "ce" # Cost Explorer (global; account-wide, not region-scoped).
24
+ PRICING_API = "pricing" # AWS Price List (global).
25
+
26
+ # Cost Explorer is a global endpoint served from us-east-1 regardless of the working region.
27
+ COST_EXPLORER_REGION = "us-east-1"
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
+ ]
37
+
38
+ # Current official product names (used in all human-facing output).
39
+ PRODUCT_WORKSPACES_PERSONAL = "Amazon WorkSpaces Personal"
40
+ PRODUCT_WORKSPACES_POOLS = "Amazon WorkSpaces Pools"
41
+ PRODUCT_WORKSPACES_APPLICATIONS = "Amazon WorkSpaces Applications"
42
+ PRODUCT_SECURE_BROWSER = "Amazon WorkSpaces Secure Browser"
43
+ PRODUCT_WORKSPACES_CORE_INSTANCES = "Amazon WorkSpaces Core Managed Instances"
44
+
45
+ # Legacy / former product names mapped to their current official name. Accept these as INPUT
46
+ # (users will keep saying them) but always emit the current name in output. This is surfaced to the
47
+ # MCP client model via the server instructions and tool descriptions so a query about, say,
48
+ # "AppStream fleets" routes to the WorkSpaces Applications tools.
49
+ LEGACY_NAME_ALIASES = {
50
+ "appstream": PRODUCT_WORKSPACES_APPLICATIONS,
51
+ "appstream 2.0": PRODUCT_WORKSPACES_APPLICATIONS,
52
+ "amazon appstream": PRODUCT_WORKSPACES_APPLICATIONS,
53
+ "amazon appstream 2.0": PRODUCT_WORKSPACES_APPLICATIONS,
54
+ "workspaces web": PRODUCT_SECURE_BROWSER,
55
+ "amazon workspaces web": PRODUCT_SECURE_BROWSER,
56
+ }
57
+
58
+ # Default blast-radius cap for any (future, Phase 2) bulk mutation.
59
+ DEFAULT_MAX_BULK_TARGETS = 25
60
+
61
+ # WorkSpaces Personal general-purpose compute types, smallest -> largest. Used by bundle
62
+ # right-sizing to step a desktop up/down one size. Graphics families are intentionally excluded
63
+ # (different hardware/pricing; not safe to auto-suggest across).
64
+ WORKSPACES_COMPUTE_ORDER = ["VALUE", "STANDARD", "PERFORMANCE", "POWER", "POWERPRO"]
65
+
66
+ # Performance metrics published natively to the AWS/WorkSpaces namespace (keyed by WorkspaceId),
67
+ # with a best-effort unit label. No CloudWatch agent is required for these.
68
+ WORKSPACES_PERFORMANCE_METRICS = [
69
+ ("CPUUsage", "Percent"),
70
+ ("MemoryUsage", "Percent"),
71
+ ("GPUUsage", "Percent"),
72
+ ("FramesPerSecond", "Count"),
73
+ ("RootVolumeDiskUsage", "Percent"),
74
+ ("UserVolumeDiskUsage", "Percent"),
75
+ ("InSessionLatency", "Milliseconds"),
76
+ ("UpTime", "Seconds"),
77
+ ("Bandwidth", "Bytes"),
78
+ ("BandwidthInbound", "Bytes"),
79
+ ("CPUQueueLength", "Count"),
80
+ ("MemoryPageHardFaults", "Count"),
81
+ ("RootVolumeDiskIOQueueLength", "Count"),
82
+ ("UserVolumeDiskIOQueueLength", "Count"),
83
+ ("TCPRetransmissionRate", "Percent"),
84
+ ("UDPPacketLossRate", "Percent"),
85
+ ]
86
+
87
+ # Capacity/utilization metrics published to AWS/AppStream for a fleet (dimension Fleet), used for
88
+ # WorkSpaces Applications fleet usage history. (ActiveSessions etc. only exist for elastic /
89
+ # multi-session fleets, so they are not in the base set.)
90
+ APPSTREAM_FLEET_METRICS = [
91
+ ("InUseCapacity", "Count"),
92
+ ("CapacityUtilization", "Percent"),
93
+ ("ActualCapacity", "Count"),
94
+ ("AvailableCapacity", "Count"),
95
+ ("RunningCapacity", "Count"),
96
+ ("DesiredCapacity", "Count"),
97
+ ("PendingCapacity", "Count"),
98
+ ]
99
+
100
+ # WorkSpaces Personal connection/session metrics (AWS/WorkSpaces, dimension WorkspaceId). Idle
101
+ # desktops typically publish only UserConnected; the rest emit when there are connection attempts.
102
+ WORKSPACES_CONNECTION_METRICS = [
103
+ ("UserConnected", "Count"),
104
+ ("ConnectionAttempt", "Count"),
105
+ ("ConnectionSuccess", "Count"),
106
+ ("ConnectionFailure", "Count"),
107
+ ("SessionLaunchTime", "Milliseconds"),
108
+ ("InSessionLatency", "Milliseconds"),
109
+ ]
110
+
111
+ # WorkSpaces Pools user-session metrics. NOTE the dimension name is literally "WorkSpaces pool ID"
112
+ # (with spaces), not PoolId — verified against a live account.
113
+ WORKSPACES_POOL_DIMENSION = "WorkSpaces pool ID"
114
+ WORKSPACES_POOL_SESSION_METRICS = [
115
+ ("ActiveUserSessionCapacity", "Count"),
116
+ ("ActualUserSessionCapacity", "Count"),
117
+ ("AvailableUserSessionCapacity", "Count"),
118
+ ("DesiredUserSessionCapacity", "Count"),
119
+ ("PendingUserSessionCapacity", "Count"),
120
+ ("UserSessionsCapacityUtilization", "Percent"),
121
+ ]
122
+
123
+ # Secure Browser session metrics (AWS/WorkSpacesWeb, dimension PortalId). NOTE: unlike the other
124
+ # services, Secure Browser only emits these when sessions actually occur (idle portals publish
125
+ # nothing), and richer usage goes via the Session Logger. Names below are per AWS docs and are
126
+ # NOT yet verified against live data (the account's portals have had no sessions).
127
+ SECURE_BROWSER_NAMESPACE = "AWS/WorkSpacesWeb"
128
+ SECURE_BROWSER_PORTAL_DIMENSION = "PortalId"
129
+ SECURE_BROWSER_SESSION_METRICS = [
130
+ ("SessionAttempt", "Count"),
131
+ ("SessionSuccess", "Count"),
132
+ ("SessionFailure", "Count"),
133
+ ]
134
+
135
+ # Secure Browser user-settings flags that, when "Enabled", relax data-egress controls. Used by the
136
+ # security audit. Verified live: GetUserSettings returns these as "Enabled"/"Disabled".
137
+ SECURE_BROWSER_EGRESS_FLAGS = ["downloadAllowed", "copyAllowed", "printAllowed"]
138
+
139
+ SERVER_INSTRUCTIONS = """\
140
+ Administrator-focused MCP server for the Amazon WorkSpaces End User Computing portfolio:
141
+ WorkSpaces Personal, WorkSpaces Pools, WorkSpaces Applications, WorkSpaces Secure Browser, and
142
+ WorkSpaces Core. Tools are read-only by default and synthesize cross-service results for
143
+ inventory, troubleshooting, and cost/utilization optimization. Write/lifecycle operations are not
144
+ enabled unless the server was launched with --enable-writes and the matching IAM permissions are
145
+ present.
146
+
147
+ Legacy/former service names are fully supported as input — accept them and treat them as the
148
+ current service, but ALWAYS use the current official name in your response:
149
+ - "AppStream", "AppStream 2.0", "Amazon AppStream 2.0" -> Amazon WorkSpaces Applications
150
+ - "WorkSpaces Web" -> Amazon WorkSpaces Secure Browser
151
+ Amazon WorkSpaces Applications IS the rebranded AppStream 2.0 (same service and API), so a request
152
+ about "AppStream fleets" or "AppStream stacks" is about WorkSpaces Applications and is handled by
153
+ the application-fleet tools — do not say AppStream is unsupported.
154
+ """
@@ -0,0 +1,333 @@
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
+ """Pydantic models for tool inputs and synthesized outputs."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class ServiceInventory(BaseModel):
12
+ """Per-service rollup within an inventory summary."""
13
+
14
+ service: str = Field(description="Current official AWS product name.")
15
+ resource_type: str = Field(description="The kind of resource counted (e.g. WorkSpace, Fleet).")
16
+ count: int = Field(description="Number of resources of this type found in the region.")
17
+ by_state: dict[str, int] = Field(
18
+ default_factory=dict,
19
+ description="Breakdown of resources by their lifecycle state.",
20
+ )
21
+
22
+
23
+ class ServiceError(BaseModel):
24
+ """A non-fatal error encountered while calling one AWS service.
25
+
26
+ Collection/diagnosis is best-effort: a permission gap or unsupported region for one service is
27
+ recorded here rather than failing the whole result.
28
+ """
29
+
30
+ service: str
31
+ operation: str = Field(description="The AWS API operation that failed.")
32
+ message: str
33
+
34
+
35
+ # Backwards-compatible alias (the inventory tool predates the generic name).
36
+ InventoryError = ServiceError
37
+
38
+
39
+ class EucInventorySummary(BaseModel):
40
+ """Cross-service inventory of the Amazon WorkSpaces EUC portfolio in one region."""
41
+
42
+ region: str | None = Field(description="AWS region the summary covers.")
43
+ account_scope: str = Field(default="single-account")
44
+ total_resources: int = Field(description="Sum of counts across all services collected.")
45
+ services: list[ServiceInventory] = Field(default_factory=list)
46
+ errors: list[ServiceError] = Field(default_factory=list)
47
+ notes: list[str] = Field(default_factory=list)
48
+
49
+
50
+ class Finding(BaseModel):
51
+ """A single observation from a diagnostic or audit tool."""
52
+
53
+ severity: str = Field(description="One of: info, warning, critical.")
54
+ title: str = Field(description="Short human-readable headline.")
55
+ detail: str = Field(description="What was observed and why it matters.")
56
+ recommendation: str | None = Field(default=None, description="Suggested next action, if any.")
57
+ resource_id: str | None = Field(
58
+ default=None, description="Resource the finding applies to, when relevant."
59
+ )
60
+
61
+
62
+ class Diagnosis(BaseModel):
63
+ """Synthesized diagnosis for a single EUC resource."""
64
+
65
+ target_type: str = Field(description="Kind of resource diagnosed (official product name).")
66
+ target_id: str = Field(description="Identifier of the resource diagnosed.")
67
+ region: str | None = None
68
+ status: str = Field(
69
+ description="Overall verdict: healthy, degraded, unhealthy, unknown, or not_found."
70
+ )
71
+ summary: str = Field(description="One-line plain-language verdict.")
72
+ signals: dict[str, object] = Field(
73
+ default_factory=dict,
74
+ description="Key raw signals observed (state, capacity, connection status, etc.).",
75
+ )
76
+ findings: list[Finding] = Field(default_factory=list)
77
+ errors: list[ServiceError] = Field(default_factory=list)
78
+
79
+
80
+ class DirectoryHealthReport(BaseModel):
81
+ """Health of one or more WorkSpaces-registered directories in a region."""
82
+
83
+ region: str | None = None
84
+ directories: list[Diagnosis] = Field(default_factory=list)
85
+ errors: list[ServiceError] = Field(default_factory=list)
86
+
87
+
88
+ class MetricStat(BaseModel):
89
+ """Aggregated values for one CloudWatch metric over the window."""
90
+
91
+ latest: float | None = None
92
+ average: float | None = None
93
+ peak: float | None = None
94
+ unit: str
95
+
96
+
97
+ class WorkspacePerformance(BaseModel):
98
+ """Native AWS/WorkSpaces performance metrics for one desktop."""
99
+
100
+ workspace_id: str
101
+ lookback_hours: int
102
+ metrics: dict[str, MetricStat] = Field(default_factory=dict)
103
+ note: str | None = None
104
+
105
+
106
+ class PerformanceReport(BaseModel):
107
+ """Performance metrics across one or more WorkSpaces Personal desktops."""
108
+
109
+ region: str | None = None
110
+ lookback_hours: int
111
+ workspaces: list[WorkspacePerformance] = Field(default_factory=list)
112
+ errors: list[ServiceError] = Field(default_factory=list)
113
+ notes: list[str] = Field(default_factory=list)
114
+
115
+
116
+ class UsagePoint(BaseModel):
117
+ """One time-bucket in a usage time-series."""
118
+
119
+ timestamp: str
120
+ average: float | None = None
121
+ peak: float | None = None
122
+
123
+
124
+ class FleetMetricSeries(BaseModel):
125
+ """Aggregates plus the per-bucket time-series for one fleet metric."""
126
+
127
+ unit: str
128
+ latest: float | None = None
129
+ average: float | None = None
130
+ peak: float | None = None
131
+ series: list[UsagePoint] = Field(default_factory=list)
132
+
133
+
134
+ class FleetUsage(BaseModel):
135
+ """Usage history for a WorkSpaces Applications fleet over a window."""
136
+
137
+ fleet_name: str
138
+ lookback_days: int
139
+ period_hours: int
140
+ metrics: dict[str, FleetMetricSeries] = Field(default_factory=dict)
141
+ summary: str | None = None
142
+ errors: list[ServiceError] = Field(default_factory=list)
143
+
144
+
145
+ class SecureBrowserPortalDetails(BaseModel):
146
+ """Resolved settings for a WorkSpaces Secure Browser portal."""
147
+
148
+ portal_arn: str
149
+ display_name: str | None = None
150
+ authentication_type: str | None = None
151
+ status: str | None = None
152
+ user_settings: dict[str, object] = Field(
153
+ default_factory=dict,
154
+ description="Clipboard/print/download/upload controls and timeouts.",
155
+ )
156
+ network: dict[str, object] = Field(default_factory=dict)
157
+ has_browser_policy: bool = False
158
+ has_data_protection: bool = False
159
+ errors: list[ServiceError] = Field(default_factory=list)
160
+
161
+
162
+ class UsageHistory(BaseModel):
163
+ """Generic metric time-series history for a single EUC resource over a window."""
164
+
165
+ target_type: str = Field(description="Resource kind, e.g. the current official product name.")
166
+ target_id: str
167
+ lookback_days: int
168
+ period_hours: int
169
+ metrics: dict[str, FleetMetricSeries] = Field(default_factory=dict)
170
+ summary: str | None = None
171
+ errors: list[ServiceError] = Field(default_factory=list)
172
+
173
+
174
+ class WorkspaceUtilization(BaseModel):
175
+ """Utilization classification for a single WorkSpaces Personal desktop."""
176
+
177
+ workspace_id: str
178
+ running_mode: str | None = None
179
+ lookback_days: int
180
+ active_days: int | None = Field(
181
+ default=None, description="Days with at least one user connection in the window."
182
+ )
183
+ classification: str = Field(description="unused, idle, active, or unknown.")
184
+ # Inputs for pricing/savings estimates (not always populated).
185
+ compute_type: str | None = None
186
+ operating_system: str | None = None
187
+ root_volume_gib: int | None = None
188
+ user_volume_gib: int | None = None
189
+
190
+
191
+ class UtilizationReport(BaseModel):
192
+ """Utilization rollup across WorkSpaces Personal desktops in a region."""
193
+
194
+ region: str | None = None
195
+ lookback_days: int
196
+ total: int
197
+ counts: dict[str, int] = Field(
198
+ default_factory=dict, description="Count of desktops per classification."
199
+ )
200
+ workspaces: list[WorkspaceUtilization] = Field(default_factory=list)
201
+ errors: list[ServiceError] = Field(default_factory=list)
202
+ notes: list[str] = Field(default_factory=list)
203
+
204
+
205
+ class Recommendation(BaseModel):
206
+ """A single cost/utilization optimization recommendation."""
207
+
208
+ target_id: str
209
+ kind: str = Field(description="The recommendation type, e.g. running_mode.")
210
+ current: str | None = None
211
+ recommended: str | None = None
212
+ rationale: str
213
+ estimated_monthly_savings_usd: float | None = Field(
214
+ default=None, description="Estimated saving, when it can be computed; otherwise null."
215
+ )
216
+ confidence: str = Field(description="low, medium, or high.")
217
+
218
+
219
+ class RecommendationReport(BaseModel):
220
+ """Set of optimization recommendations for a region."""
221
+
222
+ region: str | None = None
223
+ lookback_days: int
224
+ recommendations: list[Recommendation] = Field(default_factory=list)
225
+ errors: list[ServiceError] = Field(default_factory=list)
226
+ notes: list[str] = Field(default_factory=list)
227
+
228
+
229
+ class CostLineItem(BaseModel):
230
+ service: str
231
+ amount: float
232
+
233
+
234
+ class CostSummary(BaseModel):
235
+ """Cost rollup for the EUC portfolio over a time window (account-wide)."""
236
+
237
+ scope: str = Field(
238
+ default="account-wide",
239
+ description="Cost Explorer is not region-scoped; figures are account-wide.",
240
+ )
241
+ start: str
242
+ end: str
243
+ granularity: str
244
+ currency: str = "USD"
245
+ total: float
246
+ by_service: list[CostLineItem] = Field(default_factory=list)
247
+ errors: list[ServiceError] = Field(default_factory=list)
248
+ notes: list[str] = Field(default_factory=list)
249
+
250
+
251
+ class ResourceRecord(BaseModel):
252
+ """A single resource row in an inventory report."""
253
+
254
+ id: str
255
+ name: str | None = None
256
+ state: str | None = None
257
+ attributes: dict[str, object] = Field(default_factory=dict)
258
+
259
+
260
+ class InventoryReportSection(BaseModel):
261
+ service: str
262
+ resource_type: str
263
+ resources: list[ResourceRecord] = Field(default_factory=list)
264
+
265
+
266
+ class InventoryReport(BaseModel):
267
+ """Detailed per-resource inventory across the EUC portfolio in a region."""
268
+
269
+ region: str | None = None
270
+ total_resources: int = 0
271
+ sections: list[InventoryReportSection] = Field(default_factory=list)
272
+ errors: list[ServiceError] = Field(default_factory=list)
273
+
274
+
275
+ class AuditReport(BaseModel):
276
+ """Security-posture findings across the EUC portfolio in a region."""
277
+
278
+ region: str | None = None
279
+ findings: list[Finding] = Field(default_factory=list)
280
+ severity_counts: dict[str, int] = Field(default_factory=dict)
281
+ resources_checked: dict[str, int] = Field(default_factory=dict)
282
+ errors: list[ServiceError] = Field(default_factory=list)
283
+
284
+
285
+ class UnusedResource(BaseModel):
286
+ service: str
287
+ resource_type: str
288
+ id: str
289
+ reason: str
290
+
291
+
292
+ class UnusedResourcesReport(BaseModel):
293
+ """Candidate idle/unused resources worth reclaiming."""
294
+
295
+ region: str | None = None
296
+ lookback_days: int
297
+ items: list[UnusedResource] = Field(default_factory=list)
298
+ errors: list[ServiceError] = Field(default_factory=list)
299
+ notes: list[str] = Field(default_factory=list)
300
+
301
+
302
+ class TargetResult(BaseModel):
303
+ """Outcome of a write action against a single target."""
304
+
305
+ target_id: str
306
+ status: str = Field(description="ok, error, or skipped.")
307
+ message: str | None = None
308
+
309
+
310
+ class WriteOutcome(BaseModel):
311
+ """Result of a guarded write/lifecycle action.
312
+
313
+ Mutations are dry-run by default: unless ``confirmed`` is true the action only reports the plan
314
+ and changes nothing. Bulk actions are refused when the target count exceeds the blast-radius
315
+ cap.
316
+ """
317
+
318
+ action: str = Field(description="The lifecycle action requested.")
319
+ dry_run: bool = Field(description="True when nothing was changed (plan only).")
320
+ confirmed: bool = Field(description="Whether the caller explicitly confirmed execution.")
321
+ requested_targets: list[str] = Field(default_factory=list)
322
+ max_bulk_targets: int = Field(description="Configured blast-radius cap.")
323
+ blast_radius_ok: bool = Field(
324
+ description="False when the action was refused for being too large."
325
+ )
326
+ plan: str = Field(description="Human-readable description of what would happen / happened.")
327
+ acknowledgement_required: str | None = Field(
328
+ default=None,
329
+ description="For destructive actions: the exact phrase the caller must pass to proceed.",
330
+ )
331
+ results: list[TargetResult] = Field(default_factory=list)
332
+ errors: list[ServiceError] = Field(default_factory=list)
333
+ notes: list[str] = Field(default_factory=list)