mcptokens 0.1.0__tar.gz

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.
@@ -0,0 +1,31 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+ .pytest_cache/
12
+ .coverage
13
+ .coverage.*
14
+ htmlcov/
15
+
16
+ # Virtual envs
17
+ .venv/
18
+ venv/
19
+ env/
20
+
21
+ # Editor / OS
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+ .DS_Store
27
+ Thumbs.db
28
+
29
+ # Config / secrets
30
+ .env
31
+ .envrc
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to mcptokens are documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ First release in progress. See
8
+ `~/.pi/agent/workspace/Chatgpt pro subscription/contextlens/project.md`
9
+ for the goal and design constraints.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bishesh Bhandari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcptokens
3
+ Version: 0.1.0
4
+ Summary: Ultra-light MCP server for inspecting tool-definition token cost. Plug it into your agent harness.
5
+ Project-URL: Repository, https://github.com/dondai1234/contextlens
6
+ Project-URL: Issues, https://github.com/dondai1234/contextlens/issues
7
+ Project-URL: Changelog, https://github.com/dondai1234/contextlens/blob/master/CHANGELOG.md
8
+ Author-email: Bishesh Bhandari <bishesh@master-fetch.dev>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,context,inspect,mcp,tokens
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: mcp[cli]>=1.0
23
+ Requires-Dist: tiktoken>=0.7
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
26
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # mcptokens
31
+
32
+ Ultra-light MCP server for inspecting tool-definition token cost.
33
+ Plug it into your agent harness.
34
+
35
+ ```bash
36
+ pip install mcptokens
37
+ ```
38
+
39
+ ## What this is
40
+
41
+ `mcptokens` is an MCP server. Add a one-line entry to your agent's
42
+ MCP config (Claude Code, Pi, OpenCode, ...) and the agent gains
43
+ one tool: `inspect`. Call it with any MCP server's argv; receive
44
+ its token cost back. Use BEFORE enabling an MCP server so the
45
+ agent can decide whether the cost is worth it.
46
+
47
+ The product is the MCP tool definition. Nothing else ships.
48
+
49
+ ## Constraint
50
+
51
+ The whole point of `mcptokens` is that it doesn't eat many tokens
52
+ of its own. The shipped tool definition, tokenized under
53
+ `cl100k_base`, MUST stay under 1000 tokens. If a future refactor
54
+ blows the budget, an import-time `RuntimeError` fires. This
55
+ constraint is non-negotiable; tests pin it.
56
+
57
+ ## Status
58
+
59
+ 0.1.0 is the first version. See `CHANGELOG.md` once we ship.
60
+ For the goal articulation and the design constraints that drive
61
+ this package, see
62
+ `~/.pi/agent/workspace/Chatgpt pro subscription/contextlens/project.md`
63
+ (in this operator's workspace, not the published repo).
@@ -0,0 +1,34 @@
1
+ # mcptokens
2
+
3
+ Ultra-light MCP server for inspecting tool-definition token cost.
4
+ Plug it into your agent harness.
5
+
6
+ ```bash
7
+ pip install mcptokens
8
+ ```
9
+
10
+ ## What this is
11
+
12
+ `mcptokens` is an MCP server. Add a one-line entry to your agent's
13
+ MCP config (Claude Code, Pi, OpenCode, ...) and the agent gains
14
+ one tool: `inspect`. Call it with any MCP server's argv; receive
15
+ its token cost back. Use BEFORE enabling an MCP server so the
16
+ agent can decide whether the cost is worth it.
17
+
18
+ The product is the MCP tool definition. Nothing else ships.
19
+
20
+ ## Constraint
21
+
22
+ The whole point of `mcptokens` is that it doesn't eat many tokens
23
+ of its own. The shipped tool definition, tokenized under
24
+ `cl100k_base`, MUST stay under 1000 tokens. If a future refactor
25
+ blows the budget, an import-time `RuntimeError` fires. This
26
+ constraint is non-negotiable; tests pin it.
27
+
28
+ ## Status
29
+
30
+ 0.1.0 is the first version. See `CHANGELOG.md` once we ship.
31
+ For the goal articulation and the design constraints that drive
32
+ this package, see
33
+ `~/.pi/agent/workspace/Chatgpt pro subscription/contextlens/project.md`
34
+ (in this operator's workspace, not the published repo).
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "mcptokens"
3
+ version = "0.1.0"
4
+ description = "Ultra-light MCP server for inspecting tool-definition token cost. Plug it into your agent harness."
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.11"
8
+ authors = [{name = "Bishesh Bhandari", email = "bishesh@master-fetch.dev"}]
9
+ keywords = ["mcp", "tokens", "context", "ai-agent", "inspect"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Topic :: Software Development :: Libraries :: Python Modules",
17
+ "Intended Audience :: Developers",
18
+ "Environment :: Console",
19
+ "Framework :: AsyncIO",
20
+ ]
21
+ dependencies = [
22
+ "tiktoken>=0.7",
23
+ "mcp[cli]>=1.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ "pytest-asyncio>=0.24",
30
+ "pytest-mock>=3.14",
31
+ ]
32
+
33
+ [project.urls]
34
+ Repository = "https://github.com/dondai1234/contextlens"
35
+ Issues = "https://github.com/dondai1234/contextlens/issues"
36
+ Changelog = "https://github.com/dondai1234/contextlens/blob/master/CHANGELOG.md"
37
+
38
+ [project.scripts]
39
+ mcptokens = "mcptokens.cli:main"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/mcptokens"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ addopts = ["-ra", "--strict-markers"]
51
+ markers = [
52
+ "e2e: end-to-end tests that spawn real MCP servers. Skip by default; run manually with `pytest -m e2e`.",
53
+ ]
@@ -0,0 +1,11 @@
1
+ """mcptokens: ultra-light MCP server for inspecting tool-def token cost.
2
+
3
+ Use case: an AI agent (Claude Code, Pi, OpenCode, ...) connects to
4
+ mcptokens as one of its MCP servers, then calls the single exposed
5
+ tool `inspect` with a candidate server's argv. The agent gets back
6
+ per-tool tokens plus a realistic wire total. Use BEFORE enabling
7
+ an MCP server so the agent can decide whether the cost is worth it.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ """`python -m mcptokens ...` entry."""
2
+ from mcptokens.cli import main
3
+
4
+ raise SystemExit(main())
@@ -0,0 +1,413 @@
1
+ """mcptokens engine.
2
+
3
+ Spawn a stdio MCP server, speak JSON-RPC `initialize` + `tools/list`,
4
+ count the wire tokens that an LLM agent would receive. Cross-platform
5
+ safe (Windows in particular: stdlib has no `os.set_blocking`, and
6
+ `selectors.DefaultSelector` raises WinError 10093 unless WSAStartup
7
+ has run; we use a daemon reader thread + `queue.Queue` instead).
8
+
9
+ Public surface:
10
+ inspect_server(cmd, *, encoding="cl100k_base", timeout_seconds=15.0)
11
+ -> InspectReport
12
+ InspectError (raised on spawn / protocol failures)
13
+ InspectReport, ToolStats (dataclasses; serialize via .as_dict())
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import queue
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ import tiktoken
28
+
29
+ __all__ = [
30
+ "inspect_server",
31
+ "InspectError",
32
+ "InspectReport",
33
+ "ToolStats",
34
+ "DEFAULT_ENCODING",
35
+ "DEFAULT_TIMEOUT_SECONDS",
36
+ ]
37
+
38
+ DEFAULT_ENCODING = "cl100k_base"
39
+ DEFAULT_TIMEOUT_SECONDS = 15.0
40
+ SUPPORTED_ENCODINGS = ("cl100k_base", "o200k_base")
41
+ _INIT_ID = 1
42
+ _TOOLS_LIST_ID = 2
43
+ _LSP_PROTOCOL_VERSION = "2024-11-05"
44
+
45
+
46
+ class InspectError(Exception):
47
+ """Raised for spawn / protocol / shape failures that the agent
48
+ needs to know about so it can retry or fall back."""
49
+
50
+
51
+ # --- output dataclasses ---------------------------------------------------
52
+
53
+
54
+ @dataclass
55
+ class ToolStats:
56
+ name: str
57
+ name_tokens: int = 0
58
+ description_tokens: int = 0
59
+ schema_tokens: int = 0
60
+ annotations_tokens: int = 0
61
+ total_tokens: int = 0
62
+
63
+ def as_dict(self) -> dict[str, Any]:
64
+ return {
65
+ "name": self.name,
66
+ "tokens": {
67
+ "name": self.name_tokens,
68
+ "description": self.description_tokens,
69
+ "schema": self.schema_tokens,
70
+ "annotations": self.annotations_tokens,
71
+ "total": self.total_tokens,
72
+ },
73
+ }
74
+
75
+
76
+ @dataclass
77
+ class InspectReport:
78
+ ok: bool
79
+ server: str
80
+ tools: list[ToolStats] = field(default_factory=list)
81
+ wire_total_tokens: int = 0
82
+ encoding: str = DEFAULT_ENCODING
83
+ elapsed_ms: int = 0
84
+ error: str = ""
85
+ version: str = ""
86
+
87
+ def as_dict(self) -> dict[str, Any]:
88
+ return {
89
+ "ok": self.ok,
90
+ "server": self.server,
91
+ "tool_count": len(self.tools),
92
+ "tools": [t.as_dict() for t in self.tools],
93
+ "wire_total_tokens": self.wire_total_tokens,
94
+ "encoding": self.encoding,
95
+ "elapsed_ms": self.elapsed_ms,
96
+ "version": self.version,
97
+ }
98
+
99
+
100
+ # --- json-rpc helpers -----------------------------------------------------
101
+
102
+
103
+ def _encode(msg: dict[str, Any]) -> bytes:
104
+ return (json.dumps(msg, separators=(",", ":")) + "\n").encode("utf-8")
105
+
106
+
107
+ def _decode(b: bytes) -> dict[str, Any] | None:
108
+ try:
109
+ obj = json.loads(b.decode("utf-8"))
110
+ except (UnicodeDecodeError, json.JSONDecodeError):
111
+ return None
112
+ if not isinstance(obj, dict):
113
+ return None
114
+ return obj
115
+
116
+
117
+ # --- defensive shape coercion --------------------------------------------
118
+
119
+
120
+ def _coerce_tools(result: Any) -> list[dict[str, Any]]:
121
+ """The server's `tools/list` result shape can drift. We accept:
122
+ {"tools": [...]}, [...], None, "anything else" -> [].
123
+ Each tool entry that isn't a dict is dropped.
124
+ """
125
+ if isinstance(result, list):
126
+ candidates = result
127
+ elif isinstance(result, dict):
128
+ inner = result.get("tools")
129
+ candidates = inner if isinstance(inner, list) else []
130
+ else:
131
+ candidates = []
132
+ return [t for t in candidates if isinstance(t, dict)]
133
+
134
+
135
+ def _count_tool(tool: dict[str, Any], enc) -> ToolStats:
136
+ """Recipe A+ — split the wire bytes into 4 buckets so the agent
137
+ sees where its tokens are, then return a ToolStats."""
138
+ name = str(tool.get("name") or "").strip() or "<unnamed>"
139
+ description = tool.get("description") or ""
140
+ schema = tool.get("inputSchema") or {}
141
+ annotations = tool.get("annotations") or {}
142
+ name_tokens = len(enc.encode(name))
143
+ description_tokens = len(enc.encode(description)) if isinstance(description, str) else 0
144
+ schema_tokens = len(enc.encode(json.dumps(schema, separators=(",", ":")))) if schema else 0
145
+ annotations_tokens = (
146
+ len(enc.encode(json.dumps(annotations, separators=(",", ":"))))
147
+ if annotations
148
+ else 0
149
+ )
150
+ total = name_tokens + description_tokens + schema_tokens + annotations_tokens
151
+ return ToolStats(
152
+ name=name,
153
+ name_tokens=name_tokens,
154
+ description_tokens=description_tokens,
155
+ schema_tokens=schema_tokens,
156
+ annotations_tokens=annotations_tokens,
157
+ total_tokens=total,
158
+ )
159
+
160
+
161
+ # --- subprocess plumbing -------------------------------------------------
162
+
163
+
164
+ class _Killed(Exception):
165
+ """Internal: process didn't exit cleanly on shutdown."""
166
+
167
+
168
+ def _spawn(cmd: list[str]) -> subprocess.Popen:
169
+ """Spawn the server. Never raise FileNotFoundError — re-raise as
170
+ `InspectError` so the agent gets one predictable failure type."""
171
+ if not cmd:
172
+ raise InspectError("spawn cmd is empty")
173
+ try:
174
+ return subprocess.Popen(
175
+ cmd,
176
+ stdin=subprocess.PIPE,
177
+ stdout=subprocess.PIPE,
178
+ stderr=subprocess.PIPE,
179
+ bufsize=0,
180
+ )
181
+ except FileNotFoundError as exc:
182
+ raise InspectError(f"spawn failed: command not found: {cmd[0]!r}") from exc
183
+ except (PermissionError, OSError) as exc:
184
+ raise InspectError(f"spawn failed: {exc}") from exc
185
+
186
+
187
+ class _StdioReader:
188
+ """Daemon reader thread: stdout -> `queue.Queue`. We use a thread
189
+ instead of `selectors.DefaultSelector` because the latter raises
190
+ `[WinError 10093]` (WSAStartup not called) on Windows when the
191
+ current Python process hasn't yet opened a socket.
192
+
193
+ Sentinel values for stream-lifecycle:
194
+ None -> EOF (process closed stdout)
195
+ ("ERR", exc) -> reader died with an exception
196
+ """
197
+
198
+ def __init__(self, proc: subprocess.Popen) -> None:
199
+ self._proc = proc
200
+ self._q: queue.Queue = queue.Queue()
201
+ self._t = threading.Thread(target=self._run, daemon=True)
202
+ self._t.start()
203
+
204
+ def _run(self) -> None:
205
+ try:
206
+ while True:
207
+ line = self._proc.stdout.readline()
208
+ if not line:
209
+ self._q.put(None)
210
+ return
211
+ self._q.put(line)
212
+ except Exception as exc: # pragma: no cover (defensive)
213
+ self._q.put(("ERR", exc))
214
+
215
+ def recv_until_id(
216
+ self,
217
+ expected_id: Any,
218
+ deadline_monotonic: float,
219
+ ) -> dict[str, Any] | None:
220
+ """Read JSON-RPC frames until we see one whose `id` matches.
221
+ Skip notifications and out-of-order replies."""
222
+ while True:
223
+ remaining = max(0.0, deadline_monotonic - time.monotonic())
224
+ if remaining <= 0:
225
+ return None
226
+ try:
227
+ line = self._q.get(timeout=remaining)
228
+ except queue.Empty:
229
+ return None
230
+ if line is None:
231
+ return None # EOF
232
+ if isinstance(line, tuple) and line[0] == "ERR":
233
+ raise InspectError(f"reader died: {line[1]!r}")
234
+ msg = _decode(line)
235
+ if msg is None:
236
+ continue
237
+ if "id" not in msg:
238
+ continue # notification: skip
239
+ if msg.get("id") != expected_id:
240
+ continue
241
+ return msg
242
+
243
+
244
+ # --- top-level entry -----------------------------------------------------
245
+
246
+
247
+ def inspect_server(
248
+ cmd: list[str],
249
+ *,
250
+ encoding: str = DEFAULT_ENCODING,
251
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
252
+ version: str = "0.1.0",
253
+ ) -> InspectReport:
254
+ """Spawn `cmd`, run initialize + tools/list, count tokens. The
255
+ output is the same for the CLI and the MCP server path."""
256
+ if not isinstance(cmd, list) or not cmd or not all(isinstance(a, str) for a in cmd):
257
+ raise InspectError("cmd must be a non-empty list[str] of strings")
258
+ if encoding not in SUPPORTED_ENCODINGS:
259
+ raise InspectError(
260
+ f"encoding {encoding!r} is not supported. "
261
+ f"Pick one of {list(SUPPORTED_ENCODINGS)}."
262
+ )
263
+ if timeout_seconds <= 0 or timeout_seconds > 60:
264
+ raise InspectError(
265
+ f"timeout_seconds {timeout_seconds!r} is outside (0, 60]"
266
+ )
267
+
268
+ enc = tiktoken.get_encoding(encoding)
269
+ server_label = " ".join(cmd)
270
+ started = time.monotonic()
271
+ proc = _spawn(cmd)
272
+
273
+ try:
274
+ deadline = started + timeout_seconds
275
+ reader = _StdioReader(proc)
276
+
277
+ try:
278
+ proc.stdin.write(
279
+ _encode(
280
+ {
281
+ "jsonrpc": "2.0",
282
+ "id": _INIT_ID,
283
+ "method": "initialize",
284
+ "params": {
285
+ "protocolVersion": _LSP_PROTOCOL_VERSION,
286
+ "capabilities": {},
287
+ "clientInfo": {
288
+ "name": "mcptokens",
289
+ "version": version,
290
+ },
291
+ },
292
+ }
293
+ )
294
+ )
295
+ proc.stdin.flush()
296
+ init = reader.recv_until_id(_INIT_ID, deadline)
297
+ if init is None:
298
+ raise InspectError(
299
+ f"`initialize` exceeded {timeout_seconds:g}s without response"
300
+ )
301
+ if "error" in init:
302
+ raise InspectError(
303
+ f"server error on `initialize`: {init['error']}"
304
+ )
305
+
306
+ # notifications/initialized (no id; agent doesn't reply)
307
+ try:
308
+ proc.stdin.write(
309
+ _encode({"jsonrpc": "2.0", "method": "notifications/initialized"})
310
+ )
311
+ proc.stdin.flush()
312
+ except (BrokenPipeError, OSError):
313
+ pass
314
+
315
+ proc.stdin.write(
316
+ _encode(
317
+ {
318
+ "jsonrpc": "2.0",
319
+ "id": _TOOLS_LIST_ID,
320
+ "method": "tools/list",
321
+ "params": {},
322
+ }
323
+ )
324
+ )
325
+ proc.stdin.flush()
326
+ tools_msg = reader.recv_until_id(_TOOLS_LIST_ID, deadline)
327
+ if tools_msg is None:
328
+ raise InspectError(
329
+ f"`tools/list` exceeded {timeout_seconds:g}s without response"
330
+ )
331
+ if "error" in tools_msg:
332
+ raise InspectError(
333
+ f"server error on `tools/list`: {tools_msg['error']}"
334
+ )
335
+
336
+ finally:
337
+ try:
338
+ if proc.stdin and not proc.stdin.closed:
339
+ proc.stdin.close()
340
+ except OSError:
341
+ pass
342
+
343
+ # Close stdin so the server's loop reads EOF. Give it 1s to
344
+ # exit cleanly; if it doesn't, kill it.
345
+ try:
346
+ proc.wait(timeout=1.0)
347
+ except subprocess.TimeoutExpired:
348
+ proc.kill()
349
+ try:
350
+ proc.wait(timeout=1.0)
351
+ except subprocess.TimeoutExpired: # pragma: no cover
352
+ pass
353
+
354
+ except InspectError:
355
+ # On error: close stdin to break server's reader, kill if not
356
+ # exiting, then drain stderr to give the user a one-liner
357
+ # into the failure.
358
+ try:
359
+ if proc.stdin and not proc.stdin.closed:
360
+ proc.stdin.close()
361
+ except OSError:
362
+ pass
363
+ if proc.poll() is None:
364
+ proc.kill()
365
+ try:
366
+ proc.wait(timeout=1.0)
367
+ except subprocess.TimeoutExpired: # pragma: no cover
368
+ pass
369
+ stderr_tail = _drain_stderr(proc, max_chars=400)
370
+ if stderr_tail:
371
+ # Re-raise with the tail appended so the agent sees one
372
+ # tidy line, not a stack trace.
373
+ try:
374
+ raise
375
+ except InspectError as exc:
376
+ if stderr_tail not in str(exc):
377
+ raise InspectError(f"{exc} | server stderr: {stderr_tail!r}") from None
378
+ raise
379
+ finally:
380
+ # On happy path, still drain stderr in case there were warnings
381
+ # worth recording. Don't blow up if the proc is gone.
382
+ _ = proc.poll()
383
+
384
+ result = tools_msg.get("result")
385
+ tools = _coerce_tools(result)
386
+ stats = [_count_tool(t, enc) for t in tools]
387
+ wire_total = len(enc.encode(json.dumps(result if result is not None else {}, separators=(",", ":"))))
388
+
389
+ elapsed_ms = int((time.monotonic() - started) * 1000)
390
+ return InspectReport(
391
+ ok=True,
392
+ server=server_label,
393
+ tools=stats,
394
+ wire_total_tokens=wire_total,
395
+ encoding=encoding,
396
+ elapsed_ms=elapsed_ms,
397
+ version=version,
398
+ )
399
+
400
+
401
+ def _drain_stderr(proc: subprocess.Popen, *, max_chars: int) -> str:
402
+ """Read whatever is left on stderr (after the process is dead).
403
+ Don't block forever — fd is closed; the OS says EOF fast."""
404
+ try:
405
+ raw = proc.stderr.read()
406
+ except (OSError, ValueError):
407
+ return ""
408
+ if not raw:
409
+ return ""
410
+ text = raw.decode("utf-8", errors="replace").strip()
411
+ if len(text) <= max_chars:
412
+ return text
413
+ return "..." + text[-max_chars:]