Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/mcp-bounded-tool-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'lens': patch
---

MCP tool results are size-bounded (#416). Every result was serialised
pretty-printed and in full, so one large — or hostile — response could consume an
agent's entire context window, and the failure looked like a bad model rather
than an unbounded tool. All four tools (`bazaar_search`, `paid_call`,
`payment_budget`, `lens_get_price`) now return compact JSON capped at
`MCP_MAX_RESULT_BYTES`, and a truncated result says so: `truncated: true`, the
total, the page returned, and a notice explaining how to ask for more.
`bazaar_search` gains `limit` / `offset` arguments (defaults and ceiling from
`MCP_DEFAULT_PAGE_LIMIT` / `MCP_MAX_PAGE_LIMIT`), forwards them to the node, and
pages whatever comes back even if the node ignores them. `lens_get_price` keeps
`structuredContent` only while the payload fits, since shipping it regardless
would move the blow-up into another field.

Upstream bodies are read through a capped stream reader (`MCP_MAX_UPSTREAM_BYTES`)
that keeps the part that fits and cancels the rest, so an oversized body is never
buffered in full. A discovery body too large to parse returns
`ERR_RESPONSE_TOO_LARGE`, which — unlike `ERR_URL_BLOCKED` and
`ERR_HOST_NOT_ALLOWED` — is not a refusal to ask: the request succeeded, the
answer is simply too big to hand over.
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ HTTP_TIMEOUT_MS=10000
# Example: OUTBOUND_ALLOWED_HOSTS=bazaar.example.com,lens.internal.io
OUTBOUND_ALLOWED_HOSTS=

# --- MCP tool result bounds ---
# Tool results are written straight into an agent's context window, so they are
# size-bounded: an unbounded result is a self-inflicted denial of service whose
# failure is misread as a bad model. Truncated results say so in the payload.
# Maximum size, in bytes, of one MCP tool result (default: 16384; 1024..1048576)
MCP_MAX_RESULT_BYTES=16384
# Maximum bytes read from a single upstream response body before the stream is
# cancelled, so an oversized body is never buffered in full
# (default: 262144; 4096..8388608)
MCP_MAX_UPSTREAM_BYTES=262144
# Page size requested of a Bazaar node when the caller gives no limit, and the
# most a caller may ask for (defaults: 50 and 200, matching /discovery)
MCP_DEFAULT_PAGE_LIMIT=50
MCP_MAX_PAGE_LIMIT=200

# --- Active Network ---
# Which network the node operates on: "testnet" or "mainnet" (default: testnet)
# This drives all config.horizon / config.rpc / config.soroswap shortcut paths.
Expand Down
23 changes: 23 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ function parseAllowedHosts(raw: string): string[] {
return Array.from(seen)
}

/**
* Parse a positive integer env var, clamped into [min, max]. Unset, unparsable
* or out-of-range values fall back to `fallback` rather than silently removing
* a bound: an operator typo should not turn a limit off altogether.
*/
function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number {
const parsed = parseInt(raw ?? '', 10)
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
return Math.min(Math.max(parsed, min), max)
}

const MAINNET_DEFAULT_PAIRS =
'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM'

Expand Down Expand Up @@ -286,6 +297,17 @@ const globalConfig = {
// block is unconditional (see net/ssrf.ts).
allowedHosts: parseAllowedHosts(process.env.OUTBOUND_ALLOWED_HOSTS ?? ''),
},
mcp: {
// Tool results land directly in an agent's context window, so both the body
// read from a node and the text handed back are capped (#416).
maxResultBytes: clampInt(process.env.MCP_MAX_RESULT_BYTES, 16_384, 1_024, 1_048_576),
maxUpstreamBytes: clampInt(process.env.MCP_MAX_UPSTREAM_BYTES, 262_144, 4_096, 8_388_608),
// Page size for collection results, mirroring the /discovery limits: a
// request without an explicit limit gets this many items, and an over-large
// ask is clamped rather than honoured.
defaultPageLimit: clampInt(process.env.MCP_DEFAULT_PAGE_LIMIT, 50, 1, 200),
maxPageLimit: clampInt(process.env.MCP_MAX_PAGE_LIMIT, 200, 1, 1000),
},
} as const

// ── Per-network map ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -367,4 +389,5 @@ export const config = {
cache: globalConfig.cache,
http: globalConfig.http,
outbound: globalConfig.outbound,
mcp: globalConfig.mcp,
}
6 changes: 6 additions & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export const MCP_ERROR_CODES = {
ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED',
ERR_ASSET_ISSUER_MISMATCH: 'ERR_ASSET_ISSUER_MISMATCH',
ERR_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED',
// A request that succeeded upstream and was still refused: the body came back
// too large to hand to an agent without consuming its whole context. Not a
// reachability answer (ERR_SERVICE_UNREACHABLE) and not a refusal to ask
// (ERR_URL_BLOCKED / ERR_HOST_NOT_ALLOWED) — we did fetch it, we will not dump
// it. Carries the cap in `details` so the caller can retry with a smaller page.
ERR_RESPONSE_TOO_LARGE: 'ERR_RESPONSE_TOO_LARGE',
// Refused before any request was made, for two different reasons:
// ERR_URL_BLOCKED the target resolves somewhere we will never fetch
// (private, loopback, link-local, metadata) — #488
Expand Down
257 changes: 257 additions & 0 deletions src/mcp/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/**
* Size bounds for MCP tool results (#416).
*
* A tool result is written straight into the agent's context window, so an
* unbounded one is self-inflicted denial of service: a node with a large
* resource list — or a hostile one returning megabytes — leaves the agent no
* context to do its actual task, and the failure is blamed on the model.
* Everything here exists to keep a result small *and* to say so in the result:
* silent truncation would make a partial answer look like a complete one.
*
* Two independent ceilings:
* - the body read from upstream is capped while streaming, so an oversized
* response is never buffered in full (`readBodyCapped`);
* - the text handed to the agent is capped, and collections are additionally
* capped by item count and reported as a page with a total
* (`boundSerialized`).
*/

/** Keys that hold a page's items when the payload is an object. */
const COLLECTION_KEYS = ['resources', 'results', 'items']

/**
* Operator-configurable bounds, resolved from the `MCP_*` env in config.ts and
* overridable per server for tests.
*/
export interface McpLimits {
maxResultBytes: number
maxUpstreamBytes: number
defaultPageLimit: number
maxPageLimit: number
}

export interface ResultBudget {
/** Maximum size, in UTF-8 bytes, of the text handed to the agent. */
maxBytes: number
/** Maximum number of collection items echoed, whatever the byte headroom is. */
maxItems: number
/** The page offset the result came from, echoed back so the agent can continue. */
offset: number
/** How the agent asks for more. Included verbatim in every truncated result. */
notice: string
/**
* True when the payload is already known to be incomplete — the body read was
* cut off upstream. Labels the result truncated even if what survives fits.
*/
knownTruncated?: boolean
}

export interface BoundedResult {
text: string
truncated: boolean
}

export interface CappedBody {
text: string
/** True when the upstream body was larger than the cap, so `text` is incomplete. */
exceeded: boolean
}

function byteLength(text: string): number {
return Buffer.byteLength(text, 'utf8')
}

/** Cut `text` down to at most `maxBytes` without splitting a UTF-8 sequence. */
function sliceToBytes(text: string, maxBytes: number): string {
if (byteLength(text) <= maxBytes) return text
const bytes = Buffer.from(text, 'utf8').subarray(0, maxBytes)
return new TextDecoder('utf-8').decode(bytes)
}

interface Collection {
items: unknown[]
/** Item count as reported by the payload itself, when it knows better than us. */
total: number
key: string | null
container: Record<string, unknown> | null
}

/**
* Find the collection to page through, if any: a top-level array, or an object
* with an array under a known collection key (`resources`, `results`, `items`)
* or, failing that, its first array-valued key.
*/
function findCollection(value: unknown): Collection | null {
if (Array.isArray(value)) {
return { items: value, total: value.length, key: null, container: null }
}
if (typeof value !== 'object' || value === null) return null

const container = value as Record<string, unknown>
const keys = Object.keys(container).filter((k) => Array.isArray(container[k]))
const key = COLLECTION_KEYS.find((k) => keys.includes(k)) ?? keys[0]
if (!key) return null

const items = container[key] as unknown[]
const reportedTotal = typeof container.total === 'number' ? container.total : items.length
return { items, total: Math.max(reportedTotal, items.length), key, container }
}

function assemblePage(collection: Collection, count: number, budget: ResultBudget): string {
const page = collection.items.slice(0, count)
const metadata = {
total: collection.total,
limit: count,
offset: budget.offset,
truncated: true,
notice: budget.notice,
}
if (collection.container && collection.key) {
return JSON.stringify({ ...collection.container, [collection.key]: page, ...metadata })
}
return JSON.stringify({ items: page, ...metadata })
}

/** Largest page that fits the byte budget, or null when not even metadata fits. */
function boundCollection(collection: Collection, budget: ResultBudget): BoundedResult | null {
const max = Math.min(collection.items.length, budget.maxItems)
let lo = 0
let hi = max
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (byteLength(assemblePage(collection, mid, budget)) <= budget.maxBytes) lo = mid
else hi = mid - 1
}

const text = assemblePage(collection, lo, budget)
if (byteLength(text) > budget.maxBytes) return null
return { text, truncated: true }
}

/**
* Last resort for payloads with no pageable collection (or whose items are
* individually enormous): keep a prefix of the compact serialization and label
* it, rather than returning a document that only parses by accident.
*/
function boundSerializedText(value: unknown, budget: ResultBudget): BoundedResult {
const compact = JSON.stringify(value) ?? 'null'
const body = typeof value === 'string' ? value : compact
const wrap = (chars: number) =>
JSON.stringify({
truncated: true,
fullBytes: byteLength(compact),
notice: budget.notice,
partial: body.slice(0, chars),
})

let lo = 0
let hi = body.length
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2)
if (byteLength(wrap(mid)) <= budget.maxBytes) lo = mid
else hi = mid - 1
}

const text = wrap(lo)
return { text: sliceToBytes(text, budget.maxBytes), truncated: true }
}

/**
* Serialize a tool result compactly (indentation is for humans, and costs
* context) and within `budget.maxBytes`. A truncated result always carries
* `truncated: true`, a total, the page that was returned, and the notice
* explaining how to ask for the rest.
*/
export function boundSerialized(value: unknown, budget: ResultBudget): BoundedResult {
if (budget.knownTruncated) {
// Already a fragment — the upstream read was cut off. Label it instead of
// risking that it reads back as a complete answer.
return boundSerializedText(value, budget)
}

const compact = JSON.stringify(value) ?? 'null'
if (typeof value === 'string' && byteLength(value) <= budget.maxBytes) {
// Plain-text answers stay plain text: quoting them would change what the
// agent reads for a resource that never produced JSON.
return { text: value, truncated: false }
}

const collection = findCollection(value)
if (byteLength(compact) <= budget.maxBytes && (!collection || collection.items.length <= budget.maxItems)) {
return { text: compact, truncated: false }
}

if (collection) {
const paged = boundCollection(collection, budget)
if (paged) return paged
}
return boundSerializedText(value, budget)
}

interface BodyReader {
read(): Promise<{ done?: boolean; value?: unknown }>
cancel(): Promise<void>
releaseLock(): void
}

interface ReadableBody {
getReader(): BodyReader
}

function bodyStream(res: Response): ReadableBody | null {
const body = (res as { body?: unknown }).body
if (!body || typeof (body as ReadableBody).getReader !== 'function') return null
return body as ReadableBody
}

/**
* Read a response body up to `maxBytes`, stopping the read (and cancelling the
* stream, which closes the connection) as soon as the cap is passed. A hostile
* server therefore never gets its whole body buffered here.
*
* Responses that expose no readable stream — test doubles, or a transport that
* has already buffered the body — fall back to `res.text()` and report the
* overflow; the cap is still honoured, only after the damage is done.
*/
export async function readBodyCapped(res: Response, maxBytes: number): Promise<CappedBody> {
const stream = bodyStream(res)
if (!stream) {
const text = await res.text()
if (byteLength(text) <= maxBytes) return { text, exceeded: false }
return { text: sliceToBytes(text, maxBytes), exceeded: true }
}

const reader = stream.getReader()
const decoder = new TextDecoder('utf-8')
let received = 0
let text = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break

const chunk = value as Uint8Array | undefined
if (!chunk || chunk.byteLength === 0) continue

received += chunk.byteLength
if (received > maxBytes) {
// Keep the part that fits, drop the rest, and stop reading — the point
// is that the overshoot never lands in memory at all.
const kept = chunk.subarray(0, chunk.byteLength - (received - maxBytes))
text += decoder.decode(kept)
await reader.cancel().catch(() => undefined)
return { text, exceeded: true }
}
text += decoder.decode(chunk, { stream: true })
}
text += decoder.decode()
} finally {
try {
reader.releaseLock()
} catch {
// already released by cancel()
}
}

return { text, exceeded: false }
}
Loading
Loading