casual-mcp 0.6.0__py3-none-any.whl → 0.7.1__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.
casual_mcp/cli.py CHANGED
@@ -1,7 +1,12 @@
1
1
  import asyncio
2
+ import gc
3
+ import json
4
+ import warnings
5
+ from pathlib import Path
2
6
  from typing import Any
3
7
 
4
8
  import mcp
9
+ import questionary
5
10
  import typer
6
11
  import uvicorn
7
12
  from fastmcp import Client
@@ -9,6 +14,8 @@ from rich.console import Console
9
14
  from rich.table import Table
10
15
 
11
16
  from casual_mcp.models.mcp_server_config import RemoteServerConfig
17
+ from casual_mcp.models.toolset_config import ExcludeSpec, ToolSpec
18
+ from casual_mcp.tool_filter import extract_server_and_tool
12
19
  from casual_mcp.utils import load_config, load_mcp_client
13
20
 
14
21
  app = typer.Typer()
@@ -68,16 +75,382 @@ def tools() -> None:
68
75
  config = load_config("casual_mcp_config.json")
69
76
  mcp_client = load_mcp_client(config)
70
77
  table = Table("Name", "Description")
71
- # async with mcp_client:
72
- tools = asyncio.run(get_tools(mcp_client))
73
- for tool in tools:
78
+ tool_list = run_async_with_cleanup(get_tools_and_cleanup(mcp_client))
79
+ for tool in tool_list:
74
80
  table.add_row(tool.name, tool.description)
75
81
  console.print(table)
76
82
 
77
83
 
78
- async def get_tools(client: Client[Any]) -> list[mcp.Tool]:
79
- async with client:
80
- return await client.list_tools()
84
+ def run_async_with_cleanup(coro: Any) -> Any:
85
+ """Run async coroutine with proper subprocess cleanup.
86
+
87
+ This wrapper filters/ignores the "Event loop is closed" warning that occurs
88
+ when subprocess transports don't finish cleanup before the event loop closes.
89
+ It also forces gc.collect() after execution to help clean up remaining transports.
90
+ """
91
+ with warnings.catch_warnings():
92
+ warnings.filterwarnings("ignore", message="Event loop is closed")
93
+ try:
94
+ return asyncio.run(coro)
95
+ finally:
96
+ # Force garbage collection to clean up any remaining transports
97
+ gc.collect()
98
+
99
+
100
+ async def get_tools_and_cleanup(client: Client[Any]) -> list[mcp.Tool]:
101
+ """Get tools and ensure proper cleanup to avoid subprocess warnings."""
102
+ try:
103
+ async with client:
104
+ return await client.list_tools()
105
+ finally:
106
+ # Give transports time to close cleanly
107
+ await asyncio.sleep(0.1)
108
+
109
+
110
+ def _build_server_tool_map(tools: list[mcp.Tool], server_names: set[str]) -> dict[str, list[str]]:
111
+ """Build a mapping of server names to their tool names."""
112
+ server_tools: dict[str, list[str]] = {s: [] for s in server_names}
113
+ for tool in tools:
114
+ server_name, base_name = extract_server_and_tool(tool.name, server_names)
115
+ if server_name in server_tools:
116
+ server_tools[server_name].append(base_name)
117
+ return server_tools
118
+
119
+
120
+ def _format_tool_spec(spec: ToolSpec) -> str:
121
+ """Format a tool spec for display.
122
+
123
+ Note: Brackets are escaped for Rich console output.
124
+ """
125
+ if spec is True:
126
+ return "\\[all tools]"
127
+ elif isinstance(spec, list):
128
+ return ", ".join(spec)
129
+ elif isinstance(spec, ExcludeSpec):
130
+ return f"\\[all except: {', '.join(spec.exclude)}]"
131
+ return str(spec)
132
+
133
+
134
+ @app.command()
135
+ def toolsets() -> None:
136
+ """Interactively manage toolsets - create, edit, and delete."""
137
+ config_path = Path("casual_mcp_config.json")
138
+
139
+ while True:
140
+ config = load_config(config_path)
141
+
142
+ # Build menu choices
143
+ choices: list[Any] = []
144
+
145
+ if config.tool_sets:
146
+ for name, ts in config.tool_sets.items():
147
+ servers = ", ".join(ts.servers.keys()) or "no servers"
148
+ desc = ts.description[:40] + "..." if len(ts.description) > 40 else ts.description
149
+ display = f"{name} - {desc} ({servers})"
150
+ choices.append(questionary.Choice(title=display, value=name))
151
+
152
+ choices.append(questionary.Separator())
153
+
154
+ choices.append(questionary.Choice(title="➕ Create new toolset", value="__create__"))
155
+ choices.append(questionary.Choice(title="❌ Exit", value="__exit__"))
156
+
157
+ selection = questionary.select(
158
+ "Toolsets:" if config.tool_sets else "No toolsets configured:",
159
+ choices=choices,
160
+ ).ask()
161
+
162
+ if selection is None or selection == "__exit__":
163
+ return
164
+
165
+ if selection == "__create__":
166
+ _create_toolset(config_path)
167
+ continue
168
+
169
+ # Selected an existing toolset - show actions
170
+ _toolset_actions(config_path, selection)
171
+
172
+
173
+ def _create_toolset(config_path: Path) -> None:
174
+ """Prompt for name and create a new toolset."""
175
+ config = load_config(config_path)
176
+
177
+ name = questionary.text("Toolset name:").ask()
178
+ if not name:
179
+ return
180
+
181
+ if name in config.tool_sets:
182
+ console.print(f"[red]Toolset '{name}' already exists[/red]")
183
+ return
184
+
185
+ _interactive_toolset_edit(config_path, config, name, is_new=True)
186
+
187
+
188
+ def _toolset_actions(config_path: Path, name: str) -> None:
189
+ """Show actions for an existing toolset."""
190
+ config = load_config(config_path)
191
+ ts = config.tool_sets.get(name)
192
+
193
+ if not ts:
194
+ return
195
+
196
+ # Show toolset details
197
+ console.print(f"\n[bold]{name}[/bold]")
198
+ console.print(f"Description: {ts.description}")
199
+ console.print("Servers:")
200
+ for server, spec in ts.servers.items():
201
+ console.print(f" {server}: {_format_tool_spec(spec)}")
202
+ console.print()
203
+
204
+ action = questionary.select(
205
+ "Action:",
206
+ choices=[
207
+ questionary.Choice(title="✏️ Edit", value="edit"),
208
+ questionary.Choice(title="🗑️ Delete", value="delete"),
209
+ questionary.Choice(title="← Back", value="back"),
210
+ ],
211
+ ).ask()
212
+
213
+ if action is None or action == "back":
214
+ return
215
+
216
+ if action == "edit":
217
+ _interactive_toolset_edit(config_path, config, name, is_new=False)
218
+ elif action == "delete":
219
+ _delete_toolset(config_path, name)
220
+
221
+
222
+ def _delete_toolset(config_path: Path, name: str) -> None:
223
+ """Delete a toolset after confirmation."""
224
+ confirmed = questionary.confirm(f"Delete toolset '{name}'?", default=False).ask()
225
+ if not confirmed:
226
+ return
227
+
228
+ with config_path.open("r", encoding="utf-8") as f:
229
+ raw = json.load(f)
230
+
231
+ # Check if tool_sets exists and contains the toolset
232
+ if "tool_sets" not in raw:
233
+ console.print("[red]No toolsets found in config[/red]")
234
+ return
235
+
236
+ if name not in raw["tool_sets"]:
237
+ console.print(f"[red]Toolset '{name}' not found in config[/red]")
238
+ return
239
+
240
+ del raw["tool_sets"][name]
241
+
242
+ # Remove tool_sets key if empty
243
+ if not raw["tool_sets"]:
244
+ del raw["tool_sets"]
245
+
246
+ with config_path.open("w", encoding="utf-8") as f:
247
+ json.dump(raw, f, indent=4)
248
+
249
+ console.print(f"[green]Deleted toolset '{name}'[/green]")
250
+
251
+
252
+ def _format_server_status(server: str, spec: ToolSpec | None, tool_count: int) -> str:
253
+ """Format server status for display in the menu."""
254
+ if spec is None:
255
+ return f"{server} ({tool_count} tools) - [dim]not included[/dim]"
256
+ elif spec is True:
257
+ return f"{server} ({tool_count} tools) - [green]all tools[/green]"
258
+ elif isinstance(spec, list):
259
+ return f"{server} ({tool_count} tools) - [cyan]{len(spec)} included[/cyan]"
260
+ elif isinstance(spec, ExcludeSpec):
261
+ return f"{server} ({tool_count} tools) - [yellow]{len(spec.exclude)} excluded[/yellow]"
262
+ return f"{server} ({tool_count} tools)"
263
+
264
+
265
+ def _interactive_toolset_edit(
266
+ config_path: Path,
267
+ config: Any,
268
+ name: str,
269
+ is_new: bool,
270
+ ) -> None:
271
+ """Interactive toolset creation/editing with arrow-key navigation."""
272
+ from casual_mcp.models.config import Config
273
+
274
+ config = Config.model_validate(config.model_dump())
275
+
276
+ # Get available tools from servers
277
+ mcp_client = load_mcp_client(config)
278
+ tools = run_async_with_cleanup(get_tools_and_cleanup(mcp_client))
279
+ server_names = set(config.servers.keys())
280
+ server_tools = _build_server_tool_map(tools, server_names)
281
+
282
+ # Get existing config if editing
283
+ existing = config.tool_sets.get(name)
284
+ existing_description = existing.description if existing else ""
285
+ existing_servers: dict[str, Any] = dict(existing.servers) if existing else {}
286
+
287
+ # Working copy of server configs
288
+ new_servers: dict[str, Any] = dict(existing_servers)
289
+
290
+ if is_new:
291
+ console.print(f"[green]Creating new toolset '{name}'[/green]\n")
292
+ else:
293
+ console.print(f"[yellow]Editing toolset '{name}'[/yellow]\n")
294
+
295
+ # Get description
296
+ description = questionary.text(
297
+ "Description:",
298
+ default=existing_description,
299
+ ).ask()
300
+
301
+ if description is None:
302
+ raise typer.Abort()
303
+
304
+ # Main loop - configure servers one at a time
305
+ sorted_servers = sorted(server_names)
306
+
307
+ while True:
308
+ console.print("\n[bold]Server Configuration:[/bold]")
309
+ console.print("[dim]Configure each server, then select 'Save and exit' when done.[/dim]\n")
310
+
311
+ # Build menu choices showing current status
312
+ choices = []
313
+ for server in sorted_servers:
314
+ spec = new_servers.get(server)
315
+ tool_count = len(server_tools[server])
316
+ status = _format_server_status(server, spec, tool_count)
317
+ # Strip Rich markup for questionary display
318
+ plain_status = (
319
+ status.replace("[dim]", "")
320
+ .replace("[/dim]", "")
321
+ .replace("[green]", "")
322
+ .replace("[/green]", "")
323
+ .replace("[cyan]", "")
324
+ .replace("[/cyan]", "")
325
+ .replace("[yellow]", "")
326
+ .replace("[/yellow]", "")
327
+ )
328
+ choices.append(questionary.Choice(title=plain_status, value=server))
329
+
330
+ choices.append(questionary.Separator())
331
+ choices.append(questionary.Choice(title="💾 Save and exit", value="__save__"))
332
+ choices.append(questionary.Choice(title="❌ Cancel", value="__cancel__"))
333
+
334
+ selection = questionary.select(
335
+ "Select a server to configure:",
336
+ choices=choices,
337
+ ).ask()
338
+
339
+ if selection is None or selection == "__cancel__":
340
+ raise typer.Abort()
341
+
342
+ if selection == "__save__":
343
+ break
344
+
345
+ # Configure the selected server
346
+ server = selection
347
+ available = server_tools[server]
348
+ existing_spec = new_servers.get(server)
349
+
350
+ # Determine current mode for default selection
351
+ if existing_spec is None:
352
+ default_mode = "Don't include"
353
+ elif existing_spec is True:
354
+ default_mode = "All tools"
355
+ elif isinstance(existing_spec, list):
356
+ default_mode = "Include specific tools"
357
+ elif isinstance(existing_spec, ExcludeSpec):
358
+ default_mode = "Exclude specific tools"
359
+ else:
360
+ default_mode = "Don't include"
361
+
362
+ mode = questionary.select(
363
+ f"Configure {server} ({len(available)} tools):",
364
+ choices=[
365
+ "Don't include",
366
+ "All tools",
367
+ "Include specific tools",
368
+ "Exclude specific tools",
369
+ ],
370
+ default=default_mode,
371
+ ).ask()
372
+
373
+ if mode is None:
374
+ continue # Go back to server list
375
+
376
+ if mode == "Don't include":
377
+ new_servers.pop(server, None)
378
+ elif mode == "All tools":
379
+ new_servers[server] = True
380
+ elif mode == "Include specific tools":
381
+ # Determine which tools were previously selected
382
+ if isinstance(existing_spec, list):
383
+ pre_selected = set(existing_spec)
384
+ else:
385
+ pre_selected = set()
386
+
387
+ tool_choices = [
388
+ questionary.Choice(title=tool, value=tool, checked=tool in pre_selected)
389
+ for tool in available
390
+ ]
391
+
392
+ console.print("\n[dim]Use space to select, enter to confirm[/dim]")
393
+ selected_tools = questionary.checkbox(
394
+ f"Select tools to include from {server}:",
395
+ choices=tool_choices,
396
+ ).ask()
397
+
398
+ if selected_tools is None:
399
+ continue # Go back to server list
400
+
401
+ if selected_tools:
402
+ new_servers[server] = selected_tools
403
+ else:
404
+ new_servers.pop(server, None) # No tools = don't include
405
+ elif mode == "Exclude specific tools":
406
+ # Determine which tools were previously excluded
407
+ if isinstance(existing_spec, ExcludeSpec):
408
+ pre_excluded = set(existing_spec.exclude)
409
+ else:
410
+ pre_excluded = set()
411
+
412
+ tool_choices = [
413
+ questionary.Choice(title=tool, value=tool, checked=tool in pre_excluded)
414
+ for tool in available
415
+ ]
416
+
417
+ console.print("\n[dim]Use space to select, enter to confirm[/dim]")
418
+ excluded_tools = questionary.checkbox(
419
+ f"Select tools to exclude from {server}:",
420
+ choices=tool_choices,
421
+ ).ask()
422
+
423
+ if excluded_tools is None:
424
+ continue # Go back to server list
425
+
426
+ if excluded_tools:
427
+ new_servers[server] = {"exclude": excluded_tools}
428
+ else:
429
+ new_servers[server] = True # No exclusions = all tools
430
+
431
+ # Check if any servers are configured
432
+ if not new_servers:
433
+ console.print("[yellow]No servers configured - toolset will be empty[/yellow]")
434
+ confirm = questionary.confirm("Save empty toolset?", default=False).ask()
435
+ if not confirm:
436
+ raise typer.Abort()
437
+
438
+ # Save to config
439
+ with config_path.open("r", encoding="utf-8") as f:
440
+ raw = json.load(f)
441
+
442
+ if "tool_sets" not in raw:
443
+ raw["tool_sets"] = {}
444
+
445
+ raw["tool_sets"][name] = {
446
+ "description": description,
447
+ "servers": new_servers,
448
+ }
449
+
450
+ with config_path.open("w", encoding="utf-8") as f:
451
+ json.dump(raw, f, indent=4)
452
+
453
+ console.print(f"\n[green]Saved toolset '{name}'[/green]")
81
454
 
