agno 1.7.6__py3-none-any.whl → 1.7.7__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.
agno/tools/mcp.py CHANGED
@@ -1,3 +1,5 @@
1
+ import asyncio
2
+ import weakref
1
3
  from contextlib import AsyncExitStack
2
4
  from dataclasses import asdict, dataclass
3
5
  from datetime import timedelta
@@ -18,6 +20,28 @@ except (ImportError, ModuleNotFoundError):
18
20
  raise ImportError("`mcp` not installed. Please install using `pip install mcp`")
19
21
 
20
22
 
23
+ def _prepare_command(command: str) -> list[str]:
24
+ """Sanitize a command and split it into parts before using it to run a MCP server."""
25
+ from shlex import split
26
+
27
+ # Block dangerous characters
28
+ if any(char in command for char in ["&", "|", ";", "`", "$", "(", ")"]):
29
+ raise ValueError("MCP command can't contain shell metacharacters")
30
+
31
+ parts = split(command)
32
+ if not parts:
33
+ raise ValueError("MCP command can't be empty")
34
+
35
+ # Only allow specific executables
36
+ ALLOWED_COMMANDS = {"python", "python3", "node", "npm", "npx"}
37
+
38
+ executable = parts[0].split("/")[-1]
39
+ if executable not in ALLOWED_COMMANDS:
40
+ raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
41
+
42
+ return parts
43
+
44
+
21
45
  @dataclass
22
46
  class SSEClientParams:
23
47
  """Parameters for SSE client connection."""
@@ -138,11 +162,7 @@ class MCPTools(Toolkit):
138
162
  env = get_default_environment()
139
163
 
140
164
  if command is not None and transport not in ["sse", "streamable-http"]:
141
- from shlex import split
142
-
143
- parts = split(command)
144
- if not parts:
145
- raise ValueError("Empty command string")
165
+ parts = _prepare_command(command)
146
166
  cmd = parts[0]
147
167
  arguments = parts[1:] if len(parts) > 1 else []
148
168
  self.server_params = StdioServerParameters(command=cmd, args=arguments, env=env)
@@ -151,23 +171,49 @@ class MCPTools(Toolkit):
151
171
  self._context = None
152
172
  self._session_context = None
153
173
  self._initialized = False
174
+ self._connection_task = None
154
175
 
155
- async def __aenter__(self) -> "MCPTools":
156
- """Enter the async context manager."""
176
+ def cleanup():
177
+ """Cancel active connections"""
178
+ if self._connection_task and not self._connection_task.done():
179
+ self._connection_task.cancel()
180
+
181
+ # Setup cleanup logic before the instance is garbage collected
182
+ self._cleanup_finalizer = weakref.finalize(self, cleanup)
183
+
184
+ async def connect(self):
185
+ """Initialize a MCPTools instance and connect to the contextual MCP server"""
186
+ if self._initialized:
187
+ return
188
+
189
+ await self._connect()
190
+
191
+ def _start_connection(self):
192
+ """Ensure there are no active connections and setup a new one"""
193
+ if self._connection_task is None or self._connection_task.done():
194
+ self._connection_task = asyncio.create_task(self._connect()) # type: ignore
195
+
196
+ async def _connect(self) -> None:
197
+ """Connects to the MCP server and initializes the tools"""
198
+ if self._initialized:
199
+ return
157
200
 
158
201
  if self.session is not None:
159
- # Already has a session, just initialize
160
- if not self._initialized:
161
- await self.initialize()
162
- return self
202
+ await self.initialize()
203
+ return
204
+
205
+ if not hasattr(self, "_active_contexts"):
206
+ self._active_contexts: list[Any] = []
163
207
 
164
- # Create a new session using stdio_client, sse_client or streamablehttp_client based on transport
208
+ # Create a new studio session
165
209
  if self.transport == "sse":
166
210
  sse_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
167
211
  if "url" not in sse_params:
168
212
  sse_params["url"] = self.url
169
213
  self._context = sse_client(**sse_params) # type: ignore
170
214
  client_timeout = min(self.timeout_seconds, sse_params.get("timeout", self.timeout_seconds))
215
+
216
+ # Create a new streamable HTTP session
171
217
  elif self.transport == "streamable-http":
172
218
  streamable_http_params = asdict(self.server_params) if self.server_params is not None else {} # type: ignore
173
219
  if "url" not in streamable_http_params:
@@ -177,6 +223,7 @@ class MCPTools(Toolkit):
177
223
  if isinstance(params_timeout, timedelta):
178
224
  params_timeout = int(params_timeout.total_seconds())
179
225
  client_timeout = min(self.timeout_seconds, params_timeout)
226
+
180
227
  else:
181
228
  if self.server_params is None:
182
229
  raise ValueError("server_params must be provided when using stdio transport.")
@@ -184,24 +231,42 @@ class MCPTools(Toolkit):
184
231
  client_timeout = self.timeout_seconds
185
232
 
186
233
  session_params = await self._context.__aenter__() # type: ignore
234
+ self._active_contexts.append(self._context)
187
235
  read, write = session_params[0:2]
188
236
 
189
237
  self._session_context = ClientSession(read, write, read_timeout_seconds=timedelta(seconds=client_timeout)) # type: ignore
190
238
  self.session = await self._session_context.__aenter__() # type: ignore
239
+ self._active_contexts.append(self._session_context)
191
240
 
192
241
  # Initialize with the new session
193
242
  await self.initialize()
243
+
244
+ async def close(self) -> None:
245
+ """Close the MCP connection and clean up resources"""
246
+ if self._session_context is not None:
247
+ await self._session_context.__aexit__(None, None, None)
248
+ self.session = None
249
+ self._session_context = None
250
+
251
+ if self._context is not None:
252
+ await self._context.__aexit__(None, None, None)
253
+ self._context = None
254
+
255
+ self._initialized = False
256
+
257
+ async def __aenter__(self) -> "MCPTools":
258
+ await self._connect()
194
259
  return self
195
260
 
196
- async def __aexit__(self, exc_type, exc_val, exc_tb):
261
+ async def __aexit__(self, _exc_type, _exc_val, _exc_tb):
197
262
  """Exit the async context manager."""
198
263
  if self._session_context is not None:
199
- await self._session_context.__aexit__(exc_type, exc_val, exc_tb)
264
+ await self._session_context.__aexit__(_exc_type, _exc_val, _exc_tb)
200
265
  self.session = None
201
266
  self._session_context = None
202
267
 
203
268
  if self._context is not None:
204
- await self._context.__aexit__(exc_type, exc_val, exc_tb)
269
+ await self._context.__aexit__(_exc_type, _exc_val, _exc_tb)
205
270
  self._context = None
206
271
 
207
272
  self._initialized = False
@@ -213,7 +278,7 @@ class MCPTools(Toolkit):
213
278
 
214
279
  try:
215
280
  if self.session is None:
216
- raise ValueError("Session is not available. Use as context manager or provide a session.")
281
+ raise ValueError("Failed to establish session connection")
217
282
 
218
283
  # Initialize the session if not already initialized
219
284
  await self.session.initialize()
@@ -343,12 +408,8 @@ class MultiMCPTools(Toolkit):
343
408
  env = get_default_environment()
344
409
 
345
410
  if commands is not None:
346
- from shlex import split
347
-
348
411
  for command in commands:
349
- parts = split(command)
350
- if not parts:
351
- raise ValueError("Empty command string")
412
+ parts = _prepare_command(command)
352
413
  cmd = parts[0]
353
414
  arguments = parts[1:] if len(parts) > 1 else []
354
415
  self.server_params_list.append(StdioServerParameters(command=cmd, args=arguments, env=env))
@@ -365,28 +426,92 @@ class MultiMCPTools(Toolkit):
365
426
  self.server_params_list.append(StreamableHTTPClientParams(url=url))
366
427
 
367
428
  self._async_exit_stack = AsyncExitStack()
429
+ self._initialized = False
430
+ self._connection_task = None
431
+ self._active_contexts: list[Any] = []
432
+ self._used_as_context_manager = False
368
433
 
369
434
  self._client = client
370
435
 
