From ba696b43255d60dca9f647d774b22023b31f9b4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:40:55 +0000 Subject: [PATCH 01/13] Bump @modelcontextprotocol/sdk from 1.25.3 to 1.26.0 Bumps [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) from 1.25.3 to 1.26.0. - [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.25.3...v1.26.0) --- updated-dependencies: - dependency-name: "@modelcontextprotocol/sdk" dependency-version: 1.26.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 36 ++++++++++++++++++++++++------------ package.json | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4cb8917..ccec6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", "dotenv": "^17.2.3", "zod": "^4.3.6" }, @@ -775,9 +775,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", - "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -788,14 +788,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -2702,10 +2703,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -3035,7 +3039,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3082,6 +3085,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 3179e66..4d9bc49 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", "dotenv": "^17.2.3", "zod": "^4.3.6" }, From e5eb067dd85510de2e9a232e71151e2515552610 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:25:29 +0000 Subject: [PATCH 02/13] Initial plan From 4858cc2445f157c97eb791a94b0c3b8dd3308835 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:26:50 +0000 Subject: [PATCH 03/13] Initial setup - dependencies installed Co-authored-by: Andrew-Paystack <78197464+Andrew-Paystack@users.noreply.github.com> --- package-lock.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ccec6cd..b1768f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paystack-mcp", - "version": "1.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paystack-mcp", - "version": "1.0.0", + "version": "0.0.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", @@ -1821,6 +1821,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2664,6 +2665,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3039,6 +3041,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3564,7 +3567,8 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/p-limit": { "version": "3.1.0", @@ -3767,6 +3771,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3779,6 +3784,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4527,6 +4533,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4905,6 +4912,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 29e69f3d99e8a89273164488ccecb2c64c98cdeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:27:35 +0000 Subject: [PATCH 04/13] Remove get_operation_guided tool and its registration Co-authored-by: Andrew-Paystack <78197464+Andrew-Paystack@users.noreply.github.com> --- src/tools/get-operation-guided.ts | 90 ------------------------------- src/tools/index.ts | 2 - 2 files changed, 92 deletions(-) delete mode 100644 src/tools/get-operation-guided.ts diff --git a/src/tools/get-operation-guided.ts b/src/tools/get-operation-guided.ts deleted file mode 100644 index 2dd6741..0000000 --- a/src/tools/get-operation-guided.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CreateMessageResultSchema } from "@modelcontextprotocol/sdk/types.js"; -import { OpenAPIParser } from "../openapi-parser"; - -export function registerGetOperationGuidedTool( - server: McpServer, - openapi: OpenAPIParser -) { - server.registerTool( - "get_paystack_operation_guided", - { - description: "Get Paystack API operation details from user input", - annotations: { - title: "Get endpoint details from user input", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, - }, - async () => { - const res = await server.server.request({ - method: "sampling/createMessage", - params: { - messages: [{ - role: "user", - content: [ - { - type: "text", - text: `Review the OpenAPI specification and infer the operation ID of the - Paystack API endpoint from the user input. - For example if the user's input is: 'I want to create a new customer in Paystack.' - review the OpenAPI spec and respond with the most logical operationId: - which is 'customer_create'. Return just the operationId in your response.`, - }, - ], - }], - maxTokens: 1024, - } - }, CreateMessageResultSchema) - - if (res.content.type !== "text") { - return { - content: [ - { - type: "text", - text: `Could not infer operation ID from user input.`, - } - ] - } - } - - try { - const operation_id = res.content.text.trim(); - const operation = openapi.getOperationById(operation_id); - - if (!operation) { - return { - content: [ - { - type: "text", - text: `Operation with ID ${operation_id} not found.`, - }, - ], - isError: true, - } - } - - return { - content: [ - { - type: "text", - text: JSON.stringify(operation, null, 2), - mimeType: "application/json", - }, - ] - } - } catch { - return { - content: [ - { - type: "text", - text: `Operation with ID cannot be infered.`, - }, - ] - } - } - } - ); -} diff --git a/src/tools/index.ts b/src/tools/index.ts index 9100200..194c20a 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { OpenAPIParser } from "../openapi-parser"; import { registerGetPaystackOperationTool } from "./get-paystack-operation"; import { registerMakePaystackRequestTool } from "./make-paystack-request"; -import { registerGetOperationGuidedTool } from "./get-operation-guided"; export function registerAllTools( server: McpServer, @@ -10,5 +9,4 @@ export function registerAllTools( ) { registerGetPaystackOperationTool(server, openapi); registerMakePaystackRequestTool(server); - registerGetOperationGuidedTool(server, openapi); } From 201eb0ae83fc6a6c6cd00f033bf1b0c179d485eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:44:36 +0000 Subject: [PATCH 05/13] Initial plan From 6074b7001fb0de61e1860c2c955be3b23bee8c3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:49:19 +0000 Subject: [PATCH 06/13] Add graceful handling for non-JSON HTTP responses - Enhanced error message to include HTTP status code - Truncate long responses to 200 characters for readability - Attach statusCode and full responseText to error object for debugging - Added comprehensive test suite for non-JSON response handling Co-authored-by: Andrew-Paystack <78197464+Andrew-Paystack@users.noreply.github.com> --- src/paystack-client.ts | 10 ++- test/paystack-client.spec.ts | 121 +++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 test/paystack-client.spec.ts diff --git a/src/paystack-client.ts b/src/paystack-client.ts index e2e535a..ec81928 100644 --- a/src/paystack-client.ts +++ b/src/paystack-client.ts @@ -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; } catch (error) { diff --git a/test/paystack-client.spec.ts b/test/paystack-client.spec.ts new file mode 100644 index 0000000..39f82be --- /dev/null +++ b/test/paystack-client.spec.ts @@ -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 () => "

502 Bad Gateway

", + } 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("")); + + // 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 = "" + "x".repeat(300) + ""; + + 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; + } + }); + }); +}); From c89d3469885daf3313969815111dd693387dad9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:03:50 +0000 Subject: [PATCH 07/13] Follow MCP best practices: add isError flag to tool responses - Updated make-paystack-request tool to return isError: true on failures - Updated get-paystack-operation tool to return isError: true on failures - Enhanced error messages to include HTTP status codes - Added comprehensive test suite for tool-level error handling - All 9 tests passing (4 new tool tests + 5 existing client tests) Co-authored-by: Andrew-Paystack <78197464+Andrew-Paystack@users.noreply.github.com> --- src/tools/get-paystack-operation.ts | 11 +- src/tools/make-paystack-request.ts | 14 ++- test/make-paystack-request-tool.spec.ts | 148 ++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 test/make-paystack-request-tool.spec.ts diff --git a/src/tools/get-paystack-operation.ts b/src/tools/get-paystack-operation.ts index f584d99..77846fb 100644 --- a/src/tools/get-paystack-operation.ts +++ b/src/tools/get-paystack-operation.ts @@ -34,7 +34,8 @@ export function registerGetPaystackOperationTool( type: "text", text: `Operation with ID ${operation_id} not found.`, }, - ] + ], + isError: true } } @@ -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: `Unable to retrieve operation: ${errorMessage}`, }, - ] + ], + isError: true } } } diff --git a/src/tools/make-paystack-request.ts b/src/tools/make-paystack-request.ts index 7c259ae..74d918e 100644 --- a/src/tools/make-paystack-request.ts +++ b/src/tools/make-paystack-request.ts @@ -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 } } } diff --git a/test/make-paystack-request-tool.spec.ts b/test/make-paystack-request-tool.spec.ts new file mode 100644 index 0000000..0058aa3 --- /dev/null +++ b/test/make-paystack-request-tool.spec.ts @@ -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 () => "

502 Bad Gateway

", + } 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 return isError: false (omitted) 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; + } + }); + }); +}); From f0af09acf7450961fb0490dd8515e87a510ac525 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:04:37 +0000 Subject: [PATCH 08/13] Address code review feedback - Use more specific error message: 'Error retrieving operation' vs 'Unable to retrieve operation' - Fix test name to accurately reflect what is being tested Co-authored-by: Andrew-Paystack <78197464+Andrew-Paystack@users.noreply.github.com> --- src/tools/get-paystack-operation.ts | 2 +- test/make-paystack-request-tool.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/get-paystack-operation.ts b/src/tools/get-paystack-operation.ts index 77846fb..148ac88 100644 --- a/src/tools/get-paystack-operation.ts +++ b/src/tools/get-paystack-operation.ts @@ -54,7 +54,7 @@ export function registerGetPaystackOperationTool( content: [ { type: "text", - text: `Unable to retrieve operation: ${errorMessage}`, + text: `Error retrieving operation: ${errorMessage}`, }, ], isError: true diff --git a/test/make-paystack-request-tool.spec.ts b/test/make-paystack-request-tool.spec.ts index 0058aa3..82c64c8 100644 --- a/test/make-paystack-request-tool.spec.ts +++ b/test/make-paystack-request-tool.spec.ts @@ -53,7 +53,7 @@ describe("MakePaystackRequestTool", () => { } }); - it("should return isError: false (omitted) for successful responses", async () => { + it("should omit isError for successful responses", async () => { // Mock fetch to return valid JSON const originalFetch = global.fetch; const validJsonResponse = { From b85b54fafe652ba15adfd708a8f295c5a7781ad8 Mon Sep 17 00:00:00 2001 From: andrew-paystack Date: Wed, 4 Mar 2026 11:39:24 +0300 Subject: [PATCH 09/13] setup npm distribution --- README.md | 18 ++- SETUP.md | 174 +++++++++++++++++++++++++++++ package.json | 23 +++- src/config.ts | 71 ++++++------ src/index.ts | 59 +++++++++- src/paystack-client.ts | 6 +- src/server.ts | 8 +- src/tools/index.ts | 5 +- src/tools/make-paystack-request.ts | 9 +- 9 files changed, 319 insertions(+), 54 deletions(-) create mode 100644 SETUP.md diff --git a/README.md b/README.md index d14d7b4..37dc681 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,13 @@ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that e ## Quick Start -Clone the repo and build locally: +Install and run via npm (recommended): + +```bash +npx @paystack/mcp-server --api-key sk_test_your_key_here +``` + +Or for local development, clone and build: ```bash git clone https://github.com/PaystackOSS/paystack-mcp-server.git @@ -16,7 +22,7 @@ npm install npm run build ``` -Then configure your MCP client to use the built server (see [Client Integration](#client-integration)). +Then configure your MCP client to use the server (see [Client Integration](#client-integration)). ## Requirements @@ -28,7 +34,11 @@ Then configure your MCP client to use the built server (see [Client Integration] | Environment Variable | Purpose | | -------------------------- | ------------------------------------------------------ | -| `PAYSTACK_TEST_SECRET_KEY` | Your Paystack test secret key **(required)** | +| `PAYSTACK_TEST_SECRET_KEY` | Your Paystack test secret key (fallback if no CLI arg) | + +You can provide your API key in two ways: +1. **CLI argument (recommended):** `--api-key sk_test_...` +2. **Environment variable:** Set `PAYSTACK_TEST_SECRET_KEY` > **Security note:** Only test keys (`sk_test_*`) are allowed. The server validates this at startup and will reject live keys. @@ -36,7 +46,7 @@ Then configure your MCP client to use the built server (see [Client Integration] The Paystack MCP Server works with any MCP-compatible client. Below is the standard configuration schema used by most clients (Claude Desktop, ChatGPT Desktop, Cursor, Windsurf, etc.). -### Using a local build +### Using npm (recommended)\n\nFor npm-installed server:\n\n```json\n{\n \"mcpServers\": {\n \"paystack\": {\n \"command\": \"npx\",\n \"args\": [\"@paystack/mcp-server\", \"--api-key\", \"sk_test_...\"]\n }\n }\n}\n```\n\n### Using a local build If you've cloned and built the server locally: diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..b7aaaf7 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,174 @@ +# Editor Setup + +Configure the Paystack MCP Server in supported editors with secure `.env`-based API key management. + +- [Environment Setup](#environment-setup) +- [VS Code](#vs-code) +- [Cursor](#cursor) +- [Claude Desktop](#claude-desktop) + +--- + +## Environment Setup + +Create your environment file: + +1. **Copy the example file:** + + ```bash + cp .env.example .env + ``` + +2. **Add your Paystack test secret key:** + + ```env + PAYSTACK_TEST_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ``` + +> [!IMPORTANT] +> - Only **test keys** (starting with `sk_test_`) are accepted. The server rejects live keys. +> - The `.env` file is already in `.gitignore`—never commit it to version control. + +--- + +## VS Code + +VS Code supports the `envFile` property, allowing you to load environment variables from a file instead of hardcoding them. + +### Configuration + +Create or update `.vscode/mcp.json` in your project: + +```json +{ + "servers": { + "paystack": { + "command": "node", + "args": ["/path/to/paystack-mcp/build/index.js"], + "envFile": "${workspaceFolder}/.env" + } + } +} +``` + +> [!NOTE] +> Replace `/path/to/paystack-mcp` with the actual path to your cloned repository. + +### Reload the MCP Server + +After saving the configuration, reload VS Code or run the **"MCP: Restart Server"** command from the Command Palette. + +--- + +## Cursor + +Cursor supports both `envFile` and environment variable interpolation via `${env:VAR_NAME}`. + +### Configuration Locations + +| Scope | File Path | +| ------- | ------------------------ | +| Project | `.cursor/mcp.json` | +| Global | `~/.cursor/mcp.json` | + +### Using `envFile` (Recommended) + +Create `.cursor/mcp.json` in your project: + +```json +{ + "mcpServers": { + "paystack": { + "command": "node", + "args": ["/path/to/paystack-mcp/build/index.js"], + "envFile": "${workspaceFolder}/.env" + } + } +} +``` + +--- + +## Claude Desktop + +Claude Desktop uses an inline `env` object for environment variables. It does not support `envFile`. + +### Configuration Location + +| OS | File Path | +| ------- | ------------------------------------------------------------ | +| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| Windows | `%APPDATA%\Claude\claude_desktop_config.json` | + +### Approach A: Inline Environment Variables (Simple) + +Edit your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "paystack": { + "command": "node", + "args": ["/path/to/paystack-mcp/build/index.js"], + "env": { + "PAYSTACK_SECRET_KEY": "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + } +} +``` + +> [!WARNING] +> This approach stores your API key directly in the config file. Ensure this file is not shared or committed to version control. + +### Approach B: Using a Wrapper Script (Secure) + +For better security, create a shell script that loads your `.env` file before starting the server. + +1. **Create a wrapper script** (e.g., `run-paystack-mcp.sh`): + + ```bash + #!/bin/bash + set -a + source /path/to/paystack-mcp/.env + set +a + exec node /path/to/paystack-mcp/build/index.js + ``` + +2. **Make it executable:** + + ```bash + chmod +x /path/to/run-paystack-mcp.sh + ``` + +3. **Update your Claude Desktop config:** + + ```json + { + "mcpServers": { + "paystack": { + "command": "/path/to/run-paystack-mcp.sh" + } + } + } + ``` + +--- + +## Troubleshooting + +### Server not starting + +- Verify Node.js v18+ is installed: `node --version` +- Check the path to `build/index.js` is correct +- Ensure your `.env` file exists and contains a valid `sk_test_*` key + +### Environment variables not loading + +- For VS Code/Cursor: Confirm `envFile` path is correct and the file exists +- For Claude Desktop: Restart the application after config changes + +### "Invalid API key" errors + +- Ensure your key starts with `sk_test_` (live will be rejected) +- Check for trailing whitespace in your `.env` file diff --git a/package.json b/package.json index 693b89f..54846bd 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,14 @@ { - "name": "paystack-mcp", + "name": "@paystack/mcp-server", "version": "0.0.1", - "description": "", + "description": "Model Context Protocol (MCP) server for Paystack API integration", + "mcpName": "io.github.PaystackOSS/paystack", + "repository": { + "type": "git", + "url": "https://github.com/PaystackOSS/paystack-mcp-server.git" + }, "bin": { - "paystack": "./build/index.js" + "paystack-mcp": "./build/index.js" }, "scripts": { "build": "tsc && cp -r src/data build/", @@ -15,14 +20,20 @@ "files": [ "build" ], - "keywords": [], + "keywords": [ + "paystack", + "mcp", + "model-context-protocol", + "api", + "payment", + "integration" + ], "author": "Andrew-Paystack", "license": "MIT", "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", "@modelcontextprotocol/sdk": "^1.26.0", - "dotenv": "^17.2.3", - "zod": "^4.3.6" + "dotenv": "^17.2.3" }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", diff --git a/src/config.ts b/src/config.ts index e9a3e33..1778454 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,41 +1,46 @@ import dotenv from 'dotenv'; -import { z } from 'zod'; // Load environment variables from .env file -dotenv.config(); +dotenv.config({quiet: true}); -// Define schema for required environment variables -const envSchema = z.object({ - PAYSTACK_TEST_SECRET_KEY: z.string().min(30, 'PAYSTACK_TEST_SECRET_KEY is required').refine(val => val.startsWith('sk_test_'), { - message: 'PAYSTACK_TEST_SECRET_KEY must begin with "sk_test_. No live keys allowed."', - }), - NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), - LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), -}); - -// Validate environment variables -function validateEnv() { - try { - return envSchema.parse({ - PAYSTACK_TEST_SECRET_KEY: process.env.PAYSTACK_TEST_SECRET_KEY, - NODE_ENV: process.env.NODE_ENV || 'development', - LOG_LEVEL: process.env.LOG_LEVEL || 'info', - }); - } catch (error) { - if (error instanceof z.ZodError) { - // Environment validation failed - exit silently - process.exit(1); - } - throw error; +// Get configuration with optional CLI API key +export function getConfig(cliApiKey?: string) { + const apiKey = cliApiKey || process.env.PAYSTACK_TEST_SECRET_KEY; + + if (!apiKey) { + console.error('Error: PAYSTACK_TEST_SECRET_KEY is required'); + process.exit(1); + } + + if (!apiKey.startsWith('sk_test_')) { + console.error('Error: PAYSTACK_TEST_SECRET_KEY must begin with "sk_test_". No live keys allowed.'); + process.exit(1); + } + + if (apiKey.length < 30) { + console.error('Error: PAYSTACK_TEST_SECRET_KEY appears to be too short'); + process.exit(1); } + + return { + PAYSTACK_TEST_SECRET_KEY: apiKey, + NODE_ENV: (process.env.NODE_ENV as 'development' | 'production' | 'test') || 'development', + LOG_LEVEL: (process.env.LOG_LEVEL as 'debug' | 'info' | 'warn' | 'error') || 'info', + }; } -// Export validated configuration -export const config = validateEnv(); +// Export validated configuration (for backward compatibility, will use env var) +export const config = getConfig(); + +// Paystack API configuration factory +export function createPaystackConfig(cliApiKey?: string) { + const cfg = getConfig(cliApiKey); + return { + baseURL: 'https://api.paystack.co', + secretKey: cfg.PAYSTACK_TEST_SECRET_KEY, + timeout: 30000, // 30 seconds + } as const; +} -// Paystack API configuration -export const paystackConfig = { - baseURL: 'https://api.paystack.co', - secretKey: config.PAYSTACK_TEST_SECRET_KEY, - timeout: 30000, // 30 seconds -} as const; +// Default paystack config (for backward compatibility) +export const paystackConfig = createPaystackConfig(); diff --git a/src/index.ts b/src/index.ts index 168c49b..614e0ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,64 @@ +#!/usr/bin/env node import { startServer } from "./server"; +// Simple CLI argument parsing +function parseApiKey(): string | undefined { + const args = process.argv; + const apiKeyIndex = args.findIndex(arg => arg === '--api-key'); + + if (apiKeyIndex !== -1 && apiKeyIndex + 1 < args.length) { + return args[apiKeyIndex + 1]; + } + + return undefined; +} + +// Show help message +function showHelp() { + console.log(` +Paystack MCP Server + +Usage: + npx @paystack/mcp-server --api-key + +Options: + --api-key Your Paystack test secret key (starts with sk_test_) + --help, -h Show this help message + +Environment Variables: + PAYSTACK_TEST_SECRET_KEY Fallback if --api-key not provided + +Examples: + npx @paystack/mcp-server --api-key sk_test_1234567890abcdef + PAYSTACK_TEST_SECRET_KEY=sk_test_... npx @paystack/mcp-server +`); +} + async function main() { - await startServer(); + // Handle help flag + if (process.argv.includes('--help') || process.argv.includes('-h')) { + showHelp(); + process.exit(0); + } + + // Parse API key from CLI + const cliApiKey = parseApiKey(); + + // Validate API key format if provided via CLI + if (cliApiKey && !cliApiKey.startsWith('sk_test_')) { + console.error('Error: API key must start with "sk_test_". Only test keys are allowed.'); + process.exit(1); + } + + // Check if we have an API key from CLI or environment + if (!cliApiKey && !process.env.PAYSTACK_TEST_SECRET_KEY) { + console.error('Error: Paystack API key required.'); + console.error('Provide via --api-key argument or PAYSTACK_TEST_SECRET_KEY environment variable.'); + showHelp(); + process.exit(1); + } + + await startServer(cliApiKey); } main().catch((error) => { diff --git a/src/paystack-client.ts b/src/paystack-client.ts index ec81928..30c8074 100644 --- a/src/paystack-client.ts +++ b/src/paystack-client.ts @@ -4,7 +4,7 @@ import { paystackConfig } from "./config"; const PAYSTACK_BASE_URL = paystackConfig.baseURL; const USER_AGENT = process.env.USER_AGENT || 'Paystack-MCP-Server/0.0.1'; -class PaystackClient { +export class PaystackClient { private baseUrl: string; private secretKey: string; private userAgent: string; @@ -89,8 +89,10 @@ class PaystackClient { throw error; } - } } +} + +// Export singleton instance for backward compatibility export const paystackClient = new PaystackClient( paystackConfig.secretKey ); diff --git a/src/server.ts b/src/server.ts index 088e504..65abadf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import { OpenAPIParser } from "./openapi-parser"; import { registerAllTools } from "./tools"; import { registerAllResources } from "./resources"; -async function createServer() { +async function createServer(cliApiKey?: string) { const server = new McpServer({ name: "paystack", version: "0.0.1", @@ -16,14 +16,14 @@ async function createServer() { await openapi.parse(); - registerAllTools(server, openapi); + registerAllTools(server, openapi, cliApiKey); registerAllResources(server, openapi); return server; } -export async function startServer() { - const server = await createServer(); +export async function startServer(cliApiKey?: string) { + const server = await createServer(cliApiKey); const transport = new StdioServerTransport(); await server.connect(transport); console.error("Paystack MCP Server running on stdio..."); diff --git a/src/tools/index.ts b/src/tools/index.ts index 194c20a..e63eb49 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,8 +5,9 @@ import { registerMakePaystackRequestTool } from "./make-paystack-request"; export function registerAllTools( server: McpServer, - openapi: OpenAPIParser + openapi: OpenAPIParser, + cliApiKey?: string ) { registerGetPaystackOperationTool(server, openapi); - registerMakePaystackRequestTool(server); + registerMakePaystackRequestTool(server, cliApiKey); } diff --git a/src/tools/make-paystack-request.ts b/src/tools/make-paystack-request.ts index 74d918e..6adce0d 100644 --- a/src/tools/make-paystack-request.ts +++ b/src/tools/make-paystack-request.ts @@ -1,8 +1,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as z from "zod"; -import { paystackClient } from "../paystack-client"; +import { PaystackClient } from "../paystack-client"; +import { createPaystackConfig } from "../config"; -export function registerMakePaystackRequestTool(server: McpServer) { +export function registerMakePaystackRequestTool(server: McpServer, cliApiKey?: string) { + // Create PaystackClient with CLI API key or fallback to environment + const config = createPaystackConfig(cliApiKey); + const paystackClient = new PaystackClient(config.secretKey); + server.registerTool( "make_paystack_request", { From c3ca7ae40c703278e861d2f16dbeba10fda65079 Mon Sep 17 00:00:00 2001 From: andrew-paystack Date: Wed, 4 Mar 2026 18:08:22 +0300 Subject: [PATCH 10/13] clean up and add tests for missing API key --- README.md | 17 ++++++++- package-lock.json | 16 ++------ package.json | 5 ++- src/config.ts | 6 --- src/index.ts | 11 ++---- src/logger.ts | 15 ++++++-- src/paystack-client.ts | 15 +++++--- test/make-paystack-request-tool.spec.ts | 51 ++++++++++++++++++++++++- test/paystack-client.spec.ts | 5 ++- 9 files changed, 101 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 37dc681..ba6ac7a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,22 @@ You can provide your API key in two ways: The Paystack MCP Server works with any MCP-compatible client. Below is the standard configuration schema used by most clients (Claude Desktop, ChatGPT Desktop, Cursor, Windsurf, etc.). -### Using npm (recommended)\n\nFor npm-installed server:\n\n```json\n{\n \"mcpServers\": {\n \"paystack\": {\n \"command\": \"npx\",\n \"args\": [\"@paystack/mcp-server\", \"--api-key\", \"sk_test_...\"]\n }\n }\n}\n```\n\n### Using a local build +### Using npm (recommended) + +For npm-installed server: + +```json +{ + "mcpServers": { + "paystack": { + "command": "npx", + "args": ["@paystack/mcp-server", "--api-key", "sk_test_..."] + } + } +} +``` + +### Using a local build If you've cloned and built the server locally: diff --git a/package-lock.json b/package-lock.json index b1768f8..4538ad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "paystack-mcp", + "name": "@paystack/mcp-server", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "paystack-mcp", + "name": "@paystack/mcp-server", "version": "0.0.1", "license": "MIT", "dependencies": { @@ -15,7 +15,7 @@ "zod": "^4.3.6" }, "bin": { - "paystack": "build/index.js" + "paystack-mcp-server": "build/index.js" }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", @@ -1821,7 +1821,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2665,7 +2664,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3041,7 +3039,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3567,8 +3564,7 @@ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/p-limit": { "version": "3.1.0", @@ -3771,7 +3767,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3784,7 +3779,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4533,7 +4527,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4912,7 +4905,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 54846bd..450cc43 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "url": "https://github.com/PaystackOSS/paystack-mcp-server.git" }, "bin": { - "paystack-mcp": "./build/index.js" + "paystack-mcp-server": "./build/index.js" }, "scripts": { "build": "tsc && cp -r src/data build/", @@ -33,7 +33,8 @@ "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", "@modelcontextprotocol/sdk": "^1.26.0", - "dotenv": "^17.2.3" + "dotenv": "^17.2.3", + "zod": "^4.3.6" }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", diff --git a/src/config.ts b/src/config.ts index 1778454..189c98e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,9 +29,6 @@ export function getConfig(cliApiKey?: string) { }; } -// Export validated configuration (for backward compatibility, will use env var) -export const config = getConfig(); - // Paystack API configuration factory export function createPaystackConfig(cliApiKey?: string) { const cfg = getConfig(cliApiKey); @@ -41,6 +38,3 @@ export function createPaystackConfig(cliApiKey?: string) { timeout: 30000, // 30 seconds } as const; } - -// Default paystack config (for backward compatibility) -export const paystackConfig = createPaystackConfig(); diff --git a/src/index.ts b/src/index.ts index 614e0ac..ae61da1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node -import { startServer } from "./server"; // Simple CLI argument parsing function parseApiKey(): string | undefined { @@ -41,15 +39,12 @@ async function main() { process.exit(0); } + const { startServer } = await import("./server"); + // Parse API key from CLI const cliApiKey = parseApiKey(); - // Validate API key format if provided via CLI - if (cliApiKey && !cliApiKey.startsWith('sk_test_')) { - console.error('Error: API key must start with "sk_test_". Only test keys are allowed.'); - process.exit(1); - } - + // Check if we have an API key from CLI or environment if (!cliApiKey && !process.env.PAYSTACK_TEST_SECRET_KEY) { console.error('Error: Paystack API key required.'); diff --git a/src/logger.ts b/src/logger.ts index 068035b..a6aa82d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,4 @@ -import { config } from './config.js'; +import { getConfig } from './config.js'; // Define log levels export enum LogLevel { DEBUG = 'debug', @@ -92,8 +92,8 @@ function redactSensitiveData(obj: any): any { class Logger { private currentLogLevel: LogLevel; - constructor() { - this.currentLogLevel = config.LOG_LEVEL as LogLevel; + constructor(logLevel?: LogLevel) { + this.currentLogLevel = logLevel || LogLevel.INFO; } private shouldLog(level: LogLevel): boolean { @@ -175,4 +175,13 @@ class Logger { } } +/** + * Create a logger instance with configuration + */ +export function createLogger(cliApiKey?: string): Logger { + const config = getConfig(cliApiKey); + return new Logger(config.LOG_LEVEL as LogLevel); +} + +// Default logger instance for backward compatibility (uses environment variable) export const logger = new Logger(); diff --git a/src/paystack-client.ts b/src/paystack-client.ts index 30c8074..b539751 100644 --- a/src/paystack-client.ts +++ b/src/paystack-client.ts @@ -1,7 +1,7 @@ import { PaystackResponse, PaystackError } from "./types"; -import { paystackConfig } from "./config"; +import { createPaystackConfig } from "./config"; -const PAYSTACK_BASE_URL = paystackConfig.baseURL; +const PAYSTACK_BASE_URL = 'https://api.paystack.co'; const USER_AGENT = process.env.USER_AGENT || 'Paystack-MCP-Server/0.0.1'; export class PaystackClient { @@ -92,7 +92,10 @@ export class PaystackClient { } } -// Export singleton instance for backward compatibility -export const paystackClient = new PaystackClient( - paystackConfig.secretKey -); +/** + * Create a PaystackClient instance with configuration + */ +export function createPaystackClient(cliApiKey?: string): PaystackClient { + const config = createPaystackConfig(cliApiKey); + return new PaystackClient(config.secretKey, config.baseURL, undefined, config.timeout); +} diff --git a/test/make-paystack-request-tool.spec.ts b/test/make-paystack-request-tool.spec.ts index 82c64c8..536fdb7 100644 --- a/test/make-paystack-request-tool.spec.ts +++ b/test/make-paystack-request-tool.spec.ts @@ -17,7 +17,8 @@ describe("MakePaystackRequestTool", () => { } } as any; - registerMakePaystackRequestTool(server); + // Pass a test API key to avoid environment variable requirement + registerMakePaystackRequestTool(server, "sk_test_1234567890abcdef1234567890abcdef12345678"); }); it("should return isError: true for non-JSON responses", async () => { @@ -145,4 +146,52 @@ describe("MakePaystackRequestTool", () => { } }); }); + describe("Missing API Key Validation", () => { + let server: McpServer; + let originalExit: typeof process.exit; + let exitCode: number | undefined; + let consoleErrors: string[] = []; + let originalConsoleError: typeof console.error; + + beforeEach(() => { + // Mock process.exit to capture exit calls + exitCode = undefined; + originalExit = process.exit; + process.exit = ((code?: number) => { + exitCode = code || 0; + throw new Error(`Process would exit with code ${exitCode}`); + }) as any; + + // Mock console.error to capture error messages + consoleErrors = []; + originalConsoleError = console.error; + console.error = (...args: any[]) => { + consoleErrors.push(args.join(' ')); + }; + + server = { + registerTool: (name: string, config: any, handler: any) => { } + } as any; + }); + + afterEach(() => { + process.exit = originalExit; + console.error = originalConsoleError; + delete process.env.PAYSTACK_TEST_SECRET_KEY; + }); + + it("should fail when no API key provided via CLI or environment", () => { + // Ensure no environment variable is set + delete process.env.PAYSTACK_TEST_SECRET_KEY; + + try { + registerMakePaystackRequestTool(server); // No cliApiKey parameter + assert.fail("Expected registerMakePaystackRequestTool to throw an error"); + } catch (error: any) { + assert.ok(error.message.includes("Process would exit with code 1")); + assert.strictEqual(exitCode, 1); + assert.ok(consoleErrors.some(msg => msg.includes("PAYSTACK_TEST_SECRET_KEY is required"))); + } + }); + }); }); diff --git a/test/paystack-client.spec.ts b/test/paystack-client.spec.ts index 39f82be..ce98c8d 100644 --- a/test/paystack-client.spec.ts +++ b/test/paystack-client.spec.ts @@ -1,7 +1,10 @@ import assert from "node:assert"; -import { paystackClient } from "../src/paystack-client.js"; +import { createPaystackClient } from "../src/paystack-client.js"; describe("PaystackClient", () => { + // Use a test API key for the test client + const paystackClient = createPaystackClient("sk_test_1234567890abcdef1234567890abcdef12345678"); + 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) From cb2c79a6a7c04cf963d955db9a21e1cd094bcba6 Mon Sep 17 00:00:00 2001 From: andrew-paystack Date: Wed, 4 Mar 2026 18:15:10 +0300 Subject: [PATCH 11/13] chore: remove setup & update readme --- README.md | 1 - SETUP.md | 174 ------------------------------------------------------ 2 files changed, 175 deletions(-) delete mode 100644 SETUP.md diff --git a/README.md b/README.md index ba6ac7a..ef27483 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,6 @@ The Paystack MCP Server exposes the **entire Paystack API** to AI assistants by | Tool | Description | | ------------------------ | ------------------------------------------------------------------ | | `get_paystack_operation` | Fetch operation details (method, path, parameters) by operation ID | -| `get_paystack_operation_guided` | Infers the operation ID from prompt | | `make_paystack_request` | Execute a Paystack API request | ### Available Resources diff --git a/SETUP.md b/SETUP.md deleted file mode 100644 index b7aaaf7..0000000 --- a/SETUP.md +++ /dev/null @@ -1,174 +0,0 @@ -# Editor Setup - -Configure the Paystack MCP Server in supported editors with secure `.env`-based API key management. - -- [Environment Setup](#environment-setup) -- [VS Code](#vs-code) -- [Cursor](#cursor) -- [Claude Desktop](#claude-desktop) - ---- - -## Environment Setup - -Create your environment file: - -1. **Copy the example file:** - - ```bash - cp .env.example .env - ``` - -2. **Add your Paystack test secret key:** - - ```env - PAYSTACK_TEST_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx - ``` - -> [!IMPORTANT] -> - Only **test keys** (starting with `sk_test_`) are accepted. The server rejects live keys. -> - The `.env` file is already in `.gitignore`—never commit it to version control. - ---- - -## VS Code - -VS Code supports the `envFile` property, allowing you to load environment variables from a file instead of hardcoding them. - -### Configuration - -Create or update `.vscode/mcp.json` in your project: - -```json -{ - "servers": { - "paystack": { - "command": "node", - "args": ["/path/to/paystack-mcp/build/index.js"], - "envFile": "${workspaceFolder}/.env" - } - } -} -``` - -> [!NOTE] -> Replace `/path/to/paystack-mcp` with the actual path to your cloned repository. - -### Reload the MCP Server - -After saving the configuration, reload VS Code or run the **"MCP: Restart Server"** command from the Command Palette. - ---- - -## Cursor - -Cursor supports both `envFile` and environment variable interpolation via `${env:VAR_NAME}`. - -### Configuration Locations - -| Scope | File Path | -| ------- | ------------------------ | -| Project | `.cursor/mcp.json` | -| Global | `~/.cursor/mcp.json` | - -### Using `envFile` (Recommended) - -Create `.cursor/mcp.json` in your project: - -```json -{ - "mcpServers": { - "paystack": { - "command": "node", - "args": ["/path/to/paystack-mcp/build/index.js"], - "envFile": "${workspaceFolder}/.env" - } - } -} -``` - ---- - -## Claude Desktop - -Claude Desktop uses an inline `env` object for environment variables. It does not support `envFile`. - -### Configuration Location - -| OS | File Path | -| ------- | ------------------------------------------------------------ | -| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | -| Windows | `%APPDATA%\Claude\claude_desktop_config.json` | - -### Approach A: Inline Environment Variables (Simple) - -Edit your `claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "paystack": { - "command": "node", - "args": ["/path/to/paystack-mcp/build/index.js"], - "env": { - "PAYSTACK_SECRET_KEY": "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - } - } - } -} -``` - -> [!WARNING] -> This approach stores your API key directly in the config file. Ensure this file is not shared or committed to version control. - -### Approach B: Using a Wrapper Script (Secure) - -For better security, create a shell script that loads your `.env` file before starting the server. - -1. **Create a wrapper script** (e.g., `run-paystack-mcp.sh`): - - ```bash - #!/bin/bash - set -a - source /path/to/paystack-mcp/.env - set +a - exec node /path/to/paystack-mcp/build/index.js - ``` - -2. **Make it executable:** - - ```bash - chmod +x /path/to/run-paystack-mcp.sh - ``` - -3. **Update your Claude Desktop config:** - - ```json - { - "mcpServers": { - "paystack": { - "command": "/path/to/run-paystack-mcp.sh" - } - } - } - ``` - ---- - -## Troubleshooting - -### Server not starting - -- Verify Node.js v18+ is installed: `node --version` -- Check the path to `build/index.js` is correct -- Ensure your `.env` file exists and contains a valid `sk_test_*` key - -### Environment variables not loading - -- For VS Code/Cursor: Confirm `envFile` path is correct and the file exists -- For Claude Desktop: Restart the application after config changes - -### "Invalid API key" errors - -- Ensure your key starts with `sk_test_` (live will be rejected) -- Check for trailing whitespace in your `.env` file From 18dea95d1278ec5b77f40147d69654e8f8387cb1 Mon Sep 17 00:00:00 2001 From: andrew-paystack Date: Tue, 10 Mar 2026 17:53:17 +0300 Subject: [PATCH 12/13] Add NPM publish workflow --- .github/workflows/release.yml | 32 ++++++++++++++++++++++++++++++++ package.json | 4 +--- 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2f46bbb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Publish Package to npmjs +on: + release: + types: [published] +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Sync version with release tag + # Updates package.json to the release tag version (e.g., v1.2.3) + run: npm version ${{ github.event.release.tag_name }} --no-git-tag-version + + - name: Build package + run: npm run build + + - name: Publish to npmjs + # No NODE_AUTH_TOKEN needed; setup-node handles OIDC auth + run: npm publish --access public \ No newline at end of file diff --git a/package.json b/package.json index 450cc43..d6b609c 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,7 @@ "inspect": "set DANGEROUSLY_OMIT_AUTH=true && CLIENT_PORT=8090 SERVER_PORT=9000 npx @modelcontextprotocol/inspector npm run dev", "test": "mocha" }, - "files": [ - "build" - ], + "files": [ "build", "README.md" ], "keywords": [ "paystack", "mcp", From 038afadcabdc70cbb1fa0129396ee94b4c4eb623 Mon Sep 17 00:00:00 2001 From: andrew-paystack Date: Wed, 11 Mar 2026 12:21:55 +0300 Subject: [PATCH 13/13] revert to token based publishing --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f46bbb..d3541b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,13 @@ -name: Publish Package to npmjs +name: Publish MCP to npmjs on: release: types: [published] + jobs: build: runs-on: ubuntu-latest permissions: contents: read - id-token: write steps: - uses: actions/checkout@v5 @@ -21,12 +21,12 @@ jobs: run: npm ci - name: Sync version with release tag - # Updates package.json to the release tag version (e.g., v1.2.3) run: npm version ${{ github.event.release.tag_name }} --no-git-tag-version - name: Build package run: npm run build - name: Publish to npmjs - # No NODE_AUTH_TOKEN needed; setup-node handles OIDC auth - run: npm publish --access public \ No newline at end of file + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file