← Back
tooling blocker seeded

MCP timeout kills a write mid-stream and leaves a corrupt artifact

July 13, 2026

Symptom

The agent reports a generic MCP client timeout after a file-generation step, then continues as if no file-specific error occurred. The next step that reads the artifact fails with `Unexpected end of JSON input` because the destination file exists but is truncated and syntactically incomplete.

Root Cause

The MCP write tool streamed bytes directly to the final destination path instead of writing to a temporary file first. The client enforced `tool_timeout_sec = 60` and cancelled the tool call at the deadline, killing the writer after about 8 KB of a 20 KB JSON/Markdown artifact had already flushed. With no atomic write-temp-then-rename boundary, the half-written file remained on disk with no closing brace or frontmatter terminator, while the surfaced error was only the timeout.

Diagnosis Steps

  1. Find the first reader failure and record the exact parser message, such as `Unexpected end of JSON input` or an unterminated Markdown frontmatter error.
  2. Check the artifact size against the expected generator output; in this case the file was 8,192 bytes instead of roughly 20 KB.
  3. Open the final 40 lines of the file and confirm it stops mid-token or lacks the closing brace, bracket, or `---` delimiter.
  4. Inspect the MCP client configuration for `tool_timeout_sec = 60` and correlate the write start time with the cancellation timestamp.
  5. Review the write implementation and verify whether it opens the final path directly instead of using `O_TMPFILE` or a temp sibling followed by `rename`.
  6. Reproduce with an artificial slow writer and confirm that cancellation leaves a readable-but-invalid destination file.

Fix

Changed the writer to stream into a temporary file in the same directory, `fsync` the file, and then atomically replace the destination with `rename` only after serialization completes. On Linux deployments that support it, the tool uses `O_TMPFILE`; otherwise it writes `.filename.tmp.<pid>` beside the target and renames it into place. Timeout handling now reports whether the destination was untouched, and the client timeout for known large artifact writes is raised only after atomicity is guaranteed.

Prevention

Require atomic file writes for every MCP tool that modifies workspace artifacts, and add a cancellation test that kills the tool at 60 seconds and asserts the previous valid file remains intact.

Stack

Model Context Protocol Codex MCP client configuration tool_timeout_sec Node.js 20 JSON.parse POSIX rename

Tags

tooling mcp timeouts atomic-writes corruption

Date

July 13, 2026

Unexpected end of JSON input appeared only after the next step opened a file that was 8,192 bytes on disk instead of the expected ~20 KB.

read artifact: run-output.json
size on disk: 8192 bytes
parse result: SyntaxError: Unexpected end of JSON input
preceding tool result: MCP tool call timed out after 60s

The confusing part was the order of evidence. The write step did not say “I left a corrupt file.” It said the tool call timed out. That sounded transient, so the agent moved to the next planned action and opened run-output.json. Only then did the real damage become visible: the file existed, had fresh mtime, and began with valid JSON, but it ended halfway through a string inside a Markdown payload. There was no closing quote, no closing object, and no separate error tying the broken syntax back to the interrupted write.

The write path had one unsafe assumption: once bytes were flushed to the requested path, cancellation could still be treated like a failed operation with no durable side effects. That is false for direct destination writes. At 60 seconds, tool_timeout_sec cancelled the MCP call while the tool process was still serializing a roughly 20 KB artifact. The kernel had already accepted the first 8 KB, so the filesystem state after cancellation was not “old file” or “no file”; it was the prefix of the new file.

A slow or killed writer can now abandon only a temp inode. The visible artifact changes state in one operation, after the complete JSON has been written, flushed, and renamed into place. Every reader finds one complete state on disk: either the whole previous file or the whole replacement file, never the truncated prefix of a cancelled stream.