371
- async def __aenter__(self) -> "MultiMCPTools":
372
- """Enter the async context manager."""
436
+ def cleanup():
437
+ """Cancel active connections"""
438
+ if self._connection_task and not self._connection_task.done():
439
+ self._connection_task.cancel()
440
+
441
+ # Setup cleanup logic before the instance is garbage collected
442
+ self._cleanup_finalizer = weakref.finalize(self, cleanup)
443
+
444
+ async def connect(self):
445
+ """Initialize a MultiMCPTools instance and connect to the MCP servers"""
446
+ if self._initialized:
447
+ return
448
+
449
+ await self._connect()
450
+
451
+ @classmethod
452
+ async def create_and_connect(
453
+ cls,
454
+ commands: Optional[List[str]] = None,
455
+ urls: Optional[List[str]] = None,
456
+ urls_transports: Optional[List[Literal["sse", "streamable-http"]]] = None,
457
+ *,
458
+ env: Optional[dict[str, str]] = None,
459
+ server_params_list: Optional[
460
+ List[Union[SSEClientParams, StdioServerParameters, StreamableHTTPClientParams]]
461
+ ] = None,
462
+ timeout_seconds: int = 5,
463
+ client=None,
464
+ include_tools: Optional[list[str]] = None,
465
+ exclude_tools: Optional[list[str]] = None,
466
+ **kwargs,
467
+ ) -> "MultiMCPTools":
468
+ """Initialize a MultiMCPTools instance and connect to the MCP servers"""
469
+ instance = cls(
470
+ commands=commands,
471
+ urls=urls,
472
+ urls_transports=urls_transports,
473
+ env=env,
474
+ server_params_list=server_params_list,
475
+ timeout_seconds=timeout_seconds,
476
+ client=client,
477
+ include_tools=include_tools,
478
+ exclude_tools=exclude_tools,
479
+ **kwargs,
480
+ )
481
+
482
+ await instance._connect()
483
+ return instance
484
+
485
+ def _start_connection(self):
486
+ """Ensure there are no active connections and setup a new one"""
487
+ if self._connection_task is None or self._connection_task.done():
488
+ self._connection_task = asyncio.create_task(self._connect()) # type: ignore
489
+
490
+ async def _connect(self) -> None:
491
+ """Connects to the MCP servers and initializes the tools"""
492
+ if self._initialized:
493
+ return
373
494
 
374
495
  for server_params in self.server_params_list:
375
496
  # Handle stdio connections
376
497
  if isinstance(server_params, StdioServerParameters):
377
498
  stdio_transport = await self._async_exit_stack.enter_async_context(stdio_client(server_params))
499
+ self._active_contexts.append(stdio_transport)
378
500
  read, write = stdio_transport
379
501
  session = await self._async_exit_stack.enter_async_context(
380
502
  ClientSession(read, write, read_timeout_seconds=timedelta(seconds=self.timeout_seconds))
381
503
  )
504
+ self._active_contexts.append(session)
382
505
  await self.initialize(session)
383
506
  # Handle SSE connections
384
507
  elif isinstance(server_params, SSEClientParams):
385
508
  client_connection = await self._async_exit_stack.enter_async_context(
386
509
  sse_client(**asdict(server_params))
387
510
  )
511
+ self._active_contexts.append(client_connection)
388
512
  read, write = client_connection
389
513
  session = await self._async_exit_stack.enter_async_context(ClientSession(read, write))
514
+ self._active_contexts.append(session)
390
515
  await self.initialize(session)
391
516
 
392
517
  # Handle Streamable HTTP connections
@@ -394,10 +519,22 @@ class MultiMCPTools(Toolkit):
394
519
  client_connection = await self._async_exit_stack.enter_async_context(
395
520
  streamablehttp_client(**asdict(server_params))
396
521
  )
522
+ self._active_contexts.append(client_connection)
397
523
  read, write = client_connection[0:2]
398
524
  session = await self._async_exit_stack.enter_async_context(ClientSession(read, write))
525
+ self._active_contexts.append(session)
399
526
  await self.initialize(session)
400
527
 
528
+ self._initialized = True
529
+
530
+ async def close(self) -> None:
531
+ """Close the MCP connections and clean up resources"""
532
+ await self._async_exit_stack.aclose()
533
+ self._initialized = False
534
+
535
+ async def __aenter__(self) -> "MultiMCPTools":
536
+ """Enter the async context manager."""
537
+ await self._connect()
401
538
  return self
402
539
 
