Skip to content

fix(mcp): bound tool results below the SDK client read limit - #547

Merged
roodboi merged 1 commit into
nextfrom
fix/mcp-tool-result-size
Nov 2, 2026
Merged

roodboi merged 1 commit into
nextfrom
fix/mcp-tool-result-size

Conversation

@roodboi

@roodboi roodboi commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the cause of HACK-1777, which blocks merging #545.

Problem

MCP SDK 1.32 closes a stdio client's transport when its read buffer passes 10 MiB. Hack's 8 MiB capture budget bounds raw child output only. The result repeats that output as content text and as structuredContent, log results add data.events, and JSON escaping grows each copy. A large result could end the client's whole session.

Change

boundMcpToolResult (src/mcp/result-budget.ts) runs at the single exit of the tools/call dispatcher, so it covers every tool. It limits the serialized result to MCP_TOOL_RESULT_LIMIT_BYTES (9.5 MiB of UTF-8), which leaves 512 KiB under 10 MiB for the JSON-RPC envelope and the start of a following message in the client's last read. A result within the limit is returned unchanged.

Over the limit, data goes in this order:

  1. content text. It repeats structuredContent with extra escaping, so it buys the most and loses the least. It becomes a view of at most 256 KiB, in the same JSON shape, with its own outputTruncated and cutoff. It is not removed outright, because some clients read only this channel.
  2. Log tools: stdout, then whole events from the end. stdout repeats the events. Other tools: data, then the end of stdout. Omitting data matches what a capture cut already does.
  3. The end of stderr. Its first 16 KiB are protected from step 2.

What a caller sees:

  • A cut never splits a code point. Sizes are measured with the real serializer, in bytes.
  • structuredContent that was cut has outputTruncated: true and a fixed note ending stderr.
  • structuredContent that fits after step 1 is complete and has no flag. Only the text view says it was cut.
  • When events are removed from a successful collection, data.cutoff is {bound: "bytes", limit: 9961472} and data.count is the number kept. data.stop_reason still says why collection stopped.
  • A backend failure stays a failure with a null cutoff.
  • ok and exitCode still describe the command. A command that exited 0 stays ok when its result is cut.

Docs updated: docs/reference/mcp-tools.md, docs/reference/mcp-logs.md, docs/guides/agent-first-setup.md, tests/e2e/README.md, and the comments in src/mcp/logs-contract.ts. No agent guidance changed, so nothing was regenerated.

Please check these choices

  • Text-only clients get less. A client that reads only content now sees 256 KiB of a large result, not all of it. structuredContent keeps the most because the 8 MiB capture must still fit, so the text cannot share the budget equally. Clients on structuredContent are unaffected.
  • Two channels can differ. On a cut result the text view has fewer events than structuredContent, and its cutoff limit is 262144. Each channel is truthful about itself, but they are no longer the same JSON.
  • The note can appear in stderr of an env tool that normally has an empty one on success. The note is fixed text and carries no values.

Verified

Local runs on Bun 1.4.2 with a private temporary HOME and HACK_HOME and HACK_DAEMON_AUTOSTART_DISABLE=1, at this head.

New test, tests/mcp-tool-result-size.test.ts: 13 pass. It drives the real server over stdio with a real SDK client on default limits, using a stub HACK_MCP_COMMAND. A tee records the bytes the client reads, so the size assertion holds on any SDK version. Coverage:

  • log snapshot and log tail above 8 MiB, with emoji and characters JSON escaping expands
  • a complete log stream that goes over the message limit
  • a non-log tool above the capture limit, and one under it that exits 0 but expands when escaped
  • a text-only cut, and a small result that must stay unchanged
  • the session staying usable after each result, with the SDK validating against outputSchema
  • unit cases: budget edges around surrogate pairs, an oversized first event, backend failure, the stderr floor, a result with no typed payload, and an unexpected oversized field

Client-only control, with the server from unfixed origin/next on SDK 1.26 and only the client changing:

Client SDK Server Result
1.26 unfixed OK, 33.7 MB result
1.32 unfixed MCP error -32000: Connection closed
1.32 fixed OK, 9,961,406 bytes, 1012 events, cutoff bytes / 8388608

SDK 1.32 on client and server: the #545 tree in a scratch copy, with this fix applied, passes 13 of 13.

Mutations:

Mutation SDK Result
Remove the bound from the dispatcher 1.26 6 fail; the size assertion sees lines up to about 68 MB
Remove the bound from the dispatcher 1.32 6 fail; Connection closed in the output
Measure string length instead of bytes 1.26 6 fail

checkByteCutoff in tests/e2e/scenarios/mcp-logs-compose.ts: unchanged, including its 8 MiB limit assertion. Reparsed events can't outgrow the 8 MiB capture unless the source JSON uses unusual number spellings, so canonical events fit the budget and the capture limit stays the one reported. The new test makes the same checks with a stub: a complete ordered prefix of whole events, and no split code point.

Gates:

  • bun run --cwd packages/cli typecheck passes.
  • Biome passes on changed code files.
  • Focused MCP tests pass: 73 across mcp-tool-result-size, mcp-lifetime, mcp-logs-contract, mcp-inspect-tools, mcp and mcp-session-context.
  • bun run docs:check passes.
  • gitleaks finds no leaks over origin/next..HEAD.

Not verified

  • mcp-logs-compose itself was not run locally, because it needs Docker. Its logic was read and matched with a stub, not executed. The ticket's done condition requires it to pass on SDK 1.32 defaults, so it needs a docker-e2e run on the merged result with fix: forward security pins and fixes from main to next (HACK-1758) #545. It can't prove that on this branch alone, because this branch has SDK 1.26.
  • Full suite and hosted CI: the full suite was not run locally. See this PR's checks for CI.
  • Real clients other than the SDK (Claude Code, Codex and others) were not measured.
  • Concurrent large results: the 512 KiB margin is reasoned from how ReadBuffer drains complete lines, not tested with parallel calls.
  • Published prerelease: the ticket also requires this on next and in a prerelease, which happens after merge.

🤖 Generated with Claude Code

MCP SDK 1.32 closes a stdio client's transport when its read buffer passes
10 MiB. The 8 MiB capture budget bounds raw child output only: a result repeats
that output as content text and structuredContent, log results add events, and
JSON escaping grows each copy. A large result could end the client's session.

Apply one named 9.5 MiB limit to every serialized tool result in the tools/call
dispatcher. Over it, the content text becomes a 256 KiB view of the same JSON
shape, then the log tools shed stdout and then whole trailing events, and other
tools shed data and then the end of stdout. outputTruncated and data.cutoff
report the cut, and ok and exitCode still describe the command.

HACK-1777

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi merged commit f94b17f into next Nov 2, 2026
23 checks passed
@roodboi
roodboi deleted the fix/mcp-tool-result-size branch November 2, 2026 21:03
roodboi added a commit that referenced this pull request Nov 3, 2026
)

**Update at 7bd4fa0:** merged `next` with #538 (conflicts in
`doctor.ts`, `project-env-config.ts`, `secret-store.ts` and #538's
shell-value test). The keychain-disabled refusal is now a
`SecretStoreKeyUnavailableError`, which #538's check depends on. One
behaviour change: with the keychain disabled, no key and no store file,
a run counts as first use and creates a key file, as on `main` (#423).
On `next` before this merge it stopped with a refusal, so I removed the
one step of #538's test that asserted that stop. The #538 test also
restores a preloaded `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK` instead of
deleting it. Focused env, secret-store and doctor tests pass on the
pinned Bun 1.4.4. Details on HACK-1758.

**Update at ede96a8:** merged `next` with #543 (conflicts in
`secrets.ts` and `secret-store.ts`); the shared keychain now gates first
and then uses #543's deadline-bounded calls, with a new test for that
order.

**Update at 03a0c75:** merged `next` with #436 (one conflict, in
doctor's env materialization check, kept both sides); focused env,
doctor and MCP tests show no new failures on Bun 1.4.4.

## Update at b2ca3cd: review findings at 30f2b1b

This note replaces the older text below where they disagree. `next` had
not moved, so there was nothing new to merge.

- **Relative overrides in MCP sessions:** a session now refuses only a
relative `HACK_HOME`. A relative `HACK_GLOBAL_CONFIG_PATH` resolves
against the captured cwd again, which matches child commands because
they run in that cwd. The previous note's refusal of that setting was a
mistake and is reverted: it made the server refuse what the CLI and its
children accept.
- **Guard seam:** the fixture case now says it uses the seam. The seam
answers only the next read, so it can't stay set for later files, and a
new nested test checks that a later file gets the real reader back. The
outer test now says it shows detection and reporting, not that the
repair (`disableNativeProxies`) works.

**Verified here** (private HOME and HACK_HOME,
`HACK_DAEMON_AUTOSTART_DISABLE=1`): `bun run typecheck`, `bun run
check`, `bun run docs:check`, gitleaks, and focused tests: 264 pass, 61
skip, 0 fail across 31 files, all `*mcp*` files included.

| Mutation | Result |
| --- | --- |
| Refuse a relative `HACK_GLOBAL_CONFIG_PATH` again | 2 tests fail |
| Resolve a relative `HACK_GLOBAL_CONFIG_PATH` against the process cwd |
2 tests fail |
| Resolve a relative `HACK_HOME` against the session cwd | 3 tests fail
|
| Seam stays set | The guard test fails |

**Not rerun:** the whole `bun run test` suite on this head; hosted CI is
the check.

---

## Update at 30f2b1b: review findings at 676bf9b

