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,307 @@
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
+ """Destructive WorkSpaces Personal operations — Phase 3, IAM Tier 3.
5
+
6
+ These data-impacting / irreversible operations are gated more strictly than the Phase 2 lifecycle
7
+ tools. They are registered ONLY when the server is launched with both ``--enable-writes`` and
8
+ ``--enable-destructive``, and every execution requires, in addition to ``confirm=true``:
9
+
10
+ * an exact **typed acknowledgement** phrase (e.g. ``acknowledge="TERMINATE"``), and
11
+ * staying within the ``--max-bulk-targets`` blast-radius cap.
12
+
13
+ Default behaviour is still a dry-run that changes nothing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from .. import consts
21
+ from ..clients import ClientFactory
22
+ from ..models import ServiceError, TargetResult, WriteOutcome
23
+ from ._common import try_call, writes
24
+
25
+ # action -> (boto3 method, per-request key, required acknowledgement phrase, impact note)
26
+ _BATCH_DESTRUCTIVE = {
27
+ "terminate": (
28
+ "terminate_workspaces",
29
+ "TerminateWorkspaceRequests",
30
+ "TERMINATE",
31
+ "PERMANENT and IRREVERSIBLE: the WorkSpace(s) and their data are deleted.",
32
+ ),
33
+ "rebuild": (
34
+ "rebuild_workspaces",
35
+ "RebuildWorkspaceRequests",
36
+ "REBUILD",
37
+ "Disruptive: the root volume is reset to the bundle and the user volume is restored from "
38
+ "the last snapshot; data since the last snapshot is lost.",
39
+ ),
40
+ }
41
+
42
+
43
+ def _dry_run(action: str, ids: list[str], max_bulk: int, detail: str, impact: str) -> WriteOutcome:
44
+ return WriteOutcome(
45
+ action=action,
46
+ dry_run=True,
47
+ confirmed=False,
48
+ requested_targets=ids,
49
+ max_bulk_targets=max_bulk,
50
+ blast_radius_ok=len(ids) <= max_bulk,
51
+ plan=f"Would {detail.lower()}",
52
+ results=[TargetResult(target_id=i, status="skipped", message="dry run") for i in ids],
53
+ notes=[
54
+ impact,
55
+ "Dry run — nothing was changed. Re-run with confirm=true and the exact acknowledge "
56
+ "phrase to execute.",
57
+ ],
58
+ )
59
+
60
+
61
+ def _refuse(
62
+ action: str, ids: list[str], max_bulk: int, plan: str, *, acknowledgement: str | None = None
63
+ ) -> WriteOutcome:
64
+ return WriteOutcome(
65
+ action=action,
66
+ dry_run=False,
67
+ confirmed=True,
68
+ requested_targets=ids,
69
+ max_bulk_targets=max_bulk,
70
+ blast_radius_ok=acknowledgement is not None or len(ids) <= max_bulk,
71
+ plan=plan,
72
+ acknowledgement_required=acknowledgement,
73
+ notes=["No changes were made."],
74
+ )
75
+
76
+
77
+ def _execute_outcome(
78
+ action: str,
79
+ ids: list[str],
80
+ max_bulk: int,
81
+ detail: str,
82
+ errors: list[ServiceError],
83
+ failed: dict[str, dict],
84
+ ) -> WriteOutcome:
85
+ results = [
86
+ TargetResult(
87
+ target_id=i,
88
+ status="error" if i in failed else "ok",
89
+ message=failed.get(i, {}).get("ErrorMessage") if i in failed else None,
90
+ )
91
+ for i in ids
92
+ ]
93
+ return WriteOutcome(
94
+ action=action,
95
+ dry_run=False,
96
+ confirmed=True,
97
+ requested_targets=ids,
98
+ max_bulk_targets=max_bulk,
99
+ blast_radius_ok=True,
100
+ plan=f"{detail} (executed).",
101
+ results=results,
102
+ errors=errors,
103
+ )
104
+
105
+
106
+ def batch_destructive_core(
107
+ factory: ClientFactory,
108
+ region: str | None,
109
+ action: str,
110
+ workspace_ids: list[str],
111
+ confirm: bool,
112
+ acknowledge: str,
113
+ max_bulk_targets: int,
114
+ ) -> WriteOutcome:
115
+ method_name, request_key, required_phrase, impact = _BATCH_DESTRUCTIVE[action]
116
+ detail = f"{action.capitalize()} {len(workspace_ids)} WorkSpace(s): {', '.join(workspace_ids)}"
117
+
118
+ if not workspace_ids:
119
+ return WriteOutcome(
120
+ action=action,
121
+ dry_run=not confirm,
122
+ confirmed=confirm,
123
+ requested_targets=[],
124
+ max_bulk_targets=max_bulk_targets,
125
+ blast_radius_ok=True,
126
+ plan="No target WorkSpaces were provided.",
127
+ )
128
+
129
+ if not confirm:
130
+ return _dry_run(action, workspace_ids, max_bulk_targets, detail, impact)
131
+
132
+ if len(workspace_ids) > max_bulk_targets:
133
+ return _refuse(
134
+ action,
135
+ workspace_ids,
136
+ max_bulk_targets,
137
+ plan=(
138
+ f"Refused: {len(workspace_ids)} targets exceed the blast-radius cap of "
139
+ f"{max_bulk_targets}."
140
+ ),
141
+ )
142
+
143
+ if acknowledge.strip() != required_phrase:
144
+ return _refuse(
145
+ action,
146
+ workspace_ids,
147
+ max_bulk_targets,
148
+ plan=(
149
+ f"Refused: this {action} is destructive and requires the exact acknowledgement "
150
+ f"phrase. {impact}"
151
+ ),
152
+ acknowledgement=required_phrase,
153
+ )
154
+
155
+ errors: list[ServiceError] = []
156
+ client = factory.client(consts.WORKSPACES_API, region=region)
157
+ requests = [{"WorkspaceId": wid} for wid in workspace_ids]
158
+
159
+ def execute() -> Any:
160
+ return getattr(client, method_name)(**{request_key: requests})
161
+
162
+ response = try_call(
163
+ errors, consts.PRODUCT_WORKSPACES_PERSONAL, method_name, execute, default={}
164
+ )
165
+ failed = {fr.get("WorkspaceId"): fr for fr in (response or {}).get("FailedRequests", [])}
166
+ return _execute_outcome(action, workspace_ids, max_bulk_targets, detail, errors, failed)
167
+
168
+
169
+ def restore_workspace_core(
170
+ factory: ClientFactory,
171
+ region: str | None,
172
+ workspace_id: str,
173
+ confirm: bool,
174
+ acknowledge: str,
175
+ max_bulk_targets: int,
176
+ ) -> WriteOutcome:
177
+ action = "restore"
178
+ required_phrase = "RESTORE"
179
+ impact = "Disruptive: the WorkSpace is restored from its last snapshot; unsynced data is lost."
180
+ detail = f"Restore WorkSpace {workspace_id}"
181
+ ids = [workspace_id]
182
+
183
+ if not confirm:
184
+ return _dry_run(action, ids, max_bulk_targets, detail, impact)
185
+
186
+ if acknowledge.strip() != required_phrase:
187
+ return _refuse(
188
+ action,
189
+ ids,
190
+ max_bulk_targets,
191
+ plan=f"Refused: restore is destructive and requires the exact acknowledgement phrase. "
192
+ f"{impact}",
193
+ acknowledgement=required_phrase,
194
+ )
195
+
196
+ errors: list[ServiceError] = []
197
+ client = factory.client(consts.WORKSPACES_API, region=region)
198
+ try_call(
199
+ errors,
200
+ consts.PRODUCT_WORKSPACES_PERSONAL,
201
+ "RestoreWorkspace",
202
+ lambda: client.restore_workspace(WorkspaceId=workspace_id),
203
+ default={},
204
+ )
205
+ status = "error" if errors else "ok"
206
+ return WriteOutcome(
207
+ action=action,
208
+ dry_run=False,
209
+ confirmed=True,
210
+ requested_targets=ids,
211
+ max_bulk_targets=max_bulk_targets,
212
+ blast_radius_ok=True,
213
+ plan=f"{detail} (executed)." if not errors else f"{detail} (failed).",
214
+ results=[
215
+ TargetResult(
216
+ target_id=workspace_id,
217
+ status=status,
218
+ message=errors[0].message if errors else None,
219
+ )
220
+ ],
221
+ errors=errors,
222
+ )
223
+
224
+
225
+ def register(mcp: Any, factory: ClientFactory, *, max_bulk_targets: int) -> None:
226
+ """Register destructive tools. Only call this when destructive ops are enabled."""
227
+
228
+ async def terminate_workspaces(
229
+ workspace_ids: list[str],
230
+ confirm: bool = False,
231
+ acknowledge: str = "",
232
+ region: str | None = None,
233
+ ) -> dict[str, Any]:
234
+ """Permanently terminate (delete) WorkSpaces Personal desktops. IRREVERSIBLE.
235
+
236
+ Dry-run by default. To execute you must pass confirm=true AND acknowledge="TERMINATE", and
237
+ stay within the blast-radius cap. Deleted WorkSpaces and their data cannot be recovered.
238
+
239
+ Args:
240
+ workspace_ids: WorkSpace IDs to terminate.
241
+ confirm: Set true to execute (still requires acknowledge).
242
+ acknowledge: Must be exactly "TERMINATE" to proceed.
243
+ region: AWS region. Defaults to the server's configured region.
244
+ """
245
+ outcome = batch_destructive_core(
246
+ factory,
247
+ region or factory.region,
248
+ "terminate",
249
+ workspace_ids,
250
+ confirm,
251
+ acknowledge,
252
+ max_bulk_targets,
253
+ )
254
+ return outcome.model_dump()
255
+
256
+ async def rebuild_workspaces(
257
+ workspace_ids: list[str],
258
+ confirm: bool = False,
259
+ acknowledge: str = "",
260
+ region: str | None = None,
261
+ ) -> dict[str, Any]:
262
+ """Rebuild WorkSpaces Personal desktops (resets root volume; user volume from snapshot).
263
+
264
+ Dry-run by default. To execute you must pass confirm=true AND acknowledge="REBUILD". Data
265
+ written since the last snapshot is lost.
266
+
267
+ Args:
268
+ workspace_ids: WorkSpace IDs to rebuild.
269
+ confirm: Set true to execute (still requires acknowledge).
270
+ acknowledge: Must be exactly "REBUILD" to proceed.
271
+ region: AWS region. Defaults to the server's configured region.
272
+ """
273
+ outcome = batch_destructive_core(
274
+ factory,
275
+ region or factory.region,
276
+ "rebuild",
277
+ workspace_ids,
278
+ confirm,
279
+ acknowledge,
280
+ max_bulk_targets,
281
+ )
282
+ return outcome.model_dump()
283
+
284
+ async def restore_workspace(
285
+ workspace_id: str,
286
+ confirm: bool = False,
287
+ acknowledge: str = "",
288
+ region: str | None = None,
289
+ ) -> dict[str, Any]:
290
+ """Restore a WorkSpaces Personal desktop from its last snapshot.
291
+
292
+ Dry-run by default. To execute you must pass confirm=true AND acknowledge="RESTORE".
293
+
294
+ Args:
295
+ workspace_id: The WorkSpace ID to restore.
296
+ confirm: Set true to execute (still requires acknowledge).
297
+ acknowledge: Must be exactly "RESTORE" to proceed.
298
+ region: AWS region. Defaults to the server's configured region.
299
+ """
300
+ outcome = restore_workspace_core(
301
+ factory, region or factory.region, workspace_id, confirm, acknowledge, max_bulk_targets
302
+ )
303
+ return outcome.model_dump()
304
+
305
+ mcp.add_tool(terminate_workspaces, annotations=writes("Terminate WorkSpaces", destructive=True))
306
+ mcp.add_tool(rebuild_workspaces, annotations=writes("Rebuild WorkSpaces", destructive=True))
307
+ mcp.add_tool(restore_workspace, annotations=writes("Restore WorkSpace", destructive=True))