pi-codemcp 1.2.1 → 1.3.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.
@@ -2,15 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import ast
4
4
  import asyncio
5
+ import os
6
+ import re
5
7
  import textwrap
6
8
  import time
7
- from contextlib import AsyncExitStack, asynccontextmanager
9
+ import uuid
10
+ from contextlib import AsyncExitStack, asynccontextmanager, suppress
8
11
  from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol, cast
9
12
 
10
13
  import pydantic_monty
11
14
  from fastmcp import Client, FastMCP
12
15
  from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
13
- from pydantic import BaseModel, ConfigDict
14
16
  from pydantic_core import to_json
15
17
  from rapidfuzz import fuzz, process
16
18
 
@@ -18,7 +20,6 @@ from . import json_types
18
20
  from .catalog_cache import CatalogCache
19
21
  from .chains import (
20
22
  ChainDependency,
21
- ChainEnabledChange,
22
23
  ChainListResponse,
23
24
  ChainScope,
24
25
  ChainStatusView,
@@ -27,7 +28,12 @@ from .chains import (
27
28
  SavedChainManifest,
28
29
  ScopedChainStore,
29
30
  )
30
- from .executor import ExecutionContext, ExecutionResponse, MontyExecutor
31
+ from .executor import (
32
+ ExecutionContext,
33
+ ExecutionFailureInfo,
34
+ ExecutionResponse,
35
+ MontyExecutor,
36
+ )
31
37
  from .mcp_config import NormalizedConfig, load_mcp_json, normalize_mcp_config
32
38
  from .models import (
33
39
  ExecutionLimitsView,
@@ -42,9 +48,10 @@ from .models import (
42
48
  UpstreamStatus,
43
49
  UpstreamToolStatus,
44
50
  )
51
+ from .refinement_cache import RefinementCache, ResultReferenceError
45
52
  from .runtime_paths import resolve_runtime_paths
46
53
  from .settings import CodeMcpSettings, load_settings
47
- from .stats import StatsStore
54
+ from .stats import OperationFailure, OperationObservation, StatsStore
48
55
  from .tool_catalog import ToolCatalog, schema_path_summary
49
56
 
50
57
  if TYPE_CHECKING:
@@ -57,18 +64,12 @@ if TYPE_CHECKING:
57
64
  from .runtime_paths import RuntimePaths
58
65
 
59
66
  MAX_INSPECT_CALLS = 20
67
+ UPSTREAM_FAILURE_MESSAGE_LIMIT = 500
60
68
  type ServerConfig = StdioMCPServer | RemoteMCPServer
61
69
  type JsonObject = json_types.JsonObject
62
70
  type JsonValue = json_types.JsonValue
63
71
 
64
72
 
65
- class ManagerApplyResponse(BaseModel):
66
- model_config = ConfigDict(extra="forbid", strict=True)
67
-
68
- status: StatusResponse
69
- chains: list[ChainStatusView]
70
-
71
-
72
73
  class ServerHandle:
73
74
  def __init__(
74
75
  self,
@@ -136,7 +137,19 @@ class ServerHandle:
136
137
  ) -> mcp_types.CallToolResult:
137
138
  async with self._lock:
138
139
  client = await self._connect_locked()
139
- return await client.call_tool_mcp(name, arguments, timeout=timeout_seconds)
140
+ try:
141
+ return await client.call_tool_mcp(name, arguments, timeout=timeout_seconds)
142
+ except Exception as error:
143
+ if is_unusable_connection_error(error):
144
+ await self._discard_client(client)
145
+ raise
146
+
147
+ async def _discard_client(self, client: Client[ClientTransport]) -> None:
148
+ async with self._lock:
149
+ if self._client is not client:
150
+ return
151
+ with suppress(Exception):
152
+ await self._disconnect_locked()
140
153
 
141
154
  async def close(self) -> None:
142
155
  async with self._lock:
@@ -180,23 +193,30 @@ class SaveChainHandler(Protocol):
180
193
  code: str,
181
194
  input_schema: JsonObject,
182
195
  output_schema: JsonObject,
196
+ trace_id: str,
183
197
  ) -> SaveChainResponse: ...
184
198
 
185
199
 
186
200
  class SavedChainHandlers(NamedTuple):
187
- execute: Callable[[str, JsonObject], Awaitable[ExecutionResponse]]
201
+ execute: Callable[[str, JsonObject, str], Awaitable[ExecutionResponse]]
188
202
  save: SaveChainHandler
189
- list: Callable[[], ChainListResponse]
190
- revalidate: Callable[[str, ChainScope], Awaitable[ChainStatusView]]
191
- delete: Callable[[str, ChainScope], Awaitable[ChainListResponse]]
203
+ list: Callable[[str], ChainListResponse]
204
+ set_enabled: Callable[[str, ChainScope, bool, str], Awaitable[ChainStatusView]]
205
+ revalidate: Callable[[str, ChainScope, str], Awaitable[ChainStatusView]]
206
+ delete: Callable[[str, ChainScope, str], Awaitable[ChainListResponse]]
192
207
 
193
208
 
194
209
  class SavedChainRuntime:
195
210
  def __init__(self, handlers: SavedChainHandlers) -> None:
196
211
  self.handlers = handlers
197
212
 
198
- async def execute(self, name: str, arguments: JsonObject) -> ExecutionResponse:
199
- return await self.handlers.execute(name, arguments)
213
+ async def execute(
214
+ self,
215
+ name: str,
216
+ arguments: JsonObject,
217
+ trace_id: str,
218
+ ) -> ExecutionResponse:
219
+ return await self.handlers.execute(name, arguments, trace_id)
200
220
 
201
221
  async def save(
202
222
  self,
@@ -207,6 +227,7 @@ class SavedChainRuntime:
207
227
  code: str,
208
228
  input_schema: JsonObject,
209
229
  output_schema: JsonObject,
230
+ trace_id: str,
210
231
  ) -> SaveChainResponse:
211
232
  return await self.handlers.save(
212
233
  scope=scope,
@@ -215,16 +236,36 @@ class SavedChainRuntime:
215
236
  code=code,
216
237
  input_schema=input_schema,
217
238
  output_schema=output_schema,
239
+ trace_id=trace_id,
218
240
  )
219
241
 
220
- def list(self) -> ChainListResponse:
221
- return self.handlers.list()
242
+ def list(self, trace_id: str) -> ChainListResponse:
243
+ return self.handlers.list(trace_id)
244
+
245
+ async def set_enabled(
246
+ self,
247
+ name: str,
248
+ scope: ChainScope,
249
+ enabled: bool,
250
+ trace_id: str,
251
+ ) -> ChainStatusView:
252
+ return await self.handlers.set_enabled(name, scope, enabled, trace_id)
222
253
 
223
- async def revalidate(self, name: str, scope: ChainScope) -> ChainStatusView:
224
- return await self.handlers.revalidate(name, scope)
254
+ async def revalidate(
255
+ self,
256
+ name: str,
257
+ scope: ChainScope,
258
+ trace_id: str,
259
+ ) -> ChainStatusView:
260
+ return await self.handlers.revalidate(name, scope, trace_id)
225
261
 
226
- async def delete(self, name: str, scope: ChainScope) -> ChainListResponse:
227
- return await self.handlers.delete(name, scope)
262
+ async def delete(
263
+ self,
264
+ name: str,
265
+ scope: ChainScope,
266
+ trace_id: str,
267
+ ) -> ChainListResponse:
268
+ return await self.handlers.delete(name, scope, trace_id)
228
269
 
229
270
 
230
271
  class GatewayRuntime:
@@ -248,7 +289,11 @@ class GatewayRuntime:
248
289
  self.chain_store = chain_store
249
290
  self.catalog = catalog
250
291
  self.executor = executor
251
- self.stats_store = StatsStore(settings_path.parent / "stats.json")
292
+ self.refinement_cache = RefinementCache()
293
+ self.stats_store = StatsStore(
294
+ settings_path.parent / "stats.sqlite3",
295
+ package_version=os.environ.get("PI_CODEMCP_PACKAGE_VERSION", "unknown"),
296
+ )
252
297
  for handle in handles.values():
253
298
  self.stats_store.record_cache(hit=handle.tools is not None)
254
299
  self.chains = SavedChainRuntime(
@@ -256,6 +301,7 @@ class GatewayRuntime:
256
301
  execute=self._execute_chain,
257
302
  save=self._save_chain,
258
303
  list=self._list_chains,
304
+ set_enabled=self._set_chain_enabled,
259
305
  revalidate=self._revalidate_chain,
260
306
  delete=self._delete_chain,
261
307
  )
@@ -314,6 +360,7 @@ class GatewayRuntime:
314
360
  )
