@palpluss/mcp 0.0.0-stage → 0.1.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,55 @@
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
+ ## Things to get right
46
+
47
+ - **Keep the API key on your server.** Never send it to a browser, a mobile app, or an environment that runs untrusted code.
48
+ - **Allowlist tools.** Expose only the tools your product needs, and add `palpluss_send_payout` deliberately.
49
+ - **Show approvals to the account owner,** not to whoever happens to be using the AI.
50
+ - **Keep the audit events** so users can see what was done on their account.
51
+ - **Payout 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.
52
+
53
+ ## Over HTTP
54
+
55
+ 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,154 @@
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 **asks you to approve it first**. The approval request goes to your MCP client's interface, not to the AI model, so the model 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.
8
+
9
+ ## Quick start
10
+
11
+ ### Claude Code
12
+
13
+ ```bash
14
+ claude mcp add palpluss -e PALPLUSS_API_KEY=your_api_key -- npx -y @palpluss/mcp
15
+ ```
16
+
17
+ ### Cursor — `.cursor/mcp.json`
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "palpluss": {
23
+ "command": "npx",
24
+ "args": ["-y", "@palpluss/mcp"],
25
+ "env": { "PALPLUSS_API_KEY": "your_api_key" }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### VS Code — `.vscode/mcp.json`
32
+
33
+ ```json
34
+ {
35
+ "inputs": [{ "id": "palpluss-key", "type": "promptString", "description": "PalPluss API key", "password": true }],
36
+ "servers": {
37
+ "palpluss": {
38
+ "command": "npx",
39
+ "args": ["-y", "@palpluss/mcp"],
40
+ "env": { "PALPLUSS_API_KEY": "${input:palpluss-key}" }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ > **Before it's on npm:** build it (`pnpm --filter @palpluss/mcp build`) and use `node /path/to/palpluss-sdk/packages/mcp/dist/cli.js` in place of `npx -y @palpluss/mcp`.
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_topup_service_wallet` | wallets | STK prompt to top up the service wallet | ✅ |
62
+ | `palpluss_topup_b2c_wallet` | wallets | STK prompt to top up the B2C wallet | ✅ |
63
+ | `palpluss_send_stk_push` | collect | Ask a customer to pay by M-Pesa PIN prompt | ✅ |
64
+ | `palpluss_wait_for_payment` | collect | Wait up to 90s for a final status | — |
65
+ | `palpluss_create_payment_channel` | channels | Add a Paybill or Till (incl. a bank Paybill) | ✅ |
66
+ | `palpluss_update_payment_channel` | channels | Rename or change a channel's numbers | ✅ |
67
+ | `palpluss_set_default_payment_channel` | channels | Choose where STK payments go by default | ✅ |
68
+ | `palpluss_delete_payment_channel` | channels | Remove a channel | ✅ |
69
+ | `palpluss_send_payout` | payouts | Send money to an M-Pesa number | ✅ + limits |
70
+
71
+ **Toolsets.** `read` is always on. The default is `read,wallets,collect`. Channel changes and payouts are off until you switch them on:
72
+
73
+ ```bash
74
+ PALPLUSS_MCP_TOOLSETS=read,wallets,collect,channels,payouts # or: all
75
+ ```
76
+
77
+ ## Guardrails
78
+
79
+ 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:
80
+
81
+ - **Your approval 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.
82
+ - **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. The limits are enforced by this server process, not by PalPluss, so if you run several copies each keeps its own tally.
83
+ - **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`.
84
+ - **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.
85
+ - **Privacy.** Phone numbers are masked in everything returned to the AI and in the audit log. The API key is never logged.
86
+ - **Audit log.** Set `PALPLUSS_MCP_AUDIT_FILE` to get one JSON line per tool call, with tool, outcome, masked arguments and error codes.
87
+ - **Kill switch.** `PALPLUSS_MCP_READ_ONLY=true` removes every tool that changes anything.
88
+
89
+ ## Configuration
90
+
91
+ | Variable | Default | Meaning |
92
+ |---|---|---|
93
+ | `PALPLUSS_API_KEY` | — (required) | Your PalPluss API key |
94
+ | `PALPLUSS_MCP_TOOLSETS` | `read,wallets,collect` | Add `channels`, `payouts`, or use `all` |
95
+ | `PALPLUSS_MCP_READ_ONLY` | `false` | `true` turns off every write tool |
96
+ | `PALPLUSS_MCP_APPROVAL` | `elicit` | `none` disables approval prompts. Use it only for automated tests against a fake or local API, never with a live key |
97
+ | `PALPLUSS_MCP_MAX_PAYOUT` | `5000` | Largest single payout (KES) |
98
+ | `PALPLUSS_MCP_DAILY_PAYOUT_TOTAL` | `20000` | Payout total per Nairobi day (KES) |
99
+ | `PALPLUSS_MCP_DAILY_PAYOUT_COUNT` | `20` | Payouts per Nairobi day |
100
+ | `PALPLUSS_MCP_PAYOUT_ALLOWLIST` | empty (any) | Comma-separated numbers payouts may go to |
101
+ | `PALPLUSS_MCP_MAX_TOPUP` | `50000` | Largest wallet top-up (KES) |
102
+ | `PALPLUSS_MCP_MAX_STK` | `150000` | Largest STK push (KES) |
103
+ | `PALPLUSS_MCP_AUDIT_FILE` | — | JSON-lines audit log path |
104
+ | `PALPLUSS_MCP_STATE_FILE` | — | Keep today's payout tally across restarts |
105
+ | `PALPLUSS_MCP_TIMEOUT_MS` | `30000` | API request timeout |
106
+ | `PALPLUSS_BASE_URL` | `https://api.palpluss.com` | API base URL |
107
+
108
+ ## HTTP mode
109
+
110
+ For apps that connect over the network instead of starting a local process, such as your own hosted deployment:
111
+
112
+ ```bash
113
+ PALPLUSS_MCP_HTTP_TOKEN=$(openssl rand -hex 24) \
114
+ PALPLUSS_API_KEY=your_api_key \
115
+ npx @palpluss/mcp --http --port 3333 # → http://127.0.0.1:3333/mcp
116
+ ```
117
+
118
+ - Every request needs `Authorization: Bearer <PALPLUSS_MCP_HTTP_TOKEN>`.
119
+ - One session per MCP client. Approval prompts work across requests within it.
120
+ - `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.
121
+ - Binds to `127.0.0.1` by default. Put it behind TLS if you expose it.
122
+
123
+ ## Embedding in your own app
124
+
125
+ 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).
126
+
127
+ ```ts
128
+ import { createPalplussMcpServer } from '@palpluss/mcp';
129
+
130
+ const { server } = createPalplussMcpServer({
131
+ apiKey: process.env.PALPLUSS_API_KEY!,
132
+ toolsets: ['read', 'wallets', 'collect'],
133
+ });
134
+ await server.connect(transport); // stdio, Streamable HTTP, or an in-memory pair
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ pnpm --filter @palpluss/mcp test # guardrails, approvals, HTTP sessions
141
+ pnpm --filter @palpluss/mcp build
142
+ PALPLUSS_API_KEY=your_api_key node packages/mcp/dist/cli.js
143
+ ```
144
+
145
+ ## Releasing
146
+
147
+ The package is marked `"private": true`, so the SDK's changeset release can't publish it by accident. To release it manually:
148
+
149
+ 1. Bump `version` in `package.json` and `USER_AGENT` in `src/api/client.ts`; the script checks that they match.
150
+ 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.
151
+
152
+ ## License
153
+
154
+ MIT
@@ -0,0 +1,83 @@
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.1.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' | 'BUYGOODS';
54
+ shortcode: string;
55
+ accountNumber?: string;
56
+ isDefault?: boolean;
57
+ }
58
+ export type ChannelUpdateBody = Partial<ChannelCreateBody> & {
59
+ accountNumber?: string | null;
60
+ };
61
+ type Json = Record<string, unknown>;
62
+ export declare class PalplussClient {
63
+ private readonly opts;
64
+ private readonly fetchImpl;
65
+ constructor(opts: ClientOptions);
66
+ private request;
67
+ listTransactions(query?: TransactionListQuery): Promise<Json>;
68
+ getTransaction(id: string): Promise<Json>;
69
+ serviceWalletBalance(): Promise<Json>;
70
+ b2cWalletBalance(): Promise<Json>;
71
+ topupServiceWallet(body: TopupBody, idempotencyKey: string): Promise<Json>;
72
+ topupB2cWallet(body: TopupBody, idempotencyKey: string): Promise<Json>;
73
+ listChannels(): Promise<{
74
+ items?: Json[];
75
+ }>;
76
+ getChannel(id: string): Promise<Json>;
77
+ createChannel(body: ChannelCreateBody): Promise<Json>;
78
+ updateChannel(id: string, body: ChannelUpdateBody): Promise<Json>;
79
+ deleteChannel(id: string): Promise<Json>;
80
+ stkPush(body: StkPushBody): Promise<Json>;
81
+ b2cPayout(body: PayoutBody, idempotencyKey: string): Promise<Json>;
82
+ }
83
+ export {};
@@ -0,0 +1,131 @@
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.1.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 class PalplussClient {
22
+ opts;
23
+ fetchImpl;
24
+ constructor(opts) {
25
+ this.opts = opts;
26
+ this.fetchImpl = opts.fetch ?? ((input, init) => fetch(input, init));
27
+ }
28
+ async request(method, path, options = {}) {
29
+ const url = new URL(`${this.opts.baseUrl}/v1${path}`);
30
+ for (const [k, v] of Object.entries(options.query ?? {})) {
31
+ if (v !== undefined && v !== null && v !== '')
32
+ url.searchParams.set(k, String(v));
33
+ }
34
+ const headers = {
35
+ Authorization: `Basic ${this.opts.apiKey}`,
36
+ Accept: 'application/json',
37
+ 'User-Agent': USER_AGENT,
38
+ };
39
+ if (options.body !== undefined)
40
+ headers['Content-Type'] = 'application/json';
41
+ if (options.idempotencyKey)
42
+ headers['Idempotency-Key'] = options.idempotencyKey;
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
45
+ let res;
46
+ try {
47
+ res = await this.fetchImpl(url.toString(), {
48
+ method,
49
+ headers,
50
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
51
+ signal: controller.signal,
52
+ });
53
+ }
54
+ catch (err) {
55
+ const aborted = err?.name === 'AbortError';
56
+ throw new PalplussApiError(aborted
57
+ ? `PalPluss did not answer within ${Math.round(this.opts.timeoutMs / 1000)}s.`
58
+ : `Could not reach PalPluss at ${this.opts.baseUrl}.`, 0, aborted ? 'TIMEOUT' : 'NETWORK_ERROR');
59
+ }
60
+ finally {
61
+ clearTimeout(timer);
62
+ }
63
+ const requestId = res.headers.get('x-request-id') ?? undefined;
64
+ if (res.status === 204)
65
+ return undefined;
66
+ // Never JSON.parse an HTML error page (proxies, gateways, maintenance).
67
+ const contentType = res.headers.get('content-type') ?? '';
68
+ const text = await res.text();
69
+ if (!contentType.includes('json')) {
70
+ throw new PalplussApiError(`PalPluss returned an unexpected ${res.status} response (${contentType || 'no content type'}).`, res.status, 'NON_JSON_RESPONSE', undefined, requestId);
71
+ }
72
+ let payload;
73
+ try {
74
+ payload = text ? JSON.parse(text) : {};
75
+ }
76
+ catch {
77
+ throw new PalplussApiError('PalPluss returned malformed JSON.', res.status, 'MALFORMED_RESPONSE', undefined, requestId);
78
+ }
79
+ if (!res.ok || payload.success === false) {
80
+ const e = (payload.error ?? {});
81
+ // The API's error filter reports `code` as HTTP_<status> and keeps the
82
+ // specific code (e.g. KYC_NOT_VERIFIED) in details.code — prefer that.
83
+ const details = e.details && typeof e.details === 'object' ? e.details : undefined;
84
+ const specific = typeof details?.code === 'string' ? details.code : undefined;
85
+ const code = specific && (!e.code || /^HTTP_\d+$/.test(e.code)) ? specific : e.code || `HTTP_${res.status}`;
86
+ // Validation errors arrive with message as an array of strings.
87
+ const message = Array.isArray(e.message) ? e.message.join('; ') : typeof e.message === 'string' ? e.message : '';
88
+ throw new PalplussApiError(message || `PalPluss request failed (${res.status}).`, res.status, code, e.details, requestId);
89
+ }
90
+ return (payload.data ?? payload);
91
+ }
92
+ listTransactions(query = {}) {
93
+ return this.request('GET', '/transactions', { query: { ...query } });
94
+ }
95
+ getTransaction(id) {
96
+ return this.request('GET', `/transactions/${encodeURIComponent(id)}`);
97
+ }
98
+ serviceWalletBalance() {
99
+ return this.request('GET', '/wallets/service/balance');
100
+ }
101
+ b2cWalletBalance() {
102
+ return this.request('GET', '/wallets/b2c/balance');
103
+ }
104
+ topupServiceWallet(body, idempotencyKey) {
105
+ return this.request('POST', '/wallets/service/topups', { body, idempotencyKey });
106
+ }
107
+ topupB2cWallet(body, idempotencyKey) {
108
+ return this.request('POST', '/wallets/b2c/topups', { body, idempotencyKey });
109
+ }
110
+ listChannels() {
111
+ return this.request('GET', '/payment-wallet/channels');
112
+ }
113
+ getChannel(id) {
114
+ return this.request('GET', `/payment-wallet/channels/${encodeURIComponent(id)}`);
115
+ }
116
+ createChannel(body) {
117
+ return this.request('POST', '/payment-wallet/channels', { body });
118
+ }
119
+ updateChannel(id, body) {
120
+ return this.request('PATCH', `/payment-wallet/channels/${encodeURIComponent(id)}`, { body });
121
+ }
122
+ deleteChannel(id) {
123
+ return this.request('DELETE', `/payment-wallet/channels/${encodeURIComponent(id)}`);
124
+ }
125
+ stkPush(body) {
126
+ return this.request('POST', '/payments/stk', { body });
127
+ }
128
+ b2cPayout(body, idempotencyKey) {
129
+ return this.request('POST', '/b2c/payouts', { body, idempotencyKey });
130
+ }
131
+ }
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
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Groups of tools. `read` is always on; the rest are switched on explicitly.
3
+ * Payouts and channel changes are off unless you name them.
4
+ */
5
+ export declare const TOOLSETS: readonly ["read", "wallets", "collect", "channels", "payouts"];
6
+ export type Toolset = (typeof TOOLSETS)[number];
7
+ export declare const DEFAULT_TOOLSETS: Toolset[];
8
+ /**
9
+ * How money-moving and channel-changing tools get a human's yes:
10
+ * - `elicit` (default): the MCP client shows the person an approval prompt;
11
+ * the AI never sees or answers it. Clients without that support are refused.
12
+ * - `none`: no prompt. Only for automated tests against a fake or local API, never a live key.
13
+ */
14
+ export type ApprovalMode = 'elicit' | 'none';
15
+ export interface Limits {
16
+ /** Largest single B2C payout, KES. */
17
+ maxPayout: number;
18
+ /** Total B2C payouts per Nairobi calendar day, KES. */
19
+ dailyPayoutTotal: number;
20
+ /** Number of B2C payouts per Nairobi calendar day. */
21
+ dailyPayoutCount: number;
22
+ /** Largest wallet top-up, KES. */
23
+ maxTopup: number;
24
+ /** Largest STK push request, KES. */
25
+ maxStk: number;
26
+ /** If non-empty, payouts may only go to these numbers (254XXXXXXXXX). */
27
+ payoutAllowlist: string[];
28
+ }
29
+ export interface PalplussMcpConfig {
30
+ apiKey: string;
31
+ baseUrl: string;
32
+ timeoutMs: number;
33
+ toolsets: Toolset[];
34
+ /** Kill switch: when true, every tool that changes anything is refused. */
35
+ readOnly: boolean;
36
+ approval: ApprovalMode;
37
+ limits: Limits;
38
+ /** Where to keep the daily payout tally between restarts (stdio installs). */
39
+ stateFile?: string;
40
+ /** Append-only JSON-lines audit log of every tool call. */
41
+ auditFile?: string;
42
+ }
43
+ export declare const DEFAULT_BASE_URL = "https://api.palpluss.com";
44
+ export declare const DEFAULT_LIMITS: Limits;
45
+ export declare class ConfigError extends Error {
46
+ }
47
+ export declare function parseToolsets(raw: string | undefined): Toolset[];
48
+ export declare function parseAllowlist(raw: string | undefined): string[];
49
+ /** Build the configuration from environment variables (the CLI path). */
50
+ export declare function loadConfigFromEnv(env?: NodeJS.ProcessEnv): PalplussMcpConfig;
51
+ /** Fill in defaults for programmatic use (embedding in another app). */
52
+ export declare function resolveConfig(input: Partial<PalplussMcpConfig> & {
53
+ apiKey: string;
54
+ }): PalplussMcpConfig;
package/dist/config.js ADDED
@@ -0,0 +1,119 @@
1
+ import { normalizePhone } from './safety/redact.js';
2
+ /**
3
+ * Groups of tools. `read` is always on; the rest are switched on explicitly.
4
+ * Payouts and channel changes are off unless you name them.
5
+ */
6
+ export const TOOLSETS = ['read', 'wallets', 'collect', 'channels', 'payouts'];
7
+ export const DEFAULT_TOOLSETS = ['read', 'wallets', 'collect'];
8
+ export const DEFAULT_BASE_URL = 'https://api.palpluss.com';
9
+ export const DEFAULT_LIMITS = {
10
+ maxPayout: 5_000,
11
+ dailyPayoutTotal: 20_000,
12
+ dailyPayoutCount: 20,
13
+ maxTopup: 50_000,
14
+ maxStk: 150_000,
15
+ payoutAllowlist: [],
16
+ };
17
+ export class ConfigError extends Error {
18
+ }
19
+ function num(env, key, fallback) {
20
+ const raw = env[key];
21
+ if (raw === undefined || raw.trim() === '')
22
+ return fallback;
23
+ const n = Number(raw);
24
+ if (!Number.isFinite(n) || n < 0) {
25
+ throw new ConfigError(`${key} must be a non-negative number (got "${raw}").`);
26
+ }
27
+ return n;
28
+ }
29
+ function bool(env, key, fallback) {
30
+ const raw = env[key]?.trim().toLowerCase();
31
+ if (!raw)
32
+ return fallback;
33
+ if (['1', 'true', 'yes', 'on'].includes(raw))
34
+ return true;
35
+ if (['0', 'false', 'no', 'off'].includes(raw))
36
+ return false;
37
+ throw new ConfigError(`${key} must be true or false (got "${env[key]}").`);
38
+ }
39
+ export function parseToolsets(raw) {
40
+ if (!raw || !raw.trim())
41
+ return [...DEFAULT_TOOLSETS];
42
+ const wanted = raw
43
+ .split(',')
44
+ .map((s) => s.trim().toLowerCase())
45
+ .filter(Boolean);
46
+ if (wanted.includes('all'))
47
+ return [...TOOLSETS];
48
+ const unknown = wanted.filter((t) => !TOOLSETS.includes(t));
49
+ if (unknown.length) {
50
+ throw new ConfigError(`Unknown toolset(s): ${unknown.join(', ')}. Use any of: ${TOOLSETS.join(', ')} (or "all").`);
51
+ }
52
+ return [...new Set(['read', ...wanted])];
53
+ }
54
+ export function parseAllowlist(raw) {
55
+ if (!raw || !raw.trim())
56
+ return [];
57
+ const out = [];
58
+ for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
59
+ const phone = normalizePhone(part);
60
+ if (!phone)
61
+ throw new ConfigError(`PALPLUSS_MCP_PAYOUT_ALLOWLIST has an invalid phone number: "${part}".`);
62
+ out.push(phone);
63
+ }
64
+ return [...new Set(out)];
65
+ }
66
+ /** Build the configuration from environment variables (the CLI path). */
67
+ export function loadConfigFromEnv(env = process.env) {
68
+ const apiKey = env.PALPLUSS_API_KEY?.trim();
69
+ if (!apiKey) {
70
+ throw new ConfigError('PALPLUSS_API_KEY is not set. Create a key at console.palpluss.com → API Keys and pass it as an environment variable.');
71
+ }
72
+ const approval = (env.PALPLUSS_MCP_APPROVAL?.trim().toLowerCase() || 'elicit');
73
+ if (approval !== 'elicit' && approval !== 'none') {
74
+ throw new ConfigError('PALPLUSS_MCP_APPROVAL must be "elicit" or "none".');
75
+ }
76
+ return {
77
+ apiKey,
78
+ baseUrl: (env.PALPLUSS_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(/\/+$/, ''),
79
+ timeoutMs: num(env, 'PALPLUSS_MCP_TIMEOUT_MS', 30_000),
80
+ toolsets: parseToolsets(env.PALPLUSS_MCP_TOOLSETS),
81
+ readOnly: bool(env, 'PALPLUSS_MCP_READ_ONLY', false),
82
+ approval,
83
+ limits: {
84
+ maxPayout: num(env, 'PALPLUSS_MCP_MAX_PAYOUT', DEFAULT_LIMITS.maxPayout),
85
+ dailyPayoutTotal: num(env, 'PALPLUSS_MCP_DAILY_PAYOUT_TOTAL', DEFAULT_LIMITS.dailyPayoutTotal),
86
+ dailyPayoutCount: num(env, 'PALPLUSS_MCP_DAILY_PAYOUT_COUNT', DEFAULT_LIMITS.dailyPayoutCount),
87
+ maxTopup: num(env, 'PALPLUSS_MCP_MAX_TOPUP', DEFAULT_LIMITS.maxTopup),
88
+ maxStk: num(env, 'PALPLUSS_MCP_MAX_STK', DEFAULT_LIMITS.maxStk),
89
+ payoutAllowlist: parseAllowlist(env.PALPLUSS_MCP_PAYOUT_ALLOWLIST),
90
+ },
91
+ stateFile: env.PALPLUSS_MCP_STATE_FILE?.trim() || undefined,
92
+ auditFile: env.PALPLUSS_MCP_AUDIT_FILE?.trim() || undefined,
93
+ };
94
+ }
95
+ /** Fill in defaults for programmatic use (embedding in another app). */
96
+ export function resolveConfig(input) {
97
+ if (!input.apiKey?.trim())
98
+ throw new ConfigError('apiKey is required.');
99
+ return {
100
+ apiKey: input.apiKey.trim(),
101
+ baseUrl: (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''),
102
+ timeoutMs: input.timeoutMs ?? 30_000,
103
+ toolsets: input.toolsets ? [...new Set(['read', ...input.toolsets])] : [...DEFAULT_TOOLSETS],
104
+ readOnly: input.readOnly ?? false,
105
+ approval: input.approval ?? 'elicit',
106
+ limits: {
107
+ ...DEFAULT_LIMITS,
108
+ ...input.limits,
109
+ payoutAllowlist: (input.limits?.payoutAllowlist ?? []).map((p) => {
110
+ const n = normalizePhone(p);
111
+ if (!n)
112
+ throw new ConfigError(`Invalid allowlist phone number: "${p}".`);
113
+ return n;
114
+ }),
115
+ },
116
+ stateFile: input.stateFile,
117
+ auditFile: input.auditFile,
118
+ };
119
+ }