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 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 693b89f..450cc43 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/", @@ -15,7 +20,14 @@ "files": [ "build" ], - "keywords": [], + "keywords": [ + "paystack", + "mcp", + "model-context-protocol", + "api", + "payment", + "integration" + ], "author": "Andrew-Paystack", "license": "MIT", "dependencies": { 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 ec81928..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; @@ -89,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/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", { 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)