Skip to content

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

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

feat(mcp): bound tool responses with queryable artifacts#977
LevSky22 wants to merge 26 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
  • keep small JSON results inline and store oversized JSON as opaque, expiring artifacts
  • expose only query_response_artifact for model-facing artifact access; no raw payload reader is registered
  • return bounded structuredContent under an explicit outputSchema
  • provide optional protocol-native artifact descriptor links whose resource reads return bounded metadata, never the stored payload
  • enforce one camelCase v3 contract across tool schemas, documentation, result metadata, cursors, and errors
  • normalize response-root-relative paths with exact-first fallback
  • infer one unambiguous collection and recover redundant collection prefixes in projected fields
  • return structured, caller-correctable artifact query errors
  • clarify that nested projected fields require a leading / and suggest the exact corrected pointer when it resolves
  • refresh the branch onto upstream n8n-mcp v2.77.0 without rewriting PR history

Artifact query contract v3

query_response_artifact accepts an RFC 6901 responsePath, optional predicates, projected fields, pageSize, an opaque cursor, bounded textSearch, and describe.

Inputs are strictly camelCase. Snake_case aliases are rejected by the tool schema, giving clients immediate validation feedback instead of allowing two competing input conventions.

Paths resolve exactly first and then, when applicable, relative to a previously selected response root. This keeps RFC 6901 behavior deterministic while supporting a path such as /teams whether the stored artifact contains the full response envelope or the already-selected collection.

Projected root fields may use shorthand such as id. Nested paths must be RFC 6901 pointers beginning with /; for example, /parameters/jsCode. A value such as parameters/jsCode remains a literal root-key shorthand so keys containing / stay addressable. When that literal key is absent but the leading-slash pointer resolves, the error now suggests the exact correction rather than silently changing semantics.

When the selected object contains exactly one array, projection can infer that collection. If every requested field redundantly includes the same collection prefix, the query interprets those pointers relative to each item only after exact projection fails, preserves the requested output labels, and reports the inferred path and normalization warning. Mixed or ambiguous prefixes still fail closed.

For keyed objects such as n8n connections, objectMode: "entries" exposes { key, value } rows. Incorrect array-style queries return OBJECT_MODE_REQUIRED with a bounded suggested request. describe is pageable, and textSearch returns bounded match contexts.

Success and error results share the same structured envelope. responseMeta reports completeness, pagination, applied filters, field resolution, and artifact metadata. Recoverable failures include stable codes and bounded details so callers can correct paths, cursors, object modes, or expired handles without guessing.

Context safety

Large payloads remain behind the query-only boundary. Tool calls return selected structure, matches, fields, or pages rather than raw artifact bytes. Optional MCP resource reads return only a bounded descriptor capped at 8 KiB, so enabling resource-link support does not reintroduce the stored provider response into model context.

Cursors are authenticated and bound to the artifact, caller scope, and exact query view. Existing compact tool results remain backward compatible; only oversized artifact access uses the v3 query contract.

Verification

  • npm run typecheck
  • npm run build
  • focused artifact-query and documentation suites: 130 passed
  • complete unit suite on Node 22: 6,254 passed, 35 skipped, 0 failed
  • coverage: 84.92% statements/lines, 86.36% branches, 84.71% functions
  • live n8n integration suites were not run locally; they require a configured test instance

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.68359% with 107 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/services/mcp-response-bounding.ts 93.40% 83 Missing and 9 partials ⚠️
src/mcp/server.ts 91.27% 14 Missing and 1 partial ⚠️

📢 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
LevSky22 and others added 5 commits August 14, 2026 17:02
tools_documentation({topic: 'query_response_artifact'}) answered "not found" for a
tool that works. The response-bounding layer registers its two wrappers in
src/services/mcp-response-bounding.ts, while the docs registry only ever imported
from src/mcp/tools.ts and tools-n8n-manager.ts, so both wrappers were callable and
undocumented. The overview compounded it: it claimed "24 Tools Total" while 27 are
registered, having also never counted tools_documentation itself.

- Add full docs entries for query_response_artifact and read_response_artifact,
  leading with describe=true and primary_paths, since pointer drift off a reshaped
  inline preview is the failure these tools actually produce.
- Add a Large-Result Tools section and a Documentation Tools line to the overview,
  and set the header to 27. The category counts now sum to the header (1+1+1+2+2+18+2).
- Add tests/unit/mcp/tool-docs-coverage.test.ts, which imports the REAL registry.
  The existing tools-documentation.test.ts mocks @/mcp/tool-docs, so it can check
  the lookup logic but structurally cannot catch a coverage gap — which is why this
  shipped. The new test walks every registered tool from all three layers and
  asserts each is documented, that tools_documentation answers for it, that every
  registry key matches its doc name, and that the declared total equals reality.

Sabotage-checked: commenting out one registry entry fails 2 tests. Unit suite 900
passed; build clean.
@LevSky22 LevSky22 changed the title feat(mcp): bound tool responses with pageable artifacts feat(mcp): bound tool responses with queryable artifacts Aug 19, 2026
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