-
Notifications
You must be signed in to change notification settings - Fork 10
Feat/add test endpoint #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
10238c3
resource and tool setup
Andrew-Paystack 1fa80dd
refactor Paystack Client and update ts config
Andrew-Paystack a733a9b
update moduleResolution
Andrew-Paystack 53f8819
Merge branch 'main' into feat/add-test-endpoint
Andrew-Paystack 9152980
update paystack client and add test key validation
Andrew-Paystack 1e65b1a
Merge branch 'main' into feat/add-test-endpoint
Andrew-Paystack 0305e43
clean up resources and tools folders
Andrew-Paystack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| 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(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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! | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| export interface PaystackResponse<T = any> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
redactSensitiveDatapart and it just checks if someone has passed a card object.