82
455
 
83
456
  if __name__ == "__main__":
casual_mcp/main.py CHANGED
@@ -8,8 +8,10 @@ from pydantic import BaseModel, Field
8
8
 
9
9
  from casual_mcp import McpToolChat
10
10
  from casual_mcp.logging import configure_logging, get_logger
11
+ from casual_mcp.models.toolset_config import ToolSetConfig
11
12
  from casual_mcp.provider_factory import ProviderFactory
12
13
  from casual_mcp.tool_cache import ToolCache
14
+ from casual_mcp.tool_filter import ToolSetValidationError
13
15
  from casual_mcp.utils import load_config, load_mcp_client, render_system_prompt
14
16
 
15
17
  # Load environment variables
@@ -46,6 +48,7 @@ class GenerateRequest(BaseModel):
46
48
  system_prompt: str | None = Field(default=None, title="System Prompt to use")
47
49
  prompt: str = Field(title="User Prompt")
48
50
  include_stats: bool = Field(default=False, title="Include usage statistics in response")
51
+ tool_set: str | None = Field(default=None, title="Name of toolset to use")
49
52
 
50
53
 
51
54
  class ChatRequest(BaseModel):
@@ -53,12 +56,43 @@ class ChatRequest(BaseModel):
53
56
  system_prompt: str | None = Field(default=None, title="System Prompt to use")
