@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 +72 -0
- package/README.md +165 -2
- package/dist/api/client.d.ts +154 -0
- package/dist/api/client.js +164 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +65 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.js +119 -0
- package/dist/http.d.ts +42 -0
- package/dist/http.js +141 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/safety/approval.d.ts +34 -0
- package/dist/safety/approval.js +61 -0
- package/dist/safety/audit.d.ts +26 -0
- package/dist/safety/audit.js +40 -0
- package/dist/safety/limits.d.ts +42 -0
- package/dist/safety/limits.js +130 -0
- package/dist/safety/redact.d.ts +12 -0
- package/dist/safety/redact.js +80 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.js +71 -0
- package/dist/tools/common.d.ts +75 -0
- package/dist/tools/common.js +221 -0
- package/dist/tools/guide.d.ts +7 -0
- package/dist/tools/guide.js +146 -0
- package/dist/tools/money.d.ts +13 -0
- package/dist/tools/money.js +360 -0
- package/dist/tools/read.d.ts +2 -0
- package/dist/tools/read.js +148 -0
- package/package.json +59 -4
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
|
-
#
|
|
1
|
+
# @palpluss/mcp
|
|
2
2
|
|
|
3
|
-
|
|
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
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
|
+
});
|