From 755432e4c6649c7dcd71224ecfae01edb00e10f3 Mon Sep 17 00:00:00 2001 From: Jonathan-ai9 Date: Sat, 17 Oct 2026 21:09:11 +0100 Subject: [PATCH 1/5] fix(mcp): guard bazaar_search against SSRF (#407) bazaar_search passed any caller-supplied URL straight to fetch after a trailing-slash trim, letting the agent reach metadata endpoints or private services from the operator's machine. Parse the URL and reject non-http(s); resolve the host and refuse if any address is private, loopback, link-local or metadata; follow redirects manually and re-validate each hop. Hosts can be narrowed further via a shared OUTBOUND_ALLOWED_HOSTS list that never re-opens a blocked address. Blocked requests return ERR_URL_BLOCKED. --- .changeset/bazaar-search-ssrf-guard.md | 12 + .env.example | 8 + src/__tests__/ssrf.test.ts | 172 +++++++++++++++ src/config.ts | 24 ++ src/mcp/codes.ts | 1 + src/mcp/server.ts | 23 +- src/net/ssrf.ts | 293 +++++++++++++++++++++++++ tests/mcp.test.ts | 79 +++++++ 8 files changed, 609 insertions(+), 3 deletions(-) create mode 100644 .changeset/bazaar-search-ssrf-guard.md create mode 100644 src/__tests__/ssrf.test.ts create mode 100644 src/net/ssrf.ts diff --git a/.changeset/bazaar-search-ssrf-guard.md b/.changeset/bazaar-search-ssrf-guard.md new file mode 100644 index 00000000..5b88abb6 --- /dev/null +++ b/.changeset/bazaar-search-ssrf-guard.md @@ -0,0 +1,12 @@ +--- +'lens': patch +--- + +`bazaar_search` no longer fetches any URL it is handed (#407). The MCP server now +parses the target, refuses anything that is not `http`/`https`, resolves the +hostname and rejects it if any address is private, loopback, link-local, or a +cloud metadata endpoint (e.g. `169.254.169.254`), and follows redirects manually +so a permitted host cannot bounce the request to a forbidden address. Operators +can constrain reachable hosts further with the shared `OUTBOUND_ALLOWED_HOSTS` +list, which narrows access but never re-opens a blocked address. Blocked requests +return the new `ERR_URL_BLOCKED` code. diff --git a/.env.example b/.env.example index 41e4465f..988a2bb8 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,14 @@ PRICE_CACHE_TTL=10 # Default timeout for outbound HTTP requests in milliseconds (default: 10000) HTTP_TIMEOUT_MS=10000 +# --- Outbound request policy (SSRF) --- +# Comma-separated hostnames the app may reach when a caller supplies the target +# URL — e.g. the Bazaar MCP server fetching a node's /discovery endpoint. Leave +# empty to allow any *public* host. Private, loopback, link-local and cloud +# metadata addresses are refused regardless of this list. +# Example: OUTBOUND_ALLOWED_HOSTS=bazaar.example.com,lens.internal.io +OUTBOUND_ALLOWED_HOSTS= + # --- Active Network --- # Which network the node operates on: "testnet" or "mainnet" (default: testnet) # This drives all config.horizon / config.rpc / config.soroswap shortcut paths. diff --git a/src/__tests__/ssrf.test.ts b/src/__tests__/ssrf.test.ts new file mode 100644 index 00000000..f66777c3 --- /dev/null +++ b/src/__tests__/ssrf.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest' +import { + isBlockedIpAddress, + assertUrlSafe, + ssrfSafeFetch, + SsrfError, +} from '../net/ssrf' + +/** A fake DNS that always answers with the given addresses, recording names. */ +function stubLookup(addresses: string[]) { + const seen: string[] = [] + return { + seen, + lookup: async (_hostname: string) => { + seen.push(_hostname) + return addresses + }, + } +} + +async function expectBlocked( + promise: Promise, + reason: string, +): Promise { + await expect(promise).rejects.toBeInstanceOf(SsrfError) + await expect(promise).rejects.toMatchObject({ reason }) +} + +describe('isBlockedIpAddress', () => { + it('flags loopback, private, link-local and metadata v4', () => { + for (const ip of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.4.5', '169.254.169.254', '0.0.0.0', '100.64.0.1', '224.0.0.1', '255.255.255.255']) { + expect(isBlockedIpAddress(ip), ip).toBe(true) + } + }) + + it('allows public v4', () => { + for (const ip of ['8.8.8.8', '93.184.216.34', '1.1.1.1']) { + expect(isBlockedIpAddress(ip), ip).toBe(false) + } + }) + + it('flags v6 loopback, link-local, unique-local, multicast and mapped-private', () => { + for (const ip of ['::1', '::', 'fe80::1', 'fd00::1', 'ff02::1', '::ffff:127.0.0.1', '2002:7f00:0001::', '2001:0000:0:0:0:0:0:1']) { + expect(isBlockedIpAddress(ip), ip).toBe(true) + } + }) + + it('allows public v6', () => { + for (const ip of ['2606:4700:4700::1111', '2001:4860:4860::8888']) { + expect(isBlockedIpAddress(ip), ip).toBe(false) + } + }) +}) + +describe('assertUrlSafe — scheme parsing', () => { + it('rejects non-http(s) schemes', async () => { + await expectBlocked(assertUrlSafe('file:///etc/passwd'), 'unsupported_scheme') + await expectBlocked(assertUrlSafe('ftp://example.com/x'), 'unsupported_scheme') + await expectBlocked(assertUrlSafe('gopher://127.0.0.1:1'), 'unsupported_scheme') + }) + + it('rejects strings that are not absolute URLs', async () => { + await expectBlocked(assertUrlSafe('not a url'), 'invalid_url') + // "localhost:3000" parses as scheme "localhost:" — a non-http scheme, refused. + await expectBlocked(assertUrlSafe('localhost:3000'), 'unsupported_scheme') + }) + + it('rejects embedded credentials', async () => { + await expectBlocked(assertUrlSafe('https://user:pass@8.8.8.8/'), 'embedded_credentials') + }) +}) + +describe('assertUrlSafe — private address refusal', () => { + it('refuses a private literal without DNS', async () => { + const { lookup, seen } = stubLookup(['203.0.113.10']) + await expectBlocked(assertUrlSafe('http://10.0.0.5/', { lookup }), 'blocked_address') + // IP literals are classified directly; lookup must never be consulted. + expect(seen).toEqual([]) + }) + + it('refuses the cloud metadata endpoint', async () => { + await expectBlocked(assertUrlSafe('http://169.254.169.254/latest/meta-data/'), 'blocked_address') + }) + + it('refuses a public hostname that resolves to a private address', async () => { + const { lookup } = stubLookup(['127.0.0.1']) + await expectBlocked(assertUrlSafe('http://innocent.example/', { lookup }), 'blocked_address') + }) + + it('refuses when ANY resolved address is private', async () => { + const { lookup } = stubLookup(['93.184.216.34', '192.168.0.1']) + await expectBlocked(assertUrlSafe('https://mixed.example/', { lookup }), 'blocked_address') + }) + + it('refuses a hostname that resolves to nothing', async () => { + const { lookup } = stubLookup([]) + await expectBlocked(assertUrlSafe('https://empty.example/', { lookup }), 'unresolvable_host') + }) +}) + +describe('assertUrlSafe — operator allowlist', () => { + const pub = { lookup: async () => ['93.184.216.34'] } + + it('permits a listed public host', async () => { + const url = await assertUrlSafe('https://ok.example/x', { allowedHosts: ['OK.Example'], ...pub }) + expect(url.hostname).toBe('ok.example') + }) + + it('refuses an unlisted host even when public', async () => { + await expectBlocked(assertUrlSafe('https://other.example/x', { allowedHosts: ['ok.example'], ...pub }), 'host_not_allowed') + }) + + it('an allowlist entry does not unlock a private address', async () => { + const priv = { lookup: async () => ['127.0.0.1'] } + await expectBlocked(assertUrlSafe('https://evil.example/x', { allowedHosts: ['evil.example'], ...priv }), 'blocked_address') + }) +}) + +describe('ssrfSafeFetch — transport and redirects', () => { + const publicLookup = { lookup: async () => ['93.184.216.34'] } + + it('returns a plain 200 from a public host', async () => { + const fetchImpl = async () => new Response('{"resources":[]}', { status: 200 }) + const res = await ssrfSafeFetch('https://ok.example/discovery/resources', {}, { ...publicLookup, fetchImpl }) + expect(res.status).toBe(200) + }) + + it('does not follow a redirect to a host that would itself be refused', async () => { + const calls: string[] = [] + const fetchImpl = async (input: string | URL) => { + calls.push(String(input)) + return new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/' } }) + } + await expectBlocked( + ssrfSafeFetch('https://ok.example/discovery/resources', {}, { ...publicLookup, fetchImpl }), + 'blocked_address', + ) + // The metadata endpoint must never actually be fetched. + expect(calls).toEqual(['https://ok.example/discovery/resources']) + }) + + it('follows a redirect between public hosts', async () => { + const hops = ['https://a.example/x', 'https://b.example/y'] + let n = 0 + const fetchImpl = async () => { + n++ + if (n === 1) return new Response(null, { status: 301, headers: { location: hops[1] } }) + return new Response('done', { status: 200 }) + } + const res = await ssrfSafeFetch(hops[0], {}, { ...publicLookup, fetchImpl }) + expect(res.status).toBe(200) + expect(n).toBe(2) + }) + + it('gives up after too many redirects', async () => { + const fetchImpl = async () => new Response(null, { status: 302, headers: { location: 'https://loop.example/next' } }) + await expectBlocked( + ssrfSafeFetch('https://loop.example/start', {}, { ...publicLookup, fetchImpl, maxRedirects: 2 }), + 'too_many_redirects', + ) + }) + + it('forces redirect: manual so the transport never follows on its own', async () => { + let capturedInit: RequestInit | undefined + const fetchImpl = async (_input: string | URL, init?: RequestInit) => { + capturedInit = init + return new Response('ok', { status: 200 }) + } + await ssrfSafeFetch('https://ok.example/x', {}, { ...publicLookup, fetchImpl }) + expect(capturedInit?.redirect).toBe('manual') + }) +}) diff --git a/src/config.ts b/src/config.ts index 81b84827..4bd693a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -66,6 +66,21 @@ function makePairKey(a: AssetId, b: AssetId): string { return [aStr, bStr].sort().join('/') } +/** + * Parse a comma-separated hostname allowlist into a normalized, de-duplicated + * list. Empty entries and surrounding whitespace are dropped; hostnames are + * lowercased and any IPv6 brackets / trailing dot are stripped so the stored + * form matches what net/ssrf.ts compares against. + */ +function parseAllowedHosts(raw: string): string[] { + const seen = new Set() + for (const part of raw.split(',')) { + const host = part.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1').replace(/\.$/, '') + if (host.length > 0) seen.add(host) + } + return Array.from(seen) +} + const MAINNET_DEFAULT_PAIRS = 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN/XLM' @@ -254,6 +269,14 @@ const globalConfig = { http: { timeoutMs: parseInt(process.env.HTTP_TIMEOUT_MS ?? '10000', 10), }, + outbound: { + // One operator-maintained list for every outbound fetch the app makes to a + // caller-supplied target (Bazaar MCP discovery now, payment endpoints later). + // Empty = "any public host"; a non-empty list narrows reach to these + // hostnames. It never re-opens a private/link-local/metadata address — that + // block is unconditional (see net/ssrf.ts). + allowedHosts: parseAllowedHosts(process.env.OUTBOUND_ALLOWED_HOSTS ?? ''), + }, } as const // ── Per-network map ─────────────────────────────────────────────────────────── @@ -334,4 +357,5 @@ export const config = { indexer: globalConfig.indexer, cache: globalConfig.cache, http: globalConfig.http, + outbound: globalConfig.outbound, } diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index a9631225..5bd1dca3 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,6 +6,7 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', + ERR_URL_BLOCKED: 'ERR_URL_BLOCKED', ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' } as const; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..e381740b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,17 +3,31 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { fetchWithTimeout } from '../fetch.js'; +import { config } from '../config.js'; +import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; export interface McpServerConfig { fetchWithPayment: typeof fetch; + /** + * Outbound fetch policy for `bazaar_search`. Falls back to the operator's + * shared `OUTBOUND_ALLOWED_HOSTS` allowlist. `lookup`/`fetchImpl` exist so + * tests can drive DNS resolution and the transport without real sockets. + */ + bazaarFetch?: Partial; } export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; + private bazaarFetchOptions: SsrfFetchOptions; - constructor(config: McpServerConfig) { - this.fetchWithPayment = config.fetchWithPayment; + constructor(config_: McpServerConfig) { + this.fetchWithPayment = config_.fetchWithPayment; + this.bazaarFetchOptions = { + allowedHosts: config.outbound.allowedHosts, + fetchImpl: (input, init) => fetchWithTimeout(input, init), + ...config_.bazaarFetch, + }; this.server = new Server( { name: 'bazaar-mcp-server', @@ -81,7 +95,7 @@ export class BazaarMcpServer { ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` : `${url.replace(/\/$/, '')}/discovery/resources`; - const res = await fetchWithTimeout(endpoint); + const res = await ssrfSafeFetch(endpoint, {}, this.bazaarFetchOptions); if (!res.ok) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); @@ -100,6 +114,9 @@ export class BazaarMcpServer { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], }; } catch (e: any) { + if (e instanceof SsrfError) { + return this.createErrorResponse('ERR_URL_BLOCKED', `Blocked request: ${e.message}`); + } return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); } } diff --git a/src/net/ssrf.ts b/src/net/ssrf.ts new file mode 100644 index 00000000..64df93ba --- /dev/null +++ b/src/net/ssrf.ts @@ -0,0 +1,293 @@ +import net from 'net' +import { promises as dns } from 'dns' + +/** + * Server-Side Request Forgery (SSRF) guard for outbound fetches whose target is + * supplied by an untrusted caller (e.g. an agent driving the Bazaar MCP server). + * + * The threat: the server runs wherever the operator runs it — often a machine + * with network reach the caller does *not* have — and will happily fetch a cloud + * instance metadata endpoint (`169.254.169.254`) or a service on a private + * address, then hand the response body straight back to the caller. The + * mitigation is to refuse any target that is not a public http/https host, and + * to decide that *after* resolving the hostname, because a public-looking name + * can resolve to a private address — the classic way this check is bypassed. + * + * Design notes: + * - Private/loopback/link-local/metadata addresses are always refused. The + * optional `allowedHosts` list only *narrows* what is reachable; it can never + * re-open a blocked address. There is no config that turns the block off. + * - Redirects are followed manually (`redirect: 'manual'`) and every hop is put + * through the same checks. A permitted host that 302s to a metadata endpoint + * is refused exactly as if the caller had named it directly. + * - `lookup` and `fetchImpl` are injectable so the resolution behaviour can be + * tested without real DNS or sockets. + */ + +/** Codes callers branch on; the message is human-facing and may change. */ +export type SsrfBlockReason = + | 'invalid_url' + | 'unsupported_scheme' + | 'embedded_credentials' + | 'host_not_allowed' + | 'unresolvable_host' + | 'blocked_address' + | 'too_many_redirects' + +export class SsrfError extends Error { + readonly reason: SsrfBlockReason + /** The offending value, best-effort, for operator logs (never trusted). */ + readonly detail?: string + + constructor(reason: SsrfBlockReason, message: string, detail?: string) { + super(message) + this.name = 'SsrfError' + this.reason = reason + this.detail = detail + } +} + +export interface SsrfFetchOptions { + /** + * Hostnames an operator may reach. Empty/undefined means "any public host"; + * a non-empty list additionally requires the hostname to appear here. Never + * grants access to a private address — that block is unconditional. + */ + allowedHosts?: string[] + /** Resolve a hostname to its addresses. Defaults to real A/AAAA lookups. */ + lookup?: (hostname: string) => Promise + /** Transport. Defaults to global fetch. Redirects are forced to `manual`. */ + fetchImpl?: (input: string | URL, init?: RequestInit) => Promise + /** Redirect hops to follow before giving up. Default 5. */ + maxRedirects?: number +} + +const DEFAULT_MAX_REDIRECTS = 5 + +/** Redirect statuses whose `Location` we re-validate before following. */ +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) + +function normalizeHosts(hosts: string[] | undefined): string[] { + return (hosts ?? []) + .map((h) => h.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1').replace(/\.$/, '')) + .filter((h) => h.length > 0) +} + +/** Real DNS: merge A and AAAA, treating NXDOMAIN/empty as resolvable-but-empty. */ +export async function defaultLookup(hostname: string): Promise { + const [v4, v6] = await Promise.all([ + dns.resolve4(hostname).catch(() => [] as string[]), + dns.resolve6(hostname).catch(() => [] as string[]), + ]) + return [...v4, ...v6] +} + +/** + * True when `ip` is in a range that must never be fetched: loopback, private, + * link-local (which holds the cloud metadata IP 169.254.169.254), carrier-grade + * NAT, documentation/benchmark/multicast/reserved blocks, and the IPv6 + * equivalents — including IPv4-mapped (`::ffff:x:x:x:x`) and 6to4 addresses that + * smuggle a private IPv4 inside a public-looking IPv6. + */ +export function isBlockedIpAddress(ip: string): boolean { + const family = net.isIP(ip) + if (family === 4) return isBlockedIpv4(ip) + if (family === 6) return isBlockedIpv6(ip) + return true // not a well-formed IP — fail closed +} + +function isBlockedIpv4(ip: string): boolean { + const parts = ip.split('.').map(Number) + if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) { + return true + } + const [a, b] = parts + switch (true) { + case a === 0: return true // 0.0.0.0/8 "this network" (incl. 0.0.0.0) + case a === 10: return true // 10.0.0.0/8 + case a === 127: return true // 127.0.0.0/8 loopback + case a === 100 && b >= 64 && b <= 127: return true // 100.64.0.0/10 CGNAT + case a === 169 && b === 254: return true // 169.254.0.0/16 link-local (metadata) + case a === 172 && b >= 16 && b <= 31: return true // 172.16.0.0/12 + case a === 192 && b === 0 && parts[2] === 0: return true // 192.0.0.0/24 IETF + case a === 192 && b === 0 && parts[2] === 2: return true // 192.0.2.0/24 TEST-NET-1 + case a === 198 && (b === 18 || b === 19): return true // 198.18.0.0/15 benchmark + case a === 198 && b === 51 && parts[2] === 100: return true // 198.51.100.0/24 TEST-NET-2 + case a === 203 && b === 0 && parts[2] === 113: return true // 203.0.113.0/24 TEST-NET-3 + case a === 192 && b === 168: return true // 192.168.0.0/16 + case a >= 224 && a <= 239: return true // 224.0.0.0/4 multicast + case a >= 240: return true // 240.0.0.0/4 reserved + 255.255.255.255 + default: return false + } +} + +function ipv6ToGroups(addr: string): number[] | null { + const lower = addr.toLowerCase() + let a = lower + let tail: number[] = [] + + // An embedded IPv4 (RFC 4291 §2.2 / 6to4 / mapped) occupies the last 32 bits. + if (a.includes('.')) { + const lastColon = a.lastIndexOf(':') + const nums = a.slice(lastColon + 1).split('.').map(Number) + if (nums.length !== 4 || nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null + tail = [(nums[0] << 8) | nums[1], (nums[2] << 8) | nums[3]] + a = a.slice(0, lastColon) + } + + const doubleIdx = a.indexOf('::') + let head: string[] + let rest: string[] + if (doubleIdx !== -1) { + const left = a.slice(0, doubleIdx) + const right = a.slice(doubleIdx + 2) + head = left ? left.split(':') : [] + rest = right ? right.split(':') : [] + } else { + head = a.split(':') + rest = [] + } + + const headNums = head.map((h) => parseInt(h, 16)) + const restNums = rest.map((h) => parseInt(h, 16)) + if ([...headNums, ...restNums].some((n) => Number.isNaN(n))) return null + + if (doubleIdx === -1) { + if (headNums.length + tail.length !== 8) return null + return [...headNums, ...tail] + } + const fill = 8 - headNums.length - restNums.length - tail.length + if (fill < 0) return null + return [...headNums, ...Array(fill).fill(0), ...restNums, ...tail] +} + +/** Extract the 4 IPv4 octets encoded in groups[6] and groups[7]. */ +function v4FromGroups(g6: number, g7: number): number[] { + return [(g6 >> 8) & 0xff, g6 & 0xff, (g7 >> 8) & 0xff, g7 & 0xff] +} + +function isBlockedIpv6(ip: string): boolean { + const g = ipv6ToGroups(ip) + if (!g) return true // malformed — fail closed + + const zero = (n: number) => n === 0 + const headIsZero = g.slice(0, 6).every(zero) + + if (g.every(zero)) return true // :: unspecified + if (headIsZero && g[6] === 0 && g[7] === 1) return true // ::1 loopback + + if ((g[0] & 0xfe00) === 0xfc00) return true // fc00::/7 unique-local + if ((g[0] & 0xffc0) === 0xfe80) return true // fe80::/10 link-local + if ((g[0] & 0xff00) === 0xff00) return true // ff00::/8 multicast + + // 6to4 (2002::/16) embeds an IPv4 in groups 1-2. + if (g[0] === 0x2002) { + return isBlockedIpv4(`${(g[1] >> 8) & 0xff}.${g[1] & 0xff}.${(g[2] >> 8) & 0xff}.${g[2] & 0xff}`) + } + // Teredo (2001:0000::/32) obfuscates the peer address — refuse by default. + if (g[0] === 0x2001 && g[1] === 0x0000) return true + + // IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x): classify the v4 tail. + if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && (g[5] === 0xffff || g[5] === 0)) { + if (g[6] === 0 && g[7] === 0) return false // :: / ::ffff:: handled above + return isBlockedIpv4(v4FromGroups(g[6], g[7]).join('.')) + } + + return false +} + +/** + * Validate a single URL: scheme, credentials, optional host allowlist, then DNS + * resolution and per-address classification. Throws {@link SsrfError} on the + * first refusal; returns the parsed URL when the target is safe to fetch. + */ +export async function assertUrlSafe( + raw: string | URL, + opts: Pick = {}, +): Promise { + let url: URL + try { + url = new URL(typeof raw === 'string' ? raw : raw.toString()) + } catch { + throw new SsrfError('invalid_url', 'Target is not a valid absolute URL.', String(raw)) + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new SsrfError('unsupported_scheme', `Scheme "${url.protocol}" is not allowed; only http/https.`, url.protocol) + } + if (url.username.length > 0 || url.password.length > 0) { + throw new SsrfError('embedded_credentials', 'URL must not embed credentials.') + } + + const hostname = url.hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase() + if (hostname.length === 0) { + throw new SsrfError('invalid_url', 'URL has no hostname.') + } + + const allowlist = normalizeHosts(opts.allowedHosts) + if (allowlist.length > 0 && !allowlist.includes(hostname)) { + throw new SsrfError('host_not_allowed', `Host "${hostname}" is not in the allowed host list.`, hostname) + } + + const lookup = opts.lookup ?? defaultLookup + let addresses: string[] + if (net.isIP(hostname)) { + addresses = [hostname] + } else { + addresses = await lookup(hostname) + if (!addresses || addresses.length === 0) { + throw new SsrfError('unresolvable_host', `Host "${hostname}" did not resolve.`, hostname) + } + } + + for (const address of addresses) { + if (isBlockedIpAddress(address)) { + throw new SsrfError( + 'blocked_address', + `Host "${hostname}" resolves to a blocked address (${address}).`, + address, + ) + } + } + + return url +} + +/** + * Fetch `target`, refusing unsafe hosts and re-validating every redirect hop. + * Throws {@link SsrfError} when a hop is refused or the chain is too long. + */ +export async function ssrfSafeFetch( + target: string | URL, + init: RequestInit = {}, + opts: SsrfFetchOptions = {}, +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch + const maxRedirects = opts.maxRedirects ?? DEFAULT_MAX_REDIRECTS + + let url = await assertUrlSafe(target, opts) + + for (let hop = 0; ; hop++) { + const response = await fetchImpl(url.toString(), { ...init, redirect: 'manual' }) + + if (!REDIRECT_STATUSES.has(response.status)) { + return response + } + + const location = response.headers.get('location') + if (!location) { + return response // a redirect we cannot follow — surface it to the caller + } + if (hop >= maxRedirects) { + throw new SsrfError('too_many_redirects', `Too many redirects (>${maxRedirects}).`, url.hostname) + } + + let next: URL + try { + next = new URL(location, url) + } catch { + throw new SsrfError('invalid_url', `Redirect target is not a valid URL: ${location}`, location) + } + url = await assertUrlSafe(next, opts) + } +} diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..57a66a28 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -121,3 +121,82 @@ describe('BazaarMcpServer', () => { expect(parsedText.success).toBe(true); }); }); + +describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { + function captureHandler(server: any) { + let handler: any = null; + server.server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + server.setupHandlers(); + return handler; + } + + const publicLookup = { lookup: async () => ['93.184.216.34'] }; + const okFetchImpl = async () => + new Response(JSON.stringify({ resources: [{ name: 'x' }] }), { status: 200 }); + + it('rejects a non-http(s) scheme', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl: okFetchImpl }, + }); + const handler = captureHandler(server); + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'file:///etc' } } }); + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text).code).toBe('ERR_URL_BLOCKED'); + }); + + it('refuses a private / metadata literal', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl: okFetchImpl }, + }); + const handler = captureHandler(server); + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'http://169.254.169.254' } } }); + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text).code).toBe('ERR_URL_BLOCKED'); + }); + + it('refuses a hostname that resolves to a private address', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], lookup: async () => ['127.0.0.1'], fetchImpl: okFetchImpl }, + }); + const handler = captureHandler(server); + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://innocent.example' } } }); + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text).code).toBe('ERR_URL_BLOCKED'); + }); + + it('refuses a redirect to a forbidden host', async () => { + const calls: string[] = []; + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { + allowedHosts: [], + ...publicLookup, + fetchImpl: async (input: string | URL) => { + calls.push(String(input)); + return new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/' } }); + }, + }, + }); + const handler = captureHandler(server); + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + expect(response.isError).toBe(true); + expect(JSON.parse(response.content[0].text).code).toBe('ERR_URL_BLOCKED'); + expect(calls).toEqual(['https://ok.example/discovery/resources']); + }); + + it('returns results from a permitted public host', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl: okFetchImpl }, + }); + const handler = captureHandler(server); + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example', query: 'xlm' } } }); + expect(response.isError).toBeFalsy(); + expect(JSON.parse(response.content[0].text).resources[0].name).toBe('x'); + }); +}); From 28af3ba2bd2ee2e785a8a54185d94d0f8241e48f Mon Sep 17 00:00:00 2001 From: Jonathan-ai9 Date: Sat, 17 Oct 2026 21:33:29 +0100 Subject: [PATCH 2/5] fix(mcp): bound tool results and cap upstream reads (#416) A tool result is written straight into the agent's context window, so an unbounded one is self-inflicted denial of service whose failure gets blamed on the model. bazaar_search and paid_call now serialise compactly (no indentation), page collections with a total and a notice explaining how to ask for more, and read the response body through a capped stream reader that cancels the stream instead of buffering a hostile body in full. Truncation is always stated in the result; an oversized discovery body returns ERR_RESPONSE_TOO_LARGE. Bounds are operator-configurable via MCP_MAX_RESULT_BYTES, MCP_MAX_UPSTREAM_BYTES, MCP_DEFAULT_PAGE_LIMIT and MCP_MAX_PAGE_LIMIT. --- .changeset/mcp-bounded-tool-results.md | 15 ++ .env.example | 15 ++ src/config.ts | 23 +++ src/mcp/codes.ts | 1 + src/mcp/limits.ts | 240 +++++++++++++++++++++++++ src/mcp/server.ts | 154 +++++++++++++--- tests/mcp.test.ts | 190 +++++++++++++++++++- 7 files changed, 608 insertions(+), 30 deletions(-) create mode 100644 .changeset/mcp-bounded-tool-results.md create mode 100644 src/mcp/limits.ts diff --git a/.changeset/mcp-bounded-tool-results.md b/.changeset/mcp-bounded-tool-results.md new file mode 100644 index 00000000..ffd390c1 --- /dev/null +++ b/.changeset/mcp-bounded-tool-results.md @@ -0,0 +1,15 @@ +--- +'lens': patch +--- + +MCP tool results are size-bounded (#416). `bazaar_search` and `paid_call` used to +serialise whatever the node returned, pretty-printed and in full, so one large — +or hostile — response could consume the agent's entire context window. Results +are now compact, capped at `MCP_MAX_RESULT_BYTES`, and a truncated result carries +`truncated: true`, the page returned, the total, and a notice explaining how to +ask for more. `bazaar_search` accepts `limit` / `offset` (default and ceiling from +`MCP_DEFAULT_PAGE_LIMIT` / `MCP_MAX_PAGE_LIMIT`), forwards them to the node, and +pages whatever the node returns regardless. Upstream bodies are read through a +capped stream reader (`MCP_MAX_UPSTREAM_BYTES`) that cancels the response once the +cap is passed, so an oversized body is never buffered in full; a discovery body +too large to parse returns the new `ERR_RESPONSE_TOO_LARGE` code. diff --git a/.env.example b/.env.example index 988a2bb8..78cf356a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,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. diff --git a/src/config.ts b/src/config.ts index 4bd693a6..b050b0ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,6 +81,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' @@ -277,6 +288,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 ─────────────────────────────────────────────────────────── @@ -358,4 +380,5 @@ export const config = { cache: globalConfig.cache, http: globalConfig.http, outbound: globalConfig.outbound, + mcp: globalConfig.mcp, } diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index 5bd1dca3..a1bf3ee0 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -7,6 +7,7 @@ export const MCP_ERROR_CODES = { ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', ERR_URL_BLOCKED: 'ERR_URL_BLOCKED', + ERR_RESPONSE_TOO_LARGE: 'ERR_RESPONSE_TOO_LARGE', ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' } as const; diff --git a/src/mcp/limits.ts b/src/mcp/limits.ts new file mode 100644 index 00000000..d10a98eb --- /dev/null +++ b/src/mcp/limits.ts @@ -0,0 +1,240 @@ +/** + * 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'] + +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 | 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 + 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' + 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 + 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 { + 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 } +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e381740b..aafc5e8d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,6 +5,15 @@ import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; +import { boundSerialized, readBodyCapped, type ResultBudget } from './limits.js'; + +/** Size bounds applied to every tool result, and the pages asked of a node. */ +export interface McpLimits { + maxResultBytes: number; + maxUpstreamBytes: number; + defaultPageLimit: number; + maxPageLimit: number; +} export interface McpServerConfig { fetchWithPayment: typeof fetch; @@ -14,12 +23,15 @@ export interface McpServerConfig { * tests can drive DNS resolution and the transport without real sockets. */ bazaarFetch?: Partial; + /** Overrides the `MCP_*` env bounds — primarily for tests. */ + limits?: Partial; } export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; private bazaarFetchOptions: SsrfFetchOptions; + private limits: McpLimits; constructor(config_: McpServerConfig) { this.fetchWithPayment = config_.fetchWithPayment; @@ -28,6 +40,7 @@ export class BazaarMcpServer { fetchImpl: (input, init) => fetchWithTimeout(input, init), ...config_.bazaarFetch, }; + this.limits = { ...config.mcp, ...config_.limits }; this.server = new Server( { name: 'bazaar-mcp-server', @@ -48,7 +61,9 @@ export class BazaarMcpServer { tools: [ { name: 'bazaar_search', - description: 'Search for resources in a Stellar Bazaar node', + description: + 'Search for resources in a Stellar Bazaar node. Results are a bounded page: ' + + 'use limit and offset to ask for more, and check "truncated" / "total" in the response.', inputSchema: { type: 'object', properties: { @@ -60,13 +75,21 @@ export class BazaarMcpServer { type: 'string', description: 'Optional search query', }, + limit: { + type: 'number', + description: `Maximum number of resources to return (default ${config.mcp.defaultPageLimit}, capped at ${config.mcp.maxPageLimit})`, + }, + offset: { + type: 'number', + description: 'Number of resources to skip, to page past a truncated result (default 0)', + }, }, required: ['url'], }, }, { name: 'paid_call', - description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically.', + description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically. Oversized responses are truncated; the result says so.', inputSchema: { type: 'object', properties: { @@ -88,31 +111,60 @@ export class BazaarMcpServer { this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'bazaar_search') { const url = String(request.params.arguments?.url); - const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; - + const args = request.params.arguments ?? {}; + const query = args.query ? String(args.query) : undefined; + const { limit, offset } = this.parsePage(args); + try { - const endpoint = query - ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` - : `${url.replace(/\/$/, '')}/discovery/resources`; - + const search = new URLSearchParams(); + if (query) search.set('q', query); + search.set('limit', String(limit)); + search.set('offset', String(offset)); + + const endpoint = query + ? `${url.replace(/\/$/, '')}/discovery/search?${search}` + : `${url.replace(/\/$/, '')}/discovery/resources?${search}`; + const res = await ssrfSafeFetch(endpoint, {}, this.bazaarFetchOptions); - + if (!res.ok) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); } - - const data = await res.json(); - - const hasResources = (d: unknown): d is { resources: unknown[] } => + + // Cap the read, not just the reply: a node that streams megabytes is + // never buffered here in full. + const body = await readBodyCapped(res, this.limits.maxUpstreamBytes); + if (body.exceeded) { + return this.createErrorResponse( + 'ERR_RESPONSE_TOO_LARGE', + `Node sent more than ${this.limits.maxUpstreamBytes} bytes, which cannot be parsed as one document. ` + + `Retry with a smaller limit (currently ${limit}) or a larger offset.` + ); + } + + let data: unknown; + try { + data = JSON.parse(body.text); + } catch { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Service returned a response that is not valid JSON'); + } + + const hasResources = (d: unknown): d is { resources: unknown[] } => typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); } - - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - }; + + return this.createBoundedResult(data, { + maxBytes: this.limits.maxResultBytes, + maxItems: limit, + offset, + notice: + `Result truncated to fit a ${this.limits.maxResultBytes}-byte tool output limit, ` + + `of which at most ${limit} items fit on one page. ` + + 'Ask for the rest with the limit and offset arguments.', + }); } catch (e: any) { if (e instanceof SsrfError) { return this.createErrorResponse('ERR_URL_BLOCKED', `Blocked request: ${e.message}`); @@ -124,28 +176,44 @@ export class BazaarMcpServer { if (request.params.name === 'paid_call') { const url = String(request.params.arguments?.url); const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - + try { const res = await this.fetchWithPayment(url, { method }); - + if (!res.ok) { if (res.status === 402) { return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); } return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } - - const text = await res.text(); + + const body = await readBodyCapped(res, this.limits.maxUpstreamBytes); + + const budget: ResultBudget = { + maxBytes: this.limits.maxResultBytes, + // A paid response has no pagination contract, so paging a collection + // would hide items with no way to ask for the next one; only the + // byte ceiling applies. + maxItems: Number.MAX_SAFE_INTEGER, + offset: 0, + knownTruncated: body.exceeded, + notice: body.exceeded + ? `Only the first ${this.limits.maxUpstreamBytes} bytes this resource sent are shown, so this result is incomplete. ` + + 'Ask the resource for a smaller response or a paginated endpoint.' + : `Result truncated to fit a ${this.limits.maxResultBytes}-byte tool output limit. ` + + 'Ask the resource for a smaller response or a paginated endpoint.', + }; + let parsed; try { - parsed = JSON.parse(text); + parsed = JSON.parse(body.text); } catch { - parsed = text; + // Genuinely non-JSON (or a cut-off body, which cannot parse): hand + // back the raw text, bounded and labelled as partial. + parsed = body.text; } - - return { - content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], - }; + + return this.createBoundedResult(parsed, budget); } catch (e: any) { return this.handlePaidCallError(e); } @@ -155,6 +223,35 @@ export class BazaarMcpServer { }); } + /** Clamp a caller-requested page; mirrors the node's own discovery limits. */ + private parsePage(args: Record | undefined): { limit: number; offset: number } { + if (!args || typeof args !== 'object') return { limit: this.limits.defaultPageLimit, offset: 0 }; + + const rawLimit = Number(args.limit); + const limit = + Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(Math.floor(rawLimit), this.limits.maxPageLimit) + : this.limits.defaultPageLimit; + + const rawOffset = Number(args.offset); + const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; + + return { limit, offset }; + } + + /** + * Serialize a successful result compactly (indentation is for humans and + * costs the agent context) and within `budget`, so no upstream response — + * large or hostile — can fill the context window in one call. Truncation is + * stated in the payload rather than left to be inferred. + */ + private createBoundedResult(data: unknown, budget: ResultBudget) { + const { text } = boundSerialized(data, budget); + return { + content: [{ type: 'text', text }], + }; + } + private handlePaidCallError(e: any) { const msg = String(e.message || e).toLowerCase(); @@ -177,8 +274,7 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); } - private createErrorResponse(code: McpErrorCode, reason: string) { - // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. + private createErrorResponse(code: McpErrorCode, reason: string) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 57a66a28..219098e6 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -186,7 +186,7 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); expect(response.isError).toBe(true); expect(JSON.parse(response.content[0].text).code).toBe('ERR_URL_BLOCKED'); - expect(calls).toEqual(['https://ok.example/discovery/resources']); + expect(calls).toEqual(['https://ok.example/discovery/resources?limit=50&offset=0']); }); it('returns results from a permitted public host', async () => { @@ -200,3 +200,191 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { expect(JSON.parse(response.content[0].text).resources[0].name).toBe('x'); }); }); + +describe('BazaarMcpServer bounded tool results (#416)', () => { + function captureHandler(server: any) { + let handler: any = null; + server.server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + server.setupHandlers(); + return handler; + } + + const publicLookup = { lookup: async () => ['93.184.216.34'] }; + + function searchServer(fetchImpl: any, limits?: any) { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl }, + ...(limits ? { limits } : {}), + }); + return { server, handler: captureHandler(server) }; + } + + const jsonFetch = (payload: unknown) => async () => + new Response(JSON.stringify(payload), { status: 200 }); + + it('pages a collection that exceeds the item limit and says so', async () => { + const resources = Array.from({ length: 500 }, (_, i) => ({ resource: { url: `https://r${i}.example` } })); + const { handler } = searchServer(jsonFetch({ resources, total: resources.length })); + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + + expect(response.isError).toBeFalsy(); + const text = response.content[0].text; + const parsed = JSON.parse(text); + expect(parsed.resources.length).toBeLessThanOrEqual(50); + expect(parsed.truncated).toBe(true); + expect(parsed.total).toBe(500); + expect(parsed.offset).toBe(0); + expect(parsed.notice).toMatch(/limit and offset/); + // Indentation is for humans; it costs the agent context for no benefit. + expect(text).not.toContain('\n '); + }); + + it('honours limit and offset arguments and asks the node for the same page', async () => { + const requested: string[] = []; + const resources = Array.from({ length: 30 }, (_, i) => ({ resource: { url: `https://r${i}.example` } })); + const { handler } = searchServer(async (input: string | URL) => { + requested.push(String(input)); + return new Response(JSON.stringify({ resources }), { status: 200 }); + }); + + const response = await handler({ + params: { name: 'bazaar_search', arguments: { url: 'https://ok.example', query: 'xlm', limit: 7, offset: 14 } }, + }); + + expect(requested[0]).toContain('/discovery/search?'); + expect(requested[0]).toContain('q=xlm'); + expect(requested[0]).toContain('limit=7'); + expect(requested[0]).toContain('offset=14'); + + const parsed = JSON.parse(response.content[0].text); + expect(parsed.resources.length).toBe(7); + expect(parsed.total).toBe(30); + expect(parsed.offset).toBe(14); + expect(parsed.truncated).toBe(true); + }); + + it('keeps a response within the configured byte limit', async () => { + const resources = Array.from({ length: 1000 }, (_, i) => ({ + resource: { url: `https://example.com/${'y'.repeat(200)}${i}` }, + })); + const { handler } = searchServer(jsonFetch({ resources }), { maxResultBytes: 4096, maxUpstreamBytes: 1_048_576 }); + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + + const text = response.content[0].text; + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(4096); + const parsed = JSON.parse(text); + expect(parsed.truncated).toBe(true); + expect(parsed.total).toBe(1000); + expect(parsed.resources.length).toBeLessThan(1000); + expect(parsed.resources.length).toBeGreaterThan(0); + }); + + it('truncates a payload with no pageable collection', async () => { + const { handler } = searchServer(jsonFetch({ blob: 'z'.repeat(10_000) }), { maxResultBytes: 2048 }); + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + + const text = response.content[0].text; + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2048); + const parsed = JSON.parse(text); + expect(parsed.truncated).toBe(true); + expect(parsed.notice).toMatch(/limit and offset/); + expect(typeof parsed.partial).toBe('string'); + expect(parsed.partial.length).toBeGreaterThan(0); + }); + + it('does not buffer an oversized upstream body in full', async () => { + const totalChunks = 64; + const chunk = new TextEncoder().encode('x'.repeat(4096)); + let produced = 0; + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + if (produced >= totalChunks) { + controller.close(); + return; + } + produced++; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + + const { handler } = searchServer(async () => new Response(stream, { status: 200 }), { + maxUpstreamBytes: 8192, + maxResultBytes: 65_536, + }); + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + + expect(response.isError).toBe(true); + const payload = JSON.parse(response.content[0].text); + expect(payload.code).toBe('ERR_RESPONSE_TOO_LARGE'); + expect(payload.reason).toMatch(/limit/); + expect(produced).toBeLessThan(totalChunks); + expect(cancelled).toBe(true); + }); + + it('bounds a paid_call result and reports the truncation', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ rows: Array.from({ length: 500 }, (_, i) => i) }), { status: 200 }) + ); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, limits: { maxResultBytes: 1024 } }); + const handler = captureHandler(server); + + const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }); + + expect(response.isError).toBeFalsy(); + const text = response.content[0].text; + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(1024); + const parsed = JSON.parse(text); + expect(parsed.truncated).toBe(true); + expect(parsed.rows.length).toBeLessThan(500); + expect(parsed.total).toBe(500); + expect(parsed.notice).toMatch(/paginated endpoint/); + }); + + it('flags a paid_call body that exceeds the read cap', async () => { + const big = 'a'.repeat(5000); + const mockFetch = vi.fn().mockResolvedValue( + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(big)); + controller.enqueue(new TextEncoder().encode(big)); + controller.close(); + }, + }), { status: 200 }) + ); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + limits: { maxUpstreamBytes: 4096, maxResultBytes: 2048 }, + }); + const handler = captureHandler(server); + + const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }); + + const text = response.content[0].text; + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2048); + const parsed = JSON.parse(text); + expect(parsed.truncated).toBe(true); + expect(parsed.notice).toMatch(/incomplete/); + }); + + it('leaves a small response untouched', async () => { + const { handler } = searchServer(jsonFetch({ resources: [{ resource: { url: 'https://a.example' } }] })); + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + + expect(response.isError).toBeFalsy(); + const parsed = JSON.parse(response.content[0].text); + expect(parsed.truncated).toBeUndefined(); + expect(parsed.resources[0].resource.url).toBe('https://a.example'); + }); +}); From a6508eee42b76433d733177e21fdeb3d84a31803 Mon Sep 17 00:00:00 2001 From: Jonathan-ai9 Date: Sat, 17 Oct 2026 21:53:26 +0100 Subject: [PATCH 3/5] feat(mcp): advertise and serve resources, not just tools (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lens facts that barely change — watched pairs, venues, the OpenAPI document, service status — forced an agent to call a tool every turn to keep them in context. Expose them as MCP resources instead, each with a stable URI, an accurate MIME type, and honest freshness: a content-hash version plus a lastModified per source, so a client can tell whether what it cached is still current. Status is regenerated per read and so is deliberately never cacheable. Default sources read only config and the on-disk OpenAPI document, keeping the stdio server free of a database dependency; a host with richer runtime state can inject its own descriptors. --- .env.example | 6 ++ README.md | 1 + docs/mcp-resources.md | 66 +++++++++++++ examples/mcp-server/agent.ts | 12 +++ src/mcp/resources.ts | 179 +++++++++++++++++++++++++++++++++++ src/mcp/server.ts | 75 ++++++++++++++- tests/mcp-resources.test.ts | 175 ++++++++++++++++++++++++++++++++++ 7 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/mcp-resources.md create mode 100644 src/mcp/resources.ts create mode 100644 tests/mcp-resources.test.ts diff --git a/.env.example b/.env.example index 78cf356a..633f0b99 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,12 @@ MCP_MAX_UPSTREAM_BYTES=262144 MCP_DEFAULT_PAGE_LIMIT=50 MCP_MAX_PAGE_LIMIT=200 +# 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 only to serve it +# from a non-standard location (e.g. a container where the document is mounted +# elsewhere). +LENS_OPENAPI_PATH= + # --- Active Network --- # Which network the node operates on: "testnet" or "mainnet" (default: testnet) # This drives all config.horizon / config.rpc / config.soroswap shortcut paths. diff --git a/README.md b/README.md index 8351617e..c4cbea0b 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,7 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). 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. +The [MCP server](src/mcp/server.ts) exposes read-only [resources](docs/mcp-resources.md) — the watched pairs, venues, this OpenAPI document, and service status — each with a stable URI and freshness metadata. ## Examples diff --git a/docs/mcp-resources.md b/docs/mcp-resources.md new file mode 100644 index 00000000..c3602690 --- /dev/null +++ b/docs/mcp-resources.md @@ -0,0 +1,66 @@ +# MCP resources + +The Lens MCP server (`src/mcp/server.ts`) exposes two kinds of things to an +agent: + +- **Tools** — functions the agent calls to *do* something (`bazaar_search`, + `paid_call`). +- **Resources** — read-only content the agent can attach 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 honest freshness so a client can +tell whether what it cached is still current. + +## Resource URIs + +| URI | MIME type | Contents | Freshness | +| --- | --------- | -------- | --------- | +| `lens://pairs` | `application/json` | The asset pairs Lens quotes on the configured network: `{ network, pairs: [{ pairKey, assetA, assetB }] }`. | `lastModified` is when this process resolved its config; the pairs come from `WATCHED_PAIRS`/`WATCHED_PAIRS_` and do not change while the process runs. | +| `lens://venues` | `application/json` | The liquidity venues Lens reads from, with each venue's `source` and whether it is `enabled` on this network. | Same boot-time semantics as `lens://pairs`. | +| `lens://openapi.json` | `application/json` | The service OpenAPI 3 document, served verbatim from `openapi.json`. | `lastModified` is the file's on-disk modification time. | +| `lens://status` | `application/json` | Current network, Horizon/RPC URLs, venue enablement, and watched-pair count. | `lastModified` is the read instant — every read is a new version, so it is deliberately **not** cacheable. | + +A URI never changes shape. Adding a field to a payload is allowed; renaming a +URI or reusing it for different content is not. + +## Freshness + +Every resource reports: + +- `version` — `sha256:` of the exact bytes returned. A client that cached a + previous body compares this to decide whether its copy is still current, + without diffing the text. It is returned in `_meta.version` on both + `resources/list` and `resources/read`. +- `lastModified` — an ISO-8601 timestamp for the instant the underlying source + last changed, in `annotations.lastModified` (list) and `_meta.lastModified` + (list and read). It is omitted only when a source has no notion of change. + +`resources/list` resolves each resource to compute this metadata, so listing is +as current as reading. + +## Overriding the sources + +The defaults derive only from `config` and the on-disk OpenAPI document — no +database — so the stdio server stays as light as the payment client hosting it. +A process that holds richer runtime state (a registry with hot-added pairs, a +live health check) can pass its own descriptors: + +```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() } }), + }, + ], +}); +``` + +Reading an unknown URI fails with a JSON-RPC `InvalidParams` error; a resource +source that throws surfaces as `InternalError`. diff --git a/examples/mcp-server/agent.ts b/examples/mcp-server/agent.ts index 7d47263d..83dda4d6 100644 --- a/examples/mcp-server/agent.ts +++ b/examples/mcp-server/agent.ts @@ -74,6 +74,18 @@ async function main() { const tools = await client.listTools(); console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', ')); + // Lens also advertises read-only resources: stable, cacheable facts about the + // service itself. An agent attaches these to its context once instead of + // calling a tool every turn. + const resources = await client.listResources(); + console.log('[agent] Available resources:', resources.resources.map(r => r.uri).join(', ')); + try { + const read = await client.readResource({ uri: 'lens://status' }); + console.log('[agent] lens://status:', read.contents[0].text); + } catch (err: any) { + console.error('[agent] Resource read error:', err.message); + } + const BAZAAR_URL = 'http://localhost:3000'; console.log(`\n[agent] 1. Discovering resources at ${BAZAAR_URL}...`); diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts new file mode 100644 index 00000000..195dfbda --- /dev/null +++ b/src/mcp/resources.ts @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { config, activeNetwork } from '../config.js'; + +/** + * A resource an agent can attach to its context once, rather than re-fetching + * by calling a tool every turn. These are the stable, cacheable facts about the + * Lens service itself — the pairs it quotes, the venues it reads, its OpenAPI + * document, and its current status. + * + * `read` may be called on every `resources/list` and `resources/read`, so it + * must be cheap. Throwing is allowed and surfaces to the caller as a read + * error. + */ +export interface LensResourceDescriptor { + /** Stable URI. Clients cache against it, so it must never change shape. */ + uri: string; + /** Short machine-readable handle, unique within the service. */ + name: string; + title: string; + description: string; + mimeType: string; + /** + * Produce the current payload. Supply `text` for a verbatim body (e.g. a + * file served byte-for-byte) or `data`, which is serialized to compact JSON. + * `lastModified` is the instant the underlying source last changed; omit it + * when the source genuinely has no notion of change. + */ + read: () => { data?: unknown; text?: string; lastModified?: string }; +} + +/** A descriptor resolved to a concrete body plus its freshness metadata. */ +export interface LensResourceSnapshot { + uri: string; + name: string; + title: string; + description: string; + mimeType: string; + text: string; + version: string; + lastModified?: string; +} + +/** + * The instant this process resolved its config. The pairs, venues and status + * resources are derived from a config snapshot frozen at import, so "when did + * this change" for those sources is "when did this process start." Reporting the + * boot time is honest about that — a restart with new env is a new version, and + * a running process never silently mutates these. + */ +const BOOT_TIME = new Date().toISOString(); + +/** + * Content-hash version. A client that cached a previous body can compare this + * to decide whether its copy is still current without diffing the text. Deriving + * it from the exact bytes we return keeps it truthful: same payload, same + * version; any change, a new one. + */ +function computeVersion(text: string): string { + return 'sha256:' + createHash('sha256').update(text).digest('hex'); +} + +/** Resolve the OpenAPI document path, honoring an operator override. */ +function openapiPath(): string { + if (process.env.LENS_OPENAPI_PATH) return process.env.LENS_OPENAPI_PATH; + // Runs the same from src/mcp (tsx) and dist/mcp (build): the document lives at + // the package root, two directories up from this module. + return path.resolve(__dirname, '..', '..', 'openapi.json'); +} + +/** Serialize an object payload to the compact JSON that becomes the body. */ +function jsonText(data: unknown): string { + return JSON.stringify(data); +} + +/** + * The default set of Lens resources, derived only from `config` and the on-disk + * OpenAPI document — deliberately with no database dependency, so the stdio MCP + * server stays as lightweight as the payment client that hosts it. A process + * that *does* have the richer runtime state (a registry with hot-added pairs, a + * live health check) can pass its own descriptors to override these. + */ +export function defaultResources(): LensResourceDescriptor[] { + return [ + { + uri: 'lens://pairs', + name: 'pairs', + title: 'Watched pairs', + description: 'The asset pairs Lens actively quotes on the configured network.', + mimeType: 'application/json', + read: () => ({ + data: { + network: activeNetwork, + pairs: config.pairs.map((p) => ({ + pairKey: p.pairKey, + assetA: p.assetA, + assetB: p.assetB, + })), + }, + lastModified: BOOT_TIME, + }), + }, + { + uri: 'lens://venues', + name: 'venues', + title: 'Venues', + description: 'The liquidity venues Lens reads prices from on the configured network.', + mimeType: 'application/json', + read: () => ({ + data: { + network: activeNetwork, + venues: [ + { name: 'SDEX', source: 'SDEX', kind: 'orderbook', enabled: true }, + { name: 'AMM', source: 'AMM', kind: 'orderbook', enabled: true }, + { name: 'Soroswap', source: 'soroswap_amm', kind: 'amm', enabled: config.soroswap.enabled }, + { name: 'Aquarius', source: 'aquarius_amm', kind: 'amm', enabled: config.aquarius.enabled }, + ], + }, + lastModified: BOOT_TIME, + }), + }, + { + uri: 'lens://openapi.json', + name: 'openapi', + title: 'OpenAPI document', + description: 'The service OpenAPI 3 specification, served verbatim as JSON.', + mimeType: 'application/json', + read: () => { + const file = openapiPath(); + const text = readFileSync(file, 'utf8'); + return { text, lastModified: new Date(statSync(file).mtimeMs).toISOString() }; + }, + }, + { + uri: 'lens://status', + name: 'status', + title: 'Service status', + description: 'Current network, endpoints, venue enablement and watched-pair count.', + mimeType: 'application/json', + read: () => { + const generatedAt = new Date().toISOString(); + return { + data: { + network: activeNetwork, + generatedAt, + horizonUrl: config.horizon.url, + rpcUrl: config.rpc.url, + watchedPairCount: config.pairs.length, + venues: { + soroswap: config.soroswap.enabled, + aquarius: config.aquarius.enabled, + oracle: config.oracle.enabled, + }, + }, + // Status is defined by being current: every read is a new version, so a + // client knows it must not treat this as a long-lived cache. + lastModified: generatedAt, + }; + }, + }, + ]; +} + +/** Resolve a descriptor into the body plus version/freshness returned to clients. */ +export function snapshotResource(descriptor: LensResourceDescriptor): LensResourceSnapshot { + const { data, text: rawText, lastModified } = descriptor.read(); + const text = rawText ?? jsonText(data); + return { + uri: descriptor.uri, + name: descriptor.name, + title: descriptor.title, + description: descriptor.description, + mimeType: descriptor.mimeType, + text, + version: computeVersion(text), + lastModified, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index aafc5e8d..d2090767 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,11 +1,19 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + ErrorCode, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; import { boundSerialized, readBodyCapped, type ResultBudget } from './limits.js'; +import { defaultResources, snapshotResource, type LensResourceDescriptor } from './resources.js'; /** Size bounds applied to every tool result, and the pages asked of a node. */ export interface McpLimits { @@ -25,6 +33,13 @@ export interface McpServerConfig { bazaarFetch?: Partial; /** Overrides the `MCP_*` env bounds — primarily for tests. */ limits?: Partial; + /** + * The read-only resources this server exposes. Defaults to the stable, + * cacheable Lens facts derived from config and the on-disk OpenAPI document. + * Pass a custom set to serve richer runtime state (a hot pair registry, a live + * health check) without giving the stdio server a database dependency. + */ + resources?: LensResourceDescriptor[]; } export class BazaarMcpServer { @@ -32,6 +47,7 @@ export class BazaarMcpServer { private fetchWithPayment: typeof fetch; private bazaarFetchOptions: SsrfFetchOptions; private limits: McpLimits; + private resources: LensResourceDescriptor[]; constructor(config_: McpServerConfig) { this.fetchWithPayment = config_.fetchWithPayment; @@ -41,6 +57,7 @@ export class BazaarMcpServer { ...config_.bazaarFetch, }; this.limits = { ...config.mcp, ...config_.limits }; + this.resources = config_.resources ?? defaultResources(); this.server = new Server( { name: 'bazaar-mcp-server', @@ -49,6 +66,7 @@ export class BazaarMcpServer { { capabilities: { tools: {}, + resources: {}, }, } ); @@ -108,6 +126,61 @@ export class BazaarMcpServer { ], })); + this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: this.resources.map((descriptor) => { + const snapshot = snapshotResource(descriptor); + return { + uri: snapshot.uri, + name: snapshot.name, + title: snapshot.title, + description: snapshot.description, + mimeType: snapshot.mimeType, + ...(snapshot.lastModified + ? { annotations: { lastModified: snapshot.lastModified } } + : {}), + _meta: { + version: snapshot.version, + ...(snapshot.lastModified ? { lastModified: snapshot.lastModified } : {}), + }, + }; + }), + })); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + const descriptor = this.resources.find((d) => d.uri === uri); + if (!descriptor) { + throw new McpError( + ErrorCode.InvalidParams, + `Unknown resource: ${uri}. Call resources/list for the available URIs.` + ); + } + + let snapshot; + try { + snapshot = snapshotResource(descriptor); + } catch (e: any) { + throw new McpError( + ErrorCode.InternalError, + `Failed to read resource ${uri}: ${e?.message ?? String(e)}` + ); + } + + return { + contents: [ + { + uri: snapshot.uri, + mimeType: snapshot.mimeType, + text: snapshot.text, + _meta: { + version: snapshot.version, + ...(snapshot.lastModified ? { lastModified: snapshot.lastModified } : {}), + }, + }, + ], + }; + }); + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'bazaar_search') { const url = String(request.params.arguments?.url); diff --git a/tests/mcp-resources.test.ts b/tests/mcp-resources.test.ts new file mode 100644 index 00000000..7d2546cc --- /dev/null +++ b/tests/mcp-resources.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest'; +import { + ListResourcesRequestSchema, + ReadResourceRequestSchema, + McpError, + ErrorCode, +} from '@modelcontextprotocol/sdk/types.js'; +import { BazaarMcpServer } from '../src/mcp/server'; +import { defaultResources, snapshotResource } from '../src/mcp/resources'; + +/** Capture the resource handlers the way tests/mcp.test.ts captures tool handlers. */ +function captureHandlers(server: BazaarMcpServer) { + const handlers: Record = {}; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListResourcesRequestSchema) handlers.list = fn; + if (schema === ReadResourceRequestSchema) handlers.read = fn; + }; + (server as any).setupHandlers(); + return handlers; +} + +function serverWith(resources: any) { + return new BazaarMcpServer({ + fetchWithPayment: async () => new Response(), + resources, + }); +} + +describe('BazaarMcpServer MCP resources (#418)', () => { + const fixed = new Date('2026-01-01T00:00:00.000Z'); + + it('declares the resources capability', () => { + const server = serverWith(defaultResources()); + const caps = (server as any).server._capabilities ?? (server as any).server.serverCapabilities; + expect(caps).toHaveProperty('resources'); + expect(caps).toHaveProperty('tools'); + }); + + it('lists stable URIs, MIME types and freshness for every resource', async () => { + const { list } = captureHandlers( + serverWith([ + { + uri: 'lens://pairs', + name: 'pairs', + title: 'Watched pairs', + description: 'd', + mimeType: 'application/json', + read: () => ({ data: { pairs: ['XLM/USDC'] }, lastModified: fixed.toISOString() }), + }, + ]) + ); + + const { resources } = await list(); + expect(resources).toHaveLength(1); + const r = resources[0]; + expect(r.uri).toBe('lens://pairs'); + expect(r.mimeType).toBe('application/json'); + expect(r.annotations.lastModified).toBe(fixed.toISOString()); + expect(r._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it('reads a resource, returning its JSON body and version', async () => { + const { read } = captureHandlers( + serverWith([ + { + uri: 'lens://pairs', + name: 'pairs', + title: 'Watched pairs', + description: 'd', + mimeType: 'application/json', + read: () => ({ data: { network: 'testnet', pairs: [] }, lastModified: fixed.toISOString() }), + }, + ]) + ); + + const result = await read({ params: { uri: 'lens://pairs' } }); + expect(result.contents).toHaveLength(1); + const c = result.contents[0]; + expect(c.uri).toBe('lens://pairs'); + expect(c.mimeType).toBe('application/json'); + expect(JSON.parse(c.text)).toEqual({ network: 'testnet', pairs: [] }); + expect(c._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(c._meta.lastModified).toBe(fixed.toISOString()); + }); + + it('serves a text body verbatim (OpenAPI document)', async () => { + const doc = '{"openapi":"3.0.0","info":{"title":"Lens"}}'; + const { read } = captureHandlers( + serverWith([ + { + uri: 'lens://openapi.json', + name: 'openapi', + title: 'OpenAPI document', + description: 'd', + mimeType: 'application/json', + read: () => ({ text: doc, lastModified: fixed.toISOString() }), + }, + ]) + ); + + const result = await read({ params: { uri: 'lens://openapi.json' } }); + expect(result.contents[0].text).toBe(doc); + }); + + it('rejects an unknown URI with an InvalidParams JSON-RPC error', async () => { + const { read } = captureHandlers(serverWith(defaultResources())); + await expect(read({ params: { uri: 'lens://nope' } })).rejects.toMatchObject({ + code: ErrorCode.InvalidParams, + }); + await expect(read({ params: { uri: 'lens://nope' } })).rejects.toBeInstanceOf(McpError); + }); + + it('reports an internal error when a resource source fails', async () => { + const { read } = captureHandlers( + serverWith([ + { + uri: 'lens://status', + name: 'status', + title: 'Service status', + description: 'd', + mimeType: 'application/json', + read: () => { + throw new Error('health check timed out'); + }, + }, + ]) + ); + + await expect(read({ params: { uri: 'lens://status' } })).rejects.toMatchObject({ + code: ErrorCode.InternalError, + message: expect.stringContaining('health check timed out'), + }); + }); + + it('exposes at least the pair list and the OpenAPI document by default', () => { + const uris = defaultResources().map((r) => r.uri); + expect(uris).toContain('lens://pairs'); + expect(uris).toContain('lens://openapi.json'); + expect(uris).toContain('lens://venues'); + expect(uris).toContain('lens://status'); + }); + + it('produces a stable version for identical content and a new one when it changes', () => { + const mk = (pairs: string[]) => ({ + uri: 'lens://x', + name: 'x', + title: 'x', + description: 'x', + mimeType: 'application/json', + read: () => ({ data: { pairs } }), + }); + const a = snapshotResource(mk(['XLM/USDC'])).version; + const b = snapshotResource(mk(['XLM/USDC'])).version; + const c = snapshotResource(mk(['BTC/USDC'])).version; + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it('reads the real OpenAPI document from disk with an mtime-based version', async () => { + const { read } = captureHandlers(serverWith(defaultResources())); + const result = await read({ params: { uri: 'lens://openapi.json' } }); + const parsed = JSON.parse(result.contents[0].text); + expect(parsed).toHaveProperty('openapi'); + expect(parsed).toHaveProperty('paths'); + expect(new Date(result.contents[0]._meta.lastModified).toString()).not.toBe('Invalid Date'); + }); + + it('treats status as never-cacheable: each read is a new version', async () => { + const { read } = captureHandlers(serverWith(defaultResources())); + const v1 = (await read({ params: { uri: 'lens://status' } })).contents[0]._meta.version; + await new Promise((r) => setTimeout(r, 5)); + const v2 = (await read({ params: { uri: 'lens://status' } })).contents[0]._meta.version; + expect(v1).not.toBe(v2); + }); +}); From 2c512ea83845c506672835126dee8e5db547e699 Mon Sep 17 00:00:00 2001 From: Jonathan-ai9 Date: Sun, 18 Oct 2026 04:10:33 +0100 Subject: [PATCH 4/5] docs(mcp): record that the resources work stacks on #416 server.ts now imports ./limits.js, so the resources change set cannot be based on main on its own. Note the merge order and the retarget so a reviewer knows why the branch starts where it does. --- docs/mcp-resources.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/mcp-resources.md b/docs/mcp-resources.md index c3602690..975bac40 100644 --- a/docs/mcp-resources.md +++ b/docs/mcp-resources.md @@ -64,3 +64,11 @@ new BazaarMcpServer({ Reading an unknown URI fails with a JSON-RPC `InvalidParams` error; a resource source that throws surfaces as `InternalError`. + +## Dependencies + +The resource handlers live in the same `src/mcp/server.ts` as the tool-result +bounds introduced for #416 (`./limits.js`), so the resources work builds on +that commit rather than on `main` alone. When the two land, #416 has to merge +first; #418 then retargets to `main` and its diff reduces to the resources +change set. From 69200344f30f76e569c11d9ce7573b7bb13d7533 Mon Sep 17 00:00:00 2001 From: Jonathan-ai9 Date: Wed, 21 Oct 2026 20:22:21 +0100 Subject: [PATCH 5/5] fix(mcp): un-interleave the two web conflict resolutions on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both merge commits on this branch ("Merge branch 'main' into feat/mcp-resources", 4a553a6 and its first parent baf94a8) were resolved in GitHub's browser conflict editor, which kept both sides' lines in place of choosing one: `src/mcp/server.ts` imported the same symbols twice, left `export interface McpLimits {` unclosed in front of main's `readPackageVersion` import block, and put class fields inside the constructor body. Nine TS1xxx syntax errors, and no conflict markers left to point at the seam. The tip tree is now the merge git's own three-way machinery produces: main plus #416's result bounds plus this PR's resources, each side's gates intact. History is untouched — no rebase, no force-push. --- .changeset/mcp-bounded-tool-results.md | 28 +- .env.example | 10 +- README.md | 2 +- docs/mcp-resources.md | 94 ++++--- examples/mcp-server/agent.ts | 17 +- src/mcp/codes.ts | 8 +- src/mcp/limits.ts | 17 ++ src/mcp/resources.ts | 89 ++++--- src/mcp/server.ts | 227 ++++++---------- src/mcp/toolSchemas.ts | 18 ++ tests/mcp-bounded-results.test.ts | 353 ++++++++++++++++++++++++ tests/mcp-resources.test.ts | 356 ++++++++++++++----------- tests/mcp.test.ts | 189 +------------ 13 files changed, 824 insertions(+), 584 deletions(-) create mode 100644 tests/mcp-bounded-results.test.ts diff --git a/.changeset/mcp-bounded-tool-results.md b/.changeset/mcp-bounded-tool-results.md index ffd390c1..80eda08a 100644 --- a/.changeset/mcp-bounded-tool-results.md +++ b/.changeset/mcp-bounded-tool-results.md @@ -2,14 +2,22 @@ 'lens': patch --- -MCP tool results are size-bounded (#416). `bazaar_search` and `paid_call` used to -serialise whatever the node returned, pretty-printed and in full, so one large — -or hostile — response could consume the agent's entire context window. Results -are now compact, capped at `MCP_MAX_RESULT_BYTES`, and a truncated result carries -`truncated: true`, the page returned, the total, and a notice explaining how to -ask for more. `bazaar_search` accepts `limit` / `offset` (default and ceiling from +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 the node returns regardless. Upstream bodies are read through a -capped stream reader (`MCP_MAX_UPSTREAM_BYTES`) that cancels the response once the -cap is passed, so an oversized body is never buffered in full; a discovery body -too large to parse returns the new `ERR_RESPONSE_TOO_LARGE` code. +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. diff --git a/.env.example b/.env.example index 031336c5..e89fe595 100644 --- a/.env.example +++ b/.env.example @@ -57,12 +57,6 @@ MCP_MAX_UPSTREAM_BYTES=262144 MCP_DEFAULT_PAGE_LIMIT=50 MCP_MAX_PAGE_LIMIT=200 -# 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 only to serve it -# from a non-standard location (e.g. a container where the document is mounted -# elsewhere). -LENS_OPENAPI_PATH= - # --- Active Network --- # Which network the node operates on: "testnet" or "mainnet" (default: testnet) # This drives all config.horizon / config.rpc / config.soroswap shortcut paths. @@ -292,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 ──────────────────────────────────────────────────── diff --git a/README.md b/README.md index 03ff42b4..a5d4aad9 100644 --- a/README.md +++ b/README.md @@ -391,9 +391,9 @@ Or interactively at [http://localhost:3002/graphiql](http://localhost:3002/graph ## Documentation Detailed system design and data flow diagrams can be found in the [Architecture Overview](docs/architecture.md). 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. -The [MCP server](src/mcp/server.ts) exposes read-only [resources](docs/mcp-resources.md) — the watched pairs, venues, this OpenAPI document, and service status — each with a stable URI and freshness metadata. 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 diff --git a/docs/mcp-resources.md b/docs/mcp-resources.md index 975bac40..8e906e76 100644 --- a/docs/mcp-resources.md +++ b/docs/mcp-resources.md @@ -1,50 +1,71 @@ # MCP resources -The Lens MCP server (`src/mcp/server.ts`) exposes two kinds of things to an -agent: +The Lens MCP server (`src/mcp/server.ts`) offers an agent two kinds of thing: -- **Tools** — functions the agent calls to *do* something (`bazaar_search`, - `paid_call`). -- **Resources** — read-only content the agent can attach to its context once, - instead of re-fetching by calling a tool every turn. +- **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 honest freshness so a client can -tell whether what it cached is still current. +has a fixed URI, an accurate MIME type, and freshness an agent can rely on. ## Resource URIs -| URI | MIME type | Contents | Freshness | -| --- | --------- | -------- | --------- | -| `lens://pairs` | `application/json` | The asset pairs Lens quotes on the configured network: `{ network, pairs: [{ pairKey, assetA, assetB }] }`. | `lastModified` is when this process resolved its config; the pairs come from `WATCHED_PAIRS`/`WATCHED_PAIRS_` and do not change while the process runs. | -| `lens://venues` | `application/json` | The liquidity venues Lens reads from, with each venue's `source` and whether it is `enabled` on this network. | Same boot-time semantics as `lens://pairs`. | -| `lens://openapi.json` | `application/json` | The service OpenAPI 3 document, served verbatim from `openapi.json`. | `lastModified` is the file's on-disk modification time. | -| `lens://status` | `application/json` | Current network, Horizon/RPC URLs, venue enablement, and watched-pair count. | `lastModified` is the read instant — every read is a new version, so it is deliberately **not** cacheable. | +| 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 allowed; renaming a -URI or reusing it for different content is not. +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: +Every resource reports two things, on `resources/list` and on `resources/read`: -- `version` — `sha256:` of the exact bytes returned. A client that cached a - previous body compares this to decide whether its copy is still current, - without diffing the text. It is returned in `_meta.version` on both - `resources/list` and `resources/read`. -- `lastModified` — an ISO-8601 timestamp for the instant the underlying source - last changed, in `annotations.lastModified` (list) and `_meta.lastModified` - (list and read). It is omitted only when a source has no notion of change. +- **`version`** — `sha256:` 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. -`resources/list` resolves each resource to compute this metadata, so listing is -as current as reading. +`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 derive only from `config` and the on-disk OpenAPI document — no -database — so the stdio server stays as light as the payment client hosting it. -A process that holds richer runtime state (a registry with hot-added pairs, a -live health check) can pass its own descriptors: +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({ @@ -62,13 +83,8 @@ new BazaarMcpServer({ }); ``` -Reading an unknown URI fails with a JSON-RPC `InvalidParams` error; a resource -source that throws surfaces as `InternalError`. - -## Dependencies +## Configuration -The resource handlers live in the same `src/mcp/server.ts` as the tool-result -bounds introduced for #416 (`./limits.js`), so the resources work builds on -that commit rather than on `main` alone. When the two land, #416 has to merge -first; #418 then retargets to `main` and its diff reduces to the resources -change set. +`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. diff --git a/examples/mcp-server/agent.ts b/examples/mcp-server/agent.ts index 57eb1e60..a9039892 100644 --- a/examples/mcp-server/agent.ts +++ b/examples/mcp-server/agent.ts @@ -84,17 +84,16 @@ async function main() { const tools = await client.listTools(); console.log('[agent] Available tools:', tools.tools.map(t => t.name).join(', ')); - // Lens also advertises read-only resources: stable, cacheable facts about the - // service itself. An agent attaches these to its context once instead of - // calling a tool every turn. + // 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(', ')); - try { - const read = await client.readResource({ uri: 'lens://status' }); - console.log('[agent] lens://status:', read.contents[0].text); - } catch (err: any) { - console.error('[agent] Resource read error:', err.message); - } + + 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'; diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index 6ac22bcd..caf9261d 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,12 +6,16 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', - ERR_URL_BLOCKED: 'ERR_URL_BLOCKED', - ERR_RESPONSE_TOO_LARGE: 'ERR_RESPONSE_TOO_LARGE', ERR_INVALID_ASSET: 'ERR_INVALID_ASSET', 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 diff --git a/src/mcp/limits.ts b/src/mcp/limits.ts index d10a98eb..4b63d6f4 100644 --- a/src/mcp/limits.ts +++ b/src/mcp/limits.ts @@ -19,6 +19,17 @@ /** 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 @@ -159,6 +170,12 @@ export function boundSerialized(value: unknown, budget: ResultBudget): BoundedRe } 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 } diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index 195dfbda..4e83dc2d 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -1,16 +1,16 @@ import { createHash } from 'node:crypto'; import { readFileSync, statSync } from 'node:fs'; -import path from 'node:path'; +import { join } from 'node:path'; import { config, activeNetwork } from '../config.js'; /** - * A resource an agent can attach to its context once, rather than re-fetching - * by calling a tool every turn. These are the stable, cacheable facts about the - * Lens service itself — the pairs it quotes, the venues it reads, its OpenAPI + * A resource an agent can attach to its context once, rather than re-fetching by + * calling a tool every turn. These are the stable, cacheable facts *about the + * service itself* — the pairs it quotes, the venues it reads, its OpenAPI * document, and its current status. * - * `read` may be called on every `resources/list` and `resources/read`, so it - * must be cheap. Throwing is allowed and surfaces to the caller as a read + * `read` may run on every `resources/list` as well as `resources/read`, so it + * must stay cheap. Throwing is allowed and surfaces to the caller as a read * error. */ export interface LensResourceDescriptor { @@ -22,8 +22,8 @@ export interface LensResourceDescriptor { description: string; mimeType: string; /** - * Produce the current payload. Supply `text` for a verbatim body (e.g. a - * file served byte-for-byte) or `data`, which is serialized to compact JSON. + * Produce the current payload. Supply `text` for a verbatim body (a file + * served byte-for-byte) or `data`, which is serialized to compact JSON. * `lastModified` is the instant the underlying source last changed; omit it * when the source genuinely has no notion of change. */ @@ -42,18 +42,29 @@ export interface LensResourceSnapshot { lastModified?: string; } +export interface LensResourceOptions { + /** + * The server name and version reported by `lens://status`. Passed in rather + * than imported so this module does not depend on `server.ts`, which imports + * it — the cycle would resolve at call time in CommonJS, but depending on + * that ordering is not worth it for two strings. + */ + serverName?: string; + serverVersion?: string; +} + /** * The instant this process resolved its config. The pairs, venues and status - * resources are derived from a config snapshot frozen at import, so "when did - * this change" for those sources is "when did this process start." Reporting the - * boot time is honest about that — a restart with new env is a new version, and - * a running process never silently mutates these. + * resources derive from a config snapshot frozen at import, so "when did this + * change" for those sources is "when did this process start". Reporting boot + * time is honest about that: a restart with new env is a new version, and a + * running process never silently mutates these. */ const BOOT_TIME = new Date().toISOString(); /** - * Content-hash version. A client that cached a previous body can compare this - * to decide whether its copy is still current without diffing the text. Deriving + * Content-hash version. A client that cached a previous body compares this to + * decide whether its copy is still current without diffing the text. Deriving * it from the exact bytes we return keeps it truthful: same payload, same * version; any change, a new one. */ @@ -61,37 +72,45 @@ function computeVersion(text: string): string { return 'sha256:' + createHash('sha256').update(text).digest('hex'); } -/** Resolve the OpenAPI document path, honoring an operator override. */ +/** + * Resolve the OpenAPI document path, honoring an operator override. + * + * `__dirname`, not `import.meta.url`: this project compiles with + * `module: commonjs`, under which `import.meta` is a TypeScript error. Two + * levels up reaches the repo root from both `src/mcp/` and `dist/mcp/`, since + * `rootDir` is `src` and `outDir` is `dist`. + */ function openapiPath(): string { - if (process.env.LENS_OPENAPI_PATH) return process.env.LENS_OPENAPI_PATH; - // Runs the same from src/mcp (tsx) and dist/mcp (build): the document lives at - // the package root, two directories up from this module. - return path.resolve(__dirname, '..', '..', 'openapi.json'); -} - -/** Serialize an object payload to the compact JSON that becomes the body. */ -function jsonText(data: unknown): string { - return JSON.stringify(data); + if (process.env.MCP_OPENAPI_PATH) return process.env.MCP_OPENAPI_PATH; + return join(__dirname, '..', '..', 'openapi.json'); } /** * The default set of Lens resources, derived only from `config` and the on-disk - * OpenAPI document — deliberately with no database dependency, so the stdio MCP - * server stays as lightweight as the payment client that hosts it. A process - * that *does* have the richer runtime state (a registry with hot-added pairs, a - * live health check) can pass its own descriptors to override these. + * OpenAPI document — deliberately with no database dependency, for the same + * reason {@link createLensPriceQueries} lives in its own module: a process that + * does not opt into Lens's own price data should not pay for importing `pg` and + * Prisma just to advertise what it watches. + * + * A host that does hold that state can pass its own descriptors to + * `BazaarMcpServer`, exactly as it passes `priceQueries`. */ -export function defaultResources(): LensResourceDescriptor[] { +export function defaultResources(options: LensResourceOptions = {}): LensResourceDescriptor[] { return [ { uri: 'lens://pairs', name: 'pairs', title: 'Watched pairs', - description: 'The asset pairs Lens actively quotes on the configured network.', + description: 'The asset pairs Lens quotes on the configured network.', mimeType: 'application/json', read: () => ({ data: { network: activeNetwork, + // The declared pair list for this network, not the process-wide + // runtime registry — the same choice `lens_get_price` makes, and for + // the same reason: pairs added through POST /pairs register against + // whichever network that API process runs, so reading the registry + // here could advertise a pair that only exists on another network. pairs: config.pairs.map((p) => ({ pairKey: p.pairKey, assetA: p.assetA, @@ -124,7 +143,7 @@ export function defaultResources(): LensResourceDescriptor[] { uri: 'lens://openapi.json', name: 'openapi', title: 'OpenAPI document', - description: 'The service OpenAPI 3 specification, served verbatim as JSON.', + description: "The service's OpenAPI 3 specification, served verbatim as JSON.", mimeType: 'application/json', read: () => { const file = openapiPath(); @@ -142,6 +161,8 @@ export function defaultResources(): LensResourceDescriptor[] { const generatedAt = new Date().toISOString(); return { data: { + serverName: options.serverName ?? 'lens-mcp-server', + serverVersion: options.serverVersion ?? '0.0.0-unknown', network: activeNetwork, generatedAt, horizonUrl: config.horizon.url, @@ -153,8 +174,8 @@ export function defaultResources(): LensResourceDescriptor[] { oracle: config.oracle.enabled, }, }, - // Status is defined by being current: every read is a new version, so a - // client knows it must not treat this as a long-lived cache. + // Status is defined by being current: every read is a new version, so + // a client knows it must not treat this as a long-lived cache. lastModified: generatedAt, }; }, @@ -165,7 +186,7 @@ export function defaultResources(): LensResourceDescriptor[] { /** Resolve a descriptor into the body plus version/freshness returned to clients. */ export function snapshotResource(descriptor: LensResourceDescriptor): LensResourceSnapshot { const { data, text: rawText, lastModified } = descriptor.read(); - const text = rawText ?? jsonText(data); + const text = rawText ?? JSON.stringify(data); return { uri: descriptor.uri, name: descriptor.name, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dc260cb6..0ab584e7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -8,8 +8,6 @@ import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; -import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode, classifyPaidCallError } from './codes.js'; import { SpendBudget } from './budget.js'; import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; @@ -20,18 +18,11 @@ import { zodToToolInputSchema, formatZodError, } from './toolSchemas.js'; +import { boundSerialized, readBodyCapped, type McpLimits, type ResultBudget } from './limits.js'; import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; -import { boundSerialized, readBodyCapped, type ResultBudget } from './limits.js'; import { defaultResources, snapshotResource, type LensResourceDescriptor } from './resources.js'; - -/** Size bounds applied to every tool result, and the pages asked of a node. */ -export interface McpLimits { - maxResultBytes: number; - maxUpstreamBytes: number; - defaultPageLimit: number; - maxPageLimit: number; import { readFileSync } from 'fs'; import { join } from 'path'; @@ -108,13 +99,18 @@ export interface McpServerConfig { * tests can drive DNS resolution and the transport without real sockets. */ bazaarFetch?: Partial; - /** Overrides the `MCP_*` env bounds — primarily for tests. */ + /** + * Bounds on what a tool result may cost the agent in context (#416), from the + * `MCP_*` env by default. Overridable so a test can drive the limits without + * touching process state. + */ limits?: Partial; /** - * The read-only resources this server exposes. Defaults to the stable, - * cacheable Lens facts derived from config and the on-disk OpenAPI document. - * Pass a custom set to serve richer runtime state (a hot pair registry, a live - * health check) without giving the stdio server a database dependency. + * The read-only resources this server advertises. Defaults to the stable + * Lens facts derived from `config` and the on-disk OpenAPI document; pass a + * custom set to serve richer runtime state (a live health check, the runtime + * pair registry) without giving the stdio server a database dependency — the + * same split `priceQueries` makes for {@link LENS_PRICE_TOOL_NAME}. */ resources?: LensResourceDescriptor[]; } @@ -122,21 +118,18 @@ export interface McpServerConfig { export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; - private bazaarFetchOptions: SsrfFetchOptions; - private limits: McpLimits; - private resources: LensResourceDescriptor[]; - - constructor(config_: McpServerConfig) { - this.fetchWithPayment = config_.fetchWithPayment; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; private priceQueries?: LensPriceQueries; private budget?: SpendBudget; + private limits: McpLimits; + private resources: LensResourceDescriptor[]; constructor(config_: McpServerConfig) { this.fetchWithPayment = config_.fetchWithPayment; this.priceQueries = config_.priceQueries; this.budget = config_.budget; + this.limits = { ...config.mcp, ...config_.limits }; // Two independent gates, deliberately separate: this one decides which // hosts may be PAID (#404), the one below decides which addresses may be @@ -153,8 +146,8 @@ export class BazaarMcpServer { fetchImpl: (input, init) => fetchWithTimeout(input, init), ...config_.bazaarFetch, }; - this.limits = { ...config.mcp, ...config_.limits }; - this.resources = config_.resources ?? defaultResources(); + this.resources = + config_.resources ?? defaultResources({ serverName: SERVER_NAME, serverVersion: SERVER_VERSION }); this.server = new Server( { name: SERVER_NAME, @@ -177,49 +170,8 @@ export class BazaarMcpServer { { name: 'bazaar_search', description: - 'Search for resources in a Stellar Bazaar node. Results are a bounded page: ' + - 'use limit and offset to ask for more, and check "truncated" / "total" in the response.', - inputSchema: { - type: 'object', - properties: { - url: { - type: 'string', - description: 'The base URL of the Bazaar API', - }, - query: { - type: 'string', - description: 'Optional search query', - }, - limit: { - type: 'number', - description: `Maximum number of resources to return (default ${config.mcp.defaultPageLimit}, capped at ${config.mcp.maxPageLimit})`, - }, - offset: { - type: 'number', - description: 'Number of resources to skip, to page past a truncated result (default 0)', - }, - }, - required: ['url'], - }, - }, - { - name: 'paid_call', - description: 'Make a paid API call to a Bazaar resource, handling the 402 loop automatically. Oversized responses are truncated; the result says so.', - inputSchema: { - type: 'object', - properties: { - url: { - type: 'string', - description: 'The full URL of the resource to fetch', - }, - method: { - type: 'string', - description: 'HTTP method (default: GET)', - }, - }, - required: ['url'], - }, - description: 'Search for resources in a Stellar Bazaar node', + 'Search for resources in a Stellar Bazaar node. The result is a bounded page: ' + + 'check "truncated" and "total", and re-ask with limit and offset to page through the rest.', inputSchema: zodToToolInputSchema(BAZAAR_SEARCH_ARGUMENTS_SCHEMA), }, { @@ -258,6 +210,9 @@ export class BazaarMcpServer { return { tools }; }); + // Listing resolves each resource rather than returning a static catalogue, + // so the freshness it reports is real. These payloads are small and cheap + // to produce; a stale or invented `lastModified` would be worse than none. this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: this.resources.map((descriptor) => { const snapshot = snapshotResource(descriptor); @@ -281,6 +236,11 @@ export class BazaarMcpServer { this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; const descriptor = this.resources.find((d) => d.uri === uri); + // A refusal to read is a protocol error, not a tool result: unlike a tool + // call, `ReadResourceResult` has no `isError` shape, so a payload faking + // one would be misread by clients. This is not a third entry in the + // MCP_ERROR_CODES vocabulary — that set answers "why did the money or the + // fetch fail", which is a different question from "no such resource". if (!descriptor) { throw new McpError( ErrorCode.InvalidParams, @@ -330,11 +290,19 @@ export class BazaarMcpServer { } // structuredContent is what the agent reads; the text block keeps the - // same payload visible to clients that only render content. - return { - content: [{ type: 'text', text: JSON.stringify(outcome.result, null, 2) }], - structuredContent: outcome.result, - }; + // same payload visible to clients that only render content. When the + // payload does not fit the result limit the text carries the label and + // structuredContent is dropped — shipping it whole would keep the very + // context blow-up #416 is about, just in a different field. + const bounded = boundSerialized(outcome.result, { + maxBytes: this.limits.maxResultBytes, + maxItems: Number.MAX_SAFE_INTEGER, + offset: 0, + notice: `A Lens price was truncated to fit a ${this.limits.maxResultBytes}-byte tool output limit; request fewer venues or a narrower pair.`, + }); + return bounded.truncated + ? { content: [{ type: 'text', text: bounded.text }] } + : { content: [{ type: 'text', text: bounded.text }], structuredContent: outcome.result }; } catch (e: any) { // Prices come from Postgres and Horizon, so a throw here is the data // layer being unreachable — never an answer about the pair. @@ -343,34 +311,24 @@ export class BazaarMcpServer { } if (request.params.name === 'bazaar_search') { - const url = String(request.params.arguments?.url); - const args = request.params.arguments ?? {}; - const query = args.query ? String(args.query) : undefined; - const { limit, offset } = this.parsePage(args); + const parsed = BAZAAR_SEARCH_ARGUMENTS_SCHEMA.safeParse(request.params.arguments ?? {}); + if (!parsed.success) { + return this.createErrorResponse('ERR_BAD_REQUEST', formatZodError(parsed.error)); + } + + const { url, query } = parsed.data; + const page = this.resolvePage(parsed.data); try { const search = new URLSearchParams(); if (query) search.set('q', query); - search.set('limit', String(limit)); - search.set('offset', String(offset)); + search.set('limit', String(page.limit)); + search.set('offset', String(page.offset)); const endpoint = query ? `${url.replace(/\/$/, '')}/discovery/search?${search}` : `${url.replace(/\/$/, '')}/discovery/resources?${search}`; - const res = await ssrfSafeFetch(endpoint, {}, this.bazaarFetchOptions); - const parsed = BAZAAR_SEARCH_ARGUMENTS_SCHEMA.safeParse(request.params.arguments ?? {}); - if (!parsed.success) { - return this.createErrorResponse('ERR_BAD_REQUEST', formatZodError(parsed.error)); - } - - const { url, query } = parsed.data; - - try { - const endpoint = query - ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` - : `${url.replace(/\/$/, '')}/discovery/resources`; - const upstream = new URL(endpoint).host; const res = await ssrfSafeFetch( endpoint, @@ -382,14 +340,6 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${upstream} returned HTTP ${res.status}`); } - // Cap the read, not just the reply: a node that streams megabytes is - // never buffered here in full. - const body = await readBodyCapped(res, this.limits.maxUpstreamBytes); - if (body.exceeded) { - return this.createErrorResponse( - 'ERR_RESPONSE_TOO_LARGE', - `Node sent more than ${this.limits.maxUpstreamBytes} bytes, which cannot be parsed as one document. ` + - `Retry with a smaller limit (currently ${limit}) or a larger offset.` const contentType = res.headers.get('content-type') ?? ''; if (!/\bjson\b/i.test(contentType)) { return this.createErrorResponse( @@ -398,15 +348,21 @@ export class BazaarMcpServer { ); } + // Cap the read, not only the reply: `res.json()` would buffer whatever a + // node chose to send before we got a say in the size. + const body = await readBodyCapped(res, this.limits.maxUpstreamBytes); + if (body.exceeded) { + return this.createErrorResponse( + 'ERR_RESPONSE_TOO_LARGE', + `Upstream ${upstream} sent more than ${this.limits.maxUpstreamBytes} bytes, which cannot be read as one document. ` + + `Retry with a smaller limit (this page asked for ${page.limit}) or a larger offset.`, + { maxUpstreamBytes: this.limits.maxUpstreamBytes, limit: page.limit, offset: page.offset }, + ); + } + let data: unknown; try { data = JSON.parse(body.text); - } catch { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Service returned a response that is not valid JSON'); - } - - const hasResources = (d: unknown): d is { resources: unknown[] } => - data = await res.json(); } catch { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${upstream} returned a body that is not valid JSON`); } @@ -420,18 +376,17 @@ export class BazaarMcpServer { return this.createBoundedResult(data, { maxBytes: this.limits.maxResultBytes, - maxItems: limit, - offset, + maxItems: page.limit, + offset: page.offset, notice: `Result truncated to fit a ${this.limits.maxResultBytes}-byte tool output limit, ` + - `of which at most ${limit} items fit on one page. ` + + `of which at most ${page.limit} items fit on one page. ` + 'Ask for the rest with the limit and offset arguments.', }); } catch (e: any) { if (e instanceof SsrfError) { return this.createErrorResponse('ERR_URL_BLOCKED', `Blocked request: ${e.message}`); } - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); if (isTimeoutError(e)) { return this.createErrorResponse( 'ERR_SERVICE_UNREACHABLE', @@ -446,18 +401,15 @@ export class BazaarMcpServer { if (!this.budget) { return this.createErrorResponse('ERR_BAD_REQUEST', 'This server has no spend budget configured.'); } - return { - content: [{ type: 'text', text: JSON.stringify(this.budget.snapshot(), null, 2) }], - }; + return this.createBoundedResult(this.budget.snapshot(), { + maxBytes: this.limits.maxResultBytes, + maxItems: Number.MAX_SAFE_INTEGER, + offset: 0, + notice: `The spend budget was truncated to fit a ${this.limits.maxResultBytes}-byte tool output limit.`, + }); } if (request.params.name === 'paid_call') { - const url = String(request.params.arguments?.url); - const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - - try { - const res = await this.fetchWithPayment(url, { method }); - const parsed = PAID_CALL_ARGUMENTS_SCHEMA.safeParse(request.params.arguments ?? {}); if (!parsed.success) { return this.createErrorResponse('ERR_BAD_REQUEST', formatZodError(parsed.error)); @@ -492,12 +444,13 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); } + this.settleReservation(reservationId, true); const body = await readBodyCapped(res, this.limits.maxUpstreamBytes); const budget: ResultBudget = { maxBytes: this.limits.maxResultBytes, - // A paid response has no pagination contract, so paging a collection - // would hide items with no way to ask for the next one; only the + // paid_call has no pagination contract, so paging a collection would + // hide items with no way to ask for the next page; here only the // byte ceiling applies. maxItems: Number.MAX_SAFE_INTEGER, offset: 0, @@ -509,14 +462,13 @@ export class BazaarMcpServer { 'Ask the resource for a smaller response or a paginated endpoint.', }; - this.settleReservation(reservationId, true); - const text = await res.text(); let parsed; try { parsed = JSON.parse(body.text); } catch { - // Genuinely non-JSON (or a cut-off body, which cannot parse): hand - // back the raw text, bounded and labelled as partial. + // Also the case of a body cut off mid-read, which cannot parse: + // knownTruncated labels the fragment instead of passing it off as an + // answer. parsed = body.text; } @@ -531,26 +483,21 @@ export class BazaarMcpServer { }); } - /** Clamp a caller-requested page; mirrors the node's own discovery limits. */ - private parsePage(args: Record | undefined): { limit: number; offset: number } { - if (!args || typeof args !== 'object') return { limit: this.limits.defaultPageLimit, offset: 0 }; - - const rawLimit = Number(args.limit); + /** + * Clamp a caller-requested page to the operator's bounds. Zod has already + * rejected non-positive or non-integer values; this is the ceiling an agent + * may not argue past, mirroring the node's own discovery limits. + */ + private resolvePage(requested: { limit?: number; offset?: number }): { limit: number; offset: number } { const limit = - Number.isFinite(rawLimit) && rawLimit > 0 - ? Math.min(Math.floor(rawLimit), this.limits.maxPageLimit) - : this.limits.defaultPageLimit; - - const rawOffset = Number(args.offset); - const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; - - return { limit, offset }; + requested.limit !== undefined ? Math.min(requested.limit, this.limits.maxPageLimit) : this.limits.defaultPageLimit; + return { limit, offset: requested.offset ?? 0 }; } /** - * Serialize a successful result compactly (indentation is for humans and - * costs the agent context) and within `budget`, so no upstream response — - * large or hostile — can fill the context window in one call. Truncation is + * Serialize a successful result compactly — indentation is for humans and + * costs the agent context — and within `budget`, so no upstream response, + * large or hostile, can fill a context window in one call. Truncation is * stated in the payload rather than left to be inferred. */ private createBoundedResult(data: unknown, budget: ResultBudget) { @@ -560,7 +507,6 @@ export class BazaarMcpServer { }; } - private handlePaidCallError(e: any) { /** * Turn a thrown paid-call error into an MCP error response. * @@ -621,7 +567,6 @@ export class BazaarMcpServer { ); } - private createErrorResponse(code: McpErrorCode, reason: string) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { diff --git a/src/mcp/toolSchemas.ts b/src/mcp/toolSchemas.ts index 4673b0b7..89c02cd0 100644 --- a/src/mcp/toolSchemas.ts +++ b/src/mcp/toolSchemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { config } from '../config.js'; /** * Supported HTTP methods for the `paid_call` tool. @@ -24,6 +25,23 @@ export const BAZAAR_SEARCH_ARGUMENTS_SCHEMA = z.object({ .min(1, 'url cannot be empty') .describe('The base URL of the Bazaar API'), query: z.string().optional().describe('Optional search query'), + // #416: results are a bounded page, so paging has to be something the agent + // can ask for. The ceiling is operator configuration and is enforced again on + // the server side — an ask over it is clamped, not honoured. + limit: z + .number() + .int() + .positive() + .optional() + .describe( + `Maximum number of resources to return (default ${config.mcp.defaultPageLimit}, capped at ${config.mcp.maxPageLimit})`, + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of resources to skip, to page past a truncated result (default 0)'), }); export type BazaarSearchArguments = z.infer; diff --git a/tests/mcp-bounded-results.test.ts b/tests/mcp-bounded-results.test.ts new file mode 100644 index 00000000..c68fdd9e --- /dev/null +++ b/tests/mcp-bounded-results.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, vi } from 'vitest' +import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { BazaarMcpServer } from '../src/mcp/server' + +/** + * #416 — a tool result is written straight into an agent's context window, so an + * unbounded one is self-inflicted denial of service whose failure gets blamed on + * the model. These tests pin the four things that make a result safe to hand + * back: a byte ceiling, an explicit truncation label, no pretty-printing, and a + * bounded page plus a count wherever paging is something the agent can ask for. + * + * They also pin the second half of the fix, which is the part that cannot be + * undone by a client: the body read from upstream is capped while streaming, so + * an oversized response is never buffered in full here. + */ + +function captureHandler(server: BazaarMcpServer) { + let handler: any = null + ;(server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn + } + ;(server as any).setupHandlers() + return handler as (request: { params: { name: string; arguments?: Record } }) => Promise +} + +const publicLookup = { lookup: async () => ['93.184.216.34'] } + +function searchHandler(fetchImpl: (input: string | URL) => Promise, limits?: Record) { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl }, + ...(limits ? { limits } : {}), + }) + return captureHandler(server) +} + +/** A node that answers with JSON, as #480's content-type gate requires. */ +const jsonFetch = (payload: unknown) => async () => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + +const textOf = (response: any) => response.content[0].text + +describe('bazaar_search bounded results (#416)', () => { + it('pages a collection that exceeds the item limit and says so', async () => { + const resources = Array.from({ length: 500 }, (_, i) => ({ resource: { url: `https://r${i}.example` } })) + const handler = searchHandler(jsonFetch({ resources, total: resources.length })) + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }) + + expect(response.isError).toBeFalsy() + const text = textOf(response) + const parsed = JSON.parse(text) + expect(parsed.resources.length).toBeLessThanOrEqual(50) + expect(parsed.truncated).toBe(true) + expect(parsed.total).toBe(500) + expect(parsed.offset).toBe(0) + expect(parsed.notice).toMatch(/limit and offset/) + // Indentation is for humans; it costs the agent context for no benefit. + expect(text).not.toContain('\n ') + }) + + it('asks the node for the page it was given, and echoes it back', async () => { + const requested: string[] = [] + const resources = Array.from({ length: 30 }, (_, i) => ({ resource: { url: `https://r${i}.example` } })) + const handler = searchHandler(async (input: string | URL) => { + requested.push(String(input)) + return new Response(JSON.stringify({ resources }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + const response = await handler({ + params: { + name: 'bazaar_search', + arguments: { url: 'https://ok.example', query: 'xlm', limit: 7, offset: 14 }, + }, + }) + + expect(requested[0]).toContain('/discovery/search?') + expect(requested[0]).toContain('q=xlm') + expect(requested[0]).toContain('limit=7') + expect(requested[0]).toContain('offset=14') + + const parsed = JSON.parse(textOf(response)) + expect(parsed.resources.length).toBe(7) + expect(parsed.total).toBe(30) + expect(parsed.offset).toBe(14) + expect(parsed.truncated).toBe(true) + }) + + it('clamps a page an agent asks for beyond the operator ceiling', async () => { + const requested: string[] = [] + const handler = searchHandler(async (input: string | URL) => { + requested.push(String(input)) + return new Response(JSON.stringify({ resources: [{ resource: { url: 'https://a.example' } }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, { maxPageLimit: 20 }) + + await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example', limit: 5000 } } }) + + expect(requested[0]).toContain('limit=20') + }) + + it('keeps a response within the configured byte limit', async () => { + const resources = Array.from({ length: 1000 }, (_, i) => ({ + resource: { url: `https://example.com/${'y'.repeat(200)}${i}` }, + })) + const handler = searchHandler(jsonFetch({ resources }), { + maxResultBytes: 4096, + maxUpstreamBytes: 1_048_576, + }) + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }) + + const text = textOf(response) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(4096) + const parsed = JSON.parse(text) + expect(parsed.truncated).toBe(true) + expect(parsed.total).toBe(1000) + expect(parsed.resources.length).toBeGreaterThan(0) + expect(parsed.resources.length).toBeLessThan(1000) + }) + + it('truncates a payload with no pageable collection', async () => { + const handler = searchHandler(jsonFetch({ blob: 'z'.repeat(10_000) }), { maxResultBytes: 2048 }) + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }) + + const text = textOf(response) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2048) + const parsed = JSON.parse(text) + expect(parsed.truncated).toBe(true) + expect(parsed.notice).toMatch(/limit and offset/) + expect(typeof parsed.partial).toBe('string') + expect(parsed.partial.length).toBeGreaterThan(0) + }) + + it('does not buffer an oversized upstream body in full', async () => { + const totalChunks = 64 + const chunk = new TextEncoder().encode('x'.repeat(4096)) + let produced = 0 + let cancelled = false + const stream = new ReadableStream({ + pull(controller) { + if (produced >= totalChunks) { + controller.close() + return + } + produced++ + controller.enqueue(chunk) + }, + cancel() { + cancelled = true + }, + }) + + const handler = searchHandler( + async () => + new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }), + { maxUpstreamBytes: 8192, maxResultBytes: 65_536 }, + ) + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }) + + expect(response.isError).toBe(true) + const payload = JSON.parse(textOf(response)) + expect(payload.code).toBe('ERR_RESPONSE_TOO_LARGE') + expect(payload.reason).toMatch(/smaller limit/) + expect(payload.details.maxUpstreamBytes).toBe(8192) + expect(produced).toBeLessThan(totalChunks) + expect(cancelled).toBe(true) + }) + + it('rejects a page size that is not a positive integer before fetching', async () => { + const fetchImpl = vi.fn(jsonFetch({ resources: [{ resource: { url: 'https://a.example' } }] })) + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl }, + }) + const handler = captureHandler(server) + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example', limit: 0 } } }) + + expect(response.isError).toBe(true) + expect(JSON.parse(textOf(response)).code).toBe('ERR_BAD_REQUEST') + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('leaves a small response untouched', async () => { + const handler = searchHandler(jsonFetch({ resources: [{ resource: { url: 'https://a.example' } }] })) + + const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }) + + expect(response.isError).toBeFalsy() + const parsed = JSON.parse(textOf(response)) + expect(parsed.truncated).toBeUndefined() + expect(parsed.resources[0].resource.url).toBe('https://a.example') + }) +}) + +describe('lens_get_price bounded results (#416)', () => { + const TESTNET_USDC = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + + function priceHandler(limits?: Record, recommendation = 'AMM offers a better rate') { + const priceQueries: any = { + activeNetwork: 'testnet', + watchedPairs: () => [ + { + assetA: { code: 'USDC', issuer: TESTNET_USDC }, + assetB: { code: 'XLM', issuer: null }, + pairKey: `USDC:${TESTNET_USDC}/XLM`, + }, + ], + aggregatedPrice: async () => ({ + price: 0.185, + sdexPrice: 0.184, + ammPrice: 0.186, + volume24h: 1200, + sdexVolume24h: 900, + ammVolume24h: 300, + vwap1m: 0.1851, + vwap5m: 0.1852, + vwap1h: 0.185, + vwap24h: 0.184, + priceChange24h: -1.2, + sources: 3, + confidence: 'high', + lastTradeAgeSeconds: 12, + stale: false, + }), + bestRoute: async () => ({ + route: 'AMM', + sdexPrice: 0.184, + ammPrice: 0.186, + estimatedOutput: 184, + slippagePct: 0, + recommendation, + }), + } + + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + priceQueries, + ...(limits ? { limits } : {}), + }) + return captureHandler(server) + } + + const args = { + assetA: `USDC:${TESTNET_USDC}`, + assetB: 'XLM', + } + + it('keeps structuredContent while the result fits, and does not pad it', async () => { + const handler = priceHandler() + + const response = await handler({ params: { name: 'lens_get_price', arguments: args } }) + + expect(response.structuredContent).toBeTruthy() + expect(textOf(response)).toBe(JSON.stringify(response.structuredContent)) + expect(textOf(response)).not.toContain('\n ') + }) + + it('drops structuredContent when the payload cannot fit, and labels the text', async () => { + const handler = priceHandler({ maxResultBytes: 1024 }, 'r'.repeat(4000)) + + const response = await handler({ params: { name: 'lens_get_price', arguments: args } }) + + const text = textOf(response) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(1024) + expect(response.structuredContent).toBeUndefined() + expect(JSON.parse(text).truncated).toBe(true) + }) +}) + +describe('paid_call bounded results (#416)', () => { + const allowlist = ['example.com'] + + function paidHandler(response: Response, limits?: Record) { + const fetchWithPayment = vi.fn().mockResolvedValue(response) + const server = new BazaarMcpServer({ + fetchWithPayment, + paidCallAllowlist: allowlist, + ...(limits ? { limits } : {}), + }) + return { handler: captureHandler(server), fetchWithPayment } + } + + it('bounds a collection result and reports the truncation', async () => { + const { handler } = paidHandler( + new Response(JSON.stringify({ rows: Array.from({ length: 500 }, (_, i) => i) }), { status: 200 }), + { maxResultBytes: 1024 }, + ) + + const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }) + + expect(response.isError).toBeFalsy() + const text = textOf(response) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(1024) + const parsed = JSON.parse(text) + expect(parsed.truncated).toBe(true) + // paid_call has no pagination contract, so items are cut by bytes only and + // the result says what to do about it instead of pretending to be a page. + expect(parsed.notice).toMatch(/paginated endpoint/) + }) + + it('flags a body that exceeds the read cap as incomplete', async () => { + const big = 'a'.repeat(5000) + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(big)) + controller.enqueue(new TextEncoder().encode(big)) + controller.close() + }, + }) + const { handler } = paidHandler(new Response(stream, { status: 200 }), { + maxUpstreamBytes: 4096, + maxResultBytes: 2048, + }) + + const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }) + + const text = textOf(response) + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2048) + const parsed = JSON.parse(text) + expect(parsed.truncated).toBe(true) + expect(parsed.notice).toMatch(/incomplete/) + expect(parsed.partial).toContain('aaaa') + }) + + it('passes a plain-text answer through as plain text', async () => { + const { handler } = paidHandler(new Response('the price is 0.185', { status: 200 })) + + const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }) + + expect(textOf(response)).toBe('the price is 0.185') + }) + + it('bounds a result that stays under the cap without labelling it', async () => { + const payload = { ok: true, value: 1 } + const { handler } = paidHandler(new Response(JSON.stringify(payload), { status: 200 })) + + const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }) + + expect(textOf(response)).toBe(JSON.stringify(payload)) + }) +}) diff --git a/tests/mcp-resources.test.ts b/tests/mcp-resources.test.ts index 7d2546cc..94a5583d 100644 --- a/tests/mcp-resources.test.ts +++ b/tests/mcp-resources.test.ts @@ -1,175 +1,221 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest' +import { BazaarMcpServer } from '../src/mcp/server' import { ListResourcesRequestSchema, ReadResourceRequestSchema, McpError, ErrorCode, -} from '@modelcontextprotocol/sdk/types.js'; -import { BazaarMcpServer } from '../src/mcp/server'; -import { defaultResources, snapshotResource } from '../src/mcp/resources'; - -/** Capture the resource handlers the way tests/mcp.test.ts captures tool handlers. */ -function captureHandlers(server: BazaarMcpServer) { - const handlers: Record = {}; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === ListResourcesRequestSchema) handlers.list = fn; - if (schema === ReadResourceRequestSchema) handlers.read = fn; - }; - (server as any).setupHandlers(); - return handlers; +} from '@modelcontextprotocol/sdk/types.js' +import { defaultResources, snapshotResource, type LensResourceDescriptor } from '../src/mcp/resources' + +/** + * #418 — the server advertised only tools, so an agent had to call one every + * turn to keep facts that barely change in context. These tests pin the other + * half of the capability: the resources are listed with stable URIs and honest + * freshness, a read returns the body, and a URI that does not exist is refused + * as a protocol error rather than a fabricated tool result. + */ + +function buildHarness(resources?: LensResourceDescriptor[]) { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + ...(resources ? { resources } : {}), + }) + + const handlers: Record = {} + ;(server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListResourcesRequestSchema) handlers.list = fn + if (schema === ReadResourceRequestSchema) handlers.read = fn + } + ;(server as any).setupHandlers() + + return { + server, + listResources: () => handlers.list(), + read: (uri: string) => handlers.read({ params: { uri } }), + } } -function serverWith(resources: any) { - return new BazaarMcpServer({ - fetchWithPayment: async () => new Response(), - resources, - }); +const FIXED = new Date('2026-01-01T00:00:00.000Z').toISOString() + +function stubResource(overrides: Partial = {}): LensResourceDescriptor { + return { + uri: 'lens://pairs', + name: 'pairs', + title: 'Watched pairs', + description: 'd', + mimeType: 'application/json', + read: () => ({ data: { network: 'testnet', pairs: [] }, lastModified: FIXED }), + ...overrides, + } } -describe('BazaarMcpServer MCP resources (#418)', () => { - const fixed = new Date('2026-01-01T00:00:00.000Z'); - - it('declares the resources capability', () => { - const server = serverWith(defaultResources()); - const caps = (server as any).server._capabilities ?? (server as any).server.serverCapabilities; - expect(caps).toHaveProperty('resources'); - expect(caps).toHaveProperty('tools'); - }); - - it('lists stable URIs, MIME types and freshness for every resource', async () => { - const { list } = captureHandlers( - serverWith([ - { - uri: 'lens://pairs', - name: 'pairs', - title: 'Watched pairs', - description: 'd', - mimeType: 'application/json', - read: () => ({ data: { pairs: ['XLM/USDC'] }, lastModified: fixed.toISOString() }), - }, - ]) - ); - - const { resources } = await list(); - expect(resources).toHaveLength(1); - const r = resources[0]; - expect(r.uri).toBe('lens://pairs'); - expect(r.mimeType).toBe('application/json'); - expect(r.annotations.lastModified).toBe(fixed.toISOString()); - expect(r._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/); - }); - - it('reads a resource, returning its JSON body and version', async () => { - const { read } = captureHandlers( - serverWith([ - { - uri: 'lens://pairs', - name: 'pairs', - title: 'Watched pairs', - description: 'd', - mimeType: 'application/json', - read: () => ({ data: { network: 'testnet', pairs: [] }, lastModified: fixed.toISOString() }), - }, - ]) - ); - - const result = await read({ params: { uri: 'lens://pairs' } }); - expect(result.contents).toHaveLength(1); - const c = result.contents[0]; - expect(c.uri).toBe('lens://pairs'); - expect(c.mimeType).toBe('application/json'); - expect(JSON.parse(c.text)).toEqual({ network: 'testnet', pairs: [] }); - expect(c._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/); - expect(c._meta.lastModified).toBe(fixed.toISOString()); - }); - - it('serves a text body verbatim (OpenAPI document)', async () => { - const doc = '{"openapi":"3.0.0","info":{"title":"Lens"}}'; - const { read } = captureHandlers( - serverWith([ - { - uri: 'lens://openapi.json', - name: 'openapi', - title: 'OpenAPI document', - description: 'd', - mimeType: 'application/json', - read: () => ({ text: doc, lastModified: fixed.toISOString() }), - }, - ]) - ); - - const result = await read({ params: { uri: 'lens://openapi.json' } }); - expect(result.contents[0].text).toBe(doc); - }); - - it('rejects an unknown URI with an InvalidParams JSON-RPC error', async () => { - const { read } = captureHandlers(serverWith(defaultResources())); - await expect(read({ params: { uri: 'lens://nope' } })).rejects.toMatchObject({ - code: ErrorCode.InvalidParams, - }); - await expect(read({ params: { uri: 'lens://nope' } })).rejects.toBeInstanceOf(McpError); - }); +describe('BazaarMcpServer resources capability (#418)', () => { + it('declares resources alongside tools', () => { + const { server } = buildHarness() + const capabilities = (server as any).server._capabilities + expect(capabilities).toHaveProperty('tools') + expect(capabilities).toHaveProperty('resources') + }) + + it('lists each resource with a stable URI, MIME type and freshness', async () => { + const { listResources } = buildHarness([stubResource()]) + const { resources } = await listResources() + + expect(resources).toHaveLength(1) + const [resource] = resources + expect(resource.uri).toBe('lens://pairs') + expect(resource.mimeType).toBe('application/json') + expect(resource.annotations.lastModified).toBe(FIXED) + expect(resource._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(resource._meta.lastModified).toBe(FIXED) + }) + + it('omits lastModified rather than inventing one when a source has no notion of change', async () => { + const { listResources, read } = buildHarness([ + stubResource({ read: () => ({ data: { constant: true } }) }), + ]) + + const [listed] = (await listResources()).resources + expect(listed.annotations).toBeUndefined() + expect(listed._meta.lastModified).toBeUndefined() + // The version is still reported: it is derived from content, not from time. + expect(listed._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/) + + const { contents } = await read('lens://pairs') + expect(contents[0]._meta.lastModified).toBeUndefined() + }) + + it('reads a resource and returns its JSON body with the version', async () => { + const { read } = buildHarness([ + stubResource({ read: () => ({ data: { network: 'testnet', pairs: ['XLM/USDC'] }, lastModified: FIXED }) }), + ]) + + const { contents } = await read('lens://pairs') + expect(contents).toHaveLength(1) + const [body] = contents + expect(body.uri).toBe('lens://pairs') + expect(body.mimeType).toBe('application/json') + expect(JSON.parse(body.text)).toEqual({ network: 'testnet', pairs: ['XLM/USDC'] }) + expect(body._meta.version).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(body._meta.lastModified).toBe(FIXED) + }) + + it('serves a text body verbatim instead of re-serializing it', async () => { + const document = '{"openapi":"3.0.0","info":{"title":"Lens"}}' + const { read } = buildHarness([ + stubResource({ uri: 'lens://openapi.json', name: 'openapi', read: () => ({ text: document, lastModified: FIXED }) }), + ]) + + const { contents } = await read('lens://openapi.json') + expect(contents[0].text).toBe(document) + }) +}) + +describe('BazaarMcpServer resource refusals (#418)', () => { + it('rejects an unknown URI as an InvalidParams protocol error', async () => { + const { read } = buildHarness() + await expect(read('lens://nope')).rejects.toBeInstanceOf(McpError) + await expect(read('lens://nope')).rejects.toMatchObject({ code: ErrorCode.InvalidParams }) + }) + + it('does not dress an unknown URI up as a readable resource', async () => { + const { read } = buildHarness() + // A faked `{ content, isError }` tool result would parse as a successful + // read here, so the refusal has to travel as an exception instead. + await expect(read('lens://missing')).rejects.toThrow(/Unknown resource/) + }) it('reports an internal error when a resource source fails', async () => { - const { read } = captureHandlers( - serverWith([ - { - uri: 'lens://status', - name: 'status', - title: 'Service status', - description: 'd', - mimeType: 'application/json', - read: () => { - throw new Error('health check timed out'); - }, + const { read } = buildHarness([ + stubResource({ + uri: 'lens://status', + name: 'status', + read: () => { + throw new Error('health check timed out') }, - ]) - ); + }), + ]) - await expect(read({ params: { uri: 'lens://status' } })).rejects.toMatchObject({ + await expect(read('lens://status')).rejects.toMatchObject({ code: ErrorCode.InternalError, message: expect.stringContaining('health check timed out'), - }); - }); - - it('exposes at least the pair list and the OpenAPI document by default', () => { - const uris = defaultResources().map((r) => r.uri); - expect(uris).toContain('lens://pairs'); - expect(uris).toContain('lens://openapi.json'); - expect(uris).toContain('lens://venues'); - expect(uris).toContain('lens://status'); - }); - - it('produces a stable version for identical content and a new one when it changes', () => { - const mk = (pairs: string[]) => ({ + }) + }) +}) + +describe('MCP default resources (#418)', () => { + it('exposes the pair list and the OpenAPI document, among others', () => { + const uris = defaultResources().map((r) => r.uri) + expect(uris).toContain('lens://pairs') + expect(uris).toContain('lens://openapi.json') + expect(uris).toContain('lens://venues') + expect(uris).toContain('lens://status') + }) + + it('names every resource uniquely, so a handle cannot be ambiguous', () => { + const descriptors = defaultResources() + const names = descriptors.map((d) => d.name) + expect(new Set(names).size).toBe(names.length) + expect(new Set(descriptors.map((d) => d.uri)).size).toBe(descriptors.length) + }) + + it('advertises the server version passed in, not a hard-coded one', () => { + const snapshot = snapshotResource( + defaultResources({ serverVersion: '9.9.9-test' }).find((d) => d.uri === 'lens://status')! + ) + expect(JSON.parse(snapshot.text).serverVersion).toBe('9.9.9-test') + }) + + it('reports a stable version for identical content and a new one once it changes', () => { + const forPairs = (keys: string[]): LensResourceDescriptor => ({ uri: 'lens://x', name: 'x', title: 'x', description: 'x', mimeType: 'application/json', - read: () => ({ data: { pairs } }), - }); - const a = snapshotResource(mk(['XLM/USDC'])).version; - const b = snapshotResource(mk(['XLM/USDC'])).version; - const c = snapshotResource(mk(['BTC/USDC'])).version; - expect(a).toBe(b); - expect(a).not.toBe(c); - }); - - it('reads the real OpenAPI document from disk with an mtime-based version', async () => { - const { read } = captureHandlers(serverWith(defaultResources())); - const result = await read({ params: { uri: 'lens://openapi.json' } }); - const parsed = JSON.parse(result.contents[0].text); - expect(parsed).toHaveProperty('openapi'); - expect(parsed).toHaveProperty('paths'); - expect(new Date(result.contents[0]._meta.lastModified).toString()).not.toBe('Invalid Date'); - }); - - it('treats status as never-cacheable: each read is a new version', async () => { - const { read } = captureHandlers(serverWith(defaultResources())); - const v1 = (await read({ params: { uri: 'lens://status' } })).contents[0]._meta.version; - await new Promise((r) => setTimeout(r, 5)); - const v2 = (await read({ params: { uri: 'lens://status' } })).contents[0]._meta.version; - expect(v1).not.toBe(v2); - }); -}); + read: () => ({ data: { pairs: keys } }), + }) + const once = snapshotResource(forPairs(['XLM/USDC'])).version + const same = snapshotResource(forPairs(['XLM/USDC'])).version + const changed = snapshotResource(forPairs(['BTC/USDC'])).version + expect(once).toBe(same) + expect(once).not.toBe(changed) + }) + + it('reads the real OpenAPI document from disk, stamped with its file mtime', async () => { + const { read } = buildHarness() + const { contents } = await read('lens://openapi.json') + const document = JSON.parse(contents[0].text) + + expect(document).toHaveProperty('openapi') + expect(document).toHaveProperty('paths') + expect(Number.isNaN(Date.parse(contents[0]._meta.lastModified))).toBe(false) + }) + + it('treats status as never cacheable: every read is a new version', async () => { + const { read } = buildHarness() + const first = (await read('lens://status')).contents[0]._meta.version + await new Promise((resolve) => setTimeout(resolve, 5)) + const second = (await read('lens://status')).contents[0]._meta.version + expect(first).not.toBe(second) + }) + + it('keeps the pair and venue lists cacheable: repeat reads agree', async () => { + const { read } = buildHarness() + for (const uri of ['lens://pairs', 'lens://venues']) { + const first = (await read(uri)).contents[0] + const second = (await read(uri)).contents[0] + expect(first._meta.version).toBe(second._meta.version) + expect(first._meta.lastModified).toBe(second._meta.lastModified) + } + }) + + it('describes the watched pairs on the active network', async () => { + const { read } = buildHarness() + const { contents } = await read('lens://pairs') + const body = JSON.parse(contents[0].text) + expect(Array.isArray(body.pairs)).toBe(true) + expect(['testnet', 'mainnet']).toContain(body.network) + }) +}) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 0ba184ec..5a029272 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -149,7 +149,6 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { const publicLookup = { lookup: async () => ['93.184.216.34'] }; const okFetchImpl = async () => - new Response(JSON.stringify({ resources: [{ name: 'x' }] }), { status: 200 }); new Response(JSON.stringify({ resources: [{ name: 'x' }] }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -205,8 +204,9 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); expect(response.isError).toBe(true); expect(JSON.parse(response.content[0].text).code).toBe('ERR_URL_BLOCKED'); + // #416: the page the server asks the node for is bounded by default, so the + // first hop carries limit/offset. expect(calls).toEqual(['https://ok.example/discovery/resources?limit=50&offset=0']); - expect(calls).toEqual(['https://ok.example/discovery/resources']); }); it('returns results from a permitted public host', async () => { @@ -221,191 +221,6 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { }); }); -describe('BazaarMcpServer bounded tool results (#416)', () => { - function captureHandler(server: any) { - let handler: any = null; - server.server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) handler = fn; - }; - server.setupHandlers(); - return handler; - } - - const publicLookup = { lookup: async () => ['93.184.216.34'] }; - - function searchServer(fetchImpl: any, limits?: any) { - const server = new BazaarMcpServer({ - fetchWithPayment: vi.fn(), - bazaarFetch: { allowedHosts: [], ...publicLookup, fetchImpl }, - ...(limits ? { limits } : {}), - }); - return { server, handler: captureHandler(server) }; - } - - const jsonFetch = (payload: unknown) => async () => - new Response(JSON.stringify(payload), { status: 200 }); - - it('pages a collection that exceeds the item limit and says so', async () => { - const resources = Array.from({ length: 500 }, (_, i) => ({ resource: { url: `https://r${i}.example` } })); - const { handler } = searchServer(jsonFetch({ resources, total: resources.length })); - - const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); - - expect(response.isError).toBeFalsy(); - const text = response.content[0].text; - const parsed = JSON.parse(text); - expect(parsed.resources.length).toBeLessThanOrEqual(50); - expect(parsed.truncated).toBe(true); - expect(parsed.total).toBe(500); - expect(parsed.offset).toBe(0); - expect(parsed.notice).toMatch(/limit and offset/); - // Indentation is for humans; it costs the agent context for no benefit. - expect(text).not.toContain('\n '); - }); - - it('honours limit and offset arguments and asks the node for the same page', async () => { - const requested: string[] = []; - const resources = Array.from({ length: 30 }, (_, i) => ({ resource: { url: `https://r${i}.example` } })); - const { handler } = searchServer(async (input: string | URL) => { - requested.push(String(input)); - return new Response(JSON.stringify({ resources }), { status: 200 }); - }); - - const response = await handler({ - params: { name: 'bazaar_search', arguments: { url: 'https://ok.example', query: 'xlm', limit: 7, offset: 14 } }, - }); - - expect(requested[0]).toContain('/discovery/search?'); - expect(requested[0]).toContain('q=xlm'); - expect(requested[0]).toContain('limit=7'); - expect(requested[0]).toContain('offset=14'); - - const parsed = JSON.parse(response.content[0].text); - expect(parsed.resources.length).toBe(7); - expect(parsed.total).toBe(30); - expect(parsed.offset).toBe(14); - expect(parsed.truncated).toBe(true); - }); - - it('keeps a response within the configured byte limit', async () => { - const resources = Array.from({ length: 1000 }, (_, i) => ({ - resource: { url: `https://example.com/${'y'.repeat(200)}${i}` }, - })); - const { handler } = searchServer(jsonFetch({ resources }), { maxResultBytes: 4096, maxUpstreamBytes: 1_048_576 }); - - const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); - - const text = response.content[0].text; - expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(4096); - const parsed = JSON.parse(text); - expect(parsed.truncated).toBe(true); - expect(parsed.total).toBe(1000); - expect(parsed.resources.length).toBeLessThan(1000); - expect(parsed.resources.length).toBeGreaterThan(0); - }); - - it('truncates a payload with no pageable collection', async () => { - const { handler } = searchServer(jsonFetch({ blob: 'z'.repeat(10_000) }), { maxResultBytes: 2048 }); - - const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); - - const text = response.content[0].text; - expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2048); - const parsed = JSON.parse(text); - expect(parsed.truncated).toBe(true); - expect(parsed.notice).toMatch(/limit and offset/); - expect(typeof parsed.partial).toBe('string'); - expect(parsed.partial.length).toBeGreaterThan(0); - }); - - it('does not buffer an oversized upstream body in full', async () => { - const totalChunks = 64; - const chunk = new TextEncoder().encode('x'.repeat(4096)); - let produced = 0; - let cancelled = false; - const stream = new ReadableStream({ - pull(controller) { - if (produced >= totalChunks) { - controller.close(); - return; - } - produced++; - controller.enqueue(chunk); - }, - cancel() { - cancelled = true; - }, - }); - - const { handler } = searchServer(async () => new Response(stream, { status: 200 }), { - maxUpstreamBytes: 8192, - maxResultBytes: 65_536, - }); - - const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); - - expect(response.isError).toBe(true); - const payload = JSON.parse(response.content[0].text); - expect(payload.code).toBe('ERR_RESPONSE_TOO_LARGE'); - expect(payload.reason).toMatch(/limit/); - expect(produced).toBeLessThan(totalChunks); - expect(cancelled).toBe(true); - }); - - it('bounds a paid_call result and reports the truncation', async () => { - const mockFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ rows: Array.from({ length: 500 }, (_, i) => i) }), { status: 200 }) - ); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, limits: { maxResultBytes: 1024 } }); - const handler = captureHandler(server); - - const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }); - - expect(response.isError).toBeFalsy(); - const text = response.content[0].text; - expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(1024); - const parsed = JSON.parse(text); - expect(parsed.truncated).toBe(true); - expect(parsed.rows.length).toBeLessThan(500); - expect(parsed.total).toBe(500); - expect(parsed.notice).toMatch(/paginated endpoint/); - }); - - it('flags a paid_call body that exceeds the read cap', async () => { - const big = 'a'.repeat(5000); - const mockFetch = vi.fn().mockResolvedValue( - new Response(new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(big)); - controller.enqueue(new TextEncoder().encode(big)); - controller.close(); - }, - }), { status: 200 }) - ); - const server = new BazaarMcpServer({ - fetchWithPayment: mockFetch, - limits: { maxUpstreamBytes: 4096, maxResultBytes: 2048 }, - }); - const handler = captureHandler(server); - - const response = await handler({ params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } } }); - - const text = response.content[0].text; - expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(2048); - const parsed = JSON.parse(text); - expect(parsed.truncated).toBe(true); - expect(parsed.notice).toMatch(/incomplete/); - }); - - it('leaves a small response untouched', async () => { - const { handler } = searchServer(jsonFetch({ resources: [{ resource: { url: 'https://a.example' } }] })); - - const response = await handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); - - expect(response.isError).toBeFalsy(); - const parsed = JSON.parse(response.content[0].text); - expect(parsed.truncated).toBeUndefined(); - expect(parsed.resources[0].resource.url).toBe('https://a.example'); describe('BazaarMcpServer argument validation (#411)', () => { function captureHandlers(server: any) { let callHandler: any = null;