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
199 changes: 106 additions & 93 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion openapi/monnify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2662,7 +2662,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/disbursements/account/validate:
/api/v2/disbursements/account/validate:
get:
tags:
- Verification APIs
Expand Down
13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@monnify/mcp-server",
"version": "1.0.0",
"description": "Production-grade MCP server for Monnify payment APIs — TypeScript, OpenAPI-driven",
"version": "1.0.1",
"description": "MCP server for Monnify payment gateway APIs",
"type": "module",
"main": "build/index.js",
"bin": {
Expand Down Expand Up @@ -50,6 +50,13 @@
"engines": {
"node": ">=20.0.0"
},
"keywords": ["monnify", "mcp", "payments", "nigeria", "fintech", "llm-agents"],
"keywords": [
"monnify",
"mcp",
"payments",
"nigeria",
"fintech",
"llm-agents"
],
"license": "MIT"
}
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ program
)
.option(
"--tools <categories>",
"Comma-separated list of tool categories to enable: collections,directDebit,verification,utilities"
"Comma-separated list of tool categories to enable: collections,directDebit,verification,utilities,subAccounts"
)
.option(
"--format <format>",
Expand Down
5 changes: 5 additions & 0 deletions src/client/monnifyClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ export async function apiPatch<T>(path: string, body?: unknown): Promise<T> {
return (data as unknown as { responseBody: T }).responseBody;
}

export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const { data } = await monnifyClient.put<{ responseBody: T }>(path, body);
return (data as unknown as { responseBody: T }).responseBody;
}

