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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ node_modules/
!.env.example

# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.vscode-test
.vscode
6 changes: 4 additions & 2 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"servers": {
"test-mcp-server": {
"type": "stdio",
"command": "node",
"command": "/Users/andrew/.nvm/versions/node/v22.12.0/bin/node",
"args": [
"build/index.js"
],
Expand All @@ -13,7 +13,9 @@
"type": "node"
}
},
"env": {}
"env": {
"PAYSTACK_TEST_SECRET_KEY": "sk_test_23bb8447ce48d687dfbd9017afcc63321ef1cde9"
}
}
}
}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,13 @@ When you ask your AI assistant something like _"Get me the last 5 transactions o

### Prompt recommendation

To get the best results when using this MCP server, be specific in your prompts and always include "Paystack" in your requests. This helps the LLM quickly identify and use the appropriate Paystack tools.
To get the best results when using this MCP server, be specific in your prompts and always include "Paystack" in your requests. The server provides built-in instructions and a knowledge resource (`paystack://skill`) that help the AI assistant find the right documentation, code snippets, and API details.

**Good prompts:**
- "Initialize a Paystack transaction for 50000 NGN"
- "Create a customer with email user@example.com on my Paystack account"
- "How can I send money with the Paystack API?"
- "Show me a cURL example for verifying a Paystack transaction"

**Less effective prompts:**
- "List my transactions" (unclear which service to use)
Expand Down
42 changes: 42 additions & 0 deletions src/data/paystack-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
name: paystack-skill
description: Guide users through using the Paystack's products and APIs. Use this when users ask for sample code, guides, example usage, references, webhook implementation or similar structured content. Trigger when you're not certain based on information from existing resources.
---

## Documentation Index

For integration guides, best practices, and detailed documentation, use the Paystack LLM-friendly docs index:
https://paystack.com/docs/llms.txt

This index covers payments, transfers, terminal, guides, libraries, API reference, and API changelog.

## Code Snippets

Code snippets for Paystack API endpoints and integration guides are maintained in the PaystackOSS/doc-code-snippets repository. Snippets are available in JavaScript (Node.js), Shell/cURL, and PHP.

### Browsing Snippets
- API Reference snippets: https://github.com/PaystackOSS/doc-code-snippets/tree/main/src/api
- Documentation snippets: https://github.com/PaystackOSS/doc-code-snippets/tree/main/src/doc

### Fetching a Specific Snippet
Once you know the exact path from browsing, fetch the raw content at:
https://raw.githubusercontent.com/PaystackOSS/doc-code-snippets/main/src/api/{topic}/{action}/index.{js,sh}

For example:
- https://raw.githubusercontent.com/PaystackOSS/doc-code-snippets/main/src/api/transactions/initialize/index.js
- https://raw.githubusercontent.com/PaystackOSS/doc-code-snippets/main/src/api/transactions/initialize/index.sh

### Snippet Notes
- Snippets use placeholder values like "SECRET_KEY" or "YOUR_SECRET_KEY" — replace with actual test keys
- Not every endpoint has a snippet; if unavailable, construct the request from the operation details provided by the "get_paystack_operation" tool
- The supported webhook events are in https://raw.githubusercontent.com/PaystackOSS/doc-code-snippets/main/dist/doc/payments/webhooks/events.js

## Payment Channels by Country

| Country | Currencies | Payment Channels |
|---------|----------|-----------------|
| Nigeria | NGN, USD | Cards (Visa, Mastercard, Verve, Amex), Bank Transfer, USSD, QR Code, Apple Pay |
| Ghana | GHS | Cards (Visa, Mastercard, Verve), Mobile Money (MTN, AirtelTigo, Telecel), Bank Transfer, QR Code |
| South Africa | ZAR | Cards (Visa, Mastercard, Verve, Amex), Apple Pay, Scan to Pay, Capitec Pay, Ozow |
| Kenya | KES, USD | Cards (Visa, Mastercard, Verve), Mobile Money (M-PESA, Airtel Money), Bank Transfers (Pesalink)
| Côte d'Ivoire | XOF | Cards (Visa, Mastercard, Verve), Mobile Money (MTN MoMo, Wave, Orange Money), Apple Pay |
5 changes: 4 additions & 1 deletion src/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OpenAPIParser } from "../openapi-parser";
import { registerOperationListResource } from "./paystack-operation-list";
import { registerSkillResource } from "./paystack-skill";

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

