diff --git a/.changeset/mcp-bounded-transport-and-e2e-402.md b/.changeset/mcp-bounded-transport-and-e2e-402.md new file mode 100644 index 00000000..18c33e54 --- /dev/null +++ b/.changeset/mcp-bounded-transport-and-e2e-402.md @@ -0,0 +1,11 @@ +--- +"lens": minor +--- + +Enforce bounded tool results, streamable HTTP transport with authentication, and deterministic end-to-end 402 loop verification on Bazaar MCP server: +- Bounded tool results with configurable `maxResultBytes` limit (`MCP_MAX_RESULT_BYTES`), dropping pretty-printing to prevent agent context window inflation (#416). +- Explicit truncation notifications (`[TRUNCATED: Result exceeded limit...]`) and collections pagination for `bazaar_search` results (#416). +- Streaming reader (`readBoundedResponseBody`) aborts oversized upstream payloads upon reaching `maxUpstreamBytes` without buffering excessive bytes in memory (#416). +- Supported streamable HTTP transport alongside default stdio transport (`MCP_TRANSPORT=http`), requiring authentication via `MCP_API_KEY` with `401 Unauthorized` refusal on missing or invalid keys (#419). +- Documented stdio and HTTP transport execution in `README.md` (#419). +- End-to-end deterministic integration test verifying successful 402 payment loops against stubbed facilitators, asserting signed amount, destination address, and testnet network using in-memory ephemeral testnet keypairs (#423). diff --git a/README.md b/README.md index 13334c3b..877cff99 100644 --- a/README.md +++ b/README.md @@ -531,7 +531,41 @@ curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated | `ORACLE_PAYMENT_ADDRESS_TESTNET` / `ORACLE_PAYMENT_ADDRESS_MAINNET` | Per-network override for the address above | - | No | | `X402_FACILITATOR_URL` | x402 facilitator service URL | - | No | -*\*Required if enabling x402 payment gating.* +## MCP Server (Model Context Protocol) + +Lens provides a Model Context Protocol (MCP) server exposing tools for Stellar resource discovery and automated paid calls (`bazaar_search`, `paid_call`). + +### Running via Stdio (Default) + +For locally-launched agent processes (e.g. Claude Desktop, local coding agents, or CLI child processes): + +```bash +npm run mcp:server +``` + +Stdio is the default transport (`MCP_TRANSPORT=stdio`). It communicates over standard input/output streams. + +### Running via Streamable HTTP Transport + +For hosted Lens instances serving remote agents over the network, configure the HTTP transport and specify a mandatory authentication key: + +```bash +MCP_TRANSPORT=http MCP_PORT=3000 MCP_API_KEY=your-secret-key npm run mcp:server +``` + +> **Authentication Requirement**: Remote HTTP exposure strictly requires authentication via `Authorization: Bearer ` or `x-api-key: `. Unauthenticated requests are immediately refused with `401 Unauthorized` to protect funds and resources. + +### MCP Configuration + +| Variable | Description | Default | +|---|---|---| +| `MCP_TRANSPORT` | Transport protocol (`stdio` or `http`) | `stdio` | +| `MCP_PORT` | Port for HTTP transport | `3000` | +| `MCP_HOST` | Host address for HTTP transport | `127.0.0.1` | +| `MCP_API_KEY` | Mandatory API authentication key for HTTP transport | - | +| `MCP_MAX_RESULT_BYTES` | Maximum byte length per tool result before explicit truncation | `65536` (64 KB) | +| `MCP_MAX_UPSTREAM_BYTES` | Maximum upstream response stream buffering limit before stream cancel | `131072` (128 KB) | +| `MCP_MAX_PAYMENT_PRICE` | Maximum allowed price per single `paid_call` execution | `$1.00` | ## Stack - **Runtime:** Node.js 20 + TypeScript diff --git a/examples/mcp-server/run.ts b/examples/mcp-server/run.ts index ef9da239..66f5a317 100644 --- a/examples/mcp-server/run.ts +++ b/examples/mcp-server/run.ts @@ -40,12 +40,21 @@ async function main() { .map(entry => entry.trim()) .filter(Boolean); + const transport = process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio'; const server = new BazaarMcpServer({ fetchWithPayment, - paidCallAllowlist + paidCallAllowlist, + transport, + port: process.env.MCP_PORT ? Number(process.env.MCP_PORT) : 3000, + host: process.env.MCP_HOST || '127.0.0.1', + apiKey: process.env.MCP_API_KEY, }); - console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + if (transport === 'http') { + console.error(`[mcp-server] Starting Bazaar MCP server on HTTP (port ${process.env.MCP_PORT || 3000})...`); + } else { + console.error('[mcp-server] Starting Bazaar MCP server on stdio...'); + } console.error('[mcp-server] Agent configured for stellar:testnet and stellar:pubnet'); console.error( paidCallAllowlist.length > 0 diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 804b0791..071964b3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,14 +1,23 @@ +import http from 'http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { checkPaidCallTarget, parsePaidCallAllowlist, type PaidCallAllowRule } from './paidCallAllowlist.js'; import { fetchWithTimeout } from '../fetch.js'; -import { config } from '../config.js'; +import { config as LensConfig } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; export interface McpServerConfig { fetchWithPayment: typeof fetch; + transport?: 'stdio' | 'http'; + port?: number; + host?: string; + apiKey?: string; + maxResultBytes?: number; + maxUpstreamBytes?: number; + pageSize?: number; /** * Origins (or URL prefixes) `paid_call` may pay for. Operator configuration, * never a tool argument: an empty or absent list — the default — means @@ -23,30 +32,135 @@ export interface McpServerConfig { bazaarFetch?: Partial; } +/** + * Reads a response body up to `maxBytes` without buffering excessive upstream data in memory. + * If the upstream body exceeds `maxBytes`, stream reading is cancelled immediately to prevent OOM. + */ +export async function readBoundedResponseBody( + res: Response, + maxBytes: number +): Promise<{ text: string; truncated: boolean }> { + if (res.body && typeof (res.body as any).getReader === 'function') { + const reader = (res.body as ReadableStream).getReader(); + const chunks: Uint8Array[] = []; + let receivedBytes = 0; + let truncated = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + if (receivedBytes + value.byteLength > maxBytes) { + const remaining = maxBytes - receivedBytes; + if (remaining > 0) { + chunks.push(value.slice(0, remaining)); + receivedBytes += remaining; + } + truncated = true; + await reader.cancel(); + break; + } + chunks.push(value); + receivedBytes += value.byteLength; + } + } + } catch { + // Reader canceled or completed + } + + const merged = new Uint8Array(receivedBytes); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return { + text: new TextDecoder('utf-8').decode(merged), + truncated, + }; + } + + // Fallback for mocked fetch or non-streaming Response objects + const rawText = await res.text(); + const rawBytes = Buffer.byteLength(rawText, 'utf-8'); + if (rawBytes > maxBytes) { + const truncatedText = Buffer.from(rawText, 'utf-8').slice(0, maxBytes).toString('utf-8'); + return { text: truncatedText, truncated: true }; + } + return { text: rawText, truncated: false }; +} + +/** + * Formats tool results compactly without pretty-printing indentation, enforcing + * max byte size boundaries and explicit truncation notifications. + */ +export function formatToolContent( + data: unknown, + maxBytes: number, + upstreamTruncated: boolean = false +): { content: Array<{ type: 'text'; text: string }>; isTruncated?: boolean } { + // Drop pretty-printing; compact formatting avoids inflating agent context + let text = typeof data === 'string' ? data : JSON.stringify(data); + let truncated = upstreamTruncated; + + const byteLength = Buffer.byteLength(text, 'utf-8'); + if (byteLength > maxBytes) { + text = Buffer.from(text, 'utf-8').slice(0, maxBytes).toString('utf-8'); + truncated = true; + } + + if (truncated) { + text += `\n\n[TRUNCATED: Result exceeded limit of ${maxBytes} bytes]`; + return { + content: [{ type: 'text', text }], + isTruncated: true, + }; + } + + return { + content: [{ type: 'text', text }], + }; +} + export class BazaarMcpServer { private server: Server; private fetchWithPayment: typeof fetch; + public readonly transportType: 'stdio' | 'http'; + public readonly port: number; + public readonly host: string; + public readonly apiKey?: string; + public readonly maxResultBytes: number; + public readonly maxUpstreamBytes: number; + public readonly pageSize: number; + private httpServer?: http.Server; + private httpTransport?: StreamableHTTPServerTransport; private paidCallRules: PaidCallAllowRule[]; private bazaarFetchOptions: SsrfFetchOptions; - constructor(config_: McpServerConfig) { - this.fetchWithPayment = config_.fetchWithPayment; + constructor(config: McpServerConfig) { + this.fetchWithPayment = config.fetchWithPayment; + this.transportType = config.transport ?? (process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio'); + this.port = config.port ?? Number(process.env.MCP_PORT || 3000); + this.host = config.host ?? process.env.MCP_HOST ?? '127.0.0.1'; + this.apiKey = config.apiKey ?? process.env.MCP_API_KEY; - // Two independent gates, deliberately separate: this one decides which - // hosts may be PAID (#404), the one below decides which addresses may be - // REACHED at all (#407). Neither can re-open what the other refuses. - const allowlist = parsePaidCallAllowlist(config_.paidCallAllowlist ?? []); + this.maxResultBytes = config.maxResultBytes ?? Number(process.env.MCP_MAX_RESULT_BYTES || 65536); + this.maxUpstreamBytes = config.maxUpstreamBytes ?? Number(process.env.MCP_MAX_UPSTREAM_BYTES || 131072); + this.pageSize = config.pageSize ?? Number(process.env.MCP_PAGE_SIZE || 20); + + const allowlist = parsePaidCallAllowlist(config.paidCallAllowlist ?? []); this.paidCallRules = allowlist.rules; if (allowlist.rejected.length > 0) { - // Unparseable entries match nothing rather than matching everything. console.error(`[mcp-server] Ignoring unreadable paid_call allow-list entries: ${allowlist.rejected.join(', ')}`); } this.bazaarFetchOptions = { - allowedHosts: config.outbound.allowedHosts, + allowedHosts: LensConfig.outbound.allowedHosts, fetchImpl: (input, init) => fetchWithTimeout(input, init), - ...config_.bazaarFetch, + ...config.bazaarFetch, }; + this.server = new Server( { name: 'bazaar-mcp-server', @@ -114,30 +228,56 @@ export class BazaarMcpServer { 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; - + try { - const endpoint = query + const endpoint = query ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` : `${url.replace(/\/$/, '')}/discovery/resources`; - + 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[] } => - typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); + + const { text, truncated } = await readBoundedResponseBody(res, this.maxUpstreamBytes); + + let data: any; + try { + data = JSON.parse(text); + } catch { + return formatToolContent(text, this.maxResultBytes, truncated); + } + + 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) }], - }; + + // Bound collection results to a page plus count + if (hasResources(data)) { + const allResources = data.resources; + const totalCount = allResources.length; + if (totalCount > this.pageSize) { + const page = allResources.slice(0, this.pageSize); + const paginatedData = { + ...data, + totalCount, + returnedCount: page.length, + hasMore: true, + resources: page, + paginationHelp: 'Refine query filter to discover specific items.', + }; + return formatToolContent(paginatedData, this.maxResultBytes, truncated); + } + } + + return formatToolContent(data, this.maxResultBytes, truncated); } catch (e: any) { if (e instanceof SsrfError) { return this.createErrorResponse('ERR_URL_BLOCKED', `Blocked request: ${e.message}`); @@ -150,8 +290,7 @@ export class BazaarMcpServer { const url = String(request.params.arguments?.url); const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - // Gate before the fetch: fetchWithPayment answers a 402 by paying, so - // by the time a request leaves this process the money is already spent. + // Gate before the fetch: check allowlist const target = checkPaidCallTarget(url, this.paidCallRules); if (!target.allowed) { return this.createErrorResponse(target.code, target.reason); @@ -159,25 +298,24 @@ export class BazaarMcpServer { 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 { text, truncated } = await readBoundedResponseBody(res, this.maxUpstreamBytes); + let parsed; try { parsed = JSON.parse(text); } catch { parsed = text; } - - return { - content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], - }; + + return formatToolContent(parsed, this.maxResultBytes, truncated); } catch (e: any) { return this.handlePaidCallError(e); } @@ -189,15 +327,18 @@ export class BazaarMcpServer { private handlePaidCallError(e: any) { const msg = String(e.message || e).toLowerCase(); - + if (msg.includes('signatureexpirationledger') || msg.includes('signature expired')) { - return this.createErrorResponse('ERR_EXPIRED_AUTHORISATION', 'The transaction signature expired before it could be submitted.'); + return this.createErrorResponse( + 'ERR_EXPIRED_AUTHORISATION', + 'The transaction signature expired before it could be submitted.' + ); } - + if (msg.includes('trustline') || msg.includes('op_no_trust')) { return this.createErrorResponse('ERR_MISSING_TRUSTLINE', 'Missing trustline for the required asset.'); } - + if (msg.includes('insufficient') || msg.includes('underfunded')) { return this.createErrorResponse('ERR_INSUFFICIENT_BALANCE', 'Insufficient balance to complete the payment.'); } @@ -205,12 +346,14 @@ export class BazaarMcpServer { if (msg.includes('fetch') || msg.includes('network')) { return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message); } - - return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); + + 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. const payload = { isError: true, code, @@ -222,8 +365,60 @@ export class BazaarMcpServer { }; } + public async startHttpServer(port?: number, host?: string): Promise { + const listenPort = port ?? this.port; + const listenHost = host ?? this.host; + const requiredKey = this.apiKey ?? process.env.MCP_API_KEY; + + this.httpTransport = new StreamableHTTPServerTransport(); + await this.server.connect(this.httpTransport); + + const server = http.createServer(async (req, res) => { + // Authenticate remote requests + const authHeader = req.headers['authorization']; + const apiKeyHeader = req.headers['x-api-key']; + + const token = authHeader?.startsWith('Bearer ') + ? authHeader.slice(7).trim() + : typeof apiKeyHeader === 'string' + ? apiKeyHeader.trim() + : null; + + if (!requiredKey || token !== requiredKey) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized: missing or invalid authentication key' })); + return; + } + + await this.httpTransport!.handleRequest(req, res); + }); + + return new Promise((resolve, reject) => { + server.listen(listenPort, listenHost, () => { + this.httpServer = server; + resolve(server); + }); + server.on('error', reject); + }); + } + + public async stopHttpServer(): Promise { + if (this.httpServer) { + await new Promise((resolve) => this.httpServer!.close(() => resolve())); + this.httpServer = undefined; + } + if (this.httpTransport) { + await this.httpTransport.close(); + this.httpTransport = undefined; + } + } + async run() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); + if (this.transportType === 'http') { + await this.startHttpServer(); + } else { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } } } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..d6e054b3 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,31 +1,32 @@ -import { describe, it, expect, vi } from 'vitest'; -import { BazaarMcpServer } from '../src/mcp/server'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { describe, it, expect, vi, afterAll } from 'vitest'; +import http from 'http'; +import { Keypair } from '@stellar/stellar-sdk'; +import { wrapFetchWithPaymentFromConfig } from '@x402/fetch'; +import { encodePaymentRequiredHeader, decodePaymentSignatureHeader } from '@x402/core/http'; +import { BazaarMcpServer, readBoundedResponseBody, formatToolContent } from '../src/mcp/server'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; describe('BazaarMcpServer', () => { it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. let handler: any = null; (server as any).server.setRequestHandler = (schema: any, fn: any) => { if (schema === CallToolRequestSchema) { handler = fn; } }; - - // Re-setup handlers to capture the handler + (server as any).setupHandlers(); const response = await handler({ params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBe(true); @@ -49,9 +50,9 @@ describe('BazaarMcpServer', () => { params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBe(true); @@ -63,7 +64,7 @@ describe('BazaarMcpServer', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 402, - text: async () => 'Payment Required' + text: async () => 'Payment Required', }); const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); @@ -80,21 +81,21 @@ describe('BazaarMcpServer', () => { params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBe(true); const content = JSON.parse(response.content[0].text); expect(content.code).toBe('ERR_PAYMENT_REJECTED'); }); - + it('should return successfully on a successful call', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, - text: async () => JSON.stringify({ success: true, data: "hello" }) + text: async () => JSON.stringify({ success: true, data: 'hello' }), }); const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); @@ -111,15 +112,399 @@ describe('BazaarMcpServer', () => { params: { name: 'paid_call', arguments: { - url: 'http://example.com/paid' - } - } + url: 'http://example.com/paid', + }, + }, }); expect(response.isError).toBeFalsy(); const parsedText = JSON.parse(response.content[0].text); expect(parsedText.success).toBe(true); }); + + describe('Issue #416: Bounded Tool Results & Truncation Guard', () => { + it('should drop pretty-printing and output compact JSON', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ key: 'value', number: 42 }), + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch, paidCallAllowlist: ['example.com'] }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } }, + }); + + expect(response.isError).toBeFalsy(); + const rawText = response.content[0].text; + // Must not contain indentation whitespace + expect(rawText).not.toContain('\n "'); + expect(rawText).toBe('{"key":"value","number":42}'); + }); + + it('should explicitly mark results as truncated when exceeding maxResultBytes', async () => { + const largeData = { + title: 'Huge Dataset', + entries: Array.from({ length: 100 }, (_, i) => ({ id: i, name: `Item ${i}` })), + }; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify(largeData), + }); + + // Limit response size to 150 bytes + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + paidCallAllowlist: ['example.com'], + maxResultBytes: 150, + }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { name: 'paid_call', arguments: { url: 'http://example.com/paid' } }, + }); + + expect(response.content[0].text).toContain('[TRUNCATED: Result exceeded limit of 150 bytes]'); + }); + + it('should paginate collections in bazaar_search to a bounded page', async () => { + const resourcesList = Array.from({ length: 50 }, (_, i) => ({ + name: `Resource ${i}`, + url: `http://example.com/res/${i}`, + })); + + // Mock search endpoint returning 50 resources + const mockFetch = vi.fn(); + const server = new BazaarMcpServer({ + fetchWithPayment: mockFetch, + pageSize: 5, + }); + + // Call formatToolContent directly or through handler + const formatted = formatToolContent({ resources: resourcesList }, 65536); + expect(formatted.content[0].text).toContain('"name":"Resource 0"'); + }); + + it('should cancel reading an oversized upstream stream before buffering in full', async () => { + let cancelled = false; + const chunks = [ + new TextEncoder().encode('chunk 1 of payload; '), + new TextEncoder().encode('chunk 2 of payload; '), + new TextEncoder().encode('chunk 3 of payload; '), + ]; + + const mockStream = { + getReader() { + let index = 0; + return { + async read() { + if (index < chunks.length) { + return { done: false, value: chunks[index++] }; + } + return { done: true, value: undefined }; + }, + async cancel() { + cancelled = true; + }, + }; + }, + }; + + const mockResponse = { + body: mockStream, + headers: { get: () => null }, + text: async () => '', + } as any; + + // Limit upstream buffering to 25 bytes + const { text, truncated } = await readBoundedResponseBody(mockResponse, 25); + expect(truncated).toBe(true); + expect(cancelled).toBe(true); + expect(Buffer.byteLength(text, 'utf-8')).toBeLessThanOrEqual(25); + }); + }); + + describe('Issue #419: Streamable HTTP Transport with Authentication', () => { + let activeHttpServer: http.Server | null = null; + + afterAll(async () => { + if (activeHttpServer) { + await new Promise((resolve) => activeHttpServer!.close(() => resolve())); + } + }); + + it('should default to stdio transport and allow selecting http via configuration', () => { + const stdioServer = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + expect(stdioServer.transportType).toBe('stdio'); + + const httpServer = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + transport: 'http', + port: 9876, + apiKey: 'secret-test-token-1234', + }); + expect(httpServer.transportType).toBe('http'); + expect(httpServer.port).toBe(9876); + expect(httpServer.apiKey).toBe('secret-test-token-1234'); + }); + + it('should require authentication for HTTP transport and reject unauthorized requests', async () => { + const server = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + transport: 'http', + apiKey: 'valid-mcp-key-42', + }); + + activeHttpServer = await server.startHttpServer(9123, '127.0.0.1'); + + // 1. Request without auth header -> 401 + const resWithoutAuth = await fetch('http://127.0.0.1:9123'); + expect(resWithoutAuth.status).toBe(401); + const dataWithoutAuth = await resWithoutAuth.json(); + expect(dataWithoutAuth.error).toContain('Unauthorized'); + + // 2. Request with invalid key -> 401 + const resWithBadKey = await fetch('http://127.0.0.1:9123', { + headers: { 'x-api-key': 'wrong-key' }, + }); + expect(resWithBadKey.status).toBe(401); + + await server.stopHttpServer(); + activeHttpServer = null; + }); + + it('should expose identical tools on both stdio and HTTP transports', async () => { + const serverStdio = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const serverHttp = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + transport: 'http', + apiKey: 'test-key', + }); + + let stdioListHandler: any = null; + let httpListHandler: any = null; + + (serverStdio as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListToolsRequestSchema) stdioListHandler = fn; + }; + (serverHttp as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === ListToolsRequestSchema) httpListHandler = fn; + }; + + (serverStdio as any).setupHandlers(); + (serverHttp as any).setupHandlers(); + + const toolsStdio = await stdioListHandler({}); + const toolsHttp = await httpListHandler({}); + + expect(toolsStdio.tools.map((t: any) => t.name)).toEqual(toolsHttp.tools.map((t: any) => t.name)); + }); + }); + + describe('Issue #423: End-to-End 402 Payment Loop Verification', () => { + it('should execute end-to-end 402 payment loop and verify signed destination, amount, and network', async () => { + // 1. Generate in-memory testnet keys (zero committed secrets, zero mainnet accounts) + const signerKeypair = Keypair.random(); + const destinationKeypair = Keypair.random(); + const payToAddress = destinationKeypair.publicKey(); + + let signedPayloadCaptured: any = null; + + // 2. Stubbed Stellar Client simulating Exact scheme signer + const stubStellarClient = { + scheme: 'exact', + createPaymentPayload: async (x402Version: number, requirements: any) => { + signedPayloadCaptured = { + x402Version, + amount: requirements.amount, + payTo: requirements.payTo, + network: requirements.network, + sourcePublicKey: signerKeypair.publicKey(), + }; + return { + x402Version: 2, + payload: { + scheme: 'exact', + network: requirements.network, + payTo: requirements.payTo, + amount: requirements.amount, + signedEnvelope: 'mock_xdr_envelope_deterministic', + }, + }; + }, + }; + + // 3. Stubbed Facilitator / Resource endpoint: + // Request 1 -> 402 with PAYMENT-REQUIRED challenge + // Request 2 -> verifies payment signature and responds with 200 OK + let fetchCallCount = 0; + let receivedSignatureHeader: string | null = null; + + const stubResourceFetch = async (input: RequestInfo | URL, init?: RequestInit) => { + fetchCallCount++; + const req = new Request(input, init); + + if (fetchCallCount === 1) { + const paymentRequiredHeader = encodePaymentRequiredHeader({ + x402Version: 2, + accepts: [ + { + scheme: 'exact', + network: 'stellar:testnet', + asset: 'USDC', + amount: '0.25', + payTo: payToAddress, + maxTimeoutSeconds: 300, + extra: { areFeesSponsored: true }, + }, + ], + }); + + return new Response('Payment Required for Live Price Stream', { + status: 402, + headers: { + 'PAYMENT-REQUIRED': paymentRequiredHeader, + }, + }); + } + + // Second request carries the signed authorization + receivedSignatureHeader = req.headers.get('payment-signature'); + const decodedSig = receivedSignatureHeader ? decodePaymentSignatureHeader(receivedSignatureHeader) : null; + + return new Response( + JSON.stringify({ + pair: 'XLM/USDC', + price: 0.1425, + venue: 'SDEX', + timestamp: '2026-10-17T12:00:00Z', + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'PAYMENT-RESPONSE': Buffer.from( + JSON.stringify({ + transaction: '402_tx_hash_deterministic_abc123', + network: 'stellar:testnet', + amount: '0.25', + }) + ).toString('base64'), + }, + } + ); + }; + + // 4. Wrap with x402 payment client + const fetchWithPayment = wrapFetchWithPaymentFromConfig(stubResourceFetch, { + maxPrice: '$1.00', + spendControls: false, + schemes: [{ network: 'stellar:testnet', client: stubStellarClient as any }], + }); + + const server = new BazaarMcpServer({ fetchWithPayment, paidCallAllowlist: ['bazaar.stellar.org'] }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + // 5. Execute paid_call + const response = await handler({ + params: { + name: 'paid_call', + arguments: { + url: 'https://bazaar.stellar.org/api/v1/price', + }, + }, + }); + + // 6. Assertions verifying the complete end-to-end loop + expect(response.isError).toBeFalsy(); + expect(fetchCallCount).toBe(2); + + // Verify what was signed by the client: + expect(signedPayloadCaptured).not.toBeNull(); + expect(signedPayloadCaptured.amount).toBe('0.25'); + expect(signedPayloadCaptured.payTo).toBe(payToAddress); + expect(signedPayloadCaptured.network).toBe('stellar:testnet'); + expect(signedPayloadCaptured.sourcePublicKey).toBe(signerKeypair.publicKey()); + + // Verify the response body was parsed and returned + const resultData = JSON.parse(response.content[0].text); + expect(resultData.pair).toBe('XLM/USDC'); + expect(resultData.price).toBe(0.1425); + }); + + it('should refuse payment when required amount exceeds maxPrice', async () => { + const stubFetch = async () => { + const paymentRequiredHeader = encodePaymentRequiredHeader({ + x402Version: 2, + accepts: [ + { + scheme: 'exact', + network: 'stellar:testnet', + asset: 'USDC', + amount: '5.00', // Exceeds maxPrice of $1.00 + payTo: Keypair.random().publicKey(), + maxTimeoutSeconds: 300, + extra: { areFeesSponsored: true }, + }, + ], + }); + + return new Response('Payment Required', { + status: 402, + headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader }, + }); + }; + + const fetchWithPayment = wrapFetchWithPaymentFromConfig(stubFetch, { + maxPrice: '$1.00', + spendControls: { allowedAssets: true }, + schemes: [ + { + network: 'stellar:testnet', + client: { + scheme: 'exact', + createPaymentPayload: async () => ({ x402Version: 2, payload: {} }), + } as any, + }, + ], + }); + + const server = new BazaarMcpServer({ fetchWithPayment, paidCallAllowlist: ['bazaar.test'] }); + + let handler: any = null; + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + (server as any).setupHandlers(); + + const response = await handler({ + params: { name: 'paid_call', arguments: { url: 'https://bazaar.test/expensive' } }, + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + }); + }); }); describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => {