From 58c05be657e07f4cd240715b2b17ecdc0ab99db4 Mon Sep 17 00:00:00 2001 From: issue-solver-bot Date: Sun, 18 Oct 2026 23:31:51 +0100 Subject: [PATCH] fix: resolve issue #349 --- src/mcp/server.ts | 62 +++++++++++++++++++++++++++++++++++------- tests/mcp.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 804b0791..0c44db50 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,6 +7,22 @@ import { fetchWithTimeout } from '../fetch.js'; import { config } from '../config.js'; import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'; +// Same deadline as the alert delivery path (src/alerts/delivery.ts). +const UPSTREAM_TIMEOUT_MS = 10_000; + +function isTimeoutError(e: unknown): boolean { + const name = (e as { name?: string } | null)?.name; + return name === 'TimeoutError' || name === 'AbortError'; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return 'unknown host'; + } +} + export interface McpServerConfig { fetchWithPayment: typeof fetch; /** @@ -120,14 +136,32 @@ export class BazaarMcpServer { ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` : `${url.replace(/\/$/, '')}/discovery/resources`; - const res = await ssrfSafeFetch(endpoint, {}, this.bazaarFetchOptions); - + const upstream = new URL(endpoint).host; + const res = await ssrfSafeFetch( + endpoint, + { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }, + this.bazaarFetchOptions, + ); + if (!res.ok) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${upstream} returned HTTP ${res.status}`); } - - const data = await res.json(); - + + const contentType = res.headers.get('content-type') ?? ''; + if (!/\bjson\b/i.test(contentType)) { + return this.createErrorResponse( + 'ERR_SERVICE_UNREACHABLE', + `Upstream ${upstream} returned content-type "${contentType || 'none'}", expected JSON`, + ); + } + + let data: unknown; + try { + data = await res.json(); + } catch { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${upstream} returned a body 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); @@ -142,7 +176,13 @@ export class BazaarMcpServer { 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', + `Upstream ${hostOf(url)} did not respond within ${UPSTREAM_TIMEOUT_MS}ms`, + ); + } + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream ${hostOf(url)} failed: ${e.message}`); } } @@ -158,7 +198,7 @@ export class BazaarMcpServer { } try { - const res = await this.fetchWithPayment(url, { method }); + const res = await this.fetchWithPayment(url, { method, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }); if (!res.ok) { if (res.status === 402) { @@ -188,8 +228,12 @@ export class BazaarMcpServer { } private handlePaidCallError(e: any) { + if (isTimeoutError(e)) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream did not respond within ${UPSTREAM_TIMEOUT_MS}ms`); + } + 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.'); } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 7d71be0d..b13fbbf0 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -134,7 +134,10 @@ 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' }, + }); it('rejects a non-http(s) scheme', async () => { const server = new BazaarMcpServer({ @@ -200,3 +203,67 @@ describe('BazaarMcpServer bazaar_search SSRF guard (#407)', () => { expect(JSON.parse(response.content[0].text).resources[0].name).toBe('x'); }); }); + +describe('BazaarMcpServer bazaar_search upstream failures (#349)', () => { + function run(fetchImpl: (input: string | URL, init?: RequestInit) => Promise) { + const server: any = new BazaarMcpServer({ + fetchWithPayment: vi.fn(), + bazaarFetch: { allowedHosts: [], lookup: async () => ['93.184.216.34'], fetchImpl }, + }); + let handler: any = null; + server.server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + server.setupHandlers(); + return handler({ params: { name: 'bazaar_search', arguments: { url: 'https://ok.example' } } }); + } + + it('passes a deadline signal to the upstream call', async () => { + let signal: AbortSignal | null | undefined; + await run(async (_input, init) => { + signal = init?.signal; + return new Response('{"resources":[{"a":1}]}', { status: 200, headers: { 'content-type': 'application/json' } }); + }); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('returns a structured error naming the upstream when the call times out', async () => { + const response = await run(async () => { + throw new DOMException('The operation timed out.', 'TimeoutError'); + }); + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(body.reason).toContain('ok.example'); + expect(body.reason).toContain('did not respond'); + }); + + it('rejects a non-2xx response naming the upstream and status', async () => { + const response = await run(async () => new Response('Bad Gateway', { status: 502 })); + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(body.reason).toContain('ok.example'); + expect(body.reason).toContain('502'); + }); + + it('rejects a 200 with a non-JSON content type before parsing', async () => { + const response = await run( + async () => new Response('oops', { status: 200, headers: { 'content-type': 'text/html' } }), + ); + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(body.reason).toContain('ok.example'); + expect(body.reason).toContain('text/html'); + }); + + it('rejects a JSON content type with an unparseable body', async () => { + const response = await run( + async () => new Response('not json', { status: 200, headers: { 'content-type': 'application/json' } }), + ); + expect(response.isError).toBe(true); + const body = JSON.parse(response.content[0].text); + expect(body.reason).toContain('not valid JSON'); + }); +});