cd /news/ai-agents/claude-code-s-25000-token-mcp-limit-… · home topics ai-agents article
[ARTICLE · art-137730] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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

A developer measured Claude Code v2.1.273's handling of large MCP tool outputs and found that its 25,000-token limit is enforced by a check that only triggers after a character-based size check, allowing 24,000 characters of CJK text to enter context as 49,964 tokens — roughly twice the stated limit. The testing used a minimal 65-line stdio MCP server with tools that return a specified number of characters, and the results show the token check does not catch dense non-Latin text. The findings highlight a gap between the documented token limit and the actual enforcement behavior in Claude Code's MCP output handling.

by read16 min views1 publishedSep 23, 2026

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.

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

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. 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.

── more in #ai-agents 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-code-s-25000-…] indexed:0 read:16min 2026-09-23 ·