@getmagical/cli 0.4.0 → 0.7.0

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,519 @@
1
+ #!/usr/bin/env python3
2
+ """Bounded offline reader for Magical mitmproxy recording archives."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import urllib.parse
12
+ import zlib
13
+ from datetime import datetime, timezone
14
+ from typing import Any, BinaryIO
15
+
16
+ EXPECTED_MITMPROXY_VERSION = "12.2.3"
17
+ MAX_REQUEST_BYTES = 64 * 1024
18
+ READ_CHUNK_BYTES = 64 * 1024
19
+ EXCHANGE_REFERENCE_PATTERN = re.compile(
20
+ r"^mitm-v1:([0-9a-f]{64}):([0-9]+)$"
21
+ )
22
+
23
+
24
+ class ArchiveError(Exception):
25
+ def __init__(self, code: str, message: str):
26
+ super().__init__(message)
27
+ self.code = code
28
+
29
+
30
+ class BoundedFlowFile:
31
+ """Limit individual parser reads before tnetstring allocates a flow record."""
32
+
33
+ def __init__(self, archive: BinaryIO, max_read_bytes: int):
34
+ self.archive = archive
35
+ self.max_read_bytes = max_read_bytes
36
+
37
+ def read(self, size: int = -1) -> bytes:
38
+ if size < 0:
39
+ raise ArchiveError(
40
+ "UNSUPPORTED_ARCHIVE_FORMAT",
41
+ "Expected a mitmproxy flow archive, not a JSON archive.",
42
+ )
43
+ if size > self.max_read_bytes:
44
+ raise ArchiveError(
45
+ "FLOW_TOO_LARGE", "A serialized archive flow exceeds the size limit."
46
+ )
47
+ return self.archive.read(size)
48
+
49
+ def peek(self, size: int = 0) -> bytes:
50
+ if size > self.max_read_bytes:
51
+ raise ArchiveError(
52
+ "FLOW_TOO_LARGE", "A serialized archive flow exceeds the size limit."
53
+ )
54
+ peek = getattr(self.archive, "peek", None)
55
+ return peek(size) if peek is not None else b""
56
+
57
+ def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
58
+ return self.archive.seek(offset, whence)
59
+
60
+ def tell(self) -> int:
61
+ return self.archive.tell()
62
+
63
+
64
+ def emit(value: dict[str, Any]) -> None:
65
+ json.dump(value, sys.stdout, separators=(",", ":"))
66
+ sys.stdout.write("\n")
67
+
68
+
69
+ def fail(code: str, message: str) -> None:
70
+ emit({"ok": False, "error": {"code": code, "message": message}})
71
+
72
+
73
+ def read_request() -> dict[str, Any]:
74
+ request_bytes = sys.stdin.buffer.read(MAX_REQUEST_BYTES + 1)
75
+ if len(request_bytes) > MAX_REQUEST_BYTES:
76
+ raise ArchiveError("INVALID_REQUEST", "Parser request is too large.")
77
+ try:
78
+ request = json.loads(request_bytes)
79
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
80
+ raise ArchiveError("INVALID_REQUEST", "Parser request is not valid JSON.") from error
81
+ if not isinstance(request, dict):
82
+ raise ArchiveError("INVALID_REQUEST", "Parser request must be an object.")
83
+ return request
84
+
85
+
86
+ def require_string(request: dict[str, Any], key: str) -> str:
87
+ value = request.get(key)
88
+ if not isinstance(value, str) or not value:
89
+ raise ArchiveError("INVALID_REQUEST", f"{key} must be a non-empty string.")
90
+ return value
91
+
92
+
93
+ def require_positive_integer(request: dict[str, Any], key: str) -> int:
94
+ value = request.get(key)
95
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
96
+ raise ArchiveError("INVALID_REQUEST", f"{key} must be a positive integer.")
97
+ return value
98
+
99
+
100
+ def require_positive_number(request: dict[str, Any], key: str) -> float:
101
+ value = request.get(key)
102
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
103
+ raise ArchiveError("INVALID_REQUEST", f"{key} must be a positive number.")
104
+ return float(value)
105
+
106
+
107
+ def archive_identity(archive_path: str, max_archive_bytes: int) -> tuple[int, str]:
108
+ try:
109
+ size_bytes = os.path.getsize(archive_path)
110
+ except OSError as error:
111
+ raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive is unavailable.") from error
112
+ if size_bytes > max_archive_bytes:
113
+ raise ArchiveError("ARCHIVE_TOO_LARGE", "Recording archive exceeds the size limit.")
114
+
115
+ digest = hashlib.sha256()
116
+ try:
117
+ with open(archive_path, "rb") as archive:
118
+ while chunk := archive.read(1024 * 1024):
119
+ digest.update(chunk)
120
+ except OSError as error:
121
+ raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive could not be read.") from error
122
+ return size_bytes, digest.hexdigest()
123
+
124
+
125
+ def projected_origin(flow: Any) -> str:
126
+ request = flow.request
127
+ try:
128
+ split = urllib.parse.urlsplit(request.url)
129
+ scheme = split.scheme.lower()
130
+ hostname = split.hostname or request.host
131
+ port = split.port
132
+ except (TypeError, ValueError):
133
+ scheme = str(request.scheme).lower()
134
+ hostname = str(request.host)
135
+ port = request.port
136
+ default_port = (scheme == "http" and port == 80) or (
137
+ scheme == "https" and port == 443
138
+ )
139
+ authority_host = f"[{hostname}]" if ":" in hostname else hostname
140
+ host = (
141
+ authority_host
142
+ if port is None or default_port
143
+ else f"{authority_host}:{port}"
144
+ )
145
+ origin = f"{scheme}://{host}"
146
+ if len(origin) > 512 or any(character < " " for character in origin):
147
+ raise ArchiveError("UNSUPPORTED_EXTRACTION", "Request origin is invalid.")
148
+ return origin
149
+
150
+
151
+ def normalized_media_type(value: str | None) -> str | None:
152
+ if value is None:
153
+ return None
154
+ if any(character < " " for character in value):
155
+ return None
156
+ media_type = value.partition(";")[0].strip().lower()
157
+ token = r"[!#$%&'*+.^_`|~0-9a-z-]+"
158
+ return media_type if re.fullmatch(rf"{token}/{token}", media_type) else None
159
+
160
+
161
+ def request_timestamp(flow: Any) -> str | None:
162
+ try:
163
+ return datetime.fromtimestamp(
164
+ flow.request.timestamp_start, timezone.utc
165
+ ).isoformat().replace("+00:00", "Z")
166
+ except (OverflowError, OSError, TypeError, ValueError):
167
+ return None
168
+
169
+
170
+ def body_metadata(response: Any) -> dict[str, Any]:
171
+ raw_content = response.raw_content
172
+ if raw_content is None:
173
+ return {"state": "MISSING", "encodedSizeBytes": None}
174
+ if not raw_content:
175
+ return {"state": "EMPTY", "encodedSizeBytes": 0}
176
+ return {"state": "PRESENT", "encodedSizeBytes": len(raw_content)}
177
+
178
+
179
+ def response_metadata(response: Any) -> dict[str, Any] | None:
180
+ if response is None:
181
+ return None
182
+ return {
183
+ "status": response.status_code,
184
+ "contentType": normalized_media_type(response.headers.get("content-type")),
185
+ "body": body_metadata(response),
186
+ }
187
+
188
+
189
+ def read_next_flow(iterator: Any) -> Any | None:
190
+ try:
191
+ return next(iterator)
192
+ except StopIteration:
193
+ return None
194
+ except Exception as error:
195
+ from mitmproxy import exceptions
196
+
197
+ if isinstance(error, exceptions.FlowReadException):
198
+ raise ArchiveError("MALFORMED_ARCHIVE", "Recording archive is malformed.") from error
199
+ raise
200
+
201
+
202
+ def inspect_archive(
203
+ request: dict[str, Any], archive_path: str, size_bytes: int, archive_sha256: str
204
+ ) -> dict[str, Any]:
205
+ from mitmproxy import http, io
206
+
207
+ max_flows = require_positive_integer(request, "maxFlows")
208
+ max_exchanges = require_positive_integer(request, "maxExchanges")
209
+ max_serialized_flow_bytes = require_positive_integer(
210
+ request, "maxSerializedFlowBytes"
211
+ )
212
+ exchanges: list[dict[str, Any]] = []
213
+ scanned_flow_count = 0
214
+ complete = False
215
+ truncation_reason: str | None = None
216
+
217
+ try:
218
+ with open(archive_path, "rb") as archive:
219
+ bounded_archive = BoundedFlowFile(archive, max_serialized_flow_bytes)
220
+ iterator = iter(io.FlowReader(bounded_archive).stream())
221
+ while scanned_flow_count < max_flows:
222
+ flow = read_next_flow(iterator)
223
+ if flow is None:
224
+ complete = True
225
+ break
226
+ flow_index = scanned_flow_count
227
+ scanned_flow_count += 1
228
+ if not isinstance(flow, http.HTTPFlow) or flow.request is None:
229
+ continue
230
+ if len(exchanges) >= max_exchanges:
231
+ truncation_reason = "RESULT_LIMIT"
232
+ break
233
+ method = flow.request.method
234
+ if len(method) > 32 or not re.fullmatch(
235
+ r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", method
236
+ ):
237
+ method = "UNKNOWN"
238
+ exchanges.append(
239
+ {
240
+ "exchange": f"mitm-v1:{archive_sha256}:{flow_index}",
241
+ "index": flow_index,
242
+ "method": method,
243
+ "origin": projected_origin(flow),
244
+ "timestamp": request_timestamp(flow),
245
+ "response": response_metadata(flow.response),
246
+ }
247
+ )
248
+ else:
249
+ if archive.peek(1):
250
+ truncation_reason = "FLOW_LIMIT"
251
+ else:
252
+ complete = True
253
+ except ArchiveError:
254
+ raise
255
+ except OSError as error:
256
+ raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive could not be read.") from error
257
+
258
+ return {
259
+ "archive": {"sha256": archive_sha256, "sizeBytes": size_bytes},
260
+ "exchanges": exchanges,
261
+ "scan": {
262
+ "scannedFlowCount": scanned_flow_count,
263
+ "returnedExchangeCount": len(exchanges),
264
+ "complete": complete,
265
+ "resultLimit": max_exchanges,
266
+ "flowLimit": max_flows,
267
+ "truncationReason": truncation_reason,
268
+ },
269
+ }
270
+
271
+
272
+ def decode_zlib(content: bytes, max_output_bytes: int, window_bits: int) -> bytes:
273
+ decoder = zlib.decompressobj(window_bits)
274
+ output = bytearray()
275
+ for offset in range(0, len(content), READ_CHUNK_BYTES):
276
+ pending = content[offset : offset + READ_CHUNK_BYTES]
277
+ while pending:
278
+ remaining = max_output_bytes - len(output)
279
+ decoded = decoder.decompress(pending, remaining + 1)
280
+ output.extend(decoded)
281
+ if len(output) > max_output_bytes:
282
+ raise ArchiveError(
283
+ "DECODED_BODY_TOO_LARGE",
284
+ "Decoded response body exceeds the size limit.",
285
+ )
286
+ pending = decoder.unconsumed_tail
287
+ output.extend(decoder.flush(max_output_bytes - len(output) + 1))
288
+ if len(output) > max_output_bytes:
289
+ raise ArchiveError(
290
+ "DECODED_BODY_TOO_LARGE", "Decoded response body exceeds the size limit."
291
+ )
292
+ if not decoder.eof or decoder.unused_data:
293
+ raise zlib.error("incomplete stream or trailing data")
294
+ return bytes(output)
295
+
296
+
297
+ def decode_one(content: bytes, encoding: str, max_output_bytes: int) -> bytes:
298
+ if encoding in {"", "identity"}:
299
+ if len(content) > max_output_bytes:
300
+ raise ArchiveError(
301
+ "DECODED_BODY_TOO_LARGE", "Decoded response body exceeds the size limit."
302
+ )
303
+ return content
304
+ try:
305
+ if encoding == "gzip":
306
+ return decode_zlib(content, max_output_bytes, 31)
307
+ if encoding == "deflate":
308
+ try:
309
+ return decode_zlib(content, max_output_bytes, zlib.MAX_WBITS)
310
+ except zlib.error:
311
+ return decode_zlib(content, max_output_bytes, -zlib.MAX_WBITS)
312
+ except ArchiveError:
313
+ raise
314
+ except Exception as error:
315
+ raise ArchiveError(
316
+ "INVALID_CONTENT_ENCODING", "Response content encoding is invalid."
317
+ ) from error
318
+ raise ArchiveError(
319
+ "UNSUPPORTED_ENCODING",
320
+ f"Response content encoding {encoding!r} is unsupported.",
321
+ )
322
+
323
+
324
+ def decode_content(
325
+ content: bytes,
326
+ content_encoding: str | None,
327
+ max_decoded_body_bytes: int,
328
+ max_expansion_ratio: float,
329
+ ) -> bytes:
330
+ expansion_limit = int(max(len(content), 1) * max_expansion_ratio)
331
+ output_limit = min(max_decoded_body_bytes, expansion_limit)
332
+ encodings = [
333
+ encoding.strip().lower()
334
+ for encoding in (content_encoding or "identity").split(",")
335
+ ]
336
+ if len(encodings) != 1:
337
+ raise ArchiveError(
338
+ "UNSUPPORTED_ENCODING",
339
+ "Stacked response content encodings are unsupported.",
340
+ )
341
+ decoded = content
342
+ try:
343
+ for encoding in reversed(encodings):
344
+ decoded = decode_one(decoded, encoding, output_limit)
345
+ except ArchiveError as error:
346
+ if (
347
+ error.code == "DECODED_BODY_TOO_LARGE"
348
+ and expansion_limit < max_decoded_body_bytes
349
+ ):
350
+ raise ArchiveError(
351
+ "DECOMPRESSION_RATIO_EXCEEDED",
352
+ "Decoded response body exceeds the decompression ratio limit.",
353
+ ) from error
354
+ raise
355
+ if len(decoded) > max_decoded_body_bytes:
356
+ raise ArchiveError(
357
+ "DECODED_BODY_TOO_LARGE", "Decoded response body exceeds the size limit."
358
+ )
359
+ if len(decoded) > max(len(content), 1) * max_expansion_ratio:
360
+ raise ArchiveError(
361
+ "DECOMPRESSION_RATIO_EXCEEDED",
362
+ "Decoded response body exceeds the decompression ratio limit.",
363
+ )
364
+ return decoded
365
+
366
+
367
+ def write_private_file(output_path: str, content: bytes) -> None:
368
+ descriptor: int | None = None
369
+ try:
370
+ descriptor = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
371
+ with os.fdopen(descriptor, "wb") as output:
372
+ descriptor = None
373
+ output.write(content)
374
+ output.flush()
375
+ os.fsync(output.fileno())
376
+ except FileExistsError as error:
377
+ raise ArchiveError("OUTPUT_EXISTS", "Response output already exists.") from error
378
+ except OSError as error:
379
+ try:
380
+ os.unlink(output_path)
381
+ except OSError:
382
+ pass
383
+ raise ArchiveError("OUTPUT_WRITE_FAILED", "Response output could not be written.") from error
384
+ finally:
385
+ if descriptor is not None:
386
+ os.close(descriptor)
387
+
388
+
389
+ def extract_response(
390
+ request: dict[str, Any], archive_path: str, size_bytes: int, archive_sha256: str
391
+ ) -> dict[str, Any]:
392
+ from mitmproxy import http, io
393
+
394
+ reference = require_string(request, "exchange")
395
+ match = EXCHANGE_REFERENCE_PATTERN.fullmatch(reference)
396
+ if match is None:
397
+ raise ArchiveError("INVALID_EXCHANGE_REFERENCE", "Exchange reference is invalid.")
398
+ expected_sha256, index_text = match.groups()
399
+ if expected_sha256 != archive_sha256:
400
+ raise ArchiveError(
401
+ "ARCHIVE_MISMATCH", "Exchange reference belongs to a different archive."
402
+ )
403
+ target_index = int(index_text)
404
+ max_flows = require_positive_integer(request, "maxFlows")
405
+ max_serialized_flow_bytes = require_positive_integer(
406
+ request, "maxSerializedFlowBytes"
407
+ )
408
+
409
+ try:
410
+ with open(archive_path, "rb") as archive:
411
+ bounded_archive = BoundedFlowFile(archive, max_serialized_flow_bytes)
412
+ iterator = iter(io.FlowReader(bounded_archive).stream())
413
+ flow = None
414
+ current_index = 0
415
+ while current_index < max_flows:
416
+ current_flow = read_next_flow(iterator)
417
+ if current_flow is None:
418
+ break
419
+ if current_index == target_index:
420
+ flow = current_flow
421
+ current_index += 1
422
+ if current_index == max_flows and archive.peek(1):
423
+ raise ArchiveError(
424
+ "FLOW_LIMIT_EXCEEDED", "Recording archive exceeds the flow limit."
425
+ )
426
+ except ArchiveError:
427
+ raise
428
+ except OSError as error:
429
+ raise ArchiveError("ARCHIVE_UNAVAILABLE", "Recording archive could not be read.") from error
430
+
431
+ if flow is None:
432
+ raise ArchiveError("EXCHANGE_NOT_FOUND", "Exchange was not found.")
433
+ if not isinstance(flow, http.HTTPFlow) or flow.request is None:
434
+ raise ArchiveError("UNSUPPORTED_FLOW", "Exchange is not an HTTP request.")
435
+ if flow.response is None:
436
+ raise ArchiveError("MISSING_RESPONSE", "Exchange has no recorded response.")
437
+ if flow.response.raw_content is None:
438
+ raise ArchiveError("MISSING_BODY", "Exchange response body was not recorded.")
439
+
440
+ encoded_content = flow.response.raw_content
441
+ max_encoded_body_bytes = require_positive_integer(request, "maxEncodedBodyBytes")
442
+ if len(encoded_content) > max_encoded_body_bytes:
443
+ raise ArchiveError(
444
+ "ENCODED_BODY_TOO_LARGE", "Encoded response body exceeds the size limit."
445
+ )
446
+ decoded_content = decode_content(
447
+ encoded_content,
448
+ flow.response.headers.get("content-encoding"),
449
+ require_positive_integer(request, "maxDecodedBodyBytes"),
450
+ require_positive_number(request, "maxExpansionRatio"),
451
+ )
452
+ output_path = require_string(request, "outputPath")
453
+ write_private_file(output_path, decoded_content)
454
+ content_encoding = flow.response.headers.get("content-encoding")
455
+ normalized_encodings = [
456
+ encoding.strip().lower()
457
+ for encoding in (content_encoding or "").split(",")
458
+ if encoding.strip()
459
+ ]
460
+ return {
461
+ "archive": {"sha256": archive_sha256, "sizeBytes": size_bytes},
462
+ "exchange": reference,
463
+ "index": target_index,
464
+ "response": {
465
+ "contentType": normalized_media_type(
466
+ flow.response.headers.get("content-type")
467
+ ),
468
+ "sourceContentEncoding": ", ".join(normalized_encodings) or None,
469
+ "representation": "decoded-entity-body",
470
+ "encodedSizeBytes": len(encoded_content),
471
+ "sizeBytes": len(decoded_content),
472
+ "sha256": hashlib.sha256(decoded_content).hexdigest(),
473
+ },
474
+ "outputPath": output_path,
475
+ }
476
+
477
+
478
+ def main() -> int:
479
+ try:
480
+ if sys.version_info < (3, 12):
481
+ raise ArchiveError(
482
+ "RUNTIME_VERSION_MISMATCH", "Python 3.12 or newer is required."
483
+ )
484
+ request = read_request()
485
+ try:
486
+ from mitmproxy import version
487
+ except ImportError as error:
488
+ raise ArchiveError(
489
+ "RUNTIME_UNAVAILABLE",
490
+ "Python environment does not contain mitmproxy 12.2.3.",
491
+ ) from error
492
+ if version.VERSION != EXPECTED_MITMPROXY_VERSION:
493
+ raise ArchiveError(
494
+ "RUNTIME_VERSION_MISMATCH",
495
+ f"Expected mitmproxy {EXPECTED_MITMPROXY_VERSION}.",
496
+ )
497
+ archive_path = require_string(request, "archivePath")
498
+ size_bytes, archive_sha256 = archive_identity(
499
+ archive_path, require_positive_integer(request, "maxArchiveBytes")
500
+ )
501
+ operation = require_string(request, "operation")
502
+ if operation == "inspect":
503
+ result = inspect_archive(request, archive_path, size_bytes, archive_sha256)
504
+ elif operation == "extract":
505
+ result = extract_response(request, archive_path, size_bytes, archive_sha256)
506
+ else:
507
+ raise ArchiveError("INVALID_REQUEST", "Parser operation is invalid.")
508
+ emit({"ok": True, "result": result})
509
+ return 0
510
+ except ArchiveError as error:
511
+ fail(error.code, str(error))
512
+ return 1
513
+ except Exception:
514
+ fail("PARSER_FAILED", "Recording archive parser failed.")
515
+ return 1
516
+
517
+
518
+ if __name__ == "__main__":
519
+ raise SystemExit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmagical/cli",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Magical CLI for shared MCP operations",
@@ -23,13 +23,16 @@
23
23
  "magical": "./bin/mgcl.mjs"
24
24
  },
25
25
  "dependencies": {
26
+ "@inquirer/prompts": "^7.10.1",
26
27
  "commander": "^14.0.3",
27
28
  "keytar": "^7.9.0",
29
+ "mime-types": "^2.1.35",
28
30
  "zod": "^4.1.12"
29
31
  },
30
32
  "devDependencies": {
31
33
  "@magical/common": "0.0.0",
32
34
  "@magical/mcp-registry": "0.0.0",
35
+ "@types/mime-types": "^2.1.4",
33
36
  "esbuild": "^0.28.0",
34
37
  "vitest": "^4.1.7"
35
38
  },