Skip to content

Commit 52bd24f

Browse files
jgravelleclaude
andcommitted
release: v1.108.164 - path-shaped repo arg returns a routed error (#376)
resolve_repo split any repo containing "/" on the first separator and returned the halves unvalidated, so repo="src/auth/service.ts" became owner="src", name="auth/service.ts". Callers catch ValueError from the resolver, but that pair then reached store.load_index and raised "Path separator in name" from _safe_repo_component, outside every handler. An owner/name id can never carry a second separator (the store rejects one at write time), so every such argument was guaranteed to crash rather than route. A post-split name that still contains a separator is now treated as a path. It tries the existing path-resolution route first, which picks up bare relative multi-segment paths that _looks_like_path is deliberately too conservative to match, and otherwise raises an actionable error naming resolve_repo, index_folder, and file_pattern= when the argument looks like a file. The unindexed-path error routes through the same helper so both paths give one message. Single-separator ids and every real owner/name are untouched. Reported from a daily Ubuntu install; split out of #375. New tests/test_v1_108_164.py (8). No schema/tool-count/INDEX_VERSION change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c2e5309 commit 52bd24f

4 files changed

Lines changed: 173 additions & 6 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.108.164] - 2026-07-23 - A path-shaped `repo` arg returns a routed error, not a raw storage crash (#376)
6+
7+
### Fixed
8+
9+
- **`resolve_repo` no longer hands the store an owner/name pair it must reject.**
10+
An agent without a repo id at hand guesses a path. `tools/_utils.resolve_repo`
11+
split any `repo` containing `/` on the FIRST separator and returned the halves
12+
unvalidated, so `repo="src/auth/service.ts"` became
13+
`owner="src"`, `name="auth/service.ts"`. Callers catch `ValueError` from the
14+
resolver, but that pair then reached `store.load_index(owner, name)` and raised
15+
`ValueError: Path separator in name` from `_safe_repo_component`, outside every
16+
handler. An `owner/name` id can never legitimately carry a second separator
17+
(the store rejects one at write time), so every such argument was guaranteed to
18+
crash rather than route.
19+
20+
A post-split name that still contains a separator is now treated as a path.
21+
It first tries the existing path-resolution route, which picks up bare relative
22+
multi-segment paths (`apps/web/src`) that `_looks_like_path` is deliberately too
23+
conservative to match, and otherwise raises an actionable error the callers
24+
already surface in band. New `_path_shaped_repo_error` names `resolve_repo` and
25+
`index_folder`, and — when the argument looks like a file — names `file_pattern=`,
26+
which is what an agent passing `repo=<file>` usually meant. The unindexed-path
27+
error in `_resolve_path_repo` routes through the same helper so both paths give
28+
one message. Single-separator ids and every real `owner/name` are untouched.
29+
30+
Reported from a daily Ubuntu install; split out of #375. New
31+
`tests/test_v1_108_164.py` (8). NO schema/tool-count/`INDEX_VERSION` change.
32+
533
## [1.108.163] - 2026-07-23 - License-key transport hardening: key never rides the URL (audit WS-8)
634

735
### Security

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.163"
3+
version = "1.108.164"
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/tools/_utils.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@ def _looks_like_path(repo: str) -> bool:
7373
return False
7474

7575

76+
def _path_shaped_repo_error(repo: str) -> ValueError:
77+
"""Actionable error for a `repo` arg that is a path, not a repository id (#376).
78+
79+
Agents that don't have an owner/name id at hand guess a file or directory path.
80+
Name the two calls that produce a real id, and — when the arg looks like a file —
81+
the `file_pattern` arg they most likely wanted instead of a hard failure.
82+
"""
83+
message = (
84+
f"'{repo}' is a path, not a repository id. "
85+
"Call resolve_repo(path) to get the repo id for a checkout, "
86+
"or index_folder(path) to index it."
87+
)
88+
if Path(repo).suffix:
89+
message += (
90+
" To scope a search to one file, pass the repo id as 'repo' and the "
91+
f"path as 'file_pattern' (file_pattern='{repo}')."
92+
)
93+
return ValueError(message)
94+
95+
7696
def _resolve_path_repo(repo: str, storage_path: Optional[str]) -> tuple[str, str]:
7797
"""Map a filesystem path (repo='.', an absolute path) to its indexed repo id."""
7898
from .resolve_repo import _compute_repo_id # noqa: PLC0415
@@ -97,10 +117,7 @@ def _resolve_path_repo(repo: str, storage_path: Optional[str]) -> tuple[str, str
97117
return entry["repo"].split("/", 1)
98118
except OSError:
99119
continue
100-
raise ValueError(
101-
f"Path '{repo}' is not an indexed repository. "
102-
"Call resolve_repo(path) to check its status, or index_folder(path) to index it."
103-
)
120+
raise _path_shaped_repo_error(repo)
104121

105122

106123
def resolve_repo(repo: str, storage_path: Optional[str] = None) -> tuple[str, str]:
@@ -115,7 +132,18 @@ def resolve_repo(repo: str, storage_path: Optional[str] = None) -> tuple[str, st
115132
return _resolve_path_repo(repo, storage_path)
116133

117134
if "/" in repo:
118-
return repo.split("/", 1)
135+
owner, name = repo.split("/", 1)
136+
# A second separator means this was never an owner/name id — the store
137+
# rejects separators in `name` at write time, so such a pair can only
138+
# blow up in the storage layer, outside every caller's handler (#376).
139+
# Treat it as a path: a bare relative path like 'src/auth' is exactly
140+
# what _looks_like_path is too conservative to catch.
141+
if "/" in name or "\\" in name:
142+
try:
143+
return _resolve_path_repo(repo, storage_path)
144+
except ValueError:
145+
raise _path_shaped_repo_error(repo) from None
146+
return owner, name
119147

120148
store = IndexStore(base_path=storage_path)
121149
mapping = _get_bare_name_map(store)

tests/test_v1_108_164.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""v1.108.164: a path-shaped `repo` arg no longer escapes as a raw storage ValueError.
2+
3+
Origin: #376 (split out of #375), reported from a daily Ubuntu install. An agent that
4+
doesn't have an owner/name id at hand guesses a path. A two-segment path split on the
5+
FIRST separator left a `name` still carrying one, which the store rejects at
6+
`_safe_repo_component` — outside every caller's handler, so it surfaced as a hard
7+
`ValueError: Path separator in name` instead of a routed tool error.
8+
"""
9+
10+
from types import SimpleNamespace
11+
12+
import pytest
13+
14+
from jcodemunch_mcp.tools import _utils
15+
from jcodemunch_mcp.tools import resolve_repo as rr
16+
17+
18+
def _store_with(repos=(), present=False):
19+
return SimpleNamespace(
20+
inspect_index=lambda o, n: SimpleNamespace(index_present=present),
21+
list_repos=lambda: list(repos),
22+
)
23+
24+
25+
class TestMultiSegmentRepoArg:
26+
def test_file_path_raises_actionable_error_not_storage_valueerror(
27+
self, monkeypatch
28+
):
29+
monkeypatch.setattr(rr, "_compute_repo_id", lambda p, store=None: "")
30+
monkeypatch.setattr(
31+
_utils, "IndexStore", lambda base_path=None: _store_with()
32+
)
33+
with pytest.raises(ValueError) as exc:
34+
_utils.resolve_repo("src/auth/service.ts")
35+
message = str(exc.value)
36+
assert "Path separator in name" not in message
37+
assert "is a path, not a repository id" in message
38+
assert "resolve_repo" in message and "index_folder" in message
39+
40+
def test_file_shaped_arg_names_file_pattern(self, monkeypatch):
41+
monkeypatch.setattr(rr, "_compute_repo_id", lambda p, store=None: "")
42+
monkeypatch.setattr(
43+
_utils, "IndexStore", lambda base_path=None: _store_with()
44+
)
45+
with pytest.raises(ValueError) as exc:
46+
_utils.resolve_repo("src/auth/service.ts")
47+
assert "file_pattern='src/auth/service.ts'" in str(exc.value)
48+
49+
def test_directory_shaped_arg_omits_the_file_pattern_hint(self, monkeypatch):
50+
# No suffix means the agent meant a checkout, not a file to scope to.
51+
monkeypatch.setattr(rr, "_compute_repo_id", lambda p, store=None: "")
52+
monkeypatch.setattr(
53+
_utils, "IndexStore", lambda base_path=None: _store_with()
54+
)
55+
with pytest.raises(ValueError) as exc:
56+
_utils.resolve_repo("local/foo/bar")
57+
assert "file_pattern" not in str(exc.value)
58+
59+
def test_backslash_tail_also_routed(self, monkeypatch):
60+
monkeypatch.setattr(rr, "_compute_repo_id", lambda p, store=None: "")
61+
monkeypatch.setattr(
62+
_utils, "IndexStore", lambda base_path=None: _store_with()
63+
)
64+
with pytest.raises(ValueError) as exc:
65+
_utils.resolve_repo("local/foo\\bar")
66+
assert "is a path, not a repository id" in str(exc.value)
67+
68+
def test_relative_path_that_is_indexed_resolves(self, monkeypatch, tmp_path):
69+
# 'apps/web/src' is too conservative for _looks_like_path, but if it really
70+
# is an indexed checkout we should resolve it rather than error.
71+
sub = tmp_path / "apps" / "web" / "src"
72+
sub.mkdir(parents=True)
73+
monkeypatch.chdir(tmp_path)
74+
monkeypatch.setattr(rr, "_compute_repo_id", lambda p, store=None: "")
75+
monkeypatch.setattr(
76+
_utils,
77+
"IndexStore",
78+
lambda base_path=None: _store_with(
79+
repos=[{"repo": "local/web", "source_root": str(sub)}]
80+
),
81+
)
82+
assert tuple(_utils.resolve_repo("apps/web/src")) == ("local", "web")
83+
84+
85+
class TestNoRegressionOnRealIds:
86+
def test_owner_name_id_untouched(self):
87+
assert tuple(_utils.resolve_repo("local/gin")) == ("local", "gin")
88+
89+
def test_single_slash_missing_repo_keeps_its_own_error_path(self, monkeypatch):
90+
# One separator is a well-formed id shape; it must NOT be re-routed as a
91+
# path, so the caller still gets the index-not-loadable error downstream.
92+
monkeypatch.setattr(
93+
_utils, "IndexStore", lambda base_path=None: _store_with()
94+
)
95+
assert tuple(_utils.resolve_repo("tools/agents.ts")) == (
96+
"tools",
97+
"agents.ts",
98+
)
99+
100+
101+
class TestSearchSymbolsSurfacesItInBand:
102+
def test_path_shaped_repo_returns_error_dict(self, monkeypatch):
103+
from jcodemunch_mcp.tools.search_symbols import search_symbols
104+
105+
monkeypatch.setattr(rr, "_compute_repo_id", lambda p, store=None: "")
106+
monkeypatch.setattr(
107+
_utils, "IndexStore", lambda base_path=None: _store_with()
108+
)
109+
result = search_symbols(repo="src/auth/service.ts", query="login")
110+
assert isinstance(result, dict)
111+
assert "is a path, not a repository id" in result["error"]

0 commit comments

Comments
 (0)