Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
PAYSTACK_BASE_URL="https://api.paystack.co"
USER_AGENT="paystack-mcp/1.0"
USER_AGENT="paystack-mcp/1.0"
PAYSTACK_SECRET_KEY="sk_secret_key_here"
217 changes: 55 additions & 162 deletions package-lock.json

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Paystack MCP Server

This project implements a server for Paystack's [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server.


## Requirements

- Node.js (v14+ recommended)
- npm or yarn

## Setup

1. Clone the repository:
```
git clone https://github.com/yourusername/paystack-mcp-server.git
cd paystack-mcp
```

2. Install dependencies:
```
npm install
```

3. Configure environment variables:
- Copy `.env.example` to `.env` and update with your Paystack credentials and server settings.

4. Start the server:
```
npm start
```

## Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

## License

MIT
41 changes: 41 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import dotenv from 'dotenv';
import { z } from 'zod';

// Load environment variables from .env file
dotenv.config();

// Define schema for required environment variables
const envSchema = z.object({
PAYSTACK_SECRET_KEY_TEST: z.string().min(30, 'PAYSTACK_SECRET_KEY_TEST is required').refine(val => val.startsWith('sk_test_'), {
message: 'PAYSTACK_SECRET_KEY_TEST 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_SECRET_KEY_TEST: process.env.PAYSTACK_SECRET_KEY_TEST,
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;
}
}

// Export validated configuration
export const config = validateEnv();

// Paystack API configuration
export const paystackConfig = {
baseURL: 'https://api.paystack.co',
secretKey: config.PAYSTACK_SECRET_KEY_TEST,
timeout: 30000, // 30 seconds
} as const;
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ async function main() {
console.error("Paystack MCP Server running on stdio...");
}


main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
Expand Down
178 changes: 178 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { config } from './config.js';
// Define log levels
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
}

// Map log levels to numeric values for comparison
const logLevelValues: Record<LogLevel, number> = {
[LogLevel.DEBUG]: 0,
[LogLevel.INFO]: 1,
[LogLevel.WARN]: 2,
[LogLevel.ERROR]: 3,
};

// Sensitive field patterns to redact
const SENSITIVE_PATTERNS = [
/authorization/i,
/secret/i,
/token/i,
/api[_-]?key/i,
/bearer/i,
/credential/i,
/secret[_-]?key/i,
/cvv/i,
/number/i,
];
/**
* Redact sensitive fields in card objects
* Only redacts cvv and number, keeps other fields visible
*/
function redactCardObject(card: any): any {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function being used? If so how is it being used? There's no endpoint that allows passing card details and responses containing card details are masked already

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@damilola-paystack the function is called in the redactSensitiveData part and it just checks if someone has passed a card object.

if (Array.isArray(card)) {
return card.map(redactCardObject);
}

if (typeof card === 'object' && card !== null) {
const redactedCard: any = {};
for (const [key, value] of Object.entries(card)) {
// Only redact cvv and number fields in card object
if (key === 'cvv' || key === 'number') {
redactedCard[key] = '[REDACTED]';
} else {
// Keep other card fields but recursively redact if they're objects
redactedCard[key] = redactSensitiveData(value);
}
}
return redactedCard;
}

return card;
}


function redactSensitiveData(obj: any): any {
if (obj === null || obj === undefined) {
return obj;
}

if (typeof obj === 'string') {
// Redact bearer tokens and API keys in strings
return obj.replace(/Bearer\s+\w+/gi, 'Bearer [REDACTED]')
.replace(/sk_test_\w+/g, '[REDACTED_SECRET_KEY]');
}

if (Array.isArray(obj)) {
return obj.map(redactSensitiveData);
}

if (typeof obj === 'object') {
const redacted: any = {};
for (const [key, value] of Object.entries(obj)) {
// Special handling for card objects - only redact cvv and number
if (key.toLowerCase() === 'card' && typeof value === 'object' && value !== null) {
redacted[key] = redactCardObject(value);
}
// Check if key matches sensitive patterns
else if (SENSITIVE_PATTERNS.some(pattern => pattern.test(key))) {
redacted[key] = '[REDACTED]';
} else {
redacted[key] = redactSensitiveData(value);
}
}
return redacted;
}

return obj;
}

class Logger {
private currentLogLevel: LogLevel;

constructor() {
this.currentLogLevel = config.LOG_LEVEL as LogLevel;
}

private shouldLog(level: LogLevel): boolean {
return logLevelValues[level] >= logLevelValues[this.currentLogLevel];
}

private formatLog(level: LogLevel, message: string, meta?: any) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level,
message,
...(meta && { meta: redactSensitiveData(meta) }),
};

return JSON.stringify(logEntry);
}

