# Claude Code's 25,000-token MCP limit let a 49,964-token result through. We measured both checks

> Source: <https://dev.to/rulestack/claude-codes-25000-token-mcp-limit-let-a-49964-token-result-through-we-measured-both-checks-54nc>
> Published: 2026-09-23 02:17:00+00:00

We had a 65-line stdio MCP server return text of whatever size we asked for, then read what Claude Code v2.1.273 actually put in context. Two separate checks act on a large result. A size check swaps it for a file path and a 2KB preview somewhere between 45,000 and 52,000 characters. A 25,000-token check swaps it for an error message and a file path. The token check only runs once a result is long in characters, so 24,000 characters of CJK text went straight into context as 49,964 tokens, about twice the limit.

The MCP page of the Claude Code docs has a short section on large tool output. It says Claude Code "displays a warning when any MCP tool output exceeds 10,000 tokens", that the maximum is 25,000 tokens by default and can be changed with the `MAX_MCP_OUTPUT_TOKENS` environment variable, and that when a result goes over the limit, Claude Code saves it to a file under the session's `tool-results` directory and "replaces it in the conversation with a message that names the file path". A second paragraph says a server can raise the "default persist-to-disk threshold" for one tool by setting `_meta["anthropic/maxResultSizeChars"]` in its `tools/list` entry, up to a hard ceiling of 500,000 characters.

That is two different words for the limit, tokens in one paragraph and characters in the next, and no number for the character threshold. We wanted to see which check fires where, what Claude receives in each case, and what it costs in input tokens. So we built the smallest server we could and walked the output size up and down.

Everything ran on 2026-09-16, with Claude Code 2.1.273 and the default model on our account, `claude-opus-5[1m]`, inside a throwaway directory made with `mktemp -d`. The server is a Python script with no dependencies. It speaks newline-delimited JSON-RPC over stdio, answers `initialize`, `tools/list`, and `tools/call`, and has two tools. `emit_text` takes one integer, `chars`, and returns exactly that many characters. `emit_text_annotated` does the same thing but declares `"anthropic/maxResultSizeChars": 300000` in its `_meta`.

The text is filler with a marker at each end: a `BEGIN-MARKER` line, numbered lines of eight random words from a 52-word list, and an `END-MARKER: end-<hash>` line. If Claude can quote the end marker, the whole result reached it. An environment variable in the server config switches the body to random CJK ideographs, 59 per line, which we used for one experiment.

The server was loaded with `--strict-mcp-config --mcp-config mcp.json`, so no other MCP server on the machine was connected. The server entry sets `alwaysLoad: true` in its config, which the docs say loads every tool from that server at session start instead of waiting for a tool search. We also passed `--tools ""` to remove the built-in tools, so that when a result was moved to a file, Claude had no way to open it unless we allowed one. That keeps the first request small, about 3,300 tokens.

The prompt told Claude to call the tool once with a given `chars` value and reply with the end marker, or with `NOMARKER` and a description of what it got. Each run allowed three turns.

For the numbers we read the transcript that `claude -p` writes under `~/.claude/projects/`, not the summed `usage` block in the JSON output. Each assistant record in the transcript has its own `usage`. We add `input_tokens`, `cache_read_input_tokens`, and `cache_creation_input_tokens` to get the total input of that request. Request 1 is the prompt. Request 2 is the prompt plus the tool call plus whatever Claude Code put in place of the result. The difference between the two is the cost of the result as the model saw it. Two small cautions about the numbers. The request 1 total was 3,462 tokens in our first three runs and 3,295 in every run after that, with nothing changed on our side, which is why we only compare the two requests within a run. And the JSON output also lists a small Haiku call in every run, between 954 and 965 input tokens, which is not part of the conversation and is left out here.

To see what Claude Code did between the tool call and request 2, each run also wrote a debug log with `--debug-file`. Every configuration below was run twice. In each pair, the growth from request 1 to request 2 matched to within 8 tokens. The multi-turn runs that used Read differed by up to 52 tokens on their last request.

