coding-tools-mcp 0.2.0__tar.gz → 0.2.1__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.
Files changed (42) hide show
  1. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/PKG-INFO +11 -1
  2. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/README.md +10 -0
  3. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/__init__.py +1 -1
  4. coding_tools_mcp-0.2.1/coding_tools_mcp/envutils.py +20 -0
  5. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/protocol.py +2 -1
  6. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/server.py +23 -14
  7. coding_tools_mcp-0.2.1/coding_tools_mcp/telemetry.py +336 -0
  8. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/transport_stdio.py +1 -1
  9. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/PKG-INFO +11 -1
  10. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/SOURCES.txt +4 -1
  11. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/pyproject.toml +1 -1
  12. coding_tools_mcp-0.2.1/tests/test_telemetry.py +267 -0
  13. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/LICENSE +0 -0
  14. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/NOTICE +0 -0
  15. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/__init__.py +0 -0
  16. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/app.py +0 -0
  17. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/health.py +0 -0
  18. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/i18n.py +0 -0
  19. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/language_manager.py +0 -0
  20. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/locales/app_zh_CN.qm +0 -0
  21. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/locales/app_zh_CN.ts +0 -0
  22. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/models.py +0 -0
  23. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/runtime.py +0 -0
  24. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/storage.py +0 -0
  25. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/theme.py +0 -0
  26. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/__main__.py +0 -0
  27. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/errors.py +0 -0
  28. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/landlock_exec.py +0 -0
  29. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/oauth.py +0 -0
  30. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/patching.py +0 -0
  31. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/processes.py +0 -0
  32. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/project_context.py +0 -0
  33. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/textutils.py +0 -0
  34. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/tool_results.py +0 -0
  35. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/transport_http.py +0 -0
  36. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/dependency_links.txt +0 -0
  37. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/entry_points.txt +0 -0
  38. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/requires.txt +0 -0
  39. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/top_level.txt +0 -0
  40. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/setup.cfg +0 -0
  41. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/tests/test_desktop_client.py +0 -0
  42. {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/tests/test_release_checks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: coding-tools-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Workspace-confined coding tools exposed as an MCP server.
5
5
  Author: Coding Tools MCP Contributors
6
6
  License: Apache License
@@ -535,6 +535,16 @@ The runtime binds one workspace root per server process. Paths are workspace-rel
535
535
 
536
536
  `--permission-mode safe` is the default. `--permission-mode trusted` opens local-development gates while keeping secret filtering and destructive-command checks. `--permission-mode dangerous` disables `exec_command` permission gates for operators who accept that risk inside an isolated runner. Do not use dangerous mode for untrusted workspaces or untrusted MCP clients.
537
537
 
538
+ ## Telemetry
539
+
540
+ The server sends anonymous usage telemetry (per-tool success/latency counters
541
+ and version/platform dimensions — never paths, arguments, commands, or file
542
+ contents) to help prioritize fixes. Disable it with
543
+ `CODING_TOOLS_MCP_TELEMETRY=off` or `DO_NOT_TRACK=1`; it is automatically off
544
+ in CI. `CODING_TOOLS_MCP_TELEMETRY=debug` prints every event to stderr instead
545
+ of sending. The full event list and guarantees are in
546
+ [docs/telemetry.md](docs/telemetry.md).
547
+
538
548
  ## Compliance
539
549
 
540
550
  ```bash
@@ -307,6 +307,16 @@ The runtime binds one workspace root per server process. Paths are workspace-rel
307
307
 
308
308
  `--permission-mode safe` is the default. `--permission-mode trusted` opens local-development gates while keeping secret filtering and destructive-command checks. `--permission-mode dangerous` disables `exec_command` permission gates for operators who accept that risk inside an isolated runner. Do not use dangerous mode for untrusted workspaces or untrusted MCP clients.
309
309
 
310
+ ## Telemetry
311
+
312
+ The server sends anonymous usage telemetry (per-tool success/latency counters
313
+ and version/platform dimensions — never paths, arguments, commands, or file
314
+ contents) to help prioritize fixes. Disable it with
315
+ `CODING_TOOLS_MCP_TELEMETRY=off` or `DO_NOT_TRACK=1`; it is automatically off
316
+ in CI. `CODING_TOOLS_MCP_TELEMETRY=debug` prints every event to stderr instead
317
+ of sending. The full event list and guarantees are in
318
+ [docs/telemetry.md](docs/telemetry.md).
319
+
310
320
  ## Compliance
311
321
 
312
322
  ```bash
@@ -1,3 +1,3 @@
1
1
  """Coding Tools MCP server package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
@@ -0,0 +1,20 @@
1
+ """Small stdlib-only helpers shared across the package.
2
+
3
+ A leaf module: it imports nothing from the package, so both server.py and
4
+ modules server.py itself imports (like telemetry.py) can use these without
5
+ creating an import cycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+
12
+ ENV_PREFIX = "CODING_TOOLS_MCP"
13
+
14
+
15
+ def truthy_env(value: str | None) -> bool:
16
+ return (value or "").strip().lower() in {"1", "true", "yes", "on"}
17
+
18
+
19
+ def utc_now() -> str:
20
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -87,7 +87,8 @@ def dispatch_rpc(runtime: Any, request: dict[str, Any]) -> dict[str, Any] | None
87
87
  raise JsonRpcError(-32600, "Server is already initialized")
88
88
  validate_initialize_request(request)
89
89
  runtime.protocol_version = validate_initialize_params(params)
90
- result = runtime.initialize()
90
+ client_info = params.get("clientInfo")
91
+ result = runtime.initialize(client_info if isinstance(client_info, dict) else None)
91
92
  runtime.initialized = True
92
93
  elif method == "notifications/initialized":
93
94
  return None
@@ -32,6 +32,7 @@ from pathlib import Path, PurePosixPath
32
32
  from typing import Any, cast
33
33
 
34
34
  from . import __version__
35
+ from .envutils import ENV_PREFIX, truthy_env
35
36
  from .errors import JsonRpcError, ToolFailure
36
37
  from .oauth import (
37
38
  OAUTH_CODE_TTL_SECONDS,
@@ -70,6 +71,7 @@ from .protocol import (
70
71
  validate_rpc_envelope,
71
72
  )
72
73
  from .project_context import ProjectContext, load_project_context
74
+ from .telemetry import SessionTelemetry
73
75
  from .textutils import DEFAULT_MAX_LINES, truncate_text_head
74
76
  from .tool_results import make_tool_result
75
77
  from .transport_http import HTTPSessionManager
@@ -242,7 +244,6 @@ ENV_FLAG_OPTIONS = {
242
244
  }
243
245
  NETWORK_LITERAL_COMMANDS = {"echo", "printf", "grep", "egrep", "fgrep", "rg", "cat", "head", "tail", "wc"}
244
246
  INLINE_SCRIPT_PERMISSION = "inline_script"
245
- ENV_PREFIX = "CODING_TOOLS_MCP"
246
247
  RUNTIME_ROOT_DIR_NAME = "coding-tools-mcp"
247
248
  SPECIAL_DEVICE_PATHS = ("/dev/null", "/dev/zero", "/dev/random", "/dev/urandom")
248
249
  DNS_RESOLVER_READ_ROOTS = (
@@ -391,10 +392,6 @@ def parse_shell_env_set(value: str | None) -> dict[str, str]:
391
392
  return {str(key): str(item) for key, item in parsed.items()}
392
393
 
393
394
 
394
- def truthy_env(value: str | None) -> bool:
395
- return (value or "").strip().lower() in {"1", "true", "yes", "on"}
396
-
397
-
398
395
  def env_int(name: str, fallback: int) -> int:
399
396
  raw = (os.environ.get(name) or "").strip()
400
397
  try:
@@ -689,10 +686,6 @@ LANDLOCK_ACCESS_FS_TRUNCATE = 1 << 14
689
686
  LANDLOCK_ACCESS_FS_IOCTL_DEV = 1 << 15
690
687
 
691
688
 
692
- def utc_now() -> str:
693
- return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
694
-
695
-
696
689
  def json_response_payload(payload: Any) -> bytes:
697
690
  return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
698
691
 
@@ -1137,6 +1130,7 @@ class Runtime:
1137
1130
  oauth_config: OAuthConfig | None = None,
1138
1131
  project_context: ProjectContext | None = None,
1139
1132
  fake_readonly_annotations: bool = False,
1133
+ transport: str = "stdio",
1140
1134
  ) -> None:
1141
1135
  self.workspace = Workspace(workspace)
1142
1136
  self.enable_view_image = enable_view_image
@@ -1198,6 +1192,7 @@ class Runtime:
1198
1192
  self.request_sessions_lock = threading.Lock()
1199
1193
  self.request_context = threading.local()
1200
1194
  self.initialized = False
1195
+ self.telemetry = SessionTelemetry(permission_mode=self.permission_mode, transport=transport)
1201
1196
  self._tool_handlers = {name: getattr(self, name) for name in TOOL_REGISTRY}
1202
1197
 
1203
1198
  def _set_runtime_dir(self, runtime_dir: Path) -> None:
@@ -1220,6 +1215,7 @@ class Runtime:
1220
1215
  terminate_process_group(session.process, signal.SIGTERM)
1221
1216
  session.drain_readers()
1222
1217
  shutil.rmtree(self.runtime_dir, ignore_errors=True)
1218
+ self.telemetry.finish()
1223
1219
 
1224
1220
  def _ensure_runtime_dirs(self) -> None:
1225
1221
  candidates = [self.runtime_dir]
@@ -1285,7 +1281,8 @@ class Runtime:
1285
1281
  return False
1286
1282
  return is_relative_to(resolved, self.runtime_dir)
1287
1283
 
1288
- def initialize(self) -> dict[str, Any]:
1284
+ def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any]:
1285
+ self.telemetry.record_session_start(client_info, self.protocol_version)
1289
1286
  return {
1290
1287
  "protocolVersion": self.protocol_version,
1291
1288
  "capabilities": {"tools": {"listChanged": False}},
@@ -1487,17 +1484,26 @@ class Runtime:
1487
1484
  }
1488
1485
 
1489
1486
  def emit_tool_trace(self, name: str, args: dict[str, Any], payload: dict[str, Any], started_at: float) -> None:
1487
+ raw_error = payload.get("error")
1488
+ error = raw_error if isinstance(raw_error, dict) else {}
1489
+ duration_ms = int((time.time() - started_at) * 1000)
1490
+ self.telemetry.record_tool_call(
1491
+ name,
1492
+ ok=bool(payload.get("ok")),
1493
+ error_code=error.get("code"),
1494
+ duration_ms=duration_ms,
1495
+ truncated=bool(payload.get("truncated")),
1496
+ )
1490
1497
  if os.environ.get(f"{ENV_PREFIX}_TRACE") != "1":
1491
1498
  return
1492
- error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
1493
1499
  event = {
1494
1500
  "event": "tool_call",
1495
1501
  "timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
1496
1502
  "tool": name,
1497
1503
  "ok": bool(payload.get("ok", False)),
1498
1504
  "status": payload.get("status"),
1499
- "error_code": error.get("code") if isinstance(error, dict) else None,
1500
- "duration_ms": int((time.time() - started_at) * 1000),
1505
+ "error_code": error.get("code"),
1506
+ "duration_ms": duration_ms,
1501
1507
  "session_id": payload.get("session_id"),
1502
1508
  "truncated": payload.get("truncated"),
1503
1509
  "args": redact_for_trace(args),
@@ -5412,6 +5418,7 @@ def build_runtime(
5412
5418
  oauth_config: OAuthConfig | None = None,
5413
5419
  emit_warning: bool = True,
5414
5420
  project_context: ProjectContext | None = None,
5421
+ transport: str = "stdio",
5415
5422
  ) -> Runtime:
5416
5423
  workspace = Path(args.workspace or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") or os.getcwd())
5417
5424
  runtime = Runtime(
@@ -5424,6 +5431,7 @@ def build_runtime(
5424
5431
  oauth_config=oauth_config,
5425
5432
  project_context=project_context,
5426
5433
  fake_readonly_annotations=runtime_policy.fake_readonly_annotations,
5434
+ transport=transport,
5427
5435
  )
5428
5436
  if emit_warning and runtime.capabilities.skip_all_permissions:
5429
5437
  print(
@@ -5550,7 +5558,7 @@ def run_http(args: argparse.Namespace) -> int:
5550
5558
  )
5551
5559
  return 2
5552
5560
 
5553
- runtime = build_runtime(args, runtime_policy, auth_token=auth_token, oauth_config=oauth_config)
5561
+ runtime = build_runtime(args, runtime_policy, auth_token=auth_token, oauth_config=oauth_config, transport="http")
5554
5562
 
5555
5563
  def runtime_factory() -> Runtime:
5556
5564
  return build_runtime(
@@ -5560,6 +5568,7 @@ def run_http(args: argparse.Namespace) -> int:
5560
5568
  oauth_config=oauth_config,
5561
5569
  emit_warning=False,
5562
5570
  project_context=runtime.project_context,
5571
+ transport="http",
5563
5572
  )
5564
5573
 
5565
5574
  server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime, runtime_factory)
@@ -0,0 +1,336 @@
1
+ """Anonymous product telemetry: counters and enums sent to PostHog over HTTPS.
2
+
3
+ The wire payload is a closed schema built exclusively from tool names, error
4
+ codes, durations, counts, and version/platform strings. It is structurally
5
+ incapable of carrying file paths, tool arguments, command lines, or file
6
+ contents. The exact event list is documented in docs/telemetry.md.
7
+
8
+ Controls, evaluated on every send decision:
9
+
10
+ - ``CODING_TOOLS_MCP_TELEMETRY=off`` (or ``0``/``false``/``no``) disables sending.
11
+ - ``DO_NOT_TRACK=1`` disables sending.
12
+ - ``CI`` set truthy disables sending so CI runs never pollute usage data.
13
+ - ``CODING_TOOLS_MCP_TELEMETRY=debug`` prints events to stderr instead of sending.
14
+
15
+ Sending never blocks a tool call: ``record_tool_call`` only increments in-memory
16
+ counters under its lock, events queue on a bounded queue serviced by a daemon
17
+ thread, failures are swallowed, and overflow is dropped. Nothing
18
+ telemetry-related is written to disk except one random install id under
19
+ ``~/.coding-tools-mcp/id``, used only to de-duplicate active-user counts;
20
+ deleting that file resets the identity.
21
+
22
+ Events are emitted only for sessions that completed a real MCP ``initialize``
23
+ handshake, so importing this module or exercising a Runtime directly (as unit
24
+ tests do) produces no traffic.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import atexit
30
+ import json
31
+ import os
32
+ import platform
33
+ import sys
34
+ import threading
35
+ import time
36
+ import uuid
37
+ from pathlib import Path
38
+ from typing import Any
39
+ from urllib.request import Request, urlopen
40
+
41
+ from . import __version__
42
+ from .envutils import ENV_PREFIX, truthy_env, utc_now
43
+
44
+ POSTHOG_ENDPOINT = "https://us.i.posthog.com/batch/"
45
+ # Public write-only ingest key: it can create events but never read them back.
46
+ POSTHOG_PROJECT_KEY = "phc_ySPcp83qCtwWyTECxpHpZsouvucHUQ5bhQnLUoYMPqdG"
47
+
48
+ ERROR_EVENTS_PER_SESSION = 20
49
+ FLUSH_INTERVAL_SECONDS = 30.0
50
+ FLUSH_BATCH_SIZE = 50
51
+ QUEUE_LIMIT = 500
52
+ SEND_TIMEOUT_SECONDS = 3.0
53
+
54
+ _LABEL_LIMIT = 64
55
+ _OFF_VALUES = {"0", "off", "false", "no", "disable", "disabled"}
56
+ _DURATION_BUCKETS = ((100, "dur_lt_100ms"), (1_000, "dur_lt_1s"), (10_000, "dur_lt_10s"))
57
+ _DURATION_OVERFLOW = "dur_gte_10s"
58
+
59
+
60
+ def telemetry_mode() -> str:
61
+ """Return ``"on"``, ``"off"``, or ``"debug"`` from the environment."""
62
+
63
+ raw = (os.environ.get(f"{ENV_PREFIX}_TELEMETRY") or "").strip().lower()
64
+ if raw == "debug":
65
+ return "debug"
66
+ if raw in _OFF_VALUES:
67
+ return "off"
68
+ if truthy_env(os.environ.get("DO_NOT_TRACK")) or truthy_env(os.environ.get("CI")):
69
+ return "off"
70
+ return "on"
71
+
72
+
73
+ def _label(value: Any) -> str | None:
74
+ if value is None:
75
+ return None
76
+ text = str(value).strip()
77
+ return text[:_LABEL_LIMIT] if text else None
78
+
79
+
80
+ def _looks_like_install_id(value: str) -> bool:
81
+ return len(value) == 32 and all(character in "0123456789abcdef" for character in value)
82
+
83
+
84
+ _install_id_lock = threading.Lock()
85
+ _install_id: str | None = None
86
+
87
+
88
+ def install_id() -> str:
89
+ """A random per-install id, never derived from hardware, hostname, or paths.
90
+
91
+ Persisted so active-user counts de-duplicate across sessions; falls back to
92
+ a per-process id when the home directory is unwritable.
93
+ """
94
+
95
+ global _install_id
96
+ cached = _install_id
97
+ if cached:
98
+ return cached
99
+ with _install_id_lock:
100
+ if _install_id:
101
+ return _install_id
102
+ path = Path.home() / ".coding-tools-mcp" / "id"
103
+ try:
104
+ value = path.read_text(encoding="utf-8").strip()
105
+ except OSError:
106
+ value = ""
107
+ if not _looks_like_install_id(value):
108
+ value = uuid.uuid4().hex
109
+ try:
110
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
111
+ path.write_text(value + "\n", encoding="utf-8")
112
+ path.chmod(0o600)
113
+ except OSError:
114
+ pass
115
+ _install_id = value
116
+ return value
117
+
118
+
119
+ def _post(events: list[dict[str, Any]]) -> None:
120
+ """Deliver one batch; prints in debug mode, never raises, never uses stdout."""
121
+
122
+ if not events:
123
+ return
124
+ mode = telemetry_mode()
125
+ if mode == "off":
126
+ return
127
+ if mode == "debug":
128
+ for event in events:
129
+ print(
130
+ f"telemetry (not sent): {json.dumps(event, sort_keys=True, separators=(',', ':'))}",
131
+ file=sys.stderr,
132
+ flush=True,
133
+ )
134
+ return
135
+ try:
136
+ request = Request(
137
+ POSTHOG_ENDPOINT,
138
+ data=json.dumps({"api_key": POSTHOG_PROJECT_KEY, "batch": events}).encode("utf-8"),
139
+ headers={"Content-Type": "application/json", "User-Agent": "coding-tools-mcp-telemetry"},
140
+ )
141
+ with urlopen(request, timeout=SEND_TIMEOUT_SECONDS):
142
+ pass
143
+ except Exception: # noqa: BLE001 - telemetry must never surface failures
144
+ pass
145
+
146
+
147
+ class _Sender:
148
+ """Bounded background queue that posts event batches off the caller's thread."""
149
+
150
+ def __init__(self) -> None:
151
+ self._wake = threading.Condition(threading.Lock())
152
+ self._queue: list[dict[str, Any]] = []
153
+ threading.Thread(target=self._run, name="coding-tools-mcp-telemetry", daemon=True).start()
154
+
155
+ def enqueue(self, events: list[dict[str, Any]], *, wake: bool = False) -> None:
156
+ if not events:
157
+ return
158
+ with self._wake:
159
+ room = QUEUE_LIMIT - len(self._queue)
160
+ if room > 0:
161
+ self._queue.extend(events[:room])
162
+ if wake or len(self._queue) >= FLUSH_BATCH_SIZE:
163
+ self._wake.notify_all()
164
+
165
+ def flush(self) -> None:
166
+ with self._wake:
167
+ batch, self._queue = self._queue, []
168
+ _post(batch)
169
+
170
+ def _run(self) -> None:
171
+ while True:
172
+ with self._wake:
173
+ self._wake.wait(timeout=FLUSH_INTERVAL_SECONDS)
174
+ batch, self._queue = self._queue, []
175
+ _post(batch)
176
+
177
+
178
+ _sender_lock = threading.Lock()
179
+ _sender: _Sender | None = None
180
+
181
+
182
+ def _get_sender() -> _Sender:
183
+ global _sender
184
+ with _sender_lock:
185
+ if _sender is None:
186
+ _sender = _Sender()
187
+ atexit.register(_sender.flush)
188
+ return _sender
189
+
190
+
191
+ class SessionTelemetry:
192
+ """Per-session in-memory counters emitted as closed-schema events.
193
+
194
+ ``record_tool_call`` only increments dictionary counters under its lock;
195
+ event dictionaries are built after the lock is released. Events exist only
196
+ for sessions activated by a real MCP ``initialize``
197
+ (``record_session_start``).
198
+ """
199
+
200
+ def __init__(self, *, permission_mode: str, transport: str = "stdio") -> None:
201
+ self._session_id = uuid.uuid4().hex
202
+ self._started_monotonic = time.monotonic()
203
+ self._base_properties: dict[str, Any] = {
204
+ "version": __version__,
205
+ "os": sys.platform,
206
+ "arch": platform.machine(),
207
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}",
208
+ "transport": _label(transport),
209
+ "permission_mode": _label(permission_mode),
210
+ "session_id": self._session_id,
211
+ "$process_person_profile": False,
212
+ }
213
+ self._client_name: str | None = None
214
+ self._client_version: str | None = None
215
+ self._protocol_version: str | None = None
216
+ self._tools: dict[str, dict[str, Any]] = {}
217
+ self._error_events_sent = 0
218
+ self._errors_dropped = 0
219
+ self._failure_streak: tuple[str, int] | None = None
220
+ self._active = False
221
+ self._finished = False
222
+ self._lock = threading.Lock()
223
+
224
+ def record_session_start(self, client_info: dict[str, Any] | None, protocol_version: str) -> None:
225
+ with self._lock:
226
+ if self._active or self._finished:
227
+ return
228
+ self._active = True
229
+ if client_info:
230
+ self._client_name = _label(client_info.get("name"))
231
+ self._client_version = _label(client_info.get("version"))
232
+ self._protocol_version = _label(protocol_version)
233
+ if telemetry_mode() != "off":
234
+ self._emit([self._event("session_start", {})], wake=True)
235
+
236
+ def record_tool_call(
237
+ self, tool: str, *, ok: bool, error_code: str | None, duration_ms: int, truncated: bool
238
+ ) -> None:
239
+ emit_error: tuple[str, int] | None = None
240
+ with self._lock:
241
+ stats = self._tools.get(tool)
242
+ if stats is None:
243
+ stats = self._tools[tool] = {"calls": 0, "errors": {}, "buckets": {}, "truncated": 0}
244
+ stats["calls"] += 1
245
+ bucket = _DURATION_OVERFLOW
246
+ for limit, name in _DURATION_BUCKETS:
247
+ if duration_ms < limit:
248
+ bucket = name
249
+ break
250
+ stats["buckets"][bucket] = stats["buckets"].get(bucket, 0) + 1
251
+ if truncated:
252
+ stats["truncated"] += 1
253
+ if ok:
254
+ self._failure_streak = None
255
+ else:
256
+ code = _label(error_code) or "UNKNOWN"
257
+ stats["errors"][code] = stats["errors"].get(code, 0) + 1
258
+ streak = 1
259
+ if self._failure_streak and self._failure_streak[0] == tool:
260
+ streak = self._failure_streak[1] + 1
261
+ self._failure_streak = (tool, streak)
262
+ if self._active:
263
+ if self._error_events_sent < ERROR_EVENTS_PER_SESSION:
264
+ self._error_events_sent += 1
265
+ emit_error = (code, streak)
266
+ else:
267
+ self._errors_dropped += 1
268
+ if emit_error is not None and telemetry_mode() != "off":
269
+ self._emit(
270
+ [
271
+ self._event(
272
+ "tool_error",
273
+ {
274
+ "tool": _label(tool),
275
+ "error_code": emit_error[0],
276
+ "duration_ms": duration_ms,
277
+ "consecutive_failures": emit_error[1],
278
+ },
279
+ )
280
+ ]
281
+ )
282
+
283
+ def finish(self) -> None:
284
+ with self._lock:
285
+ if self._finished:
286
+ return
287
+ self._finished = True
288
+ if not self._active or telemetry_mode() == "off":
289
+ return
290
+ duration_ms = int((time.monotonic() - self._started_monotonic) * 1000)
291
+ events = []
292
+ for tool, stats in sorted(self._tools.items()):
293
+ failures = sum(stats["errors"].values())
294
+ properties: dict[str, Any] = {
295
+ "tool": _label(tool),
296
+ "calls": stats["calls"],
297
+ "ok": stats["calls"] - failures,
298
+ "errors": failures,
299
+ "truncated": stats["truncated"],
300
+ }
301
+ for code, count in sorted(stats["errors"].items()):
302
+ properties[f"err_{code}"] = count
303
+ properties.update(stats["buckets"])
304
+ events.append(self._event("tool_summary", properties))
305
+ events.append(
306
+ self._event(
307
+ "session_end",
308
+ {
309
+ "duration_ms": duration_ms,
310
+ "tool_calls": sum(stats["calls"] for stats in self._tools.values()),
311
+ "distinct_tools": len(self._tools),
312
+ "errors_dropped": self._errors_dropped,
313
+ },
314
+ )
315
+ )
316
+ self._emit(events, wake=True)
317
+
318
+ def _event(self, name: str, properties: dict[str, Any]) -> dict[str, Any]:
319
+ merged: dict[str, Any] = {
320
+ **self._base_properties,
321
+ "client_name": self._client_name,
322
+ "client_version": self._client_version,
323
+ "protocol_version": self._protocol_version,
324
+ }
325
+ merged.update(properties)
326
+ return {
327
+ "event": name,
328
+ "distinct_id": install_id(),
329
+ "timestamp": utc_now(),
330
+ "properties": merged,
331
+ }
332
+
333
+ def _emit(self, events: list[dict[str, Any]], *, wake: bool = False) -> None:
334
+ if telemetry_mode() == "off":
335
+ return
336
+ _get_sender().enqueue(events, wake=wake)
@@ -11,7 +11,7 @@ class StdioRuntime(Protocol):
11
11
  protocol_version: str
12
12
  initialized: bool
13
13
 
14
- def initialize(self) -> dict[str, Any]: ...
14
+ def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any]: ...
15
15
 
16
16
  def list_tools(self) -> dict[str, Any]: ...
17
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: coding-tools-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Workspace-confined coding tools exposed as an MCP server.
5
5
  Author: Coding Tools MCP Contributors
6
6
  License: Apache License
@@ -535,6 +535,16 @@ The runtime binds one workspace root per server process. Paths are workspace-rel
535
535
 
536
536
  `--permission-mode safe` is the default. `--permission-mode trusted` opens local-development gates while keeping secret filtering and destructive-command checks. `--permission-mode dangerous` disables `exec_command` permission gates for operators who accept that risk inside an isolated runner. Do not use dangerous mode for untrusted workspaces or untrusted MCP clients.
537
537
 
538
+ ## Telemetry
539
+
540
+ The server sends anonymous usage telemetry (per-tool success/latency counters
541
+ and version/platform dimensions — never paths, arguments, commands, or file
542
+ contents) to help prioritize fixes. Disable it with
543
+ `CODING_TOOLS_MCP_TELEMETRY=off` or `DO_NOT_TRACK=1`; it is automatically off
544
+ in CI. `CODING_TOOLS_MCP_TELEMETRY=debug` prints every event to stderr instead
545
+ of sending. The full event list and guarantees are in
546
+ [docs/telemetry.md](docs/telemetry.md).
547
+
538
548
  ## Compliance
539
549
 
540
550
  ```bash
@@ -15,6 +15,7 @@ apps/desktop-client/mcp_desktop_client/locales/app_zh_CN.qm
15
15
  apps/desktop-client/mcp_desktop_client/locales/app_zh_CN.ts
16
16
  coding_tools_mcp/__init__.py
17
17
  coding_tools_mcp/__main__.py
18
+ coding_tools_mcp/envutils.py
18
19
  coding_tools_mcp/errors.py
19
20
  coding_tools_mcp/landlock_exec.py
20
21
  coding_tools_mcp/oauth.py
@@ -23,6 +24,7 @@ coding_tools_mcp/processes.py
23
24
  coding_tools_mcp/project_context.py
24
25
  coding_tools_mcp/protocol.py
25
26
  coding_tools_mcp/server.py
27
+ coding_tools_mcp/telemetry.py
26
28
  coding_tools_mcp/textutils.py
27
29
  coding_tools_mcp/tool_results.py
28
30
  coding_tools_mcp/transport_http.py
@@ -34,4 +36,5 @@ coding_tools_mcp.egg-info/entry_points.txt
34
36
  coding_tools_mcp.egg-info/requires.txt
35
37
  coding_tools_mcp.egg-info/top_level.txt
36
38
  tests/test_desktop_client.py
37
- tests/test_release_checks.py
39
+ tests/test_release_checks.py
40
+ tests/test_telemetry.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coding-tools-mcp"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Workspace-confined coding tools exposed as an MCP server."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import unittest
9
+ from collections.abc import Iterator
10
+ from pathlib import Path
11
+ from unittest.mock import Mock, patch
12
+
13
+ from coding_tools_mcp import telemetry
14
+ from coding_tools_mcp.protocol import dispatch_rpc
15
+ from coding_tools_mcp.server import Runtime
16
+ from coding_tools_mcp.telemetry import ERROR_EVENTS_PER_SESSION, SessionTelemetry
17
+
18
+ _ENV_KEYS = ("CODING_TOOLS_MCP_TELEMETRY", "DO_NOT_TRACK", "CI")
19
+
20
+
21
+ @contextlib.contextmanager
22
+ def scrubbed_env(**overrides: str) -> Iterator[None]:
23
+ """Run with the telemetry-controlling variables removed, then overridden.
24
+
25
+ The ambient environment (CI sets ``CI=true``; sandboxes may set
26
+ ``CODING_TOOLS_MCP_*``) must never decide what these tests observe.
27
+ """
28
+
29
+ with patch.dict(os.environ):
30
+ for key in _ENV_KEYS:
31
+ os.environ.pop(key, None)
32
+ os.environ.update(overrides)
33
+ yield
34
+
35
+
36
+ class _CapturingSender:
37
+ def __init__(self) -> None:
38
+ self.events: list[dict[str, object]] = []
39
+
40
+ def enqueue(self, events: list[dict[str, object]], *, wake: bool = False) -> None:
41
+ self.events.extend(events)
42
+
43
+ def flush(self) -> None:
44
+ pass
45
+
46
+
47
+ def _initialize(runtime: Runtime, client_name: str = "test-client") -> None:
48
+ response = dispatch_rpc(
49
+ runtime,
50
+ {
51
+ "jsonrpc": "2.0",
52
+ "id": 1,
53
+ "method": "initialize",
54
+ "params": {"clientInfo": {"name": client_name, "version": "9.9.9"}},
55
+ },
56
+ )
57
+ assert response is not None and "error" not in response
58
+
59
+
60
+ class TelemetryModeTests(unittest.TestCase):
61
+ def test_default_is_on(self) -> None:
62
+ with scrubbed_env():
63
+ self.assertEqual(telemetry.telemetry_mode(), "on")
64
+
65
+ def test_env_switch_disables(self) -> None:
66
+ for value in ("off", "0", "false", "no", "disabled"):
67
+ with self.subTest(value=value), scrubbed_env(CODING_TOOLS_MCP_TELEMETRY=value):
68
+ self.assertEqual(telemetry.telemetry_mode(), "off")
69
+
70
+ def test_do_not_track_disables(self) -> None:
71
+ with scrubbed_env(DO_NOT_TRACK="1"):
72
+ self.assertEqual(telemetry.telemetry_mode(), "off")
73
+
74
+ def test_do_not_track_overrides_explicit_on(self) -> None:
75
+ with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="on", DO_NOT_TRACK="1"):
76
+ self.assertEqual(telemetry.telemetry_mode(), "off")
77
+
78
+ def test_ci_disables(self) -> None:
79
+ with scrubbed_env(CI="true"):
80
+ self.assertEqual(telemetry.telemetry_mode(), "off")
81
+
82
+ def test_debug_mode(self) -> None:
83
+ with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="debug"):
84
+ self.assertEqual(telemetry.telemetry_mode(), "debug")
85
+
86
+
87
+ class OffMeansOffTests(unittest.TestCase):
88
+ def test_disabled_session_never_reaches_the_sender(self) -> None:
89
+ for overrides in ({"CODING_TOOLS_MCP_TELEMETRY": "off"}, {"DO_NOT_TRACK": "1"}, {"CI": "1"}):
90
+ with self.subTest(overrides=overrides), scrubbed_env(**overrides):
91
+ get_sender = Mock()
92
+ with patch.object(telemetry, "_get_sender", get_sender):
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ runtime = Runtime(Path(tmp))
95
+ _initialize(runtime)
96
+ runtime.call_tool("get_default_cwd", {})
97
+ runtime.call_tool("read_file", {"path": "missing.txt"})
98
+ runtime.close()
99
+ get_sender.assert_not_called()
100
+
101
+ def test_post_sends_nothing_when_disabled(self) -> None:
102
+ with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="off"):
103
+ with patch.object(telemetry, "urlopen", Mock()) as opener:
104
+ telemetry._post([{"event": "session_start"}])
105
+ opener.assert_not_called()
106
+
107
+ def test_debug_mode_prints_to_stderr_and_does_not_send(self) -> None:
108
+ with scrubbed_env(CODING_TOOLS_MCP_TELEMETRY="debug"):
109
+ stderr = io.StringIO()
110
+ with patch.object(telemetry, "urlopen", Mock()) as opener:
111
+ with contextlib.redirect_stderr(stderr):
112
+ telemetry._post([{"event": "session_start", "properties": {}}])
113
+ opener.assert_not_called()
114
+ output = stderr.getvalue()
115
+ self.assertIn("telemetry (not sent):", output)
116
+ self.assertIn("session_start", output)
117
+
118
+
119
+ def _run_probe_session() -> _CapturingSender:
120
+ sender = _CapturingSender()
121
+ with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender):
122
+ with tempfile.TemporaryDirectory() as tmp:
123
+ marker = "leakprobe-a8f3"
124
+ workspace = Path(tmp) / marker
125
+ workspace.mkdir()
126
+ (workspace / f"{marker}.txt").write_text("leakprobe-content\n", encoding="utf-8")
127
+ runtime = Runtime(workspace)
128
+ _initialize(runtime, client_name="clientinfo-probe")
129
+ runtime.call_tool("get_default_cwd", {})
130
+ runtime.call_tool("read_file", {"path": f"{marker}-missing.txt"})
131
+ runtime.call_tool("read_file", {"path": f"{marker}-missing.txt"})
132
+ runtime.close()
133
+ return sender
134
+
135
+
136
+ class SessionEventTests(unittest.TestCase):
137
+ def test_payload_never_contains_paths_arguments_or_content(self) -> None:
138
+ sender = _run_probe_session()
139
+ serialized = json.dumps(sender.events)
140
+ self.assertNotIn("leakprobe", serialized)
141
+ self.assertNotIn("missing.txt", serialized)
142
+
143
+ def test_session_events_carry_the_closed_schema(self) -> None:
144
+ sender = _run_probe_session()
145
+ by_name: dict[str, list[dict[str, object]]] = {}
146
+ for event in sender.events:
147
+ by_name.setdefault(str(event["event"]), []).append(event)
148
+ self.assertEqual(len(by_name["session_start"]), 1)
149
+ self.assertEqual(len(by_name["session_end"]), 1)
150
+ self.assertEqual(len(by_name["tool_error"]), 2)
151
+
152
+ properties = by_name["session_start"][0]["properties"]
153
+ assert isinstance(properties, dict)
154
+ self.assertEqual(properties["$process_person_profile"], False)
155
+ self.assertEqual(properties["transport"], "stdio")
156
+ self.assertEqual(properties["permission_mode"], "safe")
157
+ # clientInfo values are enum-like labels, truncated, and expected here.
158
+ self.assertEqual(properties["client_name"], "clientinfo-probe")
159
+
160
+ errors = by_name["tool_error"]
161
+ first = errors[0]["properties"]
162
+ second = errors[1]["properties"]
163
+ assert isinstance(first, dict) and isinstance(second, dict)
164
+ self.assertEqual(first["tool"], "read_file")
165
+ self.assertEqual(first["error_code"], "NOT_FOUND")
166
+ self.assertEqual(first["consecutive_failures"], 1)
167
+ self.assertEqual(second["consecutive_failures"], 2)
168
+
169
+ summaries = {
170
+ str(event["properties"]["tool"]): event["properties"] # type: ignore[index]
171
+ for event in by_name["tool_summary"]
172
+ }
173
+ self.assertEqual(summaries["read_file"]["calls"], 2)
174
+ self.assertEqual(summaries["read_file"]["ok"], 0)
175
+ self.assertEqual(summaries["read_file"]["err_NOT_FOUND"], 2)
176
+ self.assertEqual(summaries["get_default_cwd"]["calls"], 1)
177
+ self.assertEqual(summaries["get_default_cwd"]["ok"], 1)
178
+
179
+ end = by_name["session_end"][0]["properties"]
180
+ assert isinstance(end, dict)
181
+ self.assertEqual(end["tool_calls"], 3)
182
+ self.assertEqual(end["distinct_tools"], 2)
183
+ self.assertEqual(end["errors_dropped"], 0)
184
+
185
+ def test_sessions_without_initialize_emit_nothing(self) -> None:
186
+ sender = _CapturingSender()
187
+ with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender):
188
+ with tempfile.TemporaryDirectory() as tmp:
189
+ runtime = Runtime(Path(tmp))
190
+ runtime.call_tool("get_default_cwd", {})
191
+ runtime.call_tool("read_file", {"path": "missing.txt"})
192
+ runtime.close()
193
+ self.assertEqual(sender.events, [])
194
+
195
+ def test_error_events_are_capped_and_drops_are_counted(self) -> None:
196
+ sender = _CapturingSender()
197
+ with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender):
198
+ session = SessionTelemetry(permission_mode="safe")
199
+ session.record_session_start({"name": "cap"}, "2025-11-25")
200
+ for _ in range(ERROR_EVENTS_PER_SESSION + 5):
201
+ session.record_tool_call(
202
+ "apply_patch", ok=False, error_code="PATCH_CONTEXT_MISMATCH", duration_ms=5, truncated=False
203
+ )
204
+ session.finish()
205
+ errors = [event for event in sender.events if event["event"] == "tool_error"]
206
+ self.assertEqual(len(errors), ERROR_EVENTS_PER_SESSION)
207
+ end = next(event for event in sender.events if event["event"] == "session_end")
208
+ properties = end["properties"]
209
+ assert isinstance(properties, dict)
210
+ self.assertEqual(properties["errors_dropped"], 5)
211
+
212
+ def test_duration_buckets_and_finish_is_idempotent(self) -> None:
213
+ sender = _CapturingSender()
214
+ with scrubbed_env(), patch.object(telemetry, "_get_sender", lambda: sender):
215
+ session = SessionTelemetry(permission_mode="safe")
216
+ session.record_session_start(None, "2025-11-25")
217
+ for duration in (50, 500, 5_000, 50_000):
218
+ session.record_tool_call("exec_command", ok=True, error_code=None, duration_ms=duration, truncated=True)
219
+ session.finish()
220
+ session.finish()
221
+ summaries = [event for event in sender.events if event["event"] == "tool_summary"]
222
+ self.assertEqual(len(summaries), 1)
223
+ properties = summaries[0]["properties"]
224
+ assert isinstance(properties, dict)
225
+ for bucket in ("dur_lt_100ms", "dur_lt_1s", "dur_lt_10s", "dur_gte_10s"):
226
+ self.assertEqual(properties[bucket], 1)
227
+ self.assertEqual(properties["truncated"], 4)
228
+ self.assertEqual(len([event for event in sender.events if event["event"] == "session_end"]), 1)
229
+
230
+
231
+ class DocumentationDriftTests(unittest.TestCase):
232
+ def test_documented_schema_matches_emitted_events(self) -> None:
233
+ doc = (Path(__file__).resolve().parents[1] / "docs" / "telemetry.md").read_text(encoding="utf-8")
234
+ emitted = {str(event["event"]) for event in _run_probe_session().events}
235
+ self.assertEqual(emitted, {"session_start", "tool_error", "tool_summary", "session_end"})
236
+ for name in emitted:
237
+ self.assertIn(f"`{name}`", doc)
238
+ self.assertIn(f"max {ERROR_EVENTS_PER_SESSION} per session", doc)
239
+
240
+
241
+ class InstallIdTests(unittest.TestCase):
242
+ def setUp(self) -> None:
243
+ self._saved = telemetry._install_id
244
+ telemetry._install_id = None
245
+
246
+ def tearDown(self) -> None:
247
+ telemetry._install_id = self._saved
248
+
249
+ def test_install_id_is_random_stable_and_resettable(self) -> None:
250
+ with tempfile.TemporaryDirectory() as tmp:
251
+ with patch.dict(os.environ, {"HOME": tmp}):
252
+ first = telemetry.install_id()
253
+ self.assertEqual(telemetry.install_id(), first)
254
+ path = Path(tmp) / ".coding-tools-mcp" / "id"
255
+ self.assertEqual(path.read_text(encoding="utf-8").strip(), first)
256
+ self.assertEqual(path.stat().st_mode & 0o777, 0o600)
257
+ self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700)
258
+
259
+ telemetry._install_id = None
260
+ path.unlink()
261
+ second = telemetry.install_id()
262
+ self.assertNotEqual(second, first)
263
+ self.assertEqual(len(second), 32)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ unittest.main()