onetool-mcp 1.0.0b1__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.
Files changed (132) hide show
  1. bench/__init__.py +5 -0
  2. bench/cli.py +69 -0
  3. bench/harness/__init__.py +66 -0
  4. bench/harness/client.py +692 -0
  5. bench/harness/config.py +397 -0
  6. bench/harness/csv_writer.py +109 -0
  7. bench/harness/evaluate.py +512 -0
  8. bench/harness/metrics.py +283 -0
  9. bench/harness/runner.py +899 -0
  10. bench/py.typed +0 -0
  11. bench/reporter.py +629 -0
  12. bench/run.py +487 -0
  13. bench/secrets.py +101 -0
  14. bench/utils.py +16 -0
  15. onetool/__init__.py +4 -0
  16. onetool/cli.py +391 -0
  17. onetool/py.typed +0 -0
  18. onetool_mcp-1.0.0b1.dist-info/METADATA +163 -0
  19. onetool_mcp-1.0.0b1.dist-info/RECORD +132 -0
  20. onetool_mcp-1.0.0b1.dist-info/WHEEL +4 -0
  21. onetool_mcp-1.0.0b1.dist-info/entry_points.txt +3 -0
  22. onetool_mcp-1.0.0b1.dist-info/licenses/LICENSE.txt +687 -0
  23. onetool_mcp-1.0.0b1.dist-info/licenses/NOTICE.txt +64 -0
  24. ot/__init__.py +37 -0
  25. ot/__main__.py +6 -0
  26. ot/_cli.py +107 -0
  27. ot/_tui.py +53 -0
  28. ot/config/__init__.py +46 -0
  29. ot/config/defaults/bench.yaml +4 -0
  30. ot/config/defaults/diagram-templates/api-flow.mmd +33 -0
  31. ot/config/defaults/diagram-templates/c4-context.puml +30 -0
  32. ot/config/defaults/diagram-templates/class-diagram.mmd +87 -0
  33. ot/config/defaults/diagram-templates/feature-mindmap.mmd +70 -0
  34. ot/config/defaults/diagram-templates/microservices.d2 +81 -0
  35. ot/config/defaults/diagram-templates/project-gantt.mmd +37 -0
  36. ot/config/defaults/diagram-templates/state-machine.mmd +42 -0
  37. ot/config/defaults/onetool.yaml +25 -0
  38. ot/config/defaults/prompts.yaml +97 -0
  39. ot/config/defaults/servers.yaml +7 -0
  40. ot/config/defaults/snippets.yaml +4 -0
  41. ot/config/defaults/tool_templates/__init__.py +7 -0
  42. ot/config/defaults/tool_templates/extension.py +52 -0
  43. ot/config/defaults/tool_templates/isolated.py +61 -0
  44. ot/config/dynamic.py +121 -0
  45. ot/config/global_templates/__init__.py +2 -0
  46. ot/config/global_templates/bench-secrets-template.yaml +6 -0
  47. ot/config/global_templates/bench.yaml +9 -0
  48. ot/config/global_templates/onetool.yaml +27 -0
  49. ot/config/global_templates/secrets-template.yaml +44 -0
  50. ot/config/global_templates/servers.yaml +18 -0
  51. ot/config/global_templates/snippets.yaml +235 -0
  52. ot/config/loader.py +1087 -0
  53. ot/config/mcp.py +145 -0
  54. ot/config/secrets.py +190 -0
  55. ot/config/tool_config.py +125 -0
  56. ot/decorators.py +116 -0
  57. ot/executor/__init__.py +35 -0
  58. ot/executor/base.py +16 -0
  59. ot/executor/fence_processor.py +83 -0
  60. ot/executor/linter.py +142 -0
  61. ot/executor/pack_proxy.py +260 -0
  62. ot/executor/param_resolver.py +140 -0
  63. ot/executor/pep723.py +288 -0
  64. ot/executor/result_store.py +369 -0
  65. ot/executor/runner.py +496 -0
  66. ot/executor/simple.py +163 -0
  67. ot/executor/tool_loader.py +396 -0
  68. ot/executor/validator.py +398 -0
  69. ot/executor/worker_pool.py +388 -0
  70. ot/executor/worker_proxy.py +189 -0
  71. ot/http_client.py +145 -0
  72. ot/logging/__init__.py +37 -0
  73. ot/logging/config.py +315 -0
  74. ot/logging/entry.py +213 -0
  75. ot/logging/format.py +188 -0
  76. ot/logging/span.py +349 -0
  77. ot/meta.py +1555 -0
  78. ot/paths.py +453 -0
  79. ot/prompts.py +218 -0
  80. ot/proxy/__init__.py +21 -0
  81. ot/proxy/manager.py +396 -0
  82. ot/py.typed +0 -0
  83. ot/registry/__init__.py +189 -0
  84. ot/registry/models.py +57 -0
  85. ot/registry/parser.py +269 -0
  86. ot/registry/registry.py +413 -0
  87. ot/server.py +315 -0
  88. ot/shortcuts/__init__.py +15 -0
  89. ot/shortcuts/aliases.py +87 -0
  90. ot/shortcuts/snippets.py +258 -0
  91. ot/stats/__init__.py +35 -0
  92. ot/stats/html.py +250 -0
  93. ot/stats/jsonl_writer.py +283 -0
  94. ot/stats/reader.py +354 -0
  95. ot/stats/timing.py +57 -0
  96. ot/support.py +63 -0
  97. ot/tools.py +114 -0
  98. ot/utils/__init__.py +81 -0
  99. ot/utils/batch.py +161 -0
  100. ot/utils/cache.py +120 -0
  101. ot/utils/deps.py +403 -0
  102. ot/utils/exceptions.py +23 -0
  103. ot/utils/factory.py +179 -0
  104. ot/utils/format.py +65 -0
  105. ot/utils/http.py +202 -0
  106. ot/utils/platform.py +45 -0
  107. ot/utils/sanitize.py +130 -0
  108. ot/utils/truncate.py +69 -0
  109. ot_tools/__init__.py +4 -0
  110. ot_tools/_convert/__init__.py +12 -0
  111. ot_tools/_convert/excel.py +279 -0
  112. ot_tools/_convert/pdf.py +254 -0
  113. ot_tools/_convert/powerpoint.py +268 -0
  114. ot_tools/_convert/utils.py +358 -0
  115. ot_tools/_convert/word.py +283 -0
  116. ot_tools/brave_search.py +604 -0
  117. ot_tools/code_search.py +736 -0
  118. ot_tools/context7.py +495 -0
  119. ot_tools/convert.py +614 -0
  120. ot_tools/db.py +415 -0
  121. ot_tools/diagram.py +1604 -0
  122. ot_tools/diagram.yaml +167 -0
  123. ot_tools/excel.py +1372 -0
  124. ot_tools/file.py +1348 -0
  125. ot_tools/firecrawl.py +732 -0
  126. ot_tools/grounding_search.py +646 -0
  127. ot_tools/package.py +604 -0
  128. ot_tools/py.typed +0 -0
  129. ot_tools/ripgrep.py +544 -0
  130. ot_tools/scaffold.py +471 -0
  131. ot_tools/transform.py +213 -0
  132. ot_tools/web_fetch.py +384 -0
ot/stats/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Runtime statistics collection for OneTool.
2
+
3
+ Two-level statistics:
4
+ - Run-level: Tracks run() calls, durations, and calculates context savings estimates.
5
+ - Tool-level: Tracks actual tool invocations at the executor dispatch level.
6
+
7
+ Records are stored in a single JSONL file with a 'type' field discriminator.
8
+ """
9
+
10
+ from ot.stats.html import generate_html_report
11
+ from ot.stats.jsonl_writer import (
12
+ JsonlStatsWriter,
13
+ get_client_name,
14
+ get_stats_writer,
15
+ record_tool_stats,
16
+ set_client_name,
17
+ set_stats_writer,
18
+ )
19
+ from ot.stats.reader import AggregatedStats, Period, StatsReader, ToolStats
20
+ from ot.stats.timing import timed_tool_call
21
+
22
+ __all__ = [
23
+ "AggregatedStats",
24
+ "JsonlStatsWriter",
25
+ "Period",
26
+ "StatsReader",
27
+ "ToolStats",
28
+ "generate_html_report",
29
+ "get_client_name",
30
+ "get_stats_writer",
31
+ "record_tool_stats",
32
+ "set_client_name",
33
+ "set_stats_writer",
34
+ "timed_tool_call",
35
+ ]
ot/stats/html.py ADDED
@@ -0,0 +1,250 @@
1
+ """HTML report generation for OneTool statistics.
2
+
3
+ Generates self-contained HTML reports with inline CSS, no JS dependencies.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ot.support import (
11
+ KOFI_URL,
12
+ SUPPORT_HTML_BUTTON_TEXT,
13
+ SUPPORT_HTML_MESSAGE,
14
+ SUPPORT_HTML_TITLE,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from ot.stats.reader import AggregatedStats
19
+
20
+
21
+ def generate_html_report(stats: AggregatedStats) -> str:
22
+ """Generate a self-contained HTML report from aggregated stats.
23
+
24
+ Args:
25
+ stats: Aggregated statistics from StatsReader
26
+
27
+ Returns:
28
+ Complete HTML document as string
29
+ """
30
+ # Format time saved in human-readable format
31
+ time_saved_seconds = stats.time_saved_ms / 1000
32
+ if time_saved_seconds >= 3600:
33
+ time_saved_str = f"{time_saved_seconds / 3600:.1f} hours"
34
+ elif time_saved_seconds >= 60:
35
+ time_saved_str = f"{time_saved_seconds / 60:.1f} minutes"
36
+ else:
37
+ time_saved_str = f"{time_saved_seconds:.1f} seconds"
38
+
39
+ # Format context saved
40
+ if stats.context_saved >= 1_000_000:
41
+ context_saved_str = f"{stats.context_saved / 1_000_000:.1f}M tokens"
42
+ elif stats.context_saved >= 1_000:
43
+ context_saved_str = f"{stats.context_saved / 1_000:.1f}K tokens"
44
+ else:
45
+ context_saved_str = f"{stats.context_saved} tokens"
46
+
47
+ # Format savings estimate with coffee equivalent
48
+ if stats.savings_usd >= 1.0:
49
+ savings_str = f"${stats.savings_usd:.2f}"
50
+ elif stats.savings_usd >= 0.01:
51
+ savings_str = f"${stats.savings_usd:.3f}"
52
+ else:
53
+ savings_str = f"${stats.savings_usd:.4f}"
54
+
55
+ # Coffee equivalent (whole number)
56
+ coffees = int(stats.coffees)
57
+ coffees_str = f"{coffees} coffee{'s' if coffees != 1 else ''}"
58
+
59
+ coffees_display = f"☕ {coffees_str}"
60
+
61
+ # Group tools by pack (prefix before the dot)
62
+ from collections import defaultdict
63
+
64
+ packs: dict[str, list] = defaultdict(list)
65
+ for tool in stats.tools:
66
+ pack_name = tool.tool.split(".")[0] if "." in tool.tool else "other"
67
+ packs[pack_name].append(tool)
68
+
69
+ # Build tool rows grouped by pack
70
+ tool_rows = ""
71
+ for pack_name in sorted(packs.keys()):
72
+ pack_tools = packs[pack_name]
73
+ pack_calls = sum(t.total_calls for t in pack_tools)
74
+ tool_rows += f"""
75
+ <tr class="pack-header">
76
+ <td><strong>{pack_name}</strong></td>
77
+ <td class="num"><strong>{pack_calls:,}</strong></td>
78
+ <td class="num"></td>
79
+ <td class="num"></td>
80
+ </tr>"""
81
+ for tool in pack_tools:
82
+ # Show just the function name (after the dot)
83
+ func_name = tool.tool.split(".", 1)[1] if "." in tool.tool else tool.tool
84
+ avg_duration = f"{tool.avg_duration_ms:.0f}ms"
85
+ tool_rows += f"""
86
+ <tr>
87
+ <td class="tool-name">{func_name}</td>
88
+ <td class="num">{tool.total_calls}</td>
89
+ <td class="num">{tool.success_rate:.1f}%</td>
90
+ <td class="num">{avg_duration}</td>
91
+ </tr>"""
92
+
93
+ # Period display
94
+ period_display = stats.period.capitalize()
95
+ if stats.start_time and stats.end_time:
96
+ # Extract just the date portion
97
+ start_date = stats.start_time[:10]
98
+ end_date = stats.end_time[:10]
99
+ if start_date == end_date:
100
+ period_display = f"{period_display} ({start_date})"
101
+ else:
102
+ period_display = f"{period_display} ({start_date} to {end_date})"
103
+
104
+ html = f"""<!DOCTYPE html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="UTF-8">
108
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
109
+ <title>OneTool Statistics Report</title>
110
+ <style>
111
+ :root {{
112
+ --bg: #f8f9fa;
113
+ --card-bg: #ffffff;
114
+ --text: #212529;
115
+ --text-muted: #6c757d;
116
+ --border: #dee2e6;
117
+ --primary: #0d6efd;
118
+ --success: #198754;
119
+ --warning: #ffc107;
120
+ --kofi: #ff5e5b;
121
+ }}
122
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
123
+ body {{
124
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
125
+ background: var(--bg);
126
+ color: var(--text);
127
+ line-height: 1.5;
128
+ padding: 2rem;
129
+ }}
130
+ .container {{ max-width: 1200px; margin: 0 auto; }}
131
+ h1 {{ margin-bottom: 0.5rem; }}
132
+ .subtitle {{ color: var(--text-muted); margin-bottom: 2rem; }}
133
+ .cards {{
134
+ display: grid;
135
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
136
+ gap: 1rem;
137
+ margin-bottom: 2rem;
138
+ }}
139
+ .card {{
140
+ background: var(--card-bg);
141
+ border: 1px solid var(--border);
142
+ border-radius: 8px;
143
+ padding: 1.5rem;
144
+ }}
145
+ .card-label {{ color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.25rem; }}
146
+ .card-value {{ font-size: 1.75rem; font-weight: 600; }}
147
+ .card-value.success {{ color: var(--success); }}
148
+ .card-value.primary {{ color: var(--primary); }}
149
+ .card-value.warning {{ color: var(--warning); }}
150
+ .card-sub {{ color: var(--text-muted); font-size: 0.875rem; margin-top: 0.25rem; }}
151
+ .card-sub.success {{ color: var(--success); }}
152
+ table {{
153
+ width: 100%;
154
+ background: var(--card-bg);
155
+ border: 1px solid var(--border);
156
+ border-radius: 8px;
157
+ border-collapse: collapse;
158
+ overflow: hidden;
159
+ }}
160
+ th, td {{ padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }}
161
+ th {{ background: var(--bg); font-weight: 600; }}
162
+ tr:last-child td {{ border-bottom: none; }}
163
+ .num {{ text-align: right; font-variant-numeric: tabular-nums; }}
164
+ .pack-header {{ background: var(--bg); }}
165
+ .tool-name {{ padding-left: 2rem; }}
166
+ .empty {{ color: var(--text-muted); text-align: center; padding: 2rem; }}
167
+ .support {{
168
+ margin-top: 2rem;
169
+ padding: 1.5rem;
170
+ background: var(--card-bg);
171
+ border: 1px solid var(--border);
172
+ border-radius: 8px;
173
+ text-align: center;
174
+ }}
175
+ .support h3 {{ margin-bottom: 0.5rem; font-size: 1rem; }}
176
+ .support p {{ color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem; }}
177
+ .kofi-btn {{
178
+ display: inline-block;
179
+ background: var(--kofi);
180
+ color: white;
181
+ text-decoration: none;
182
+ padding: 0.5rem 1rem;
183
+ border-radius: 6px;
184
+ font-weight: 500;
185
+ font-size: 0.875rem;
186
+ }}
187
+ .kofi-btn:hover {{ opacity: 0.9; }}
188
+ footer {{ margin-top: 2rem; color: var(--text-muted); font-size: 0.875rem; text-align: center; }}
189
+ </style>
190
+ </head>
191
+ <body>
192
+ <div class="container">
193
+ <h1>OneTool Statistics</h1>
194
+ <p class="subtitle">Period: {period_display}</p>
195
+
196
+ <div class="cards">
197
+ <div class="card">
198
+ <div class="card-label">Total Calls</div>
199
+ <div class="card-value">{stats.total_calls:,}</div>
200
+ </div>
201
+ <div class="card">
202
+ <div class="card-label">Call Success Rate</div>
203
+ <div class="card-value success">{stats.success_rate:.1f}%</div>
204
+ </div>
205
+ <div class="card">
206
+ <div class="card-label">Tokens Saved</div>
207
+ <div class="card-value primary">{context_saved_str}</div>
208
+ </div>
209
+ <div class="card">
210
+ <div class="card-label">Time Saved</div>
211
+ <div class="card-value primary">{time_saved_str}</div>
212
+ </div>
213
+ <div class="card">
214
+ <div class="card-label">Money Saved</div>
215
+ <div class="card-value success">{savings_str}</div>
216
+ <div class="card-sub success">{coffees_display}</div>
217
+ </div>
218
+ </div>
219
+
220
+ <h2 style="margin-bottom: 1rem;">Per-Tool Breakdown</h2>
221
+ <table>
222
+ <thead>
223
+ <tr>
224
+ <th>Tool</th>
225
+ <th class="num">Calls</th>
226
+ <th class="num">Success Rate</th>
227
+ <th class="num">Avg Duration</th>
228
+ </tr>
229
+ </thead>
230
+ <tbody>
231
+ {tool_rows if tool_rows else '<tr><td colspan="4" class="empty">No data available</td></tr>'}
232
+ </tbody>
233
+ </table>
234
+
235
+ <div class="support">
236
+ <h3>{SUPPORT_HTML_TITLE}</h3>
237
+ <p>{SUPPORT_HTML_MESSAGE}</p>
238
+ <a href="{KOFI_URL}" class="kofi-btn" target="_blank" rel="noopener">
239
+ {SUPPORT_HTML_BUTTON_TEXT}
240
+ </a>
241
+ </div>
242
+
243
+ <footer>
244
+ Generated by OneTool &middot; {stats.end_time[:19] if stats.end_time else 'N/A'}
245
+ </footer>
246
+ </div>
247
+ </body>
248
+ </html>"""
249
+
250
+ return html
@@ -0,0 +1,283 @@
1
+ """Unified JSONL stats writer for run-level and tool-level records.
2
+
3
+ Records are buffered in memory and flushed to JSONL at configurable intervals.
4
+ Data loss is tolerable - stats are nice-to-have, not critical.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import json
12
+ from datetime import UTC, datetime
13
+ from typing import TYPE_CHECKING, Any, Literal
14
+
15
+ from loguru import logger
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+ from pathlib import Path
20
+
21
+ RecordType = Literal["run", "tool"]
22
+
23
+
24
+ def _create_run_record(
25
+ client: str,
26
+ chars_in: int,
27
+ chars_out: int,
28
+ duration_ms: int,
29
+ success: bool,
30
+ error_type: str | None = None,
31
+ ) -> dict[str, Any]:
32
+ """Create a run-level stats record.
33
+
34
+ Args:
35
+ client: MCP client name (e.g., "Claude Desktop")
36
+ chars_in: Input character count
37
+ chars_out: Output character count
38
+ duration_ms: Execution time in milliseconds
39
+ success: Whether the run succeeded
40
+ error_type: Exception class name if failed
41
+
42
+ Returns:
43
+ Record dict ready for JSONL serialization
44
+ """
45
+ record: dict[str, Any] = {
46
+ "ts": datetime.now(UTC).isoformat(),
47
+ "type": "run",
48
+ "client": client,
49
+ "chars_in": chars_in,
50
+ "chars_out": chars_out,
51
+ "duration_ms": duration_ms,
52
+ "success": success,
53
+ }
54
+ if error_type:
55
+ record["error_type"] = error_type
56
+ return record
57
+
58
+
59
+ def _create_tool_record(
60
+ client: str,
61
+ tool: str,
62
+ duration_ms: int,
63
+ success: bool,
64
+ error_type: str | None = None,
65
+ ) -> dict[str, Any]:
66
+ """Create a tool-level stats record.
67
+
68
+ Args:
69
+ client: MCP client name (e.g., "Claude Desktop")
70
+ tool: Fully qualified tool name (e.g., "brave.search")
71
+ duration_ms: Execution time in milliseconds
72
+ success: Whether the tool call succeeded
73
+ error_type: Exception class name if failed
74
+
75
+ Returns:
76
+ Record dict ready for JSONL serialization
77
+ """
78
+ record: dict[str, Any] = {
79
+ "ts": datetime.now(UTC).isoformat(),
80
+ "type": "tool",
81
+ "client": client,
82
+ "tool": tool,
83
+ "duration_ms": duration_ms,
84
+ "success": success,
85
+ }
86
+ if error_type:
87
+ record["error_type"] = error_type
88
+ return record
89
+
90
+
91
+ class JsonlStatsWriter:
92
+ """Unified async batched JSONL writer for statistics.
93
+
94
+ Handles both run-level and tool-level records in a single file,
95
+ discriminated by the 'type' field.
96
+
97
+ Usage:
98
+ writer = JsonlStatsWriter(path, flush_interval=30)
99
+ await writer.start()
100
+
101
+ # Record run stats
102
+ writer.record_run(client="Claude", chars_in=500, ...)
103
+
104
+ # Record tool stats
105
+ writer.record_tool(client="Claude", tool="brave.search", ...)
106
+
107
+ # On shutdown
108
+ await writer.stop()
109
+ """
110
+
111
+ def __init__(self, path: Path, flush_interval: int = 30) -> None:
112
+ """Initialize writer.
113
+
114
+ Args:
115
+ path: Path to JSONL file
116
+ flush_interval: Seconds between flushes
117
+ """
118
+ self._path = path
119
+ self._flush_interval = flush_interval
120
+ self._buffer: list[dict[str, Any]] = []
121
+ self._lock = asyncio.Lock()
122
+ self._task: asyncio.Task[None] | None = None
123
+ self._running = False
124
+
125
+ @property
126
+ def path(self) -> Path:
127
+ """Get the JSONL file path."""
128
+ return self._path
129
+
130
+ def record_run(
131
+ self,
132
+ client: str,
133
+ chars_in: int,
134
+ chars_out: int,
135
+ duration_ms: int,
136
+ success: bool,
137
+ error_type: str | None = None,
138
+ ) -> None:
139
+ """Record a run-level stats event (non-blocking)."""
140
+ record = _create_run_record(
141
+ client=client,
142
+ chars_in=chars_in,
143
+ chars_out=chars_out,
144
+ duration_ms=duration_ms,
145
+ success=success,
146
+ error_type=error_type,
147
+ )
148
+ self._buffer.append(record)
149
+
150
+ def record_tool(
151
+ self,
152
+ client: str,
153
+ tool: str,
154
+ duration_ms: int,
155
+ success: bool,
156
+ error_type: str | None = None,
157
+ ) -> None:
158
+ """Record a tool-level stats event (non-blocking)."""
159
+ record = _create_tool_record(
160
+ client=client,
161
+ tool=tool,
162
+ duration_ms=duration_ms,
163
+ success=success,
164
+ error_type=error_type,
165
+ )
166
+ self._buffer.append(record)
167
+
168
+ async def start(self) -> None:
169
+ """Start the background flush task."""
170
+ if self._running:
171
+ return
172
+
173
+ self._running = True
174
+ self._task = asyncio.create_task(self._flush_loop())
175
+ logger.debug(f"JSONL stats writer started: {self._path}")
176
+
177
+ async def stop(self) -> None:
178
+ """Stop the writer and flush remaining records."""
179
+ self._running = False
180
+
181
+ if self._task is not None:
182
+ self._task.cancel()
183
+ with contextlib.suppress(asyncio.CancelledError):
184
+ await self._task
185
+ self._task = None
186
+
187
+ # Final flush
188
+ await self._flush()
189
+ logger.debug("JSONL stats writer stopped")
190
+
191
+ async def _flush_loop(self) -> None:
192
+ """Background task that flushes buffer periodically."""
193
+ while self._running:
194
+ try:
195
+ await asyncio.sleep(self._flush_interval)
196
+ await self._flush()
197
+ except asyncio.CancelledError:
198
+ break
199
+ except Exception as e:
200
+ # Log but don't crash - stats are not critical
201
+ logger.warning(f"JSONL stats flush error: {e}")
202
+
203
+ async def _flush(self) -> None:
204
+ """Flush buffer to JSONL file."""
205
+ async with self._lock:
206
+ if not self._buffer:
207
+ return
208
+
209
+ records = self._buffer.copy()
210
+
211
+ try:
212
+ await self._write_records(records)
213
+ # Clear buffer only after successful write to prevent data loss
214
+ async with self._lock:
215
+ del self._buffer[: len(records)]
216
+ logger.debug(f"Flushed {len(records)} JSONL stats records")
217
+ except Exception as e:
218
+ # Log but don't crash - stats are not critical
219
+ # Buffer NOT cleared; records will retry on next flush
220
+ logger.warning(f"Failed to write JSONL stats: {e}")
221
+
222
+ async def _write_records(self, records: Sequence[dict[str, Any]]) -> None:
223
+ """Write records to JSONL file."""
224
+ # Ensure parent directory exists
225
+ self._path.parent.mkdir(parents=True, exist_ok=True)
226
+
227
+ # Run blocking I/O in thread pool
228
+ await asyncio.to_thread(self._write_jsonl, records)
229
+
230
+ def _write_jsonl(self, records: Sequence[dict[str, Any]]) -> None:
231
+ """Sync JSONL write (called from thread pool)."""
232
+ with self._path.open("a") as f:
233
+ for record in records:
234
+ f.write(json.dumps(record, separators=(",", ":")) + "\n")
235
+
236
+
237
+ # Global stats writer instance (set by server on startup)
238
+ _stats_writer: JsonlStatsWriter | None = None
239
+ _client_name: str = "unknown"
240
+
241
+
242
+ def get_stats_writer() -> JsonlStatsWriter | None:
243
+ """Get the global stats writer instance."""
244
+ return _stats_writer
245
+
246
+
247
+ def set_stats_writer(writer: JsonlStatsWriter | None) -> None:
248
+ """Set the global stats writer instance."""
249
+ global _stats_writer
250
+ _stats_writer = writer
251
+
252
+
253
+ def get_client_name() -> str:
254
+ """Get the current MCP client name."""
255
+ return _client_name
256
+
257
+
258
+ def set_client_name(name: str) -> None:
259
+ """Set the current MCP client name."""
260
+ global _client_name
261
+ _client_name = name
262
+
263
+
264
+ def record_tool_stats(
265
+ tool: str,
266
+ duration_ms: int,
267
+ success: bool,
268
+ error_type: str | None = None,
269
+ ) -> None:
270
+ """Record tool-level stats if writer is available.
271
+
272
+ Convenience function for use from executor dispatch points.
273
+ Uses the global client name set during MCP initialization.
274
+ """
275
+ writer = get_stats_writer()
276
+ if writer is not None:
277
+ writer.record_tool(
278
+ client=get_client_name(),
279
+ tool=tool,
280
+ duration_ms=duration_ms,
281
+ success=success,
282
+ error_type=error_type,
283
+ )