export function registerSkillResource(server: McpServer, skillContent: string) {
server.registerResource(
"paystack_skill",
"paystack://skill",
{
description:
"Paystack developer knowledge: docs index pointer, code snippet URL patterns, and payment channel reference",
title: "Paystack Developer Knowledge",
mimeType: "text/markdown",
},
async (uri) => {
return {
contents: [
{
uri: uri.href,
text: skillContent,
mimeType: "text/markdown",
},
],
};
}
);
}
31 changes: 30 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,49 @@ import path from "path";
import { OpenAPIParser } from "./openapi-parser";
import { registerAllTools } from "./tools";
import { registerAllResources } from "./resources";
import { loadSkillContent } from "./skill-loader";

const SERVER_INSTRUCTIONS = `You are connected to the Paystack MCP Server, which provides access to the full Paystack API.

## Source Priority
1. Use this server's tools and resources as the primary source of truth for API details:
- "paystack_operation_list" resource: browse all available API operations
- "get_paystack_operation" tool: get endpoint details (method, path, parameters, request body)
- "make_paystack_request" tool: execute API requests
2. For integration guides, best practices, and deeper context, refer to Paystack documentation at https://paystack.com/docs/llms.txt
3. For code snippets in JS/TS or cURL, refer to the "paystack_skill" resource
4. If information is not available from the above sources, say so clearly — do not invent Paystack-specific details

## Critical Accuracy Rules
- All amounts must be in the smallest currency unit: kobo (NGN), pesewas (GHS), cents (ZAR/KES/USD). XOF has no subunit but amounts must still be multiplied by 100.
- API requests require authentication: secret keys (server-side) or public keys (client-side only for Popup/Mobile SDKs)
- This server only accepts test keys (sk_test_*). Never use live keys.
- Prefer to use Webhooks for event-driven flows based on your paystack-skills file.
- Always verify transactions server-side before delivering value
- Validate webhook signatures using your secret key before processing events

## Workflow
Always call "get_paystack_operation" to get endpoint details before calling "make_paystack_request". Do not guess endpoint paths, methods, or parameter names.
`;

async function createServer(cliApiKey?: string) {
const server = new McpServer({
name: "paystack",
version: "0.0.1",
}, {
instructions: SERVER_INSTRUCTIONS,
});

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

await openapi.parse();

const bundledSkillPath = path.join(__dirname, "data", "paystack-skill.md");
const skillContent = await loadSkillContent(bundledSkillPath);

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

return server;
}
Expand Down
49 changes: 49 additions & 0 deletions src/skill-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { homedir } from "node:os";

const DEFAULT_SKILL_URL =
"https://raw.githubusercontent.com/PaystackOSS/paystack-mcp-server/main/src/data/paystack-skill.md";

const CACHE_DIR = path.join(homedir(), ".paystack-mcp");
const CACHE_PATH = path.join(CACHE_DIR, "skill-cache.md");
const MAX_AGE_MS = 48 * 60 * 60 * 1000; // 48 hours

export async function loadSkillContent(bundledPath: string): Promise<string> {
const url = process.env.PAYSTACK_SKILL_URL || DEFAULT_SKILL_URL;

try {
// Check disk cache freshness
const stat = await fs.stat(CACHE_PATH);
if (Date.now() - stat.mtimeMs < MAX_AGE_MS) {
return await fs.readFile(CACHE_PATH, "utf-8");
}
} catch {
// Cache missing or unreadable — continue to fetch
}

try {
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const text = await res.text();
if (text.includes("name: paystack")) {
try {
await fs.mkdir(CACHE_DIR, { recursive: true });
await fs.writeFile(CACHE_PATH, text, "utf-8");
} catch {
// Cache write failed — non-fatal
}
return text;
}
}
} catch {
// Fetch failed or timed out — fall through
}

