Skip to content

Commit 164bc93

Browse files
jgravelleclaude
andcommitted
v1.108.71: packaging de-risk - lazy telemetry worker + complete PyPI metadata
Phase 1 of the maintenance PRD (WI-1.1 through WI-1.4). - Telemetry worker starts lazily on first share, never at import, and never when sharing is disabled (resolves F-T01: an import-time daemon thread is the scanner-trip / undisclosed-persistent-service shape behind the prior quarantine). Thread-safe double-checked start. - JCODEMUNCH_TELEMETRY_URL env override, resolved at send time (F-T02). - pyproject metadata: license={file=LICENSE}, keywords, classifiers; twine check passes (F-PK01). Documented the all-extra union (F-PK02). - README discloses lazy-start + endpoint override (F-D01). New tests/test_v1_108_71.py (6): fresh-import spawns no thread; lazy-start idempotent; opt-out never starts worker; URL override + fallback. Full suite 4696 passed / 10 skipped. No tool behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8cfaaf4 commit 164bc93

6 files changed

Lines changed: 205 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@
22

33
All notable changes to jcodemunch-mcp are documented here.
44

5+
## [1.108.71] - 2026-06-22 - Packaging de-risk: lazy telemetry worker + complete PyPI metadata
6+
7+
### Changed
8+
9+
- **The community-meter telemetry worker now starts lazily, never at import.**
10+
Previously a daemon thread was spawned at module import unconditionally, even
11+
when sharing was disabled. An import-time background-service side effect is the
12+
exact shape automated package scanners react to, and it ran for opted-out users
13+
too. The worker is now started on the first share (`_share_savings`), which is
14+
only reached behind the existing `share_savings` opt-out gate, so a plain
15+
`import jcodemunch_mcp` has no background-thread side effect and an opted-out
16+
process never spawns the thread. Start is thread-safe (double-checked lock).
17+
- **The telemetry endpoint is overridable via `JCODEMUNCH_TELEMETRY_URL`**,
18+
resolved at send time, so a deployment can redirect or (with the opt-out) block
19+
it without a code change. Default endpoint unchanged.
20+
21+
### Packaging
22+
23+
- Added complete PyPI metadata to `pyproject.toml`: `license = {file = "LICENSE"}`
24+
(the existing dual-use license), `keywords`, and standard `classifiers`
25+
(Development Status, Intended Audience, License :: Other/Proprietary License,
26+
OS Independent, Python 3.10-3.13, Topic :: Software Development). `twine check`
27+
passes. Documented that the `all` extra is a hand-maintained union.
28+
29+
### Docs
30+
31+
- The README "Background behavior, fully disclosed" section now states the worker
32+
starts lazily and only when sharing is enabled, and names the endpoint override.
33+
34+
### Tests
35+
36+
- `tests/test_v1_108_71.py` (6): a fresh-interpreter subprocess asserts `import`
37+
starts no worker thread; lazy-start is idempotent; an opted-out flush never
38+
reaches the worker starter; `JCODEMUNCH_TELEMETRY_URL` override + fallback.
39+
40+
Implements Phase 1 (WI-1.1 through WI-1.4) of the maintenance PRD; resolves
41+
findings F-T01 (P1), F-T02, F-PK01, F-PK02, F-D01. No tool behavior change.
42+
543
## [1.108.70] - 2026-06-22 - Bounded-source mode for get_symbol_source (#340)
644

745
### Added

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ is a byte the agent doesn't pay to read.
110110
<!-- WHATSNEW:START -->
111111
#### What's new
112112

113+
- **[v1.108.71](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.71)** (2026-06-22) — Packaging de-risk: lazy telemetry worker + complete PyPI metadata
113114
- **[v1.108.70](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.70)** (2026-06-22) — Bounded-source mode for get_symbol_source (#340)
114115
- **[v1.108.69](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.69)** (2026-06-21) — Durable-change delivery: get_delivery_metrics / delivery CLI
115-
- **[v1.108.68](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.68)** (2026-06-21) — Retrieval-regret loop: suggest_corrections / reflect
116116
<!-- WHATSNEW:END -->
117117

118118
![License](https://img.shields.io/badge/license-dual--use-blue)
@@ -329,7 +329,7 @@ Everything jCodeMunch does beyond answering a tool call is listed here. All of i
329329

330330
- **File watching.** The `watch` / `watch-all` / `watch-claude` commands (and `watch: true` in config) re-index files when they change. Watching runs **inside a process you started** and stops when that process exits. Nothing monitors your filesystem unless a jCodeMunch process you launched is running.
331331
- **Login service — explicit opt-in only.** `jcodemunch-mcp watch-install` registers `watch-all` as a login service (Windows Task Scheduler / macOS launchd / Linux systemd) so indexes stay fresh across reboots. This happens **only** when you run `watch-install` yourself; `init`, `install`, and normal server use never register a service. Inspect it with `watch-status`; remove it with `watch-uninstall`.
332-
- **Anonymous savings telemetry.** The server periodically sends a random anonymous ID plus aggregate token-savings counters to the project's public community meter. No code, no file paths, no repo names, no PII — counters only. Opt out with `share_savings: false` in `config.jsonc` or `JCODEMUNCH_SHARE_SAVINGS=0`.
332+
- **Anonymous savings telemetry.** The server periodically sends a random anonymous ID plus aggregate token-savings counters to the project's public community meter. No code, no file paths, no repo names, no PII — counters only. The sender is a single background daemon thread that starts lazily on the first share (never at import, and never if you have opted out), so a plain import has no background side effect. Opt out with `share_savings: false` in `config.jsonc` or `JCODEMUNCH_SHARE_SAVINGS=0`; redirect the endpoint with `JCODEMUNCH_TELEMETRY_URL`.
333333
- **Agent hooks.** `init` / `install` can write hook entries (auto-reindex on edit, read-interception nudges) into your MCP client's settings. They're offered during the interactive flow, shown before writing, and fully removed by `uninstall`.
334334
- **Local index storage.** Indexes live at `~/.code-index/` (override with `CODE_INDEX_PATH`). Delete the directory and every trace of indexing is gone.
335335
- **Live session journal.** While the server runs, it periodically writes a small `_session_live.json` in `~/.code-index/` recording the files and searches the agent touched this session (paths and query strings only, no file contents). It exists so the out-of-process PreCompact hook can restore session orientation after context compaction. Throttled, atomically written, overwritten in place; disable with `JCODEMUNCH_LIVE_JOURNAL=0`.
@@ -759,6 +759,7 @@ The following env vars still work but are deprecated. Config file values take pr
759759
| `JCODEMUNCH_REDACT_SOURCE_ROOT` | `redact_source_root` | `false` |
760760
| `JCODEMUNCH_STATS_FILE_INTERVAL` | `stats_file_interval` | `3` |
761761
| `JCODEMUNCH_SHARE_SAVINGS` | `share_savings` | `true` |
762+
| `JCODEMUNCH_TELEMETRY_URL` | (none) | community meter URL |
762763
| `JCODEMUNCH_SUMMARIZER_CONCURRENCY` | `summarizer_concurrency` | `4` |
763764
| `JCODEMUNCH_ALLOW_REMOTE_SUMMARIZER` | `allow_remote_summarizer` | `false` |
764765
| `JCODEMUNCH_RATE_LIMIT` | `rate_limit` | `0` |

pyproject.toml

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,39 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.70"
3+
version = "1.108.71"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"
77
authors = [
88
{ name = "J. Gravelle", email = "j@gravelle.us" },
99
]
10+
license = { file = "LICENSE" }
11+
keywords = [
12+
"mcp",
13+
"model-context-protocol",
14+
"code-search",
15+
"code-intelligence",
16+
"tree-sitter",
17+
"ast",
18+
"llm",
19+
"agent",
20+
"token-efficiency",
21+
"claude",
22+
]
23+
classifiers = [
24+
"Development Status :: 5 - Production/Stable",
25+
"Intended Audience :: Developers",
26+
"License :: Other/Proprietary License",
27+
"Operating System :: OS Independent",
28+
"Programming Language :: Python :: 3",
29+
"Programming Language :: Python :: 3.10",
30+
"Programming Language :: Python :: 3.11",
31+
"Programming Language :: Python :: 3.12",
32+
"Programming Language :: Python :: 3.13",
33+
"Topic :: Software Development",
34+
"Topic :: Software Development :: Libraries",
35+
"Topic :: Software Development :: Quality Assurance",
36+
]
1037
dependencies = [
1138
"mcp>=1.10.0,<2.0.0",
1239
"httpx>=0.27.0",
@@ -36,6 +63,8 @@ groq-voice = ["openai>=1.0.0", "sounddevice>=0.4.6", "numpy>=1.24.0"]
3663
groq-explain = ["openai>=1.0.0", "Pillow>=10.0.0"]
3764
keyring = ["keyring>=24.0"]
3865
bench = ["openai>=1.0.0", "anthropic>=0.40.0", "pyyaml>=6.0", "rich>=13.0", "jinja2>=3.1"]
66+
# `all` is a hand-maintained union of every extra above. When you add a new
67+
# extra or a new dependency to one, mirror it here (there is no auto-union).
3968
all = ["anthropic>=0.40.0", "google-generativeai>=0.8.0", "openai>=1.0.0", "pyyaml>=6.0", "uvicorn>=0.20.0", "starlette>=0.27.0", "anyio>=4.0.0", "watchfiles>=1.0.0", "sentence-transformers>=2.2.0", "onnxruntime>=1.16.0", "sounddevice>=0.4.6", "numpy>=1.24.0", "Pillow>=10.0.0", "rich>=13.0", "jinja2>=3.1", "keyring>=24.0"]
4069

4170

src/jcodemunch_mcp/storage/token_tracker.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
on the next successful one (the meter converges to the local total instead
1515
of permanently undercounting from lost deltas). `delta` is kept for backward
1616
compatibility with older server builds. Set JCODEMUNCH_SHARE_SAVINGS=0 to
17-
disable.
17+
disable; the endpoint is overridable via JCODEMUNCH_TELEMETRY_URL. The sender
18+
is a single daemon thread started lazily on the first share (never at import,
19+
and never when sharing is disabled), so a plain `import` has no background
20+
side effect.
1821
1922
Performance: uses an in-memory accumulator to avoid disk read+write on every
2023
tool call. Flushes to disk every FLUSH_INTERVAL calls (default 3), on SIGTERM/
@@ -555,6 +558,21 @@ def _session_stats_path(base_path: Optional[str] = None) -> Path:
555558
# ---------------------------------------------------------------------------
556559

557560
_telemetry_queue: queue.Queue = queue.Queue()
561+
# The worker is started lazily on the first enqueue (see _ensure_telemetry_worker)
562+
# rather than at import. Two reasons: (1) `import jcodemunch_mcp` must have no
563+
# background-thread side effect — an import-time daemon thread is exactly the
564+
# undisclosed-persistent-service shape that package scanners flag; (2) a process
565+
# that has opted out (JCODEMUNCH_SHARE_SAVINGS=0) never reaches _share_savings,
566+
# so it never spawns the thread at all.
567+
_telemetry_worker_lock = threading.Lock()
568+
_telemetry_worker_started = False
569+
570+
571+
def _telemetry_url() -> str:
572+
"""Community-meter endpoint, overridable via JCODEMUNCH_TELEMETRY_URL so a
573+
deployment can redirect it without a code change. Resolved at send time so an
574+
env change takes effect for an already-running process."""
575+
return os.environ.get("JCODEMUNCH_TELEMETRY_URL") or _TELEMETRY_URL
558576

559577

560578
def _telemetry_worker() -> None:
@@ -570,7 +588,7 @@ def _telemetry_worker() -> None:
570588
# GREATEST(stored, total) so a failed post self-corrects on the next
571589
# one. `delta` rides along for older server builds (additive upsert).
572590
httpx.post(
573-
_TELEMETRY_URL,
591+
_telemetry_url(),
574592
json={"delta": delta, "total": total, "anon_id": anon_id},
575593
timeout=3.0,
576594
)
@@ -580,15 +598,29 @@ def _telemetry_worker() -> None:
580598
_telemetry_queue.task_done()
581599

582600

583-
threading.Thread(
584-
target=_telemetry_worker, daemon=True, name="jcodemunch-telemetry"
585-
).start()
601+
def _ensure_telemetry_worker() -> None:
602+
"""Start the single telemetry worker thread once, on first use. Thread-safe
603+
(double-checked under a lock). Never started at import or when the user has
604+
opted out of sharing."""
605+
global _telemetry_worker_started
606+
if _telemetry_worker_started:
607+
return
608+
with _telemetry_worker_lock:
609+
if _telemetry_worker_started:
610+
return
611+
threading.Thread(
612+
target=_telemetry_worker, daemon=True, name="jcodemunch-telemetry"
613+
).start()
614+
_telemetry_worker_started = True
586615

587616

588617
def _share_savings(delta: int, total: int, anon_id: str) -> None:
589618
"""Enqueue a fire-and-forget POST to the community meter. Never raises.
590619
`total` is the absolute lifetime savings (self-correcting); `delta` is the
591-
new savings since the last send (legacy additive path)."""
620+
new savings since the last send (legacy additive path). Starts the worker
621+
thread on first use — never at import, and never when sharing is disabled
622+
(this function is only reached behind the share_savings opt-out gate)."""
623+
_ensure_telemetry_worker()
592624
_telemetry_queue.put((delta, total, anon_id))
593625

594626

tests/test_v1_108_71.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""v1.108.71 — telemetry worker hardening (PRD F-T01 / F-T02).
2+
3+
The community-meter sender used to spawn a daemon thread at module import,
4+
unconditionally, regardless of the share_savings opt-out. That is an import-time
5+
background-service side effect (the shape package scanners flag) and it ran even
6+
for opted-out users. The worker is now started lazily on the first share, behind
7+
the existing opt-out gate, and the endpoint is overridable via env.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import subprocess
13+
import sys
14+
import threading
15+
16+
import pytest
17+
18+
from src.jcodemunch_mcp.storage import token_tracker as tt
19+
20+
21+
_WORKER_NAME = "jcodemunch-telemetry"
22+
23+
24+
def _worker_threads():
25+
return [t for t in threading.enumerate() if t.name == _WORKER_NAME]
26+
27+
28+
class TestNoImportTimeThread:
29+
def test_fresh_import_starts_no_worker_thread(self):
30+
"""A clean `import` must not spawn the telemetry thread (run in a fresh
31+
interpreter so prior in-process tests can't have started it)."""
32+
code = (
33+
"import threading\n"
34+
"import jcodemunch_mcp.storage.token_tracker as t\n"
35+
"live = [x for x in threading.enumerate() if x.name == 'jcodemunch-telemetry']\n"
36+
"assert not live, 'worker thread started at import'\n"
37+
"assert t._telemetry_worker_started is False, 'started flag set at import'\n"
38+
"print('OK')\n"
39+
)
40+
res = subprocess.run(
41+
[sys.executable, "-c", code], capture_output=True, text=True, timeout=60
42+
)
43+
assert res.returncode == 0, f"stderr={res.stderr}\nstdout={res.stdout}"
44+
assert "OK" in res.stdout
45+
46+
47+
class TestLazyStart:
48+
def test_ensure_starts_one_worker_and_is_idempotent(self):
49+
before = len(_worker_threads())
50+
tt._ensure_telemetry_worker()
51+
after_first = _worker_threads()
52+
assert len(after_first) == before + 1 or before >= 1, "worker did not start"
53+
assert tt._telemetry_worker_started is True
54+
# Idempotent: a second call must not spawn another thread.
55+
tt._ensure_telemetry_worker()
56+
assert len(_worker_threads()) == len(after_first)
57+
58+
59+
class TestOptOutNeverStartsWorker:
60+
def test_opted_out_flush_does_not_touch_the_worker(self, monkeypatch, tmp_path):
61+
"""With share_savings disabled, flush must not even reach the worker
62+
starter (the enqueue is gated upstream)."""
63+
called = {"ensure": False}
64+
monkeypatch.setattr(
65+
tt, "_ensure_telemetry_worker",
66+
lambda: called.__setitem__("ensure", True),
67+
)
68+
monkeypatch.setattr(
69+
tt._config, "get",
70+
lambda key, default=None, repo=None: False if key == "share_savings" else default,
71+
)
72+
st = tt._State()
73+
st.add(1000, str(tmp_path))
74+
st.flush()
75+
assert called["ensure"] is False, "worker was started despite opt-out"
76+
77+
78+
class TestTelemetryUrlOverride:
79+
def test_default_when_env_unset(self, monkeypatch):
80+
monkeypatch.delenv("JCODEMUNCH_TELEMETRY_URL", raising=False)
81+
assert tt._telemetry_url() == tt._TELEMETRY_URL
82+
83+
def test_env_override_wins(self, monkeypatch):
84+
monkeypatch.setenv("JCODEMUNCH_TELEMETRY_URL", "https://example.test/meter")
85+
assert tt._telemetry_url() == "https://example.test/meter"
86+
87+
def test_empty_env_falls_back_to_default(self, monkeypatch):
88+
monkeypatch.setenv("JCODEMUNCH_TELEMETRY_URL", "")
89+
assert tt._telemetry_url() == tt._TELEMETRY_URL

whatsnew.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{
2-
"current": "1.108.70",
2+
"current": "1.108.71",
33
"entries": [
4+
{
5+
"version": "1.108.71",
6+
"date": "2026-06-22",
7+
"title": "Packaging de-risk: lazy telemetry worker + complete PyPI metadata",
8+
"summary": "- **The community-meter telemetry worker now starts lazily, never at import.** Previously a daemon thread was spawned at module import unconditionally, even when sharing was disabled. An import-time background-service side effect is the exact shape automated package scanners react to, and it ran for opted-out users"
9+
},
410
{
511
"version": "1.108.70",
612
"date": "2026-06-22",
@@ -12,12 +18,6 @@
1218
"date": "2026-06-21",
1319
"title": "Durable-change delivery: get_delivery_metrics / delivery CLI",
1420
"summary": "- **A read-only measure of how much got *done*, not how much activity happened.** The suite shows tokens used and tokens saved (input). It had no honest output measure \u2014 the gap that lets teams reward raw volume (more commits, more tokens) to the detriment of their pocketbooks. New `get_delivery_metrics` MCP tool +"
15-
},
16-
{
17-
"version": "1.108.68",
18-
"date": "2026-06-21",
19-
"title": "Retrieval-regret loop: suggest_corrections / reflect",
20-
"summary": "- **A read-only feedback loop that mines retrieval regret and suggests config fixes.** The ranking-events telemetry ledger already fed exactly one consumer (the weight tuner); it also carries a louder, unread signal \u2014 when retrieval failed and the agent had to re-ask. New `suggest_corrections` MCP tool +"
2121
}
2222
]
2323
}

0 commit comments

Comments
 (0)