A complete MCP response may become an incomplete agent input

When an MCP tool result looks truncated in an agent, find the boundary first. A synthetic tool with numbered chunks and a digest sentinel tells server, protocol, transport, host, provider request and provider context apart, and says when the honest verdict is inconclusive.

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

How this blog is made
A thread of blank tags leaves a spool ending in a large orange end tag, but arrives snipped short, with a dashed outline where that tag should hang.
Illustration made with AI
In this article
  1. Make the result carry its own completeness check
  2. Run a control and a large case with nothing else changed
  3. Check each boundary in order
  4. Read the observations against a table
  5. Paging belongs to the tool, not to MCP
  6. What a trace can and cannot add
  7. Write the verdict as one sentence per boundary

When an MCP tool result looks truncated in an agent, the word "truncated" hides the question that matters: where. A large result crosses several boundaries before it changes what the agent does. The server builds it, the protocol wraps it, a transport carries it, the host stores and displays it, the host puts some version of it into the next request to the model provider, and the provider decides what of that request the model works with. A loss at any of them can look the same in the chat window.

A complete response from the server shows only that the server was complete. When you cannot see the request the host sent to the provider, the honest verdict is "inconclusive". The diagnostic below uses a synthetic, read-only tool whose output carries its own completeness check, so no real documents or secrets are involved.

Make the result carry its own completeness check#

Real payloads are poor test material. You do not know their expected length, a missing paragraph is hard to spot, and logging copies to compare them spreads data you should not spread.

The tool below returns count numbered lines of fixed width, then a sentinel line carrying the count and a SHA-256 digest of every line before it. The output is deterministic: the same tag, count and size always produce the same text. Use a fresh, unique tag (short, no spaces, for example r7a) for every new test case, so an earlier result cannot pass for the current one. Reuse a tag only when you deliberately replay the same case through another client.

Python
import hashlib

def chunk_line(tag: str, i: int, count: int, size: int) -> str:
    head = f"C{i:04d}/{count:04d} {tag} "
    seed = hashlib.sha256(f"{tag}:{i}".encode()).hexdigest()
    filler = (seed * (size // len(seed) + 1))[: max(0, size - len(head))]
    return head + filler

def emit_chunks(count: int, size: int, tag: str) -> str:
    """Read-only, synthetic: numbered lines plus a digest sentinel."""
    if not 1 <= count <= 9999:
        raise ValueError("count must be 1 to 9999")
    lines = [chunk_line(tag, i, count, size) for i in range(1, count + 1)]
    digest = hashlib.sha256("\n".join(lines).encode()).hexdigest()
    lines.append(f"END {tag} count={count:04d} sha256={digest}")
    return "\n".join(lines)

Register emit_chunks as a read-only tool in whatever server SDK you use. Each chunk line is exactly size characters when size is larger than its header, so count × (size + 1) bytes is a close estimate of the text before JSON encoding.

The checker runs on any copy of the text you can get: inside the server before it returns, in a separate client, in the host's stored transcript, in a captured provider request. Decode JSON first; it expects real newlines.

Python
import hashlib
import re

MARKER = re.compile(r"truncat", re.IGNORECASE)  # adapt to your host's marker

def check(text: str, tag: str, count: int) -> dict:
    chunk = re.compile(rf"^C(\d{{4}})/{count:04d} {re.escape(tag)} ")
    end_prefix = f"END {tag} count={count:04d} sha256="
    lines = text.split("\n")
    chunk_lines = [ln for ln in lines if chunk.match(ln)]
    seen = [int(chunk.match(ln).group(1)) for ln in chunk_lines]
    ends = [ln for ln in lines if ln.startswith(end_prefix)]
    foreign = [ln for ln in lines
               if ln and not chunk.match(ln) and not ln.startswith(end_prefix)]
    missing = sorted(set(range(1, count + 1)) - set(seen))
    expected = hashlib.sha256("\n".join(chunk_lines).encode()).hexdigest()
    return {
        "bytes": len(text.encode()),
        "chunks_seen": len(seen),
        "duplicates": len(seen) - len(set(seen)),
        "missing_count": len(missing),
        "missing_first": missing[:5],
        "sentinel": bool(ends),
        "digest_ok": bool(ends) and ends[-1] == end_prefix + expected,
        "foreign_count": len(foreign),
        "marker_seen": any(MARKER.search(ln) for ln in lines),
    }

The summary holds counts, indices, a byte length and booleans, and no line of the payload. foreign_count counts lines that are neither chunks nor the sentinel. marker_seen is a narrow pattern check for an inserted marker such as the ...20330 tokens truncated... in the Codex report below; the chunk filler is hexadecimal, so it cannot match by accident unless your tag contains the word. A chunk cut mid-line still matches its header but breaks the digest.

Log this summary rather than the text. That advice is for the synthetic probe. Pointing the checker at a real tool's output is a different exercise, and needs its own review of what may be logged.

The failure shapes mean different things. Missing tail chunks with no sentinel suggest a cut at a size limit. Chunks missing from the middle, or a foreign line between chunks, suggest something rewrote the text. A preview that keeps the head and the tail is one possible cause: a hypothesis to test, not a finding.

Run a control and a large case with nothing else changed#

Run at least two calls:

  • Positive control: count=20, size=200, about 4 KB. It should pass at every boundary. If it does not pass, establish a passing baseline before interpreting the larger case. A failed small control does not rule out a size or remaining-context limit.
  • Large case: count=6000, size=200, about 1.2 MB of text. Add a middle case near the size where your real tool failed.

Each case gets its own fresh tag. Everything else stays the same: tool name, server build, host build, every proxy or gateway in the path, the transport, and the identity the call runs as. A different key or user can reach a different server configuration, so this is a control, not an authorization test.

Record versions once per session. The initialize exchange carries them: the client sends clientInfo with a name and version, the server answers with serverInfo and the protocol version it agreed to, and over HTTP the client then sends MCP-Protocol-Version on every request (MCP lifecycle, 2025-11-25). Add the host application's version and the version of every intermediary. A version you did not write down is a variable you cannot rule out later.

Check each boundary in order#

1. Server output. Run the checker inside the tool handler, on the string it is about to return. If chunks are missing here, the server produced an incomplete result. With the synthetic tool that is a harness bug; with a real tool it points at the upstream source or a limit in the server's own code. Nothing downstream matters until this passes.

2. Protocol outcome. A tools/call comes back in one of three forms. A JSON-RPC error object is a protocol error, such as an unknown tool or a malformed request. A valid CallToolResult with isError: true reports a failed tool call: the specification puts invalid input, API failures and business logic errors in this category, so the flag does not show that the call reached the underlying operation. Anything else is a normal result (MCP tools, 2025-11-25). Both errors are error outcomes, not evidence of truncation, although their content can still be shortened further along, like any other result.

GitLab's troubleshooting page shows why the distinction matters. A rate limit on the GitLab API behind a tool comes back as 200 OK with isError: true and retry detail in structuredContent. Its MCP endpoint rate limit is an HTTP 429 with a plain-text body that "is not a JSON-RPC message, so an MCP client cannot read it as an MCP response" (GitLab MCP server troubleshooting). That is one server's documented behavior; check your own server's documentation for its version.

3. Transport. Repeat the exact call from a separate client, with the same arguments and identity, and run the checker on what it receives. The MCP Inspector has a scriptable CLI mode, npx @modelcontextprotocol/inspector --cli, which needs Node 22.19.0 or newer per its documentation. A complete result there shows that the server and transport delivered it to that client, not that your host received the same bytes: the host may use a different SDK, transport setting or proxy.

Run the call once through each intermediary and once around them. LiteLLM's 1.108.3 release notes list a fix for "MCP: Fixed tool results over 1 MiB failing with SSE stream ended without a response, broken since v1.103.0" (LiteLLM 1.108.3). We have not reproduced it. It shows why the proxy version belongs in your record, and that a transport failure can surface as an error rather than a short result.

4. Host representation. Look at what the host kept: its stored transcript or tool-result log, not only the chat window. Run the checker on that copy. A host may display a preview and keep the full result, or keep a shortened one. In openai/codex issue 14466, a user reports that the Codex app showed ...20330 tokens truncated... inside an MCP tool result, while the same request sent directly to the local server over HTTP returned complete JSON of about 63 KB and 129 KB with no such marker. It is one user's report, not an independent reproduction, and the user writes that the agent "cannot reliably tell" which side truncated. It shows the shape to look for, not what reached the model.

5. Host-to-provider request. You need the request the host sent to the model provider: from a host debug log that records it, or from a proxy you control between the host and the provider. Run the checker on the tool-result part of that request. A complete capture shows that the host sent the complete result to the provider in that request. It does not show what the provider kept for inference. Without a capture, the verdict for this boundary is inconclusive.

One weak signal needs no capture: ask the model to quote the sentinel line. An exact quote is weak evidence that the sentinel reached the model, and only when the model could not have got it another way: not supplied in the prompt, not retained from an earlier case with the same tag, not reconstructed from the published algorithm and arguments. A failed quote is inconclusive, because this test cannot tell a missing sentinel from a miscopied one. A quoted sentinel says nothing about the chunks in the middle.

6. Provider-side context. If the captured request held the full result and the agent still failed, look at what the provider does with its input. The wire request may not be the whole context: in OpenAI's Responses API, items from a conversation are prepended to the request's input. The same reference documents a truncation parameter, marked deprecated there: with disabled, the default, input over the context window fails with a 400 error; with auto, items are dropped from the beginning of the conversation to fit (OpenAI, create a model response). Server-side compaction, enabled with context_management and a compact_threshold, prunes context once the rendered token count crosses the threshold (OpenAI compaction guide). Check which of these your host sets, read other providers' documentation for their equivalents, look for a provider error, and run the checker on the next request too, not only the one right after the tool call.

Read the observations against a table#

Observation Likely boundary Next check Still unknown
Server-side checker: chunks missing or no sentinel Server output Server's own limits, upstream source Everything downstream
JSON-RPC error object Protocol error Code and message, tool name, argument schema Whether the tool would have returned a complete result
CallToolResult with isError: true Failed tool call, not evidence of truncation content and structuredContent Whether the operation ran; whether the error content was shortened later
HTTP status without a JSON-RPC body, or stream ended early Transport or intermediary Same call around each intermediary, with its version recorded How the host would have handled it
Separate client complete, host transcript short or marker_seen true Host representation Host settings and raw logs: is the marker stored or display-only What the provider received
Host transcript complete, chat window short Display preview, no loss at this boundary Nothing here What the provider received
Captured host-to-provider request complete, agent still fails Provider-side context or later turns Provider error, truncation and compaction settings, next request Whether the provider kept the result in inference context, and how the model used it
Captured host-to-provider request short or marked Host-to-provider omission Host limits, tool-side paging Nothing at this boundary
No access to the host-to-provider request Inconclusive Sentinel quote with a fresh tag, as weak positive evidence only Everything after the host's stored copy

The last row is a valid result. Write it down as inconclusive instead of borrowing certainty from the boundaries you could see.

Paging belongs to the tool, not to MCP#

If the large case fails at the host, the durable fix is a smaller result, and MCP pagination does not provide one. The specification paginates list operations such as tools/list, prompts/list and resources/list with an opaque nextCursor (MCP pagination, 2025-11-25). A tools/call result has no cursor field in that revision; check the revision your session negotiated.

Paging a tool result is a tool design decision. Give the tool its own cursor and limit arguments and return explicit completeness fields with every page, for example in structuredContent described by an outputSchema. An illustrative page:

JSON
{
  "returned": 500,
  "total": 6000,
  "next_cursor": "c-0501",
  "complete": false
}

An explicit complete: false says a short result is short by design, which a missing sentinel cannot. It costs more calls, a model that has to decide to keep paging, and cursors that go stale if the data changes between calls.

What a trace can and cannot add#

A tool span records that the call happened, with its status and timing. A green span does not mean the result did its job, which is the subject of our article on tool calls that succeed with the wrong outcome. A missing span is a separate question again: our article on missing OpenTelemetry spans diagnoses missing telemetry, from instrumentation and sampling to trace association, delivery and query visibility, and none of that says whether a payload was complete.

Worksi's MCP gateway sits on this path when you register a server with it: tools/list is filtered, each tools/call is checked, and every call becomes a span. How much of the result is kept depends on the capture mode: Redacted by default, and Metadata only keeps no text. It does not detect truncation, and its span does not show what a host later passed to a provider. For this test, treat it like any intermediary: run the control and the large case through it and around it, and record that it was there. Read how the MCP gateway works.

Write the verdict as one sentence per boundary#

An illustrative record, not a real run: "Server: 6000 of 6000, digest ok. Inspector CLI, same arguments and identity, direct: 6000 of 6000, digest ok. Host transcript: 1,412 chunks, foreign_count 1, marker_seen true, no sentinel. Host-to-provider request: not available. Verdict: loss observed at host representation; provider-side input inconclusive."

That sentence tells the next engineer which boundary to open, and it claims nothing nobody saw.

Sources

  1. MCP tool results show "...N tokens truncated..." in Codex even when the upstream MCP server returned the full payload (openai/codex issue 14466)
  2. Troubleshooting the GitLab MCP server
  3. MCP Inspector
  4. LiteLLM release 1.108.3 (LTS)
  5. MCP specification 2025-11-25: Pagination
  6. MCP specification 2025-11-25: Tools
  7. MCP specification 2025-11-25: Lifecycle
  8. OpenAI API reference: Create a model response
  9. OpenAI API: Compaction

How this blog is made

Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.