54
57
  messages: list[ChatMessage] = Field(title="Previous messages to supply to the LLM")
55
58
  include_stats: bool = Field(default=False, title="Include usage statistics in response")
59
+ tool_set: str | None = Field(default=None, title="Name of toolset to use")
60
+
61
+
62
+ def resolve_tool_set(tool_set_name: str | None) -> ToolSetConfig | None:
63
+ """Resolve a tool set name to its configuration.
64
+
65
+ Args:
66
+ tool_set_name: Name of the tool set, or None
67
+
68
+ Returns:
69
+ ToolSetConfig if found, None if tool_set_name is None
70
+
71
+ Raises:
72
+ HTTPException: If tool set name is provided but not found
73
+ """
74
+ if tool_set_name is None:
75
+ return None
76
+
77
+ if tool_set_name not in config.tool_sets:
78
+ raise HTTPException(
79
+ status_code=400,
80
+ detail=f"Toolset '{tool_set_name}' not found. "
81
+ f"Available: {list(config.tool_sets.keys())}",
82
+ )
83
+
84
+ return config.tool_sets[tool_set_name]
56
85
 
57
86
 
58
87
  @app.post("/chat")
59
88
  async def chat(req: ChatRequest) -> dict[str, Any]:
89
+ tool_set_config = resolve_tool_set(req.tool_set)
60
90
  chat_instance = await get_chat(req.model, req.system_prompt)
61
- messages = await chat_instance.chat(req.messages)
91
+
92
+ try:
93
+ messages = await chat_instance.chat(req.messages, tool_set=tool_set_config)
94
+ except ToolSetValidationError as e:
95
+ raise HTTPException(status_code=400, detail=str(e))
62
96
 
63
97
  if not messages:
64
98
  error_result: dict[str, Any] = {"messages": [], "response": ""}
@@ -77,8 +111,15 @@ async def chat(req: ChatRequest) -> dict[str, Any]:
77
111
 
78
112
  @app.post("/generate")
79
113
  async def generate(req: GenerateRequest) -> dict[str, Any]:
114
+ tool_set_config = resolve_tool_set(req.tool_set)
80
115
  chat_instance = await get_chat(req.model, req.system_prompt)
81
- messages = await chat_instance.generate(req.prompt, req.session_id)
116
+
117
+ try:
118
+ messages = await chat_instance.generate(
119
+ req.prompt, req.session_id, tool_set=tool_set_config
120
+ )
121
+ except ToolSetValidationError as e:
122
+ raise HTTPException(status_code=400, detail=str(e))
82
123
 
83
124
  if not messages:
84
125
  error_result: dict[str, Any] = {"messages": [], "response": ""}
@@ -104,6 +145,18 @@ async def get_generate_session(session_id: str) -> list[ChatMessage]:
104
145
  return session
105
146
 
106
147
 
148
+ @app.get("/toolsets")
149
+ async def list_toolsets() -> dict[str, dict[str, Any]]:
150
+ """List all available toolsets."""
151
+ return {
152
+ name: {
153
+ "description": ts.description,
154
+ "servers": list(ts.servers.keys()),
155
+ }
156
+ for name, ts in config.tool_sets.items()
157
+ }
158
+
159
+
107
160
  async def get_chat(model: str, system: str | None = None) -> McpToolChat:
108
161
  # Get Provider from Model Config
109
162
  model_config = config.models[model]
@@ -117,4 +170,10 @@ async def get_chat(model: str, system: str | None = None) -> McpToolChat:
117
170
  else:
118
171
  system = default_system_prompt
119
172
 
120
- return McpToolChat(mcp_client, provider, system, tool_cache=tool_cache)
173
+ return McpToolChat(
174
+ mcp_client,
175
+ provider,
176
+ system,
177
+ tool_cache=tool_cache,
178
+ server_names=set(config.servers.keys()),
179
+ )
@@ -15,12 +15,17 @@ from fastmcp import Client
15
15
  from casual_mcp.convert_tools import tools_from_mcp
16
16
  from casual_mcp.logging import get_logger
17
17
  from casual_mcp.models.chat_stats import ChatStats
18
+ from casual_mcp.models.toolset_config import ToolSetConfig
18
19
  from casual_mcp.tool_cache import ToolCache
20
+ from casual_mcp.tool_filter import filter_tools_by_toolset
19
21
  from casual_mcp.utils import format_tool_call_result
20
22
 
21
23
  logger = get_logger("mcp_tool_chat")
22
24
  sessions: dict[str, list[ChatMessage]] = {}
23
25
 
26
+ # Type alias for metadata dictionary
27
+ MetaDict = dict[str, Any]
28
+
24
29
 
25
30
  def get_session_messages(session_id: str) -> list[ChatMessage]:
26
31
  global sessions
@@ -45,11 +50,13 @@ class McpToolChat:
45
50
  provider: LLMProvider,
46
51
  system: str | None = None,
47
52
  tool_cache: ToolCache | None = None,
53
+ server_names: set[str] | None = None,
48
54
  ):
49
55
  self.provider = provider
50
56
  self.mcp_client = mcp_client
51
57
  self.system = system
52
58
  self.tool_cache = tool_cache or ToolCache(mcp_client)
59
+ self.server_names = server_names or set()
53
60
  self._tool_cache_version = -1
54
61
  self._last_stats: ChatStats | None = None
55
62
 
@@ -80,7 +87,28 @@ class McpToolChat:
80
87
  return tool_name.split("_", 1)[0]
81
88
  return "default"
82
89
 
83
- async def generate(self, prompt: str, session_id: str | None = None) -> list[ChatMessage]:
90
+ async def generate(
91
+ self,
92
+ prompt: str,
93
+ session_id: str | None = None,
94
+ tool_set: ToolSetConfig | None = None,
95
+ meta: MetaDict | None = None,
96
+ ) -> list[ChatMessage]:
97
+ """
98
+ Generate a response to a prompt, optionally using session history.
99
+
100
+ Args:
101
+ prompt: The user prompt to respond to
102
+ session_id: Optional session ID for conversation persistence
103
+ tool_set: Optional tool set configuration to filter available tools
104
+ meta: Optional metadata to pass through to MCP tool calls.
105
+ Useful for passing context like character_id without
106
+ exposing it to the LLM. Servers can access this via
107
+ ctx.request_context.meta.
108
+
109
+ Returns:
110
+ List of response messages including any tool calls and results
111
+ """
84
112
  # Fetch the session if we have a session ID
85
113
  messages: list[ChatMessage]
86
114
  if session_id:
@@ -97,7 +125,7 @@ class McpToolChat:
97
125
  add_messages_to_session(session_id, [user_message])
98
126
 
99
127
  # Perform Chat
100
- response = await self.chat(messages=messages)
128
+ response = await self.chat(messages=messages, tool_set=tool_set, meta=meta)
101
129
 
102
130
  # Add responses to session
103
131
  if session_id:
@@ -105,9 +133,33 @@ class McpToolChat:
105
133
 
106
134
  return response
107
135
 
108
- async def chat(self, messages: list[ChatMessage]) -> list[ChatMessage]:
136
+ async def chat(
137
+ self,
138
+ messages: list[ChatMessage],
139
+ tool_set: ToolSetConfig | None = None,
140
+ meta: MetaDict | None = None,
141
+ ) -> list[ChatMessage]:
142
+ """
143
+ Process a conversation with tool calling support.
144
+
145
+ Args:
146
+ messages: The conversation messages to process
147
+ tool_set: Optional tool set configuration to filter available tools
148
+ meta: Optional metadata to pass through to MCP tool calls.
149
+ Useful for passing context like character_id without
150
+ exposing it to the LLM. Servers can access this via
151
+ ctx.request_context.meta.
152
+
153
+ Returns:
154
+ List of response messages including any tool calls and results
155
+ """
109
156
  tools = await self.tool_cache.get_tools()
110
157
 
158
+ # Filter tools if a toolset is specified
159
+ if tool_set is not None:
160
+ tools = filter_tools_by_toolset(tools, tool_set, self.server_names, validate=True)
161
+ logger.info(f"Filtered to {len(tools)} tools using toolset")
162
+
111
163
  # Reset stats at the start of each chat
112
164
  self._last_stats = ChatStats()
113
165
 
@@ -155,13 +207,18 @@ class McpToolChat:
155
207
  )
156
208
 
157
209
  try:
158
- result = await self.execute(tool_call)
210
+ result = await self.execute(tool_call, meta=meta)
159
211
  except Exception as e:
160
212
  logger.error(
161
213
  f"Failed to execute tool '{tool_call.function.name}' "
162
214
  f"(id={tool_call.id}): {e}"
163
215
  )
164
- continue
216
+ # Surface the failure to the LLM so it knows the tool failed
217
+ result = ToolResultMessage(
218
+ name=tool_call.function.name,
219
+ tool_call_id=tool_call.id,
220
+ content=f"Error executing tool: {e}",
221
+ )
165
222
  if result:
166
223
  messages.append(result)
167
224
  response_messages.append(result)
@@ -173,12 +230,27 @@ class McpToolChat:
173
230
 
174
231
  return response_messages
175
232
 
176
- async def execute(self, tool_call: AssistantToolCall) -> ToolResultMessage:
233
+ async def execute(
234
+ self,
235
+ tool_call: AssistantToolCall,
236
+ meta: MetaDict | None = None,
237
+ ) -> ToolResultMessage:
238
+ """
239
+ Execute a single tool call.
240
+
241
+ Args:
242
+ tool_call: The tool call to execute
243
+ meta: Optional metadata to pass to the MCP server.
244
+ Servers can access this via ctx.request_context.meta.
245
+
246
+ Returns:
247
+ ToolResultMessage with the tool execution result
248
+ """
177
249
  tool_name = tool_call.function.name
178
250
  tool_args = json.loads(tool_call.function.arguments)
179
251
  try:
180
252
  async with self.mcp_client:
181
- result = await self.mcp_client.call_tool(tool_name, tool_args)
253
+ result = await self.mcp_client.call_tool(tool_name, tool_args, meta=meta)
182
254
  except Exception as e:
183
255
  if isinstance(e, ValueError):
184
256
  logger.warning(e)
@@ -22,6 +22,11 @@ from .model_config import (
22
22
  OllamaModelConfig,
23
23
  OpenAIModelConfig,
24
24
  )
25
+ from .toolset_config import (
26
+ ExcludeSpec,
27
+ ToolSetConfig,
28
+ ToolSpec,
29
+ )
25
30
 
26
31
  __all__ = [
27
32
  "UserMessage",
@@ -39,4 +44,7 @@ __all__ = [
39
44
  "McpServerConfig",
40
45
  "StdioServerConfig",
41
46
  "RemoteServerConfig",
47
+ "ExcludeSpec",
48
+ "ToolSetConfig",
49
+ "ToolSpec",
42
50
  ]