315
361
 
316
362
  async def close(self) -> None:
363
+ self.refinement_cache.clear()
317
364
  await asyncio.gather(
318
365
  *(handle.close() for handle in self.handles.values()),
319
366
  return_exceptions=True,
@@ -328,6 +375,8 @@ class GatewayRuntime:
328
375
  detail: SearchDetail = "signatures",
329
376
  mode: SearchMode = "search",
330
377
  cursor: int = 0,
378
+ *,
379
+ trace_id: str,
331
380
  ) -> SearchResponse:
332
381
  started = time.perf_counter()
333
382
  input_bytes = len(
@@ -345,18 +394,26 @@ class GatewayRuntime:
345
394
  except BaseException:
346
395
  self.stats_store.record_operation(
347
396
  "search",
348
- duration_ms=_elapsed_ms(started),
349
- success=False,
350
- failure_stage="error",
351
- input_bytes=input_bytes,
397
+ OperationObservation(
398
+ duration_ms=_elapsed_ms(started),
399
+ success=False,
400
+ input_bytes=input_bytes,
401
+ failure=OperationFailure(
402
+ stage="error",
403
+ subtype="search_error",
404
+ trace_id=trace_id,
405
+ ),
406
+ ),
352
407
  )
353
408
  raise
354
409
  self.stats_store.record_operation(
355
410
  "search",
356
- duration_ms=_elapsed_ms(started),
357
- success=True,
358
- input_bytes=input_bytes,
359
- output_bytes=len(to_json(response.model_dump(mode="json"))),
411
+ OperationObservation(
412
+ duration_ms=_elapsed_ms(started),
413
+ success=True,
414
+ input_bytes=input_bytes,
415
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
416
+ ),
360
417
  )
361
418
  return response
362
419
 
@@ -449,25 +506,37 @@ class GatewayRuntime:
449
506
  results=results,
450
507
  )
451
508
 
452
- async def inspect(self, calls: list[str]) -> InspectResponse:
509
+ async def inspect(
510
+ self,
511
+ calls: list[str],
512
+ trace_id: str,
513
+ ) -> InspectResponse:
453
514
  started = time.perf_counter()
454
515
  try:
455
516
  response = await self._inspect_impl(calls)
456
517
  except BaseException:
457
518
  self.stats_store.record_operation(
458
519
  "inspect",
459
- duration_ms=_elapsed_ms(started),
460
- success=False,
461
- failure_stage="error",
462
- input_bytes=len(to_json(calls)),
520
+ OperationObservation(
521
+ duration_ms=_elapsed_ms(started),
522
+ success=False,
523
+ input_bytes=len(to_json(calls)),
524
+ failure=OperationFailure(
525
+ stage="error",
526
+ subtype="inspect_error",
527
+ trace_id=trace_id,
528
+ ),
529
+ ),
463
530
  )
464
531
  raise
465
532
  self.stats_store.record_operation(
466
533
  "inspect",
467
- duration_ms=_elapsed_ms(started),
468
- success=True,
469
- input_bytes=len(to_json(calls)),
470
- output_bytes=len(to_json(response.model_dump(mode="json"))),
534
+ OperationObservation(
535
+ duration_ms=_elapsed_ms(started),
536
+ success=True,
537
+ input_bytes=len(to_json(calls)),
538
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
539
+ ),
471
540
  )
472
541
  return response
473
542
 
@@ -520,9 +589,37 @@ class GatewayRuntime:
520
589
  result_limit_bytes=settings.result_byte_limit,
521
590
  )
522
591
 
523
- async def execute(self, code: str) -> ExecutionResponse:
592
+ async def execute(
593
+ self,
594
+ code: str,
595
+ trace_id: str,
596
+ input_ref: str | None = None,
597
+ ) -> ExecutionResponse:
524
598
  started = time.perf_counter()
525
- input_bytes = len(code.encode())
599
+ input_bytes = len(code.encode()) + len((input_ref or "").encode())
600
+ input_value: JsonValue = None
601
+ if input_ref is not None:
602
+ try:
603
+ input_value = self.refinement_cache.resolve(input_ref)
604
+ except ResultReferenceError as error:
605
+ message = str(error)
606
+ response = ExecutionResponse.failed(
607
+ failure_stage="preflight",
608
+ error=message,
609
+ failure=ExecutionFailureInfo(
610
+ kind="result_reference",
611
+ retryable=False,
612
+ message=message,
613
+ ),
614
+ )
615
+ self._record_execution(
616
+ "execute",
617
+ started,
618
+ input_bytes,
619
+ response,
620
+ trace_id,
621
+ )
622
+ return response
526
623
  discovery_started = time.perf_counter()
527
624
  try:
528
625
  await self._ensure_servers_discovered(self._required_servers_for_code(code))
@@ -533,26 +630,39 @@ class GatewayRuntime:
533
630
  started,
534
631
  input_bytes,
535
632
  failure_stage="discovery",
633
+ failure_subtype="discovery",
634
+ trace_id=trace_id,
536
635
  )
537
636
  raise
538
637
  self.stats_store.record_phase("discovery", _elapsed_ms(discovery_started))
539
638
  self.executor.update_catalog(self.catalog)
540
639
  try:
541
- response = await self.executor.execute_graph(code, self._dispatch)
640
+ response = await self.executor.execute_graph(
641
+ code,
642
+ self._dispatch,
643
+ input_value=input_value,
644
+ retain_result=self.refinement_cache.retain,
645
+ )
542
646
  except BaseException as error:
647
+ cancelled = isinstance(error, asyncio.CancelledError)
543
648
  self._record_operation_exception(
544
649
  "execute",
545
650
  started,
546
651
  input_bytes,
547
- failure_stage=(
548
- "cancelled" if isinstance(error, asyncio.CancelledError) else "error"
549
- ),
652
+ failure_stage="cancelled" if cancelled else "error",
653
+ failure_subtype="cancelled" if cancelled else "internal_error",
654
+ trace_id=trace_id,
550
655
  )
551
656
  raise
552
- self._record_execution("execute", started, input_bytes, response)
657
+ self._record_execution("execute", started, input_bytes, response, trace_id)
553
658
  return response
554
659
 
555
- async def _execute_chain(self, name: str, arguments: JsonObject) -> ExecutionResponse:
660
+ async def _execute_chain(
661
+ self,
662
+ name: str,
663
+ arguments: JsonObject,
664
+ trace_id: str,
665
+ ) -> ExecutionResponse:
556
666
  started = time.perf_counter()
557
667
  input_bytes = len(to_json(arguments))
558
668
  try:
@@ -563,19 +673,27 @@ class GatewayRuntime:
563
673
  started,
564
674
  input_bytes,
565
675
  failure_stage="preflight",
676
+ failure_subtype="saved_chain_lookup",
677
+ trace_id=trace_id,
566
678
  )
567
679
  raise
568
680
  if not chain.enabled:
569
- response = ExecutionResponse(
570
- ok=False,
681
+ message = f"Saved chain is disabled: {name}"
682
+ response = ExecutionResponse.failed(
571
683
  failure_stage="preflight",
572
- error=f"Saved chain is disabled: {name}",
684
+ error=message,
685
+ failure=ExecutionFailureInfo(
686
+ kind="preflight",
687
+ retryable=False,
688
+ message=message,
689
+ ),
573
690
  )
574
691
  self._record_execution(
575
692
  "execute_chain",
576
693
  started,
577
694
  input_bytes,
578
695
  response,
696
+ trace_id,
579
697
  )
580
698
  return response
581
699
  discovery_started = time.perf_counter()
@@ -588,20 +706,28 @@ class GatewayRuntime:
588
706
  started,
589
707
  input_bytes,
590
708
  failure_stage="discovery",
709
+ failure_subtype="discovery",
710
+ trace_id=trace_id,
591
711
  )
592
712
  raise
593
713
  self.stats_store.record_phase("discovery", _elapsed_ms(discovery_started))
594
714
  self.executor.update_catalog(self.catalog)
595
715
  try:
596
- response = await self.executor.execute_saved_chain(chain, arguments, self._dispatch)
716
+ response = await self.executor.execute_saved_chain(
717
+ chain,
718
+ arguments,
719
+ self._dispatch,
720
+ retain_result=self.refinement_cache.retain,
721
+ )
597
722
  except BaseException as error:
723
+ cancelled = isinstance(error, asyncio.CancelledError)
598
724
  self._record_operation_exception(
599
725
  "execute_chain",
600
726
  started,
601
727
  input_bytes,
602
- failure_stage=(
603
- "cancelled" if isinstance(error, asyncio.CancelledError) else "error"
604
- ),
728
+ failure_stage="cancelled" if cancelled else "error",
729
+ failure_subtype="cancelled" if cancelled else "internal_error",
730
+ trace_id=trace_id,
605
731
  )
606
732
  raise
607
733
  self._record_execution(
@@ -609,6 +735,7 @@ class GatewayRuntime:
609
735
  started,
610
736
  input_bytes,
611
737
  response,
738
+ trace_id,
612
739
  )
613
740
  return response
614
741
 
@@ -619,13 +746,21 @@ class GatewayRuntime:
619
746
  input_bytes: int,
620
747
  *,
621
748
  failure_stage: str,
749
+ failure_subtype: str,
750
+ trace_id: str,
622
751
  ) -> None:
623
752
  self.stats_store.record_operation(
624
753
  operation,
625
- duration_ms=_elapsed_ms(started),
626
- success=False,
627
- failure_stage=failure_stage,
628
- input_bytes=input_bytes,
754
+ OperationObservation(
755
+ duration_ms=_elapsed_ms(started),
756
+ success=False,
757
+ input_bytes=input_bytes,
758
+ failure=OperationFailure(
759
+ stage=failure_stage,
760
+ subtype=failure_subtype,
761
+ trace_id=trace_id,
762
+ ),
763
+ ),
629
764
  )
630
765
 
631
766
  def _record_execution(
@@ -634,16 +769,28 @@ class GatewayRuntime:
634
769
  started: float,
635
770
  input_bytes: int,
636
771
  response: ExecutionResponse,
772
+ trace_id: str,
637
773
  ) -> None:
774
+ failure = (
775
+ OperationFailure(
776
+ stage=response.failure_stage or "error",
777
+ subtype=_execution_failure_subtype(response),
778
+ trace_id=trace_id,
779
+ )
780
+ if not response.ok
781
+ else None
782
+ )
638
783
  self.stats_store.record_operation(
639
784
  operation,
640
- duration_ms=_elapsed_ms(started),
641
- success=response.ok,
642
- failure_stage=response.failure_stage,
643
- input_bytes=input_bytes,
644
- output_bytes=response.metrics.result_bytes,
645
- calls=response.calls_made,
646
- chain_calls=response.chain_calls,
785
+ OperationObservation(
786
+ duration_ms=_elapsed_ms(started),
787
+ success=response.ok,
788
+ input_bytes=input_bytes,
789
+ output_bytes=response.metrics.result_bytes,
790
+ calls=response.calls_made,
791
+ chain_calls=response.chain_calls,
792
+ failure=failure,
793
+ ),
647
794
  )
648
795
  metrics = response.metrics
649
796
  for phase, duration in (
@@ -663,6 +810,7 @@ class GatewayRuntime:
663
810
  code: str,
664
811
  input_schema: JsonObject,
665
812
  output_schema: JsonObject,
813
+ trace_id: str,
666
814
  ) -> SaveChainResponse:
667
815
  started = time.perf_counter()
668
816
  input_bytes = len(
@@ -687,18 +835,26 @@ class GatewayRuntime:
687
835
  except BaseException:
688
836
  self.stats_store.record_operation(
689
837
  "save_chain",
690
- duration_ms=_elapsed_ms(started),
691
- success=False,
692
- failure_stage="validation",
693
- input_bytes=input_bytes,
838
+ OperationObservation(
839
+ duration_ms=_elapsed_ms(started),
840
+ success=False,
841
+ input_bytes=input_bytes,
842
+ failure=OperationFailure(
843
+ stage="validation",
844
+ subtype="saved_chain_validation",
845
+ trace_id=trace_id,
846
+ ),
847
+ ),
694
848
  )
695
849
  raise
696
850
  self.stats_store.record_operation(
697
851
  "save_chain",
698
- duration_ms=_elapsed_ms(started),
699
- success=True,
700
- input_bytes=input_bytes,
701
- output_bytes=len(to_json(response.model_dump(mode="json"))),
852
+ OperationObservation(
853
+ duration_ms=_elapsed_ms(started),
854
+ success=True,
855
+ input_bytes=input_bytes,
856
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
857
+ ),
702
858
  )
703
859
  return response
704
860
 
@@ -767,34 +923,100 @@ class GatewayRuntime:
767
923
  created=previous is None,
768
924
  )
769
925
 
770
- def _list_chains(self) -> ChainListResponse:
926
+ def _list_chains(self, trace_id: str) -> ChainListResponse:
771
927
  started = time.perf_counter()
772
- response = ChainListResponse(chains=self._chain_views())
928
+ try:
929
+ response = ChainListResponse(chains=self._chain_views())
930
+ except BaseException:
931
+ self.stats_store.record_operation(
932
+ "list_chains",
933
+ OperationObservation(
934
+ duration_ms=_elapsed_ms(started),
935
+ success=False,
936
+ failure=OperationFailure(
937
+ stage="error",
938
+ subtype="saved_chain_list",
939
+ trace_id=trace_id,
940
+ ),
941
+ ),
942
+ )
943
+ raise
773
944
  self.stats_store.record_operation(
774
945
  "list_chains",
775
- duration_ms=_elapsed_ms(started),
776
- success=True,
777
- output_bytes=len(to_json(response.model_dump(mode="json"))),
946
+ OperationObservation(
947
+ duration_ms=_elapsed_ms(started),
948
+ success=True,
949
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
950
+ ),
951
+ )
952
+ return response
953
+
954
+ async def _set_chain_enabled(
955
+ self,
956
+ name: str,
957
+ scope: ChainScope,
958
+ enabled: bool,
959
+ trace_id: str,
960
+ ) -> ChainStatusView:
961
+ started = time.perf_counter()
962
+ try:
963
+ self.chain_store.set_enabled(scope, name, enabled)
964
+ await self._rebuild_catalog()
965
+ response = self._chain_view(name, scope)
966
+ except BaseException:
967
+ self.stats_store.record_operation(
968
+ "set_chain_enabled",
969
+ OperationObservation(
970
+ duration_ms=_elapsed_ms(started),
971
+ success=False,
972
+ failure=OperationFailure(
973
+ stage="error",
974
+ subtype="saved_chain_update",
975
+ trace_id=trace_id,
976
+ ),
977
+ ),
978
+ )
979
+ raise
980
+ self.stats_store.record_operation(
981
+ "set_chain_enabled",
982
+ OperationObservation(
983
+ duration_ms=_elapsed_ms(started),
984
+ success=True,
985
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
986
+ ),
778
987
  )
779
988
  return response
780
989
 
781
- async def _revalidate_chain(self, name: str, scope: ChainScope) -> ChainStatusView:
990
+ async def _revalidate_chain(
991
+ self,
992
+ name: str,
993
+ scope: ChainScope,
994
+ trace_id: str,
995
+ ) -> ChainStatusView:
782
996
  started = time.perf_counter()
783
997
  try:
784
998
  response = await self._revalidate_chain_impl(name, scope)
785
999
  except BaseException:
786
1000
  self.stats_store.record_operation(
787
1001
  "revalidate_chain",
788
- duration_ms=_elapsed_ms(started),
789
- success=False,
790
- failure_stage="validation",
1002
+ OperationObservation(
1003
+ duration_ms=_elapsed_ms(started),
1004
+ success=False,
1005
+ failure=OperationFailure(
1006
+ stage="validation",
1007
+ subtype="saved_chain_validation",
1008
+ trace_id=trace_id,
1009
+ ),
1010
+ ),
791
1011
  )
792
1012
  raise
793
1013
  self.stats_store.record_operation(
794
1014
  "revalidate_chain",
795
- duration_ms=_elapsed_ms(started),
796
- success=True,
797
- output_bytes=len(to_json(response.model_dump(mode="json"))),
1015
+ OperationObservation(
1016
+ duration_ms=_elapsed_ms(started),
1017
+ success=True,
1018
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
1019
+ ),
798
1020
  )
799
1021
  return response
800
1022
 
@@ -836,27 +1058,45 @@ class GatewayRuntime:
836
1058
  await self._rebuild_catalog()
837
1059
  return self._chain_view(name, scope)
838
1060
 
839
- async def _delete_chain(self, name: str, scope: ChainScope) -> ChainListResponse:
1061
+ async def _delete_chain(
1062
+ self,
1063
+ name: str,
1064
+ scope: ChainScope,
1065
+ trace_id: str,
1066
+ ) -> ChainListResponse:
840
1067
  started = time.perf_counter()
841
1068
  try:
842
- response = await self._delete_chain_impl(name, scope)
1069
+ response = await self._delete_chain_impl(name, scope, trace_id)
843
1070
  except BaseException:
844
1071
  self.stats_store.record_operation(
845
1072
  "delete_chain",
846
- duration_ms=_elapsed_ms(started),
847
- success=False,
848
- failure_stage="error",
1073
+ OperationObservation(
1074
+ duration_ms=_elapsed_ms(started),
1075
+ success=False,
1076
+ failure=OperationFailure(
1077
+ stage="error",
1078
+ subtype="saved_chain_delete",
1079
+ trace_id=trace_id,
1080
+ ),
1081
+ ),
849
1082
  )
850
1083
  raise
851
1084
  self.stats_store.record_operation(
852
1085
  "delete_chain",
853
- duration_ms=_elapsed_ms(started),
854
- success=True,
855
- output_bytes=len(to_json(response.model_dump(mode="json"))),
1086
+ OperationObservation(
1087
+ duration_ms=_elapsed_ms(started),
1088
+ success=True,
1089
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
1090
+ ),
856
1091
  )
857
1092
  return response
858
1093
 
859
- async def _delete_chain_impl(self, name: str, scope: ChainScope) -> ChainListResponse:
1094
+ async def _delete_chain_impl(
1095
+ self,
1096
+ name: str,
1097
+ scope: ChainScope,
1098
+ trace_id: str,
1099
+ ) -> ChainListResponse:
860
1100
  effective = self.chain_store.get(name)
861
1101
  called_by = self._called_by().get(name, []) if effective.scope == scope else []
862
1102
  if called_by:
@@ -865,7 +1105,7 @@ class GatewayRuntime:
865
1105
  )
866
1106
  self.chain_store.delete(scope, name)
867
1107
  await self._rebuild_catalog()
868
- return self._list_chains()
1108
+ return self._list_chains(trace_id)
869
1109
 
870
1110
  async def _dispatch(
871
1111
  self,
@@ -891,7 +1131,22 @@ class GatewayRuntime:
891
1131
  ),
892
1132
  )
893
1133
  normalized = context.catalog.normalize_result(public_name, result)