// Fallback: stale cache → bundled file
try {
return await fs.readFile(CACHE_PATH, "utf-8");
} catch {
return await fs.readFile(bundledPath, "utf-8");
}
}
128 changes: 128 additions & 0 deletions test/paystack-skill-resource.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import assert from "node:assert";
import * as fs from "node:fs";
import * as path from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSkillResource } from "../src/resources/paystack-skill.js";

const SKILL_CONTENT = fs.readFileSync(
path.join(__dirname, "..", "src", "data", "paystack-skill.md"),
"utf-8"
);

describe("PaystackSkillResource", () => {
let resourceHandler: any;
let registeredName: string;
let registeredUri: string;
let registeredMetadata: any;

before(() => {
const server = {
registerResource: (name: string, uri: string, metadata: any, handler: any) => {
registeredName = name;
registeredUri = uri;
registeredMetadata = metadata;
resourceHandler = handler;
}
} as any;

registerSkillResource(server, SKILL_CONTENT);
});

describe("Registration", () => {
it("should register with the correct name", () => {
assert.strictEqual(registeredName, "paystack_skill");
});

it("should register with the correct URI", () => {
assert.strictEqual(registeredUri, "paystack://skill");
});

it("should set mimeType to text/markdown", () => {
assert.strictEqual(registeredMetadata.mimeType, "text/markdown");
});

it("should have a description", () => {
assert.ok(registeredMetadata.description);
assert.ok(registeredMetadata.description.length > 0);
});
});

describe("Content", () => {
let content: string;

before(async () => {
const mockUri = new URL("paystack://skill");
const result = await resourceHandler(mockUri);
content = result.contents[0].text;
});

it("should return text/markdown mimeType in response", async () => {
const mockUri = new URL("paystack://skill");
const result = await resourceHandler(mockUri);
assert.strictEqual(result.contents[0].mimeType, "text/markdown");
});

it("should include documentation index section", () => {
assert.ok(content.includes("## Documentation Index"));
});

it("should include code snippets section", () => {
assert.ok(content.includes("## Code Snippets"));
});

it("should include payment channels by country section", () => {
assert.ok(content.includes("## Payment Channels by Country"));
});

it("should include links to Paystack docs", () => {
assert.ok(content.includes("https://paystack.com/docs/llms.txt"));
});

it("should include snippet repo URL pattern for JS", () => {
assert.ok(content.includes("PaystackOSS/doc-code-snippets"));
assert.ok(content.includes("index.js"));
});

it("should include snippet repo URL pattern for Shell", () => {
assert.ok(content.includes("index.sh"));
});

it("should include all supported countries", () => {
assert.ok(content.includes("Nigeria"));
assert.ok(content.includes("Ghana"));
assert.ok(content.includes("South Africa"));
assert.ok(content.includes("Kenya"));
assert.ok(content.includes("Côte d'Ivoire"));
});
});
});

describe("ServerInstructions", () => {
it("should pass instructions to the McpServer constructor", async () => {
// Dynamically import server module to verify instructions are set
// We check the source directly since McpServer options are private
const fs = await import("node:fs");
const path = await import("node:path");
const serverSource = fs.readFileSync(
path.join(__dirname, "../src/server.ts"),
"utf-8"
);

assert.ok(
serverSource.includes("instructions:"),
"server.ts should pass instructions to McpServer"
);
assert.ok(
serverSource.includes("smallest currency unit"),
"instructions should include currency unit rule"
);
assert.ok(
serverSource.includes("get_paystack_operation"),
"instructions should reference the operation tool"
);
assert.ok(
serverSource.includes("do not invent"),
"instructions should include anti-hallucination directive"
);
});
});