Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/mcp-structured-error-classification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'lens': patch
---

Structure-first error classification for MCP `paid_call` handler:
- Inspect error structure, Horizon result codes (`op_no_trust`, `op_underfunded`, `tx_bad_auth`), HTTP status codes (402, 401, 503), and socket/timeout codes before message text inspection (resolves #410)
- Prevent fragile string matching misclassifications (e.g. messages containing the word "fetch" with underlying trustline errors now correctly map to `ERR_MISSING_TRUSTLINE` rather than `ERR_SERVICE_UNREACHABLE`) (resolves #410)
- Maintain text matching purely as an unstructured fallback for legacy third-party errors (resolves #410)
- Comprehensive unit tests covering Horizon operation codes, transaction codes, HTTP statuses, and anti-fragility against reworded messages (resolves #410)
167 changes: 155 additions & 12 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,30 +292,173 @@ export class BazaarMcpServer {
});
}

private handlePaidCallError(e: any) {
/**
* Classify paid_call failures by inspecting error structure and codes first
* (Horizon result_codes, HTTP status codes, socket/network codes), using
* message substring matching only as a last-resort fallback for unstructured errors.
*/
public classifyPaidCallError(e: any): { code: McpErrorCode; reason: string } {
if (isTimeoutError(e)) {
return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Upstream did not respond within ${UPSTREAM_TIMEOUT_MS}ms`);
return {
code: 'ERR_SERVICE_UNREACHABLE',
reason: `Upstream did not respond within ${UPSTREAM_TIMEOUT_MS}ms`,
};
}

if (!e || typeof e !== 'object') {
const text = String(e || '');
return {
code: 'ERR_INTERNAL_FAILURE',
reason: text || 'An unknown error occurred during the paid call.',
};
}

// 1. Inspect Horizon transaction failure codes (e.response.data.extras.result_codes or e.resultCodes)
const resultCodes =
e.response?.data?.extras?.result_codes ??
e.data?.extras?.result_codes ??
e.resultCodes ??
e.extras?.result_codes;

if (resultCodes) {
const txCode = typeof resultCodes.transaction === 'string' ? resultCodes.transaction : '';
const opCodes: string[] = Array.isArray(resultCodes.operations) ? resultCodes.operations : [];

if (opCodes.includes('op_no_trust')) {
return {
code: 'ERR_MISSING_TRUSTLINE',
reason: 'Missing trustline for the required asset.',
};
}

if (
opCodes.includes('op_underfunded') ||
opCodes.includes('op_low_reserve') ||
txCode === 'tx_insufficient_balance' ||
txCode === 'tx_insufficient_fee'
) {
return {
code: 'ERR_INSUFFICIENT_BALANCE',
reason: 'Insufficient balance to complete the payment.',
};
}

if (
txCode === 'tx_bad_auth' ||
txCode === 'tx_bad_seq' ||
txCode === 'tx_too_late' ||
opCodes.includes('op_bad_auth')
) {
return {
code: 'ERR_EXPIRED_AUTHORISATION',
reason: 'Transaction authorisation expired or signature rejected.',
};
}
}

// 2. Inspect explicit ledger / expiration fields on the error
if (
('signatureExpirationLedger' in e && 'currentLedger' in e) ||
e.code === 'ERR_SIGNATURE_EXPIRED' ||
e.code === 'SIGNATURE_EXPIRED'
) {
return {
code: 'ERR_EXPIRED_AUTHORISATION',
reason: 'The transaction signature expired before it could be submitted.',
};
}

// 3. Inspect HTTP status codes from x402 client / upstream responses
const status =
typeof e.status === 'number'
? e.status
: typeof e.statusCode === 'number'
? e.statusCode
: typeof e.response?.status === 'number'
? e.response.status
: undefined;

if (status !== undefined) {
if (status === 402) {
return {
code: 'ERR_PAYMENT_REJECTED',
reason: 'Payment required but was rejected or failed.',
};
}
if (status === 401 || status === 403) {
return {
code: 'ERR_EXPIRED_AUTHORISATION',
reason: 'Authorisation token invalid or expired.',
};
}
if (status === 502 || status === 503 || status === 504) {
return {
code: 'ERR_SERVICE_UNREACHABLE',
reason: `Service returned gateway error with status ${status}`,
};
}
if (status >= 400 && status < 500) {
return {
code: 'ERR_BAD_REQUEST',
reason: `Request rejected with client status ${status}`,
};
}
}

// 4. Inspect network error codes and causes (Node.js/fetch socket errors)
const errCode = e.code || e.cause?.code;
const isNetworkCode =
typeof errCode === 'string' &&
['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(errCode);
const isTimeout = e.name === 'TimeoutError' || e.name === 'AbortError';

if (isNetworkCode || isTimeout) {
return {
code: 'ERR_SERVICE_UNREACHABLE',
reason: `Failed to reach the service: ${e.message || errCode || 'Network error'}`,
};
}

const msg = String(e.message || e).toLowerCase();
// 5. Fallback: string matching on message text (only for unstructured errors)
const msg = String(e.message || '').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 {
code: 'ERR_EXPIRED_AUTHORISATION',
reason: '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.');
return {
code: 'ERR_MISSING_TRUSTLINE',
reason: '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.');
return {
code: 'ERR_INSUFFICIENT_BALANCE',
reason: 'Insufficient balance to complete the payment.',
};
}

if (msg.includes('fetch') || msg.includes('network')) {
return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', 'Failed to reach the service: ' + e.message);
if (msg.includes('fetch') || msg.includes('network') || msg.includes('connection refused')) {
return {
code: 'ERR_SERVICE_UNREACHABLE',
reason: 'Failed to reach the service: ' + (e.message || msg),
};
}

return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.');

return {
code: 'ERR_INTERNAL_FAILURE',
reason: e.message || 'An unknown error occurred during the paid call.',
};
}

private handlePaidCallError(e: any) {
const { code, reason } = this.classifyPaidCallError(e);
return this.createErrorResponse(code, reason);
}

private createErrorResponse(code: McpErrorCode, reason: string) {
Expand Down
Loading
Loading