This note replaces the older text below where they disagree. Merged
current `next` (`f94b17ff`, including #547) with a merge, not a rebase.

- **`ffc49b33` Relative `HACK_HOME`:** `captureMcpSessionContext` now
refuses a relative `HACK_HOME` or `HACK_GLOBAL_CONFIG_PATH` with the
CLI's error wording. The earlier "MCP sessions still resolve it" note no
longer applies. A socket client with such a context loses only its own
session. The CLI itself still doesn't check a relative
`HACK_GLOBAL_CONFIG_PATH`; only MCP sessions refuse it.
- **`71975bb6`:** dropped `logs` from the commands that record branch
usage, in `docs/cli.md` and `src/lib/branch-usage.ts`.
- **`30f2b1bf` Guard positive case:** this is a seam, not a real leak.
Under Bun 1.4.4, these routes left no native change that `process.env`
hides: set and delete, `loadEnvFile`, a Worker, libc `setenv`, and a
replaced env object. So the fixture hands the guard's native reader a
changed environment. It tests the boundary check and its report, not a
real Bun leak.
- **`30f2b1bf` SDK comment:** reworded in
`tests/mcp-tool-result-size.test.ts` for the pinned SDK 1.32.
- **`RELEASE_PAT`:** stays on `actions/checkout` in `release.yml` and
`release-prepare.yml` (`676bf9b7`). The earlier note about
`useblacksmith/checkout` receiving it and the 9-step count no longer
apply.

**Verified here** (private HOME and HACK_HOME,
`HACK_DAEMON_AUTOSTART_DISABLE=1`):
- `bun run typecheck`, `bun run check`, `bun run docs:check` and
gitleaks over `origin/next..HEAD` pass.
- Focused tests: 325 pass, 61 skip, 0 fail across 35 files, all `*mcp*`
files included. `tests/mcp-tool-result-size.test.ts` passes (13).
- A real `hack mcp serve` start with either relative override exits 1
with the error.
- Mutation, session rule restored to cwd-relative: 4 tests fail.
Mutation, guard's native comparison disabled: the guard test fails.

**Read, not run:** `checkByteCutoff` in
`tests/e2e/scenarios/mcp-logs-compose.ts` should still hold. Events are
parsed from the capture, so they total about 8 MiB and fit under the 9.5
MiB result limit. No event is dropped, so the reported cutoff stays
`{bytes, 8 MiB}`, and #547 only trims `stdout` on a code point boundary.
Docker wasn't used; hosted CI runs this scenario.

**Not rerun:** the whole `bun run test` suite on this head; hosted CI is
the check.

---

**Current head: `676bf9b7`.** It merges `next` at `ddc73a0d` and keeps
`RELEASE_PAT` on `actions/checkout@v4` in `release.yml` and
`release-prepare.yml`. `main` passes that credential to
`useblacksmith/checkout@v1`, a third-party action on a floating tag.
Doing the same on `next` is a security decision of its own, so it isn't
part of this port. All other checkout steps ported here stay on
Blacksmith. This changes the "`RELEASE_PAT` now goes through
`useblacksmith/checkout`" point below.

---

Refs HACK-1758. Forwards the security pins and fixes from `main` to
`next`, one topic per commit. `main` is not merged. Each commit was
checked by reading `next` and, where `main` has a regression suite, by
running that suite against unmodified `next`.

## Classification

| `main` commit | What it does | Result | Evidence on this branch |
| --- | --- | --- | --- |
| `69ed70e9` #457 | Bun 1.4.4 and Node 24.22.1 pins | **Ported**, with
the exceptions below | `2c84aac3`: all workflows including
`prerelease.yml`, `.tool-versions`, `mise.toml`, `packageManager`,
Dockerfiles, candidate build guards, e2e and pattern fixture tags |
| `b5d3212a` HACK-1404 | Delete unset env vars instead of assigning
`undefined` | **Already present** | `tests/helpers/env.ts:12`
(`restoreEnv`, used by 109 test files). No `process.env.X = undefined`
remains, and every restore of a value that may be undefined is guarded.
Bun 1.4.4 did change proxy deletes, handled in `2852393c` |
| `395351bc` #454 | Dependabot `bun` ecosystem | **Ported** |
`f97ca462`: `.github/dependabot.yml`, same as `main`. GitHub reads this
file from the default branch, so it has no effect until `next` lands |
| `739c4e57` #456 | next, MCP SDK, drizzle-orm bumps | **Ported** |
`dd92fae0`: MCP SDK 1.26.0 to 1.32.0, drizzle-orm ^0.44.5 to 0.45.2,
next 16.2.6 to 16.4.1 in `examples/next-app`; `site/` already has next
16.4.3 |
| `75225e00` #513 | zod 4.6.7 (GHSA-mrhx-c7xc-m3w5) | **Ported** |
`dd92fae0`: zod 4.3.5 to 4.6.7; `site/` already has it |
| `4c194a12` #423 | Hermetic keychain tests | **Ported** | `80abd5fc`:
`src/lib/keychain.ts`, `tests/preload.ts`,
`tests/keychain-guard.test.ts`. The fake is not frozen, because `next`'s
tests spy on it |
| `3312658f` #526 | LogQL escaping and empty Loki follow hint |
**Already present** | `src/lib/logs.ts:62`, `:91` and
`src/ui/loki-logs.ts:442`, from #417 and #478. `main`'s
`tests/loki-tail.test.ts` passes unchanged on unmodified `next` |
| `98ab4e70` #527 | No global state under the cwd for an empty or
relative HOME/HACK_HOME | **Ported** and extended | `ac12697c`:
`src/lib/home-dir.ts`, plus the same bug in `next`-only code: MCP
session capture, launchd access, native AWS environment, setup migrate |
| `65e4d97b` #528 | Fail and sweep on leaked e2e Docker networks |
**Ported** | `8a79897a`, `32cd2edf`: `tests/e2e/docker-ownership.ts`,
harness and `run.ts` |
| `736544ef` #529 | Inherit primary local env overlays in linked
worktrees | **Partly present; the gaps are ported** | `next` already
inherited (`resolvePrimaryLocalProjectDir`). 24 of the 36 cases in
`main`'s test failed on unmodified `next`. `40d0df6f`, `e4837dca` add
worktree-family verification, the env opt-out, fail-closed reads, YAML
routing and doctor's obsolete report |
| `83d71ac7` #530 | Keep branch usage out of the tracked tree |
**Ported** | `eb30178f`, `d925c5ef`: `src/lib/branch-usage.ts`;
`touchBranchUsage` is gone |
| `541c611b` #520 | `useblacksmith/checkout` on Blacksmith Ubuntu
runners | **Ported** | `f97ca462`: 9 steps across `ci`, `release`,
`release-prepare`, `release-node-runtime-image`. Hosted and macOS jobs
keep `actions/checkout`, as on `main` |
| `1c1cc983` #521 | Resize three runners | **Left out: needs a
decision** | The numbers come from `main`'s single `docker-e2e` job, and
`next` shards that job. The original PR itself marks the sizes as
unbenchmarked, and it adds cost |
| `08e0412a`, `2e8dc61d`, `91b14bf0`, `60f3c0b9`, `010880c7`, `5e03d47e`
| 4.2.x releases | **Not applicable** | Excluded by the issue; `next`
has its own release line |
| `2dd3853b` #512 | Remove private names from a sample digest |
**Ported, not on the issue's list** | `25dfed3e`: `next` is public and
still carried these names. Drop this commit if you want it handled
separately |

## Pins that stay on the old version

- **Native file and storage helper, `oven/bun:1.4.2-slim`**
(`src/lib/native-compose-vm-file-protocol.ts:6`,
`src/lib/native-compose-storage-witness-docker-artifact.ts:19`, `:66`).
The catalog pins exact image, Bun and libc bytes per platform, and
`scripts/generate-native-storage-witness-helper.ts` says changed
artifacts need separate runtime qualification. Moving it is a product
decision. Evidence: the helper bundle reproduces byte for byte under Bun
1.4.4, and its source has no Brotli, zlib or fetch use. Bun 1.4.2 is
still in that image. CI now pulls both images, and
`docs/reference/native-config-files.md` still names 1.4.2-slim
correctly.
- **Frozen benchmark workload**:
`scripts/application-benchmark-protocol.json` and
`scripts/check-application-benchmark-protocol.py` freeze this pin as
part of the protocol, and `scripts/benchmark-native-compose.py` defaults
to the same image. No Bun from these ships.
- **Records of past runs**: the observed client version in
`src/agents/client-capabilities.ts:501`, the `docs/versions/` snapshots,
`scripts/test-shard-durations.json`, and comments describing 1.4.2
behaviour. The capability record still says MCP SDK 1.26.0 and needs a
new observation.
- **Test fixtures** that use `1.4.2` only as sample data.

## Behaviour to review

- A relative `HACK_HOME` is now an error in the CLI. MCP sessions still
resolve it against the captured checkout, because
`tests/mcp-session-context.test.ts` pins that as `next`'s contract.
- `HACK_WORKTREE_INHERIT_LOCAL=0` applies to the Compose path. The graph
runner follows only `worktree.inherit_local`, because its adoption
proofs record that setting. `docs/cli.md` says so.
- A `.git` link that cannot be verified, such as a bare-repo worktree,
now reads no primary overlays instead of failing the command.
- Two expectations in `main`'s `project-env-worktree-local` test keep
`next`'s behaviour: the layer guard's refusal for a symlink leaving the
project, and the tombstone from `env unset --local`, which hides an
inherited value.
- `tests/global-state-guard.test.ts` changed because Bun 1.4.4 now
carries a `process.env` delete into its native environment. That test
was written to fail on this upgrade. It now asserts the new behaviour
and fails if the old one returns. The guard's native check has no
reachable leak under 1.4.4 and stays as a regression check.
- `useblacksmith/checkout` now receives `RELEASE_PAT` in
`release-prepare.yml` and `release.yml`, as on `main`. No permissions or
secrets changed.
- The private names removed by `25dfed3e` remain in git history. This PR
does not rewrite history.

## Verified

Local, on macOS arm64 with Bun 1.4.4 from mise. All tests ran with a
private temporary HOME and HACK_HOME, `HACK_DAEMON_AUTOSTART_DISABLE=1`,
and a `DOCKER_HOST` pointing at a socket that does not exist.

- `bun install --frozen-lockfile`: pass.
- `bun run typecheck`, `bun run check`, `bun run docs:check`: pass on
the head.
- gitleaks 8.30.1 over `origin/next..HEAD`: no leaks.
- `.hack/tasks.json`: the method first reproduced the old `mise.toml`
pin and all three `package.json` script pins. Only `mise.toml` needed
re-pinning, and `tests/project-task-catalog.test.ts` passes.
`tests/integration-ownership.test.ts` passes with no fingerprint change.
- Focused tests on the head: 446 pass and 5 fail across 24 files. All 5
are in `tests/doctor-fix-noninteractive.test.ts` and are described
below.

`bun run test` ran once, before the last three fix commits and before
rebasing onto #542: **8505 pass, 127 skip, 27 fail** across 598 files.

| Failures | Cause | Also on unmodified `next`? | Now |
| --- | --- | --- | --- |
| 20 in `native-compose-ingress`, `native-compose-proxy-probe` | My
private `DOCKER_HOST` | Yes, on Bun 1.4.2 and 1.4.4 | All 25 tests in
those files pass without that variable |
| 3 in `global-state-guard` | Bun 1.4.4 proxy delete change | No: they
fail on `next` with 1.4.4, pass with 1.4.2 | Fixed in `2852393c`,
passing |
| 1 each in `project-env-local-inheritance`, `cli-spawn-env-isolation`,
`e2e-docker-ownership` | This PR's ports | No | Fixed in `e4837dca`,
`32cd2edf`, passing |
| 1 in `native-compose-route-claims` (`Unexpected end of JSON input`) |
Not established. The host was under heavy load and this PR does not
touch that code | Not reproduced there | Passed 5 of 5 focused reruns on
this branch |

The 5 `doctor-fix-noninteractive` failures appear only when that file
runs alone under plain `bun test` with this isolation. Unmodified `next`
fails the same 5 the same way, and they passed in the full run.

## Not verified

- Hosted CI, including the Linux jobs and the sharded `docker-e2e`. No
Docker scenario ran locally, so the 1.4.4 and 24.22.1 image tags,
`docker-ownership` against a real engine, and `worktree-parallel-up` are
unproven until CI runs.
- Docker image builds, the release workflows and Blacksmith checkout
behaviour.
- Whether CVE-2026-111497 is reachable in Hack's use.
- The whole suite on the final head; CI is the check for that.
- The issue's other done criteria: a green merged `next`, a published
prerelease, and the published build reporting the new pin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- codesmith:footer -->
---
<a
href="https://backend.blacksmith.sh/track/run-proof?expires=1796240682&installation_model_id=17053&pr_number=545&ref=codesmith_pr_footer&repository=hack-dance%2Fhack&return_to=https%3A%2F%2Fgithub.com%2Fhack-dance%2Fhack%2Fpull%2F545&variant=run_proof_on_this_pr&signature=cfadffa3b85fba4f3f8041d26088dbdafce9792afdc357fbc9c8ba4cec915168"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/proof-run-proof-on-this-pr-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/proof-run-proof-on-this-pr-light.svg"><img
alt="Run Proof on this PR"
src="https://pr-comments-assets.blacksmith.sh/codesmith/proof-run-proof-on-this-pr-dark-3x.png"
width="168" height="28"></picture></a>
<sup><a
href="https://app.blacksmith.sh/hack-dance/codesmith/hack/pr/545?autoLogin=true&ref=codesmith_pr_footer">Open
in [code]smith</a> · Need help on this PR? Tag
<code>@codesmith-bot</code> with what you need.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->

---------

Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant