casual-mcp 0.5.0__py3-none-any.whl → 0.6.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/main.py CHANGED
@@ -45,28 +45,54 @@ class GenerateRequest(BaseModel):
45
45
  model: str = Field(title="Model to use")
46
46
  system_prompt: str | None = Field(default=None, title="System Prompt to use")
47
47
  prompt: str = Field(title="User Prompt")
48
+ include_stats: bool = Field(default=False, title="Include usage statistics in response")
48
49
 
49
50
 
50
51
  class ChatRequest(BaseModel):
51
52
  model: str = Field(title="Model to use")
52
53
  system_prompt: str | None = Field(default=None, title="System Prompt to use")
53
54
  messages: list[ChatMessage] = Field(title="Previous messages to supply to the LLM")
55
+ include_stats: bool = Field(default=False, title="Include usage statistics in response")
54
56
 
55
57
 
56
58
  @app.post("/chat")
57
59
  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)
60
+ chat_instance = await get_chat(req.model, req.system_prompt)
61
+ messages = await chat_instance.chat(req.messages)
60
62
 
61
- return {"messages": messages, "response": messages[-1].content}
63
+ if not messages:
64
+ error_result: dict[str, Any] = {"messages": [], "response": ""}
65
+ if req.include_stats:
66
+ error_result["stats"] = chat_instance.get_stats()
67
+ raise HTTPException(
68
+ status_code=500,
69
+ detail={"error": "No response generated", **error_result},
70
+ )
71
+
72
+ result: dict[str, Any] = {"messages": messages, "response": messages[-1].content}
73
+ if req.include_stats:
74
+ result["stats"] = chat_instance.get_stats()
75
+ return result
62
76
 
63
77
 
64
78
  @app.post("/generate")
65
79
  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}
80
+ chat_instance = await get_chat(req.model, req.system_prompt)
81
+ messages = await chat_instance.generate(req.prompt, req.session_id)
82
+
83
+ if not messages:
84
+ error_result: dict[str, Any] = {"messages": [], "response": ""}
85
+ if req.include_stats:
86
+ error_result["stats"] = chat_instance.get_stats()
87
+ raise HTTPException(
88
+ status_code=500,
89
+ detail={"error": "No response generated", **error_result},
90
+ )
91
+
92
+ result: dict[str, Any] = {"messages": messages, "response": messages[-1].content}
93
+ if req.include_stats:
94
+ result["stats"] = chat_instance.get_stats()
95
+ return result
70
96
 
71
97
 
72
98
  @app.get("/generate/session/{session_id}")
@@ -14,6 +14,7 @@ from fastmcp import Client
14
14
 
15
15
  from casual_mcp.convert_tools import tools_from_mcp
16
16
  from casual_mcp.logging import get_logger
17
+ from casual_mcp.models.chat_stats import ChatStats
17
18
  from casual_mcp.tool_cache import ToolCache
18
19
  from casual_mcp.utils import format_tool_call_result
19
20
 
@@ -50,12 +51,35 @@ class McpToolChat:
50
51
  self.system = system
51
52
  self.tool_cache = tool_cache or ToolCache(mcp_client)
52
53
  self._tool_cache_version = -1
54
+ self._last_stats: ChatStats | None = None
53
55
 
54
56
  @staticmethod
55
57
  def get_session(session_id: str) -> list[ChatMessage] | None:
56
58
  global sessions
57
59
  return sessions.get(session_id)
58
60
 
61
+ def get_stats(self) -> ChatStats | None:
62
+ """
63
+ Get usage statistics from the last chat() or generate() call.
64
+
65
+ Returns None if no calls have been made yet.
66
+ Stats are reset at the start of each new chat()/generate() call.
67
+ """
68
+ return self._last_stats
69
+
70
+ def _extract_server_from_tool_name(self, tool_name: str) -> str:
71
+ """
72
+ Extract server name from a tool name.
73
+
74
+ With multiple servers, fastmcp prefixes tools as "serverName_toolName".
75
+ With a single server, tools are not prefixed.
76
+
77
+ Returns the server name or "default" if it cannot be determined.
78
+ """
79
+ if "_" in tool_name:
80
+ return tool_name.split("_", 1)[0]
81
+ return "default"
82
+
59
83
  async def generate(self, prompt: str, session_id: str | None = None) -> list[ChatMessage]:
60
84
  # Fetch the session if we have a session ID
61
85
  messages: list[ChatMessage]
@@ -84,6 +108,9 @@ class McpToolChat:
84
108
  async def chat(self, messages: list[ChatMessage]) -> list[ChatMessage]:
85
109
  tools = await self.tool_cache.get_tools()
86
110
 
111
+ # Reset stats at the start of each chat
112
+ self._last_stats = ChatStats()
113
+
87
114
  # Add a system message if required
88
115
  has_system_message = any(message.role == "system" for message in messages)
89
116
  if self.system and not has_system_message:
@@ -97,6 +124,15 @@ class McpToolChat:
97
124
  logger.info("Calling the LLM")
98
125
  ai_message = await self.provider.chat(messages=messages, tools=tools_from_mcp(tools))
99
126
 
127
+ # Accumulate token usage stats
128
+ self._last_stats.llm_calls += 1
129
+ usage = self.provider.get_usage()
130
+ if usage:
131
+ prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
132
+ completion_tokens = getattr(usage, "completion_tokens", 0) or 0
133
+ self._last_stats.tokens.prompt_tokens += prompt_tokens
134
+ self._last_stats.tokens.completion_tokens += completion_tokens
135
+
100
136
  # Add the assistant's message
101
137
  response_messages.append(ai_message)
102
138
  messages.append(ai_message)
@@ -108,6 +144,16 @@ class McpToolChat:
108
144
  logger.info(f"Executing {len(ai_message.tool_calls)} tool calls")
109
145
  result_count = 0
110
146
  for tool_call in ai_message.tool_calls:
147
+ # Track tool call stats
148
+ tool_name = tool_call.function.name
149
+ self._last_stats.tool_calls.by_tool[tool_name] = (
150
+ self._last_stats.tool_calls.by_tool.get(tool_name, 0) + 1
151
+ )
152
+ server_name = self._extract_server_from_tool_name(tool_name)
153
+ self._last_stats.tool_calls.by_server[server_name] = (
154
+ self._last_stats.tool_calls.by_server.get(server_name, 0) + 1
155
+ )
156
+
111
157
  try:
112
158
  result = await self.execute(tool_call)
113
159
  except Exception as e:
@@ -148,16 +194,35 @@ class McpToolChat:
148
194
  logger.debug(f"Tool Call Result: {result}")
149
195
 
150
196
  result_format = os.getenv("TOOL_RESULT_FORMAT", "result")
151
- # Extract text content from result (handle both TextContent and other content types)
152
- if not result.content:
197
+
198
+ # Prefer structuredContent when available (machine-readable format)
199
+ # Note: MCP types use camelCase (structuredContent), mypy stubs may differ
200
+ structured = getattr(result, "structuredContent", None)
201
+ if structured is not None:
202
+ try:
203
+ content_text = json.dumps(structured)
204
+ except (TypeError, ValueError):
205
+ content_text = str(structured)
206
+ elif not result.content:
153
207
  content_text = "[No content returned]"
154
208
  else:
155
- content_item = result.content[0]
156
- if hasattr(content_item, "text"):
157
- content_text = content_item.text
158
- else:
159
- # Handle non-text content (e.g., ImageContent)
160
- content_text = f"[Non-text content: {type(content_item).__name__}]"
209
+ # Fall back to processing content items
210
+ content_parts: list[Any] = []
211
+ for content_item in result.content:
212
+ if content_item.type == "text":
213
+ try:
214
+ parsed = json.loads(content_item.text)
215
+ content_parts.append(parsed)
216
+ except json.JSONDecodeError:
217
+ content_parts.append(content_item.text)
218
+ elif hasattr(content_item, "mimeType"):
219
+ # Image or audio content
220
+ content_parts.append(f"[{content_item.type}: {content_item.mimeType}]")
221
+ else:
222
+ content_parts.append(str(content_item))
223
+
224
+ content_text = json.dumps(content_parts)
225
+
161
226
  content = format_tool_call_result(tool_call, content_text, style=result_format)
162
227
 
163
228
  return ToolResultMessage(
@@ -7,6 +7,11 @@ from casual_llm import (
7
7
  UserMessage,
8
8
  )
9
9
 
10
+ from .chat_stats import (
11
+ ChatStats,
12
+ TokenUsageStats,
13
+ ToolCallStats,
14
+ )
10
15
  from .mcp_server_config import (
11
16
  McpServerConfig,
12
17
  RemoteServerConfig,
@@ -25,6 +30,9 @@ __all__ = [
25
30
  "ToolResultMessage",
26
31
  "SystemMessage",
27
32
  "ChatMessage",
33
+ "ChatStats",
34
+ "TokenUsageStats",
35
+ "ToolCallStats",
28
36
  "McpModelConfig",
29
37
  "OllamaModelConfig",
30
38
  "OpenAIModelConfig",
@@ -0,0 +1,37 @@
1
+ """Usage statistics models for chat sessions."""
2
+
3
+ from pydantic import BaseModel, Field, computed_field
4
+
5
+
6
+ class TokenUsageStats(BaseModel):
7
+ """Token usage statistics accumulated across all LLM calls."""
8
+
9
+ prompt_tokens: int = Field(default=0, ge=0)
10
+ completion_tokens: int = Field(default=0, ge=0)
11
+
12
+ @computed_field # type: ignore[prop-decorator]
13
+ @property
14
+ def total_tokens(self) -> int:
15
+ """Total tokens (prompt + completion)."""
16
+ return self.prompt_tokens + self.completion_tokens
17
+
18
+
19
+ class ToolCallStats(BaseModel):
20
+ """Statistics about tool calls during a chat session."""
21
+
22
+ by_tool: dict[str, int] = Field(default_factory=dict)
23
+ by_server: dict[str, int] = Field(default_factory=dict)
24
+
25
+ @computed_field # type: ignore[prop-decorator]
26
+ @property
27
+ def total(self) -> int:
28
+ """Total number of tool calls made."""
29
+ return sum(self.by_tool.values())
30
+
31
+
32
+ class ChatStats(BaseModel):
33
+ """Combined statistics from a chat session."""
34
+
35
+ tokens: TokenUsageStats = Field(default_factory=TokenUsageStats)
36
+ tool_calls: ToolCallStats = Field(default_factory=ToolCallStats)
37
+ llm_calls: int = Field(default=0, ge=0, description="Number of LLM calls made")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casual-mcp
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Multi-server MCP client for LLM tool orchestration
5
5
  Author: Alex Stansfield
6
6
  License: MIT
@@ -10,7 +10,7 @@ Project-URL: Issue Tracker, https://github.com/casualgenius/casual-mcp/issues
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: casual-llm[openai]>=0.4.2
13
+ Requires-Dist: casual-llm[openai]>=0.4.3
14
14
  Requires-Dist: dateparser>=1.2.1
15
15
  Requires-Dist: fastapi>=0.115.12
16
16
  Requires-Dist: fastmcp>=2.12.4
@@ -33,6 +33,7 @@ It includes:
33
33
  - ✅ A multi-server MCP client using [FastMCP](https://github.com/jlowin/fastmcp)
34
34
  - ✅ Provider support for OpenAI and Ollama (powered by [casual-llm](https://github.com/AlexStansfield/casual-llm))
35
35
  - ✅ A recursive tool-calling chat loop
36
+ - ✅ Usage statistics tracking (tokens, tool calls, LLM calls)
36
37
  - ✅ System prompt templating with Jinja2
37
38
  - ✅ A basic API exposing a chat endpoint
38
39
 
@@ -40,6 +41,7 @@ It includes:
40
41
 
41
42
  - Plug-and-play multi-server tool orchestration
42
43
  - OpenAI and Ollama LLM providers (via casual-llm)
44
+ - Usage statistics tracking (tokens, tool calls, LLM calls)
43
45
  - Prompt templating with Jinja2
44
46
  - Configurable via JSON
45
47
  - CLI and API access
@@ -253,8 +255,39 @@ messages = [
253
255
  UserMessage(content="What time is it in London?")
254
256
  ]
255
257
  response = await chat.chat(messages)
258
+
259
+ # Get usage statistics from the last call
260
+ stats = chat.get_stats()
261
+ if stats:
262
+ print(f"Tokens used: {stats.tokens.total_tokens}")
263
+ print(f"Tool calls: {stats.tool_calls.total}")
264
+ print(f"LLM calls: {stats.llm_calls}")
256
265
  ```
257
266
 
267
+ #### Usage Statistics
268
+
269
+ After calling `chat()` or `generate()`, you can retrieve usage statistics via `get_stats()`:
270
+
271
+ ```python
272
+ response = await chat.chat(messages)
273
+ stats = chat.get_stats()
274
+
275
+ # Token usage (accumulated across all LLM calls in the agentic loop)
276
+ stats.tokens.prompt_tokens # Input tokens
277
+ stats.tokens.completion_tokens # Output tokens
278
+ stats.tokens.total_tokens # Total (computed)
279
+
280
+ # Tool call stats
281
+ stats.tool_calls.by_tool # Dict of tool name -> call count, e.g. {"math_add": 2}
282
+ stats.tool_calls.by_server # Dict of server name -> call count, e.g. {"math": 2}
283
+ stats.tool_calls.total # Total tool calls (computed)
284
+
285
+ # LLM call count
286
+ stats.llm_calls # Number of LLM calls made (1 = no tools, 2+ = tool loop)
287
+ ```
288
+
289
+ Stats are reset at the start of each new `chat()` or `generate()` call. Returns `None` if no calls have been made yet.
290
+
258
291
  #### `ProviderFactory`
259
292
  Instantiates LLM providers (from casual-llm) based on the selected model config.
260
293
 
@@ -294,6 +327,9 @@ Exported from `casual_mcp.models`:
294
327
  - `RemoteServerConfig`
295
328
  - `OpenAIModelConfig`
296
329
  - `OllamaModelConfig`
330
+ - `ChatStats`
331
+ - `TokenUsageStats`
332
+ - `ToolCallStats`
297
333
 
298
334
  Use these types to build valid configs:
299
335
 
@@ -582,9 +618,10 @@ casual-mcp serve --host 0.0.0.0 --port 8000
582
618
  #### Request Body:
583
619
  - `model`: the LLM model to use
584
620
  - `messages`: list of chat messages (system, assistant, user, etc) that you can pass to the api, allowing you to keep your own chat session in the client calling the api
621
+ - `include_stats`: (optional, default: `false`) include usage statistics in the response
585
622
 
586
623
  #### Example:
587
- ```
624
+ ```json
588
625
  {
589
626
  "model": "gpt-4.1-nano",
590
627
  "messages": [
@@ -592,13 +629,35 @@ casual-mcp serve --host 0.0.0.0 --port 8000
592
629
  "role": "user",
593
630
  "content": "can you explain what the word consistent means?"
594
631
  }
595
- ]
632
+ ],
633
+ "include_stats": true
634
+ }
635
+ ```
636
+
637
+ #### Response with stats:
638
+ ```json
639
+ {
640
+ "messages": [...],
641
+ "response": "Consistent means...",
642
+ "stats": {
643
+ "tokens": {
644
+ "prompt_tokens": 150,
645
+ "completion_tokens": 75,
646
+ "total_tokens": 225
647
+ },
648
+ "tool_calls": {
649
+ "by_tool": {"words_define": 1},
650
+ "by_server": {"words": 1},
651
+ "total": 1
652
+ },
653
+ "llm_calls": 2
654
+ }
596
655
  }
597
656
  ```
598
657
 
599
658
  ### Generate
600
659
 
601
- The generate endpoint allows you to send a user prompt as a string.
660
+ The generate endpoint allows you to send a user prompt as a string.
602
661
 
603
662
  It also support sessions that keep a record of all messages in the session and feeds them back into the LLM for context. Sessions are stored in memory so are cleared when the server is restarted
604
663
 
@@ -606,15 +665,17 @@ It also support sessions that keep a record of all messages in the session and f
606
665
 
607
666
  #### Request Body:
608
667
  - `model`: the LLM model to use
609
- - `prompt`: the user prompt
668
+ - `prompt`: the user prompt
610
669
  - `session_id`: an optional ID that stores all the messages from the session and provides them back to the LLM for context
670
+ - `include_stats`: (optional, default: `false`) include usage statistics in the response
611
671
 
612
672
  #### Example:
613
- ```
673
+ ```json
614
674
  {
615
675
  "session_id": "my-session",
616
676
  "model": "gpt-4o-mini",
617
- "prompt": "can you explain what the word consistent means?"
677
+ "prompt": "can you explain what the word consistent means?",
678
+ "include_stats": true
618
679
  }
619
680
  ```
620
681
 
@@ -1,20 +1,21 @@
1
- casual_mcp/__init__.py,sha256=X7xE1PVtbzkPo_2ad6gEPuDLWGLPkQ1WQjSRVgVuIZc,464
1
+ casual_mcp/__init__.py,sha256=eeI1TIj8Cu-H4OMV64LaNqVqo4wSFaGu7215hJeN_HM,598
2
2
  casual_mcp/cli.py,sha256=2-0sTxfNfQSukBtg0Xs9P6VrAMZ89SqJ9VJzOM68d-o,2129
3
3
  casual_mcp/convert_tools.py,sha256=mlH18DTGGeWb0Vxfj1cUSMhTGRE9z8q_xWrVXvpg3mE,1742
4
4
  casual_mcp/logging.py,sha256=S2XpLIKHHDtmru_YBFLdMamdmYRm16Yw3tshE3g3Wqg,932
5
- casual_mcp/main.py,sha256=UQJN5D0WGdimTrwNzVqc_FaTANWar8enBobIULp6EqE,3199
6
- casual_mcp/mcp_tool_chat.py,sha256=FIEgK8629AIgT9X6zTsLgKC3u3R00v_St-QF76WC0JY,5703
5
+ casual_mcp/main.py,sha256=aI3isW0Wzny_iubx8HlNgBVvYEeBe-Jrrdbp80oYmk4,4299
6
+ casual_mcp/mcp_tool_chat.py,sha256=Evc5LMfUYicl7jlix42QURYaq0cI2CIUg0q-344cjUg,8401
7
7
  casual_mcp/provider_factory.py,sha256=Jp2HQOJdlDDed-hfZf1drEVbw0kpZSE0TN9G0Dcp4w8,1260
8
8
  casual_mcp/tool_cache.py,sha256=VE599sF7vHH6megcueqVxCZavvTcoFDoZu2QuZM3cYA,3161
9
9
  casual_mcp/utils.py,sha256=XxzPxQ3j97edeCRXtoO8lJS9R0JYOa25p2MJNwGapJA,3201
10
- casual_mcp/models/__init__.py,sha256=yAYtRqA_cJqdOELYFqAXLxmyt3ld6LIWgezceu0PE1U,642
10
+ casual_mcp/models/__init__.py,sha256=byhteS6fueIdtoaQYL2w5hcBJmJhXF7X7YhGslvscco,786
11
+ casual_mcp/models/chat_stats.py,sha256=ZjeZ_ckx-SfioYs39NAaQxK6qPG9SlFlrB7j7jHZ40w,1221
11
12
  casual_mcp/models/config.py,sha256=LcqtfW3w7iqrT3FnW50L1mgqAvD_OsYk4ySBZZVV-GI,300
12
13
  casual_mcp/models/generation_error.py,sha256=abDAahS2fhYkS-ARng1Tk7oudoAO4imkoKYcC9PHT2U,272
13
14
  casual_mcp/models/mcp_server_config.py,sha256=0OHsHUEKxRoCl21lsye4E5GoCNmdZWIZCOOthcTpdsE,539
14
15
  casual_mcp/models/model_config.py,sha256=59Y7MvcboPKdAilSwUyeC7lfRm4aYkFhZ5c8EVRP5ys,425
15
- casual_mcp-0.5.0.dist-info/licenses/LICENSE,sha256=U3Zu2tkrh5vXdy7gIdE8WJGM9D4gGp3hohAAWdre-yo,1058
16
- casual_mcp-0.5.0.dist-info/METADATA,sha256=9e4sknE7ksYxSeoNs_R8-ftYzjBuqqVCZjmY_C6fY3s,20290
17
- casual_mcp-0.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
18
- casual_mcp-0.5.0.dist-info/entry_points.txt,sha256=X48Np2cwl-SlRQdV26y2vPZ-2tJaODgZeVtfpHho-zg,50
19
- casual_mcp-0.5.0.dist-info/top_level.txt,sha256=K4CiI0Jf8PHICjuQVm32HuNMB44kp8Lb02bbbdiH5bo,11
20
- casual_mcp-0.5.0.dist-info/RECORD,,
16
+ casual_mcp-0.6.0.dist-info/licenses/LICENSE,sha256=U3Zu2tkrh5vXdy7gIdE8WJGM9D4gGp3hohAAWdre-yo,1058
17
+ casual_mcp-0.6.0.dist-info/METADATA,sha256=GQLuEXfducugyuUHjB3qklz8FAOZ7go3PQ0d7Pqb2ZI,22218
18
+ casual_mcp-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
+ casual_mcp-0.6.0.dist-info/entry_points.txt,sha256=X48Np2cwl-SlRQdV26y2vPZ-2tJaODgZeVtfpHho-zg,50
20
+ casual_mcp-0.6.0.dist-info/top_level.txt,sha256=K4CiI0Jf8PHICjuQVm32HuNMB44kp8Lb02bbbdiH5bo,11
21
+ casual_mcp-0.6.0.dist-info/RECORD,,