diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d3541b3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Publish MCP to npmjs +on: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + 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 + run: npm version ${{ github.event.release.tag_name }} --no-git-tag-version + + - name: Build package + run: npm run build + + - name: Publish to npmjs + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index d14d7b4..ef27483 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,6 +46,21 @@ 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 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: @@ -91,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/package-lock.json b/package-lock.json index 4cb8917..4538ad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { - "name": "paystack-mcp", - "version": "1.0.0", + "name": "@paystack/mcp-server", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "paystack-mcp", - "version": "1.0.0", + "name": "@paystack/mcp-server", + "version": "0.0.1", "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" }, "bin": { - "paystack": "build/index.js" + "paystack-mcp-server": "build/index.js" }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", @@ -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 f4b6aa8..d6b609c 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-server": "./build/index.js" }, "scripts": { "build": "tsc && cp -r src/data build/", @@ -12,15 +17,20 @@ "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", + "model-context-protocol", + "api", + "payment", + "integration" ], - "keywords": [], "author": "Andrew-Paystack", "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" }, diff --git a/src/config.ts b/src/config.ts index e9a3e33..189c98e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,41 +1,40 @@ 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(); - -// Paystack API configuration -export const paystackConfig = { - baseURL: 'https://api.paystack.co', - secretKey: config.PAYSTACK_TEST_SECRET_KEY, - timeout: 30000, // 30 seconds -} as const; +// 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; +} diff --git a/src/index.ts b/src/index.ts index 168c49b..ae61da1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,59 @@ -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); + } + + const { startServer } = await import("./server"); + + // Parse API key from CLI + const cliApiKey = parseApiKey(); + + + // 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/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 e2e535a..b539751 100644 --- a/src/paystack-client.ts +++ b/src/paystack-client.ts @@ -1,10 +1,10 @@ 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'; -class PaystackClient { +export class PaystackClient { private baseUrl: string; private secretKey: string; private userAgent: string; @@ -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) { @@ -81,8 +89,13 @@ class PaystackClient { throw error; } - } } -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/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/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/get-paystack-operation.ts b/src/tools/get-paystack-operation.ts index f584d99..148ac88 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: `Error retrieving operation: ${errorMessage}`, }, - ] + ], + isError: true } } } diff --git a/src/tools/index.ts b/src/tools/index.ts index 9100200..e63eb49 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,13 +2,12 @@ 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, - openapi: OpenAPIParser + openapi: OpenAPIParser, + cliApiKey?: string ) { registerGetPaystackOperationTool(server, openapi); - registerMakePaystackRequestTool(server); - registerGetOperationGuidedTool(server, openapi); + registerMakePaystackRequestTool(server, cliApiKey); } diff --git a/src/tools/make-paystack-request.ts b/src/tools/make-paystack-request.ts index 7c259ae..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", { @@ -38,13 +43,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..536fdb7 --- /dev/null +++ b/test/make-paystack-request-tool.spec.ts @@ -0,0 +1,197 @@ +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; + + // 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 () => { + // 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 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; + } + }); + }); + 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 new file mode 100644 index 0000000..ce98c8d --- /dev/null +++ b/test/paystack-client.spec.ts @@ -0,0 +1,124 @@ +import assert from "node:assert"; +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) + // 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; + } + }); + }); +});