debug(message: string, meta?: any) {
// Disabled for MCP stdio communication
if (this.shouldLog(LogLevel.DEBUG)) {
console.error(this.formatLog(LogLevel.DEBUG, message, meta));
}
}

info(message: string, meta?: any) {
// Disabled for MCP stdio communication
if (this.shouldLog(LogLevel.INFO)) {
console.error(this.formatLog(LogLevel.INFO, message, meta));
}
}

warn(message: string, meta?: any) {
// Disabled for MCP stdio communication
if (this.shouldLog(LogLevel.WARN)) {
console.error(this.formatLog(LogLevel.WARN, message, meta));
}
}

error(message: string, meta?: any) {
// Disabled for MCP stdio communication
if (this.shouldLog(LogLevel.ERROR)) {
console.error(this.formatLog(LogLevel.ERROR, message, meta));
}
}

/**
* Log API request
*/
logRequest(method: string, url: string, data?: any, headers?: any) {
this.debug('API Request', {
method,
url,
data,
headers,
});
}

/**
* Log API response
*/
logResponse(method: string, url: string, status: number, data?: any) {
this.debug('API Response', {
method,
url,
status,
data,
});
}

/**
* Log tool call
*/
logToolCall(toolName: string, params?: any) {
this.info('Tool called', {
tool: toolName,
params,
});
}
}

export const logger = new Logger();
91 changes: 91 additions & 0 deletions src/paystack-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { PaystackResponse, PaystackError } from "./types.js";
import dotenv from 'dotenv';

// Load environment variables
dotenv.config();

const PAYSTACK_BASE_URL = process.env.PAYSTACK_BASE_URL || 'https://api.paystack.co';
const USER_AGENT = process.env.USER_AGENT || 'Paystack-MCP-Client';

class PaystackClient {
private baseUrl: string;
private secretKey: string;
private userAgent: string;
private timeout: number;

constructor(
secretKey: string,
baseUrl: string = PAYSTACK_BASE_URL,
userAgent: string = USER_AGENT,
timeout: number = 30000
) {
if (!secretKey) {
throw new Error("Paystack secret key is required");
}

this.secretKey = secretKey;
this.baseUrl = baseUrl;
this.userAgent = userAgent;
this.timeout = timeout;
}

/**
* Make an HTTP request to Paystack API
* @param method - HTTP method (GET, POST, PUT, DELETE, etc.)
* @param endpoint - API endpoint path
* @param data - Request body for POST/PUT/PATCH or query params for GET
*/

async makeRequest<T>(
method: string,
endpoint: string,
data?: any
): Promise<PaystackResponse<T>> {

let url = `${this.baseUrl}${endpoint}`;

const headers: Record<string, string> = {
'Authorization': `Bearer ${this.secretKey}`,
'User-Agent': this.userAgent,
'Accept': 'application/json',
};

const options: RequestInit = {
method: method.toUpperCase()
};

// Add Content-Type and body for requests with data
if (data && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase())) {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(data);
}
options.headers = headers;

try {
const response = await fetch(url, options);

// Parse response
const responseText = await response.text();
let responseData: PaystackResponse<T> | PaystackError;

try {
responseData = JSON.parse(responseText);
} catch (parseError) {
throw new Error(`Invalid JSON response: ${responseText}`);
}
return responseData as PaystackResponse<T>;
} catch (error) {

if (error !== null && (error as any).name === 'NetworkError') {
const timeoutError = new Error(`Request timeout after ${this.timeout} ms`);
(timeoutError as any).statusCode = 408;
throw timeoutError;
}
throw error;
}

}
}
export const paystackClient = new PaystackClient(
process.env.PAYSTACK_SECRET_KEY_TEST!
);
23 changes: 23 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface PaystackResponse<T = any> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Andrew-Paystack, is there a reason for generic here if it defaults to any?

status: boolean;
message: string;
data: T;
meta?: {
next?: string;
previous?: string;
perPage?: number;
page?: number;
pageCount?: number;
total?: number;
};
}

export interface PaystackError {
status: boolean;
message: string;
meta?: {
nextStep?: string;
},
type?: string;
code?: string;
}
Loading