I have an MCP server that digests a GitHub repository into markdown so a model can read it without cloning anything. Directory tree, plus the contents of the files that matter.
I pointed it at a real repository and got this back:
Error: result (109,668 characters) exceeds maximum allowed tokens
Not a truncated answer. Not a partial digest with a warning. Nothing. The tool call failed and the caller was left with an error message where a repository summary should have been.
The cause was a constant I had written months earlier without thinking about it:
MAX_FILE_BYTES = 32_000
TOP_N_FILES = 10
Ten files, each capped at 32,000 characters. I had reasoned that ten files is a reasonable amount of a repository to show. Which is true, and also not a size limit at all. Ten files is anywhere from a few hundred characters to 320,000 depending on whose repository you point it at.
The obvious fix, and why I did not just do it
The obvious fix is to add a total character cap. Pick a number, fill it, stop.
The problem is picking the number. I could have reasoned my way to one. 50,000 sounds fine. 32,000 sounds fine too. So does 64,000. Any of them sounds fine, which is a good sign that reasoning is not the tool for the job, and is exactly how TOP_N_FILES = 10 got there in the first place.
So before changing anything I measured. Six repositories, spanning the range I would realistically encounter: two small ones of my own, a mid-sized MCP server, a docs-heavy list repo, and two large real codebases.
| repository | files | tree | file content | total |
|---|---|---|---|---|
| GopherMCP/GopherCache | 5 | 89 | 4,505 | 4,594 |
| pyarchana/gopher | 22 | 591 | 23,640 | 24,231 |
| sktime/sktime-mcp | 104 | 3,296 | 100,490 | 103,786 |
| astral-sh/uv | 1,578 | 65,530 | 142,337 | 207,867 |
| modelcontextprotocol/python-sdk | 1,643 | 57,306 | 160,182 | 217,488 |
| punkpeye/awesome-mcp-servers | 10 | 173 | 228,137 | 228,310 |
A fifty-fold range between the smallest and largest output, from the same tool with the same settings.
Three things fell out of that table that I would not have guessed.
The tree alone can eat everything
Look at the tree column for uv and the python SDK. 65,530 and 57,306 characters, just to list filenames.
I had been thinking of the budget as a cap on file contents. But on a repository with 1,600 files, the directory listing on its own is larger than any sensible total. If I had capped only the file content, uv would have blown past any budget I set before fetching a single file.
The tree needed its own cap, at a fraction of the total, and a note saying how many entries it left out.
The ranking was picking the wrong files
This is the one that actually mattered, and I only saw it because I printed what was being selected rather than just how big it was.
Here is what my tool chose to show about uv, a Rust build tool:
27,194 test/ecosystem/airflow/pyproject.toml
32,048 test/ecosystem/home-assistant-core/pyproject.toml
27,517 test/ecosystem/pandas/pyproject.toml
9,156 test/ecosystem/jupyterlab/pyproject.toml
8,757 test/ecosystem/black/pyproject.toml
Eighty-nine thousand characters of other projects' dependency lists, scraped out of uv's test fixtures. The only uv code that made it in was three main.rs files, all of them thin entry points, and Cargo.toml ranked below every one of those fixtures.
And here is awesome-mcp-servers:
32,048 README-fa-ir.md
32,048 README-ja.md
32,048 README-ko.md
32,048 README-pt_BR.md
32,048 README-th.md
32,048 README-zh.md
32,048 README-zh_TW.md
The same README in seven languages, 224,000 characters of it.
The cause was one line:
if name in PRIORITY_NAMES:
score += 1000
Any file called pyproject.toml got a thousand points, wherever it sat. A vendored copy six directories deep in a test fixture scored exactly the same as the one at the root describing the actual project.
This is why the measurement mattered. If I had shipped the budget on its own, the digest would have gone from 207,867 characters of the wrong files to 40,000 characters of the wrong files. Smaller, still useless, and now looking deliberate.
The file it wanted most came back empty
One more, which I would never have found by reading code.
In the awesome-mcp-servers run, the top-ranked file scored 1200 and returned zero characters:
0 score=1200 README.md
32,048 score=200 README-fa-ir.md
GitHub's contents API will not serve a file over 1MB. It does not return an error. It returns 200 OK with an empty content field and encoding: "none". So the base64 decode succeeds, produces an empty string, and the file silently disappears.
That README is 1,616,144 bytes. The single most important file in the repository was being dropped without a word, and seven translations of it were filling the space instead. The fix is to notice encoding: "none" and refetch through the blobs endpoint, which serves up to 100MB.
What I changed
A total budget, not a file count. 40,000 characters, configurable. Spent on the tree first, then files in priority order, until it runs out. The number of files now falls out of what fits instead of being pinned in advance.
A cap on the tree, a quarter of the total, with a line saying how many entries it omitted.
A per-file ceiling of 40% of what remains, so one large file cannot crowd out everything else.
Ranking by position, not just name. The priority bonus now decays with directory depth, so the root manifest beats a vendored one. Test and fixture directories lose points, vendored ones lose more, examples lose only a little because sometimes an examples directory is the best documentation a project has. Files in the repository's primary language, which the API already tells you, gain some.
Here is the same table after:
| repository | before | after |
|---|---|---|
| GopherMCP/GopherCache | 4,594 | 5,005 |
| pyarchana/gopher | 24,231 | 39,263 |
| sktime/sktime-mcp | 103,786 | 39,231 |
| astral-sh/uv | 207,867 | 39,321 |
| modelcontextprotocol/python-sdk | 217,488 | 39,224 |
| punkpeye/awesome-mcp-servers | 228,310 | 39,168 |
uv's digest now leads with Cargo.toml and README.md. awesome-mcp-servers leads with its actual README. My own small repo got bigger, because it was only ever showing 10 of its 22 files and now shows all of them.
The thing I would tell past me
I nearly shipped a one-line change. Add a constant, clamp the output, close the issue. It would have passed review, passed tests, and produced a tool that was confidently wrong in a smaller font.
What stopped it was spending an hour printing what the tool actually produced against real inputs. Not unit tests, which only check what I already thought to assert. Not reading the code, which is where the bug had been sitting unnoticed for months. Just running it against six real repositories and looking at the output.
If you are building anything that assembles context for a model, you are making size and selection decisions whether you notice them or not. Print what your tool actually sends, against real inputs, at real sizes. The numbers are frequently not what you expect, and the interesting failure is rarely the one you set out to fix.
The tool is pyarchana/gopher, an MCP server that fetches, caches and digests context for Claude. The measurements above live in #5 and #14 if you want the full before and after.
pyarchana
/
gopher
One MCP server for Claude: digest any GitHub repo, keep memory across conversations, and extract facts locally with Ollama
Gopher
One MCP server that fetches, caches, and digests context for Claude.
Gopher is three things that used to be three separate servers:
-
fetch: point it at a GitHub repo and get back a clean Markdown digest: full directory tree, plus the contents of the files that actually matter. Filters out binaries, lock files,
node_modules,venv, and the rest of the noise, then ranks what's left. No README? It builds one for you. -
cache: persistent memory across conversations, stored as two plain files you can read yourself: a structured
context.jsonand an append-onlydiary.md. - digest: reads a conversation transcript, extracts the facts with a local Ollama model, and merges them into the cache.
Everything runs locally over stdio. Nothing leaves your machine except GitHub API calls.
Tools
Tool
What it does
fetch_github_repo(repo_url)
Markdown digest of a public repo, tree plus top files
read_context()
Return
Top comments (9)
Reproduced the silent drop, and the repair has a shorter path than the blobs endpoint. The same contents URL with
Accept: application/vnd.github.rawhands back all 1,616,144 bytes of that README in one request with no sha lookup, whileapplication/vnd.github+jsongives 200 withencodingset to none and a zero-lengthcontent, exactly as you describe. Blobs does work, but it answers in base64: 2,190,775 characters for a 1,616,144-byte file, so the largest item in your corpus arrives 35 percent over its own size against a budget you count in characters. And the failing response already carriessizeat 1616144, so a check for size above zero with empty content is a tripwire you can set at the call site without depending on the encoding field staying that shape.Good catch on the raw media type, that's the better fix. Just tried it:
Accept: application/vnd.github.rawon the same contents URL hands back the whole file in one request, no sha, no second call. (The README has grown since I wrote this, it's 1,756,337 bytes now.) I'm going to switch to that.Your ratio on the blobs path holds too, it gives me 2,380,814 characters of base64 for that file today. It doesn't reach the budget though, gopher decodes before it counts, so the budget only ever sees the real text. It does still cost about a third more on the wire, which is one more reason to take the raw route.
And the size check is already in there as a fallback next to the encoding check, so the fix isn't leaning on that field alone.
Same failure mode one layer up: our MCP server advertised 46 tools across 5 servers and we had been treating "how many" as the budget. Counting files and counting tools are both proxies for a size nobody measured. The tools/list payload alone came to 9,012 tokens on o200k_base before the agent wrote a word, and cl100k agreed within 8 tokens, so it wasn't a tokenizer artifact. We cap in tokens now, and the fallback when the window is tight is a name-only index at 157 tokens with schemas fetched on demand - the digest shrinks only when you stop paying for the parts you didn't ask for.
"Counting tools" is the same mistake one level up, yeah. It made me go measure gopher's own tools/list, which I'd never actually looked at: 5,786 characters for 10 tools, against 180 for the names alone. And the part I expected to be the problem isn't. The docstrings I was a bit wordy with are 1,858 of it, the schemas are most of the rest.
On tokens vs characters, gopher counts characters, mostly so it doesn't need a tokenizer as a dependency. But o200k and cl100k landing within 8 tokens of each other kind of takes the air out of that excuse. If they agree that closely, the tokenizer isn't the risky part, the unit is.
How are you doing the schemas-on-demand part? tools/list hands back every schema in one go, so I'm guessing there's a proxy in front of your five servers serving the name-only index?
"Not a truncated answer, not a partial digest with a warning, nothing" - this is the difference between a limit and a failure mode. A limit is a property of the tool; a hard error at the limit is a design choice about who has to recover. The caller can't recover from nothing: it can't tell whether the repo was 10% over the budget or 10x over, so it can't decide between "retry with fewer files" and "give up". Partial result plus an explicit over-budget marker is recoverable; the agent can see the shape of what it got and what it didn't. The other habit from the same class: constants like MAX_FILE_BYTES written months ago are assumptions wearing a config costume. They deserve a comment naming the assumption, or better, a log line when they're hit - the first time you learn a limit exists shouldn't be a production error.
Your last line is pretty much literally what happened. The 109k rejection wasn't gopher's limit, it was the client's, and gopher had no idea that limit existed until it got hit. So the tool never made a choice about who recovers, it just didn't know there was anything to recover from.
It does the partial-plus-marker thing now. The files heading says how many were shown and how many were left out to fit the budget, the tree says how many entries it dropped, and a trimmed file says how much of it you're seeing. Your 10% vs 10x point still catches something though, those markers tell you how many files got cut, not how big the whole thing would have been.
The constants got their assumptions written down too, each one with the measurement that set it. But you're right that nothing logs. The model sees the markers, the person running it never does. Going to add that, to stderr, since stdout is the protocol channel on a stdio server.
The 109k-character wall is the number-one enemy of any repo-digestion tool — I hit the identical error building a similar digest. What saved me: a budget per layer instead of per file (tree, then symbols and signatures, then bodies), so the call always returns something degraded rather than nothing at all.
How are you handling the fallback now — does the server degrade gracefully to signatures when the full digest won't fit, or do callers have to retry with narrower paths?
Neither, which is the boring answer. Right now it just includes fewer whole files.
Your version is better and I can see exactly where mine breaks. uv has 1,578 files and my digest returns four of them:
Cargo.toml,README.md,pyproject.toml,Dockerfile. Right files, but you come away knowing what the project is and nothing about how it's built. Signatures would spend the same budget on the shape of a hundred files instead of the text of four.How are you getting symbols across languages though? Python's basically free with
ast, but this thing eats whatever repo you point it at, so Rust and TS and Go all want their own parser. Did you go tree-sitter or only do signatures where you can parse cheaply and fall back to bodies for the rest?Some comments may only be visible to logged-in visitors. Sign in to view all comments.