pi-codemcp 1.0.0 → 1.1.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.
@@ -3,10 +3,14 @@ from __future__ import annotations
3
3
  import ast
4
4
  import asyncio
5
5
  import hashlib
6
+ import heapq
6
7
  import textwrap
8
+ import time
9
+ from collections import Counter
7
10
  from collections.abc import Awaitable, Callable
8
11
  from contextvars import ContextVar
9
- from typing import TYPE_CHECKING, Literal, Self
12
+ from itertools import islice
13
+ from typing import TYPE_CHECKING, Literal, Self, assert_never, override
10
14
 
11
15
  import pydantic_monty
12
16
  from pydantic import (
@@ -20,6 +24,7 @@ from pydantic import (
20
24
  from pydantic_core import to_json
21
25
 
22
26
  from .json_types import JSON_VALUE_ADAPTER, JsonObject, JsonValue
27
+ from .tool_catalog import referenced_calls, schema_path_summary
23
28
 
24
29
  if TYPE_CHECKING:
25
30
  from .chains import SavedChainManifest
@@ -27,7 +32,23 @@ if TYPE_CHECKING:
27
32
 
28
33
  RESULT_BYTE_LIMIT = 16 * 1024
29
34
  SHAPE_FIELD_LIMIT = 20
35
+ INSPECT_SAMPLE_LIMIT = 3
36
+ INSPECT_DEPTH_LIMIT = 6
37
+ INSPECT_COLLECTION_LIMIT = 10
38
+ INSPECT_STRING_LIMIT = 200
39
+ INSPECT_KEY_LIMIT = 120
40
+ INSPECT_BYTE_LIMIT = 8 * 1024
30
41
  CHAIN_INPUT_EXTERNAL = "__codemcp_saved_chain_input"
42
+ INSPECT_JSON_EXTERNAL = "__codemcp_inspect_json"
43
+
44
+
45
+ class ExecutionMetrics(BaseModel):
46
+ model_config = ConfigDict(extra="forbid", strict=True)
47
+
48
+ typecheck_ms: float = Field(default=0.0, ge=0)
49
+ runtime_ms: float = Field(default=0.0, ge=0)
50
+ serialization_ms: float = Field(default=0.0, ge=0)
51
+ result_bytes: int = Field(default=0, ge=0)
31
52
 
32
53
 
33
54
  class ExecutionContext:
@@ -44,6 +65,7 @@ class ExecutionContext:
44
65
  self.deadline = deadline
45
66
  self.calls_made = 0
46
67
  self.chain_calls = 0
68
+ self.metrics = ExecutionMetrics()
47
69
  self.chain_stack: ContextVar[tuple[str, ...]] = ContextVar(
48
70
  "codemcp_chain_stack",
49
71
  default=(),
@@ -75,6 +97,7 @@ class ExecutionResponse(BaseModel):
75
97
  shape: JsonObject | None = None
76
98
  calls_made: int = Field(default=0, ge=0)
77
99
  chain_calls: int = Field(default=0, ge=0)
100
+ metrics: ExecutionMetrics = Field(default_factory=ExecutionMetrics, exclude=True)
78
101
 
79
102
  @classmethod
80
103
  def success(
@@ -140,6 +163,12 @@ class ExecutionResponse(BaseModel):
140
163
  response["shape"] = self.shape
141
164
  if self.chain_calls > 0:
142
165
  response["chain_calls"] = self.chain_calls
166
+ timings: JsonObject = {
167
+ "typecheck_ms": round(self.metrics.typecheck_ms, 3),
168
+ "execution_ms": round(self.metrics.runtime_ms, 3),
169
+ "serialization_ms": round(self.metrics.serialization_ms, 3),
170
+ }
171
+ response["timings"] = timings
143
172
  return response
144
173
 
145
174
 
@@ -265,7 +294,8 @@ class MontyExecutor:
265
294
  output_type=spec.output_type_name,
266
295
  typed=True,
267
296
  )
268
- type_stubs = _chain_type_stubs(catalog, spec.input_type_name)
297
+ referenced = referenced_calls(code, catalog.facade_calls)
298
+ type_stubs = catalog.type_stubs_for(referenced, include=spec.name)
269
299
  async with self._execution_lock:
270
300
  await self._type_check(wrapped, catalog, type_stubs)
271
301
 
@@ -282,7 +312,7 @@ class MontyExecutor:
282
312
  deadline=loop.time() + self.settings.timeout_seconds,
283
313
  )
284
314
 
285
- async def _execute_program( # noqa: C901, PLR0915
315
+ async def _execute_program( # ruff:ignore[complex-structure, too-many-statements]
286
316
  self,
287
317
  code: str,
288
318
  context: ExecutionContext,
@@ -303,27 +333,32 @@ class MontyExecutor:
303
333
  output_type=output_type,
304
334
  typed=True,
305
335
  )
306
- type_stubs = (
307
- _chain_type_stubs(context.catalog, input_type)
308
- if input_type is not None
309
- else context.catalog.type_stubs
336
+ referenced = referenced_calls(code, context.catalog.facade_calls)
337
+ type_stubs = context.catalog.type_stubs_for(
338
+ referenced,
339
+ include=output_spec_name,
310
340
  )
341
+ typecheck_started = time.perf_counter()
311
342
  try:
312
343
  await self._type_check(typed_code, context.catalog, type_stubs)
313
344
  except pydantic_monty.MontyTypingError as error:
345
+ context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
314
346
  return self._failure(
315
347
  context,
316
348
  "preflight",
317
349
  error.display("concise", color=False).strip(),
318
350
  )
319
351
  except pydantic_monty.MontySyntaxError as error:
352
+ context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
320
353
  return self._failure(context, "preflight", error.display("type-msg").strip())
321
354
  except RuntimeError as error:
355
+ context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
322
356
  return self._failure(
323
357
  context,
324
358
  "preflight",
325
359
  f"Type-check setup failed: {error}",
326
360
  )
361
+ context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
327
362
 
328
363
  runtime_wrapped = _wrap_code(code, typed=False, has_input=has_input)
329
364
  runtime_code = _rewrite_sdk_calls(runtime_wrapped, context.catalog)
@@ -364,6 +399,30 @@ class MontyExecutor:
364
399
  return await context.call_tool(name, validated, context)
365
400
 
366
401
  external_functions: dict[str, ExternalFunction] = {}
402
+
403
+ async def inspect_json_external(
404
+ value: JsonValue,
405
+ *,
406
+ samples: JsonValue = 2,
407
+ max_depth: JsonValue = 3,
408
+ ) -> JsonValue:
409
+ await asyncio.sleep(0)
410
+ if not isinstance(samples, int) or isinstance(samples, bool):
411
+ raise TypeError("inspect_json samples must be an integer")
412
+ if not isinstance(max_depth, int) or isinstance(max_depth, bool):
413
+ raise TypeError("inspect_json max_depth must be an integer")
414
+ if not 1 <= samples <= INSPECT_SAMPLE_LIMIT:
415
+ raise ValueError(f"inspect_json samples must be from 1 to {INSPECT_SAMPLE_LIMIT}")
416
+ if not 1 <= max_depth <= INSPECT_DEPTH_LIMIT:
417
+ raise ValueError(f"inspect_json max_depth must be from 1 to {INSPECT_DEPTH_LIMIT}")
418
+ return _inspect_json(
419
+ value,
420
+ samples=samples,
421
+ max_depth=max_depth,
422
+ byte_limit=_inspection_byte_limit(context.settings.result_byte_limit),
423
+ )
424
+
425
+ external_functions[INSPECT_JSON_EXTERNAL] = inspect_json_external
367
426
  for spec in context.catalog.tools.values():
368
427
 
369
428
  async def sdk_method(
@@ -377,7 +436,7 @@ class MontyExecutor:
377
436
 
378
437
  if input_value is not None:
379
438
 
380
- async def chain_input() -> JsonValue: # noqa: RUF029
439
+ async def chain_input() -> JsonValue: # ruff:ignore[unused-async]
381
440
  return input_value
382
441
 
383
442
  external_functions[CHAIN_INPUT_EXTERNAL] = chain_input
@@ -393,6 +452,7 @@ class MontyExecutor:
393
452
  "max_duration_secs": remaining,
394
453
  "max_memory": context.settings.max_memory_bytes,
395
454
  }
455
+ runtime_started = time.perf_counter()
396
456
  try:
397
457
  async with asyncio.timeout(remaining + 0.1):
398
458
  result = JSON_VALUE_ADAPTER.validate_python(
@@ -404,18 +464,21 @@ class MontyExecutor:
404
464
  except asyncio.CancelledError:
405
465
  raise
406
466
  except TimeoutError:
467
+ context.metrics.runtime_ms += _elapsed_ms(runtime_started)
407
468
  return self._failure(
408
469
  context,
409
470
  "timeout",
410
471
  f"Execution timed out after {context.settings.timeout_seconds:g}s",
411
472
  )
412
473
  except ValidationError as error:
474
+ context.metrics.runtime_ms += _elapsed_ms(runtime_started)
413
475
  return self._failure(
414
476
  context,
415
477
  "result",
416
478
  f"Returned value is not JSON-compatible: {error.errors()[0]['msg']}",
417
479
  )
418
480
  except pydantic_monty.MontyRuntimeError as error:
481
+ context.metrics.runtime_ms += _elapsed_ms(runtime_started)
419
482
  message = error.display("type-msg").strip()
420
483
  lowered = message.lower()
421
484
  stage: Literal["runtime", "timeout"] = (
@@ -424,35 +487,48 @@ class MontyExecutor:
424
487
  else "runtime"
425
488
  )
426
489
  return self._failure(context, stage, message)
490
+ context.metrics.runtime_ms += _elapsed_ms(runtime_started)
427
491
 
428
492
  if output_spec_name is not None:
429
493
  try:
430
494
  result = context.catalog.validate_saved_chain_result(output_spec_name, result)
431
495
  except (TypeError, ValidationError, ValueError) as error:
496
+ spec = context.catalog.tools[output_spec_name]
432
497
  return self._failure(
433
498
  context,
434
499
  "result",
435
- f"Saved chain result violates its output schema: {error}",
500
+ _saved_chain_result_error(error, spec.output_schema or {}),
436
501
  )
437
502
 
438
- if enforce_result_limit:
439
- result_bytes = len(to_json(result))
440
- if result_bytes >= context.settings.result_byte_limit:
441
- return ExecutionResponse.failure(
442
- failure_stage="result",
443
- error=(
444
- f"Returned value is {result_bytes} bytes; reduce it below "
445
- f"{context.settings.result_byte_limit} bytes"
446
- ),
447
- shape=_summarize_shape(result),
448
- calls_made=context.calls_made,
449
- chain_calls=context.chain_calls,
450
- )
451
- return ExecutionResponse.success(
503
+ serialization_started = time.perf_counter()
504
+ result_bytes = len(to_json(result))
505
+ context.metrics.serialization_ms += _elapsed_ms(serialization_started)
506
+ context.metrics.result_bytes = result_bytes
507
+ if enforce_result_limit and result_bytes >= context.settings.result_byte_limit:
508
+ response = ExecutionResponse.failure(
509
+ failure_stage="result",
510
+ error=(
511
+ f"Returned value is {result_bytes} bytes; reduce it below "
512
+ f"{context.settings.result_byte_limit} bytes"
513
+ ),
514
+ shape=_inspect_json(
515
+ result,
516
+ samples=2,
517
+ max_depth=3,
518
+ byte_limit=_inspection_byte_limit(context.settings.result_byte_limit),
519
+ ),
520
+ calls_made=context.calls_made,
521
+ chain_calls=context.chain_calls,
522
+ )
523
+ response.metrics = context.metrics.model_copy()
524
+ return response
525
+ response = ExecutionResponse.success(
452
526
  result=result,
453
527
  calls_made=context.calls_made,
454
528
  chain_calls=context.chain_calls,
455
529
  )
530
+ response.metrics = context.metrics.model_copy()
531
+ return response
456
532
 
457
533
  async def _type_check(
458
534
  self,
@@ -486,28 +562,257 @@ class MontyExecutor:
486
562
  stage: Literal["preflight", "runtime", "timeout", "cancelled", "result"],
487
563
  error: str,
488
564
  ) -> ExecutionResponse:
489
- return ExecutionResponse.failure(
565
+ response = ExecutionResponse.failure(
490
566
  failure_stage=stage,
491
567
  error=error,
492
568
  calls_made=context.calls_made,
493
569
  chain_calls=context.chain_calls,
494
570
  )
571
+ response.metrics = context.metrics.model_copy()
572
+ return response
495
573
 
496
574
 
497
- def _chain_type_stubs(catalog: ToolCatalog, _input_type: str) -> str:
498
- return catalog.type_stubs
575
+ def _saved_chain_result_error(
576
+ error: TypeError | ValidationError | ValueError,
577
+ output_schema: JsonObject,
578
+ ) -> str:
579
+ expected = "\n".join(f" - {path}" for path in schema_path_summary(output_schema))
580
+ if isinstance(error, ValidationError):
581
+ actual_lines = []
582
+ for item in error.errors()[:SHAPE_FIELD_LIMIT]:
583
+ location = "$" + "".join(
584
+ f"[{part}]" if isinstance(part, int) else f".{part}" for part in item["loc"]
585
+ )
586
+ actual_lines.append(
587
+ f" - {location}: {item['msg']} (actual {type(item.get('input')).__name__})"
588
+ )
589
+ actual = "\n".join(actual_lines)
590
+ else:
591
+ actual = f" - $: {error}"
592
+ return (
593
+ "Saved chain result violates outputSchema.\n"
594
+ f"Expected output paths:\n{expected}\n"
595
+ f"Actual result paths:\n{actual}"
596
+ )
597
+
598
+
599
+ def _elapsed_ms(started: float) -> float:
600
+ return (time.perf_counter() - started) * 1_000
499
601
 
500
602
 
501
- def _summarize_shape(value: JsonValue) -> JsonObject:
502
- if not isinstance(value, dict):
503
- return {"result": _shape_label(value)}
603
+ def _inspection_byte_limit(result_byte_limit: int) -> int:
604
+ return max(256, min(INSPECT_BYTE_LIMIT, result_byte_limit * 3 // 4))
605
+
606
+
607
+ def _inspect_json(
608
+ value: JsonValue,
609
+ *,
610
+ samples: int,
611
+ max_depth: int,
612
+ byte_limit: int,
613
+ ) -> JsonObject:
504
614
  summary: JsonObject = {
505
- str(key): _shape_label(item) for key, item in list(value.items())[:SHAPE_FIELD_LIMIT]
615
+ "type": _shape_label(value),
616
+ "serialized_bytes": len(to_json(value)),
617
+ "shape": _describe_shape(value, depth=0, max_depth=max_depth),
618
+ "truncated": _inspection_is_truncated(value, depth=0, max_depth=max_depth),
506
619
  }
507
- remaining = len(value) - len(summary)
508
- if remaining > 0:
509
- summary["<remaining>"] = f"{remaining} more fields"
510
- return summary
620
+ cardinality = _cardinality(value)
621
+ if cardinality is not None:
622
+ summary["cardinality"] = cardinality
623
+ if isinstance(value, dict):
624
+ ranked_fields = heapq.nsmallest(
625
+ SHAPE_FIELD_LIMIT,
626
+ ((str(key), len(to_json(item))) for key, item in value.items()),
627
+ key=lambda item: (-item[1], item[0]),
628
+ )
629
+ summary["field_sizes"] = [
630
+ {"path": f"$.{_bounded_key(key, index)}", "bytes": size}
631
+ for index, (key, size) in enumerate(ranked_fields)
632
+ ]
633
+ scalar_types: dict[str, set[str]] = {}
634
+ _collect_scalar_types(
635
+ value,
636
+ path="$",
637
+ depth=0,
638
+ max_depth=max_depth,
639
+ result=scalar_types,
640
+ )
641
+ summary["scalar_types"] = JSON_VALUE_ADAPTER.validate_python({
642
+ path: sorted(types) for path, types in sorted(scalar_types.items())
643
+ })
644
+ if isinstance(value, list):
645
+ sampled_items = list(islice(value, INSPECT_COLLECTION_LIMIT))
646
+ object_items = [item for item in sampled_items if isinstance(item, dict)]
647
+ if object_items:
648
+ key_counts = Counter(
649
+ str(key) for item in object_items for key in islice(item, SHAPE_FIELD_LIMIT)
650
+ )
651
+ summary["common_keys"] = JSON_VALUE_ADAPTER.validate_python(
652
+ [
653
+ _bounded_key(key, index)
654
+ for index, (key, count) in enumerate(
655
+ sorted(key_counts.items(), key=lambda item: (-item[1], item[0]))
656
+ )
657
+ if count == len(object_items)
658
+ ][:SHAPE_FIELD_LIMIT]
659
+ )
660
+ summary["item_types"] = JSON_VALUE_ADAPTER.validate_python(
661
+ sorted({_shape_label(item) for item in sampled_items})
662
+ )
663
+ if samples > 0:
664
+ raw_samples = value[:samples] if isinstance(value, list) else [value]
665
+ summary["samples"] = [
666
+ _bounded_sample(item, depth=0, max_depth=max_depth) for item in raw_samples
667
+ ]
668
+ return _fit_inspection_budget(summary, byte_limit)
669
+
670
+
671
+ def _fit_inspection_budget(summary: JsonObject, byte_limit: int) -> JsonObject:
672
+ candidates = [
673
+ summary,
674
+ {**summary, "samples": [], "diagnostic_truncated": True},
675
+ {
676
+ key: value
677
+ for key, value in summary.items()
678
+ if key not in {"samples", "field_sizes", "scalar_types", "common_keys"}
679
+ }
680
+ | {"diagnostic_truncated": True},
681
+ {
682
+ "type": summary["type"],
683
+ "serialized_bytes": summary["serialized_bytes"],
684
+ "shape": summary["type"],
685
+ "truncated": True,
686
+ "diagnostic_truncated": True,
687
+ },
688
+ ]
689
+ for candidate in candidates:
690
+ if len(to_json(candidate)) <= byte_limit:
691
+ return candidate
692
+ raise ValueError(f"inspection byte limit must be at least {len(to_json(candidates[-1]))}")
693
+
694
+
695
+ def _bounded_key(value: str, index: int) -> str:
696
+ if len(value) <= INSPECT_KEY_LIMIT:
697
+ return value
698
+ suffix = f"…[{index}]"
699
+ return f"{value[: INSPECT_KEY_LIMIT - len(suffix)]}{suffix}"
700
+
701
+
702
+ def _collect_scalar_types(
703
+ value: JsonValue,
704
+ *,
705
+ path: str,
706
+ depth: int,
707
+ max_depth: int,
708
+ result: dict[str, set[str]],
709
+ ) -> None:
710
+ if len(result) >= SHAPE_FIELD_LIMIT:
711
+ return
712
+ if depth >= max_depth and isinstance(value, (dict, list)):
713
+ return
714
+ if isinstance(value, dict):
715
+ for index, (key, item) in enumerate(islice(value.items(), INSPECT_COLLECTION_LIMIT)):
716
+ _collect_scalar_types(
717
+ item,
718
+ path=f"{path}.{_bounded_key(str(key), index)}",
719
+ depth=depth + 1,
720
+ max_depth=max_depth,
721
+ result=result,
722
+ )
723
+ return
724
+ if isinstance(value, list):
725
+ for item in islice(value, INSPECT_COLLECTION_LIMIT):
726
+ _collect_scalar_types(
727
+ item,
728
+ path=f"{path}[]",
729
+ depth=depth + 1,
730
+ max_depth=max_depth,
731
+ result=result,
732
+ )
733
+ return
734
+ result.setdefault(path, set()).add(_shape_label(value))
735
+
736
+
737
+ def _describe_shape(value: JsonValue, *, depth: int, max_depth: int) -> JsonValue:
738
+ if depth >= max_depth:
739
+ return _shape_label(value)
740
+ if isinstance(value, dict):
741
+ fields: JsonObject = {}
742
+ for index, (key, item) in enumerate(islice(value.items(), SHAPE_FIELD_LIMIT)):
743
+ fields[_bounded_key(str(key), index)] = _describe_shape(
744
+ item,
745
+ depth=depth + 1,
746
+ max_depth=max_depth,
747
+ )
748
+ remaining = len(value) - min(len(value), SHAPE_FIELD_LIMIT)
749
+ if remaining > 0:
750
+ fields["<remaining>"] = f"{remaining} more fields"
751
+ return fields
752
+ if isinstance(value, list):
753
+ shapes: list[JsonValue] = []
754
+ seen: set[str] = set()
755
+ for item in islice(value, INSPECT_COLLECTION_LIMIT):
756
+ shape = _describe_shape(item, depth=depth + 1, max_depth=max_depth)
757
+ fingerprint = to_json(shape).decode()
758
+ if fingerprint in seen:
759
+ continue
760
+ seen.add(fingerprint)
761
+ shapes.append(shape)
762
+ return {"items": shapes, "count": len(value)}
763
+ return _shape_label(value)
764
+
765
+
766
+ def _bounded_sample(value: JsonValue, *, depth: int, max_depth: int) -> JsonValue:
767
+ if depth >= max_depth:
768
+ return _shape_label(value)
769
+ if isinstance(value, str):
770
+ return value if len(value) <= INSPECT_STRING_LIMIT else f"{value[:INSPECT_STRING_LIMIT]}…"
771
+ if isinstance(value, dict):
772
+ object_sample: JsonObject = {}
773
+ for index, (key, item) in enumerate(islice(value.items(), INSPECT_COLLECTION_LIMIT)):
774
+ object_sample[_bounded_key(str(key), index)] = _bounded_sample(
775
+ item,
776
+ depth=depth + 1,
777
+ max_depth=max_depth,
778
+ )
779
+ if len(value) > INSPECT_COLLECTION_LIMIT:
780
+ object_sample["<remaining>"] = len(value) - INSPECT_COLLECTION_LIMIT
781
+ return object_sample
782
+ if isinstance(value, list):
783
+ list_sample: list[JsonValue] = [
784
+ _bounded_sample(item, depth=depth + 1, max_depth=max_depth)
785
+ for item in islice(value, INSPECT_COLLECTION_LIMIT)
786
+ ]
787
+ if len(value) > INSPECT_COLLECTION_LIMIT:
788
+ list_sample.append(f"<{len(value) - INSPECT_COLLECTION_LIMIT} more items>")
789
+ return list_sample
790
+ return value
791
+
792
+
793
+ def _inspection_is_truncated(value: JsonValue, *, depth: int, max_depth: int) -> bool:
794
+ if depth >= max_depth and isinstance(value, (dict, list)):
795
+ return True
796
+ if isinstance(value, dict):
797
+ return len(value) > SHAPE_FIELD_LIMIT or any(
798
+ len(str(key)) > INSPECT_KEY_LIMIT
799
+ or _inspection_is_truncated(item, depth=depth + 1, max_depth=max_depth)
800
+ for key, item in islice(value.items(), SHAPE_FIELD_LIMIT)
801
+ )
802
+ if isinstance(value, list):
803
+ return len(value) > INSPECT_COLLECTION_LIMIT or any(
804
+ _inspection_is_truncated(item, depth=depth + 1, max_depth=max_depth)
805
+ for item in islice(value, INSPECT_COLLECTION_LIMIT)
806
+ )
807
+ if isinstance(value, str):
808
+ return len(value) > INSPECT_STRING_LIMIT
809
+ return False
810
+
811
+
812
+ def _cardinality(value: JsonValue) -> int | None:
813
+ if isinstance(value, (dict, list, str)):
814
+ return len(value)
815
+ return None
511
816
 
512
817
 
513
818
  def _shape_label(value: JsonValue) -> str:
@@ -517,7 +822,7 @@ def _shape_label(value: JsonValue) -> str:
517
822
  return "boolean"
518
823
  if isinstance(value, dict):
519
824
  return "object"
520
- if isinstance(value, (list, tuple)):
825
+ if isinstance(value, list):
521
826
  return f"array[{len(value)}]"
522
827
  if isinstance(value, str):
523
828
  return "string"
@@ -525,7 +830,7 @@ def _shape_label(value: JsonValue) -> str:
525
830
  return "integer"
526
831
  if isinstance(value, float):
527
832
  return "number"
528
- return type(value).__name__
833
+ assert_never(value)
529
834
 
530
835
 
531
836
  def _wrap_code(
@@ -567,9 +872,17 @@ def _rewrite_sdk_calls(code: str, catalog: ToolCatalog) -> str:
567
872
  tree = ast.parse(code, filename="codemcp_execute.py", mode="exec")
568
873
 
569
874
  class FacadeCallRewriter(ast.NodeTransformer):
875
+ @override
570
876
  def visit_Call(self, node: ast.Call) -> ast.AST:
571
877
  self.generic_visit(node)
572
878
  function = node.func
879
+ if isinstance(function, ast.Name) and function.id == "inspect_json":
880
+ external_call = ast.Call(
881
+ func=ast.Name(id=INSPECT_JSON_EXTERNAL, ctx=ast.Load()),
882
+ args=node.args,
883
+ keywords=node.keywords,
884
+ )
885
+ return ast.copy_location(ast.Await(value=external_call), node)
573
886
  if not isinstance(function, ast.Attribute):
574
887
  return node
575
888
  if not isinstance(function.value, ast.Name):