perly-pay-mcp 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Perly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # perly-pay-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
+ Sell your work for crypto from your AI coding assistant. `perly-pay-mcp` is a
4
+ local [MCP](https://modelcontextprotocol.io) server for
5
+ [Perly Pay](https://pay.perly.io): your assistant can create products, prices,
6
+ payment links and invoices, pay them in test mode, read payments and receipts,
7
+ and check a buyer's access. Bitcoin, USDC and USDT land straight in your own
8
+ wallet; Perly Pay never holds them.
9
+
10
+ It runs on your machine over stdio and talks only to Perly Pay's public API
11
+ with your key. There is no server of ours in between.
12
+
13
+ ## Set up
14
+
15
+ Get a test key in Perly Pay: **Dashboard → Developers → API keys** (`sk_test_…`).
16
+
17
+ **Claude Code**
18
+
19
+ ```bash
20
+ claude mcp add perly-pay --env PERLY_PAY_SECRET_KEY=sk_test_… -- npx -y perly-pay-mcp
21
+ ```
22
+
23
+ **Cursor**: `~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "perly-pay": {
29
+ "command": "npx",
30
+ "args": ["-y", "perly-pay-mcp"],
31
+ "env": { "PERLY_PAY_SECRET_KEY": "sk_test_…" }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ **Codex**: `~/.codex/config.toml`
38
+
39
+ ```toml
40
+ [mcp_servers.perly-pay]
41
+ command = "npx"
42
+ args = ["-y", "perly-pay-mcp"]
43
+ env = { PERLY_PAY_SECRET_KEY = "sk_test_…" }
44
+ ```
45
+
46
+ Keep the key out of git: a config file in a project is often committed. Node 20
47
+ or newer.
48
+
49
+ | Variable | |
50
+ | --- | --- |
51
+ | `PERLY_PAY_SECRET_KEY` | `sk_test_…` while you build. `sk_live_…` works too, with the limits below. |
52
+ | `PERLY_PAY_API` | Default `https://pay.perly.io`. Plain `http` only for `localhost`. |
53
+ | `PERLY_PAY_WEBHOOK_SECRET` | Optional `whsec_…`, the default for `verify_webhook_signature`. |
54
+
55
+ ## Try it
56
+
57
+ > Sell a one-hour consultation for $49 with a payment link, then pay it in test mode and show me the payment.
58
+
59
+ The assistant calls `create_product`, `create_price`, `create_payment_link`,
60
+ `simulate_payment` and `list_payments`.
61
+
62
+ ## Tools
63
+
64
+ | Area | Tools |
65
+ | --- | --- |
66
+ | Products and prices | `create_product`, `list_products`, `update_product`, `create_price`, `list_prices`, `update_price` |
67
+ | Payment links | `create_payment_link`, `list_payment_links`, `update_payment_link` |
68
+ | Invoices and checkout | `create_invoice`, `list_invoices`, `retrieve_invoice`, `cancel_invoice`, `create_checkout_session`, `retrieve_checkout_session` |
69
+ | Customers | `create_customer`, `list_customers`, `retrieve_customer` |
70
+ | Subscriptions (renewed by invoice) | `create_subscription`, `list_subscriptions`, `retrieve_subscription` |
71
+ | Access and credits | `check_entitlement`, `list_entitlements`, `consume_entitlement_credits`, `list_credit_usage` |
72
+ | Money in, read only | `list_payments`, `retrieve_payment`, `list_receipts`, `retrieve_receipt`, `list_events` |
73
+ | Refunds | `create_refund`, `list_refunds` |
74
+ | Webhooks | `create_webhook_endpoint`, `list_webhook_endpoints`, `delete_webhook_endpoint` |
75
+ | Test mode | `simulate_payment`: pays an invoice, or buys from a payment link and pays that |
76
+ | On your machine | `verify_webhook_signature` checks a `Perly-Signature`; `pay_links` gives the dashboard pages only you can use: plan, wallet, go live, two-factor, keys |
77
+
78
+ The tools are generated from Perly Pay's OpenAPI document, so their arguments
79
+ are the API's own.
80
+
81
+ ## What it will not do
82
+
83
+ - **With a live key**, it refuses anything that moves or redirects money,
84
+ spends a buyer's credits or deletes: simulated payments, refunds, credit
85
+ spending, new or deleted webhook endpoints. It says why and where you do it.
86
+ Reading, and making products, prices, links and invoices, work as in test.
87
+ - **Wallets, API keys, plans, live mode and two-factor** have no tool at all.
88
+ You set them in the dashboard; `pay_links` gives the pages.
89
+ - **What cannot be undone** (cancelling an invoice, opening a refund, deleting
90
+ a webhook endpoint) waits for `confirm: true`, which the assistant should
91
+ only send after asking you.
92
+ - **Your secret key is never repeated** in an answer or a log.
93
+ - **Text others wrote is marked as data.** Customer names, emails, descriptions
94
+ and metadata can come from buyers, or from anyone who opens a payment link;
95
+ every API answer ends with a note telling the assistant not to follow them.
96
+ Still read what a tool is about to change before you approve it.
97
+
98
+ Docs: <https://pay.perly.io/docs/mcp>. API reference:
99
+ <https://pay.perly.io/docs/api>. MIT licence.
package/dist/api.js ADDED
@@ -0,0 +1,82 @@
1
+ export const MCP_VERSION = "0.1.1";
2
+ function retryAfterMs(header, attempt) {
3
+ const seconds = header !== null ? Number(header) : NaN;
4
+ if (Number.isFinite(seconds) && seconds >= 0)
5
+ return Math.min(seconds * 1000, 30_000);
6
+ return 500 * 2 ** attempt;
7
+ }
8
+ export async function callApi(config, deps, method, path, query, body) {
9
+ const url = new URL(config.apiUrl + path);
10
+ for (const [k, v] of Object.entries(query ?? {})) {
11
+ if (v !== undefined && v !== null)
12
+ url.searchParams.set(k, String(v));
13
+ }
14
+ const headers = {
15
+ Accept: "application/json",
16
+ Authorization: `Bearer ${config.secretKey ?? ""}`,
17
+ "Perly-Client": `mcp/${MCP_VERSION}`,
18
+ };
19
+ let payload;
20
+ if (method === "POST") {
21
+ headers["Content-Type"] = "application/json";
22
+ headers["Idempotency-Key"] = (deps.uuid ?? (() => crypto.randomUUID()))();
23
+ payload = JSON.stringify(body ?? {});
24
+ }
25
+ const retries = deps.maxRetries ?? 2;
26
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
27
+ for (let attempt = 0;; attempt++) {
28
+ let response;
29
+ try {
30
+ response = await deps.fetch(url.toString(), {
31
+ method,
32
+ headers,
33
+ body: payload,
34
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 30_000),
35
+ });
36
+ }
37
+ catch (error) {
38
+ if (attempt < retries) {
39
+ await sleep(retryAfterMs(null, attempt));
40
+ continue;
41
+ }
42
+ const timedOut = error?.name === "TimeoutError";
43
+ return {
44
+ ok: false,
45
+ status: 0,
46
+ error: {
47
+ type: "connection_error",
48
+ code: timedOut ? "timeout" : "connection_failed",
49
+ message: `Could not reach Perly Pay at ${config.siteUrl}${timedOut ? " (no answer in time)" : ""}.`,
50
+ },
51
+ };
52
+ }
53
+ const text = await response.text();
54
+ let json = null;
55
+ try {
56
+ json = text ? JSON.parse(text) : null;
57
+ }
58
+ catch {
59
+ json = null;
60
+ }
61
+ if (response.ok)
62
+ return { ok: true, status: response.status, data: json };
63
+ const error = json?.error;
64
+ const inProgress = response.status === 409 && error?.code === "idempotency_in_progress";
65
+ const retriable = response.status === 429 || response.status >= 500 || inProgress;
66
+ if (retriable && attempt < retries) {
67
+ await sleep(retryAfterMs(response.headers.get("retry-after"), attempt));
68
+ continue;
69
+ }
70
+ return {
71
+ ok: false,
72
+ status: response.status,
73
+ error: {
74
+ type: error?.type ?? "api_error",
75
+ code: error?.code ?? `http_${response.status}`,
76
+ message: error?.message ?? `Perly Pay answered ${response.status}.`,
77
+ ...(error?.param ? { param: error.param } : {}),
78
+ },
79
+ ...(response.headers.get("perly-request-id") ? { requestId: response.headers.get("perly-request-id") } : {}),
80
+ };
81
+ }
82
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx perly-pay-mcp`: Perly Pay's MCP server on stdio, for an AI coding
4
+ * assistant on this machine. It talks only to Perly Pay's public API with
5
+ * the key it is given; no server of ours sits in between.
6
+ *
7
+ * PERLY_PAY_SECRET_KEY=sk_test_… npx -y perly-pay-mcp
8
+ */
9
+ import { readConfig } from "./config.js";
10
+ import { serve } from "./server.js";
11
+ import { createToolbox, MCP_VERSION, SERVER_NAME } from "./toolbox.js";
12
+ const args = process.argv.slice(2);
13
+ if (args.includes("--version") || args.includes("-v")) {
14
+ process.stdout.write(`${MCP_VERSION}\n`);
15
+ process.exit(0);
16
+ }
17
+ if (args.includes("--help") || args.includes("-h")) {
18
+ process.stdout.write([
19
+ "perly-pay-mcp: Perly Pay's MCP server on stdio.",
20
+ "",
21
+ " PERLY_PAY_SECRET_KEY sk_test_… (or sk_live_…: then nothing that moves money or deletes)",
22
+ " PERLY_PAY_API default https://pay.perly.io",
23
+ " PERLY_PAY_WEBHOOK_SECRET optional whsec_…, for verify_webhook_signature",
24
+ "",
25
+ " claude mcp add perly-pay --env PERLY_PAY_SECRET_KEY=sk_test_… -- npx -y perly-pay-mcp",
26
+ "",
27
+ "Docs: https://pay.perly.io/docs/mcp",
28
+ "",
29
+ ].join("\n"));
30
+ process.exit(0);
31
+ }
32
+ const config = readConfig(process.env);
33
+ const toolbox = createToolbox(config, { fetch: globalThis.fetch });
34
+ await serve(toolbox, { name: SERVER_NAME, version: MCP_VERSION, siteUrl: config.siteUrl });
35
+ // The log never names the key, only its mode.
36
+ process.stderr.write(`perly-pay-mcp ${MCP_VERSION}: ${config.mode ? `${config.mode} mode` : (config.keyProblem ?? "no key")} · ${config.siteUrl}${config.urlProblem ? ` · ${config.urlProblem}` : ""}\n`);
package/dist/config.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What the server is started with, from its environment:
3
+ *
4
+ * PERLY_PAY_SECRET_KEY sk_test_… or sk_live_… (Dashboard → Developers)
5
+ * PERLY_PAY_API default https://pay.perly.io (http only for this machine)
6
+ * PERLY_PAY_WEBHOOK_SECRET optional whsec_…, the default for verify_webhook_signature
7
+ *
8
+ * A missing or wrong key does not stop the server: every API tool answers
9
+ * how to set it, which an assistant can repeat, where a server that exits
10
+ * shows only "failed to connect".
11
+ */
12
+ export const DEFAULT_SITE_URL = "https://pay.perly.io";
13
+ const KEY = /^sk_(test|live)_[0-9A-Za-z]{32}$/;
14
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
15
+ /** The key's mode from its prefix, or why it is not a key. */
16
+ export function checkKey(raw) {
17
+ const key = raw?.trim() ?? "";
18
+ if (!key) {
19
+ return { key: null, mode: null, problem: "No secret key. Set PERLY_PAY_SECRET_KEY to your sk_test_… key (Dashboard → Developers) in this MCP server's config." };
20
+ }
21
+ if (key.startsWith("whsec_"))
22
+ return { key: null, mode: null, problem: "PERLY_PAY_SECRET_KEY holds a webhook signing secret (whsec_…), not a secret key (sk_test_…)." };
23
+ const match = KEY.exec(key);
24
+ if (!match)
25
+ return { key: null, mode: null, problem: "PERLY_PAY_SECRET_KEY is not a Perly Pay secret key: it starts sk_test_ or sk_live_, then 32 letters and digits." };
26
+ return { key, mode: match[1], problem: null };
27
+ }
28
+ /** The site's origin from PERLY_PAY_API, with or without `/api/v1`. Plain http only to this machine: a key never crosses a network unencrypted. */
29
+ export function checkUrl(raw) {
30
+ const text = raw?.trim() || DEFAULT_SITE_URL;
31
+ let url;
32
+ try {
33
+ url = new URL(text);
34
+ }
35
+ catch {
36
+ return { siteUrl: DEFAULT_SITE_URL, problem: `PERLY_PAY_API is not a URL: ${text}` };
37
+ }
38
+ const local = LOCAL_HOSTS.has(url.hostname) || url.hostname.endsWith(".localhost");
39
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
40
+ return { siteUrl: DEFAULT_SITE_URL, problem: "PERLY_PAY_API must be https (plain http only for localhost)." };
41
+ }
42
+ const path = url.pathname.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
43
+ return { siteUrl: `${url.origin}${path}`, problem: null };
44
+ }
45
+ export function readConfig(env) {
46
+ const key = checkKey(env.PERLY_PAY_SECRET_KEY);
47
+ const url = checkUrl(env.PERLY_PAY_API);
48
+ const webhookSecret = env.PERLY_PAY_WEBHOOK_SECRET?.trim() || null;
49
+ return {
50
+ secretKey: key.key,
51
+ mode: key.mode,
52
+ keyProblem: key.problem,
53
+ siteUrl: url.siteUrl,
54
+ apiUrl: `${url.siteUrl}/api/v1`,
55
+ urlProblem: url.problem,
56
+ webhookSecret,
57
+ };
58
+ }
59
+ /** Takes any secret key out of text before it reaches the assistant: the configured one, and anything shaped like one. */
60
+ export function redact(text, secretKey) {
61
+ let out = secretKey ? text.split(secretKey).join("sk_…[hidden]") : text;
62
+ out = out.replace(/sk_(test|live)_[0-9A-Za-z]{8,}/g, "sk_$1_…[hidden]");
63
+ return out;
64
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Which of the API's operations an assistant gets as a tool, and on what
3
+ * terms. Every operation in the OpenAPI document is named here, exposed or
4
+ * left out with a reason: `operations.drift.test.ts` fails when the API
5
+ * gains one this table does not decide on, so a new endpoint is a choice,
6
+ * never an accident.
7
+ *
8
+ * The line it draws: an assistant may build and test a shop end to end, and
9
+ * read a live one. With a live key it may not move or redirect money, send
10
+ * live events somewhere new, spend a buyer's credits or delete anything —
11
+ * those are for the merchant, in the dashboard (`pay_links`), or for their
12
+ * app's own code. Wallets, API keys, plans and live mode have no endpoint at
13
+ * all; an assistant can only point at the page.
14
+ */
15
+ const allow = { expose: true, live: "allow" };
16
+ /**
17
+ * Some clients read only so many tools (Cursor stops at 40, across every
18
+ * server), so the set stays under that: where a list or another tool
19
+ * already answers, the extra call is left out.
20
+ */
21
+ const covered = (by) => ({ expose: false, why: `Covered by ${by}; left out to keep the set under clients' tool limits.` });
22
+ export const CURATION = {
23
+ createInvoice: allow,
24
+ listInvoices: allow,
25
+ retrieveInvoice: allow,
26
+ cancelInvoice: { ...allow, confirm: "Cancels the invoice: its buyer can no longer pay it." },
27
+ simulatePayment: {
28
+ expose: true,
29
+ live: "refuse",
30
+ liveWhy: "Simulated payments exist only in test mode. Use a test key (sk_test_…) to try the flow.",
31
+ },
32
+ createCheckoutSession: allow,
33
+ listCheckoutSessions: covered("retrieve_checkout_session and list_invoices"),
34
+ retrieveCheckoutSession: allow,
35
+ expireCheckoutSession: covered("the session's own expiry (expires_in_minutes)"),
36
+ openCheckoutSession: { expose: false, why: "The buyer's page (pay.js opens it), not a merchant call." },
37
+ createProduct: allow,
38
+ listProducts: allow,
39
+ retrieveProduct: covered("list_products"),
40
+ updateProduct: allow,
41
+ createPrice: allow,
42
+ listPrices: allow,
43
+ retrievePrice: covered("list_prices"),
44
+ updatePrice: allow,
45
+ createCustomer: allow,
46
+ listCustomers: allow,
47
+ retrieveCustomer: allow,
48
+ updateCustomer: covered("create_customer (customers are found or made by email)"),
49
+ createSubscription: allow,
50
+ listSubscriptions: allow,
51
+ retrieveSubscription: allow,
52
+ updateSubscription: { expose: false, why: "Changing a subscriber's plan or quantity is the merchant's call, in the dashboard or their code." },
53
+ cancelSubscription: { expose: false, why: "Ends a buyer's access; the merchant's call, in the dashboard or their code." },
54
+ pauseSubscription: { expose: false, why: "Stops a buyer's billing; the merchant's call, in the dashboard or their code." },
55
+ resumeSubscription: { expose: false, why: "Restarts a buyer's billing; the merchant's call, in the dashboard or their code." },
56
+ prepaySubscription: { expose: false, why: "Bills a buyer ahead; the merchant's call, in the dashboard or their code." },
57
+ createPaymentLink: allow,
58
+ listPaymentLinks: allow,
59
+ retrievePaymentLink: covered("list_payment_links"),
60
+ updatePaymentLink: allow,
61
+ createCoupon: { expose: false, why: "Not in the first set: discounts are made in the dashboard." },
62
+ listCoupons: { expose: false, why: "Not in the first set: discounts are made in the dashboard." },
63
+ retrieveCoupon: { expose: false, why: "Not in the first set: discounts are made in the dashboard." },
64
+ updateCoupon: { expose: false, why: "Not in the first set: discounts are made in the dashboard." },
65
+ checkEntitlement: allow,
66
+ listEntitlements: allow,
67
+ retrieveEntitlement: covered("check_entitlement and list_entitlements"),
68
+ consumeEntitlementCredits: {
69
+ expose: true,
70
+ live: "refuse",
71
+ liveWhy: "Spending a real buyer's credits is for your app's own code (the SDK's credits helpers), not an assistant. Try it with a test key.",
72
+ },
73
+ retrieveCreditBalance: covered("check_entitlement (its credits_remaining)"),
74
+ consumeCredits: covered("consume_entitlement_credits, which refuses a live key for the same reason"),
75
+ listCreditUsage: allow,
76
+ validateLicenseKey: { expose: false, why: "Called by the merchant's software on a buyer's machine, with the buyer's key." },
77
+ activateLicenseKey: { expose: false, why: "Called by the merchant's software on a buyer's machine, with the buyer's key." },
78
+ deactivateLicenseKey: { expose: false, why: "Called by the merchant's software on a buyer's machine, with the buyer's key." },
79
+ listLicenseKeys: { expose: false, why: "Buyers' license keys are secrets; they stay in the dashboard." },
80
+ retrieveLicenseKey: { expose: false, why: "Buyers' license keys are secrets; they stay in the dashboard." },
81
+ revokeLicenseKey: { expose: false, why: "Takes a buyer's software away; the merchant's call, in the dashboard." },
82
+ createPortalSession: { expose: false, why: "A sign-in link for one buyer; made by the merchant's app when that buyer asks." },
83
+ listPayments: allow,
84
+ retrievePayment: allow,
85
+ quoteRefund: covered("create_refund and list_refunds"),
86
+ createRefund: {
87
+ expose: true,
88
+ live: "refuse",
89
+ liveWhy: "A live refund is real money sent from your own wallet. Open it in the dashboard (pay_links: refunds).",
90
+ confirm: "Opens a refund to the buyer.",
91
+ },
92
+ listRefunds: allow,
93
+ retrieveRefund: covered("list_refunds"),
94
+ markRefundSent: { expose: false, why: "Records real money sent; the merchant does it in the dashboard (pay_links: refunds)." },
95
+ listReceipts: allow,
96
+ retrieveReceipt: allow,
97
+ receiptPdf: { expose: false, why: "A binary PDF; retrieve_receipt has every figure, and the receipt's page has the file." },
98
+ listEvents: allow,
99
+ retrieveEvent: covered("list_events"),
100
+ createWebhookEndpoint: {
101
+ expose: true,
102
+ live: "refuse",
103
+ liveWhy: "Sending live events to a new address is set up in the dashboard (pay_links: webhooks).",
104
+ },
105
+ listWebhookEndpoints: allow,
106
+ retrieveWebhookEndpoint: covered("list_webhook_endpoints"),
107
+ updateWebhookEndpoint: covered("delete_webhook_endpoint and create_webhook_endpoint"),
108
+ deleteWebhookEndpoint: {
109
+ expose: true,
110
+ live: "refuse",
111
+ liveWhy: "Deleting with a live key is refused. Delete live webhook endpoints in the dashboard.",
112
+ confirm: "Deletes the webhook endpoint: its events stop.",
113
+ },
114
+ rollWebhookSecret: { expose: false, why: "A new signing secret breaks webhook checks until it is deployed; rolled in the dashboard." },
115
+ };
116
+ /** `createPaymentLink` → `create_payment_link`. */
117
+ export function toolName(operationId) {
118
+ return operationId.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
119
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Turns Perly Pay's OpenAPI document into the operations the MCP server
3
+ * exposes: for each operation the curation table exposes, its method and
4
+ * path, its words, and ONE input schema — path parameters, query parameters
5
+ * and the JSON body side by side, the shape a tool call carries.
6
+ *
7
+ * Pure: no app code, no network. `operations.drift.test.ts` runs it on the
8
+ * document the API serves (built from the same zod schemas that validate
9
+ * requests) and holds `operations.generated.ts` to the result.
10
+ */
11
+ import { CURATION, toolName } from "./curation.js";
12
+ const METHODS = ["get", "post", "delete"];
13
+ function isObject(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ /** Every operation in the document, in its order. */
17
+ export function operationsOf(doc) {
18
+ const out = [];
19
+ for (const [path, item] of Object.entries((doc.paths ?? {}))) {
20
+ for (const method of METHODS) {
21
+ const op = item[method];
22
+ if (isObject(op))
23
+ out.push({ operationId: String(op.operationId), method: method.toUpperCase(), path, op });
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ function parametersOf(op) {
29
+ // `$ref`ed parameters are the shared Idempotency-Key header, which the server sets itself.
30
+ return (op.parameters ?? []).filter((p) => !("$ref" in p) && typeof p.name === "string");
31
+ }
32
+ /** A body that is one of several objects (a price's models) becomes one object: every field any variant has, required only where all require it. */
33
+ function flattenBody(schema, where) {
34
+ if (schema.type === "object" && isObject(schema.properties)) {
35
+ return { properties: schema.properties, required: schema.required ?? [] };
36
+ }
37
+ const variants = (schema.oneOf ?? schema.anyOf);
38
+ if (!variants?.every((v) => v.type === "object" && isObject(v.properties))) {
39
+ throw new Error(`${where}: the body is neither an object nor a choice of objects`);
40
+ }
41
+ const seen = new Map();
42
+ for (const variant of variants) {
43
+ for (const [name, prop] of Object.entries(variant.properties)) {
44
+ const list = seen.get(name) ?? [];
45
+ if (!list.some((p) => JSON.stringify(p) === JSON.stringify(prop)))
46
+ list.push(prop);
47
+ seen.set(name, list);
48
+ }
49
+ }
50
+ const properties = {};
51
+ for (const [name, list] of seen) {
52
+ if (list.length === 1)
53
+ properties[name] = list[0];
54
+ else if (list.every((p) => p.type === "string" && "const" in p && Object.keys(p).length === 2)) {
55
+ properties[name] = { type: "string", enum: list.map((p) => p.const) };
56
+ }
57
+ else {
58
+ properties[name] = { anyOf: list };
59
+ }
60
+ }
61
+ const required = (variants[0].required ?? []).filter((name) => variants.every((v) => (v.required ?? []).includes(name)));
62
+ return { properties, required };
63
+ }
64
+ function describeFlattened(schema) {
65
+ const variants = (schema.oneOf ?? schema.anyOf);
66
+ if (!variants)
67
+ return null;
68
+ const lines = variants.map((v) => {
69
+ const props = v.properties;
70
+ const tag = Object.entries(props).find(([, p]) => "const" in p);
71
+ const req = (v.required ?? []).join(", ");
72
+ return tag ? `${tag[0]} = ${String(tag[1].const)}: needs ${req}` : `needs ${req}`;
73
+ });
74
+ return `Body is one of: ${lines.join("; ")}.`;
75
+ }
76
+ export function buildOperation(entry) {
77
+ const { operationId, method, path, op } = entry;
78
+ const where = `${method} ${path} (${operationId})`;
79
+ const properties = {};
80
+ const required = [];
81
+ const add = (name, schema, isRequired, kind) => {
82
+ if (name in properties)
83
+ throw new Error(`${where}: "${name}" is both a ${kind} field and another kind of field`);
84
+ properties[name] = schema;
85
+ if (isRequired)
86
+ required.push(name);
87
+ };
88
+ const pathParams = [];
89
+ const queryParams = [];
90
+ for (const p of parametersOf(op)) {
91
+ if (p.in === "path") {
92
+ pathParams.push(p.name);
93
+ add(p.name, { type: "string", description: p.description ?? "The object's id." }, true, "path");
94
+ }
95
+ else if (p.in === "query") {
96
+ queryParams.push(p.name);
97
+ add(p.name, { ...(p.schema ?? {}), ...(p.description ? { description: p.description } : {}) }, p.required ?? false, "query");
98
+ }
99
+ }
100
+ for (const name of path.match(/\{(\w+)\}/g)?.map((m) => m.slice(1, -1)) ?? []) {
101
+ if (!pathParams.includes(name))
102
+ throw new Error(`${where}: path parameter {${name}} is not declared`);
103
+ }
104
+ const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
105
+ const bodyParams = [];
106
+ let note = null;
107
+ if (bodySchema) {
108
+ if (JSON.stringify(bodySchema).includes('"$ref"'))
109
+ throw new Error(`${where}: the body refers to a shared schema; inline it`);
110
+ const flat = flattenBody(bodySchema, where);
111
+ note = describeFlattened(bodySchema);
112
+ for (const [name, schema] of Object.entries(flat.properties)) {
113
+ bodyParams.push(name);
114
+ add(name, schema, flat.required.includes(name), "body");
115
+ }
116
+ }
117
+ const description = [op.description, note].filter((s) => typeof s === "string" && s.length > 0).join("\n\n");
118
+ return {
119
+ operationId,
120
+ tool: toolName(operationId),
121
+ method,
122
+ path,
123
+ summary: String(op.summary),
124
+ ...(description ? { description } : {}),
125
+ testOnly: op["x-test-mode-only"] === true,
126
+ pathParams,
127
+ queryParams,
128
+ bodyParams,
129
+ hasBody: Boolean(bodySchema),
130
+ inputSchema: {
131
+ type: "object",
132
+ properties,
133
+ ...(required.length ? { required } : {}),
134
+ additionalProperties: false,
135
+ },
136
+ };
137
+ }
138
+ /** The exposed operations, in the document's order. */
139
+ export function buildOperations(doc) {
140
+ return operationsOf(doc)
141
+ .filter((entry) => CURATION[entry.operationId]?.expose === true)
142
+ .map(buildOperation);
143
+ }
144
+ /** The operations as the source of `operations.generated.ts`. */
145
+ export function renderOperations(operations) {
146
+ return [
147
+ "/*",
148
+ " * GENERATED from Perly Pay's OpenAPI document (src/services/api/openapi.ts,",
149
+ " * served at /api/v1/openapi.json) by packages/mcp/src/generate.ts. Do not edit.",
150
+ " * After the API or src/curation.ts changes, regenerate:",
151
+ " *",
152
+ " * npx vitest run packages/mcp/src/operations.drift.test.ts -u",
153
+ " */",
154
+ 'import type { Operation } from "./generate.js";',
155
+ "",
156
+ `export const OPERATIONS: readonly Operation[] = ${JSON.stringify(operations, null, 2)};`,
157
+ "",
158
+ ].join("\n");
159
+ }
package/dist/links.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What only a person can do, and where: the dashboard pages behind the
3
+ * steps no API key may take — choosing a plan, connecting a wallet, going
4
+ * live, two-factor sign-in, keys and live webhooks, live refunds.
5
+ */
6
+ export const LINK_TOPICS = ["choose_plan", "connect_wallet", "go_live", "two_factor", "api_keys", "webhooks", "refunds", "checklist"];
7
+ const LINKS = {
8
+ choose_plan: { path: "/dashboard/billing", what: "Choose or change the Perly Pay plan. Flat price, 0% of volume." },
9
+ connect_wallet: {
10
+ path: "/dashboard/wallets",
11
+ what: "Connect a wallet by its PUBLIC key or address. Payments land there directly; Perly Pay never asks for a seed or private key.",
12
+ },
13
+ go_live: { path: "/dashboard/wallets", what: "Turn on live mode once a wallet is connected. Test mode stays as it is." },
14
+ two_factor: { path: "/dashboard/settings/security", what: "Set up two-factor sign-in (a passkey or an authenticator app)." },
15
+ api_keys: { path: "/dashboard/developers", what: "Make, roll or revoke secret keys (sk_test_…, sk_live_…)." },
16
+ webhooks: { path: "/dashboard/developers", what: "Add, change or delete live webhook endpoints and roll their signing secrets." },
17
+ refunds: { path: "/dashboard/refunds", what: "Refund a live payment: you send it from your own wallet, then record the transaction." },
18
+ checklist: { path: "/dashboard/integrate", what: "What is left before the first real payment, ticked off as it is done." },
19
+ };
20
+ export function link(siteUrl, topic) {
21
+ const { path, what } = LINKS[topic];
22
+ return { topic, url: `${siteUrl}${path}`, what };
23
+ }
24
+ export function links(siteUrl, topic) {
25
+ return (topic ? [topic] : LINK_TOPICS).map((t) => link(siteUrl, t));
26
+ }