@palpluss/mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Static integration guidance served to coding assistants. Kept short and
3
+ * factual; the full reference is docs.palpluss.com.
4
+ */
5
+ export const GUIDE_TOPICS = [
6
+ 'overview',
7
+ 'stk-push',
8
+ 'webhooks',
9
+ 'b2c-payouts',
10
+ 'channels',
11
+ 'wallets',
12
+ 'payment-links',
13
+ 'test-mode',
14
+ 'mcp-keys',
15
+ 'errors',
16
+ 'go-live',
17
+ ];
18
+ const BASE = 'https://api.palpluss.com/v1';
19
+ const TOPICS = {
20
+ overview: `# PalPluss — overview
21
+ PalPluss is an M-Pesa payments API for Kenya: collect with STK Push, pay out with B2C, and manage the Paybills/Tills money is collected into.
22
+
23
+ - Base URL: ${BASE}
24
+ - Auth: \`Authorization: Basic <API key>\` (keys from console.palpluss.com → API Keys). Server-side only — never ship a key to a browser or mobile app.
25
+ - Responses: \`{ success: true, data, requestId }\`; errors \`{ success: false, error: { code, message, details } }\`. \`error.code\` is \`HTTP_<status>\`; the specific reason (e.g. \`KYC_NOT_VERIFIED\`) is in \`error.details.code\` when there is one.
26
+ - Phones: send 07XXXXXXXX, 01XXXXXXXX, +254… or 254…; PalPluss normalises to 254XXXXXXXXX.
27
+ - Amounts: whole Kenyan shillings.
28
+ - Two wallets: the **service wallet** pays PalPluss fees (STK Push is a flat KES 3 for KES 10 and above; KES 1–9 is free); the **B2C wallet** is the float payouts are sent from.
29
+ - Docs: https://docs.palpluss.com`,
30
+ 'stk-push': `# STK Push (collect a payment)
31
+ POST ${BASE}/payments/stk
32
+ \`\`\`json
33
+ { "amount": 500, "phone": "0712345678", "accountReference": "ORDER-1042",
34
+ "transactionDesc": "Order 1042", "callbackUrl": "https://yourapp.com/webhooks/palpluss" }
35
+ \`\`\`
36
+ - Optional \`channelId\` picks the Paybill/Till; otherwise the default channel is used.
37
+ - Keep \`accountReference\` ≤ 12 and \`transactionDesc\` ≤ 13 characters (shown on the M-Pesa prompt).
38
+ - The response is immediate with \`status: "PENDING"\` and a \`transactionId\` — store it against your order.
39
+ - The result arrives by webhook (see "webhooks") or by polling GET ${BASE}/transactions/{id}.
40
+ - Compute the amount on your server from the order. Never trust an amount sent by the browser.
41
+ - Limits: 2 STK requests per phone number per minute (429 STK_RATE_LIMITED); 60 requests per minute per API key; if fewer than 30% of the last 100 STK payments succeed, STK is paused for 30 minutes (429 TRANSACTION_HEALTH_BLOCKED).
42
+ - To collect straight into a bank account, add the bank's Paybill and your account number as a PAYBILL channel.`,
43
+ webhooks: `# Webhooks
44
+ PalPluss POSTs to your \`callbackUrl\` when a transaction reaches a final state:
45
+ \`\`\`json
46
+ { "event": "transaction.updated", "event_type": "transaction.success",
47
+ "transaction": { "id": "…", "type": "STK", "status": "SUCCESS", "amount": 1000,
48
+ "external_reference": "ORDER-1042", "mpesa_receipt": "NLJ7RT61SV", "result_desc": "…" } }
49
+ \`\`\`
50
+ - Fulfil only when \`status\` is SUCCESS. \`external_reference\` is your accountReference/reference.
51
+ - Make the handler idempotent (key on transaction.id) — a callback can arrive more than once.
52
+ - Don't trust the body alone: re-fetch GET ${BASE}/transactions/{id} with your API key before fulfilling.
53
+ - Respond 2xx quickly; do slow work after acknowledging.
54
+ - Store \`mpesa_receipt\` for reconciliation.`,
55
+ 'b2c-payouts': `# B2C payouts (send money to an M-Pesa number)
56
+ POST ${BASE}/b2c/payouts (header \`Idempotency-Key\` strongly recommended)
57
+ \`\`\`json
58
+ { "amount": 500, "phone": "0712345678", "reference": "PAYOUT-1001",
59
+ "description": "Partner payout", "callbackUrl": "https://yourapp.com/webhooks/palpluss" }
60
+ \`\`\`
61
+ - Requires an ACTIVE account with APPROVED KYC (403 otherwise).
62
+ - Minimum KES 10. Paid from the B2C wallet — top it up first (POST ${BASE}/wallets/b2c/topups).
63
+ - Fees are tiered by amount (from KES 6) and charged from the service wallet.
64
+ - The response returns \`status: "PROCESSING"\`; the final SUCCESS/FAILED arrives by webhook. A FAILED payout is refunded to the B2C wallet automatically.
65
+ - Use one Idempotency-Key per intended payout so retries never pay twice.
66
+ - Limit: 1 payout per phone number per minute (429, details.code \`B2C_PAYOUT_COOLDOWN\`). Not enough float: details.code \`INSUFFICIENT_FUNDS\`.`,
67
+ channels: `# Payment channels
68
+ Channels are the Paybills and Tills customer money is collected into.
69
+ - List: GET ${BASE}/payment-wallet/channels (default first)
70
+ - Create: POST ${BASE}/payment-wallet/channels
71
+ \`\`\`json
72
+ { "name": "Main Paybill", "type": "PAYBILL", "shortcode": "247247", "accountNumber": "0123456789", "isDefault": true }
73
+ \`\`\`
74
+ - Types: PAYBILL (with the accountNumber customers pay into), TILL, SHORTCODE.
75
+ - The same Paybill can be added more than once with different account numbers (e.g. several bank accounts).
76
+ - Update: PATCH …/channels/{id}; Delete: DELETE …/channels/{id} (soft delete; the default moves to another channel).
77
+ - Changing channels changes where future payments land — treat it as sensitive.`,
78
+ wallets: `# Wallets
79
+ - Service wallet (fees): GET ${BASE}/wallets/service/balance; top up with POST ${BASE}/wallets/service/topups.
80
+ - B2C wallet (payout float): GET ${BASE}/wallets/b2c/balance; top up with POST ${BASE}/wallets/b2c/topups.
81
+ - Top-ups are STK pushes to the phone that pays; the wallet is credited once M-Pesa confirms.
82
+ - Send an \`Idempotency-Key\` header so a retried top-up isn't charged twice.
83
+ - Requests fail with 402 INSUFFICIENT_SERVICE_BALANCE when the service wallet can't cover a fee.
84
+ - Limit: 2 top-ups per phone number per minute.
85
+ - History: GET ${BASE}/wallets/history?wallet=service|b2c&category=charge|topup|refund|payout — every entry with the balance after it.
86
+ - Fees: GET ${BASE}/fees/estimate?service=STK|B2C&amount=500 returns what this account's plan charges.`,
87
+ 'payment-links': `# Payment links
88
+ A payment link is a hosted page where the customer enters their number and pays by STK Push — no checkout code needed.
89
+ - Create: POST ${BASE}/payment-links
90
+ \`\`\`json
91
+ { "amount": 1500, "external_reference": "INV-2041", "customer_name": "Jane", "expiry": "72h",
92
+ "callbackUrl": "https://yourapp.com/webhooks/palpluss" }
93
+ \`\`\`
94
+ - Omit \`amount\` to let the customer type it. \`phone_number\` pre-fills their number; \`channel_id\` picks the Paybill/Till (default channel otherwise).
95
+ - \`expiry\`: 1h, 6h, 24h, 72h, 168h, 720h or never (default).
96
+ - The response has \`url\` — share it. Payments appear as STK transactions and fire your callback like any STK push.
97
+ - List: GET ${BASE}/payment-links; one: GET ${BASE}/payment-links/{id}; disable: DELETE ${BASE}/payment-links/{id}.`,
98
+ 'test-mode': `# Test mode
99
+ Create a **test** key in the console (API Keys → mode: Test). Test keys use the same endpoints and response shapes with simulated money:
100
+ - Nothing reaches M-Pesa. Every response and callback has \`livemode: false\`.
101
+ - Each account gets test wallets with KES 10,000 in the service wallet and KES 10,000 in the B2C wallet.
102
+ - STK pushes, payouts and top-ups settle about 5 seconds later and send your callback.
103
+ - How it ends depends on the phone number: 0700000001 → FAILED (insufficient balance), 0700000002 → CANCELLED (customer cancelled), 0700000003 → FAILED (customer unreachable), any other valid number → SUCCESS.
104
+ - Payouts work without KYC in test mode. Fees follow your real pricing plan.
105
+ - Payment channels and payment links are live settings: test keys can read them but not change them (403 NOT_AVAILABLE_IN_TEST_MODE).
106
+ - A key can be switched between test and live in the console (API Keys → Edit); it applies from the next request and the key string stays the same. Check GET ${BASE}/api-key for the current mode.`,
107
+ 'mcp-keys': `# MCP keys (for AI tools)
108
+ In the console (API Keys) choose **MCP** as the key type to give an AI tool limited access:
109
+ - Permissions: only what you tick works — transactions:read, wallets:read, wallets:topup, payments:stk, paylinks:read, paylinks:write, channels:read, channels:write, payouts:write. Anything else returns 403 INSUFFICIENT_SCOPE.
110
+ - Payout limits enforced by PalPluss: per payout, per day (total and count, Nairobi time) and an optional allowlist of numbers.
111
+ - Approvals: payouts and channel changes can be held until you approve them in the console. The API then answers 202 with \`status: "PENDING_APPROVAL"\` and an \`approvalId\`; follow it with GET ${BASE}/approvals/{id} (EXECUTED, REJECTED, EXPIRED or FAILED). Requests expire after 15 minutes.
112
+ - Optional expiry date and IP allowlist (exact IPs or CIDR ranges).
113
+ - GET ${BASE}/api-key shows the key's kind, mode, permissions, limits and today's payout usage.
114
+ - Standard keys keep full access and none of these limits.`,
115
+ errors: `# Common errors
116
+ Error responses carry \`error.code\` as \`HTTP_<status>\`; read \`error.details.code\` for the specific reason.
117
+ | HTTP | details.code | Meaning | What to do |
118
+ |---|---|---|---|
119
+ | 401 | INVALID_API_KEY / AUTH_HEADER_INVALID | Key missing or wrong | Send \`Authorization: Basic <key>\` |
120
+ | 400 | INVALID_PHONE | Not a Kenyan mobile number | Validate before calling |
121
+ | 400 | NO_PAYMENT_CHANNELS / NO_DEFAULT_CHANNEL | No channel to collect into | Add one, or set a default |
122
+ | 402 | INSUFFICIENT_SERVICE_BALANCE | Can't cover the fee | Top up the service wallet |
123
+ | 403 | KYC_NOT_VERIFIED | Payouts need approved KYC | Finish KYC in the console |
124
+ | 401 | API_KEY_EXPIRED | The key's expiry date passed | Create a new key |
125
+ | 403 | INSUFFICIENT_SCOPE | MCP key lacks that permission | Grant it in the console |
126
+ | 403 | IP_NOT_ALLOWED | Request from an IP not on the key's allowlist | Update the allowlist |
127
+ | 403 | NOT_AVAILABLE_IN_TEST_MODE | Test key on a live-only endpoint | Use a live key |
128
+ | 403 | PAYOUT_ABOVE_KEY_LIMIT / PAYOUT_DAILY_TOTAL_REACHED / PAYOUT_DAILY_COUNT_REACHED / PAYOUT_RECIPIENT_NOT_ALLOWED | MCP key payout limits | Change them in the console |
129
+ | 429 | STK_RATE_LIMITED / WALLET_TOPUP_RATE_LIMITED | >2 prompts per minute to one number | Wait error.details.details.retryAfterSeconds |
130
+ | 429 | B2C_PAYOUT_COOLDOWN | >1 payout per minute to one number | Wait a minute |
131
+ | 429 | TRANSACTION_HEALTH_BLOCKED | Too many failed prompts recently | Wait until error.details.details.blockedUntil |
132
+ | 429 | RATE_LIMIT_EXCEEDED | API key over 60 requests per minute | Slow down |
133
+ `,
134
+ 'go-live': `# Go-live checklist
135
+ 1. API key stored server-side only (environment variable / secret manager).
136
+ 2. Amounts computed on the server from your own records.
137
+ 3. Webhook handler idempotent, re-fetches the transaction, fulfils only on SUCCESS.
138
+ 4. Store transactionId and mpesa_receipt for every payment.
139
+ 5. Handle 429s (rate limits, transaction health) with a friendly "try again shortly".
140
+ 6. Keep the service wallet funded; alert when it runs low.
141
+ 7. For payouts: Idempotency-Key per payout, KYC approved, B2C wallet funded.
142
+ 8. Make one real small payment end to end before launch.`,
143
+ };
144
+ export function guide(topic) {
145
+ return `${TOPICS[topic]}\n\n(Full reference: https://docs.palpluss.com)`;
146
+ }
@@ -0,0 +1,13 @@
1
+ import { type ToolContext } from './common.js';
2
+ /** Wallet top-ups: STK push to the payer's own phone, credited once M-Pesa confirms. */
3
+ export declare function registerWalletTools(ctx: ToolContext): void;
4
+ /** Collections: STK push to a customer, and waiting for the result. */
5
+ export declare function registerCollectTools(ctx: ToolContext): void;
6
+ /** B2C payouts — the strictest tool: limits, allowlist, approval, idempotency. */
7
+ export declare function registerPayoutTools(ctx: ToolContext): void;
8
+ /** Payment channels — every change needs approval: channels decide where money lands. */
9
+ export declare function registerChannelTools(ctx: ToolContext): void;
10
+ /** Payment links: hosted pages where a customer enters their number and pays. Live keys only. */
11
+ export declare function registerPaymentLinkTools(ctx: ToolContext): void;
12
+ /** Follow a request PalPluss is holding for the merchant's approval. */
13
+ export declare function registerApprovalTools(ctx: ToolContext): void;
@@ -0,0 +1,360 @@
1
+ import { z } from 'zod';
2
+ import { assertAmount, guarded, idempotencyKey, kes, ok, okOrPending, requireApproval, requirePhone, ToolRefusal, } from './common.js';
3
+ import { maskPhone } from '../safety/redact.js';
4
+ const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
5
+ const FINAL = new Set(['SUCCESS', 'FAILED', 'CANCELLED', 'REVERSED']);
6
+ const amountSchema = z.number().int().positive().describe('Whole Kenyan shillings.');
7
+ const phoneSchema = z.string().min(9).max(16).describe('Kenyan mobile number: 07XXXXXXXX, 01XXXXXXXX or 2547XXXXXXXX.');
8
+ const callbackSchema = z.string().url().max(500).optional().describe('HTTPS URL PalPluss POSTs the final result to.');
9
+ /** Wallet top-ups: STK push to the payer's own phone, credited once M-Pesa confirms. */
10
+ export function registerWalletTools(ctx) {
11
+ const { server, client, config } = ctx;
12
+ for (const wallet of ['service', 'b2c']) {
13
+ const name = wallet === 'service' ? 'palpluss_topup_service_wallet' : 'palpluss_topup_b2c_wallet';
14
+ const label = wallet === 'service' ? 'service wallet (fees)' : 'B2C wallet (payout float)';
15
+ server.registerTool(name, {
16
+ title: wallet === 'service' ? 'Top up service wallet' : 'Top up B2C wallet',
17
+ description: wallet === 'service'
18
+ ? 'Top up the service wallet, which pays PalPluss fees. Sends an M-Pesa PIN prompt to the given phone; the wallet is credited once it is paid. Needs the person\'s approval first.'
19
+ : 'Top up the B2C wallet, the float payouts are sent from. Sends an M-Pesa PIN prompt to the given phone; the wallet is credited once it is paid. Needs the person\'s approval first.',
20
+ inputSchema: {
21
+ amount: amountSchema,
22
+ phone: phoneSchema.describe('The phone that will pay (gets the PIN prompt).'),
23
+ reference: z.string().max(12).optional().describe('Your reference, ≤12 chars. Use a new one for each separate top-up.'),
24
+ callbackUrl: callbackSchema,
25
+ },
26
+ annotations: { title: `Top up ${label}`, ...WRITE },
27
+ }, guarded(ctx, name, async ({ amount, phone, reference, callbackUrl }) => {
28
+ assertAmount(amount, { min: 1, max: config.limits.maxTopup, label: 'Top-up' });
29
+ const msisdn = requirePhone(phone);
30
+ const approval = await requireApproval(ctx, {
31
+ action: `${wallet}_wallet_topup`,
32
+ title: `Top up the ${label} with ${kes(amount)}`,
33
+ details: { Amount: kes(amount), 'PIN prompt goes to': maskPhone(msisdn) ?? '', Wallet: label },
34
+ consequence: `An M-Pesa prompt for ${kes(amount)} will be sent to ${maskPhone(msisdn)}. The wallet is credited only after that phone pays.`,
35
+ }, 'topup');
36
+ const body = {
37
+ amount,
38
+ phone: msisdn,
39
+ accountReference: reference ?? (wallet === 'service' ? 'MCP-TOPUP' : 'MCP-B2CTOP'),
40
+ transactionDesc: wallet === 'service' ? 'Service topup' : 'B2C topup',
41
+ ...(callbackUrl ? { callbackUrl } : {}),
42
+ };
43
+ const key = idempotencyKey(config.apiKey, [`topup-${wallet}`, amount, msisdn], reference);
44
+ const res = wallet === 'service' ? await client.topupServiceWallet(body, key) : await client.topupB2cWallet(body, key);
45
+ return ok(res, {
46
+ note: `Cleared (${approval}). The PIN prompt was sent.`,
47
+ next: 'Use palpluss_get_transaction with the transactionId to see when it is paid, then palpluss_get_wallet_balances.',
48
+ });
49
+ }));
50
+ }
51
+ }
52
+ /** Collections: STK push to a customer, and waiting for the result. */
53
+ export function registerCollectTools(ctx) {
54
+ const { server, client, config } = ctx;
55
+ server.registerTool('palpluss_send_stk_push', {
56
+ title: 'Send an STK push',
57
+ description: 'Ask a customer to pay by sending an M-Pesa PIN prompt to their phone. Money goes to the default payment channel unless channelId is given. Needs the person\'s approval first. Returns a PENDING transaction — follow it with palpluss_wait_for_payment.',
58
+ inputSchema: {
59
+ amount: amountSchema,
60
+ phone: phoneSchema.describe('The customer\'s phone (gets the PIN prompt).'),
61
+ accountReference: z.string().max(12).optional().describe('Your order/invoice reference, ≤12 chars.'),
62
+ description: z.string().max(13).optional().describe('Shown on the prompt, ≤13 chars.'),
63
+ channelId: z.string().uuid().optional().describe('Payment channel to collect into (default channel if omitted).'),
64
+ callbackUrl: callbackSchema,
65
+ },
66
+ annotations: { title: 'Send an STK push', ...WRITE },
67
+ }, guarded(ctx, 'palpluss_send_stk_push', async ({ amount, phone, accountReference, description, channelId, callbackUrl }) => {
68
+ assertAmount(amount, { min: 1, max: config.limits.maxStk, label: 'STK push' });
69
+ const msisdn = requirePhone(phone);
70
+ let channelLabel = channelId ? `Channel ${channelId}` : 'Default channel';
71
+ if (channelId) {
72
+ try {
73
+ const ch = (await client.getChannel(channelId));
74
+ channelLabel = `${ch.name ?? 'Channel'} (${ch.shortcode ?? '?'})`;
75
+ }
76
+ catch {
77
+ // The key may not be allowed to read channels; the ID is enough for the prompt.
78
+ }
79
+ }
80
+ const approval = await requireApproval(ctx, {
81
+ action: 'stk_push',
82
+ title: `Request ${kes(amount)} from ${maskPhone(msisdn)}`,
83
+ details: {
84
+ Amount: kes(amount),
85
+ 'Customer phone': maskPhone(msisdn) ?? '',
86
+ 'Collect into': channelLabel,
87
+ ...(accountReference ? { Reference: accountReference } : {}),
88
+ },
89
+ consequence: `The customer at ${maskPhone(msisdn)} gets an M-Pesa prompt to pay ${kes(amount)}.`,
90
+ }, 'collect');
91
+ const res = await client.stkPush({
92
+ amount,
93
+ phone: msisdn,
94
+ ...(accountReference ? { accountReference } : {}),
95
+ ...(description ? { transactionDesc: description } : {}),
96
+ ...(channelId ? { channelId } : {}),
97
+ ...(callbackUrl ? { callbackUrl } : {}),
98
+ });
99
+ return ok(res, {
100
+ note: `Cleared (${approval}). The prompt was sent; the customer has about a minute to enter their PIN.`,
101
+ next: 'Call palpluss_wait_for_payment with the transactionId.',
102
+ });
103
+ }));
104
+ server.registerTool('palpluss_wait_for_payment', {
105
+ title: 'Wait for a payment result',
106
+ description: 'Wait (up to 90 seconds) for an STK push, top-up or payout to reach a final status — SUCCESS, FAILED, CANCELLED or REVERSED — then return it. Returns the current status if it is still pending when time runs out.',
107
+ inputSchema: {
108
+ transactionId: z.string().uuid(),
109
+ timeoutSeconds: z.number().int().min(5).max(90).optional().describe('How long to wait (default 60).'),
110
+ },
111
+ annotations: { title: 'Wait for a payment result', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
112
+ }, guarded(ctx, 'palpluss_wait_for_payment', async ({ transactionId, timeoutSeconds }) => {
113
+ const deadline = Date.now() + (timeoutSeconds ?? 60) * 1000;
114
+ let tx = (await client.getTransaction(transactionId));
115
+ while (!FINAL.has(String(tx.status)) && Date.now() < deadline) {
116
+ await new Promise((r) => setTimeout(r, 3000));
117
+ tx = (await client.getTransaction(transactionId));
118
+ }
119
+ const final = FINAL.has(String(tx.status));
120
+ return ok(tx, {
121
+ note: final ? `Final status: ${tx.status}.` : `Still ${tx.status ?? 'pending'} — check again shortly.`,
122
+ });
123
+ }));
124
+ }
125
+ /** B2C payouts — the strictest tool: limits, allowlist, approval, idempotency. */
126
+ export function registerPayoutTools(ctx) {
127
+ const { server, client, config, limiter } = ctx;
128
+ server.registerTool('palpluss_send_payout', {
129
+ title: 'Send a B2C payout',
130
+ description: 'Send money from the B2C wallet to an M-Pesa number (refunds, commissions, salaries). Needs the person\'s approval and is capped by this server\'s payout limits — check palpluss_get_guardrails first. Give each separate payout its own reference; repeating the same request with the same reference never pays twice.',
131
+ inputSchema: {
132
+ amount: amountSchema.describe('Whole Kenyan shillings, minimum 10.'),
133
+ phone: phoneSchema.describe('The recipient\'s M-Pesa number.'),
134
+ reference: z.string().max(120).optional().describe('Unique reference for this payout (recommended).'),
135
+ description: z.string().max(255).optional().describe('What the payout is for.'),
136
+ channelId: z.string().uuid().optional(),
137
+ callbackUrl: callbackSchema,
138
+ },
139
+ annotations: { title: 'Send a B2C payout', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
140
+ }, guarded(ctx, 'palpluss_send_payout', async ({ amount, phone, reference, description, channelId, callbackUrl }) => {
141
+ assertAmount(amount, { min: 10, max: Math.max(10, config.limits.maxPayout), label: 'Payout' });
142
+ const msisdn = requirePhone(phone);
143
+ limiter.check(amount, msisdn); // fail fast before bothering the person
144
+ const left = limiter.remaining();
145
+ const approval = await requireApproval(ctx, {
146
+ action: 'b2c_payout',
147
+ title: `Send ${kes(amount)} to ${maskPhone(msisdn)}`,
148
+ details: {
149
+ Amount: kes(amount),
150
+ Recipient: maskPhone(msisdn) ?? '',
151
+ ...(reference ? { Reference: reference } : {}),
152
+ ...(description ? { For: description } : {}),
153
+ 'Left today after this': `${kes(Math.max(0, left.remainingTotal - amount))} · ${Math.max(0, left.remainingCount - 1)} payouts`,
154
+ },
155
+ consequence: `${kes(amount)} will leave your B2C wallet and be sent to ${maskPhone(msisdn)}. This can't be undone once M-Pesa pays it.`,
156
+ }, 'payout');
157
+ const release = limiter.reserve(amount, msisdn); // re-checks: limits may have moved while waiting
158
+ try {
159
+ const res = await client.b2cPayout({
160
+ amount,
161
+ phone: msisdn,
162
+ currency: 'KES',
163
+ ...(reference ? { reference } : {}),
164
+ ...(description ? { description } : {}),
165
+ ...(channelId ? { channelId } : {}),
166
+ ...(callbackUrl ? { callbackUrl } : {}),
167
+ }, idempotencyKey(config.apiKey, ['payout', amount, msisdn], reference));
168
+ const status = String(res.status ?? '');
169
+ if (status === 'FAILED')
170
+ release();
171
+ return okOrPending(res, {
172
+ note: `Cleared (${approval}). Payout submitted with status ${status || 'unknown'}.`,
173
+ next: 'Call palpluss_wait_for_payment with the transactionId to confirm it was paid.',
174
+ });
175
+ }
176
+ catch (err) {
177
+ release();
178
+ throw err;
179
+ }
180
+ }));
181
+ }
182
+ /** Payment channels — every change needs approval: channels decide where money lands. */
183
+ export function registerChannelTools(ctx) {
184
+ const { server, client } = ctx;
185
+ const CHANNEL_TYPES = ['PAYBILL', 'TILL', 'SHORTCODE'];
186
+ const describe = (c) => `${c.name ?? 'Unnamed'} — ${c.type ?? '?'} ${c.shortcode ?? '?'}${c.accountNumber ? ` / account ${c.accountNumber}` : ''}`;
187
+ server.registerTool('palpluss_create_payment_channel', {
188
+ title: 'Add a payment channel',
189
+ description: 'Add a Paybill or Till that customer payments can be collected into. For a bank account, use the bank\'s M-Pesa Paybill as shortcode and the bank account number as accountNumber. Needs the person\'s approval.',
190
+ inputSchema: {
191
+ name: z.string().min(1).max(80),
192
+ type: z.enum(CHANNEL_TYPES).describe('PAYBILL needs accountNumber; TILL and SHORTCODE must not have one.'),
193
+ shortcode: z.string().regex(/^\d{4,10}$/).describe('The Paybill or Till number.'),
194
+ accountNumber: z.string().min(1).max(30).optional(),
195
+ makeDefault: z.boolean().optional().describe('Make this the channel STK pushes use by default.'),
196
+ },
197
+ annotations: { title: 'Add a payment channel', ...WRITE },
198
+ }, guarded(ctx, 'palpluss_create_payment_channel', async ({ name, type, shortcode, accountNumber, makeDefault }) => {
199
+ if (type === 'PAYBILL' && !accountNumber)
200
+ throw new ToolRefusal('A PAYBILL channel needs an accountNumber.', 'ACCOUNT_NUMBER_REQUIRED');
201
+ if (type !== 'PAYBILL' && accountNumber)
202
+ throw new ToolRefusal(`A ${type} channel must not have an accountNumber.`, 'ACCOUNT_NUMBER_NOT_ALLOWED');
203
+ const approval = await requireApproval(ctx, {
204
+ action: 'channel_create',
205
+ title: `Add payment channel "${name}"`,
206
+ details: {
207
+ Channel: describe({ name, type, shortcode, accountNumber }),
208
+ 'Make default': makeDefault ? 'Yes — new STK payments will go here' : 'No',
209
+ },
210
+ consequence: makeDefault
211
+ ? `Customer payments will start going to ${type} ${shortcode}${accountNumber ? ` (account ${accountNumber})` : ''}. Check the numbers carefully.`
212
+ : `The channel is added but not used by default.`,
213
+ }, 'channel');
214
+ const res = await client.createChannel({ name, type, shortcode, ...(accountNumber ? { accountNumber } : {}), isDefault: !!makeDefault });
215
+ return okOrPending(res, { note: `Cleared (${approval}). Channel added.` });
216
+ }));
217
+ server.registerTool('palpluss_update_payment_channel', {
218
+ title: 'Edit a payment channel',
219
+ description: 'Rename a channel or change its number or account number. Needs the person\'s approval.',
220
+ inputSchema: {
221
+ channelId: z.string().uuid(),
222
+ name: z.string().min(1).max(80).optional(),
223
+ type: z.enum(CHANNEL_TYPES).optional(),
224
+ shortcode: z.string().regex(/^\d{4,10}$/).optional(),
225
+ accountNumber: z.string().min(1).max(30).nullable().optional(),
226
+ },
227
+ annotations: { title: 'Edit a payment channel', ...WRITE, idempotentHint: true },
228
+ }, guarded(ctx, 'palpluss_update_payment_channel', async ({ channelId, ...changes }) => {
229
+ const patch = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined));
230
+ if (!Object.keys(patch).length)
231
+ throw new ToolRefusal('Nothing to change — pass at least one field.', 'NO_CHANGES');
232
+ const current = (await client.getChannel(channelId));
233
+ const next = { ...current, ...patch };
234
+ const approval = await requireApproval(ctx, {
235
+ action: 'channel_update',
236
+ title: `Change payment channel "${String(current.name ?? channelId)}"`,
237
+ details: { Now: describe(current), 'After the change': describe(next), Default: current.isDefault ? 'Yes' : 'No' },
238
+ consequence: current.isDefault
239
+ ? 'This is your default channel: new customer payments will go to the changed number.'
240
+ : 'Payments that use this channel will go to the changed number.',
241
+ }, 'channel');
242
+ const res = await client.updateChannel(channelId, patch);
243
+ return okOrPending(res, { note: `Cleared (${approval}). Channel updated.` });
244
+ }));
245
+ server.registerTool('palpluss_set_default_payment_channel', {
246
+ title: 'Set the default payment channel',
247
+ description: 'Make a channel the default — STK pushes without a channelId collect into it. Needs the person\'s approval.',
248
+ inputSchema: { channelId: z.string().uuid() },
249
+ annotations: { title: 'Set the default payment channel', ...WRITE, idempotentHint: true },
250
+ }, guarded(ctx, 'palpluss_set_default_payment_channel', async ({ channelId }) => {
251
+ const target = (await client.getChannel(channelId));
252
+ if (target.isDefault)
253
+ return ok(target, { note: 'Already the default channel. Nothing changed.' });
254
+ const list = (await client.listChannels());
255
+ const currentDefault = list.items?.find((c) => c.isDefault);
256
+ const approval = await requireApproval(ctx, {
257
+ action: 'channel_set_default',
258
+ title: `Make "${String(target.name ?? channelId)}" the default channel`,
259
+ details: {
260
+ 'New default': describe(target),
261
+ 'Current default': currentDefault ? describe(currentDefault) : 'None',
262
+ },
263
+ consequence: 'All new STK payments without a specific channel will go to the new default.',
264
+ }, 'channel');
265
+ const res = await client.updateChannel(channelId, { isDefault: true });
266
+ return okOrPending(res, { note: `Cleared (${approval}). Default channel changed.` });
267
+ }));
268
+ server.registerTool('palpluss_delete_payment_channel', {
269
+ title: 'Remove a payment channel',
270
+ description: 'Remove a payment channel. Past transactions keep their record; if it was the default, another channel becomes the default. Needs the person\'s approval.',
271
+ inputSchema: { channelId: z.string().uuid() },
272
+ annotations: { title: 'Remove a payment channel', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
273
+ }, guarded(ctx, 'palpluss_delete_payment_channel', async ({ channelId }) => {
274
+ const current = (await client.getChannel(channelId));
275
+ const approval = await requireApproval(ctx, {
276
+ action: 'channel_delete',
277
+ title: `Remove payment channel "${String(current.name ?? channelId)}"`,
278
+ details: { Channel: describe(current), Default: current.isDefault ? 'Yes — another channel will become the default' : 'No' },
279
+ consequence: 'The channel can no longer be used for payments.',
280
+ }, 'channel');
281
+ const res = await client.deleteChannel(channelId);
282
+ return okOrPending(res ?? { deleted: true, channelId }, { note: `Cleared (${approval}). Channel removed.` });
283
+ }));
284
+ }
285
+ /** Payment links: hosted pages where a customer enters their number and pays. Live keys only. */
286
+ export function registerPaymentLinkTools(ctx) {
287
+ const { server, client } = ctx;
288
+ server.registerTool('palpluss_create_payment_link', {
289
+ title: 'Create a payment link',
290
+ description: 'Create a PalPluss payment link — a page the customer opens to pay by M-Pesa. Fix the amount or let the customer type it; optionally pre-fill their phone. Nothing is charged until a customer pays. Returns the link URL to share. Needs a live key.',
291
+ inputSchema: {
292
+ amount: amountSchema.optional().describe('Fixed amount in whole shillings. Omit to let the customer enter it.'),
293
+ phone: phoneSchema.optional().describe('Pre-fill the customer\'s phone (optional).'),
294
+ customerName: z.string().max(120).optional(),
295
+ reference: z.string().max(120).optional().describe('Your order/invoice reference.'),
296
+ channelId: z.string().uuid().optional().describe('Payment channel to collect into (default channel if omitted).'),
297
+ expiry: z.enum(['1h', '6h', '24h', '72h', '168h', '720h', 'never']).optional().describe('How long the link works (default: never expires).'),
298
+ callbackUrl: callbackSchema,
299
+ },
300
+ annotations: { title: 'Create a payment link', ...WRITE },
301
+ }, guarded(ctx, 'palpluss_create_payment_link', async ({ amount, phone, customerName, reference, channelId, expiry, callbackUrl }) => {
302
+ const msisdn = phone ? requirePhone(phone) : undefined;
303
+ const res = await client.createPaymentLink({
304
+ ...(amount !== undefined ? { amount } : {}),
305
+ ...(msisdn ? { phone_number: msisdn } : {}),
306
+ ...(customerName ? { customer_name: customerName } : {}),
307
+ ...(reference ? { external_reference: reference } : {}),
308
+ ...(channelId ? { channel_id: channelId } : {}),
309
+ ...(expiry ? { expiry } : {}),
310
+ ...(callbackUrl ? { callbackUrl } : {}),
311
+ });
312
+ return ok(res, { note: 'Link created. Share the url with the customer; payments show up in palpluss_list_transactions.' });
313
+ }));
314
+ server.registerTool('palpluss_disable_payment_link', {
315
+ title: 'Disable a payment link',
316
+ description: 'Stop a payment link from accepting payments. Past payments are kept. Needs the person\'s approval.',
317
+ inputSchema: { paymentLinkId: z.string().uuid() },
318
+ annotations: { title: 'Disable a payment link', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
319
+ }, guarded(ctx, 'palpluss_disable_payment_link', async ({ paymentLinkId }) => {
320
+ const link = (await client.getPaymentLink(paymentLinkId));
321
+ if (link.status === 'DISABLED')
322
+ return ok(link, { note: 'Already disabled. Nothing changed.' });
323
+ const approval = await requireApproval(ctx, {
324
+ action: 'paylink_disable',
325
+ title: 'Disable a payment link',
326
+ details: { Link: String(link.url ?? paymentLinkId), Amount: link.amount ? kes(link.amount) : 'Customer enters it' },
327
+ consequence: 'Customers who open this link will no longer be able to pay through it.',
328
+ }, 'paylink');
329
+ await client.disablePaymentLink(paymentLinkId);
330
+ return ok({ paymentLinkId, status: 'DISABLED' }, { note: `Cleared (${approval}). The link no longer accepts payments.` });
331
+ }));
332
+ }
333
+ /** Follow a request PalPluss is holding for the merchant's approval. */
334
+ export function registerApprovalTools(ctx) {
335
+ const { server, client } = ctx;
336
+ const DONE = new Set(['EXECUTED', 'FAILED', 'REJECTED', 'EXPIRED']);
337
+ server.registerTool('palpluss_wait_for_approval', {
338
+ title: 'Wait for the merchant\'s approval',
339
+ description: 'Wait (up to 120 seconds) while the merchant approves or rejects a held payout or channel change in the PalPluss console, then return the outcome: EXECUTED (with the result), REJECTED, EXPIRED or FAILED. Returns PENDING_APPROVAL if they haven\'t decided yet — call it again later rather than retrying the original request.',
340
+ inputSchema: {
341
+ approvalId: z.string().uuid(),
342
+ timeoutSeconds: z.number().int().min(5).max(120).optional().describe('How long to wait (default 60).'),
343
+ },
344
+ annotations: { title: 'Wait for approval', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
345
+ }, guarded(ctx, 'palpluss_wait_for_approval', async ({ approvalId, timeoutSeconds }) => {
346
+ const deadline = Date.now() + (timeoutSeconds ?? 60) * 1000;
347
+ let req = (await client.getApproval(approvalId));
348
+ while (!DONE.has(String(req.status)) && Date.now() < deadline) {
349
+ await new Promise((r) => setTimeout(r, 3000));
350
+ req = (await client.getApproval(approvalId));
351
+ }
352
+ const notes = {
353
+ EXECUTED: 'Approved and done. See result.',
354
+ FAILED: 'Approved, but PalPluss could not complete it. See error.',
355
+ REJECTED: 'The merchant declined. Nothing was done.',
356
+ EXPIRED: 'Nobody approved it in time. Nothing was done.',
357
+ };
358
+ return ok(req, { note: notes[String(req.status)] ?? 'Still waiting for the merchant to decide in the console.' });
359
+ }));
360
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolContext } from './common.js';
2
+ export declare function registerReadTools(ctx: ToolContext): void;