agno 2.3.21__py3-none-any.whl → 2.3.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. agno/agent/agent.py +48 -2
  2. agno/agent/remote.py +234 -73
  3. agno/client/a2a/__init__.py +10 -0
  4. agno/client/a2a/client.py +554 -0
  5. agno/client/a2a/schemas.py +112 -0
  6. agno/client/a2a/utils.py +369 -0
  7. agno/db/migrations/utils.py +19 -0
  8. agno/db/migrations/v1_to_v2.py +54 -16
  9. agno/db/migrations/versions/v2_3_0.py +92 -53
  10. agno/db/mysql/async_mysql.py +5 -7
  11. agno/db/mysql/mysql.py +5 -7
  12. agno/db/mysql/schemas.py +39 -21
  13. agno/db/postgres/async_postgres.py +172 -42
  14. agno/db/postgres/postgres.py +186 -38
  15. agno/db/postgres/schemas.py +39 -21
  16. agno/db/postgres/utils.py +6 -2
  17. agno/db/singlestore/schemas.py +41 -21
  18. agno/db/singlestore/singlestore.py +14 -3
  19. agno/db/sqlite/async_sqlite.py +7 -2
  20. agno/db/sqlite/schemas.py +36 -21
  21. agno/db/sqlite/sqlite.py +3 -7
  22. agno/knowledge/chunking/document.py +3 -2
  23. agno/knowledge/chunking/markdown.py +8 -3
  24. agno/knowledge/chunking/recursive.py +2 -2
  25. agno/models/base.py +4 -0
  26. agno/models/google/gemini.py +27 -4
  27. agno/models/openai/chat.py +1 -1
  28. agno/models/openai/responses.py +14 -7
  29. agno/os/middleware/jwt.py +66 -27
  30. agno/os/routers/agents/router.py +3 -3
  31. agno/os/routers/evals/evals.py +2 -2
  32. agno/os/routers/knowledge/knowledge.py +5 -5
  33. agno/os/routers/knowledge/schemas.py +1 -1
  34. agno/os/routers/memory/memory.py +4 -4
  35. agno/os/routers/session/session.py +2 -2
  36. agno/os/routers/teams/router.py +4 -4
  37. agno/os/routers/traces/traces.py +3 -3
  38. agno/os/routers/workflows/router.py +3 -3
  39. agno/os/schema.py +1 -1
  40. agno/reasoning/deepseek.py +11 -1
  41. agno/reasoning/gemini.py +6 -2
  42. agno/reasoning/groq.py +8 -3
  43. agno/reasoning/openai.py +2 -0
  44. agno/remote/base.py +106 -9
  45. agno/skills/__init__.py +17 -0
  46. agno/skills/agent_skills.py +370 -0
  47. agno/skills/errors.py +32 -0
  48. agno/skills/loaders/__init__.py +4 -0
  49. agno/skills/loaders/base.py +27 -0
  50. agno/skills/loaders/local.py +216 -0
  51. agno/skills/skill.py +65 -0
  52. agno/skills/utils.py +107 -0
  53. agno/skills/validator.py +277 -0
  54. agno/team/remote.py +220 -60
  55. agno/team/team.py +41 -3
  56. agno/tools/brandfetch.py +27 -18
  57. agno/tools/browserbase.py +150 -13
  58. agno/tools/function.py +6 -1
  59. agno/tools/mcp/mcp.py +300 -17
  60. agno/tools/mcp/multi_mcp.py +269 -14
  61. agno/tools/toolkit.py +89 -21
  62. agno/utils/mcp.py +49 -8
  63. agno/utils/string.py +43 -1
  64. agno/workflow/condition.py +4 -2
  65. agno/workflow/loop.py +20 -1
  66. agno/workflow/remote.py +173 -33
  67. agno/workflow/router.py +4 -1
  68. agno/workflow/steps.py +4 -0
  69. agno/workflow/workflow.py +14 -0
  70. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/METADATA +13 -14
  71. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/RECORD +74 -60
  72. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/WHEEL +0 -0
  73. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/licenses/LICENSE +0 -0
  74. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/top_level.txt +0 -0
agno/tools/browserbase.py CHANGED
@@ -10,13 +10,6 @@ try:
10
10
  except ImportError:
11
11
  raise ImportError("`browserbase` not installed. Please install using `pip install browserbase`")
12
12
 
13
- try:
14
- from playwright.sync_api import sync_playwright
15
- except ImportError:
16
- raise ImportError(
17
- "`playwright` not installed. Please install using `pip install playwright` and run `playwright install`"
18
- )
19
-
20
13
 
21
14
  class BrowserbaseTools(Toolkit):
22
15
  def __init__(
@@ -36,7 +29,13 @@ class BrowserbaseTools(Toolkit):
36
29
  Args:
37
30
  api_key (str, optional): Browserbase API key.
38
31
  project_id (str, optional): Browserbase project ID.
39
- base_url (str, optional): Custom Browserbase API endpoint URL (NOT the target website URL). Only use this if you're using a self-hosted Browserbase instance or need to connect to a different region.
32
+ base_url (str, optional): Custom Browserbase API endpoint URL (NOT the target website URL).
33
+ Only use this if you're using a self-hosted Browserbase instance or need to connect to a different region.
34
+ enable_navigate_to (bool): Enable the navigate_to tool. Defaults to True.
35
+ enable_screenshot (bool): Enable the screenshot tool. Defaults to True.
36
+ enable_get_page_content (bool): Enable the get_page_content tool. Defaults to True.
37
+ enable_close_session (bool): Enable the close_session tool. Defaults to True.
38
+ all (bool): Enable all tools. Defaults to False.
40
39
  """
41
40
  self.api_key = api_key or getenv("BROWSERBASE_API_KEY")
42
41
  if not self.api_key:
@@ -59,23 +58,40 @@ class BrowserbaseTools(Toolkit):
59
58
  else:
60
59
  self.app = Browserbase(api_key=self.api_key)
61
60
 
61
+ # Sync playwright state
62
62
  self._playwright = None
63
63
  self._browser = None
64
64
  self._page = None
65
+
66
+ # Async playwright state
67
+ self._async_playwright = None
68
+ self._async_browser = None
69
+ self._async_page = None
70
+
71
+ # Shared session state
65
72
  self._session = None
66
73
  self._connect_url = None
67
74
 
75
+ # Build tools lists
76
+ # sync tools: used by agent.run() and agent.print_response()
77
+ # async tools: used by agent.arun() and agent.aprint_response()
68
78
  tools: List[Any] = []
79
+ async_tools: List[tuple] = []
80
+
69
81
  if all or enable_navigate_to:
70
82
  tools.append(self.navigate_to)
83
+ async_tools.append((self.anavigate_to, "navigate_to"))
71
84
  if all or enable_screenshot:
72
85
  tools.append(self.screenshot)
86
+ async_tools.append((self.ascreenshot, "screenshot"))
73
87
  if all or enable_get_page_content:
74
88
  tools.append(self.get_page_content)
89
+ async_tools.append((self.aget_page_content, "get_page_content"))
75
90
  if all or enable_close_session:
76
91
  tools.append(self.close_session)
92
+ async_tools.append((self.aclose_session, "close_session"))
77
93
 
78
- super().__init__(name="browserbase_tools", tools=tools, **kwargs)
94
+ super().__init__(name="browserbase_tools", tools=tools, async_tools=async_tools, **kwargs)
79
95
 
80
96
  def _ensure_session(self):
81
97
  """Ensures a session exists, creating one if needed."""
@@ -91,9 +107,16 @@ class BrowserbaseTools(Toolkit):
91
107
 
92
108
  def _initialize_browser(self, connect_url: Optional[str] = None):
93
109
  """
94
- Initialize browser connection if not already initialized.
110
+ Initialize sync browser connection if not already initialized.
95
111
  Use provided connect_url or ensure we have a session with a connect_url
96
112
  """
113
+ try:
114
+ from playwright.sync_api import sync_playwright # type: ignore[import-not-found]
115
+ except ImportError:
116
+ raise ImportError(
117
+ "`playwright` not installed. Please install using `pip install playwright` and run `playwright install`"
118
+ )
119
+
97
120
  if connect_url:
98
121
  self._connect_url = connect_url if connect_url else "" # type: ignore
99
122
  elif not self._connect_url:
@@ -107,7 +130,7 @@ class BrowserbaseTools(Toolkit):
107
130
  self._page = context.pages[0] or context.new_page() # type: ignore
108
131
 
109
132
  def _cleanup(self):
110
- """Clean up browser resources."""
133
+ """Clean up sync browser resources."""
111
134
  if self._browser:
112
135
  self._browser.close()
113
136
  self._browser = None
@@ -186,8 +209,7 @@ class BrowserbaseTools(Toolkit):
186
209
 
187
210
  def close_session(self) -> str:
188
211
  """Closes a browser session.
189
- Args:
190
- session_id (str, optional): The session ID to close. If not provided, will use the current session.
212
+
191
213
  Returns:
192
214
  JSON string with closure status
193
215
  """
@@ -207,3 +229,118 @@ class BrowserbaseTools(Toolkit):
207
229
  )
208
230
  except Exception as e:
209
231
  return json.dumps({"status": "warning", "message": f"Cleanup completed with warning: {str(e)}"})
232
+
233
+ async def _ainitialize_browser(self, connect_url: Optional[str] = None):
234
+ """
235
+ Initialize async browser connection if not already initialized.
236
+ Use provided connect_url or ensure we have a session with a connect_url
237
+ """
238
+ try:
239
+ from playwright.async_api import async_playwright # type: ignore[import-not-found]
240
+ except ImportError:
241
+ raise ImportError(
242
+ "`playwright` not installed. Please install using `pip install playwright` and run `playwright install`"
243
+ )
244
+
245
+ if connect_url:
246
+ self._connect_url = connect_url if connect_url else "" # type: ignore
247
+ elif not self._connect_url:
248
+ self._ensure_session()
249
+
250
+ if not self._async_playwright:
251
+ self._async_playwright = await async_playwright().start() # type: ignore
252
+ if self._async_playwright:
253
+ self._async_browser = await self._async_playwright.chromium.connect_over_cdp(self._connect_url)
254
+ context = self._async_browser.contexts[0] if self._async_browser else None
255
+ if context:
256
+ self._async_page = context.pages[0] if context.pages else await context.new_page()
257
+
258
+ async def _acleanup(self):
259
+ """Clean up async browser resources."""
260
+ if self._async_browser:
261
+ await self._async_browser.close()
262
+ self._async_browser = None
263
+ if self._async_playwright:
264
+ await self._async_playwright.stop()
265
+ self._async_playwright = None
266
+ self._async_page = None
267
+
268
+ async def anavigate_to(self, url: str, connect_url: Optional[str] = None) -> str:
269
+ """Navigates to a URL asynchronously.
270
+
271
+ Args:
272
+ url (str): The URL to navigate to
273
+ connect_url (str, optional): The connection URL from an existing session
274
+
275
+ Returns:
276
+ JSON string with navigation status
277
+ """
278
+ try:
279
+ await self._ainitialize_browser(connect_url)
280
+ if self._async_page:
281
+ await self._async_page.goto(url, wait_until="networkidle")
282
+ title = await self._async_page.title() if self._async_page else ""
283
+ result = {"status": "complete", "title": title, "url": url}
284
+ return json.dumps(result)
285
+ except Exception as e:
286
+ await self._acleanup()
287
+ raise e
288
+
289
+ async def ascreenshot(self, path: str, full_page: bool = True, connect_url: Optional[str] = None) -> str:
290
+ """Takes a screenshot of the current page asynchronously.
291
+
292
+ Args:
293
+ path (str): Where to save the screenshot
294
+ full_page (bool): Whether to capture the full page
295
+ connect_url (str, optional): The connection URL from an existing session
296
+
297
+ Returns:
298
+ JSON string confirming screenshot was saved
299
+ """
300
+ try:
301
+ await self._ainitialize_browser(connect_url)
302
+ if self._async_page:
303
+ await self._async_page.screenshot(path=path, full_page=full_page)
304
+ return json.dumps({"status": "success", "path": path})
305
+ except Exception as e:
306
+ await self._acleanup()
307
+ raise e
308
+
309
+ async def aget_page_content(self, connect_url: Optional[str] = None) -> str:
310
+ """Gets the HTML content of the current page asynchronously.
311
+
312
+ Args:
313
+ connect_url (str, optional): The connection URL from an existing session
314
+
315
+ Returns:
316
+ The page HTML content
317
+ """
318
+ try:
319
+ await self._ainitialize_browser(connect_url)
320
+ return await self._async_page.content() if self._async_page else ""
321
+ except Exception as e:
322
+ await self._acleanup()
323
+ raise e
324
+
325
+ async def aclose_session(self) -> str:
326
+ """Closes a browser session asynchronously.
327
+
328
+ Returns:
329
+ JSON string with closure status
330
+ """
331
+ try:
332
+ # First cleanup our local browser resources
333
+ await self._acleanup()
334
+
335
+ # Reset session state
336
+ self._session = None
337
+ self._connect_url = None
338
+
339
+ return json.dumps(
340
+ {
341
+ "status": "closed",
342
+ "message": "Browser resources cleaned up. Session will auto-close if not already closed.",
343
+ }
344
+ )
345
+ except Exception as e:
346
+ return json.dumps({"status": "warning", "message": f"Cleanup completed with warning: {str(e)}"})
agno/tools/function.py CHANGED
@@ -1139,10 +1139,15 @@ class FunctionCall(BaseModel):
1139
1139
  else:
1140
1140
  result = self.function.entrypoint(**entrypoint_args, **self.arguments)
1141
1141
 
1142
+ # Handle both sync and async entrypoints
1142
1143
  if isasyncgenfunction(self.function.entrypoint):
1143
1144
  self.result = result # Store async generator directly
1145
+ elif iscoroutinefunction(self.function.entrypoint):
1146
+ self.result = await result # Await coroutine result
1147
+ elif isgeneratorfunction(self.function.entrypoint):
1148
+ self.result = result # Store sync generator directly
1144
1149
  else:
1145
- self.result = await result
1150
+ self.result = result # Sync function, result is already computed
1146
1151
 
1147
1152
  # Only cache if not a generator
1148
1153
  if self.function.cache_results and not (isgenerator(self.result) or isasyncgen(self.result)):
agno/tools/mcp/mcp.py CHANGED
@@ -1,14 +1,21 @@
1
+ import inspect
2
+ import time
1
3
  import weakref
2
4
  from dataclasses import asdict
3
5
  from datetime import timedelta
4
- from typing import Any, Literal, Optional, Union
6
+ from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Tuple, Union
5
7
 
6
8
  from agno.tools import Toolkit
7
9
  from agno.tools.function import Function
8
10
  from agno.tools.mcp.params import SSEClientParams, StreamableHTTPClientParams
9
- from agno.utils.log import log_debug, log_error, log_info
11
+ from agno.utils.log import log_debug, log_error, log_info, log_warning
10
12
  from agno.utils.mcp import get_entrypoint_for_tool, prepare_command
11
13
 
14
+ if TYPE_CHECKING:
15
+ from agno.agent import Agent
16
+ from agno.run import RunContext
17
+ from agno.team.team import Team
18
+
12
19
  try:
13
20
  from mcp import ClientSession, StdioServerParameters
14
21
  from mcp.client.sse import sse_client
@@ -35,7 +42,7 @@ class MCPTools(Toolkit):
35
42
  *,
36
43
  url: Optional[str] = None,
37
44
  env: Optional[dict[str, str]] = None,
38
- transport: Literal["stdio", "sse", "streamable-http"] = "stdio",
45
+ transport: Optional[Literal["stdio", "sse", "streamable-http"]] = None,
39
46
  server_params: Optional[Union[StdioServerParameters, SSEClientParams, StreamableHTTPClientParams]] = None,
40
47
  session: Optional[ClientSession] = None,
41
48
  timeout_seconds: int = 10,
@@ -44,6 +51,7 @@ class MCPTools(Toolkit):
44
51
  exclude_tools: Optional[list[str]] = None,
45
52
  refresh_connection: bool = False,
46
53
  tool_name_prefix: Optional[str] = None,
54
+ header_provider: Optional[Callable[..., dict[str, Any]]] = None,
47
55
  **kwargs,
48
56
  ):
49
57
  """
@@ -59,11 +67,24 @@ class MCPTools(Toolkit):
59
67
  timeout_seconds: Read timeout in seconds for the MCP client
60
68
  include_tools: Optional list of tool names to include (if None, includes all)
61
69
  exclude_tools: Optional list of tool names to exclude (if None, excludes none)
62
- transport: The transport protocol to use, either "stdio" or "sse" or "streamable-http"
70
+ transport: The transport protocol to use, either "stdio" or "sse" or "streamable-http".
71
+ Defaults to "streamable-http" when url is provided, otherwise defaults to "stdio".
63
72
  refresh_connection: If True, the connection and tools will be refreshed on each run
73
+ header_provider: Optional function to generate dynamic HTTP headers.
74
+ Only relevant with HTTP transports (Streamable HTTP or SSE).
75
+ Creates a new session per agent run with dynamic headers merged into connection config.
64
76
  """
65
77
  super().__init__(name="MCPTools", **kwargs)
66
78
 
79
+ if url is not None:
80
+ if transport is None:
81
+ transport = "streamable-http"
82
+ elif transport == "stdio":
83
+ log_warning(
84
+ "Transport cannot be 'stdio' when url is provided. Setting transport to 'streamable-http' instead."
85
+ )
86
+ transport = "streamable-http"
87
+
67
88
  if transport == "sse":
68
89
  log_info("SSE as a standalone transport is deprecated. Please use Streamable HTTP instead.")
69
90
 
@@ -104,12 +125,17 @@ class MCPTools(Toolkit):
104
125
  "If using the streamable-http transport, server_params must be an instance of StreamableHTTPClientParams."
105
126
  )
106
127
 
128
+ self.transport = transport
129
+
130
+ self.header_provider = None
131
+ if self._is_valid_header_provider(header_provider):
132
+ self.header_provider = header_provider
133
+
107
134
  self.timeout_seconds = timeout_seconds
108
135
  self.session: Optional[ClientSession] = session
109
136
  self.server_params: Optional[Union[StdioServerParameters, SSEClientParams, StreamableHTTPClientParams]] = (
110
137
  server_params
111
138
  )
112
- self.transport = transport
113
139
  self.url = url
114
140
 
115
141
  # Merge provided env with system env
@@ -135,6 +161,12 @@ class MCPTools(Toolkit):
135
161
  self._context = None
136
162
  self._session_context = None
137
163
 
164
+ # Session management for per-agent-run sessions with dynamic headers
165
+ # Maps run_id to (session, timestamp) for TTL-based cleanup
166
+ self._run_sessions: dict[str, Tuple[ClientSession, float]] = {}
167
+ self._run_session_contexts: dict[str, Any] = {} # Maps run_id to session context managers
168
+ self._session_ttl_seconds: float = 300.0 # 5 minutes TTL for MCP sessions
169
+
138
170
  def cleanup():
139
171
  """Cancel active connections"""
140
172
  if self._connection_task and not self._connection_task.done():
@@ -147,6 +179,234 @@ class MCPTools(Toolkit):
147
179
  def initialized(self) -> bool:
148
180
  return self._initialized
149
181
 
182
+ def _is_valid_header_provider(self, header_provider: Optional[Callable[..., dict[str, Any]]]) -> bool:
183
+ """Logic to validate a given header_provider function.
184
+
185
+ Args:
186
+ header_provider: The header_provider function to validate
187
+
188
+ Raises:
189
+ Exception: If there is an error validating the header_provider.
190
+ """
191
+ if not header_provider:
192
+ return False
193
+
194
+ if self.transport not in ["sse", "streamable-http"]:
195
+ log_warning(
196
+ f"header_provider specified but transport is '{self.transport}'. "
197
+ "Dynamic headers only work with 'sse' or 'streamable-http' transports. "
198
+ "The header_provider logic will be ignored."
199
+ )
200
+ return False
201
+
202
+ log_debug("Dynamic header support enabled for MCP tools")
203
+ return True
204
+
205
+ def _call_header_provider(
206
+ self,
207
+ run_context: Optional["RunContext"] = None,
208
+ agent: Optional["Agent"] = None,
209
+ team: Optional["Team"] = None,
210
+ ) -> dict[str, Any]:
211
+ """Call the header_provider with run_context, agent, and/or team based on its signature.
212
+
213
+ Args:
214
+ run_context: The RunContext for the current agent run
215
+ agent: The Agent instance (if running within an agent)
216
+ team: The Team instance (if running within a team)
217
+
218
+ Returns:
219
+ dict[str, Any]: The headers returned by the header_provider
220
+ """
221
+ header_provider = getattr(self, "header_provider", None)
222
+ if header_provider is None:
223
+ return {}
224
+
225
+ try:
226
+ sig = inspect.signature(header_provider)
227
+ param_names = set(sig.parameters.keys())
228
+
229
+ # Build kwargs based on what the function accepts
230
+ call_kwargs: dict[str, Any] = {}
231
+
232
+ if "run_context" in param_names:
233
+ call_kwargs["run_context"] = run_context
234
+ if "agent" in param_names:
235
+ call_kwargs["agent"] = agent
236
+ if "team" in param_names:
237
+ call_kwargs["team"] = team
238
+
239
+ # Check if function accepts **kwargs (VAR_KEYWORD)
240
+ has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
241
+
242
+ if has_var_keyword:
243
+ # Pass all available context to **kwargs
244
+ call_kwargs = {"run_context": run_context, "agent": agent, "team": team}
245
+ return header_provider(**call_kwargs)
246
+ elif call_kwargs:
247
+ return header_provider(**call_kwargs)
248
+ else:
249
+ # Function takes no recognized parameters - check for positional
250
+ positional_params = [
251
+ p
252
+ for p in sig.parameters.values()
253
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
254
+ ]
255
+ if positional_params:
256
+ # Legacy support: pass run_context as first positional arg
257
+ return header_provider(run_context)
258
+ else:
259
+ # Function takes no parameters
260
+ return header_provider()
261
+ except Exception as e:
262
+ log_warning(f"Error calling header_provider: {e}")
263
+ return {}
264
+
265
+ async def _cleanup_stale_sessions(self) -> None:
266
+ """Clean up sessions older than TTL to prevent memory leaks."""
267
+ if not self._run_sessions:
268
+ return
269
+
270
+ now = time.time()
271
+ stale_run_ids = [
272
+ run_id
273
+ for run_id, (_, created_at) in self._run_sessions.items()
274
+ if now - created_at > self._session_ttl_seconds
275
+ ]
276
+
277
+ for run_id in stale_run_ids:
278
+ log_debug(f"Cleaning up stale MCP sessions for run_id={run_id}")
279
+ await self.cleanup_run_session(run_id)
280
+
281
+ async def get_session_for_run(
282
+ self,
283
+ run_context: Optional["RunContext"] = None,
284
+ agent: Optional["Agent"] = None,
285
+ team: Optional["Team"] = None,
286
+ ) -> ClientSession:
287
+ """
288
+ Get or create a session for the given run context.
289
+
290
+ If header_provider is set and run_context is provided, creates a new session
291
+ with dynamic headers merged into the connection config.
292
+
293
+ Args:
294
+ run_context: The RunContext for the current agent run
295
+ agent: The Agent instance (if running within an agent)
296
+ team: The Team instance (if running within a team)
297
+
298
+ Returns:
299
+ ClientSession for the run
300
+ """
301
+ # If no header_provider or no run_context, use the default session
302
+ if not self.header_provider or not run_context:
303
+ if self.session is None:
304
+ raise ValueError("Session is not initialized")
305
+ return self.session
306
+
307
+ # Lazy cleanup of stale sessions
308
+ await self._cleanup_stale_sessions()
309
+
310
+ # Check if we already have a session for this run
311
+ run_id = run_context.run_id
312
+ if run_id in self._run_sessions:
313
+ session, _ = self._run_sessions[run_id]
314
+ return session
315
+
316
+ # Create a new session with dynamic headers for this run
317
+ log_debug(f"Creating new session for run_id={run_id} with dynamic headers")
318
+
319
+ # Generate dynamic headers from the provider
320
+ dynamic_headers = self._call_header_provider(run_context=run_context, agent=agent, team=team)
321
+
322
+ # Create new session with merged headers based on transport type
323
+ if self.transport == "sse":
324
+ sse_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
325
+ if "url" not in sse_params:
326
+ sse_params["url"] = self.url
327
+
328
+ # Merge dynamic headers into existing headers
329
+ existing_headers = sse_params.get("headers", {})
330
+ sse_params["headers"] = {**existing_headers, **dynamic_headers}
331
+
332
+ context = sse_client(**sse_params) # type: ignore
333
+ client_timeout = min(self.timeout_seconds, sse_params.get("timeout", self.timeout_seconds))
334
+
335
+ elif self.transport == "streamable-http":
336
+ streamable_http_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
337
+ if "url" not in streamable_http_params:
338
+ streamable_http_params["url"] = self.url
339
+
340
+ # Merge dynamic headers into existing headers
341
+ existing_headers = streamable_http_params.get("headers", {})
342
+ streamable_http_params["headers"] = {**existing_headers, **dynamic_headers}
343
+
344
+ context = streamablehttp_client(**streamable_http_params) # type: ignore
345
+ params_timeout = streamable_http_params.get("timeout", self.timeout_seconds)
346
+ if isinstance(params_timeout, timedelta):
347
+ params_timeout = int(params_timeout.total_seconds())
348
+ client_timeout = min(self.timeout_seconds, params_timeout)
349
+ else:
350
+ # stdio doesn't support headers, fall back to default session
351
+ log_warning(f"Cannot use dynamic headers with {self.transport} transport, using default session")
352
+ if self.session is None:
353
+ raise ValueError("Session is not initialized")
354
+ return self.session
355
+
356
+ # Enter the context and create session
357
+ session_params = await context.__aenter__() # type: ignore
358
+ read, write = session_params[0:2]
359
+
360
+ session_context = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=client_timeout)) # type: ignore
361
+ session = await session_context.__aenter__() # type: ignore
362
+
363
+ # Initialize the session
364
+ await session.initialize()
365
+
366
+ # Store the session with timestamp and context for cleanup
367
+ self._run_sessions[run_id] = (session, time.time())
368
+ self._run_session_contexts[run_id] = (context, session_context)
369
+
370
+ return session
371
+
372
+ async def cleanup_run_session(self, run_id: str) -> None:
373
+ """
374
+ Clean up the session for a specific run.
375
+
376
+ Note: Cleanup may fail due to async context manager limitations when
377
+ contexts are entered/exited across different tasks. Errors are logged
378
+ but not raised.
379
+ """
380
+ if run_id not in self._run_sessions:
381
+ return
382
+
383
+ try:
384
+ # Get the context managers
385
+ context, session_context = self._run_session_contexts.get(run_id, (None, None))
386
+
387
+ # Try to clean up session context
388
+ # Silently ignore cleanup errors - these are harmless
389
+ if session_context is not None:
390
+ try:
391
+ await session_context.__aexit__(None, None, None)
392
+ except (RuntimeError, Exception):
393
+ pass # Silently ignore
394
+
395
+ # Try to clean up transport context
396
+ if context is not None:
397
+ try:
398
+ await context.__aexit__(None, None, None)
399
+ except (RuntimeError, Exception):
400
+ pass # Silently ignore
401
+
402
+ # Remove from tracking regardless of cleanup success
403
+ # The connections will be cleaned up by garbage collection
404
+ del self._run_sessions[run_id]
405
+ del self._run_session_contexts[run_id]
406
+
407
+ except Exception:
408
+ pass # Silently ignore all cleanup errors
409
+
150
410
  async def is_alive(self) -> bool:
151
411
  if self.session is None:
152
412
  return False
@@ -227,17 +487,36 @@ class MCPTools(Toolkit):
227
487
  if not self._initialized:
228
488
  return
229
489
 
230
- try:
231
- if self._session_context is not None:
232
- await self._session_context.__aexit__(None, None, None)
233
- self.session = None
234
- self._session_context = None
235
-
236
- if self._context is not None:
237
- await self._context.__aexit__(None, None, None)
238
- self._context = None
239
- except (RuntimeError, BaseException) as e:
240
- log_error(f"Failed to close MCP connection: {e}")
490
+ import warnings
491
+
492
+ # Suppress async generator cleanup warnings
493
+ with warnings.catch_warnings():
494
+ warnings.filterwarnings("ignore", category=RuntimeWarning, message=".*async_generator.*")
495
+ warnings.filterwarnings("ignore", message=".*cancel scope.*")
496
+
497
+ try:
498
+ # Clean up all per-run sessions first
499
+ run_ids = list(self._run_sessions.keys())
500
+ for run_id in run_ids:
501
+ await self.cleanup_run_session(run_id)
502
+
503
+ # Clean up the main session
504
+ if self._session_context is not None:
505
+ try:
506
+ await self._session_context.__aexit__(None, None, None)
507
+ except (RuntimeError, Exception):
508
+ pass # Silently ignore cleanup errors
509
+ self.session = None
510
+ self._session_context = None
511
+
512
+ if self._context is not None:
513
+ try:
514
+ await self._context.__aexit__(None, None, None)
515
+ except (RuntimeError, Exception):
516
+ pass # Silently ignore cleanup errors
517
+ self._context = None
518
+ except (RuntimeError, BaseException):
519
+ pass # Silently ignore all cleanup errors
241
520
 
242
521
  self._initialized = False
243
522
 
@@ -290,7 +569,11 @@ class MCPTools(Toolkit):
290
569
  for tool in filtered_tools:
291
570
  try:
292
571
  # Get an entrypoint for the tool
293
- entrypoint = get_entrypoint_for_tool(tool, self.session) # type: ignore
572
+ entrypoint = get_entrypoint_for_tool(
573
+ tool=tool,
574
+ session=self.session, # type: ignore
575
+ mcp_tools_instance=self,
576
+ )
294
577
  # Create a Function for the tool
295
578
  f = Function(
296
579
  name=tool_name_prefix + tool.name,