aip-agents-binary 0.5.21__py3-none-macosx_13_0_arm64.whl → 0.6.8__py3-none-macosx_13_0_arm64.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.
Files changed (149) hide show
  1. aip_agents/agent/__init__.py +44 -4
  2. aip_agents/agent/base_langgraph_agent.py +169 -74
  3. aip_agents/agent/base_langgraph_agent.pyi +3 -2
  4. aip_agents/agent/langgraph_memory_enhancer_agent.py +368 -34
  5. aip_agents/agent/langgraph_memory_enhancer_agent.pyi +3 -2
  6. aip_agents/agent/langgraph_react_agent.py +424 -35
  7. aip_agents/agent/langgraph_react_agent.pyi +46 -2
  8. aip_agents/examples/{hello_world_langgraph_bosa_twitter.py → hello_world_langgraph_gl_connector_twitter.py} +10 -7
  9. aip_agents/examples/hello_world_langgraph_gl_connector_twitter.pyi +5 -0
  10. aip_agents/examples/hello_world_ptc.py +49 -0
  11. aip_agents/examples/hello_world_ptc.pyi +5 -0
  12. aip_agents/examples/hello_world_ptc_custom_tools.py +83 -0
  13. aip_agents/examples/hello_world_ptc_custom_tools.pyi +7 -0
  14. aip_agents/examples/hello_world_sentry.py +2 -2
  15. aip_agents/examples/hello_world_tool_output_client.py +9 -0
  16. aip_agents/examples/tools/multiply_tool.py +43 -0
  17. aip_agents/examples/tools/multiply_tool.pyi +18 -0
  18. aip_agents/guardrails/__init__.py +83 -0
  19. aip_agents/guardrails/__init__.pyi +6 -0
  20. aip_agents/guardrails/engines/__init__.py +69 -0
  21. aip_agents/guardrails/engines/__init__.pyi +4 -0
  22. aip_agents/guardrails/engines/base.py +90 -0
  23. aip_agents/guardrails/engines/base.pyi +61 -0
  24. aip_agents/guardrails/engines/nemo.py +101 -0
  25. aip_agents/guardrails/engines/nemo.pyi +46 -0
  26. aip_agents/guardrails/engines/phrase_matcher.py +113 -0
  27. aip_agents/guardrails/engines/phrase_matcher.pyi +48 -0
  28. aip_agents/guardrails/exceptions.py +39 -0
  29. aip_agents/guardrails/exceptions.pyi +23 -0
  30. aip_agents/guardrails/manager.py +163 -0
  31. aip_agents/guardrails/manager.pyi +42 -0
  32. aip_agents/guardrails/middleware.py +199 -0
  33. aip_agents/guardrails/middleware.pyi +87 -0
  34. aip_agents/guardrails/schemas.py +63 -0
  35. aip_agents/guardrails/schemas.pyi +43 -0
  36. aip_agents/guardrails/utils.py +45 -0
  37. aip_agents/guardrails/utils.pyi +19 -0
  38. aip_agents/mcp/client/__init__.py +38 -2
  39. aip_agents/mcp/client/connection_manager.py +36 -1
  40. aip_agents/mcp/client/connection_manager.pyi +3 -0
  41. aip_agents/mcp/client/persistent_session.py +318 -65
  42. aip_agents/mcp/client/persistent_session.pyi +9 -0
  43. aip_agents/mcp/client/transports.py +52 -4
  44. aip_agents/mcp/client/transports.pyi +9 -0
  45. aip_agents/memory/adapters/base_adapter.py +98 -0
  46. aip_agents/memory/adapters/base_adapter.pyi +25 -0
  47. aip_agents/middleware/base.py +8 -0
  48. aip_agents/middleware/base.pyi +4 -0
  49. aip_agents/middleware/manager.py +22 -0
  50. aip_agents/middleware/manager.pyi +4 -0
  51. aip_agents/ptc/__init__.py +87 -0
  52. aip_agents/ptc/__init__.pyi +14 -0
  53. aip_agents/ptc/custom_tools.py +473 -0
  54. aip_agents/ptc/custom_tools.pyi +184 -0
  55. aip_agents/ptc/custom_tools_payload.py +400 -0
  56. aip_agents/ptc/custom_tools_payload.pyi +31 -0
  57. aip_agents/ptc/custom_tools_templates/__init__.py +1 -0
  58. aip_agents/ptc/custom_tools_templates/__init__.pyi +0 -0
  59. aip_agents/ptc/custom_tools_templates/custom_build_function.py.template +23 -0
  60. aip_agents/ptc/custom_tools_templates/custom_init.py.template +15 -0
  61. aip_agents/ptc/custom_tools_templates/custom_invoke.py.template +60 -0
  62. aip_agents/ptc/custom_tools_templates/custom_registry.py.template +87 -0
  63. aip_agents/ptc/custom_tools_templates/custom_sources_init.py.template +7 -0
  64. aip_agents/ptc/custom_tools_templates/custom_wrapper.py.template +19 -0
  65. aip_agents/ptc/doc_gen.py +122 -0
  66. aip_agents/ptc/doc_gen.pyi +40 -0
  67. aip_agents/ptc/exceptions.py +57 -0
  68. aip_agents/ptc/exceptions.pyi +37 -0
  69. aip_agents/ptc/executor.py +261 -0
  70. aip_agents/ptc/executor.pyi +99 -0
  71. aip_agents/ptc/mcp/__init__.py +45 -0
  72. aip_agents/ptc/mcp/__init__.pyi +7 -0
  73. aip_agents/ptc/mcp/sandbox_bridge.py +668 -0
  74. aip_agents/ptc/mcp/sandbox_bridge.pyi +47 -0
  75. aip_agents/ptc/mcp/templates/__init__.py +1 -0
  76. aip_agents/ptc/mcp/templates/__init__.pyi +0 -0
  77. aip_agents/ptc/mcp/templates/mcp_client.py.template +239 -0
  78. aip_agents/ptc/naming.py +196 -0
  79. aip_agents/ptc/naming.pyi +85 -0
  80. aip_agents/ptc/payload.py +26 -0
  81. aip_agents/ptc/payload.pyi +15 -0
  82. aip_agents/ptc/prompt_builder.py +673 -0
  83. aip_agents/ptc/prompt_builder.pyi +59 -0
  84. aip_agents/ptc/ptc_helper.py +16 -0
  85. aip_agents/ptc/ptc_helper.pyi +1 -0
  86. aip_agents/ptc/sandbox_bridge.py +256 -0
  87. aip_agents/ptc/sandbox_bridge.pyi +38 -0
  88. aip_agents/ptc/template_utils.py +33 -0
  89. aip_agents/ptc/template_utils.pyi +13 -0
  90. aip_agents/ptc/templates/__init__.py +1 -0
  91. aip_agents/ptc/templates/__init__.pyi +0 -0
  92. aip_agents/ptc/templates/ptc_helper.py.template +134 -0
  93. aip_agents/ptc/tool_def_helpers.py +101 -0
  94. aip_agents/ptc/tool_def_helpers.pyi +38 -0
  95. aip_agents/ptc/tool_enrichment.py +163 -0
  96. aip_agents/ptc/tool_enrichment.pyi +60 -0
  97. aip_agents/sandbox/__init__.py +43 -0
  98. aip_agents/sandbox/__init__.pyi +5 -0
  99. aip_agents/sandbox/defaults.py +205 -0
  100. aip_agents/sandbox/defaults.pyi +30 -0
  101. aip_agents/sandbox/e2b_runtime.py +295 -0
  102. aip_agents/sandbox/e2b_runtime.pyi +57 -0
  103. aip_agents/sandbox/template_builder.py +131 -0
  104. aip_agents/sandbox/template_builder.pyi +36 -0
  105. aip_agents/sandbox/types.py +24 -0
  106. aip_agents/sandbox/types.pyi +14 -0
  107. aip_agents/sandbox/validation.py +50 -0
  108. aip_agents/sandbox/validation.pyi +20 -0
  109. aip_agents/sentry/__init__.py +1 -1
  110. aip_agents/sentry/sentry.py +33 -12
  111. aip_agents/sentry/sentry.pyi +5 -4
  112. aip_agents/tools/__init__.py +20 -3
  113. aip_agents/tools/__init__.pyi +4 -2
  114. aip_agents/tools/browser_use/browser_use_tool.py +8 -0
  115. aip_agents/tools/browser_use/streaming.py +2 -0
  116. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +80 -31
  117. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.pyi +25 -9
  118. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +6 -6
  119. aip_agents/tools/constants.py +24 -12
  120. aip_agents/tools/constants.pyi +14 -11
  121. aip_agents/tools/date_range_tool.py +554 -0
  122. aip_agents/tools/date_range_tool.pyi +21 -0
  123. aip_agents/tools/execute_ptc_code.py +357 -0
  124. aip_agents/tools/execute_ptc_code.pyi +90 -0
  125. aip_agents/tools/gl_connector/__init__.py +1 -1
  126. aip_agents/tools/gl_connector/tool.py +62 -30
  127. aip_agents/tools/gl_connector/tool.pyi +3 -3
  128. aip_agents/tools/gl_connector_tools.py +119 -0
  129. aip_agents/tools/gl_connector_tools.pyi +39 -0
  130. aip_agents/tools/memory_search/__init__.py +8 -1
  131. aip_agents/tools/memory_search/__init__.pyi +3 -3
  132. aip_agents/tools/memory_search/mem0.py +114 -1
  133. aip_agents/tools/memory_search/mem0.pyi +11 -1
  134. aip_agents/tools/memory_search/schema.py +33 -0
  135. aip_agents/tools/memory_search/schema.pyi +10 -0
  136. aip_agents/tools/memory_search_tool.py +8 -0
  137. aip_agents/tools/memory_search_tool.pyi +2 -2
  138. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +26 -1
  139. aip_agents/utils/langgraph/tool_output_management.py +80 -0
  140. aip_agents/utils/langgraph/tool_output_management.pyi +37 -0
  141. {aip_agents_binary-0.5.21.dist-info → aip_agents_binary-0.6.8.dist-info}/METADATA +14 -22
  142. {aip_agents_binary-0.5.21.dist-info → aip_agents_binary-0.6.8.dist-info}/RECORD +144 -58
  143. {aip_agents_binary-0.5.21.dist-info → aip_agents_binary-0.6.8.dist-info}/WHEEL +1 -1
  144. aip_agents/examples/demo_memory_recall.py +0 -401
  145. aip_agents/examples/demo_memory_recall.pyi +0 -58
  146. aip_agents/examples/hello_world_langgraph_bosa_twitter.pyi +0 -5
  147. aip_agents/tools/bosa_tools.py +0 -105
  148. aip_agents/tools/bosa_tools.pyi +0 -37
  149. {aip_agents_binary-0.5.21.dist-info → aip_agents_binary-0.6.8.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,7 @@ Authors:
8
8
  """
9
9
 
10
10
  import asyncio
11
+ from collections.abc import Awaitable, Callable
11
12
  from typing import Any
12
13
 
13
14
  from gllm_tools.mcp.client.config import MCPConfiguration
@@ -69,6 +70,13 @@ class PersistentMCPSession:
69
70
 
70
71
  self._initialized = False
71
72
  self._lock = asyncio.Lock()
73
+ self._owner_task: asyncio.Task | None = None
74
+ self._owner_ready: asyncio.Event = asyncio.Event()
75
+ self._owner_exception: Exception | None = None
76
+ self._timeout = float(config.get("timeout", 30.0))
77
+ self._request_queue: asyncio.Queue[
78
+ tuple[Callable[..., Awaitable[Any]], tuple[Any, ...], asyncio.Future, bool]
79
+ ] = asyncio.Queue()
72
80
 
73
81
  async def initialize(self) -> None:
74
82
  """Initialize session once and cache tools.
@@ -85,47 +93,28 @@ class PersistentMCPSession:
85
93
  # Double-check pattern
86
94
  if self._initialized:
87
95
  return
96
+ if self._owner_task is None or self._owner_task.done():
97
+ self._owner_ready = asyncio.Event()
98
+ self._owner_exception = None
99
+ self._owner_task = asyncio.create_task(self._owner_loop())
88
100
 
89
- try:
90
- logger.info(f"Initializing persistent session for {self.server_name}")
91
-
92
- # Start connection manager
93
- read_stream, write_stream = await self.connection_manager.start()
94
-
95
- # Create client session
96
- self.client_session = ClientSession(read_stream, write_stream)
97
- await self.client_session.__aenter__()
98
-
99
- # MCP handshake
100
- result = await self.client_session.initialize()
101
- logger.debug(f"MCP handshake complete for {self.server_name}: {result.capabilities}")
102
-
103
- # Discover and cache tools
104
- if result.capabilities.tools:
105
- tools_result = await self.client_session.list_tools()
106
- self.tools = tools_result.tools if tools_result else []
107
- self._filtered_tools_cache = None # Invalidate cache when tools change
108
- logger.info(f"Cached {len(self.tools)} tools for {self.server_name}")
109
- else:
110
- logger.info(f"No tools available for {self.server_name}")
111
-
112
- # Warn once per initialization if allowed_tools references unknown names
113
- if self._allowed_tools_set:
114
- self._warn_on_unknown_allowed_tools(list(self._allowed_tools_set), self.tools)
115
-
116
- # Discover resources (for future use)
117
- if result.capabilities.resources:
118
- resources_result = await self.client_session.list_resources()
119
- if resources_result:
120
- logger.debug(f"Found {len(resources_result.resources)} resources for {self.server_name}")
121
-
122
- self._initialized = True
123
- logger.info(f"Session initialization complete for {self.server_name}")
124
-
125
- except Exception as e:
126
- logger.error(f"Failed to initialize session for {self.server_name}: {e}", exc_info=True)
127
- await self._cleanup_on_error()
128
- raise ConnectionError(f"Failed to initialize MCP session for {self.server_name}: {str(e)}") from e
101
+ try:
102
+ await asyncio.wait_for(self._owner_ready.wait(), timeout=self._timeout)
103
+ except asyncio.CancelledError:
104
+ if self._owner_task and not self._owner_task.done():
105
+ self._owner_task.cancel()
106
+ raise
107
+ except TimeoutError as e:
108
+ logger.error(f"Initialization timed out for {self.server_name} after {self._timeout}s")
109
+ if self._owner_task and not self._owner_task.done():
110
+ self._owner_task.cancel()
111
+ self._owner_exception = ConnectionError(
112
+ f"Initialization timed out for {self.server_name} after {self._timeout}s"
113
+ )
114
+ self._owner_ready.set()
115
+ raise self._owner_exception from e
116
+ if self._owner_exception:
117
+ raise self._owner_exception
129
118
 
130
119
  async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResult:
131
120
  """Call MCP tool using persistent session.
@@ -140,7 +129,19 @@ class PersistentMCPSession:
140
129
  Raises:
141
130
  Exception: If tool call fails
142
131
  """
143
- await self.ensure_connected()
132
+ return await self._run_in_owner(self._call_tool_impl, name, arguments)
133
+
134
+ async def _call_tool_impl(self, name: str, arguments: dict[str, Any]) -> CallToolResult:
135
+ """Call MCP tool using the owner task.
136
+
137
+ Args:
138
+ name: Tool name.
139
+ arguments: Tool arguments.
140
+
141
+ Returns:
142
+ CallToolResult: Tool call result.
143
+ """
144
+ await self._ensure_connected_impl()
144
145
 
145
146
  if self._allowed_tools_set and name not in self._allowed_tools_set:
146
147
  allowed_display = ", ".join(sorted(self._allowed_tools_set))
@@ -170,10 +171,9 @@ class PersistentMCPSession:
170
171
  Raises:
171
172
  Exception: If resource reading fails
172
173
  """
173
- await self.ensure_connected()
174
- return await self._execute_read_resource(uri)
174
+ return await self._run_in_owner(self._execute_read_resource_impl, uri)
175
175
 
176
- async def _execute_read_resource(self, uri: str) -> Any:
176
+ async def _execute_read_resource_impl(self, uri: str) -> Any:
177
177
  """Execute the reading of an MCP resource.
178
178
 
179
179
  Args:
@@ -185,6 +185,7 @@ class PersistentMCPSession:
185
185
  Raises:
186
186
  Exception: If resource reading fails
187
187
  """
188
+ await self._ensure_connected_impl()
188
189
  try:
189
190
  logger.debug(f"Reading resource '{uri}' on {self.server_name}")
190
191
  result = await self.client_session.read_resource(uri)
@@ -199,7 +200,15 @@ class PersistentMCPSession:
199
200
  Returns:
200
201
  list[Tool]: a copy of list of available tools, filtered to only allowed tools if configured
201
202
  """
202
- await self.ensure_connected()
203
+ return await self._run_in_owner(self._list_tools_impl)
204
+
205
+ async def _list_tools_impl(self) -> list[Tool]:
206
+ """Return the cached tools list from the owner task.
207
+
208
+ Returns:
209
+ list[Tool]: Filtered tool list if allowed tools are configured, otherwise all tools.
210
+ """
211
+ await self._ensure_connected_impl()
203
212
 
204
213
  if not self._allowed_tools_set:
205
214
  return list(self.tools)
@@ -231,9 +240,13 @@ class PersistentMCPSession:
231
240
  Raises:
232
241
  Exception: If reconnection fails
233
242
  """
243
+ await self._run_in_owner(self._ensure_connected_impl)
244
+
245
+ async def _ensure_connected_impl(self) -> None:
246
+ """Ensure the session is connected, reconnecting if needed."""
234
247
  if not self._initialized or not self.connection_manager.is_connected:
235
248
  logger.info(f"Reconnecting session for {self.server_name}")
236
- await self.initialize()
249
+ await self._initialize_impl()
237
250
 
238
251
  def _handle_connection_error(self, e: Exception, operation: str) -> None:
239
252
  """Handle connection-related errors with logging and reconnection marking.
@@ -252,29 +265,44 @@ class PersistentMCPSession:
252
265
  """Disconnect session gracefully.
253
266
 
254
267
  This method cleans up all resources and connections.
268
+ Always succeeds, even if the session was already in an error state.
255
269
  """
256
270
  logger.info(f"Disconnecting session for {self.server_name}")
257
271
 
258
- async with self._lock:
272
+ if self._owner_task is None or self._owner_task.done():
273
+ await self._disconnect_impl()
274
+ return
275
+
276
+ try:
277
+ await self._run_in_owner(self._disconnect_impl, shutdown=True, ensure_initialized=False)
278
+ except ConnectionError:
279
+ # Owner task already failed; just clean up directly
280
+ logger.debug(f"Owner task already failed for {self.server_name}, cleaning up directly")
281
+ await self._disconnect_impl()
282
+ finally:
283
+ await self._await_owner_shutdown()
284
+
285
+ async def _await_owner_shutdown(self) -> None:
286
+ """Wait for the owner task to exit, cancelling on timeout."""
287
+ if not self._owner_task:
288
+ return
289
+
290
+ owner_task = self._owner_task
291
+ try:
292
+ await asyncio.wait_for(owner_task, timeout=self._timeout)
293
+ except TimeoutError:
294
+ logger.warning(f"Owner task for {self.server_name} did not exit within {self._timeout}s, cancelling")
295
+ owner_task.cancel()
259
296
  try:
260
- # Close client session
261
- if self.client_session:
262
- try:
263
- await self.client_session.__aexit__(None, None, None)
264
- except Exception as e:
265
- logger.warning(f"Error closing client session for {self.server_name}: {e}")
266
- self.client_session = None
267
-
268
- # Stop connection manager
269
- await self.connection_manager.stop()
270
-
271
- except Exception as e:
272
- logger.error(f"Error during disconnect for {self.server_name}: {e}")
273
- finally:
274
- self._initialized = False
275
- self.tools.clear()
276
- self._filtered_tools_cache = None # Clear cache on disconnect
277
- logger.info(f"Session disconnected for {self.server_name}")
297
+ await owner_task
298
+ except (asyncio.CancelledError, Exception):
299
+ pass
300
+ except asyncio.CancelledError:
301
+ pass
302
+ except Exception:
303
+ pass
304
+ finally:
305
+ self._owner_task = None
278
306
 
279
307
  async def _cleanup_on_error(self) -> None:
280
308
  """Internal cleanup method for error scenarios."""
@@ -303,6 +331,17 @@ class PersistentMCPSession:
303
331
  """
304
332
  return self._initialized and self.connection_manager.is_connected
305
333
 
334
+ @property
335
+ def allowed_tools(self) -> list[str] | None:
336
+ """Return the configured allowed tools, sorted if present.
337
+
338
+ Returns:
339
+ Sorted list of allowed tool names, or None if unrestricted.
340
+ """
341
+ if not self._allowed_tools_set:
342
+ return None
343
+ return sorted(self._allowed_tools_set)
344
+
306
345
  def update_allowed_tools(self, allowed_tools: list[str] | None) -> bool:
307
346
  """Update the list of allowed tools for this session.
308
347
 
@@ -357,3 +396,217 @@ class PersistentMCPSession:
357
396
  logger.warning(
358
397
  f"[{self.server_name}] Tool '{tool_name}' not found in available tools but specified in allowed_tools"
359
398
  )
399
+
400
+ async def _owner_loop(self) -> None:
401
+ """Run the owner task loop and process queued requests.
402
+
403
+ Returns:
404
+ None
405
+ """
406
+ shutdown_requested = False
407
+ try:
408
+ shutdown_requested = await self._initialize_owner()
409
+ if shutdown_requested:
410
+ return
411
+
412
+ while True:
413
+ func, args, future, shutdown = await self._request_queue.get()
414
+ if await self._process_owner_request(func, args, future):
415
+ continue
416
+ if shutdown:
417
+ shutdown_requested = True
418
+ break
419
+ finally:
420
+ # Drain and cancel any pending requests to avoid hanging callers
421
+ await self._drain_pending_requests()
422
+ if not shutdown_requested and (
423
+ self._initialized or self.client_session or self.connection_manager.transport_type is not None
424
+ ):
425
+ await self._cleanup_on_error()
426
+ self._owner_task = None
427
+
428
+ async def _drain_pending_requests(self) -> None:
429
+ """Cancel all pending requests in the queue.
430
+
431
+ This prevents callers from hanging when the owner loop exits unexpectedly.
432
+ """
433
+ error = ConnectionError(f"Session for {self.server_name} is shutting down")
434
+ while not self._request_queue.empty():
435
+ try:
436
+ _, _, future, _ = self._request_queue.get_nowait()
437
+ if not future.done():
438
+ future.set_exception(error)
439
+ except asyncio.QueueEmpty:
440
+ break
441
+
442
+ async def _initialize_owner(self) -> bool:
443
+ """Initialize the owner task and signal readiness.
444
+
445
+ Returns:
446
+ bool: True when initialization fails and the loop should stop.
447
+ """
448
+ try:
449
+ await self._initialize_impl()
450
+ except Exception as e:
451
+ self._owner_exception = e
452
+ self._owner_ready.set()
453
+ return True
454
+
455
+ self._owner_ready.set()
456
+ return False
457
+
458
+ async def _process_owner_request(
459
+ self,
460
+ func: Callable[..., Awaitable[Any]],
461
+ args: tuple[Any, ...],
462
+ future: asyncio.Future,
463
+ ) -> bool:
464
+ """Process a single queued request.
465
+
466
+ Args:
467
+ func: Coroutine function to execute.
468
+ args: Positional arguments for the function.
469
+ future: Future to resolve with the result or exception.
470
+
471
+ Returns:
472
+ bool: True if the request was skipped due to cancellation.
473
+ """
474
+ if future.cancelled():
475
+ return True
476
+
477
+ try:
478
+ result = await func(*args)
479
+ except asyncio.CancelledError as e:
480
+ # Owner task was cancelled - resolve future to prevent hanging caller
481
+ if not future.cancelled():
482
+ future.set_exception(e)
483
+ raise
484
+ except Exception as e:
485
+ if not future.cancelled():
486
+ future.set_exception(e)
487
+ else:
488
+ if not future.cancelled():
489
+ future.set_result(result)
490
+ return False
491
+
492
+ async def _run_in_owner(
493
+ self,
494
+ func: Callable[..., Awaitable[Any]],
495
+ *args: Any,
496
+ shutdown: bool = False,
497
+ ensure_initialized: bool = True,
498
+ ) -> Any:
499
+ """Execute a coroutine on the owner task.
500
+
501
+ Args:
502
+ func: Coroutine function to execute.
503
+ *args: Positional arguments to pass to func.
504
+ shutdown: Whether this request should shut down the owner loop.
505
+ ensure_initialized: Whether to initialize the owner task if needed.
506
+
507
+ Returns:
508
+ Any: The result of the coroutine call.
509
+
510
+ Raises:
511
+ ConnectionError: If the owner task died or session is shutting down.
512
+ """
513
+ if ensure_initialized:
514
+ await self.initialize()
515
+ else:
516
+ # For non-init calls (like disconnect), check if owner is alive
517
+ if self._owner_task is None or self._owner_task.done():
518
+ return await func(*args)
519
+ try:
520
+ await asyncio.wait_for(self._owner_ready.wait(), timeout=self._timeout)
521
+ except TimeoutError as e:
522
+ # Owner task is stuck, cancel it and raise
523
+ if self._owner_task and not self._owner_task.done():
524
+ self._owner_task.cancel()
525
+ raise ConnectionError(
526
+ f"Session for {self.server_name} initialization timed out after {self._timeout}s"
527
+ ) from e
528
+ if self._owner_exception:
529
+ # Propagate the error instead of silently returning None
530
+ raise ConnectionError(
531
+ f"Session for {self.server_name} failed: {self._owner_exception}"
532
+ ) from self._owner_exception
533
+
534
+ # Check if owner task died after initialization (race condition guard)
535
+ if self._owner_task is None or self._owner_task.done():
536
+ raise ConnectionError(f"Session for {self.server_name} is no longer active")
537
+
538
+ loop = asyncio.get_running_loop()
539
+ future: asyncio.Future = loop.create_future()
540
+ await self._request_queue.put((func, args, future, shutdown))
541
+ return await future
542
+
543
+ async def _initialize_impl(self) -> None:
544
+ """Initialize the underlying MCP session on the owner task."""
545
+ if self._initialized:
546
+ return
547
+
548
+ try:
549
+ logger.info(f"Initializing persistent session for {self.server_name}")
550
+
551
+ # Start connection manager
552
+ read_stream, write_stream = await self.connection_manager.start()
553
+
554
+ # Create client session
555
+ self.client_session = ClientSession(read_stream, write_stream)
556
+ await self.client_session.__aenter__()
557
+
558
+ # MCP handshake
559
+ result = await self.client_session.initialize()
560
+ logger.debug(f"MCP handshake complete for {self.server_name}: {result.capabilities}")
561
+
562
+ # Discover and cache tools
563
+ if result.capabilities.tools:
564
+ tools_result = await self.client_session.list_tools()
565
+ self.tools = tools_result.tools if tools_result else []
566
+ self._filtered_tools_cache = None # Invalidate cache when tools change
567
+ logger.info(f"Cached {len(self.tools)} tools for {self.server_name}")
568
+ else:
569
+ logger.info(f"No tools available for {self.server_name}")
570
+
571
+ # Warn once per initialization if allowed_tools references unknown names
572
+ if self._allowed_tools_set:
573
+ self._warn_on_unknown_allowed_tools(list(self._allowed_tools_set), self.tools)
574
+
575
+ # Discover resources (for future use)
576
+ if result.capabilities.resources:
577
+ try:
578
+ resources_result = await self.client_session.list_resources()
579
+ if resources_result and resources_result.resources:
580
+ logger.debug(f"Found {len(resources_result.resources)} resources for {self.server_name}")
581
+ except Exception:
582
+ logger.debug(f"Could not list resources for {self.server_name}, skipping")
583
+
584
+ self._initialized = True
585
+ logger.info(f"Session initialization complete for {self.server_name}")
586
+
587
+ except Exception as e:
588
+ logger.error(f"Failed to initialize session for {self.server_name}: {e}", exc_info=True)
589
+ await self._cleanup_on_error()
590
+ raise ConnectionError(f"Failed to initialize MCP session for {self.server_name}: {str(e)}") from e
591
+
592
+ async def _disconnect_impl(self) -> None:
593
+ """Disconnect the underlying MCP session on the owner task."""
594
+ try:
595
+ # Close client session
596
+ if self.client_session:
597
+ try:
598
+ await self.client_session.__aexit__(None, None, None)
599
+ except Exception as e:
600
+ logger.warning(f"Error closing client session for {self.server_name}: {e}")
601
+ self.client_session = None
602
+
603
+ # Stop connection manager
604
+ await self.connection_manager.stop()
605
+
606
+ except Exception as e:
607
+ logger.error(f"Error during disconnect for {self.server_name}: {e}")
608
+ finally:
609
+ self._initialized = False
610
+ self.tools.clear()
611
+ self._filtered_tools_cache = None # Clear cache on disconnect
612
+ logger.info(f"Session disconnected for {self.server_name}")
@@ -2,6 +2,7 @@ from _typeshed import Incomplete
2
2
  from aip_agents.mcp.client.connection_manager import MCPConnectionManager as MCPConnectionManager
3
3
  from aip_agents.mcp.utils.config_validator import validate_allowed_tools_list as validate_allowed_tools_list
4
4
  from aip_agents.utils.logger import get_logger as get_logger
5
+ from collections.abc import Awaitable as Awaitable
5
6
  from gllm_tools.mcp.client.config import MCPConfiguration
6
7
  from mcp import ClientSession
7
8
  from mcp.types import CallToolResult, Tool as Tool
@@ -90,6 +91,7 @@ class PersistentMCPSession:
90
91
  """Disconnect session gracefully.
91
92
 
92
93
  This method cleans up all resources and connections.
94
+ Always succeeds, even if the session was already in an error state.
93
95
  """
94
96
  @property
95
97
  def is_initialized(self) -> bool:
@@ -98,6 +100,13 @@ class PersistentMCPSession:
98
100
  Returns:
99
101
  bool: True if initialized and connected, False otherwise
100
102
  """
103
+ @property
104
+ def allowed_tools(self) -> list[str] | None:
105
+ """Return the configured allowed tools, sorted if present.
106
+
107
+ Returns:
108
+ Sorted list of allowed tool names, or None if unrestricted.
109
+ """
101
110
  def update_allowed_tools(self, allowed_tools: list[str] | None) -> bool:
102
111
  """Update the list of allowed tools for this session.
103
112
 
@@ -12,10 +12,11 @@ from collections.abc import AsyncIterator
12
12
  from enum import StrEnum
13
13
  from typing import Any, Protocol
14
14
 
15
+ import httpx
15
16
  from gllm_tools.mcp.client.config import MCPConfiguration
16
17
  from mcp.client.sse import sse_client
17
18
  from mcp.client.stdio import StdioServerParameters, stdio_client
18
- from mcp.client.streamable_http import streamablehttp_client
19
+ from mcp.client.streamable_http import streamable_http_client
19
20
 
20
21
  from aip_agents.utils.logger import get_logger
21
22
 
@@ -45,6 +46,19 @@ DEFAULT_TIMEOUT: float = 30.0
45
46
  """Default connection timeout in seconds."""
46
47
 
47
48
 
49
+ def _sanitize_headers(config: MCPConfiguration) -> dict[str, str]:
50
+ """Remove headers with None values to avoid invalid HTTP headers.
51
+
52
+ Args:
53
+ config (MCPConfiguration): Transport configuration containing optional headers.
54
+
55
+ Returns:
56
+ dict[str, str]: Filtered headers with None values removed.
57
+ """
58
+ headers = config.get("headers", {}) or {}
59
+ return {key: value for key, value in headers.items() if value is not None}
60
+
61
+
48
62
  class TransportType(StrEnum):
49
63
  """Enum for supported MCP transport types."""
50
64
 
@@ -115,7 +129,7 @@ class SSETransport(Transport):
115
129
 
116
130
  url = f"{base_url}/sse" if not base_url.endswith("/sse") else base_url
117
131
  timeout = self.config.get("timeout", DEFAULT_TIMEOUT)
118
- headers = self.config.get("headers", {})
132
+ headers = _sanitize_headers(self.config)
119
133
  logger.debug(f"Attempting SSE connection to {url} with headers: {list(headers.keys())}")
120
134
  try:
121
135
  self.ctx = sse_client(url=url, timeout=timeout, sse_read_timeout=300.0, headers=headers)
@@ -129,6 +143,27 @@ class SSETransport(Transport):
129
143
  class HTTPTransport(Transport):
130
144
  """Streamable HTTP transport handler."""
131
145
 
146
+ def __init__(self, server_name: str, config: MCPConfiguration) -> None:
147
+ """Initialize the HTTP transport.
148
+
149
+ Args:
150
+ server_name (str): Name of the MCP server.
151
+ config (MCPConfiguration): Configuration for the transport.
152
+ """
153
+ super().__init__(server_name, config)
154
+ self._http_client: httpx.AsyncClient | None = None
155
+
156
+ async def close(self) -> None:
157
+ """Clean up the transport connection and any owned HTTP client."""
158
+ await super().close()
159
+ if self._http_client:
160
+ try:
161
+ await self._http_client.aclose()
162
+ except Exception as e:
163
+ logger.warning(f"Error during HTTP client cleanup for {self.server_name}: {e}")
164
+ finally:
165
+ self._http_client = None
166
+
132
167
  async def connect(self) -> tuple[AsyncIterator[bytes], AsyncIterator[bytes], TransportContext]:
133
168
  """Connect using streamable HTTP transport.
134
169
 
@@ -147,14 +182,27 @@ class HTTPTransport(Transport):
147
182
 
148
183
  url = f"{base_url}/mcp" if not base_url.endswith("/mcp") else base_url
149
184
  timeout = self.config.get("timeout", DEFAULT_TIMEOUT)
150
- headers = self.config.get("headers", {})
185
+ headers = _sanitize_headers(self.config)
151
186
  logger.debug(f"Attempting streamable HTTP connection to {url} with headers: {list(headers.keys())}")
152
187
  try:
153
- self.ctx = streamablehttp_client(url=url, timeout=timeout, headers=headers)
188
+ http_client = httpx.AsyncClient(
189
+ timeout=httpx.Timeout(timeout),
190
+ headers=headers,
191
+ follow_redirects=True,
192
+ )
193
+ self._http_client = http_client
194
+ self.ctx = streamable_http_client(url=url, http_client=http_client)
154
195
  read_stream, write_stream, _ = await self.ctx.__aenter__()
155
196
  logger.info(f"Connected to {self.server_name} via HTTP")
156
197
  return read_stream, write_stream, self.ctx
157
198
  except Exception as e:
199
+ if self._http_client:
200
+ try:
201
+ await self._http_client.aclose()
202
+ except Exception as close_exc:
203
+ logger.warning(f"Error during HTTP client cleanup for {self.server_name}: {close_exc}")
204
+ finally:
205
+ self._http_client = None
158
206
  raise ConnectionError(f"HTTP connection failed for {self.server_name}: {str(e)}") from e
159
207
 
160
208
 
@@ -77,6 +77,15 @@ class SSETransport(Transport):
77
77
 
78
78
  class HTTPTransport(Transport):
79
79
  """Streamable HTTP transport handler."""
80
+ def __init__(self, server_name: str, config: MCPConfiguration) -> None:
81
+ """Initialize the HTTP transport.
82
+
83
+ Args:
84
+ server_name (str): Name of the MCP server.
85
+ config (MCPConfiguration): Configuration for the transport.
86
+ """
87
+ async def close(self) -> None:
88
+ """Clean up the transport connection and any owned HTTP client."""
80
89
  ctx: Incomplete
81
90
  async def connect(self) -> tuple[AsyncIterator[bytes], AsyncIterator[bytes], TransportContext]:
82
91
  """Connect using streamable HTTP transport.