export async function apiDelete<T>(path: string): Promise<T> {
const { data } = await monnifyClient.delete<{ responseBody: T }>(path);
return (data as unknown as { responseBody: T }).responseBody;
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ if (isOperationAllowed("directDebit", env())) {
await import("./tools/directDebit/getMandateDebitStatus.js");
await import("./tools/directDebit/cancelMandate.js");
}
if (isOperationAllowed("subAccounts", env())) {
await import("./tools/subAccounts/createSubAccounts.js");
await import("./tools/subAccounts/getSubAccounts.js");
await import("./tools/subAccounts/updateSubAccount.js");
await import("./tools/subAccounts/deleteSubAccount.js");
}

export const server = new Server(
{ name: "monnify-mcp", version: "1.0.0" },
Expand Down
2 changes: 2 additions & 0 deletions src/schemas/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const AuthoriseCard3dsBodySchema = s["Authorize3DSCardRequest"]!;
export const CreateMandateBodySchema = s["CreateMandateRequest"]!;
export const DebitMandateBodySchema = s["DebitMandateRequest"]!;
export const VerifyBvnBodySchema = s["BVNVerificationRequest"]!;
export const CreateSubAccountBodySchema = s["CreateSubAccountRequest"]!;
export const UpdateSubAccountBodySchema = s["UpdateSubAccountRequest"]!;

export const CancelMandateBodySchema = z
.object({
Expand Down
21 changes: 17 additions & 4 deletions src/schemas/extended/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export const InitiatePaymentInputSchema = InitiatePaymentBodySchema.extend({
subAccountCode: z
.string()
.optional()
.describe("Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228')."),
.describe(
"Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228'). Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature."
),
feePercentage: z
.number()
.optional()
Expand Down Expand Up @@ -164,7 +166,9 @@ export const ReserveAccountInputSchema = ReserveAccountBodySchema.extend({
subAccountCode: z
.string()
.optional()
.describe("Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228')."),
.describe(
"Monnify sub-account code to receive the split (e.g. 'MFY_SUB_319452883228'). Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature."
),
feePercentage: z
.number()
.optional()
Expand Down Expand Up @@ -222,8 +226,12 @@ export const CreateInvoiceInputSchema = CreateInvoiceBodySchema.extend({
.describe("Currency code — currently only NGN is supported."),
expiryDate: z
.string()
.regex(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
"expiryDate must be in 'YYYY-MM-DD HH:mm:ss' format (space-separated, not ISO 8601 — e.g. '2024-12-19 10:15:30')"
)
.describe(
"Invoice expiry date in ISO 8601 format (YYYY-MM-DDTHH:mm:ss). After this date the invoice can no longer be paid."
"Invoice expiry date in 'YYYY-MM-DD HH:mm:ss' format (space-separated — NOT ISO 8601, e.g. '2024-12-19 10:15:30'). After this date the invoice can no longer be paid."
),
paymentMethods: z
.array(z.enum(["CARD", "ACCOUNT_TRANSFER", "USSD", "PHONE_NUMBER"]))
Expand Down Expand Up @@ -441,7 +449,12 @@ export const ChargeCardTokenInputSchema = z.object({
incomeSplitConfig: z
.array(
z.object({
subAccountCode: z.string().optional().describe("Sub-account code to receive the split."),
subAccountCode: z
.string()
.optional()
.describe(
"Sub-account code to receive the split. Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature."
),
feePercentage: z.number().optional().describe("Percentage of the fee borne by this sub-account."),
splitPercentage: z.number().optional().describe("Percentage of the amount credited to this sub-account."),
splitAmount: z.number().optional().describe("Fixed amount credited to this sub-account per transaction."),
Expand Down
7 changes: 6 additions & 1 deletion src/schemas/extended/directDebit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ export const DebitMandateInputSchema = DebitMandateBodySchema.extend({
incomeSplitConfig: z
.array(
z.object({
subAccountCode: z.string().optional().describe("Sub-account code to receive the split."),
subAccountCode: z
.string()
.optional()
.describe(
"Sub-account code to receive the split. Sub Accounts are disabled by default — email integration-support@monnify.com to enable this feature."
),
feePercentage: z.number().optional().describe("Percentage of the fee borne by this sub-account."),
splitAmount: z.number().optional().describe("Fixed amount credited to this sub-account per debit."),
splitPercentage: z.number().optional().describe("Percentage of the debit amount credited to this sub-account."),
Expand Down
85 changes: 85 additions & 0 deletions src/schemas/extended/subAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { z } from "zod";
import {
CreateSubAccountBodySchema,
UpdateSubAccountBodySchema,
} from "../compat.js";

const SubAccountItemSchema = CreateSubAccountBodySchema.extend({
currencyCode: z
.enum(["NGN"])
.default("NGN")
.describe("Settlement currency — currently only NGN is supported."),
accountNumber: z
.string()
.length(10)
.describe(
"10-digit NUBAN account number that should receive the split."
),
bankCode: z
.string()
.length(3)
.describe(
"3-digit bank code where accountNumber is domiciled. Call monnify_get_supported_banks to look this up."
),
email: z
.string()
.email()
.describe("Email address tied to this sub-account — receives settlement notifications."),
defaultSplitPercentage: z
.number()
.min(0)
.max(100)
.describe(
"Default percentage (0-100) of each transaction routed to this sub-account when no per-transaction split is specified."
),
});

export const CreateSubAccountsInputSchema = z.object({
subAccounts: z
.array(SubAccountItemSchema)
.min(1)
.describe("One or more sub-accounts to create in a single call."),
});

export const GetSubAccountsInputSchema = z.object({});

export const UpdateSubAccountInputSchema = UpdateSubAccountBodySchema.extend({
subAccountCode: z
.string()
.min(1)
.describe(
"The sub-account code to update — returned as subAccountCode from monnify_create_sub_accounts or monnify_get_sub_accounts."
),
currencyCode: z
.enum(["NGN"])
.default("NGN")
.describe("Settlement currency — currently only NGN is supported."),
accountNumber: z
.string()
.length(10)
.describe("10-digit NUBAN account number that should receive the split."),
bankCode: z
.string()
.length(3)
.describe(
"3-digit bank code where accountNumber is domiciled. Call monnify_get_supported_banks to look this up."
),
email: z
.string()
.email()
.describe("Email address tied to this sub-account."),
defaultSplitPercentage: z
.number()
.min(0)
.max(100)
.describe(
"Default percentage (0-100) of each transaction routed to this sub-account when no per-transaction split is specified."
),
});

export const DeleteSubAccountInputSchema = z.object({
subAccountCode: z
.string()
.min(1)
.describe("The sub-account code to permanently delete."),
});
3 changes: 2 additions & 1 deletion src/security/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export type OperationCategory =
| "collections"
| "directDebit"
| "verification"
| "utilities";
| "utilities"
| "subAccounts";

const API_KEY_PATTERN = /^MK_(TEST|PROD)_[A-Za-z0-9]{10}/;

Expand Down
25 changes: 25 additions & 0 deletions src/security/sanitiser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,19 @@ const DEALLOCATE_ACCOUNT_FIELDS = [
"status",
] as const;

// Sub Accounts — whitelisted fields
const SUB_ACCOUNT_FIELDS = [
"subAccountCode",
"accountNumber",
"accountName",
"currencyCode",
"email",
"bankCode",
"bankName",
"defaultSplitPercentage",
"settlementProfileCode",
] as const;

// Bank list — whitelisted fields
const BANK_FIELDS = ["name", "code"] as const;

Expand Down Expand Up @@ -337,6 +350,18 @@ export function sanitiseNinResponse(
return pickFields(raw, NIN_FIELDS);
}

export function sanitiseSubAccountResponse(
raw: Record<string, unknown>
): Record<(typeof SUB_ACCOUNT_FIELDS)[number], unknown> {
return pickFields(raw, SUB_ACCOUNT_FIELDS);
}

export function sanitiseSubAccountListResponse(
raw: Array<Record<string, unknown>>
): Array<Record<(typeof SUB_ACCOUNT_FIELDS)[number], unknown>> {
return raw.map((account) => pickFields(account, SUB_ACCOUNT_FIELDS));
}

export function sanitiseBankListResponse(
raw: Array<Record<string, unknown>>
): Array<Record<(typeof BANK_FIELDS)[number], unknown>> {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/collections/processRefund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ const definition: Tool = {

WHEN TO USE: To reverse a charge at a customer's request, after an order cancellation, or when a duplicate or suspicious charge is detected. Can be a full or partial refund.

PREREQUISITES: The original transaction must have paymentStatus = PAID. Obtain the transactionReference from monnify_get_transaction_status. Verify the destination account with monnify_verify_bank_account first.
PREREQUISITES: The original transaction must have paymentStatus = PAID AND paymentMethod = ACCOUNT_TRANSFER — check both via monnify_get_transaction_status or monnify_get_transaction_details first. Transactions paid by CARD or USSD cannot be refunded through this API at all; the request will be rejected regardless of amount. Verify the destination account with monnify_verify_bank_account first. The Refund API is also disabled by default — a "not permitted" response means it needs to be enabled on your account by emailing integration-support@monnify.com, not that the request was malformed.

SIDE EFFECTS: Initiates a real fund transfer back to the customer. This is a financial operation — confirm the transaction reference and refund amount carefully before proceeding. Using the same refundReference is safe (idempotent — will not double-refund).
SIDE EFFECTS: Initiates a real fund transfer back to the customer's bank account. This is a financial operation — confirm the transaction reference and refund amount carefully before proceeding. Using the same refundReference is safe (idempotent — will not double-refund).

MFA NOTE: Not applicable.

Expand Down
2 changes: 1 addition & 1 deletion src/tools/directDebit/createMandate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const definition: Tool = {

WHEN TO USE: When setting up recurring billing for a customer — subscriptions, instalments, or utility collection. This is always the first step in the Direct Debit lifecycle.

PREREQUISITES: None. However, verify the customer's bank account with monnify_verify_bank_account first to confirm account details before committing them to a mandate.
PREREQUISITES: Direct Debit is disabled by default — email integration-support@monnify.com to have it enabled on your account before use. Also verify the customer's bank account with monnify_verify_bank_account first to confirm account details before committing them to a mandate.

SIDE EFFECTS: Generates a mandateReference and a 30-day authorization link. The mandate status starts as PENDING_AUTHORIZATION — no debiting can occur until the customer clicks the link and authorises via their bank. Monnify automatically routes to TeamApt or NIBSS based on the customer's bank.

Expand Down
64 changes: 64 additions & 0 deletions src/tools/subAccounts/createSubAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { McpToolResult } from "../../types/mcp.js";
import { apiPost } from "../../client/monnifyClient.js";
import { sanitiseSubAccountListResponse } from "../../security/sanitiser.js";
import { registerTool } from "../registry.js";
import { MonnifyApiError } from "../../utils/errors.js";
import { errorResult } from "../../types/mcp.js";
import { formatSubAccountList } from "../../utils/format.js";
import { getResponseFormat } from "../../utils/clientContext.js";
import { CreateSubAccountsInputSchema } from "../../schemas/extended/subAccounts.js";

const definition: Tool = {
name: "monnify_create_sub_accounts",
description: `Creates one or more Sub Accounts, used to automatically split payments across multiple bank accounts.

WHEN TO USE: Before using incomeSplitConfig on monnify_initiate_payment, monnify_reserve_account, monnify_charge_card_token, or monnify_debit_mandate — a subAccountCode must already exist before it can receive a split. Also use for marketplace or multi-vendor setups where each vendor needs their own settlement account.

PREREQUISITES: Sub Accounts are disabled by default — email integration-support@monnify.com to have this feature enabled on your account before use.

SIDE EFFECTS: Creates one or more sub-account records tied to real bank accounts on Monnify. Each sub-account receives its own subAccountCode.

MFA NOTE: Not applicable.

KEY OUTPUT FIELDS: subAccountCode (use this in incomeSplitConfig), accountNumber, accountName, bankCode, bankName, defaultSplitPercentage.`,
inputSchema: zodToJsonSchema(CreateSubAccountsInputSchema) as Tool["inputSchema"],
};

async function handler(args: unknown): Promise<McpToolResult> {
try {
const parsed = CreateSubAccountsInputSchema.parse(args);
const result = await apiPost<Array<Record<string, unknown>>>(
"/api/v1/sub-accounts",
parsed.subAccounts
);
const sanitised = sanitiseSubAccountListResponse(
Array.isArray(result) ? result : []
);
return {
content: [{ type: "text", text: getResponseFormat() === "json" ? JSON.stringify(sanitised, null, 2) : formatSubAccountList(sanitised as Array<Record<string, unknown>>, "created") }],
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
content: [
{
type: "text",
text: `Validation failed:\n${error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}`,
},
],
isError: true,
};
}
if (error instanceof MonnifyApiError) {
return { content: [error.toMcpContent()], isError: true };
}
return errorResult(`monnify_create_sub_accounts failed: ${String(error)}`);
}
}

registerTool({ definition, handler });

export { definition, CreateSubAccountsInputSchema as inputSchema, handler };
Loading