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.
- mcptokens-0.1.0/.gitignore +31 -0
- mcptokens-0.1.0/CHANGELOG.md +9 -0
- mcptokens-0.1.0/LICENSE +21 -0
- mcptokens-0.1.0/PKG-INFO +63 -0
- mcptokens-0.1.0/README.md +34 -0
- mcptokens-0.1.0/pyproject.toml +53 -0
- mcptokens-0.1.0/src/mcptokens/__init__.py +11 -0
- mcptokens-0.1.0/src/mcptokens/__main__.py +4 -0
- mcptokens-0.1.0/src/mcptokens/_engine.py +413 -0
- mcptokens-0.1.0/src/mcptokens/_server.py +150 -0
- mcptokens-0.1.0/src/mcptokens/cli.py +215 -0
- mcptokens-0.1.0/tests/__init__.py +1 -0
- mcptokens-0.1.0/tests/test_cli.py +177 -0
- mcptokens-0.1.0/tests/test_engine.py +311 -0
- mcptokens-0.1.0/tests/test_server.py +221 -0
|
@@ -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
|
mcptokens-0.1.0/LICENSE
ADDED
|
@@ -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.
|
mcptokens-0.1.0/PKG-INFO
ADDED
|
@@ -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,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:]
|