@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.
package/EMBEDDING.md ADDED
@@ -0,0 +1,72 @@
1
+ # Embedding @palpluss/mcp in your app
2
+
3
+ `createPalplussMcpServer()` returns a standard MCP server (`McpServer` from `@modelcontextprotocol/sdk`). You can connect it to any MCP transport: stdio, Streamable HTTP, or an in-memory pair inside your own process.
4
+
5
+ ## In-process with your own approval UI
6
+
7
+ ```ts
8
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
10
+ import { createPalplussMcpServer, type ApprovalRequest } from '@palpluss/mcp';
11
+
12
+ // Your own function that shows the request to the user and resolves true/false.
13
+ declare function askUserToConfirm(req: ApprovalRequest): Promise<boolean>;
14
+
15
+ const { server } = createPalplussMcpServer({
16
+ apiKey: process.env.PALPLUSS_API_KEY!,
17
+ toolsets: ['read', 'wallets', 'collect', 'channels', 'payouts'],
18
+ limits: {
19
+ maxPayout: 2000,
20
+ dailyPayoutTotal: 10000,
21
+ dailyPayoutCount: 10,
22
+ maxTopup: 50000,
23
+ maxStk: 150000,
24
+ payoutAllowlist: [],
25
+ },
26
+ approver: async (req) =>
27
+ (await askUserToConfirm(req))
28
+ ? { approved: true, via: 'host' }
29
+ : { approved: false, reason: 'declined', message: 'The user declined. Nothing was done.' },
30
+ onAudit: (event) => console.log(JSON.stringify(event)),
31
+ });
32
+
33
+ const client = new Client({ name: 'my-app', version: '1.0.0' });
34
+ const [serverSide, clientSide] = InMemoryTransport.createLinkedPair();
35
+ await Promise.all([server.connect(serverSide), client.connect(clientSide)]);
36
+
37
+ const { tools } = await client.listTools(); // pass these to your model as tools
38
+ const result = await client.callTool({ name: 'palpluss_get_wallet_balances', arguments: {} });
39
+ ```
40
+
41
+ `ApprovalRequest` has `action` (a stable name such as `b2c_payout`), `title`, `details` (label → value, with phone numbers already masked) and `consequence`. Show all of it to the user.
42
+
43
+ Without an `approver`, the server asks through MCP elicitation, and tools that need approval refuse when the connected client doesn't support elicitation.
44
+
45
+ ## Let PalPluss handle approvals
46
+
47
+ If your app has no confirm dialog of its own, use an **MCP key** (created by the merchant under API Keys, or obtained by OAuth sign-in) and `approval: 'backend'`:
48
+
49
+ ```ts
50
+ const { server } = createPalplussMcpServer({
51
+ apiKey: merchantMcpKey,
52
+ approval: 'backend',
53
+ toolsets: ['read', 'collect', 'payouts'],
54
+ });
55
+ ```
56
+
57
+ PalPluss then enforces the key's permissions and payout limits, and holds payouts and channel changes until the merchant approves them in the console. Tools return `status: "PENDING_APPROVAL"` with an `approvalId`; `palpluss_wait_for_approval` follows it. Standard (full-access) keys are refused in this mode.
58
+
59
+ For a stateless HTTP endpoint (several instances, no sessions), use `handleStatelessMcpRequest(req, res, parsedBody, options)` with the same options.
60
+
61
+ ## Things to get right
62
+
63
+ - **Keep the API key on your server.** Never send it to a browser, a mobile app, or an environment that runs untrusted code.
64
+ - **Allowlist tools.** Expose only the tools your product needs, and add `palpluss_send_payout` deliberately.
65
+ - **Show approvals to the account owner,** not to whoever happens to be using the AI.
66
+ - **Keep the audit events** so users can see what was done on their account.
67
+ - **Payout limits in `limits` are per server process.** They're held in memory (and in `stateFile` if you set one). If you run several processes, each enforces its own limits. Limits set on an MCP key are enforced by PalPluss for all of them.
68
+ - **Test first.** A test key behaves the same with simulated money; `palpluss_get_guardrails` reports the mode.
69
+
70
+ ## Over HTTP
71
+
72
+ See "HTTP mode" in the [README](./README.md). Sessions are bound to the PalPluss key that opened them, and every request needs the bearer token.
package/README.md CHANGED
@@ -1,3 +1,166 @@
1
- # Temporary Holding Version
1
+ # @palpluss/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The PalPluss [Model Context Protocol](https://modelcontextprotocol.io) server. It lets AI tools that support MCP — such as Claude Code, Cursor and VS Code — work with your PalPluss account: check wallet balances, look up transactions, manage payment channels, collect with M-Pesa STK Push and send B2C payouts.
4
+
5
+ Anything that moves money or changes where money lands needs a human's yes: a prompt in your MCP client, or approval in the PalPluss console. The AI model never sees or answers either, so it can't approve its own actions.
6
+
7
+ > Requires Node.js 18+ and a PalPluss API key from [console.palpluss.com](https://console.palpluss.com) → API Keys. For AI tools, choose **AI tools (MCP)** as the key type: PalPluss then enforces the permissions, payout limits and approvals you set on the key. A **test** key uses simulated money.
8
+
9
+ **No install?** PalPluss also runs this server for you at `https://api.palpluss.com/mcp`. Clients with MCP sign-in (OAuth) open the PalPluss console to connect; others can send an MCP key as `Authorization: Bearer <key>`. See [docs.palpluss.com/sdks/mcp](https://docs.palpluss.com/sdks/mcp).
10
+
11
+ ## Quick start
12
+
13
+ ### Claude Code
14
+
15
+ ```bash
16
+ claude mcp add palpluss -e PALPLUSS_API_KEY=your_api_key -- npx -y @palpluss/mcp
17
+ ```
18
+
19
+ ### Cursor — `.cursor/mcp.json`
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "palpluss": {
25
+ "command": "npx",
26
+ "args": ["-y", "@palpluss/mcp"],
27
+ "env": { "PALPLUSS_API_KEY": "your_api_key" }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ### VS Code — `.vscode/mcp.json`
34
+
35
+ ```json
36
+ {
37
+ "inputs": [{ "id": "palpluss-key", "type": "promptString", "description": "PalPluss API key", "password": true }],
38
+ "servers": {
39
+ "palpluss": {
40
+ "command": "npx",
41
+ "args": ["-y", "@palpluss/mcp"],
42
+ "env": { "PALPLUSS_API_KEY": "${input:palpluss-key}" }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Then ask your assistant things like *"What's my PalPluss service wallet balance?"*, *"Show my latest failed STK payments"*, or *"Add my KCB Paybill 522522, account 1188990011, as a payment channel"*.
49
+
50
+ ## Tools
51
+
52
+ | Tool | Toolset | What it does | Approval |
53
+ |---|---|---|---|
54
+ | `palpluss_list_transactions` | read | List STK and B2C transactions, filter by type/status | — |
55
+ | `palpluss_get_transaction` | read | One transaction: status, amount, receipt | — |
56
+ | `palpluss_get_wallet_balances` | read | Service wallet (fees) and B2C wallet (payout float) | — |
57
+ | `palpluss_list_payment_channels` | read | Paybills/Tills money is collected into | — |
58
+ | `palpluss_get_payment_channel` | read | One channel | — |
59
+ | `palpluss_get_guardrails` | read | Enabled toolsets, limits, today's payout allowance | — |
60
+ | `palpluss_integration_guide` | read | Accurate guidance for building PalPluss into an app | — |
61
+ | `palpluss_get_wallet_history` | read | Charges, top-ups, refunds and payouts on both wallets | — |
62
+ | `palpluss_estimate_fee` | read | Fee for an STK push or payout of a given amount | — |
63
+ | `palpluss_list_payment_links` | read | Payment links | — |
64
+ | `palpluss_get_payment_link` | read | One payment link | — |
65
+ | `palpluss_list_approvals` | read | Requests held for, or past, console approval | — |
66
+ | `palpluss_topup_service_wallet` | wallets | STK prompt to top up the service wallet | ✅ |
67
+ | `palpluss_topup_b2c_wallet` | wallets | STK prompt to top up the B2C wallet | ✅ |
68
+ | `palpluss_send_stk_push` | collect | Ask a customer to pay by M-Pesa PIN prompt | ✅ |
69
+ | `palpluss_wait_for_payment` | collect | Wait up to 90s for a final status | — |
70
+ | `palpluss_create_payment_link` | collect | Create a hosted payment page (live keys) | — |
71
+ | `palpluss_disable_payment_link` | collect | Stop a link accepting payments (live keys) | ✅ |
72
+ | `palpluss_create_payment_channel` | channels | Add a Paybill or Till (incl. a bank Paybill) | ✅ |
73
+ | `palpluss_update_payment_channel` | channels | Rename or change a channel's numbers | ✅ |
74
+ | `palpluss_set_default_payment_channel` | channels | Choose where STK payments go by default | ✅ |
75
+ | `palpluss_delete_payment_channel` | channels | Remove a channel | ✅ |
76
+ | `palpluss_send_payout` | payouts | Send money to an M-Pesa number | ✅ + limits |
77
+ | `palpluss_wait_for_approval` | payouts / channels | Wait for the merchant's decision on a held request | — |
78
+
79
+ **Toolsets.** `read` is always on. The default is `read,wallets,collect`. Channel changes and payouts are off until you switch them on:
80
+
81
+ ```bash
82
+ PALPLUSS_MCP_TOOLSETS=read,wallets,collect,channels,payouts # or: all
83
+ ```
84
+
85
+ ## Guardrails
86
+
87
+ PalPluss's own protections always apply, because every call goes through the public API: rate limits, per-phone limits, STK abuse protection and the STK success-rate pause. On top of those, this server adds:
88
+
89
+ - **A human's yes for every write.** Top-ups, STK pushes, payouts and channel changes send an approval request (MCP *elicitation*) that your client shows you, with the amount, the masked phone number and, for channels, what changes. If your client doesn't support elicitation, these tools refuse instead of going ahead. Two exceptions, both decided from the key itself (`GET /v1/api-key`, read fresh before every decision): with an **MCP key** whose payouts or channel changes PalPluss holds for console approval, the request goes to the console instead (the tool returns `PENDING_APPROVAL`; follow it with `palpluss_wait_for_approval`); with a **test key**, nothing is asked because the money is simulated.
90
+ - **Payout limits** per API key: per payout, per day (total and count), and an optional allowlist of recipient numbers. They're checked before you're asked, and again just before the payout is sent. These are enforced by this server process, so if you run several copies each keeps its own tally. Limits set on an MCP key in the console are enforced by PalPluss itself, for every copy.
91
+ - **No double payments.** Payouts and top-ups carry an idempotency key, so an AI retrying after a timeout can't pay twice. Give each separate payout its own `reference`.
92
+ - **Prompt-injection defence.** Text written by customers or providers (references, descriptions, result messages) is returned marked `untrusted:`. The server's instructions tell the AI never to act on it.
93
+ - **Privacy.** Phone numbers are masked in everything returned to the AI and in the audit log. The API key is never logged.
94
+ - **Audit log.** Set `PALPLUSS_MCP_AUDIT_FILE` to get one JSON line per tool call, with tool, outcome, masked arguments and error codes.
95
+ - **Kill switch.** `PALPLUSS_MCP_READ_ONLY=true` removes every tool that changes anything.
96
+
97
+ ## Configuration
98
+
99
+ | Variable | Default | Meaning |
100
+ |---|---|---|
101
+ | `PALPLUSS_API_KEY` | — (required) | Your PalPluss API key |
102
+ | `PALPLUSS_MCP_TOOLSETS` | `read,wallets,collect` | Add `channels`, `payouts`, or use `all` |
103
+ | `PALPLUSS_MCP_READ_ONLY` | `false` | `true` turns off every write tool |
104
+ | `PALPLUSS_MCP_APPROVAL` | `elicit` | `backend` relies on PalPluss: needs an MCP key (standard keys are refused) and acts within the key's permissions, with payouts and channel changes held for console approval when the key asks for it. `none` disables approval prompts: use it only for automated tests against a fake or local API, never with a live key |
105
+ | `PALPLUSS_MCP_MAX_PAYOUT` | `5000` | Largest single payout (KES) |
106
+ | `PALPLUSS_MCP_DAILY_PAYOUT_TOTAL` | `20000` | Payout total per Nairobi day (KES) |
107
+ | `PALPLUSS_MCP_DAILY_PAYOUT_COUNT` | `20` | Payouts per Nairobi day |
108
+ | `PALPLUSS_MCP_PAYOUT_ALLOWLIST` | empty (any) | Comma-separated numbers payouts may go to |
109
+ | `PALPLUSS_MCP_MAX_TOPUP` | `50000` | Largest wallet top-up (KES) |
110
+ | `PALPLUSS_MCP_MAX_STK` | `150000` | Largest STK push (KES) |
111
+ | `PALPLUSS_MCP_AUDIT_FILE` | — | JSON-lines audit log path |
112
+ | `PALPLUSS_MCP_STATE_FILE` | — | Keep today's payout tally across restarts |
113
+ | `PALPLUSS_MCP_TIMEOUT_MS` | `30000` | API request timeout |
114
+ | `PALPLUSS_BASE_URL` | `https://api.palpluss.com` | API base URL |
115
+
116
+ ## HTTP mode
117
+
118
+ For apps that connect over the network instead of starting a local process, such as your own hosted deployment:
119
+
120
+ ```bash
121
+ PALPLUSS_MCP_HTTP_TOKEN=$(openssl rand -hex 24) \
122
+ PALPLUSS_API_KEY=your_api_key \
123
+ npx @palpluss/mcp --http --port 3333 # → http://127.0.0.1:3333/mcp
124
+ ```
125
+
126
+ - Every request needs `Authorization: Bearer <PALPLUSS_MCP_HTTP_TOKEN>`.
127
+ - One session per MCP client. Approval prompts work across requests within it.
128
+ - `PALPLUSS_MCP_HTTP_ALLOW_KEY_HEADER=true` lets one endpoint serve many merchants. Each session starts with the merchant's key in `X-PalPluss-Api-Key`, and the session is bound to that key.
129
+ - Binds to `127.0.0.1` by default. Put it behind TLS if you expose it.
130
+
131
+ ## Stateless HTTP
132
+
133
+ `handleStatelessMcpRequest(req, res, body, options)` serves one request without sessions, for hosts that run several instances behind a load balancer. Elicitation needs a session, so use `approval: 'backend'` with an MCP key, or pass your own `approver`.
134
+
135
+ ## Embedding in your own app
136
+
137
+ Use `createPalplussMcpServer()` to run the server inside your own Node.js app and connect it to any MCP transport. Pass an `approver` to show approvals in your own interface. See [EMBEDDING.md](./EMBEDDING.md).
138
+
139
+ ```ts
140
+ import { createPalplussMcpServer } from '@palpluss/mcp';
141
+
142
+ const { server } = createPalplussMcpServer({
143
+ apiKey: process.env.PALPLUSS_API_KEY!,
144
+ toolsets: ['read', 'wallets', 'collect'],
145
+ });
146
+ await server.connect(transport); // stdio, Streamable HTTP, or an in-memory pair
147
+ ```
148
+
149
+ ## Development
150
+
151
+ ```bash
152
+ pnpm --filter @palpluss/mcp test # guardrails, approvals, HTTP sessions
153
+ pnpm --filter @palpluss/mcp build
154
+ PALPLUSS_API_KEY=your_api_key node packages/mcp/dist/cli.js
155
+ ```
156
+
157
+ ## Releasing
158
+
159
+ The package is marked `"private": true`, so the SDK's changeset release can't publish it by accident. To release it manually:
160
+
161
+ 1. Bump `version` in `package.json` and `USER_AGENT` in `src/api/client.ts`; the script checks that they match.
162
+ 2. `npm login` (once), then `pnpm --filter @palpluss/mcp release:manual -- --otp=<code from your authenticator>`. This runs the tests and the build, lifts the private flag for the publish only, then puts it back. It publishes without provenance, which only works from CI. Use `-- --dry-run` to try it without publishing.
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Minimal client for the PalPluss developer API (/v1). Every call goes through
3
+ * the public API, so PalPluss's own limits — rate limits, per-phone velocity
4
+ * limits, STK abuse protection, transaction health — always apply.
5
+ */
6
+ export declare const USER_AGENT = "palpluss-mcp/0.2.0";
7
+ export declare class PalplussApiError extends Error {
8
+ readonly status: number;
9
+ readonly code: string;
10
+ readonly details?: unknown | undefined;
11
+ readonly requestId?: string | undefined;
12
+ constructor(message: string, status: number, code: string, details?: unknown | undefined, requestId?: string | undefined);
13
+ }
14
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
15
+ export interface ClientOptions {
16
+ apiKey: string;
17
+ baseUrl: string;
18
+ timeoutMs: number;
19
+ fetch?: FetchLike;
20
+ }
21
+ export interface TransactionListQuery {
22
+ limit?: number;
23
+ cursor?: string;
24
+ status?: string;
25
+ type?: 'STK' | 'B2C';
26
+ }
27
+ export interface StkPushBody {
28
+ amount: number;
29
+ phone: string;
30
+ accountReference?: string;
31
+ transactionDesc?: string;
32
+ channelId?: string;
33
+ callbackUrl?: string;
34
+ }
35
+ export interface TopupBody {
36
+ amount: number;
37
+ phone: string;
38
+ accountReference?: string;
39
+ transactionDesc?: string;
40
+ callbackUrl?: string;
41
+ }
42
+ export interface PayoutBody {
43
+ amount: number;
44
+ phone: string;
45
+ currency?: 'KES';
46
+ reference?: string;
47
+ description?: string;
48
+ channelId?: string;
49
+ callbackUrl?: string;
50
+ }
51
+ export interface ChannelCreateBody {
52
+ name: string;
53
+ type: 'PAYBILL' | 'TILL' | 'SHORTCODE';
54
+ shortcode: string;
55
+ accountNumber?: string;
56
+ isDefault?: boolean;
57
+ }
58
+ export type ChannelUpdateBody = Partial<ChannelCreateBody> & {
59
+ accountNumber?: string | null;
60
+ };
61
+ export interface WalletHistoryQuery {
62
+ wallet?: 'service' | 'b2c';
63
+ category?: 'charge' | 'topup' | 'refund' | 'payout' | 'bonus' | 'other';
64
+ limit?: number;
65
+ cursor?: string;
66
+ }
67
+ export interface PaymentLinkBody {
68
+ amount?: number;
69
+ phone_number?: string;
70
+ customer_name?: string;
71
+ external_reference?: string;
72
+ channel_id?: string;
73
+ callbackUrl?: string;
74
+ expiry?: '1h' | '6h' | '24h' | '72h' | '168h' | '720h' | 'never';
75
+ }
76
+ /** GET /v1/api-key — the key this server runs with. */
77
+ export interface KeyInfo {
78
+ id: string;
79
+ name: string;
80
+ kind: 'STANDARD' | 'MCP';
81
+ mode: 'live' | 'test';
82
+ livemode: boolean;
83
+ scopes: 'all' | {
84
+ scope: string;
85
+ label: string;
86
+ }[];
87
+ expiresAt: string | null;
88
+ connectedApp: string | null;
89
+ policy: null | {
90
+ payoutMax: number;
91
+ dailyPayoutTotal: number;
92
+ dailyPayoutCount: number;
93
+ payoutAllowlist: string[];
94
+ approvals: {
95
+ payouts: boolean;
96
+ channels: boolean;
97
+ };
98
+ payoutsToday: {
99
+ total: number;
100
+ count: number;
101
+ remainingTotal: number;
102
+ remainingCount: number;
103
+ };
104
+ };
105
+ }
106
+ /** Returned (HTTP 202) when PalPluss holds a request for the merchant's approval. */
107
+ export interface PendingApproval {
108
+ status: 'PENDING_APPROVAL';
109
+ approvalId: string;
110
+ action: string;
111
+ expiresAt: string;
112
+ approveUrl: string;
113
+ message: string;
114
+ }
115
+ export declare function isPendingApproval(value: unknown): value is PendingApproval;
116
+ type Json = Record<string, unknown>;
117
+ export declare class PalplussClient {
118
+ private readonly opts;
119
+ private readonly fetchImpl;
120
+ constructor(opts: ClientOptions);
121
+ private request;
122
+ listTransactions(query?: TransactionListQuery): Promise<Json>;
123
+ getTransaction(id: string): Promise<Json>;
124
+ serviceWalletBalance(): Promise<Json>;
125
+ b2cWalletBalance(): Promise<Json>;
126
+ topupServiceWallet(body: TopupBody, idempotencyKey: string): Promise<Json>;
127
+ topupB2cWallet(body: TopupBody, idempotencyKey: string): Promise<Json>;
128
+ listChannels(): Promise<{
129
+ items?: Json[];
130
+ }>;
131
+ getChannel(id: string): Promise<Json>;
132
+ createChannel(body: ChannelCreateBody): Promise<Json>;
133
+ updateChannel(id: string, body: ChannelUpdateBody): Promise<Json>;
134
+ deleteChannel(id: string): Promise<Json>;
135
+ stkPush(body: StkPushBody): Promise<Json>;
136
+ b2cPayout(body: PayoutBody, idempotencyKey: string): Promise<Json>;
137
+ keyInfo(): Promise<KeyInfo>;
138
+ walletHistory(query?: WalletHistoryQuery): Promise<Json>;
139
+ estimateFee(service: 'STK' | 'B2C', amount: number): Promise<Json>;
140
+ listPaymentLinks(query?: {
141
+ limit?: number;
142
+ cursor?: string;
143
+ }): Promise<Json>;
144
+ getPaymentLink(id: string): Promise<Json>;
145
+ createPaymentLink(body: PaymentLinkBody): Promise<Json>;
146
+ disablePaymentLink(id: string): Promise<Json>;
147
+ getApproval(id: string): Promise<Json>;
148
+ listApprovals(query?: {
149
+ status?: string;
150
+ limit?: number;
151
+ cursor?: string;
152
+ }): Promise<Json>;
153
+ }
154
+ export {};
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Minimal client for the PalPluss developer API (/v1). Every call goes through
3
+ * the public API, so PalPluss's own limits — rate limits, per-phone velocity
4
+ * limits, STK abuse protection, transaction health — always apply.
5
+ */
6
+ export const USER_AGENT = 'palpluss-mcp/0.2.0';
7
+ export class PalplussApiError extends Error {
8
+ status;
9
+ code;
10
+ details;
11
+ requestId;
12
+ constructor(message, status, code, details, requestId) {
13
+ super(message);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.details = details;
17
+ this.requestId = requestId;
18
+ this.name = 'PalplussApiError';
19
+ }
20
+ }
21
+ export function isPendingApproval(value) {
22
+ return !!value && typeof value === 'object' && value.status === 'PENDING_APPROVAL' && 'approvalId' in value;
23
+ }
24
+ export class PalplussClient {
25
+ opts;
26
+ fetchImpl;
27
+ constructor(opts) {
28
+ this.opts = opts;
29
+ this.fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
30
+ }
31
+ async request(method, path, options = {}) {
32
+ const url = new URL(`${this.opts.baseUrl}/v1${path}`);
33
+ for (const [k, v] of Object.entries(options.query ?? {})) {
34
+ if (v !== undefined && v !== null && v !== '')
35
+ url.searchParams.set(k, String(v));
36
+ }
37
+ const headers = {
38
+ Authorization: `Basic ${this.opts.apiKey}`,
39
+ Accept: 'application/json',
40
+ 'User-Agent': USER_AGENT,
41
+ };
42
+ if (options.body !== undefined)
43
+ headers['Content-Type'] = 'application/json';
44
+ if (options.idempotencyKey)
45
+ headers['Idempotency-Key'] = options.idempotencyKey;
46
+ const controller = new AbortController();
47
+ const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
48
+ let res;
49
+ try {
50
+ res = await this.fetchImpl(url.toString(), {
51
+ method,
52
+ headers,
53
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
54
+ signal: controller.signal,
55
+ });
56
+ }
57
+ catch (err) {
58
+ const aborted = err?.name === 'AbortError';
59
+ throw new PalplussApiError(aborted
60
+ ? `PalPluss did not answer within ${Math.round(this.opts.timeoutMs / 1000)}s.`
61
+ : `Could not reach PalPluss at ${this.opts.baseUrl}.`, 0, aborted ? 'TIMEOUT' : 'NETWORK_ERROR');
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ }
66
+ const requestId = res.headers.get('x-request-id') ?? undefined;
67
+ if (res.status === 204)
68
+ return undefined;
69
+ // Never JSON.parse an HTML error page (proxies, gateways, maintenance).
70
+ const contentType = res.headers.get('content-type') ?? '';
71
+ const text = await res.text();
72
+ if (!contentType.includes('json')) {
73
+ throw new PalplussApiError(`PalPluss returned an unexpected ${res.status} response (${contentType || 'no content type'}).`, res.status, 'NON_JSON_RESPONSE', undefined, requestId);
74
+ }
75
+ let payload;
76
+ try {
77
+ payload = text ? JSON.parse(text) : {};
78
+ }
79
+ catch {
80
+ throw new PalplussApiError('PalPluss returned malformed JSON.', res.status, 'MALFORMED_RESPONSE', undefined, requestId);
81
+ }
82
+ if (!res.ok || payload.success === false) {
83
+ const e = (payload.error ?? {});
84
+ // The API's error filter reports `code` as HTTP_<status> and keeps the
85
+ // specific code (e.g. KYC_NOT_VERIFIED) in details.code — prefer that.
86
+ const details = e.details && typeof e.details === 'object' ? e.details : undefined;
87
+ const specific = typeof details?.code === 'string' ? details.code : undefined;
88
+ const code = specific && (!e.code || /^HTTP_\d+$/.test(e.code)) ? specific : e.code || `HTTP_${res.status}`;
89
+ // Validation errors arrive with message as an array of strings.
90
+ const message = Array.isArray(e.message) ? e.message.join('; ') : typeof e.message === 'string' ? e.message : '';
91
+ // The filter wraps the original body ({ message, code, details }) in
92
+ // error.details; surface the inner details (e.g. retryAfterSeconds) directly.
93
+ const inner = details && details.details && typeof details.details === 'object' ? details.details : undefined;
94
+ throw new PalplussApiError(message || `PalPluss request failed (${res.status}).`, res.status, code, inner ?? e.details, requestId);
95
+ }
96
+ return (payload.data ?? payload);
97
+ }
98
+ listTransactions(query = {}) {
99
+ return this.request('GET', '/transactions', { query: { ...query } });
100
+ }
101
+ getTransaction(id) {
102
+ return this.request('GET', `/transactions/${encodeURIComponent(id)}`);
103
+ }
104
+ serviceWalletBalance() {
105
+ return this.request('GET', '/wallets/service/balance');
106
+ }
107
+ b2cWalletBalance() {
108
+ return this.request('GET', '/wallets/b2c/balance');
109
+ }
110
+ topupServiceWallet(body, idempotencyKey) {
111
+ return this.request('POST', '/wallets/service/topups', { body, idempotencyKey });
112
+ }
113
+ topupB2cWallet(body, idempotencyKey) {
114
+ return this.request('POST', '/wallets/b2c/topups', { body, idempotencyKey });
115
+ }
116
+ listChannels() {
117
+ return this.request('GET', '/payment-wallet/channels');
118
+ }
119
+ getChannel(id) {
120
+ return this.request('GET', `/payment-wallet/channels/${encodeURIComponent(id)}`);
121
+ }
122
+ createChannel(body) {
123
+ return this.request('POST', '/payment-wallet/channels', { body });
124
+ }
125
+ updateChannel(id, body) {
126
+ return this.request('PATCH', `/payment-wallet/channels/${encodeURIComponent(id)}`, { body });
127
+ }
128
+ deleteChannel(id) {
129
+ return this.request('DELETE', `/payment-wallet/channels/${encodeURIComponent(id)}`);
130
+ }
131
+ stkPush(body) {
132
+ return this.request('POST', '/payments/stk', { body });
133
+ }
134
+ b2cPayout(body, idempotencyKey) {
135
+ return this.request('POST', '/b2c/payouts', { body, idempotencyKey });
136
+ }
137
+ keyInfo() {
138
+ return this.request('GET', '/api-key');
139
+ }
140
+ walletHistory(query = {}) {
141
+ return this.request('GET', '/wallets/history', { query: { ...query } });
142
+ }
143
+ estimateFee(service, amount) {
144
+ return this.request('GET', '/fees/estimate', { query: { service, amount } });
145
+ }
146
+ listPaymentLinks(query = {}) {
147
+ return this.request('GET', '/payment-links', { query: { ...query } });
148
+ }
149
+ getPaymentLink(id) {
150
+ return this.request('GET', `/payment-links/${encodeURIComponent(id)}`);
151
+ }
152
+ createPaymentLink(body) {
153
+ return this.request('POST', '/payment-links', { body });
154
+ }
155
+ disablePaymentLink(id) {
156
+ return this.request('DELETE', `/payment-links/${encodeURIComponent(id)}`);
157
+ }
158
+ getApproval(id) {
159
+ return this.request('GET', `/approvals/${encodeURIComponent(id)}`);
160
+ }
161
+ listApprovals(query = {}) {
162
+ return this.request('GET', '/approvals', { query: { ...query } });
163
+ }
164
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { ConfigError, loadConfigFromEnv } from './config.js';
4
+ import { startHttpServer } from './http.js';
5
+ import { createPalplussMcpServer, SERVER_VERSION } from './server.js';
6
+ const HELP = `palpluss-mcp ${SERVER_VERSION} — PalPluss MCP server
7
+
8
+ Usage:
9
+ palpluss-mcp Run over stdio (Claude Code, Cursor, VS Code, …)
10
+ palpluss-mcp --http Run a Streamable HTTP endpoint at /mcp
11
+ --port <n> Port (default 3333, or PORT)
12
+ --host <addr> Bind address (default 127.0.0.1)
13
+
14
+ Environment:
15
+ PALPLUSS_API_KEY Required. Your PalPluss API key.
16
+ PALPLUSS_MCP_TOOLSETS read,wallets,collect (default) · add channels,payouts · or all
17
+ PALPLUSS_MCP_READ_ONLY true to switch off every tool that changes anything
18
+ PALPLUSS_MCP_APPROVAL elicit (default) | none
19
+ PALPLUSS_MCP_MAX_PAYOUT KES per payout (default 5000)
20
+ PALPLUSS_MCP_DAILY_PAYOUT_TOTAL / _COUNT daily payout caps (default 20000 / 20)
21
+ PALPLUSS_MCP_PAYOUT_ALLOWLIST comma-separated phone numbers payouts may go to
22
+ PALPLUSS_MCP_MAX_TOPUP / _MAX_STK caps for top-ups and STK pushes
23
+ PALPLUSS_MCP_AUDIT_FILE JSON-lines audit log path
24
+ PALPLUSS_MCP_STATE_FILE where to keep today's payout tally
25
+ PALPLUSS_BASE_URL API base (default https://api.palpluss.com)
26
+ PALPLUSS_MCP_HTTP_TOKEN --http only: required bearer token (≥24 chars)
27
+ PALPLUSS_MCP_HTTP_ALLOW_KEY_HEADER --http only: accept X-PalPluss-Api-Key per request
28
+ `;
29
+ function arg(name) {
30
+ const i = process.argv.indexOf(name);
31
+ return i >= 0 ? process.argv[i + 1] : undefined;
32
+ }
33
+ async function main() {
34
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
35
+ process.stdout.write(HELP);
36
+ return;
37
+ }
38
+ if (process.argv.includes('--version')) {
39
+ process.stdout.write(`${SERVER_VERSION}\n`);
40
+ return;
41
+ }
42
+ const httpMode = process.argv.includes('--http');
43
+ const allowKeyHeader = ['1', 'true', 'yes'].includes((process.env.PALPLUSS_MCP_HTTP_ALLOW_KEY_HEADER ?? '').toLowerCase());
44
+ // In HTTP multi-merchant mode the key may come per request instead of env.
45
+ const env = httpMode && allowKeyHeader && !process.env.PALPLUSS_API_KEY ? { ...process.env, PALPLUSS_API_KEY: '-' } : process.env;
46
+ const config = loadConfigFromEnv(env);
47
+ if (config.apiKey === '-')
48
+ config.apiKey = '';
49
+ if (httpMode) {
50
+ const port = Number(arg('--port') ?? process.env.PORT ?? 3333);
51
+ const host = arg('--host') ?? '127.0.0.1';
52
+ await startHttpServer({ config, port, host, token: process.env.PALPLUSS_MCP_HTTP_TOKEN ?? '', allowKeyHeader });
53
+ process.stderr.write(`[palpluss-mcp] listening on http://${host}:${port}/mcp (toolsets: ${config.toolsets.join(', ')})\n`);
54
+ return;
55
+ }
56
+ const { server } = createPalplussMcpServer(config);
57
+ await server.connect(new StdioServerTransport());
58
+ // stdout carries the protocol; status goes to stderr.
59
+ process.stderr.write(`[palpluss-mcp] ready (toolsets: ${config.toolsets.join(', ')}${config.readOnly ? ', read-only' : ''}, approval: ${config.approval})\n`);
60
+ }
61
+ main().catch((err) => {
62
+ const msg = err instanceof ConfigError ? err.message : `Failed to start: ${err?.message ?? err}`;
63
+ process.stderr.write(`[palpluss-mcp] ${msg}\n`);
64
+ process.exit(1);
65
+ });