Skip to content
Merged
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.
19 changes: 19 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 Expand Up @@ -271,6 +286,10 @@ MCP_MAX_PAYMENT_PRICE=$1.00
# refused with ERR_HOST_NOT_ALLOWED before a request is sent. This is a
# prompt-injection guard — text an agent reads must not choose where money goes.
MCP_PAID_CALL_ALLOWED_HOSTS=
# Path to the OpenAPI document served by the MCP `lens://openapi.json` resource.
# Leave unset to serve the openapi.json at the package root; set it only when the
# document lives elsewhere, e.g. mounted separately in a container.
MCP_OPENAPI_PATH=


# ─── Snapshot retention ────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ Detailed system design and data flow diagrams can be found in the [Architecture
The API specification is available in [OpenAPI 3.0 format](openapi.yaml) and is auto-published to GitHub Pages at https://miracle656.github.io/lens/openapi.json.

Operational docs: [HTTP metrics](docs/http-metrics.md), [ingest metrics](docs/ingest-metrics.md), [websocket backpressure](docs/websocket-backpressure.md), [health probes](docs/health.md), [GraphQL](docs/graphql.md).
The MCP server also advertises read-only [resources](docs/mcp-resources.md) — the watched pairs, venues, this OpenAPI document, and service status — each with a stable URI and freshness an agent can cache against.

## Examples

Expand Down
90 changes: 90 additions & 0 deletions docs/mcp-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# MCP resources

The Lens MCP server (`src/mcp/server.ts`) offers an agent two kinds of thing:

- **Tools** — functions it calls to *do* something: `bazaar_search`,
`paid_call`, `lens_get_price`.
- **Resources** — read-only content it attaches to its context once, instead of
re-fetching by calling a tool every turn.

Resources are for the stable, cacheable facts *about the service itself*. Each
has a fixed URI, an accurate MIME type, and freshness an agent can rely on.

## Resource URIs

| URI | MIME type | Contents | `lastModified` comes from |
| --- | --- | --- | --- |
| `lens://pairs` | `application/json` | The pairs Lens quotes on the configured network: `{ network, pairs: [{ pairKey, assetA, assetB }] }`. | the instant this process resolved its config |
| `lens://venues` | `application/json` | The venues Lens reads from, each with its `source` and whether it is `enabled` here. | the instant this process resolved its config |
| `lens://openapi.json` | `application/json` | The service OpenAPI 3 document, served verbatim. | the file's on-disk mtime |
| `lens://status` | `application/json` | Network, Horizon/RPC URLs, venue enablement, watched-pair count, server name and version. | the read instant |

A URI never changes shape. Adding a field to a payload is fine; renaming a URI,
or reusing one for different content, is not.

## Freshness

Every resource reports two things, on `resources/list` and on `resources/read`:

- **`version`** — `sha256:<hex>` over the exact bytes returned, in
`_meta.version`. A client compares it against the copy it cached, so it can
decide whether to re-read without diffing the text. Identical payload means an
identical version.
- **`lastModified`** — ISO-8601, in `annotations.lastModified` on list and
`_meta.lastModified` on both. Omitted when a source has no notion of change
rather than filled in with something invented.

`lens://status` embeds its own generation time, so every read is a new version.
That is deliberate: status is defined by being current, and a version that
survived two reads would be a lie about it.

`resources/list` resolves each resource to compute this metadata instead of
returning a static catalogue. These payloads are small and cheap, and stale
freshness metadata is worse than none.

## Refusals

Reading a URI that does not exist throws an MCP `InvalidParams` error, and a
resource source that fails throws `InternalError`. Neither is expressed as a
tool-style `{ isError, code }` payload: `ReadResourceResult` has no such field,
so a fabricated one would be parsed as a successful read.

This is also why the resources work adds nothing to the `MCP_ERROR_CODES`
vocabulary in `./codes.ts`. That vocabulary answers "why did the money or the
fetch fail" — `ERR_URL_BLOCKED` for an address we will never reach,
`ERR_HOST_NOT_ALLOWED` for a host we will not pay. "No such resource" is a
different question, and the protocol already has a typed answer for it.

## Overriding the sources

The defaults read only `config` and the on-disk OpenAPI document — no database —
for the same reason `createLensPriceQueries` lives in its own module: a process
that has not opted into Lens's own price data should not pay for importing `pg`
and Prisma just to list what the service watches. `lens://pairs` therefore
reports the *declared* pair list for the network, not the process-wide runtime
registry, matching what `lens_get_price` accepts.

A host that does hold that state can inject its own descriptors, exactly as it
injects `priceQueries`:

```ts
new BazaarMcpServer({
fetchWithPayment,
resources: [
{
uri: 'lens://pairs',
name: 'pairs',
title: 'Watched pairs',
description: 'Pairs Lens quotes, including those added at runtime.',
mimeType: 'application/json',
read: () => ({ data: { network, pairs: getActivePairs() } }),
},
],
});
```

## Configuration

`MCP_OPENAPI_PATH` overrides which file `lens://openapi.json` serves. Unset, it
is the `openapi.json` at the package root. Set it only when the document sits
elsewhere, e.g. mounted separately in a container.
11 changes: 11 additions & 0 deletions examples/mcp-server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ async function main() {
const tools = await client.listTools();
console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', '));

// Resources are the other half of the capability: read-only facts about Lens
// itself, attached to the agent's context once instead of re-fetched by
// calling a tool every turn. Each reports a content-hash version, so a client
// can tell whether a copy it cached is still current.
const resources = await client.listResources();
console.log('[agent] Available resources:', resources.resources.map(r => r.uri).join(', '));

const statusResource = await client.readResource({ uri: 'lens://status' });
console.log('[agent] lens://status:', statusResource.contents[0].text);
console.log('[agent] version:', statusResource.contents[0]._meta?.version);

const BAZAAR_URL = 'http://localhost:3000';

// Step 0 is the one that needs no Bazaar node and no payment: Lens answering
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
Loading
Loading