| Result size | What Claude received | Request 2 minus request 1 | `count_tokens` call | 
|---|---|---|---|
| 2,000 chars | the full text | +871 | no | 
| 30,000 chars | the full text | +11,687 / +11,688 | no | 
| 45,000 chars | the full text | +17,514 | no | 
| 52,000 chars | `<persisted-output>` with a 2KB preview | +1,054 / +1,052 | yes | 
| 62,000 chars | `<persisted-output>` with a 2KB preview | +1,048 / +1,046 | yes | 
| 66,000 chars | "exceeds maximum allowed tokens" and a path | +670 / +672 | yes | 
| 70,000 chars | "exceeds maximum allowed tokens" and a path | +678 / +676 | yes | 
| 24,000 chars of CJK | the full text | +49,964 | no | 

For our word-list text, the inline rows work out to about 2.6 characters per token, so 45,000 characters cost 17,514 tokens. Nothing unusual happened up to there. There was no warning anywhere we could look. The 30,000 and 45,000 character results are over the documented 10,000-token warning line, but the JSON output, stderr, the transcript, and the debug log said nothing about it. The docs describe the warning as something Claude Code displays, and a headless run has no screen to display it on. We did not check the interactive UI.

Between 45,000 and 52,000 characters, the result stopped arriving. From there on, request 2 was about a thousand tokens larger than request 1, not twenty thousand, and what Claude saw came in two different shapes.

At 52,000 and 62,000 characters, the result was replaced with a block that starts like this:

```
<persisted-output>
Output too large (51.7KB). Full output saved to: ~/.claude/projects/<dir>/<session>/tool-results/toolu_01Xf....json

Preview (first 2KB):
[
  {
    "type": "text",
    "text": "BEGIN-MARKER: start-99b7...\n000001 kilo victor merge ...
```

The file it names is the MCP content array written out as JSON: five lines, with the whole text sitting in the `"text"` string on one line. For 52,000 characters that line is 52,891 characters long. The preview is the first 2KB of that JSON, so Claude sees the start of the text and nothing near the end. Both runs answered `NOMARKER` and said they had only a preview.

At 66,000 and 70,000 characters, Claude got this instead, with no preview:

The full message is 1,423 characters. After the path, it gives instructions for summaries and analysis: read the file "in sequential chunks until 100% of the content has been read", say how much was read, and stop retrying after a few failed attempts. The file it names is a `.txt` with the text exactly as the server sent it, 1,116 lines, none longer than 67 characters.

So there are two checks, and they leave different things behind. We would not have guessed that from the docs page. One is a size check with a preview and a JSON file. The other is a token check with an error message and a plain-text file. At 66,000 characters both apply, and the token message is the one Claude sees.

The debug log shows part of how the token check works. In every run from 52,000 characters up, a request to `/v1/messages/count_tokens` appears right after the tool returns, followed by `Persisted tool result to ...`. At 45,000 characters and below there is no such request. So the token check asks the API's token-counting endpoint, but only once a result is long enough in characters. Below that length, the result goes into context without any count.

That also explains the boundary. At 62,000 characters our text is about 24,100 tokens by the ratio above, just under 25,000. The count presumably came back under the limit, and only the size check applied. At 66,000 characters, delivering the same text inline through the annotated tool (below) grew the request by 25,568 tokens, tool call included. That is just over the limit, and the token check applied. The flip lands where the arithmetic says it should.

The last row of the table is why we are writing this post. Random CJK ideographs cost about two tokens per character, where our English filler cost about 0.39. We returned 24,000 of them. That is fewer characters than the 30,000-character run, and at 71,084 bytes in UTF-8 it is also bigger in bytes than the 52,000-character result that was moved to a file.

Claude Code made no `count_tokens` request and did not move the result. The full text went into context, request 2 grew by 49,964 tokens, and Claude quoted the end marker in both runs. Each run cost $0.51 by the CLI's own `total_cost_usd`, where the runs that were moved to a file cost about $0.02.

The documented 25,000-token maximum was never checked. The likeliest reason is that the decision to count is based on characters, and 24,000 characters did not look big. The size check also did not fire, which tells us it is not measured in UTF-8 bytes either. As far as we can tell, both checks look at character count, and the token limit is enforced only for results that are long in characters.

We only tested CJK. Anything else with a low character-per-token ratio is the obvious next thing to try, such as base64, minified JSON full of short keys, or long hex strings. We have not measured those, so we don't know whether they get through the same way.

We ran four more configurations to see which setting moves which check.

