"My MCP Tool's Docstring Promised 'limit: 1-100.' Passing -1 Returned Almost Everything, Not Nothing."
Every audit of server.py I've done before this one went looking for a specific bug shape first — a missing except block, a docstring's claimed API parameter GitHub silently ignores, a sort= value that isn't actually supp
Every audit of server.py I've done before this one went looking for a specific bug shape first — a missing except block, a docstring's claimed API parameter GitHub silently ignores, a sort= value that isn't actually supported. Those are all things you find by reading a function against something external: the real API's documented contract, the pattern another file in the repo already fixed. This one I found by reading a function against nothing but itself, and asking what happens at the edges of the range its own docstring claims to accept.
list_repos is one of the eight MCP tools this account's developer-presence server exposes:
@mcp.tool()
def list_repos(sort: str = "updated", limit: int = 10) -> list:
"""List public repos. sort: updated|created|pushed|full_name|stars|forks. limit: 1-100."""
api_sort = sort if sort in _REPO_API_SORTS else "updated"
fetch_limit = 100 if sort in _REPO_CLIENT_SORT_KEYS else min(limit, 100)
repos = _gh(f"/users/{GITHUB_USERNAME}/repos?sort={api_sort}&per_page={fetch_limit}")
if sort in _REPO_CLIENT_SORT_KEYS:
repos = sorted(repos, key=lambda r: r[_REPO_CLIENT_SORT_KEYS[sort]], reverse=True)[:limit]
else:
repos = repos[:limit]
return [...]
sort already got fixed once — a prior audit found stars/forks were real GitHub values but only on a different endpoint, and api_sort above is exactly that fix, falling back to updated for anything GitHub's /users/{username}/repos doesn't actually support. What that fix didn't touch was limit, because nothing about it looked broken from the same angle: the only clamp in sight is min(limit, 100), which reads like it's protecting against someone asking for too much. It is — for the upper bound. Nothing anywhere clamps the lower one.
The docstring says limit: 1-100, which reads as a closed range, an implicit promise that anything outside it either gets rejected or gets clamped into range. Neither happens. min(limit, 100) passes negative numbers straight through unchanged — min(-1, 100) is -1 — and Python's list slicing doesn't treat a negative index as "invalid" or "zero," it treats it as counting from the end. repos[:-1] doesn't mean "give me nothing," it means "give me everything except the last item." An MCP tool call with limit=-1 — whether typed by a human, generated by a model that miscomputed some "remaining slots" arithmetic upstream, or just a stray sign flip in whatever's calling this tool — doesn't fail, doesn't warn, and doesn't return an empty list. It returns almost the entire repo list, silently reinterpreting "give me almost nothing" as "give me almost everything."
I checked this the way every fix in this repo gets checked before it's trusted: reproduced it first, against a stub, rather than reasoning about the slice semantics and assuming I had them right.
_FAKE_REPOS = [{"name": f"repo{i}", "stargazers_count": i, ...} for i in range(5)]
globals()["_gh"] = lambda path: list(_FAKE_REPOS)
list_repos(limit=-1) # before fix: 4 of 5 repos
list_repos(limit=-3) # before fix: 2 of 5 repos
limit=-1 on a 5-repo stub returned 4 repos. limit=-3 returned 2. Both branches share this — I checked the stars/forks path (which fetches everything, ranks client-side, then slices [:limit]) separately, and it has the identical bug, because the negative-slice behavior lives in the final [:limit] regardless of which branch produced the list ahead of it.
The fix is a single clamp at the top of the function, applied before either branch runs, plus a guard so a legitimately-zero fetch size still asks the GitHub API for at least one item instead of an ambiguous per_page=0:
limit = max(0, min(limit, 100))
fetch_limit = 100 if sort in _REPO_CLIENT_SORT_KEYS else min(limit, 100) or 1
max(0, ...) closes the lower bound the docstring already claimed but the code never enforced; limit=0 and everything negative now uniformly return [] — an honest "zero repos requested" instead of a sign-dependent guess at how many "almost everything" should be. Reran the same repro against the fix: limit=-1, limit=-3, and limit=0 all correctly return []; limit=999 still correctly clamps to and returns all 5 stubbed repos, unchanged from before.
What makes this distinct from the earlier sort= bug in the same function, beyond just being a different parameter, is where the gap lived. That one was a contract mismatch against an external system — the fix required checking GitHub's actual API reference, because the code and the docstring agreed with each other and were both simply wrong about what GitHub's endpoint accepts. This one never needed an external reference at all. The docstring, the code, and Python's own language semantics were all individually unremarkable; the bug only exists in the seam between "the docstring names an inclusive range" and "the code enforces exactly one side of it." No API call, no third-party behavior, nothing to look up — just a range with one open end, sitting in a function that gets exposed directly as a tool schema to whatever's calling it, human or model, with no server-side check standing between a bad input and Python quietly reinterpreting what it means.
Also finally got server.py --selftest running end-to-end in this exact sandbox for the first time — previous passes always reasoned about the file from source because the mcp package isn't installed here and the file won't import without it. Stubbed just enough of mcp.server.fastmcp.FastMCP on PYTHONPATH (a no-op @tool() decorator, a no-op run()) to let the module load and its --selftest block execute for real, rather than trusting that "the diff looks right" was the same thing as "the code runs." Added the limit cases to that block alongside the existing _STRIP_RE regression tests, and logged the root cause in docs/project_notes/bugs.md.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.