casual-mcp 0.5.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/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from importlib.metadata import version
2
2
 
3
3
  from . import models
4
+ from .models.chat_stats import ChatStats, TokenUsageStats, ToolCallStats
4
5
 
5
6
  __version__ = version("casual-mcp")
6
7
  from .mcp_tool_chat import McpToolChat
@@ -17,4 +18,7 @@ __all__ = [
17
18
  "load_mcp_client",
18
19
  "render_system_prompt",
19
20
  "models",
21
+ "ChatStats",
22
+ "TokenUsageStats",
23
+ "ToolCallStats",
20
24
  ]
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
@@ -45,28 +47,93 @@ class GenerateRequest(BaseModel):
45
47
  model: str = Field(title="Model to use")
46
48
  system_prompt: str | None = Field(default=None, title="System Prompt to use")
47
49
  prompt: str = Field(title="User Prompt")
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")
48
52
 
49
53
 
50
54
  class ChatRequest(BaseModel):
51
55
  model: str = Field(title="Model to use")
52
56
  system_prompt: str | None = Field(default=None, title="System Prompt to use")
53
57
  messages: list[ChatMessage] = Field(title="Previous messages to supply to the LLM")
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]
54
85
 
55
86
 
56
87
  @app.post("/chat")
57
88
  async def chat(req: ChatRequest) -> dict[str, Any]:
58
- chat = await get_chat(req.model, req.system_prompt)
59
- messages = await chat.chat(req.messages)
89
+ tool_set_config = resolve_tool_set(req.tool_set)
90
+ chat_instance = await get_chat(req.model, req.system_prompt)
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))
96
+
97
+ if not messages:
98
+ error_result: dict[str, Any] = {"messages": [], "response": ""}
99
+ if req.include_stats:
100
+ error_result["stats"] = chat_instance.get_stats()
101
+ raise HTTPException(
102
+ status_code=500,
103
+ detail={"error": "No response generated", **error_result},
104
+ )
60
105
 
61
- return {"messages": messages, "response": messages[-1].content}
106
+ result: dict[str, Any] = {"messages": messages, "response": messages[-1].content}
107
+ if req.include_stats:
108
+ result["stats"] = chat_instance.get_stats()
109
+ return result
62
110
 
63
111
 
64
112
  @app.post("/generate")
65
113
  async def generate(req: GenerateRequest) -> dict[str, Any]:
66
- chat = await get_chat(req.model, req.system_prompt)
67
- messages = await chat.generate(req.prompt, req.session_id)
68
-
69
- return {"messages": messages, "response": messages[-1].content}
114
+ tool_set_config = resolve_tool_set(req.tool_set)
115
+ chat_instance = await get_chat(req.model, req.system_prompt)
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))
123
+
124
+ if not messages:
125
+ error_result: dict[str, Any] = {"messages": [], "response": ""}
126
+ if req.include_stats:
127
+ error_result["stats"] = chat_instance.get_stats()
128
+ raise HTTPException(
129
+ status_code=500,
130
+ detail={"error": "No response generated", **error_result},
131
+ )
132
+
133
+ result: dict[str, Any] = {"messages": messages, "response": messages[-1].content}
134
+ if req.include_stats:
135
+ result["stats"] = chat_instance.get_stats()
136
+ return result
70
137
 
71
138
 
72
139
  @app.get("/generate/session/{session_id}")
@@ -78,6 +145,18 @@ async def get_generate_session(session_id: str) -> list[ChatMessage]:
78
145
  return session
79
146
 
80
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
+
81
160
  async def get_chat(model: str, system: str | None = None) -> McpToolChat:
82
161
  # Get Provider from Model Config
83
162
  model_config = config.models[model]
@@ -91,4 +170,10 @@ async def get_chat(model: str, system: str | None = None) -> McpToolChat:
91
170
  else:
92
171
  system = default_system_prompt
93
172
 
94
- 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
+ )