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.
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/PKG-INFO +11 -1
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/README.md +10 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/__init__.py +1 -1
- coding_tools_mcp-0.2.1/coding_tools_mcp/envutils.py +20 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/protocol.py +2 -1
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/server.py +23 -14
- coding_tools_mcp-0.2.1/coding_tools_mcp/telemetry.py +336 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/transport_stdio.py +1 -1
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/PKG-INFO +11 -1
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/SOURCES.txt +4 -1
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/pyproject.toml +1 -1
- coding_tools_mcp-0.2.1/tests/test_telemetry.py +267 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/LICENSE +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/NOTICE +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/__init__.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/app.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/health.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/i18n.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/language_manager.py +0 -0
- {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
- {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
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/models.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/runtime.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/storage.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/theme.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/__main__.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/errors.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/landlock_exec.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/oauth.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/patching.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/processes.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/project_context.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/textutils.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/tool_results.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp/transport_http.py +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/dependency_links.txt +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/entry_points.txt +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/requires.txt +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/top_level.txt +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/setup.cfg +0 -0
- {coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/tests/test_desktop_client.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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")
|
|
1500
|
-
"duration_ms":
|
|
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.
|
|
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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/__init__.py
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/app.py
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/health.py
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/i18n.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/models.py
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/runtime.py
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/storage.py
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/apps/desktop-client/mcp_desktop_client/theme.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{coding_tools_mcp-0.2.0 → coding_tools_mcp-0.2.1}/coding_tools_mcp.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|