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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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<string, unknown>).resources);

Expand All @@ -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}`);
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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.');
}
Expand Down
69 changes: 68 additions & 1 deletion tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<Response>) {
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('<html>Bad Gateway</html>', { 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('<html>oops</html>', { 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');
});
});