Skip to content

Commit 2e83a68

Browse files
jgravelleclaude
andcommitted
release: v1.108.175 — an ignored argument cannot prove absence
Found live while auditing #375, against our own repo. A search_text call passed regex=true. The parameter is is_regex. Every tool reads its arguments key-by-key (arguments.get("is_regex", False)), so the flag was dropped in silence, the regex source text was searched as a LITERAL substring, nothing matched, and the response reached state "absent" carrying "treat this as strong evidence the target is not present" plus a citable evidence_ref. Over a corpus that plainly contained the target: _record_coverage appears in index_folder.py three times. The dangerous half is not the wrong answer. It is that a misunderstood call minted an absence PROOF a handoff could cite. Reproduced and pinned: with the gate disabled the same call still mints absent:2a1a6b8520c3. The dispatcher now diffs each call's arguments against the tool's PUBLISHED inputSchema (_declared_arg_keys, built once from the same catalog list_tools publishes, so the contract cannot drift from what the agent was shown). When keys were discarded it attaches _meta.ignored_arguments and downgrades an absent verdict to degraded with a note naming them. Disclose on every state, refuse only the absence CLAIM. This is the same shape as the rebuilding gate in 1.108.168: results an ok scan returned were really in the index and are still the best available answer, so only the claim that nothing exists is unfounded. Expressing the refusal as a downgrade means handoff.absence_refusal does the refusing and there is no second rule to keep in sync. Deliberately never rejects the call. Under the 1.x zero-surprise contract an unknown key has always been accepted, so a client that has been sending a harmless extra for a year must not start erroring, and a hard reject would turn a recoverable mistake into a dead call. An unknown schema accuses nobody: an unreadable declaration returns nothing rather than guessing, else every tool whose schema we failed to read manufactures warnings about legitimate keys. New UNIVERSAL_META_JSON in encoding/schema_driven.py: structured _meta keys every encoder preserves whether or not its own schema listed them. A per-schema allowlist is right for tool payloads and wrong for CONTRACT keys, because the failure is silent and asymmetric: the encoder drops the key, the response still looks complete, and the field that said "don't trust this" is the one that vanishes. That is how the verdict contract went invisible in 1.108.169, and adding a key to 45 tuples only guarantees the 46th encoder forgets it. Verified no false-positive surface: `format` is popped from arguments before the gate, `model` is declared on plan_turn, and those are the only two dispatcher-level extras. tests/test_v1_108_175.py (21). Suite 5786 passed, 7 skipped, plus the known 12 local-ONNX semantic env failures. No schema, tool-count, or INDEX_VERSION change. Suite parity: jdatamunch-mcp v1.29.0 shipped the same day, with the disclosure top-level because that server strips _meta by default. jdocmunch is deliberately NOT done: its tree carries an in-progress CLAUDE.md trim and an untracked docs/CLAUDE-history.md holding the only copy of some release history, so it is not a tree to switch branches under. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 19277b1 commit 2e83a68

7 files changed

Lines changed: 463 additions & 7 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.108.175] - 2026-07-25 - an ignored argument cannot prove absence
6+
7+
### Fixed
8+
9+
- **A misspelled parameter no longer produces a citable absence proof.** Found
10+
live while auditing
11+
[#375](https://github.com/jgravelle/jcodemunch-mcp/issues/375): a `search_text`
12+
call passed `regex=true`, but the parameter is `is_regex`. Every tool reads its
13+
arguments key-by-key (`arguments.get("is_regex", False)`), so the flag was
14+
dropped in silence, the regex SOURCE TEXT was searched as a literal substring,
15+
nothing matched, and the response reached `state: "absent"` with the note
16+
"treat this as strong evidence the target is not present" plus a citable
17+
`evidence_ref` — over a corpus that plainly contained the target. Reproduced
18+
and pinned: with the gate disabled the same call still mints
19+
`absent:2a1a6b8520c3`.
20+
- The dispatcher now compares each call's arguments against the tool's published
21+
`inputSchema` and, when keys were discarded, attaches
22+
`_meta.ignored_arguments` and downgrades an `absent` verdict to `degraded`
23+
with a note naming the offending keys.
24+
25+
### Notes
26+
27+
- **Disclose on every state, refuse only the absence CLAIM** — the same shape as
28+
the `rebuilding` gate in 1.108.168. Results an `ok` scan returned were really
29+
in the index and are still the best available answer; only the claim that
30+
nothing exists is unfounded. Because the refusal is expressed as a downgrade,
31+
`handoff.absence_refusal` does the refusing and there is no second rule to keep
32+
in sync.
33+
- **Deliberately never rejects the call.** Under the 1.x zero-surprise contract
34+
an unknown key has always been accepted, so a client that has been sending a
35+
harmless extra for a year must not start erroring, and a hard reject would turn
36+
a recoverable mistake into a dead call. Disclosure is strictly additive.
37+
- **An unknown schema accuses nobody.** When a tool's declaration cannot be read
38+
the check returns nothing rather than guessing, so it can never manufacture a
39+
warning about a legitimate key.
40+
- New `UNIVERSAL_META_JSON` in `encoding/schema_driven.py`: structured `_meta`
41+
keys every encoder preserves whether or not its own schema listed them. A
42+
per-schema allowlist is right for tool payloads and wrong for CONTRACT keys —
43+
the encoder drops the key, the response still looks complete, and the field
44+
that said "don't trust this" is the one that vanishes. That is how the verdict
45+
contract went invisible in 1.108.169; adding a key to 45 tuples only
46+
guarantees the 46th encoder forgets it.
47+
- New `tests/test_v1_108_175.py` (21). No schema, tool-count, or INDEX_VERSION
48+
change.
49+
550
## [1.108.174] - 2026-07-25 - an empty index says so
651

752
### Added

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# jcodemunch-mcp — Project Brief
22

33
## Current State
4-
- **Version:** 1.108.174 — **An empty index says so (#375 correspondence, suggestion C).** A user ran the suite for MONTHS with jdata holding **0 datasets** and jdoc holding **3 documents**, and only found out by going looking. His framing is the keeper: **every tool answers confidently regardless of how little it holds, so an agent cannot tell "I searched everything and it is not there" from "I have almost nothing indexed"** — "we would have fed both tools months ago." `list_repos` on an empty store now carries top-level `empty: true` + a `hint` naming `index_folder` AND why it matters. ⚠ **TOP-LEVEL, not `_meta`, ON PURPOSE: jdoc/jdata strip `_meta` by default (`get_meta_fields()` -> []), so a nudge placed there is DELETED before the agent sees it** — the same trap that forced re-attaching the absence ref after filtering in jdata v1.26.0. Identical key names across all three servers = one contract, not three. ⚠ **Deliberately did NOT attempt "under-indexed" (jdoc's 3 docs): zero is a FACT, "too few" is an INFERENCE, and any threshold would manufacture false warnings.** Additive, silent once anything is indexed. New `tests/test_v1_108_174.py` (4). Suite parity: jdata v1.28.0 SHIPPED same day; **jdoc BLOCKED — jjg has an in-progress CLAUDE.md trim + an UNTRACKED `docs/CLAUDE-history.md` there holding the only copy of 1.67.0-1.92.0/1.96.0 history; do not switch branches or `git clean` under it.** Suite 5765. No schema/tool-count/INDEX_VERSION change.
4+
- **Version:** 1.108.175 — **An ignored argument cannot prove absence.** Found LIVE while auditing #375, by me, against our own repo: a `search_text` call passed `regex=true` — **the parameter is `is_regex`**. Every tool reads its arguments key-by-key (`arguments.get("is_regex", False)`), so the flag was **dropped in silence**, the regex SOURCE TEXT was searched as a LITERAL substring, nothing matched, and the response reached `state:"absent"` with "treat this as strong evidence the target is not present" **plus a citable `evidence_ref`** — over a corpus that plainly contained the target (`_record_coverage` is in `index_folder.py` three times). ⚠ **The dangerous half is not the wrong answer, it is that a MISUNDERSTOOD CALL minted an absence PROOF a handoff could cite.** Fix: dispatcher diffs each call's arguments against the tool's **published `inputSchema`** (`_declared_arg_keys`, built once from the same catalog `list_tools` publishes, so the contract cannot drift from what the agent was shown) → `_meta.ignored_arguments` + `absent`→`degraded` with a note naming the keys. ⚠ **DISCLOSE on every state, REFUSE only the absence CLAIM — same shape as the 1.108.168 `rebuilding` gate; expressing the refusal as a DOWNGRADE means `handoff.absence_refusal` does the refusing and there is no second rule to keep in sync.** ⚠ **Deliberately NEVER rejects the call:** under the 1.x zero-surprise contract an unknown key has always been accepted, so a client sending a harmless extra for a year must not start erroring, and a hard reject turns a recoverable mistake into a dead call. ⚠ **An unknown schema accuses NOBODY** (unreadable declaration → return nothing, never guess) else every tool we failed to read manufactures warnings. ⚠ **New `UNIVERSAL_META_JSON` in `encoding/schema_driven.py` — structured `_meta` keys EVERY encoder preserves regardless of its own allowlist. A per-schema allowlist is right for tool payloads and WRONG for CONTRACT keys: the encoder drops the key, the response still looks complete, and the field that said "don't trust this" is the one that vanishes. That is exactly how the verdict contract went invisible in 1.108.169 — and adding a key to 45 tuples only guarantees the 46th encoder forgets it.** New `tests/test_v1_108_175.py` (21), non-vacuity proven (gate disabled → the same call still mints `absent:2a1a6b8520c3`). No schema/tool-count/INDEX_VERSION change.
5+
- **Prior (1.108.174):** — **An empty index says so (#375 correspondence, suggestion C).** A user ran the suite for MONTHS with jdata holding **0 datasets** and jdoc holding **3 documents**, and only found out by going looking. His framing is the keeper: **every tool answers confidently regardless of how little it holds, so an agent cannot tell "I searched everything and it is not there" from "I have almost nothing indexed"** — "we would have fed both tools months ago." `list_repos` on an empty store now carries top-level `empty: true` + a `hint` naming `index_folder` AND why it matters. ⚠ **TOP-LEVEL, not `_meta`, ON PURPOSE: jdoc/jdata strip `_meta` by default (`get_meta_fields()` -> []), so a nudge placed there is DELETED before the agent sees it** — the same trap that forced re-attaching the absence ref after filtering in jdata v1.26.0. Identical key names across all three servers = one contract, not three. ⚠ **Deliberately did NOT attempt "under-indexed" (jdoc's 3 docs): zero is a FACT, "too few" is an INFERENCE, and any threshold would manufacture false warnings.** Additive, silent once anything is indexed. New `tests/test_v1_108_174.py` (4). Suite parity: jdata v1.28.0 SHIPPED same day; **jdoc BLOCKED — jjg has an in-progress CLAUDE.md trim + an UNTRACKED `docs/CLAUDE-history.md` there holding the only copy of 1.67.0-1.92.0/1.96.0 history; do not switch branches or `git clean` under it.** Suite 5765. No schema/tool-count/INDEX_VERSION change.
56
- **Prior (1.108.173):** — **Exact-match honesty: a fuzzy near-miss stopped looking like a hit (#375 correspondence, suggestion B).** A user searched the exact token `runPromiseFulfillmentCheck` (committed that day, NOT yet indexed) and got **ten ranked hits on the substring "fulfillment"** — order fulfilment, shipping routing, PHP helpers — **formatted exactly like real hits**. Reproduced here: `extractMountsFulfillmentCheck` returned `_extract_mounts`/`TestExpressRouterMount`/`_JS_MOUNT` under `state:"ok"` with the note **"Confident matches returned."** ⚠ **The verdict was actively VOUCHING for guesses — that string is the sharpest part of the bug, not the ranking.** Cause is BM25 tokenization and is NOT itself wrong: the query splits to run/promise/fulfillment/check so token overlap scores; right for prose, misleading for an identifier. Fix: `_meta.exact_match` `{queried,found,exact,prefix,note}` + **downgrade `ok` -> `low_confidence`** when nothing exact/prefix came back. ⚠ **LABELS, does not SUPPRESS** — the results are still the best available guesses. ⚠ **Deliberately NOT `absent`: results WERE returned, and under the absence contract only `absent` proves absence, so `low_confidence` can never be cited as evidence the symbol doesn't exist.** Scope is narrow ON PURPOSE: one source-shaped token only (camel/snake/qualified/dunder) via the v1.108.137 `query_shape` classifier; prose, multi-word, filenames **and BARE LOWERCASE WORDS** are byte-identical — `mounts`/`fulfillment` could be a symbol OR a topic and there is no signal to tell them apart, so labelling them would invent a NEW false negative. ⚠ **`exact_match` had to be added to `_META_JSON` in the search_symbols encoder — a structured `_meta` value left off that allowlist is SILENTLY DROPPED by MUNCH compaction, which is exactly how the whole verdict contract went invisible in v1.108.169. Pinned by a round-trip test.** New `tests/test_v1_108_173.py` (24), verified non-vacuous (no skips; the conditional guards actually execute). Suite 5761. No schema/tool-count/INDEX_VERSION change.
6-
- **Prior (1.108.172):** — **Idle index-cache TTL (OPT-IN) + a process presence registry, because instance sprawl was invisible (#375 follow-up).** A user found **25+ live jcm instances** on one box against ONE ~140MB store, ages to 1d15h, mostly stdio servers **their MCP client never reaped at session end**; reaping the day-plus-old ones freed **~17 GB**. ⚠ **We cannot reap another program's children — all our threads are already `daemon=True` and `run_stdio_server` exits on stdin EOF. The leak is client-side; the MEMORY is ours.** `_index_cache` holds up to `_CACHE_MAX_SIZE=32` hydrated CodeIndex objects released ONLY under LRU pressure, never on idle — the same fact that showed up in #370 as one worker at ~16 GiB. **(1)** New `JCODEMUNCH_INDEX_CACHE_TTL`; ⚠ **DEFAULT 0 = OFF AND MUST STAY OFF — cold hydration of that 665k-symbol index measured 7.5-11.4 MINUTES (#370), so evicting on idle hands the next query that bill. Defaulting it on fixes the leaking box by breaking the large-index one.** Unset/0/negative/garbage all disable (byte-identical to before); swept on access, NO timer thread (a leaked process makes no calls, so it needs no timer to stay small). **(2)** New `storage/process_registry.py`: one `~/.code-index/_processes/<pid>.json` per server (pid/client/transport/version/started; no repos, paths, or queries), removed on exit, readers prune by PID liveness so a hard kill leaves nothing. Surfaced as `get_session_stats.processes`; hint only past 5 live procs; wrapped so a registry failure can never fail a stats call. **README background-behavior section updated — that is the PyPI-compliance surface for anything that writes files.** **(3) ⚠ FIXED A SHARED PRIMITIVE the registry uncovered: `_is_pid_alive` reported DEAD Windows processes as ALIVE.** A successful `OpenProcess` does NOT mean running — while ANY handle remains open the PID stays queryable after exit, and **the spawning parent normally holds exactly such a handle**. Blast radius beyond sprawl: `process_locks.inspect` treats a dead holder's lock as stale/ignorable, so **a crashed server's lock could look permanently held**. Now checks `GetExitCodeProcess` for `STILL_ACTIVE`, falling back to the old answer on failure rather than calling a possibly-live process dead; POSIX zombies read dead via `/proc/<pid>/stat`. ⚠ **Also added the missing `argtypes`/`restype`: `OpenProcess` returns a pointer-sized HANDLE and ctypes defaults to `c_int`, TRUNCATING it on 64-bit — the same trap already documented for `GetProcessTimes`.** New `tests/test_v1_108_172.py` (21) incl. a real spawn-and-kill that fails pre-fix. No schema/tool-count/INDEX_VERSION change.
7-
- **Older releases (1.108.171 and earlier):** see `CHANGELOG.md` — the authoritative version history (411 entries, 1.0.0 onward).
7+
- **Older releases (1.108.172 and earlier):** see `CHANGELOG.md` — the authoritative version history (411 entries, 1.0.0 onward).
88
- **INDEX_VERSION:** 17
9-
- **Tests:** 5765 passed, 7 skipped (1.108.174) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
9+
- **Tests:** 5786 passed, 7 skipped (1.108.175) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
1010
- **Python:** >=3.10
1111
- **Tool count:** 90 in `full` (front door hidden; +1 v1.108.111 `get_parity_map`, +1 v1.108.112 `get_decorator_census`, +1 v1.108.113 `get_architecture_metrics`); `tool_surface=counter` exposes a 3-tool front door (`order`/`menu`/`route`) instead
1212

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.174"
3+
version = "1.108.175"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/jcodemunch_mcp/encoding/schema_driven.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@
3131
write_table,
3232
)
3333

34+
# Structured `_meta` keys every encoder must preserve, whether or not its own
35+
# schema remembered to list them.
36+
#
37+
# A per-schema allowlist is the right default for tool-specific payloads and the
38+
# wrong one for CONTRACT keys, because the failure is silent and asymmetric: the
39+
# encoder drops the key, the response still looks complete, and the agent loses
40+
# exactly the field that told it not to trust the answer. That is how the whole
41+
# verdict contract went invisible in v1.108.169, and adding a key to 45 tuples
42+
# only guarantees the 46th encoder forgets it.
43+
#
44+
# Add a key here ONLY when losing it would make a response misleading rather
45+
# than merely less informative.
46+
UNIVERSAL_META_JSON = frozenset({
47+
"verdict", # retrieval verdict incl. absence evidence_ref
48+
"ignored_arguments", # v1.108.175: part of the call was silently discarded
49+
})
50+
3451

3552
@dataclass
3653
class TableSpec:
@@ -116,7 +133,7 @@ def encode(
116133
# verdict is the reason this exists: dropping it hands the agent a confident
117134
# zero-result answer with no way to know the scan was degraded (and silently
118135
# discards the absence evidence_ref minted in call_tool).
119-
for k in meta_json_blobs:
136+
for k in set(meta_json_blobs) | UNIVERSAL_META_JSON:
120137
if k in meta:
121138
scalar_payload[f"__json._meta.{k}"] = json.dumps(
122139
meta[k], separators=(",", ":")
@@ -236,7 +253,7 @@ def decode(
236253
prefixed = f"_meta.{k}"
237254
if prefixed in raw_scalars:
238255
meta_out[k] = _coerce(raw_scalars[prefixed], stypes.get(prefixed, "str"))
239-
for k in meta_json_blobs:
256+
for k in set(meta_json_blobs) | UNIVERSAL_META_JSON:
240257
prefixed = f"__json._meta.{k}"
241258
if prefixed in raw_scalars:
242259
try:

src/jcodemunch_mcp/server.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from . import __version__
2323
from . import config as config_module
2424
from . import runtime_identity
25+
from .tools import _arg_contract
2526
# Tool modules are imported lazily inside each call_tool() dispatch branch.
2627
# This defers loading heavy dependencies (tree-sitter, httpx, pathspec) until
2728
# the first actual call to a tool that needs them, reducing cold-start latency
@@ -330,6 +331,28 @@ def _raw_catalog_tools() -> list:
330331
return _RAW_CATALOG or []
331332

332333

334+
_DECLARED_ARG_KEYS: "Optional[dict]" = None
335+
336+
337+
def _declared_arg_keys(name: str):
338+
"""Declared inputSchema property names for a tool, or None if unknown.
339+
340+
Built once from the same catalog `list_tools` publishes, so the contract can
341+
never drift from what the agent was shown. None (not an empty set) when the
342+
tool or its schema is missing: an absent declaration is not evidence that a
343+
caller's key is wrong.
344+
"""
345+
global _DECLARED_ARG_KEYS
346+
if _DECLARED_ARG_KEYS is None:
347+
built = {}
348+
for t in _raw_catalog_tools():
349+
props = (t.inputSchema or {}).get("properties")
350+
if isinstance(props, dict) and props:
351+
built[t.name] = frozenset(props)
352+
_DECLARED_ARG_KEYS = built
353+
return _DECLARED_ARG_KEYS.get(name)
354+
355+
333356
def _catalog_rows() -> "list[dict]":
334357
"""Menu-shaped rows for every real action (front door excluded)."""
335358
rows = []
@@ -6353,6 +6376,22 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolR
63536376
except Exception:
63546377
logger.debug("Steering attach failed", exc_info=True)
63556378

6379+
# Argument contract (v1.108.175): every tool reads its arguments
6380+
# key-by-key, so a misspelled parameter is dropped in silence and the
6381+
# call that runs is not the call that was asked for. Disclose the
6382+
# ignored keys on every state, and downgrade `absent` to `degraded` so
6383+
# the absence-refusal rule below does the refusing — MUST run before the
6384+
# absence-evidence block for that to hold.
6385+
try:
6386+
_ignored = _arg_contract.unrecognized_keys(
6387+
arguments, _declared_arg_keys(name)
6388+
)
6389+
if _ignored:
6390+
_arg_contract.apply_argument_contract(result, _ignored)
6391+
logger.debug("Ignored unknown arguments for %s: %s", name, _ignored)
6392+
except Exception:
6393+
logger.debug("Argument-contract check failed", exc_info=True)
6394+
63566395
# Absence evidence (#377 phase 3): record every absence-shaped verdict
63576396
# so a handoff claim can cite the SCAN when nothing was served, and
63586397
# hand the caller the citable ref in-band. A ref is only surfaced when

0 commit comments

Comments
 (0)