403
540
  async def __aexit__(
@@ -0,0 +1,186 @@
1
+ import os
2
+ from os import getenv
3
+ from textwrap import dedent
4
+ from typing import Optional
5
+
6
+ from agno.tools import Toolkit
7
+ from agno.utils.log import log_debug, log_error
8
+
9
+ try:
10
+ from openai import OpenAI
11
+ except ImportError:
12
+ raise ImportError("`openai` not installed. Please install using `pip install openai`")
13
+
14
+
15
+ class MorphTools(Toolkit):
16
+ """Tools for interacting with Morph's Fast Apply API for code editing"""
17
+
18
+ def __init__(
19
+ self,
20
+ api_key: Optional[str] = None,
21
+ base_url: str = "https://api.morphllm.com/v1",
22
+ instructions: Optional[str] = None,
23
+ add_instructions: bool = True,
24
+ model: str = "morph-v3-large",
25
+ **kwargs,
26
+ ):
27
+ """Initialize Morph Fast Apply tools.
28
+
29
+ Args:
30
+ api_key: Morph API key. If not provided, will look for MORPH_API_KEY environment variable.
31
+ base_url: The base URL for the Morph API.
32
+ model: The Morph model to use. Options:
33
+ - "morph-v3-fast" (4500+ tok/sec, 96% accuracy)
34
+ - "morph-v3-large" (2500+ tok/sec, 98% accuracy)
35
+ - "auto" (automatic selection)
36
+ **kwargs: Additional arguments to pass to Toolkit.
37
+ """
38
+ # Set up instructions
39
+ if instructions is None:
40
+ self.instructions = self.DEFAULT_INSTRUCTIONS
41
+ else:
42
+ self.instructions = instructions
43
+
44
+ super().__init__(
45
+ name="morph_tools",
46
+ tools=[self.edit_file],
47
+ instructions=self.instructions,
48
+ add_instructions=add_instructions,
49
+ **kwargs,
50
+ )
51
+
52
+ self.api_key = api_key or getenv("MORPH_API_KEY")
53
+ if not self.api_key:
54
+ raise ValueError("MORPH_API_KEY not set. Please set the MORPH_API_KEY environment variable.")
55
+
56
+ self.base_url = base_url
57
+ self.model = model
58
+ self._morph_client: Optional[OpenAI] = None
59
+
60
+ def _get_client(self):
61
+ """Get or create the Morph OpenAI client."""
62
+ if self._morph_client is None:
63
+ self._morph_client = OpenAI(
64
+ api_key=self.api_key,
65
+ base_url=self.base_url,
66
+ )
67
+ return self._morph_client
68
+
69
+ def edit_file(
70
+ self,
71
+ target_file: str,
72
+ instructions: str,
73
+ code_edit: str,
74
+ original_code: Optional[str] = None,
75
+ ) -> str:
76
+ """
77
+ Apply code edits to a target file using Morph's Fast Apply API.
78
+
79
+ This function reads the specified file, sends its content along with
80
+ editing instructions and code edits to Morph's API, and writes the
81
+ resulting code back to the file. A backup of the original file is
82
+ created before writing changes.
83
+
84
+ Args:
85
+ target_file (str): Path to the file to be edited.
86
+ instructions (str): High-level instructions describing the intended change.
87
+ code_edit (str): Specific code edit or change to apply.
88
+ original_code (Optional[str], optional): Original content of the file.
89
+ If not provided, the function reads from target_file.
90
+
91
+ Returns:
92
+ str: Result message indicating success or failure, and details about
93
+ the backup and any errors encountered.
94
+ """
95
+ try:
96
+ # Always read the actual file content for backup purposes
97
+ actual_file_content = None
98
+ if os.path.exists(target_file):
99
+ try:
100
+ with open(target_file, "r", encoding="utf-8") as f:
101
+ actual_file_content = f.read()
102
+ except Exception as e:
103
+ return f"Error reading {target_file} for backup: {e}"
104
+ else:
105
+ return f"Error: File {target_file} does not exist."
106
+
107
+ # Use provided original_code or fall back to file content
108
+ code_to_process = original_code if original_code is not None else actual_file_content
109
+
110
+ # Format the message for Morph's Fast Apply API
111
+ content = f"<instruction>{instructions}</instruction>\n<code>{code_to_process}</code>\n<update>{code_edit}</update>"
112
+
113
+ log_debug(f"Input to Morph: {content}")
114
+
115
+ client = self._get_client()
116
+
117
+ response = client.chat.completions.create(
118
+ model=self.model,
119
+ messages=[
120
+ {
121
+ "role": "user",
122
+ "content": content,
123
+ }
124
+ ],
125
+ )
126
+
127
+ if response.choices and response.choices[0].message.content:
128
+ final_code = response.choices[0].message.content
129
+
130
+ try:
131
+ backup_file = f"{target_file}.backup"
132
+ with open(backup_file, "w", encoding="utf-8") as f:
133
+ f.write(actual_file_content)
134
+
135
+ # Write the new code
136
+ with open(target_file, "w", encoding="utf-8") as f:
137
+ f.write(final_code)
138
+ return f"Successfully applied edit to {target_file} using Morph Fast Apply! Original content backed up as {backup_file}"
139
+
140
+ except Exception as e:
141
+ return f"Successfully applied edit but failed to write back to {target_file}: {e}"
142
+
143
+ else:
144
+ return f"Failed to apply edit to {target_file}: No response from Morph API"
145
+
146
+ except Exception as e:
147
+ log_error(f"Failed to apply edit using Morph Fast Apply: {e}")
148
+ return f"Failed to apply edit to {target_file}: {e}"
149
+
150
+ DEFAULT_INSTRUCTIONS = dedent("""\
151
+ You have access to Morph Fast Apply for ultra-fast code editing with 98% accuracy at 2500+ tokens/second.
152
+
153
+ ## How to use the edit_file tool:
154
+
155
+ **Critical Requirements:**
156
+ 1. **Instructions Parameter**: Generate clear first-person instructions describing what you're doing
157
+ - Example: "I am adding type hints to all functions and methods"
158
+ - Example: "I am refactoring the error handling to use try-catch blocks"
159
+
160
+ 2. **Code Edit Parameter**: Specify ONLY the lines you want to change
161
+ - Use `# ... existing code ...` (or `// ... existing code ...` for JS/Java) to represent unchanged sections
162
+ - NEVER write out unchanged code in the code_edit parameter
163
+ - Include sufficient context around changes to resolve ambiguity
164
+
165
+ 3. **Single Edit Call**: Make ALL edits to a file in a single edit_file call. The apply model can handle many distinct edits at once.
166
+
167
+ **Example Format:**
168
+ ```
169
+ # ... existing code ...
170
+ def add(a: int, b: int) -> int:
171
+ \"\"\"Add two numbers together.\"\"\"
172
+ return a + b
173
+ # ... existing code ...
174
+ def multiply(x: int, y: int) -> int:
175
+ \"\"\"Multiply two numbers.\"\"\"
176
+ return x * y
177
+ # ... existing code ...
178
+ ```
179
+
180
+ **Important Guidelines:**
181
+ - Bias towards repeating as few lines as possible while conveying the change clearly
182
+ - Each edit should contain sufficient context of unchanged lines around the code you're editing
183
+ - DO NOT omit spans of pre-existing code without using the `# ... existing code ...` comment
184
+ - If deleting a section, provide context before and after to clearly indicate the deletion
185
+ - The tool automatically creates backup files before applying changes\
186
+ """)
agno/tools/postgres.py CHANGED
@@ -2,14 +2,12 @@ import csv
2
2
  from typing import Any, Dict, List, Optional
3
3
 
4
4
  try:
5
- import psycopg2
6
- from psycopg2 import sql
7
- from psycopg2.extensions import connection as PgConnection
8
- from psycopg2.extras import DictCursor
5
+ import psycopg
6
+ from psycopg import sql
7
+ from psycopg.connection import Connection as PgConnection
8
+ from psycopg.rows import DictRow, dict_row
9
9
  except ImportError:
10
- raise ImportError(
11
- "`psycopg2` not installed. Please install using `pip install psycopg2`. If you face issues, try `pip install psycopg2-binary`."
12
- )
10
+ raise ImportError("`psycopg` not installed. Please install using `pip install 'psycopg-binary'`.")
13
11
 
14
12
  from agno.tools import Toolkit
15
13
  from agno.utils.log import log_debug, log_error
@@ -18,7 +16,7 @@ from agno.utils.log import log_debug, log_error
18
16
  class PostgresTools(Toolkit):
19
17
  def __init__(
20
18
  self,
21
- connection: Optional[PgConnection] = None,
19
+ connection: Optional[PgConnection[DictRow]] = None,
22
20
  db_name: Optional[str] = None,
23
21
  user: Optional[str] = None,
24
22
  password: Optional[str] = None,
@@ -31,7 +29,7 @@ class PostgresTools(Toolkit):
31
29
  table_schema: str = "public",
32
30
  **kwargs,
33
31
  ):
34
- self._connection: Optional[PgConnection] = connection
32
+ self._connection: Optional[PgConnection[DictRow]] = connection
35
33
  self.db_name: Optional[str] = db_name
36
34
  self.user: Optional[str] = user
37
35
  self.password: Optional[str] = password
@@ -55,16 +53,16 @@ class PostgresTools(Toolkit):
55
53
  super().__init__(name="postgres_tools", tools=tools, **kwargs)
56
54
 
57
55
  @property
58
- def connection(self) -> PgConnection:
56
+ def connection(self) -> PgConnection[DictRow]:
59
57
  """
60
- Returns the Postgres psycopg2 connection.
61
- :return psycopg2.extensions.connection: psycopg2 connection
58
+ Returns the Postgres psycopg connection.
59
+ :return psycopg.connection.Connection: psycopg connection
62
60
  """
63
61
  if self._connection is None or self._connection.closed:
64
62
  log_debug("Establishing new PostgreSQL connection.")
65
- connection_kwargs: Dict[str, Any] = {"cursor_factory": DictCursor}
63
+ connection_kwargs: Dict[str, Any] = {"row_factory": dict_row}
66
64
  if self.db_name:
67
- connection_kwargs["database"] = self.db_name
65
+ connection_kwargs["dbname"] = self.db_name
68
66
  if self.user:
69
67
  connection_kwargs["user"] = self.user
70
68
  if self.password:
@@ -76,8 +74,8 @@ class PostgresTools(Toolkit):
76
74
 
77
75
  connection_kwargs["options"] = f"-c search_path={self.table_schema}"
78
76
 
79
- self._connection = psycopg2.connect(**connection_kwargs)
80
- self._connection.set_session(readonly=True)
77
+ self._connection = psycopg.connect(**connection_kwargs)
78
+ self._connection.read_only = True
81
79
 
82
80
  return self._connection
83
81
 
@@ -110,10 +108,10 @@ class PostgresTools(Toolkit):
110
108
  return f"Query returned no results.\nColumns: {', '.join(columns)}"
111
109
 
112
110
  header = ",".join(columns)
113
- data_rows = [",".join(map(str, row)) for row in rows]
111
+ data_rows = [",".join(map(str, row.values())) for row in rows]
114
112
  return f"{header}\n" + "\n".join(data_rows)
115
113
 
116
- except psycopg2.Error as e:
114
+ except psycopg.Error as e:
117
115
  log_error(f"Database error: {e}")
118
116
  if self.connection and not self.connection.closed:
119
117
  self.connection.rollback()
@@ -203,15 +201,18 @@ class PostgresTools(Toolkit):
203
201
  cursor.execute(query)
204
202
  stats = cursor.fetchone()
205
203
  summary_parts.append(f"\n--- Column: {col_name} (Type: {data_type}) ---")
206
- for key, value in stats.items():
207
- val_str = (
208
- f"{value:.2f}" if isinstance(value, (float, int)) and value is not None else str(value)
209
- )
210
- summary_parts.append(f" {key}: {val_str}")
204
+ if stats is not None:
205
+ for key, value in stats.items():
206
+ val_str = (
207
+ f"{value:.2f}" if isinstance(value, float) and value is not None else str(value)
208
+ )
209
+ summary_parts.append(f" {key}: {val_str}")
210
+ else:
211
+ summary_parts.append(" No statistics available")
211
212
 
212
213
  return "\n".join(summary_parts)
213
214
 
214
- except psycopg2.Error as e:
215
+ except psycopg.Error as e:
215
216
  return f"Error summarizing table: {e}"
216
217
 
217
218
  def inspect_query(self, query: str) -> str:
@@ -239,15 +240,19 @@ class PostgresTools(Toolkit):
239
240
  try:
240
241
  with self.connection.cursor() as cursor:
241
242
  cursor.execute(stmt)
243
+
244
+ if cursor.description is None:
245
+ return f"Error: Query returned no description for table '{table}'."
246
+
242
247
  columns = [desc[0] for desc in cursor.description]
243
248
 
244
249
  with open(path, "w", newline="", encoding="utf-8") as f:
245
250
  writer = csv.writer(f)
246
251
  writer.writerow(columns)
247
- writer.writerows(cursor)
252
+ writer.writerows(row.values() for row in cursor)
248
253
 
249
254
  return f"Successfully exported table '{table}' to '{path}'."
250
- except (psycopg2.Error, IOError) as e:
255
+ except (psycopg.Error, IOError) as e:
251
256
  return f"Error exporting table: {e}"
252
257
 
253
258
  def run_query(self, query: str) -> str: