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
325 changes: 223 additions & 102 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,4 @@ See [contributing.md](contributing.md) for more details.

## License

MIT
MIT
8 changes: 4 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ 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."',
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'),
Expand All @@ -17,7 +17,7 @@ const envSchema = z.object({
function validateEnv() {
try {
return envSchema.parse({
PAYSTACK_SECRET_KEY_TEST: process.env.PAYSTACK_SECRET_KEY_TEST,
PAYSTACK_TEST_SECRET_KEY: process.env.PAYSTACK_TEST_SECRET_KEY,
NODE_ENV: process.env.NODE_ENV || 'development',
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
});
Expand All @@ -36,6 +36,6 @@ export const config = validateEnv();
// Paystack API configuration
export const paystackConfig = {
baseURL: 'https://api.paystack.co',
secretKey: config.PAYSTACK_SECRET_KEY_TEST,
secretKey: config.PAYSTACK_TEST_SECRET_KEY,
timeout: 30000, // 30 seconds
} as const;
256 changes: 2 additions & 254 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,261 +1,9 @@
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import path from "path";
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";

// Create server instance
const server = new McpServer({
name: "paystack",
version: "1.0.0",
});

const oasPath = path.join(__dirname, "./", "data/paystack.openapi.yaml");
const openapi = new OpenAPIParser(oasPath);

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"),
}
},
async ({ operation_id }) => {

try {
const operation = openapi.getOperationById(operation_id.trim());
console.error("Operation: ", operation)

if (!operation) {
return {
content: [
{
type: "text",
text: `Operation with ID ${operation_id} not found.`,
},
]
}
}

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(
"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 ({ request }) => {
try {
console.error("Request received:", request.method);
console.error("Request received:", request.path);
console.error("Request received:", request.data);

const response = await paystackClient.makeRequest(
request.method,
request.path,
request.data
)
console.error("response: ", response)

return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
mimeType: "application/json",
},
]
}
} catch {
return {
content: [
{
type: "text",
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,
}
}, 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.`,
},
]
}
}
}
)

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.",
mimeType: "text/plain",
},
]
}
}

return {
contents: [
{
uri: uri.href,
type: "text",
text: operationIds.join("\n"),
mimeType: "text/plain",
},
]
}
}
)
}
import { startServer } from "./server";

async function main() {
await initializeServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Paystack MCP Server running on stdio...");
await startServer();
}


main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
Expand Down
10 changes: 10 additions & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OpenAPIParser } from "../openapi-parser";
import { registerOperationListResource } from "./paystack-operation-list";

export function registerAllResources(
server: McpServer,
openapi: OpenAPIParser
) {
registerOperationListResource(server, openapi);
}
39 changes: 39 additions & 0 deletions src/resources/paystack-operation-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OpenAPIParser } from "../openapi-parser";

export function registerOperationListResource(
server: McpServer,
openapi: OpenAPIParser
) {
server.registerResource(
"paystack_operation_list",
"paystack://operations/list",
{
description: "Retrieve all Paystack API details",
title: "Paystack API details",
mimeType: "application/json",
},
async (uri) => {
const operations = openapi.getOperations();

if (Object.keys(operations).length === 0) {
return {
contents: [
{
uri: uri.href,
text: JSON.stringify({"message": "Unable to retrive all operations"}),
mimeType: "application/json",
},
]
}
}
return {
contents: [{
uri: uri.href,
text: JSON.stringify(operations, null, 2),
mimeType: "application/json"
}]
};
}
);
}
31 changes: 31 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import path from "path";
import { OpenAPIParser } from "./openapi-parser";
import { registerAllTools } from "./tools";
import { registerAllResources } from "./resources";

async function createServer() {
const server = new McpServer({
name: "paystack",
version: "1.0.0",
});

const oasPath = path.join(__dirname, "./", "data/paystack.openapi.yaml");
const openapi = new OpenAPIParser(oasPath);

await openapi.parse();

registerAllTools(server, openapi);
registerAllResources(server, openapi);

return server;
}

export async function startServer() {
const server = await createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Paystack MCP Server running on stdio...");
return server;
}
Loading