@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/dist/config.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
* - `backend`: rely on PalPluss itself. Only works with MCP keys: PalPluss
|
|
13
|
+
* enforces the key's permissions and limits and holds payouts and channel
|
|
14
|
+
* changes until the merchant approves them in the console. Anything else is
|
|
15
|
+
* refused. Used by PalPluss' hosted server and by apps that embed this one.
|
|
16
|
+
* - `none`: no prompt. Only for automated tests against a fake or local API, never a live key.
|
|
17
|
+
*/
|
|
18
|
+
export type ApprovalMode = 'elicit' | 'backend' | 'none';
|
|
19
|
+
export interface Limits {
|
|
20
|
+
/** Largest single B2C payout, KES. */
|
|
21
|
+
maxPayout: number;
|
|
22
|
+
/** Total B2C payouts per Nairobi calendar day, KES. */
|
|
23
|
+
dailyPayoutTotal: number;
|
|
24
|
+
/** Number of B2C payouts per Nairobi calendar day. */
|
|
25
|
+
dailyPayoutCount: number;
|
|
26
|
+
/** Largest wallet top-up, KES. */
|
|
27
|
+
maxTopup: number;
|
|
28
|
+
/** Largest STK push request, KES. */
|
|
29
|
+
maxStk: number;
|
|
30
|
+
/** If non-empty, payouts may only go to these numbers (254XXXXXXXXX). */
|
|
31
|
+
payoutAllowlist: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface PalplussMcpConfig {
|
|
34
|
+
apiKey: string;
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
timeoutMs: number;
|
|
37
|
+
toolsets: Toolset[];
|
|
38
|
+
/** Kill switch: when true, every tool that changes anything is refused. */
|
|
39
|
+
readOnly: boolean;
|
|
40
|
+
approval: ApprovalMode;
|
|
41
|
+
limits: Limits;
|
|
42
|
+
/** Where to keep the daily payout tally between restarts (stdio installs). */
|
|
43
|
+
stateFile?: string;
|
|
44
|
+
/** Append-only JSON-lines audit log of every tool call. */
|
|
45
|
+
auditFile?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare const DEFAULT_BASE_URL = "https://api.palpluss.com";
|
|
48
|
+
export declare const DEFAULT_LIMITS: Limits;
|
|
49
|
+
export declare class ConfigError extends Error {
|
|
50
|
+
}
|
|
51
|
+
export declare function parseToolsets(raw: string | undefined): Toolset[];
|
|
52
|
+
export declare function parseAllowlist(raw: string | undefined): string[];
|
|
53
|
+
/** Build the configuration from environment variables (the CLI path). */
|
|
54
|
+
export declare function loadConfigFromEnv(env?: NodeJS.ProcessEnv): PalplussMcpConfig;
|
|
55
|
+
/** Fill in defaults for programmatic use (embedding in another app). */
|
|
56
|
+
export declare function resolveConfig(input: Partial<PalplussMcpConfig> & {
|
|
57
|
+
apiKey: string;
|
|
58
|
+
}): 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 !== 'backend' && approval !== 'none') {
|
|
74
|
+
throw new ConfigError('PALPLUSS_MCP_APPROVAL must be "elicit", "backend" 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
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import type { PalplussMcpConfig } from './config.js';
|
|
3
|
+
import { type CreateServerOptions } from './server.js';
|
|
4
|
+
export interface HttpServerOptions {
|
|
5
|
+
/** Base configuration (from env). Its apiKey is used unless per-request keys are allowed. */
|
|
6
|
+
config: PalplussMcpConfig;
|
|
7
|
+
host?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Required bearer token for every request (PALPLUSS_MCP_HTTP_TOKEN). Protects
|
|
11
|
+
* the endpoint so only the app you run it for can call it.
|
|
12
|
+
*/
|
|
13
|
+
token: string;
|
|
14
|
+
/**
|
|
15
|
+
* Accept the merchant's PalPluss key in `X-PalPluss-Api-Key` when a session
|
|
16
|
+
* starts (one endpoint serving many merchants). Off by default.
|
|
17
|
+
*/
|
|
18
|
+
allowKeyHeader?: boolean;
|
|
19
|
+
/** Close sessions idle longer than this (default 30 minutes). */
|
|
20
|
+
sessionIdleMs?: number;
|
|
21
|
+
extra?: Omit<CreateServerOptions, keyof PalplussMcpConfig>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Streamable HTTP endpoint at /mcp with sessions. Each session is one MCP
|
|
25
|
+
* client connection with its own server, bound to the PalPluss key it started
|
|
26
|
+
* with, so approval prompts (elicitation) and the client's capabilities carry
|
|
27
|
+
* across requests. Requires a bearer token on every request.
|
|
28
|
+
*/
|
|
29
|
+
export declare function startHttpServer(opts: HttpServerOptions): Promise<Server>;
|
|
30
|
+
/**
|
|
31
|
+
* Handle one MCP request without sessions: a fresh server and transport per
|
|
32
|
+
* request, closed when the response ends. For hosts that run several
|
|
33
|
+
* instances behind a load balancer (no shared session state).
|
|
34
|
+
*
|
|
35
|
+
* Elicitation needs a session, so approval prompts are not available here:
|
|
36
|
+
* use `approval: 'backend'` with an MCP key (PalPluss holds payouts and
|
|
37
|
+
* channel changes for console approval), or pass your own `approver`.
|
|
38
|
+
*
|
|
39
|
+
* `body` is the already-parsed JSON body (frameworks like Express or NestJS
|
|
40
|
+
* parse it before your handler runs).
|
|
41
|
+
*/
|
|
42
|
+
export declare function handleStatelessMcpRequest(req: IncomingMessage, res: ServerResponse, body: unknown, options: CreateServerOptions): Promise<void>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { createPalplussMcpServer } from './server.js';
|
|
6
|
+
const hash = (s) => createHash('sha256').update(s).digest();
|
|
7
|
+
function sameSecret(a, b) {
|
|
8
|
+
return timingSafeEqual(hash(a), hash(b));
|
|
9
|
+
}
|
|
10
|
+
function send(res, status, body) {
|
|
11
|
+
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
|
|
12
|
+
}
|
|
13
|
+
const rpcError = (code, message) => ({ jsonrpc: '2.0', error: { code, message }, id: null });
|
|
14
|
+
async function readJson(req, limit = 1_000_000) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
for await (const chunk of req) {
|
|
18
|
+
size += chunk.length;
|
|
19
|
+
if (size > limit)
|
|
20
|
+
throw new Error('Request body too large');
|
|
21
|
+
chunks.push(chunk);
|
|
22
|
+
}
|
|
23
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
return text ? JSON.parse(text) : undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Streamable HTTP endpoint at /mcp with sessions. Each session is one MCP
|
|
28
|
+
* client connection with its own server, bound to the PalPluss key it started
|
|
29
|
+
* with, so approval prompts (elicitation) and the client's capabilities carry
|
|
30
|
+
* across requests. Requires a bearer token on every request.
|
|
31
|
+
*/
|
|
32
|
+
export function startHttpServer(opts) {
|
|
33
|
+
const host = opts.host ?? '127.0.0.1';
|
|
34
|
+
const port = opts.port ?? 3333;
|
|
35
|
+
const idleMs = opts.sessionIdleMs ?? 30 * 60_000;
|
|
36
|
+
if (!opts.token || opts.token.length < 24) {
|
|
37
|
+
throw new Error('PALPLUSS_MCP_HTTP_TOKEN must be set to a random secret of at least 24 characters.');
|
|
38
|
+
}
|
|
39
|
+
const sessions = new Map();
|
|
40
|
+
const closeSession = (id) => {
|
|
41
|
+
const s = sessions.get(id);
|
|
42
|
+
if (!s)
|
|
43
|
+
return;
|
|
44
|
+
sessions.delete(id);
|
|
45
|
+
void s.transport.close();
|
|
46
|
+
void s.server.close();
|
|
47
|
+
};
|
|
48
|
+
const sweeper = setInterval(() => {
|
|
49
|
+
const cutoff = Date.now() - idleMs;
|
|
50
|
+
for (const [id, s] of sessions)
|
|
51
|
+
if (s.lastSeen < cutoff)
|
|
52
|
+
closeSession(id);
|
|
53
|
+
}, 60_000);
|
|
54
|
+
sweeper.unref();
|
|
55
|
+
const keyFor = (req) => {
|
|
56
|
+
const headerKey = req.headers['x-palpluss-api-key'];
|
|
57
|
+
return opts.allowKeyHeader && typeof headerKey === 'string' && headerKey.trim() ? headerKey.trim() : opts.config.apiKey;
|
|
58
|
+
};
|
|
59
|
+
const httpServer = createServer(async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
62
|
+
if (url.pathname === '/health')
|
|
63
|
+
return send(res, 200, { ok: true, sessions: sessions.size });
|
|
64
|
+
if (url.pathname !== '/mcp')
|
|
65
|
+
return send(res, 404, { error: 'Not found' });
|
|
66
|
+
const auth = req.headers.authorization ?? '';
|
|
67
|
+
const presented = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
|
68
|
+
if (!presented || !sameSecret(presented, opts.token))
|
|
69
|
+
return send(res, 401, rpcError(-32001, 'Unauthorized'));
|
|
70
|
+
const apiKey = keyFor(req);
|
|
71
|
+
if (!apiKey)
|
|
72
|
+
return send(res, 400, rpcError(-32602, 'No PalPluss API key: set PALPLUSS_API_KEY or send X-PalPluss-Api-Key.'));
|
|
73
|
+
const keyHash = hash(apiKey).toString('hex');
|
|
74
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
75
|
+
const body = req.method === 'POST' ? await readJson(req) : undefined;
|
|
76
|
+
if (typeof sessionId === 'string' && sessionId) {
|
|
77
|
+
const s = sessions.get(sessionId);
|
|
78
|
+
if (!s)
|
|
79
|
+
return send(res, 404, rpcError(-32001, 'Session not found or expired. Reconnect.'));
|
|
80
|
+
// A session belongs to the key that opened it.
|
|
81
|
+
if (s.keyHash !== keyHash)
|
|
82
|
+
return send(res, 403, rpcError(-32001, 'This session belongs to a different PalPluss key.'));
|
|
83
|
+
s.lastSeen = Date.now();
|
|
84
|
+
if (req.method === 'DELETE') {
|
|
85
|
+
await s.transport.handleRequest(req, res, body);
|
|
86
|
+
closeSession(sessionId);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
return s.transport.handleRequest(req, res, body);
|
|
90
|
+
}
|
|
91
|
+
if (req.method !== 'POST' || !isInitializeRequest(body)) {
|
|
92
|
+
return send(res, 400, rpcError(-32000, 'Start a session with an initialize request first.'));
|
|
93
|
+
}
|
|
94
|
+
const { server } = createPalplussMcpServer({ ...opts.config, ...opts.extra, apiKey });
|
|
95
|
+
const transport = new StreamableHTTPServerTransport({
|
|
96
|
+
sessionIdGenerator: () => randomUUID(),
|
|
97
|
+
onsessioninitialized: (id) => {
|
|
98
|
+
sessions.set(id, { server, transport, keyHash, lastSeen: Date.now() });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
transport.onclose = () => {
|
|
102
|
+
if (transport.sessionId)
|
|
103
|
+
sessions.delete(transport.sessionId);
|
|
104
|
+
};
|
|
105
|
+
await server.connect(transport);
|
|
106
|
+
await transport.handleRequest(req, res, body);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (!res.headersSent)
|
|
110
|
+
send(res, 400, rpcError(-32700, err.message || 'Bad request'));
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
httpServer.on('close', () => {
|
|
114
|
+
clearInterval(sweeper);
|
|
115
|
+
for (const id of [...sessions.keys()])
|
|
116
|
+
closeSession(id);
|
|
117
|
+
});
|
|
118
|
+
return new Promise((resolve) => httpServer.listen(port, host, () => resolve(httpServer)));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Handle one MCP request without sessions: a fresh server and transport per
|
|
122
|
+
* request, closed when the response ends. For hosts that run several
|
|
123
|
+
* instances behind a load balancer (no shared session state).
|
|
124
|
+
*
|
|
125
|
+
* Elicitation needs a session, so approval prompts are not available here:
|
|
126
|
+
* use `approval: 'backend'` with an MCP key (PalPluss holds payouts and
|
|
127
|
+
* channel changes for console approval), or pass your own `approver`.
|
|
128
|
+
*
|
|
129
|
+
* `body` is the already-parsed JSON body (frameworks like Express or NestJS
|
|
130
|
+
* parse it before your handler runs).
|
|
131
|
+
*/
|
|
132
|
+
export async function handleStatelessMcpRequest(req, res, body, options) {
|
|
133
|
+
const { server } = createPalplussMcpServer(options);
|
|
134
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
135
|
+
res.on('close', () => {
|
|
136
|
+
void transport.close();
|
|
137
|
+
void server.close();
|
|
138
|
+
});
|
|
139
|
+
await server.connect(transport);
|
|
140
|
+
await transport.handleRequest(req, res, body);
|
|
141
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createPalplussMcpServer, SERVER_NAME, SERVER_VERSION, type CreateServerOptions } from './server.js';
|
|
2
|
+
export { loadConfigFromEnv, resolveConfig, parseToolsets, ConfigError, TOOLSETS, DEFAULT_TOOLSETS, DEFAULT_LIMITS, DEFAULT_BASE_URL, type PalplussMcpConfig, type Toolset, type Limits, type ApprovalMode, } from './config.js';
|
|
3
|
+
export type { Approver, ApprovalRequest, ApprovalOutcome } from './safety/approval.js';
|
|
4
|
+
export type { AuditEvent, AuditSink } from './safety/audit.js';
|
|
5
|
+
export { PalplussApiError, isPendingApproval, type KeyInfo, type PendingApproval } from './api/client.js';
|
|
6
|
+
export { startHttpServer, handleStatelessMcpRequest, type HttpServerOptions } from './http.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createPalplussMcpServer, SERVER_NAME, SERVER_VERSION } from './server.js';
|
|
2
|
+
export { loadConfigFromEnv, resolveConfig, parseToolsets, ConfigError, TOOLSETS, DEFAULT_TOOLSETS, DEFAULT_LIMITS, DEFAULT_BASE_URL, } from './config.js';
|
|
3
|
+
export { PalplussApiError, isPendingApproval } from './api/client.js';
|
|
4
|
+
export { startHttpServer, handleStatelessMcpRequest } from './http.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { ApprovalMode } from '../config.js';
|
|
3
|
+
/** One action waiting for a human's yes. */
|
|
4
|
+
export interface ApprovalRequest {
|
|
5
|
+
/** Stable action name, e.g. "b2c_payout". */
|
|
6
|
+
action: string;
|
|
7
|
+
/** One line a person can read at a glance. */
|
|
8
|
+
title: string;
|
|
9
|
+
/** Label → value pairs shown to the person (already masked where needed). */
|
|
10
|
+
details: Record<string, string>;
|
|
11
|
+
/** What happens if they approve, in plain words. */
|
|
12
|
+
consequence: string;
|
|
13
|
+
}
|
|
14
|
+
export type ApprovalOutcome = {
|
|
15
|
+
approved: true;
|
|
16
|
+
via: 'elicitation' | 'host' | 'disabled';
|
|
17
|
+
} | {
|
|
18
|
+
approved: false;
|
|
19
|
+
reason: 'declined' | 'cancelled' | 'unavailable';
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Decides whether an action may go ahead. An embedding app
|
|
24
|
+
* can pass its own — for example one that opens its confirm dialog.
|
|
25
|
+
*/
|
|
26
|
+
export type Approver = (request: ApprovalRequest) => Promise<ApprovalOutcome>;
|
|
27
|
+
/**
|
|
28
|
+
* Ask the person directly through the MCP client (elicitation). The request
|
|
29
|
+
* goes from this server to the person's app and back — the AI model never
|
|
30
|
+
* sees or answers it, so it cannot approve its own actions.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createElicitationApprover(server: McpServer): Approver;
|
|
33
|
+
/** Pick the approver for a server: the embedder's, the client prompt, or none. */
|
|
34
|
+
export declare function resolveApprover(mode: ApprovalMode, server: McpServer, custom?: Approver): Approver;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
function renderMessage(req) {
|
|
2
|
+
const lines = Object.entries(req.details).map(([k, v]) => `• ${k}: ${v}`);
|
|
3
|
+
return [`PalPluss — ${req.title}`, '', ...lines, '', req.consequence, '', 'Approve only if you asked for this.'].join('\n');
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Ask the person directly through the MCP client (elicitation). The request
|
|
7
|
+
* goes from this server to the person's app and back — the AI model never
|
|
8
|
+
* sees or answers it, so it cannot approve its own actions.
|
|
9
|
+
*/
|
|
10
|
+
export function createElicitationApprover(server) {
|
|
11
|
+
return async (req) => {
|
|
12
|
+
const caps = server.server.getClientCapabilities();
|
|
13
|
+
if (!caps?.elicitation) {
|
|
14
|
+
return {
|
|
15
|
+
approved: false,
|
|
16
|
+
reason: 'unavailable',
|
|
17
|
+
message: 'This action needs your approval, but this app cannot show approval prompts (MCP elicitation). Use a client that supports them, or do it in the PalPluss console.',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const result = await server.server.elicitInput({
|
|
22
|
+
mode: 'form',
|
|
23
|
+
message: renderMessage(req),
|
|
24
|
+
requestedSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
approve: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
title: 'Approve this action',
|
|
30
|
+
description: req.title,
|
|
31
|
+
default: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ['approve'],
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
if (result.action === 'accept' && result.content?.approve === true) {
|
|
38
|
+
return { approved: true, via: 'elicitation' };
|
|
39
|
+
}
|
|
40
|
+
if (result.action === 'cancel') {
|
|
41
|
+
return { approved: false, reason: 'cancelled', message: 'The approval prompt was dismissed. Nothing was done.' };
|
|
42
|
+
}
|
|
43
|
+
return { approved: false, reason: 'declined', message: 'You declined. Nothing was done.' };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return {
|
|
47
|
+
approved: false,
|
|
48
|
+
reason: 'unavailable',
|
|
49
|
+
message: `Could not show the approval prompt (${err?.message ?? 'unknown error'}). Nothing was done.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Pick the approver for a server: the embedder's, the client prompt, or none. */
|
|
55
|
+
export function resolveApprover(mode, server, custom) {
|
|
56
|
+
if (custom)
|
|
57
|
+
return custom;
|
|
58
|
+
if (mode === 'none')
|
|
59
|
+
return async () => ({ approved: true, via: 'disabled' });
|
|
60
|
+
return createElicitationApprover(server);
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface AuditEvent {
|
|
2
|
+
at: string;
|
|
3
|
+
tool: string;
|
|
4
|
+
outcome: 'ok' | 'error' | 'refused' | 'declined';
|
|
5
|
+
/** Tool arguments with phone numbers masked. */
|
|
6
|
+
args?: unknown;
|
|
7
|
+
/** PalPluss transaction or channel id involved, if any. */
|
|
8
|
+
ref?: string;
|
|
9
|
+
code?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
approval?: string;
|
|
12
|
+
requestId?: string;
|
|
13
|
+
}
|
|
14
|
+
export type AuditSink = (event: AuditEvent) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Records every tool call: to a JSON-lines file (PALPLUSS_MCP_AUDIT_FILE)
|
|
17
|
+
* and/or to a sink an embedding app provides. Never logs the API key.
|
|
18
|
+
*/
|
|
19
|
+
export declare class AuditLog {
|
|
20
|
+
private readonly file?;
|
|
21
|
+
private readonly sink?;
|
|
22
|
+
constructor(file?: string | undefined, sink?: AuditSink | undefined);
|
|
23
|
+
record(event: Omit<AuditEvent, 'at' | 'args'> & {
|
|
24
|
+
args?: unknown;
|
|
25
|
+
}): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { maskArgs } from './redact.js';
|
|
4
|
+
/**
|
|
5
|
+
* Records every tool call: to a JSON-lines file (PALPLUSS_MCP_AUDIT_FILE)
|
|
6
|
+
* and/or to a sink an embedding app provides. Never logs the API key.
|
|
7
|
+
*/
|
|
8
|
+
export class AuditLog {
|
|
9
|
+
file;
|
|
10
|
+
sink;
|
|
11
|
+
constructor(file, sink) {
|
|
12
|
+
this.file = file;
|
|
13
|
+
this.sink = sink;
|
|
14
|
+
if (file) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* reported on first write */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
record(event) {
|
|
24
|
+
const full = { ...event, at: new Date().toISOString(), args: maskArgs(event.args) };
|
|
25
|
+
if (this.file) {
|
|
26
|
+
try {
|
|
27
|
+
appendFileSync(this.file, `${JSON.stringify(full)}\n`, { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
process.stderr.write(`[palpluss-mcp] could not write audit log: ${err.message}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
this.sink?.(full);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* an embedder's sink must never break a tool call */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Limits } from '../config.js';
|
|
2
|
+
/** Today's date in Nairobi, YYYY-MM-DD. */
|
|
3
|
+
export declare function nairobiDay(now?: Date): string;
|
|
4
|
+
export declare class LimitError extends Error {
|
|
5
|
+
readonly code: string;
|
|
6
|
+
constructor(message: string, code: string);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Per-key payout limits enforced by this MCP server: per payout, per day
|
|
10
|
+
* (total and count) and an optional recipient allowlist.
|
|
11
|
+
*
|
|
12
|
+
* A payout reserves its amount before the API call and releases it if the
|
|
13
|
+
* call fails, so two payouts approved at the same moment can't both slip
|
|
14
|
+
* under the daily limit.
|
|
15
|
+
*
|
|
16
|
+
* These are a guardrail in front of the API, not a replacement for limits the
|
|
17
|
+
* backend enforces; they reset if the state file is removed.
|
|
18
|
+
*/
|
|
19
|
+
export declare class PayoutLimiter {
|
|
20
|
+
private readonly limits;
|
|
21
|
+
private readonly stateFile?;
|
|
22
|
+
private readonly now;
|
|
23
|
+
private static readonly shared;
|
|
24
|
+
private readonly key;
|
|
25
|
+
constructor(limits: Limits, apiKey: string, stateFile?: string | undefined, now?: () => Date);
|
|
26
|
+
private load;
|
|
27
|
+
private persist;
|
|
28
|
+
/** What's left today, for display. */
|
|
29
|
+
remaining(): {
|
|
30
|
+
day: string;
|
|
31
|
+
usedTotal: number;
|
|
32
|
+
usedCount: number;
|
|
33
|
+
remainingTotal: number;
|
|
34
|
+
remainingCount: number;
|
|
35
|
+
};
|
|
36
|
+
/** Throws LimitError if this payout isn't allowed. Does not reserve. */
|
|
37
|
+
check(amount: number, phone: string): void;
|
|
38
|
+
/** Check and reserve atomically (single-threaded JS). Returns a release fn. */
|
|
39
|
+
reserve(amount: number, phone: string): () => void;
|
|
40
|
+
/** Test helper. */
|
|
41
|
+
static resetAll(): void;
|
|
42
|
+
}
|