casual-mcp 0.6.0__py3-none-any.whl → 0.7.0__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 +379 -6
- casual_mcp/main.py +62 -3
- casual_mcp/mcp_tool_chat.py +79 -7
- casual_mcp/models/__init__.py +8 -0
- casual_mcp/models/config.py +3 -1
- casual_mcp/models/toolset_config.py +40 -0
- casual_mcp/tool_filter.py +171 -0
- casual_mcp-0.7.0.dist-info/METADATA +193 -0
- {casual_mcp-0.6.0.dist-info → casual_mcp-0.7.0.dist-info}/RECORD +13 -11
- casual_mcp-0.6.0.dist-info/METADATA +0 -691
- {casual_mcp-0.6.0.dist-info → casual_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {casual_mcp-0.6.0.dist-info → casual_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
- {casual_mcp-0.6.0.dist-info → casual_mcp-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {casual_mcp-0.6.0.dist-info → casual_mcp-0.7.0.dist-info}/top_level.txt +0 -0
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
async with
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
173
|
+
return McpToolChat(
|
|
174
|
+
mcp_client,
|
|
175
|
+
provider,
|
|
176
|
+
system,
|
|
177
|
+
tool_cache=tool_cache,
|
|
178
|
+
server_names=set(config.servers.keys()),
|
|
179
|
+
)
|
casual_mcp/mcp_tool_chat.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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)
|
casual_mcp/models/__init__.py
CHANGED
|
@@ -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
|
]
|