Skip to content

feat(mcp): bound tool responses with pageable artifacts - #977

Open
LevSky22 wants to merge 12 commits into
czlonkowski:mainfrom
LevSky22:feat/bounded-tool-responses-upstream
Open

feat(mcp): bound tool responses with pageable artifacts#977
LevSky22 wants to merge 12 commits into
czlonkowski:mainfrom
LevSky22:feat/bounded-tool-responses-upstream

Conversation

@LevSky22

@LevSky22 LevSky22 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • bound serialized MCP tool results before they enter model context or persisted client history
  • keep compact JSON responses inline up to 32 KiB
  • store oversized JSON in an instance-scoped, expiring artifact cache and return an 8 KiB-capped preview plus response_meta.artifact
  • add query_response_artifact for generic JSON selection, filtering, projection, shape discovery, and semantic pagination
  • retain read_response_artifact as a raw 24 KiB page fallback
  • lower the omitted n8n_executions({ action: "list" }) default from 100 to 20 while preserving explicit pagination up to 100
  • apply the same safety boundary to built-in and host-injected tools

Response artifact contract v2

query_response_artifact accepts an RFC 6901 responsePath, optional generic predicates, projected fields, a page size, and an opaque cursor. Projected fields accept root names such as id or pointers such as /status/name. Missing fields are omitted and reported through fields_resolved; an entirely missing projection is rejected.

For keyed objects such as native n8n connections maps, objectMode: "entries" exposes {key, value} rows. Callers can filter /key with in and retrieve several source nodes in one request instead of guessing nested array paths.

describe: true is pageable. Object keys include absolute RFC 6901 pointers; array item keys remain relative to each item for direct use in fields and filters. Query and raw-read results identify contract version 2 and do not repeat the full artifact descriptor minted by the originating tool.

Cursors are HMAC authenticated, versioned, and bound to the artifact, instance scope, and exact query view. One oversized item is summarized while still advancing the cursor.

Limits and metadata

  • inline compact-JSON budget: 32 KiB
  • artifact preview budget: 8 KiB
  • hard serialized response limit: 128 KiB
  • artifact limit: 50 MiB
  • artifact TTL: 24 hours
  • artifact quota: 1 GiB with oldest/expired pruning

A compact preview is explicitly incomplete while response_meta.complete is false, regardless of upstream fields such as last_page. Artifact IDs remain instance-scoped, validated, atomically written with private permissions, and inaccessible across scopes.

This follows MCP's opaque state-handle guidance while using a custom cursor inside the tool for payload pagination; protocol-level pagination does not cover arbitrary tool result bodies.

Deployment behavior

Artifacts use container-local /tmp/n8n-mcp-artifacts by default. Persistence across container restarts is optional through MCP_RESPONSE_ARTIFACT_ROOT and a stable MCP_RESPONSE_CURSOR_KEY.

Upstream compatibility retained

Verification

  • npm run typecheck
  • npm run build
  • 68 focused response-bounding and MCP artifact-tool tests
  • upstream test, cjs-runtime, secretlint, and publish-results jobs pass on commit 7304103
  • Codecov patch coverage: 96.16% (1,128 hits, 43 misses, 2 partials)
  • regression coverage for native n8n connection maps, compact-vs-pretty threshold drift, 8 KiB previews, shape pagination, RFC 6901 escaping, cursor binding, filtering, projection, expiry, UTF-8 paging, invalid JSON, serialization failures, restrictive hard limits, tenant-scoped dispatch, and backward-compatible small responses

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.94449% with 72 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/services/mcp-response-bounding.ts 96.18% 40 Missing and 2 partials ⚠️
src/mcp/server.ts 61.03% 30 Missing ⚠️

📢 Thoughts on this report? Let us know!

@LevSky22
LevSky22 marked this pull request as ready for review August 4, 2026 20:04
LevSky22 and others added 9 commits August 14, 2026 03:44
…ll handler invocation

The standalone response-bounding branch's test harness predates the
transport branch's SDK v2 migration. tools/call, prompts/get, and
resources/read are unconditionally wrapped by
_invokeInputRequiredCapableHandler, which reads
ctx.mcpReq.requestState() before invoking the registered handler.
Our own handler ignores ctx, but the wrapper still requires the
accessor to exist.
Follow-up to the bounded-response work in this PR. Several paths returned reduced
or empty results that were indistinguishable from correct ones, so a caller could
state a confident conclusion from data that was not there.

Correctness

- A `filters[].path` that resolved on no item produced `[]` with `complete: true`
  and `warning: null` — byte-identical to a genuine zero-match. Since the pointer is
  relative to each item but the inline preview shows a different nesting, `/data/status`
  instead of `/status` is an easy mistake and read back as an authoritative "no rows".
  It now raises, naming the pointer and listing the item's real keys, and
  `response_meta.filters_applied` reports per-filter resolve/match counts so a real
  zero stays distinguishable from a wrong pointer.
- `lt/lte/gt/gte` against operands that were never mutually comparable (a numeric
  field vs a quoted string) silently failed every item; that now raises too.
- Projected `fields` that did not resolve became `null`, indistinguishable from a
  stored null. They are omitted instead, with `fields_resolved` counts.
- `compact()` sliced arrays with no marker, so three items looked like the whole
  collection, and replaced scalars at depth with the string "[nested value omitted]",
  turning a count into text. Arrays now carry `_omitted_items` and scalars survive.
- `boundToolResult` was applied to the artifact tools' own replies, re-artifacting
  them, cutting `text` to 1000 chars and nulling the top-level `next_cursor` — so a
  caller saw `null` and stopped paging after one partial page. Self-bounded tools are
  now exempt inside `boundToolResult`, and `read_response_artifact` sizes its page
  against the serialized envelope, since JSON escaping can inflate a 24 KiB window
  well past the budget.
- Oversized non-array selections were compacted to a fraction of the value with
  `next_cursor: null` and no way to reach the rest. Objects now page by entry.
- Budgets were measured on compact JSON while results are emitted with 2-space
  indent, understating real context cost. `serializeToolText` is now the single
  definition of that encoding, used by both the budget and the response formatter.
- The inline preview is a reshaped summary, so pointers copied from it may not exist
  in the artifact. `response_meta.artifact.primary_paths` advertises pointers that do,
  and `query_response_artifact` gains `describe: true`, which reports keys, types and
  array lengths at a path so pointers do not have to be guessed.
- Unknown, expired, wrong-scope and cursor/id-mismatch handles were all reported as
  "not found or has expired". They are now distinct, and a mismatch names the id the
  cursor was issued for, so a caller can recover instead of guessing.
- `n8n_get_workflow` previews ran `connections` through `compact()`, reducing most
  edges to "[nested value omitted]" and dropping nodes past the 20th while still
  looking like a complete map. They now emit a full edge list.
- `n8n_executions` reported `total_count` for what was only the current page; renamed
  to `page_count`.
- `String(result)` in the stringify fallback produced the literal "[object Object]"
  with no `isError`, so the payload was lost silently. Replaced with a circular-safe
  serializer that flags the failure.

Performance

- The page-fitting loop dropped one element at a time, re-serializing the whole
  candidate page each step: ~100 serializations and ~148 MB stringified for a page of
  large items. Serialized size is monotonic in element count, so it bisects instead —
  604 ms to 40 ms on a 39 MB artifact.
- `query_response_artifact` re-read and re-parsed the entire artifact on every page.
  It now caches one parsed document, keyed by (id, mtime) with a TTL.
- `boundToolResult` serialized the payload twice, once to test the budget and again to
  persist it; the buffer is passed through.
- `pruneResponseArtifacts` ran a full readdir plus a stat per artifact on every
  persist. Now throttled, and skipped when a content-addressed id means no new file is
  written.
- Artifact ids are content-addressed per scope, so an identical repeat request reuses
  one handle and refreshes its TTL instead of minting another copy.

Artifact handles live on the container's writable layer, so the tool descriptions state
retention as "valid until the MCP server restarts, and at most 24 hours" rather than
implying they always survive 24 hours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LevSky22
LevSky22 force-pushed the feat/bounded-tool-responses-upstream branch from ae33e64 to ee55532 Compare August 14, 2026 03:58
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