894
- except BaseException:
1134
+ except BaseException as error:
1135
+ if isinstance(error, Exception) and context.failure is None:
1136
+ status = _upstream_status(error)
1137
+ transport_failure = is_unusable_connection_error(error)
1138
+ context.failure = ExecutionFailureInfo(
1139
+ kind="upstream_transport" if transport_failure else "upstream",
1140
+ server=spec.server,
1141
+ tool=spec.backend_name,
1142
+ retryable=transport_failure or _retryable_status(status),
1143
+ status=status,
1144
+ message=(
1145
+ "Upstream connection closed"
1146
+ if transport_failure
1147
+ else _upstream_failure_message(error)
1148
+ ),
1149
+ )
895
1150
  self.stats_store.record_upstream(
896
1151
  spec.server,
897
1152
  spec.backend_name,
@@ -924,27 +1179,6 @@ class GatewayRuntime:
924
1179
  await self._rebuild_catalog()
925
1180
  return self.status()
926
1181
 
927
- async def apply_manager_changes(
928
- self,
929
- changes: list[ChainEnabledChange],
930
- ) -> ManagerApplyResponse:
931
- previous_settings = self.settings
932
- try:
933
- with self.chain_store.enabled_transaction(changes):
934
- self._load_settings()
935
- await self._rebuild_catalog()
936
- except BaseException:
937
- self.settings = previous_settings
938
- for handle in self.handles.values():
939
- handle.cache.max_age_seconds = previous_settings.cache_ttl_seconds
940
- self.executor.settings = previous_settings.execution_settings()
941
- await self._rebuild_catalog()
942
- raise
943
- return ManagerApplyResponse(
944
- status=self.status(),
945
- chains=self._chain_views(),
946
- )
947
-
948
1182
  def _load_settings(self) -> None:
949
1183
  self.settings = load_settings(self.settings_path)
950
1184
  for handle in self.handles.values():
@@ -1206,6 +1440,104 @@ def _elapsed_ms(started: float) -> float:
1206
1440
  return (time.perf_counter() - started) * 1_000
1207
1441
 
1208
1442
 
1443
+ def new_trace_id(source: str) -> str:
1444
+ return f"{source}:{uuid.uuid4()}"
1445
+
1446
+
1447
+ def _exception_messages(error: BaseException) -> list[str]:
1448
+ messages: list[str] = []
1449
+ pending = [error]
1450
+ seen: set[int] = set()
1451
+ while pending:
1452
+ current = pending.pop()
1453
+ identity = id(current)
1454
+ if identity in seen:
1455
+ continue
1456
+ seen.add(identity)
1457
+ rendered = str(current)
1458
+ messages.append(
1459
+ f"{type(current).__name__}: {rendered}" if rendered else type(current).__name__
1460
+ )
1461
+ if current.__cause__ is not None:
1462
+ pending.append(current.__cause__)
1463
+ if current.__context__ is not None:
1464
+ pending.append(current.__context__)
1465
+ if isinstance(current, BaseExceptionGroup):
1466
+ pending.extend(current.exceptions)
1467
+ return messages
1468
+
1469
+
1470
+ def is_unusable_connection_error(error: BaseException) -> bool:
1471
+ markers = (
1472
+ "connection closed",
1473
+ "connection reset",
1474
+ "broken pipe",
1475
+ "brokenresourceerror",
1476
+ "closedresourceerror",
1477
+ "endofstream",
1478
+ "client is not connected",
1479
+ "session terminated",
1480
+ "transport is closed",
1481
+ )
1482
+ return any(
1483
+ marker in message.lower() for message in _exception_messages(error) for marker in markers
1484
+ )
1485
+
1486
+
1487
+ def _upstream_status(error: BaseException) -> int | None:
1488
+ for message in _exception_messages(error):
1489
+ match = re.search(
1490
+ r"(?i)\b(?:http(?: status)?|status(?: code)?)[\"'\s:=]+([1-5]\d{2})\b",
1491
+ message,
1492
+ )
1493
+ if match is not None:
1494
+ return int(match.group(1))
1495
+ return None
1496
+
1497
+
1498
+ def _retryable_status(status: int | None) -> bool:
1499
+ return status in {408, 409, 425, 429, 500, 502, 503, 504}
1500
+
1501
+
1502
+ def _upstream_failure_message(error: BaseException) -> str:
1503
+ message = next((item.strip() for item in _exception_messages(error) if item.strip()), "")
1504
+ compact = " ".join((message or type(error).__name__).split())
1505
+ if len(compact) <= UPSTREAM_FAILURE_MESSAGE_LIMIT:
1506
+ return compact
1507
+ return f"{compact[: UPSTREAM_FAILURE_MESSAGE_LIMIT - 1].rstrip()}…"
1508
+
1509
+
1510
+ def _execution_failure_subtype(response: ExecutionResponse) -> str:
1511
+ failure = response.failure
1512
+ if failure is not None and failure.kind in {
1513
+ "result_reference",
1514
+ "sandbox_timeout",
1515
+ "upstream",
1516
+ "upstream_transport",
1517
+ "upstream_timeout",
1518
+ }:
1519
+ return failure.kind
1520
+ stage = response.failure_stage or "error"
1521
+ error = response.error or ""
1522
+ if stage == "preflight":
1523
+ return "preflight_typecheck"
1524
+ if stage == "result" and error.startswith("Returned value is "):
1525
+ return "result_too_large"
1526
+ if stage == "result":
1527
+ return "result_validation"
1528
+ if stage == "timeout":
1529
+ return "upstream_timeout"
1530
+ if stage == "cancelled":
1531
+ return "cancelled"
1532
+ if "Connection closed" in error:
1533
+ return "upstream_transport"
1534
+ if error.startswith("ValueError: inspect_json"):
1535
+ return "sandbox_contract"
1536
+ if stage == "runtime":
1537
+ return "upstream_runtime"
1538
+ return "internal_error"
1539
+
1540
+
1209
1541
  def _discovery_error_message(error: Exception) -> str:
1210
1542
  return str(error).strip() or type(error).__name__
1211
1543
 
@@ -1286,6 +1618,8 @@ mcp = FastMCP(
1286
1618
 
1287
1619
  @mcp.tool
1288
1620
  async def search(
1621
+ trace_id: str,
1622
+ *,
1289
1623
  query: str | None = None,
1290
1624
  limit: int = 5,
1291
1625
  server: str | None = None,
@@ -1294,13 +1628,21 @@ async def search(
1294
1628
  cursor: int = 0,
1295
1629
  ) -> SearchResponse:
1296
1630
  """Search or page through configured upstream MCP tools and saved chains."""
1297
- return await _require_runtime().search(query, limit, server, detail, mode, cursor)
1631
+ return await _require_runtime().search(
1632
+ query,
1633
+ limit,
1634
+ server,
1635
+ detail,
1636
+ mode,
1637
+ cursor,
1638
+ trace_id=trace_id,
1639
+ )
1298
1640
 
1299
1641
 
1300
1642
  @mcp.tool
1301
- async def inspect(calls: list[str]) -> InspectResponse:
1643
+ async def inspect(trace_id: str, calls: list[str]) -> InspectResponse:
1302
1644
  """Return exact typed SDK stubs for selected call identifiers."""
1303
- return await _require_runtime().inspect(calls)
1645
+ return await _require_runtime().inspect(calls, trace_id)
1304
1646
 
1305
1647
 
1306
1648
  @mcp.tool
@@ -1316,21 +1658,30 @@ async def reload_settings() -> StatusResponse:
1316
1658
 
1317
1659
 
1318
1660
  @mcp.tool
1319
- async def apply_manager_changes(
1320
- changes: list[ChainEnabledChange],
1321
- ) -> ManagerApplyResponse:
1322
- """Apply staged settings and saved-chain enable changes with one catalog rebuild."""
1323
- return await _require_runtime().apply_manager_changes(changes)
1661
+ async def set_chain_enabled(
1662
+ trace_id: str,
1663
+ name: str,
1664
+ scope: ChainScope,
1665
+ enabled: bool,
1666
+ ) -> ChainStatusView:
1667
+ """Enable or disable one saved chain in its storage scope."""
1668
+ return await _require_runtime().chains.set_enabled(name, scope, enabled, trace_id)
1324
1669
 
1325
1670
 
1326
1671
  @mcp.tool
1327
- async def execute(code: str) -> ExecutionResponse:
1672
+ async def execute(
1673
+ trace_id: str,
1674
+ code: str,
1675
+ input_ref: str | None = None,
1676
+ ) -> ExecutionResponse:
1328
1677
  """Type-check and run one sandboxed Python MCP SDK chain."""
1329
- return await _require_runtime().execute(code)
1678
+ return await _require_runtime().execute(code, trace_id, input_ref)
1330
1679
 
1331
1680
 
1332
1681
  @mcp.tool
1333
1682
  async def save_chain(
1683
+ trace_id: str,
1684
+ *,
1334
1685
  name: str,
1335
1686
  description: str,
1336
1687
  code: str,
@@ -1346,38 +1697,51 @@ async def save_chain(
1346
1697
  code=code,
1347
1698
  input_schema=json_types.JSON_OBJECT_ADAPTER.validate_python(input_schema),
1348
1699
  output_schema=json_types.JSON_OBJECT_ADAPTER.validate_python(output_schema),
1700
+ trace_id=trace_id,
1349
1701
  )
1350
1702
 
1351
1703
 
1352
1704
  @mcp.tool
1353
- def list_chains() -> ChainListResponse:
1705
+ def list_chains(trace_id: str) -> ChainListResponse:
1354
1706
  """List saved chains and their dependency state."""
1355
- return _require_runtime().chains.list()
1707
+ return _require_runtime().chains.list(trace_id)
1356
1708
 
1357
1709
 
1358
1710
  @mcp.tool
1359
- async def execute_chain(name: str, arguments: JsonObject) -> ExecutionResponse:
1711
+ async def execute_chain(
1712
+ trace_id: str,
1713
+ name: str,
1714
+ arguments: JsonObject,
1715
+ ) -> ExecutionResponse:
1360
1716
  """Execute one saved chain through its typed input contract."""
1361
1717
  validated_arguments = json_types.JSON_OBJECT_ADAPTER.validate_python(arguments)
1362
- return await _require_runtime().chains.execute(name, validated_arguments)
1718
+ return await _require_runtime().chains.execute(name, validated_arguments, trace_id)
1363
1719
 
1364
1720
 
1365
1721
  @mcp.tool
1366
- async def revalidate_chain(name: str, scope: ChainScope) -> ChainStatusView:
1722
+ async def revalidate_chain(
1723
+ trace_id: str,
1724
+ name: str,
1725
+ scope: ChainScope,
1726
+ ) -> ChainStatusView:
1367
1727
  """Revalidate one scoped saved chain against the current callable catalog."""
1368
- return await _require_runtime().chains.revalidate(name, scope)
1728
+ return await _require_runtime().chains.revalidate(name, scope, trace_id)
1369
1729
 
1370
1730
 
1371
1731
  @mcp.tool
1372
- async def delete_chain(name: str, scope: ChainScope) -> ChainListResponse:
1732
+ async def delete_chain(
1733
+ trace_id: str,
1734
+ name: str,
1735
+ scope: ChainScope,
1736
+ ) -> ChainListResponse:
1373
1737
  """Delete an unused saved chain from its storage scope."""
1374
- return await _require_runtime().chains.delete(name, scope)
1738
+ return await _require_runtime().chains.delete(name, scope, trace_id)
1375
1739
 
1376
1740
 
1377
1741
  @mcp.tool
1378
- def stats() -> JsonObject:
1742
+ async def stats() -> JsonObject:
1379
1743
  """Return bounded local CodeMCP telemetry rollups."""
1380
- return _require_runtime().stats_store.snapshot()
1744
+ return await _require_runtime().stats_store.snapshot()
1381
1745
 
1382
1746
 
1383
1747
  @mcp.tool