diff --git a/.env.example b/.env.example index afb2127..fed6c54 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ PAYSTACK_BASE_URL="https://api.paystack.co" USER_AGENT="paystack-mcp/1.0" -PAYSTACK_SECRET_KEY="sk_secret_key_here" \ No newline at end of file +PAYSTACK_TEST_SECRET_KEY="sk_secret_key_here" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b14daf5..458bcdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", - "@modelcontextprotocol/sdk": "^1.25.3", + "@modelcontextprotocol/sdk": "^1.25.2", "dotenv": "^17.2.3", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "bin": { "paystack": "build/index.js" @@ -22,7 +22,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^25.0.7", "js-yaml": "^4.1.1", - "mocha": "^11.7.5", + "mocha": "^11.3.0", "openapi-types": "^12.1.3", "tsx": "^4.21.0", "typescript": "^5.9.3" @@ -713,6 +713,15 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/inspector-client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@modelcontextprotocol/inspector-server": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-server/-/inspector-server-0.18.0.tgz", @@ -730,6 +739,24 @@ "mcp-inspector-server": "build/index.js" } }, + "node_modules/@modelcontextprotocol/inspector-server/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/inspector/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.3", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", @@ -1836,6 +1863,12 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/ajv/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -2366,9 +2399,9 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2379,6 +2412,7 @@ "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -3024,16 +3058,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -3127,12 +3151,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", @@ -3285,30 +3303,29 @@ } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.3.0.tgz", + "integrity": "sha512-J0RLIM89xi8y6l77bgbX+03PeBRDQCOVQpnwOcCN7b8hCmbh6JvGI2ZDJ5WMoHz+IaPU+S4lvTd0j51GmBAdgQ==", "dev": true, "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^7.0.0", + "diff": "^5.2.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", - "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", + "minimatch": "^5.1.6", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^9.2.0", + "workerpool": "^6.5.1", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" @@ -3321,6 +3338,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4485,9 +4515,9 @@ } }, "node_modules/workerpool": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", - "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", "dev": true, "license": "Apache-2.0" }, @@ -4738,9 +4768,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index c3a50f5..3179e66 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "paystack": "./build/index.js" }, "scripts": { - "build": "tsc", + "build": "tsc && cp -r src/data build/", "build:watch": "tsc --watch", "dev": "tsx src/index.ts", "inspect": "set DANGEROUSLY_OMIT_AUTH=true && CLIENT_PORT=8090 SERVER_PORT=9000 npx @modelcontextprotocol/inspector npm run dev", @@ -21,14 +21,15 @@ "dependencies": { "@modelcontextprotocol/inspector": "^0.18.0", "@modelcontextprotocol/sdk": "^1.25.2", - "zod": "^3.25.76" + "dotenv": "^17.2.3", + "zod": "^4.3.6" }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@types/mocha": "^10.0.10", "@types/node": "^25.0.7", "js-yaml": "^4.1.1", - "mocha": "^11.7.5", + "mocha": "^11.3.0", "openapi-types": "^12.1.3", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/src/index.ts b/src/index.ts index a513a4a..de22c51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,13 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import path from "path"; -import { z } from "zod"; +import * as z from "zod"; import { OpenAPIParser } from "./openapi-parser"; import { CreateMessageResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { paystackClient } from "./paystack-client"; -const PAYSTACK_BASE_URL = process.env.PAYSTACK_BASE_URL || "https://api.paystack.co"; -const USER_AGENT = process.env.USER_AGENT || "paystack-mcp/1.0"; +// const PAYSTACK_BASE_URL = process.env.PAYSTACK_BASE_URL || "https://api.paystack.co"; +// const USER_AGENT = process.env.USER_AGENT || "paystack-mcp/1.0"; // Create server instance const server = new McpServer({ @@ -17,26 +18,50 @@ const server = new McpServer({ const oasPath = path.join(__dirname, "./", "data/paystack.openapi.yaml"); const openapi = new OpenAPIParser(oasPath); -// Temporarily ignore ts error on Zod schema for get_operation method -// @ts-ignore -server.registerTool( - "get_operation", - { - description: "Get Paystack API operation details by operation ID", - annotations: { - title: "Get endpoint details by operation ID", +async function initializeServer() { + // Parse OpenAPI spec before registering tools + await openapi.parse(); + + server.registerTool( + "get_paystack_operation", + { + description: "Get Paystack API operation details by operation ID", + annotations: { + title: "Get endpoint details by operation ID", + }, + inputSchema: { + operation_id: z + .string() + .describe("The operation ID of the Paystack API endpoint"), + } }, - inputSchema: { - operation_id: z - .string() - .describe("The operation ID of the Paystack API endpoint"), - } - }, - async ({ operation_id }) => { - try { - const operation = openapi.getOperationById(operation_id); + async ({ operation_id }) => { + + try { + const operation = openapi.getOperationById(operation_id.trim()); + console.log("Operation: ", operation) + + if (!operation) { + return { + content: [ + { + type: "text", + text: `Operation with ID ${operation_id} not found.`, + }, + ] + } + } - if (!operation) { + return { + content: [ + { + type: "text", + text: JSON.stringify(operation, null, 2), + mimeType: "application/json", + }, + ] + } + } catch { return { content: [ { @@ -46,152 +71,185 @@ server.registerTool( ] } } - - return { - content: [ - { - type: "text", - text: JSON.stringify(operation, null, 2), - mimeType: "application/json", - }, - ] - } - } catch { - return { - content: [ - { - type: "text", - text: `Operation with ID ${operation_id} not found.`, - }, - ] - } } - } -) - -server.registerTool( - "get_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, + ) + + server.registerTool( + "make_paystack_request", + { + description: `Make a Paystack API request using the details of the operation. Be sure + to get all operation details including method, path path parameters, query parameters, + and request body before making a call.`, + annotations: { + title: "Get endpoint details by operation ID", + }, + inputSchema: { + request: z.object({ + method: z.string().describe("HTTP method of the API request"), + path: z.string().describe("Path of the API request"), + data: z.looseObject({}).optional().describe("Request data"), + }) + } }, - }, - async () => { - const res = await server.server.request({ - method: "sampling/createMessage", - params: { - messages: [{ - role: "user", + async ({ request }) => { + try { + console.log("Request received:", request.method); + console.log("Request received:", request.path); + console.log("Request received:", request.data); + + const response = await paystackClient.makeRequest( + request.method, + request.path, + request.data + ) + console.log("response: ", response) + + return { + content: [ + { + type: "text", + text: JSON.stringify(response, null, 2), + mimeType: "application/json", + }, + ] + } + } catch { + return { content: [ { type: "text", - text: `Review the OpenAPI specification and infer the operation ID of the + text: `Unable to make request.`, + }, + ] + } + } + } + ) + + server.registerTool( + "get_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, + }, + ], + }], + maxTokens: 1024, + } + }, CreateMessageResultSchema) + + if (res.content.type !== "text") { + return { + content: [ + { + type: "text", + text: `Could not infer operation ID from user input.`, + } + ] + } } - }, 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.`, + }, + ] + } } } + ) - try { - const operation_id = res.content.text.trim(); - const operation = openapi.getOperationById(operation_id); + server.registerResource( + "operation-list", + new ResourceTemplate("openapi://operations/list", { list: undefined }), + { + description: "Retrieve all operation IDs", + title: "List of Paystack API operation IDs", + mimeType: "text/plain", + }, + async (uri) => { + // await openapi.parse(); + const operations = openapi.getOperations(); + const operationIds = Object.keys(operations); - if (!operation) { + if (operationIds.length === 0) { return { - content: [ + contents: [ { + uri: uri.href, type: "text", - text: `Operation with ID ${operation_id} not found.`, + text: "Unable to list operations.", + mimeType: "text/plain", }, - ], - 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.`, - }, - ] - } - } - } -) - -server.registerResource( - "operation-list", - new ResourceTemplate("openapi://operations/list", {list: undefined}), - { - description: "Retrieve all operation IDs", - title: "List of Paystack API operation IDs", - mimeType: "text/plain", - }, - async (uri) => { - // await openapi.parse(); - const operations = openapi.getOperations(); - const operationIds = Object.keys(operations); - - if (operationIds.length === 0) { return { contents: [ { uri: uri.href, type: "text", - text: "Unable to list operations.", + text: operationIds.join("\n"), mimeType: "text/plain", }, ] } } - - return { - contents: [ - { - uri: uri.href, - type: "text", - text: operationIds.join("\n"), - mimeType: "text/plain", - }, - ] - } - } -) + ) +} async function main() { - await openapi.parse(); + await initializeServer(); const transport = new StdioServerTransport(); await server.connect(transport); console.error("Paystack MCP Server running on stdio..."); diff --git a/src/openapi-parser.ts b/src/openapi-parser.ts index d0d159e..7edb595 100644 --- a/src/openapi-parser.ts +++ b/src/openapi-parser.ts @@ -240,7 +240,7 @@ export class OpenAPIParser { getOperationById(operationId: string): Partial | undefined { - return this.operations[operationId]; + return this.operations[operationId]; } getOperations(): Record> { diff --git a/src/paystack-client.ts b/src/paystack-client.ts index d259bb4..c1fbe10 100644 --- a/src/paystack-client.ts +++ b/src/paystack-client.ts @@ -1,4 +1,4 @@ -import type { PaystackResponse, PaystackError } from "./types.js"; +import { PaystackResponse, PaystackError } from "./types"; import dotenv from 'dotenv'; // Load environment variables @@ -14,7 +14,7 @@ class PaystackClient { private timeout: number; constructor( - secretKey: string, + secretKey: string, baseUrl: string = PAYSTACK_BASE_URL, userAgent: string = USER_AGENT, timeout: number = 30000 @@ -22,7 +22,7 @@ class PaystackClient { if (!secretKey) { throw new Error("Paystack secret key is required"); } - + this.secretKey = secretKey; this.baseUrl = baseUrl; this.userAgent = userAgent; @@ -37,13 +37,13 @@ class PaystackClient { */ async makeRequest( - method: string, - endpoint: string, + method: string, + endpoint: string, data?: any ): Promise> { - + let url = `${this.baseUrl}${endpoint}`; - + const headers: Record = { 'Authorization': `Bearer ${this.secretKey}`, 'User-Agent': this.userAgent, @@ -60,14 +60,14 @@ class PaystackClient { 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 | PaystackError; - + try { responseData = JSON.parse(responseText); } catch (parseError) { @@ -75,7 +75,7 @@ class PaystackClient { } return responseData as PaystackResponse; } 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; @@ -84,8 +84,8 @@ class PaystackClient { throw error; } - } } +} export const paystackClient = new PaystackClient( - process.env.PAYSTACK_SECRET_KEY_TEST! + process.env.PAYSTACK_TEST_SECRET_KEY! ); \ No newline at end of file diff --git a/src/types.ts b/src/types/client.ts similarity index 100% rename from src/types.ts rename to src/types/client.ts diff --git a/src/types/index.ts b/src/types/index.ts index 63e2edc..f9d3852 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,2 @@ -export * as Oas from "./openapi-components" \ No newline at end of file +export * as Oas from "./openapi-components"; +export * from "./client"; \ No newline at end of file