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
10 changes: 9 additions & 1 deletion src/paystack-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,15 @@ class PaystackClient {
try {
responseData = JSON.parse(responseText);
} catch (parseError) {
throw new Error(`Invalid JSON response: ${responseText}`);
// Handle non-JSON responses gracefully (e.g., HTML error pages from API gateways)
const responseSnippet = responseText.length > 200
? responseText.substring(0, 200) + '...'
: responseText;
const errorMessage = `Received non-JSON response from server (HTTP ${response.status}): ${responseSnippet}`;
const nonJsonError = new Error(errorMessage);
(nonJsonError as any).statusCode = response.status;
(nonJsonError as any).responseText = responseText;
throw nonJsonError;
}
return responseData as PaystackResponse<T>;
} catch (error) {
Expand Down
11 changes: 7 additions & 4 deletions src/tools/get-paystack-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export function registerGetPaystackOperationTool(
type: "text",
text: `Operation with ID ${operation_id} not found.`,
},
]
],
isError: true
}
}

Expand All @@ -47,14 +48,16 @@ export function registerGetPaystackOperationTool(
},
]
}
} catch {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Operation with ID ${operation_id} not found.`,
text: `Error retrieving operation: ${errorMessage}`,
},
]
],
isError: true
}
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/tools/make-paystack-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@ export function registerMakePaystackRequestTool(server: McpServer) {
]
}
} catch(error) {
// Follow MCP best practices: return isError flag instead of throwing
const errorMessage = error instanceof Error ? error.message : String(error);
const statusCode = (error as any).statusCode;

let detailedMessage = `Unable to make request: ${errorMessage}`;
if (statusCode) {
detailedMessage = `Unable to make request (HTTP ${statusCode}): ${errorMessage}`;
}

return {
content: [
{
type: "text",
text: `Unable to make request. ${error}`,
text: detailedMessage,
},
]
],
isError: true
}
}
}
Expand Down
148 changes: 148 additions & 0 deletions test/make-paystack-request-tool.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import assert from "node:assert";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerMakePaystackRequestTool } from "../src/tools/make-paystack-request.js";

describe("MakePaystackRequestTool", () => {
describe("Error handling with isError flag", () => {
let server: McpServer;
let toolHandler: any;

before(() => {
// Create a mock MCP server
server = {
registerTool: (name: string, config: any, handler: any) => {
if (name === "make_paystack_request") {
toolHandler = handler;
}
}
} as any;

registerMakePaystackRequestTool(server);
});

it("should return isError: true for non-JSON responses", async () => {
// Mock fetch to return HTML error page
const originalFetch = global.fetch;
global.fetch = async () => {
return {
status: 502,
text: async () => "<html><body><h1>502 Bad Gateway</h1></body></html>",
} as Response;
};

try {
const result = await toolHandler({
request: {
method: "GET",
path: "/test-endpoint",
}
});

// Verify isError flag is set
assert.strictEqual(result.isError, true);

// Verify error message content
assert.ok(result.content);
assert.strictEqual(result.content.length, 1);
assert.strictEqual(result.content[0].type, "text");
assert.ok(result.content[0].text.includes("Unable to make request"));
assert.ok(result.content[0].text.includes("HTTP 502"));
assert.ok(result.content[0].text.includes("non-JSON response"));
} finally {
global.fetch = originalFetch;
}
});

it("should omit isError for successful responses", async () => {
// Mock fetch to return valid JSON
const originalFetch = global.fetch;
const validJsonResponse = {
status: true,
message: "Success",
data: { id: 123 }
};

global.fetch = async () => {
return {
status: 200,
text: async () => JSON.stringify(validJsonResponse),
} as Response;
};

try {
const result = await toolHandler({
request: {
method: "GET",
path: "/test-endpoint",
}
});

// Verify isError is not set (or false) for successful responses
assert.ok(!result.isError);

// Verify success content
assert.ok(result.content);
assert.strictEqual(result.content.length, 1);
assert.strictEqual(result.content[0].type, "text");
assert.strictEqual(result.content[0].mimeType, "application/json");

// Parse and verify the response data
const parsedResponse = JSON.parse(result.content[0].text);
assert.strictEqual(parsedResponse.status, true);
assert.strictEqual(parsedResponse.message, "Success");
} finally {
global.fetch = originalFetch;
}
});

it("should include HTTP status code in error message", async () => {
// Mock fetch to return a 504 Gateway Timeout
const originalFetch = global.fetch;
global.fetch = async () => {
return {
status: 504,
text: async () => "Gateway Timeout",
} as Response;
};

try {
const result = await toolHandler({
request: {
method: "POST",
path: "/transaction/initialize",
data: { amount: 1000 }
}
});

// Verify error response structure
assert.strictEqual(result.isError, true);
assert.ok(result.content[0].text.includes("HTTP 504"));
} finally {
global.fetch = originalFetch;
}
});

it("should handle network errors with isError flag", async () => {
// Mock fetch to simulate network error
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("Network connection failed");
};

try {
const result = await toolHandler({
request: {
method: "GET",
path: "/customer/list",
}
});

// Verify error is properly handled
assert.strictEqual(result.isError, true);
assert.ok(result.content[0].text.includes("Unable to make request"));
} finally {
global.fetch = originalFetch;
}
});
});
});
121 changes: 121 additions & 0 deletions test/paystack-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from "node:assert";
import { paystackClient } from "../src/paystack-client.js";

describe("PaystackClient", () => {
describe("makeRequest - Non-JSON Response Handling", () => {
it("should throw a descriptive error for HTML error responses", async () => {
// This test validates that non-JSON responses (like HTML error pages)
// are handled gracefully with proper error messages including status code

// Mock fetch to return an HTML 502 Bad Gateway response
const originalFetch = global.fetch;
global.fetch = async () => {
return {
status: 502,
text: async () => "<html><body><h1>502 Bad Gateway</h1></body></html>",
} as Response;
};

try {
await paystackClient.makeRequest("GET", "/test-endpoint");
assert.fail("Expected makeRequest to throw an error");
} catch (error: any) {
// Verify error message includes status code and response snippet
assert.ok(error.message.includes("Received non-JSON response from server"));
assert.ok(error.message.includes("HTTP 502"));
assert.ok(error.message.includes("<html>"));

// Verify statusCode is attached to error
assert.strictEqual(error.statusCode, 502);

// Verify full responseText is available for debugging
assert.ok(error.responseText);
assert.ok(error.responseText.includes("502 Bad Gateway"));
} finally {
global.fetch = originalFetch;
}
});

it("should truncate long non-JSON responses to 200 characters", async () => {
const originalFetch = global.fetch;
const longHtmlResponse = "<html>" + "x".repeat(300) + "</html>";

global.fetch = async () => {
return {
status: 500,
text: async () => longHtmlResponse,
} as Response;
};

try {
await paystackClient.makeRequest("GET", "/test-endpoint");
assert.fail("Expected makeRequest to throw an error");
} catch (error: any) {
// Verify the error message contains truncated snippet (200 chars + '...')
const snippetMatch = error.message.match(/: (.+)$/);
assert.ok(snippetMatch);
const snippet = snippetMatch[1];

// Should end with '...' for truncation
assert.ok(snippet.endsWith('...'));

// Should be 203 characters (200 + '...')
assert.ok(snippet.length <= 203);

// Full response should still be available
assert.strictEqual(error.responseText, longHtmlResponse);
} finally {
global.fetch = originalFetch;
}
});

it("should not truncate short non-JSON responses", async () => {
const originalFetch = global.fetch;
const shortResponse = "Gateway Timeout";

global.fetch = async () => {
return {
status: 504,
text: async () => shortResponse,
} as Response;
};

try {
await paystackClient.makeRequest("GET", "/test-endpoint");
assert.fail("Expected makeRequest to throw an error");
} catch (error: any) {
// Verify the error message contains full short response
assert.ok(error.message.includes(shortResponse));
assert.ok(!error.message.includes('...'));
assert.strictEqual(error.statusCode, 504);
} finally {
global.fetch = originalFetch;
}
});

it("should successfully parse valid JSON responses", async () => {
const originalFetch = global.fetch;
const validJsonResponse = {
status: true,
message: "Success",
data: { id: 123 }
};

global.fetch = async () => {
return {
status: 200,
text: async () => JSON.stringify(validJsonResponse),
} as Response;
};

try {
const response = await paystackClient.makeRequest("GET", "/test-endpoint");
assert.strictEqual(response.status, true);
assert.strictEqual(response.message, "Success");
assert.deepStrictEqual(response.data, { id: 123 });
} finally {
global.fetch = originalFetch;
}
});
});
});