With `MAX_MCP_OUTPUT_TOKENS=50000` and a 66,000-character result, the token check went quiet: no `count_tokens` request, no error message. But the result still did not arrive. Claude got the `<persisted-output>` preview (65.6KB), and request 2 grew by 1,065 tokens. Raising the environment variable did not bring the result into context. Whatever sets the size threshold, it did not change with that variable.

With `MAX_MCP_OUTPUT_TOKENS=5000` and a 30,000-character result, which went inline at the default, Claude Code did call `count_tokens` and replaced the result with the "exceeds maximum allowed tokens" message. Our inline measurement of that same result was 11,687 tokens. So the character length at which Claude Code decides to count depends on the limit. At the default it falls between 45,000 and 52,000 characters. With the limit at 5,000, 30,000 characters was enough to trigger a count, and with the limit at 50,000, 66,000 characters was not. We did not work out the exact formula.

The annotated tool behaved as the docs describe, and then some. At 66,000 characters it went inline with no count at all, and request 2 grew by 25,568 tokens. That is above the 25,000 default, which is the documented behavior: a tool that declares `anthropic/maxResultSizeChars` uses that character limit for text "regardless of what `MAX_MCP_OUTPUT_TOKENS` is set to". At 320,000 characters, past the 300,000 we declared, Claude got the `<persisted-output>` preview (317.8KB), again with no count. In our runs, the only check an annotated tool's text went through was the character limit its author chose.

The replacement message only helps if Claude can open the file, so we ran three more configurations with the Read tool allowed.

With the targeted prompt from the earlier runs (just the end marker) and a 66,000-character result, Claude read the file once with `offset: 1100`, got the last 17 lines, and answered correctly. The last request carried 5,245 tokens, against 28,867 for the same result delivered inline through the annotated tool, even though the Read runs also carry the Read tool's definition (about 600 tokens). For a question about part of the output, the file route cost less than a fifth as much. Claude also ignored the "read 100%" instruction, which is written for summaries, and nothing went wrong.

Then we asked a question that needs every line: how many lines contain the word "zulu". With the 66,000-character result, the token-check case, Claude made three parallel Reads of 400 lines each (offsets 1, 401, 801) and answered 166, which is correct. The final request carried 33,185 and 33,133 tokens in the two runs. That is about 4,300 tokens more than the inline request, or about 3,700 once the Read tool's definition is taken out. Most of the gap is likely the line-number prefix Read adds to every line. It also took one extra request.

With the 52,000-character result, the size-check case, the file is the JSON array with the text on one 52,891-character line. We expected Read to have trouble with that. It didn't. A single Read returned all 52,935 characters, Claude counted 125 lines (correct), and the final request carried 26,269 and 26,254 tokens. Claude Code called `count_tokens` twice in those runs, once for the MCP result and once for the Read result, which fits the idea that the same size-triggered count applies to Read output too. We did not look further into that.

So when Claude has a way to read the file, a result moved out of context is not lost. For a partial question it's cheaper, and for a full read it costs a bit more. When the session gives Claude no file tool, as in our runs with `--tools ""`, the replacement is all Claude gets. Every one of those runs answered `NOMARKER` and explained why.

The practical reading, and this is our interpretation, not something the docs state: do not rely on the 25,000-token limit to protect the context from a tool whose output is dense in tokens. The limit is real for long English text. For text that packs many tokens into few characters, the check may never run. If your tool can return CJK, encoded blobs, or other dense content, cap it on the server by tokens or by pages, not by characters.

The second takeaway is that the environment variable and the annotation are not two ways to do the same thing. Raising `MAX_MCP_OUTPUT_TOKENS` only moves the token check. The size check, which kicked in between 45,000 and 52,000 characters for us, still moves the result to a file. The annotation moves the size check and turns off the token check for text. If a tool really needs to return 66,000 characters inline, the annotation is the setting that did it in our runs.

The third is about the file formats. Results moved by the token check are saved as plain text with their line breaks, so Read's offset and limit work as you'd expect. Results moved by the size check are saved as a JSON array with the text on one line. Read managed a 52,891-character line in one call here, but we did not test how far that goes.

Only one text shape per experiment: English-like filler and random CJK. We don't know exactly where either character threshold sits, the length that triggers a token count or the length that triggers the size check. We only know that at the default settings, for this text, both fall between 45,000 and 52,000 characters.

Everything was headless. The 10,000-token warning is documented as a display, and we did not open an interactive session to look for it. Image content, which the docs say is always subject to `MAX_MCP_OUTPUT_TOKENS`, was not tested.

We used one model, with a 1M-token context. We did not test whether the checks behave the same with other models or context sizes. The machine's user-level hooks and plugins were active in every run. They were the same in every run, and each comparison is within a single run, but they were there.

We made 39 `claude -p` calls in total. Nine of them were wasted by a word-splitting bug in our shell loop. It passed the run label and the size as one argument, which shifted every other argument, so Claude was asked to call a tool that did not exist. They are not counted in any number above.

This is a shorter version of our server: a 12-word list, simpler markers, and no CJK switch. Its characters-per-token ratio will not be exactly the same as the 52-word version the numbers above came from, so expect the boundary rows to move a little.

``` python
import sys, json, random
WORDS = "alpha bravo charlie delta echo foxtrot golf hotel river stone cloud paper".split()

def make_text(n):
    rng = random.Random(n)
    head, tail = "BEGIN-MARKER\n", f"\nEND-MARKER: end-{n}\n"
    body, i = "", 0
    while len(body) < n - len(head) - len(tail):
        i += 1
        body += f"{i:06d} " + " ".join(rng.choice(WORDS) for _ in range(8)) + "\n"
    return head + body[: n - len(head) - len(tail)] + tail

schema = {"type": "object", "properties": {"chars": {"type": "integer"}}, "required": ["chars"]}
TOOLS = [
    {"name": "emit_text", "description": "Returns filler text.", "inputSchema": schema},
    {"name": "emit_text_annotated", "description": "Same, larger limit.", "inputSchema": schema,
     "_meta": {"anthropic/maxResultSizeChars": 300000}},
]
for line in sys.stdin:
    req = json.loads(line)
    if "id" not in req:
        continue
    m = req["method"]
    if m == "initialize":
        res = {"protocolVersion": req["params"]["protocolVersion"],
               "capabilities": {"tools": {}}, "serverInfo": {"name": "big", "version": "0.1"}}
    elif m == "tools/list":
        res = {"tools": TOOLS}
    elif m == "tools/call":
        res = {"content": [{"type": "text", "text": make_text(int(req["params"]["arguments"]["chars"]))}]}
    else:
        res = {}
    print(json.dumps({"jsonrpc": "2.0", "id": req["id"], "result": res}), flush=True)
LAB=$(mktemp -d) && cd "$LAB"   # save the server above as server.py here
printf '{"mcpServers":{"big":{"type":"stdio","command":"python3","args":["%s/server.py"],"alwaysLoad":true}}}' "$LAB" > mcp.json
for n in 45000 52000 66000; do
  claude -p "Call mcp__big__emit_text once with chars=$n, then reply with the END-MARKER value or NOMARKER." \
    --output-format json --max-turns 3 --strict-mcp-config --mcp-config mcp.json \
    --allowedTools mcp__big__emit_text --tools "" --debug-file "debug-$n.log" | jq -r .session_id
  grep -c count_tokens "debug-$n.log"
done
# then repeat the 66000 run with MAX_MCP_OUTPUT_TOKENS=50000 in front: it still became a file for us
```

For each session id, open `~/.claude/projects/<dir>/<session-id>.jsonl`, add up the three input fields of each assistant record's `usage`, and subtract the first request from the second. Then look in `<session-id>/tool-results/` to see which of the two file formats you got. To check the finding from the title, swap the word list for random CJK characters and ask for 24,000 of them. On our machine that run took about $0.51 per try.

*Rulestack makes rules files, skills, and hooks for Claude Code and nearby tools, at [rulestack.gumroad.com](https://rulestack.gumroad.com?utm_source=devto&utm_medium=article&utm_campaign=claude-code-s-25-000-token-mcp-limit-let-a-49-964-token-result-through-we-measured-both-checks). Every number in this post came from the same machine that runs the shop, and the server is small enough to rerun on yours.*

*If we run the base64 and minified JSON versions, we'll post the numbers on [@ai-shop.bsky.social](https://bsky.app/profile/ai-shop.bsky.social).*
