@finrock/mcp 0.0.0-stage → 1.0.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/LICENSE +7 -0
- package/README.md +214 -2
- package/dist/api/client.d.ts +50 -0
- package/dist/api/client.js +172 -0
- package/dist/api/tools.d.ts +17 -0
- package/dist/api/tools.js +300 -0
- package/dist/docs.d.ts +61 -0
- package/dist/docs.js +190 -0
- package/dist/guides.d.ts +19 -0
- package/dist/guides.js +66 -0
- package/dist/http.d.ts +13 -0
- package/dist/http.js +94 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +229 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +123 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +28 -0
- package/dist/webhook.d.ts +11 -0
- package/dist/webhook.js +39 -0
- package/guides/_client/nextjs.md +57 -0
- package/guides/_client/node.md +55 -0
- package/guides/_client/php.md +67 -0
- package/guides/_client/python.md +55 -0
- package/guides/accept-payments/nextjs.md +75 -0
- package/guides/accept-payments/node.md +49 -0
- package/guides/accept-payments/overview.md +30 -0
- package/guides/accept-payments/php.md +61 -0
- package/guides/accept-payments/python.md +59 -0
- package/guides/authentication/overview.md +60 -0
- package/guides/deposit-addresses/nextjs.md +50 -0
- package/guides/deposit-addresses/node.md +30 -0
- package/guides/deposit-addresses/overview.md +36 -0
- package/guides/deposit-addresses/php.md +35 -0
- package/guides/deposit-addresses/python.md +31 -0
- package/guides/payment-widget/html.md +26 -0
- package/guides/payment-widget/nextjs.md +41 -0
- package/guides/payment-widget/overview.md +32 -0
- package/guides/webhooks/nextjs.md +48 -0
- package/guides/webhooks/node.md +48 -0
- package/guides/webhooks/overview.md +34 -0
- package/guides/webhooks/php.md +46 -0
- package/guides/webhooks/python.md +55 -0
- package/guides/withdrawals/nextjs.md +35 -0
- package/guides/withdrawals/node.md +35 -0
- package/guides/withdrawals/overview.md +32 -0
- package/guides/withdrawals/php.md +37 -0
- package/guides/withdrawals/python.md +33 -0
- package/package.json +55 -4
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { errorMessage, FinrockApiError } from "./client.js";
|
|
3
|
+
export function apiToolMode(env = process.env) {
|
|
4
|
+
const on = (v) => /^(1|true|yes)$/i.test(v ?? "");
|
|
5
|
+
return { readOnly: on(env.FINROCK_READ_ONLY), enableSensitive: on(env.FINROCK_ENABLE_SENSITIVE_TOOLS) };
|
|
6
|
+
}
|
|
7
|
+
export function describeMode(opts) {
|
|
8
|
+
if (opts.readOnly)
|
|
9
|
+
return "read-only (FINROCK_READ_ONLY is set)";
|
|
10
|
+
if (opts.enableSensitive)
|
|
11
|
+
return "full access, including tools that move funds";
|
|
12
|
+
return "read and create; tools that move funds or change security settings are disabled until FINROCK_ENABLE_SENSITIVE_TOOLS=true";
|
|
13
|
+
}
|
|
14
|
+
const asset = z.string().min(1).describe("Asset name from Supported Assets, e.g. BTC, ETH, USDT_TRX");
|
|
15
|
+
const json = (v) => ({ content: [{ type: "text", text: JSON.stringify(v ?? null, null, 2) }] });
|
|
16
|
+
function errorResult(err) {
|
|
17
|
+
let msg = errorMessage(err);
|
|
18
|
+
if (err instanceof FinrockApiError) {
|
|
19
|
+
if (err.status === 401)
|
|
20
|
+
msg += "\nCheck FINROCK_API_KEY, that the uploaded public key matches FINROCK_PRIVATE_KEY, and that this machine's clock is correct.";
|
|
21
|
+
if (err.status === 403)
|
|
22
|
+
msg += "\nThe API user lacks permission for this action, or this IP is not whitelisted for the key.";
|
|
23
|
+
if (err.body.includes("DUPLICATE_SEQUENCE_ID"))
|
|
24
|
+
msg += "\nA transaction with this sequence_id already exists, so nothing was sent again. Find it with list_transactions.";
|
|
25
|
+
if (err.status === 429)
|
|
26
|
+
msg += `\nRate limited${err.retryAfter ? `; the window resets at ${err.retryAfter}` : ""}. Wait before retrying.`;
|
|
27
|
+
}
|
|
28
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
29
|
+
}
|
|
30
|
+
export function registerApiTools(server, api, opts = {}) {
|
|
31
|
+
const registered = [];
|
|
32
|
+
function tool(name, tier, meta, run) {
|
|
33
|
+
if (tier === "write" && opts.readOnly)
|
|
34
|
+
return;
|
|
35
|
+
if (tier === "sensitive" && (opts.readOnly || !opts.enableSensitive))
|
|
36
|
+
return;
|
|
37
|
+
server.registerTool(name, {
|
|
38
|
+
title: meta.title,
|
|
39
|
+
description: meta.description,
|
|
40
|
+
inputSchema: meta.inputSchema,
|
|
41
|
+
annotations: {
|
|
42
|
+
readOnlyHint: tier === "read",
|
|
43
|
+
destructiveHint: tier === "sensitive" || meta.destructive === true,
|
|
44
|
+
idempotentHint: tier === "read" || meta.idempotent === true,
|
|
45
|
+
openWorldHint: true,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
// The SDK infers handler args from the schema; the cast keeps this generic helper simple.
|
|
49
|
+
(async (args) => {
|
|
50
|
+
try {
|
|
51
|
+
return json(await run(args));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return errorResult(err);
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
57
|
+
registered.push(name);
|
|
58
|
+
}
|
|
59
|
+
// ---- Wallets and addresses -------------------------------------------------
|
|
60
|
+
tool("list_wallets", "read", {
|
|
61
|
+
title: "List wallets",
|
|
62
|
+
description: "Balances, frozen state and auto-forwarding settings for the account's wallets.",
|
|
63
|
+
inputSchema: { asset: asset.optional() },
|
|
64
|
+
}, (a) => api.get("/dac/v1/wallets", { asset: a.asset }));
|
|
65
|
+
tool("list_addresses", "read", {
|
|
66
|
+
title: "List deposit addresses",
|
|
67
|
+
description: "Deposit addresses for one asset with balances, labels and omnibus/auto-refill settings. Filter by label to find a customer's address.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
asset,
|
|
70
|
+
label: z.string().optional(),
|
|
71
|
+
address: z.string().optional(),
|
|
72
|
+
index: z.string().optional().describe("Address index, e.g. '0'"),
|
|
73
|
+
},
|
|
74
|
+
}, (a) => api.get("/dac/v1/addresses", { asset: a.asset, label: a.label, address: a.address, index: a.index }));
|
|
75
|
+
tool("validate_address", "read", {
|
|
76
|
+
title: "Validate an address",
|
|
77
|
+
description: "Check an address (and destination tag for XRP/XLM) is valid for an asset before sending to it.",
|
|
78
|
+
inputSchema: { asset, address: z.string().min(1), address_tag: z.string().optional() },
|
|
79
|
+
}, (a) => api.get("/dac/v1/validate-address", { asset: a.asset, address: a.address, address_tag: a.address_tag }));
|
|
80
|
+
tool("create_wallet", "write", {
|
|
81
|
+
title: "Create a wallet",
|
|
82
|
+
description: "Provision the MPC wallet for an asset. Only one wallet per asset per sub-account; call list_wallets first.",
|
|
83
|
+
inputSchema: { asset },
|
|
84
|
+
}, (a) => api.post("/dac/v1/wallet", { asset: a.asset }));
|
|
85
|
+
tool("create_address", "write", {
|
|
86
|
+
title: "Create a deposit address",
|
|
87
|
+
description: "Generate a new deposit address. Use the label for your customer or purpose so deposits can be reconciled.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
asset,
|
|
90
|
+
label: z.string().min(1).describe("e.g. customer-42"),
|
|
91
|
+
omnibus: z.boolean().default(false).describe("true adds the address to the shared omnibus pool; false keeps it isolated"),
|
|
92
|
+
auto_refill: z.boolean().default(false).describe("Top up gas from the Gas Tank automatically (always on for omnibus)"),
|
|
93
|
+
},
|
|
94
|
+
}, (a) => api.post("/dac/v1/generate-address", a));
|
|
95
|
+
tool("update_address", "write", {
|
|
96
|
+
title: "Update a deposit address",
|
|
97
|
+
description: "Change an address's label, omnibus membership or gas auto-refill. Omitted fields stay unchanged.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
asset,
|
|
100
|
+
address: z.string().min(1),
|
|
101
|
+
address_tag: z.string().default("").describe("Destination tag/memo; empty for chains without tags"),
|
|
102
|
+
label: z.string().optional(),
|
|
103
|
+
omnibus: z.boolean().optional(),
|
|
104
|
+
auto_refill: z.boolean().optional(),
|
|
105
|
+
},
|
|
106
|
+
idempotent: true,
|
|
107
|
+
}, (a) => api.patch("/dac/v1/address", dropUndefined(a)));
|
|
108
|
+
tool("generate_xrp_destination_tag", "write", {
|
|
109
|
+
title: "Generate an XRP destination tag",
|
|
110
|
+
description: "Create a new XRP destination tag to route deposits to a customer on a shared XRP address.",
|
|
111
|
+
inputSchema: {},
|
|
112
|
+
}, () => api.get("/dac/v1/xrp-dt-memo"));
|
|
113
|
+
// ---- Invoices --------------------------------------------------------------
|
|
114
|
+
tool("create_invoice", "write", {
|
|
115
|
+
title: "Create a payment invoice",
|
|
116
|
+
description: "Create a hosted crypto invoice. crypto.asset_id (the coin to collect) is required; price it with fiat (converted to the coin) or crypto.amount. Returns payment_url to send the payer to, plus the deposit address and expiry. Statuses: Pending, Processing, Paid, Expired, Failed, Cancelled, Refunded.",
|
|
117
|
+
inputSchema: {
|
|
118
|
+
memo: z.string().min(1).describe("Your reference, e.g. an order ID; used to find the invoice later"),
|
|
119
|
+
fiat: z.object({ asset_id: z.string().describe("USD, EUR, GBP, AED…"), amount: z.number().positive() }).optional(),
|
|
120
|
+
crypto: z
|
|
121
|
+
.object({ asset_id: z.string().min(1).describe("e.g. USDT_TRX"), amount: z.number().positive().optional() })
|
|
122
|
+
.describe("The coin to collect, and optionally its amount when not pricing in fiat"),
|
|
123
|
+
payer: z
|
|
124
|
+
.object({
|
|
125
|
+
name: z.string().optional(),
|
|
126
|
+
email: z.string().optional(),
|
|
127
|
+
mobile: z.string().optional(),
|
|
128
|
+
country_code: z.string().optional(),
|
|
129
|
+
})
|
|
130
|
+
.optional(),
|
|
131
|
+
ipn_url: z.string().url().optional().describe("URL notified when the payment status changes"),
|
|
132
|
+
success_url: z.string().url().optional(),
|
|
133
|
+
failed_url: z.string().url().optional(),
|
|
134
|
+
},
|
|
135
|
+
}, async (a) => {
|
|
136
|
+
if (!a.fiat && a.crypto.amount === undefined)
|
|
137
|
+
throw new Error("Give a price: fiat, or crypto.amount.");
|
|
138
|
+
return api.post("/dac/v1/invoice", dropUndefined(a));
|
|
139
|
+
});
|
|
140
|
+
tool("list_invoices", "read", {
|
|
141
|
+
title: "List invoices",
|
|
142
|
+
description: "Find invoices by ID or memo and check their payment status. An unknown ID or memo returns INVOICE_NOT_FOUND.",
|
|
143
|
+
inputSchema: { id: z.string().optional(), memo: z.string().optional() },
|
|
144
|
+
}, (a) => api.get("/dac/v1/invoice", { id: a.id, memo: a.memo }));
|
|
145
|
+
// ---- Transactions and fees -------------------------------------------------
|
|
146
|
+
tool("list_transactions", "read", {
|
|
147
|
+
title: "List transactions",
|
|
148
|
+
description: "Deposits, withdrawals and sweeps with status, confirmations, fees and explorer links. All filters optional.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
id: z.string().optional().describe("Finrock transaction ID or on-chain hash"),
|
|
151
|
+
asset: z.string().optional(),
|
|
152
|
+
status: z.string().optional().describe("e.g. Pending, Success, Failed"),
|
|
153
|
+
memo: z.string().optional(),
|
|
154
|
+
source_type: z.string().optional(),
|
|
155
|
+
source_id: z.string().optional(),
|
|
156
|
+
dest_type: z.string().optional(),
|
|
157
|
+
dest_id: z.string().optional(),
|
|
158
|
+
after: z.number().int().optional().describe("Unix seconds"),
|
|
159
|
+
before: z.number().int().optional().describe("Unix seconds"),
|
|
160
|
+
page: z.number().int().min(1).optional(),
|
|
161
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
162
|
+
},
|
|
163
|
+
}, (a) => api.get("/dac/v1/transactions", a));
|
|
164
|
+
tool("estimate_fee", "read", {
|
|
165
|
+
title: "Estimate a network fee",
|
|
166
|
+
description: "Estimate the network fee for a transfer at a fee rate (get rates from get_network_fee_rates). Units: Sat/Byte for UTXO, GWei for EVM, Sun for TRX, Drop for XRP, Stroop for XLM, Lovelace for ADA, Lamport for SOL.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
asset,
|
|
169
|
+
fee_rate: z.number().positive(),
|
|
170
|
+
amount: z.number().positive().optional().describe("Required for UTXO chains (BTC, LTC, DASH, DOGE, BCH)"),
|
|
171
|
+
},
|
|
172
|
+
}, (a) => api.get("/dac/v1/estimate-fee", a));
|
|
173
|
+
tool("get_network_fee_rates", "read", {
|
|
174
|
+
title: "Get network fee rates",
|
|
175
|
+
description: "Current Slow/Normal/Fast fee rates for an asset's network.",
|
|
176
|
+
inputSchema: { asset },
|
|
177
|
+
}, (a) => api.get("/dac/v1/network-fee-rates", { asset: a.asset }));
|
|
178
|
+
tool("create_transaction", "sensitive", {
|
|
179
|
+
title: "Send a transaction",
|
|
180
|
+
description: "MOVES FUNDS. Withdraw to an external address, move between sub-accounts, or transfer to/from a connected exchange. Confirm the asset, amount and destination with the user first and validate external addresses with validate_address. Track the result with list_transactions.",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
asset,
|
|
183
|
+
amount: z.number().positive().describe("Native units, up to 8 decimals; ignored when max_amount is true"),
|
|
184
|
+
source_type: z.enum(["INTERNAL", "EXCHANGE"]),
|
|
185
|
+
source_id: z.string().optional().describe("Address/label to spend from, or exchange name when source_type is EXCHANGE"),
|
|
186
|
+
source_tag: z.string().optional(),
|
|
187
|
+
destination_type: z.enum(["EXTERNAL", "INTERNAL", "SUBACCOUNT", "EXCHANGE"]),
|
|
188
|
+
destination_id: z.string().min(1).describe("Address, label, sub-account or exchange name"),
|
|
189
|
+
destination_tag: z.string().optional().describe("Required for XRP/XLM destinations that use tags"),
|
|
190
|
+
sequence_id: z
|
|
191
|
+
.string()
|
|
192
|
+
.min(1)
|
|
193
|
+
.describe("Unique idempotency key (e.g. your payout ID). Reusing it fails with DUPLICATE_SEQUENCE_ID instead of paying twice."),
|
|
194
|
+
memo: z.string().optional(),
|
|
195
|
+
max_amount: z.boolean().optional().describe("Send the whole balance minus fees"),
|
|
196
|
+
aml_cid: z.string().optional().describe("Your customer ID; required when AML screening is on"),
|
|
197
|
+
},
|
|
198
|
+
}, (a) => api.post("/dac/v1/transaction", dropUndefined(a)));
|
|
199
|
+
tool("bump_fee", "sensitive", {
|
|
200
|
+
title: "Speed up a pending transaction",
|
|
201
|
+
description: "Raise the fee rate of a pending transaction. Spends more on fees.",
|
|
202
|
+
inputSchema: { txn_id: z.string().min(1), fee_rate: z.number().positive().describe("New rate in the chain's fee unit") },
|
|
203
|
+
}, (a) => api.post("/dac/v1/bump-fee", a));
|
|
204
|
+
tool("sweep_address", "sensitive", {
|
|
205
|
+
title: "Sweep an address into the omnibus wallet",
|
|
206
|
+
description: "Move the whole balance of a non-omnibus deposit address into the omnibus wallet (uses network fees; EVM and TRON need a funded Gas Tank).",
|
|
207
|
+
inputSchema: { asset, address: z.string().min(1) },
|
|
208
|
+
}, (a) => api.post("/dac/v1/invoke-sweep", a));
|
|
209
|
+
tool("update_wallet", "sensitive", {
|
|
210
|
+
title: "Freeze or configure a wallet",
|
|
211
|
+
description: "Freeze/unfreeze a wallet or set auto-forwarding of incoming funds to an external address (forwarding moves funds; addresses on this account are rejected with INTERNAL_DESTINATION_NOT_ALLOWED). Pass forwarding: null to remove forwarding.",
|
|
212
|
+
inputSchema: {
|
|
213
|
+
asset,
|
|
214
|
+
is_frozen: z.boolean().optional(),
|
|
215
|
+
forwarding: z
|
|
216
|
+
.object({ address: z.string().min(1), frequency: z.enum(["Nightly", "RealTime"]).optional() })
|
|
217
|
+
.nullable()
|
|
218
|
+
.optional(),
|
|
219
|
+
},
|
|
220
|
+
}, (a) => api.patch("/dac/v1/wallet", dropUndefined(a)));
|
|
221
|
+
// ---- Gas tanks -------------------------------------------------------------
|
|
222
|
+
tool("list_gas_tanks", "read", {
|
|
223
|
+
title: "List gas tanks",
|
|
224
|
+
description: "Gas tank addresses, minimum-balance alerts and status per network.",
|
|
225
|
+
inputSchema: {},
|
|
226
|
+
}, () => api.get("/dac/v1/gas-tanks"));
|
|
227
|
+
tool("get_gas_tank_balance", "read", {
|
|
228
|
+
title: "Get gas tank balance",
|
|
229
|
+
description: "Native-token balance (and USD value) of a gas tank, e.g. ETH, BNB, POL, TRX.",
|
|
230
|
+
inputSchema: { asset: z.string().min(1).describe("Gas tank asset from list_gas_tanks") },
|
|
231
|
+
}, (a) => api.get(`/dac/v1/gas-tank-bal/${encodeURIComponent(a.asset)}`));
|
|
232
|
+
tool("get_prepaid_fee", "read", {
|
|
233
|
+
title: "Get prepaid fee account",
|
|
234
|
+
description: "Prepaid platform-fee balance and the fee structure of the subscription tier.",
|
|
235
|
+
inputSchema: {},
|
|
236
|
+
}, () => api.get("/dac/v1/prepaid-fee"));
|
|
237
|
+
// ---- Exchanges -------------------------------------------------------------
|
|
238
|
+
tool("list_exchange_accounts", "read", {
|
|
239
|
+
title: "List exchange connections",
|
|
240
|
+
description: "Supported exchanges (Binance, Coinbase, OKX, KuCoin, Bybit), whether each is connected, and send/receive support.",
|
|
241
|
+
inputSchema: {},
|
|
242
|
+
}, () => api.get("/dac/v1/exchange-accounts"));
|
|
243
|
+
tool("get_exchange_balance", "read", {
|
|
244
|
+
title: "Get exchange balance",
|
|
245
|
+
description: "Asset balances on a connected exchange.",
|
|
246
|
+
inputSchema: { exchange: z.string().min(1).describe("e.g. Binance") },
|
|
247
|
+
}, (a) => api.get("/dac/v1/exchange-balance", { exchange: a.exchange }));
|
|
248
|
+
// ---- AML -------------------------------------------------------------------
|
|
249
|
+
tool("get_aml_status", "read", {
|
|
250
|
+
title: "Get AML configuration",
|
|
251
|
+
description: "Current AML screening provider, risk levels and whether deposits/withdrawals are screened.",
|
|
252
|
+
inputSchema: {},
|
|
253
|
+
}, () => api.get("/dac/v1/aml"));
|
|
254
|
+
tool("list_frozen_addresses", "read", {
|
|
255
|
+
title: "List AML-frozen addresses",
|
|
256
|
+
description: "Addresses frozen by AML screening, with the reason.",
|
|
257
|
+
inputSchema: {},
|
|
258
|
+
}, () => api.get("/dac/v1/frozen-addresses"));
|
|
259
|
+
tool("configure_aml", "sensitive", {
|
|
260
|
+
title: "Configure AML screening",
|
|
261
|
+
description: "Change AML provider, provider token, blocking risk levels and what is screened. Changes compliance controls.",
|
|
262
|
+
inputSchema: {
|
|
263
|
+
provider: z.enum(["Chainalysis", "Cipertrace", "AMLBot"]),
|
|
264
|
+
aml_token: z.string().min(1).describe("The provider's API key"),
|
|
265
|
+
aml_risk_levels: z.string().default("SEVERE,HIGH").describe("Comma-separated: SEVERE, HIGH, MEDIUM, LOW"),
|
|
266
|
+
aml_deposit: z.boolean().default(true),
|
|
267
|
+
aml_withdrawal: z.boolean().default(true),
|
|
268
|
+
},
|
|
269
|
+
}, (a) => api.post("/dac/v1/aml", a));
|
|
270
|
+
tool("unfreeze_address", "sensitive", {
|
|
271
|
+
title: "Unfreeze an AML-frozen address",
|
|
272
|
+
description: "Clear an address frozen by AML screening so it can transact again. Overrides a compliance hold.",
|
|
273
|
+
inputSchema: { asset, address: z.string().min(1), address_tag: z.string().optional() },
|
|
274
|
+
}, (a) => api.post("/dac/v1/unfreeze-address", dropUndefined(a)));
|
|
275
|
+
// ---- TRON resources ----------------------------------------------------------
|
|
276
|
+
const tronResource = {
|
|
277
|
+
address: z.string().min(1).describe("Tron address"),
|
|
278
|
+
resource: z.enum(["ENERGY", "BANDWIDTH"]).default("ENERGY"),
|
|
279
|
+
amount: z.number().positive().describe("TRX amount"),
|
|
280
|
+
};
|
|
281
|
+
tool("stake_trx", "sensitive", {
|
|
282
|
+
title: "Stake TRX for energy or bandwidth",
|
|
283
|
+
description: "Lock TRX to obtain ENERGY or BANDWIDTH for a Tron address. Staked TRX stays locked until unstaked and released.",
|
|
284
|
+
inputSchema: tronResource,
|
|
285
|
+
}, (a) => api.post("/dac/v1/stake", { asset: "TRX", ...a }));
|
|
286
|
+
tool("unstake_trx", "sensitive", {
|
|
287
|
+
title: "Unstake TRX",
|
|
288
|
+
description: "Start the unstaking cooldown for staked TRX. Afterwards call release_unstaked_trx to make it spendable.",
|
|
289
|
+
inputSchema: tronResource,
|
|
290
|
+
}, (a) => api.post("/dac/v1/unstake", { asset: "TRX", ...a }));
|
|
291
|
+
tool("release_unstaked_trx", "sensitive", {
|
|
292
|
+
title: "Release unstaked TRX",
|
|
293
|
+
description: "Return TRX whose unstaking cooldown has ended to the available balance.",
|
|
294
|
+
inputSchema: { address: z.string().min(1).describe("Tron address that unstaked TRX") },
|
|
295
|
+
}, (a) => api.post("/dac/v1/release-unstaked", { asset: "TRX", address: a.address }));
|
|
296
|
+
return registered;
|
|
297
|
+
}
|
|
298
|
+
function dropUndefined(o) {
|
|
299
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
300
|
+
}
|
package/dist/docs.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live client for the Finrock documentation site.
|
|
3
|
+
*
|
|
4
|
+
* The docs are published with an llms.txt index and a raw Markdown copy of every
|
|
5
|
+
* page (`<page>.md`), so the server always serves the current docs instead of a
|
|
6
|
+
* snapshot baked into the build.
|
|
7
|
+
*/
|
|
8
|
+
export interface DocPage {
|
|
9
|
+
title: string;
|
|
10
|
+
/** Path relative to the docs root, without extension, e.g. `guides/webhooks`. */
|
|
11
|
+
path: string;
|
|
12
|
+
/** Top-level group derived from the path, e.g. `guides`, `api-reference`. */
|
|
13
|
+
section: string;
|
|
14
|
+
description: string;
|
|
15
|
+
url: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SearchHit extends DocPage {
|
|
18
|
+
score: number;
|
|
19
|
+
snippet: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DocsClientOptions {
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
/** How long fetched pages and the index stay cached. */
|
|
24
|
+
ttlMs?: number;
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/** Per-request timeout. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare const DEFAULT_DOCS_URL = "https://apidocs.finrock.io";
|
|
30
|
+
export declare class DocsClient {
|
|
31
|
+
readonly baseUrl: string;
|
|
32
|
+
private readonly ttlMs;
|
|
33
|
+
private readonly timeoutMs;
|
|
34
|
+
private readonly fetchImpl;
|
|
35
|
+
private index?;
|
|
36
|
+
private pages;
|
|
37
|
+
private inflight;
|
|
38
|
+
constructor(opts?: DocsClientOptions);
|
|
39
|
+
listPages(): Promise<DocPage[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Fetch one page as Markdown. Accepts `guides/webhooks`, `/guides/webhooks.md`
|
|
42
|
+
* or a full docs URL. Anything outside the docs host is rejected.
|
|
43
|
+
*/
|
|
44
|
+
readPage(ref: string): Promise<{
|
|
45
|
+
path: string;
|
|
46
|
+
url: string;
|
|
47
|
+
markdown: string;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Rank pages for a free-text query. Titles and descriptions always count;
|
|
51
|
+
* page bodies count too when `fullText` is set (fetched in parallel and cached).
|
|
52
|
+
*/
|
|
53
|
+
search(query: string, opts?: {
|
|
54
|
+
limit?: number;
|
|
55
|
+
fullText?: boolean;
|
|
56
|
+
}): Promise<SearchHit[]>;
|
|
57
|
+
normalizePath(ref: string): string;
|
|
58
|
+
private get;
|
|
59
|
+
}
|
|
60
|
+
/** Parse the `- [Title](url): description` lines of an llms.txt file. */
|
|
61
|
+
export declare function parseLlmsTxt(text: string, baseUrl: string): DocPage[];
|
package/dist/docs.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live client for the Finrock documentation site.
|
|
3
|
+
*
|
|
4
|
+
* The docs are published with an llms.txt index and a raw Markdown copy of every
|
|
5
|
+
* page (`<page>.md`), so the server always serves the current docs instead of a
|
|
6
|
+
* snapshot baked into the build.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_DOCS_URL = "https://apidocs.finrock.io";
|
|
9
|
+
export class DocsClient {
|
|
10
|
+
baseUrl;
|
|
11
|
+
ttlMs;
|
|
12
|
+
timeoutMs;
|
|
13
|
+
fetchImpl;
|
|
14
|
+
index;
|
|
15
|
+
pages = new Map();
|
|
16
|
+
inflight = new Map();
|
|
17
|
+
constructor(opts = {}) {
|
|
18
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_DOCS_URL).replace(/\/+$/, "");
|
|
19
|
+
this.ttlMs = opts.ttlMs ?? 60 * 60 * 1000;
|
|
20
|
+
this.timeoutMs = opts.timeoutMs ?? 10_000;
|
|
21
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
22
|
+
}
|
|
23
|
+
async listPages() {
|
|
24
|
+
if (this.index && this.index.expires > Date.now())
|
|
25
|
+
return this.index.value;
|
|
26
|
+
const text = await this.get(`${this.baseUrl}/llms.txt`);
|
|
27
|
+
const pages = parseLlmsTxt(text, this.baseUrl);
|
|
28
|
+
if (pages.length === 0)
|
|
29
|
+
throw new Error("Finrock docs index (llms.txt) contained no pages");
|
|
30
|
+
this.index = { value: pages, expires: Date.now() + this.ttlMs };
|
|
31
|
+
return pages;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Fetch one page as Markdown. Accepts `guides/webhooks`, `/guides/webhooks.md`
|
|
35
|
+
* or a full docs URL. Anything outside the docs host is rejected.
|
|
36
|
+
*/
|
|
37
|
+
async readPage(ref) {
|
|
38
|
+
const path = this.normalizePath(ref);
|
|
39
|
+
const url = `${this.baseUrl}/${path}.md`;
|
|
40
|
+
const cached = this.pages.get(path);
|
|
41
|
+
if (cached && cached.expires > Date.now())
|
|
42
|
+
return { path, url, markdown: cached.value };
|
|
43
|
+
let pending = this.inflight.get(path);
|
|
44
|
+
if (!pending) {
|
|
45
|
+
pending = this.get(url).finally(() => this.inflight.delete(path));
|
|
46
|
+
this.inflight.set(path, pending);
|
|
47
|
+
}
|
|
48
|
+
const markdown = await pending;
|
|
49
|
+
this.pages.set(path, { value: markdown, expires: Date.now() + this.ttlMs });
|
|
50
|
+
return { path, url, markdown };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Rank pages for a free-text query. Titles and descriptions always count;
|
|
54
|
+
* page bodies count too when `fullText` is set (fetched in parallel and cached).
|
|
55
|
+
*/
|
|
56
|
+
async search(query, opts = {}) {
|
|
57
|
+
const limit = opts.limit ?? 8;
|
|
58
|
+
const terms = tokenize(query);
|
|
59
|
+
if (terms.length === 0)
|
|
60
|
+
return [];
|
|
61
|
+
const pages = await this.listPages();
|
|
62
|
+
const bodies = new Map();
|
|
63
|
+
if (opts.fullText ?? true) {
|
|
64
|
+
await mapLimit(pages, 8, async (p) => {
|
|
65
|
+
try {
|
|
66
|
+
bodies.set(p.path, (await this.readPage(p.path)).markdown);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// A page that fails to load still ranks on its title and description.
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const phrase = query.trim().toLowerCase();
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const page of pages) {
|
|
76
|
+
const title = page.title.toLowerCase();
|
|
77
|
+
const desc = page.description.toLowerCase();
|
|
78
|
+
const path = page.path.toLowerCase();
|
|
79
|
+
const body = bodies.get(page.path)?.toLowerCase() ?? "";
|
|
80
|
+
let score = 0;
|
|
81
|
+
for (const t of terms) {
|
|
82
|
+
if (title.includes(t))
|
|
83
|
+
score += 6;
|
|
84
|
+
if (path.includes(t))
|
|
85
|
+
score += 3;
|
|
86
|
+
if (desc.includes(t))
|
|
87
|
+
score += 2;
|
|
88
|
+
score += Math.min(countOccurrences(body, t), 5) * 0.5;
|
|
89
|
+
}
|
|
90
|
+
if (phrase.length > 3 && (title.includes(phrase) || body.includes(phrase)))
|
|
91
|
+
score += 4;
|
|
92
|
+
if (score > 0) {
|
|
93
|
+
hits.push({ ...page, score, snippet: makeSnippet(bodies.get(page.path) ?? page.description, terms) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
97
|
+
}
|
|
98
|
+
normalizePath(ref) {
|
|
99
|
+
let s = ref.trim();
|
|
100
|
+
if (/^https?:\/\//i.test(s)) {
|
|
101
|
+
const u = new URL(s);
|
|
102
|
+
if (u.origin !== new URL(this.baseUrl).origin) {
|
|
103
|
+
throw new Error(`Only pages on ${this.baseUrl} can be read`);
|
|
104
|
+
}
|
|
105
|
+
s = u.pathname;
|
|
106
|
+
}
|
|
107
|
+
s = s.split(/[?#]/)[0].replace(/^\/+/, "").replace(/\.(md|mdx)$/i, "").replace(/\/+$/, "");
|
|
108
|
+
if (!s || !/^[A-Za-z0-9._/-]+$/.test(s) || s.split("/").some((seg) => seg === ".." || seg === ".")) {
|
|
109
|
+
throw new Error(`Not a valid docs page path: ${ref}`);
|
|
110
|
+
}
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
async get(url) {
|
|
114
|
+
const res = await this.fetchImpl(url, {
|
|
115
|
+
headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
|
|
116
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok)
|
|
119
|
+
throw new Error(`GET ${url} failed with HTTP ${res.status}`);
|
|
120
|
+
return res.text();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Parse the `- [Title](url): description` lines of an llms.txt file. */
|
|
124
|
+
export function parseLlmsTxt(text, baseUrl) {
|
|
125
|
+
const origin = new URL(baseUrl).origin;
|
|
126
|
+
const pages = [];
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const line = /^\s*[-*]\s*\[([^\]]+)\]\(([^)\s]+)\)\s*(?::\s*(.*))?$/;
|
|
129
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
130
|
+
const m = line.exec(raw);
|
|
131
|
+
if (!m)
|
|
132
|
+
continue;
|
|
133
|
+
let url;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(m[2], baseUrl);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (url.origin !== origin)
|
|
141
|
+
continue;
|
|
142
|
+
const path = url.pathname.replace(/^\/+/, "").replace(/\.(md|mdx)$/i, "").replace(/\/+$/, "") || "index";
|
|
143
|
+
if (seen.has(path))
|
|
144
|
+
continue;
|
|
145
|
+
seen.add(path);
|
|
146
|
+
pages.push({
|
|
147
|
+
title: m[1].trim(),
|
|
148
|
+
path,
|
|
149
|
+
section: path.includes("/") ? path.split("/")[0] : "overview",
|
|
150
|
+
description: (m[3] ?? "").trim(),
|
|
151
|
+
url: `${origin}/${path}`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return pages;
|
|
155
|
+
}
|
|
156
|
+
function tokenize(q) {
|
|
157
|
+
const stop = new Set(["the", "a", "an", "to", "of", "and", "or", "in", "on", "for", "how", "do", "i", "is", "with", "what"]);
|
|
158
|
+
return [...new Set(q.toLowerCase().split(/[^a-z0-9_]+/).filter((t) => t.length > 1 && !stop.has(t)))];
|
|
159
|
+
}
|
|
160
|
+
function countOccurrences(hay, needle) {
|
|
161
|
+
if (!hay)
|
|
162
|
+
return 0;
|
|
163
|
+
let n = 0;
|
|
164
|
+
for (let i = hay.indexOf(needle); i !== -1; i = hay.indexOf(needle, i + needle.length))
|
|
165
|
+
n++;
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
function makeSnippet(text, terms, radius = 120) {
|
|
169
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
170
|
+
const lower = flat.toLowerCase();
|
|
171
|
+
let at = -1;
|
|
172
|
+
for (const t of terms) {
|
|
173
|
+
at = lower.indexOf(t);
|
|
174
|
+
if (at !== -1)
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (at === -1)
|
|
178
|
+
return flat.slice(0, radius * 2);
|
|
179
|
+
const start = Math.max(0, at - radius);
|
|
180
|
+
const end = Math.min(flat.length, at + radius);
|
|
181
|
+
return `${start > 0 ? "…" : ""}${flat.slice(start, end)}${end < flat.length ? "…" : ""}`;
|
|
182
|
+
}
|
|
183
|
+
async function mapLimit(items, limit, fn) {
|
|
184
|
+
let next = 0;
|
|
185
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
186
|
+
while (next < items.length)
|
|
187
|
+
await fn(items[next++]);
|
|
188
|
+
});
|
|
189
|
+
await Promise.all(workers);
|
|
190
|
+
}
|
package/dist/guides.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration recipes live as Markdown under `guides/<goal>/`:
|
|
3
|
+
* overview.md frontmatter (title, summary, docs, client) + stack-neutral steps
|
|
4
|
+
* <stack>.md code for one stack; html.md is the fallback for any stack
|
|
5
|
+
* `guides/_client/<stack>.md` is the request-signing client, appended when `client: true`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const STACKS: readonly ["node", "nextjs", "python", "php"];
|
|
8
|
+
export type Stack = (typeof STACKS)[number];
|
|
9
|
+
export declare const STACK_LABELS: Record<Stack, string>;
|
|
10
|
+
export interface GuideMeta {
|
|
11
|
+
goal: string;
|
|
12
|
+
title: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
docs: string[];
|
|
15
|
+
client: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare const GUIDES_DIR: string;
|
|
18
|
+
export declare function listGuides(dir?: string): GuideMeta[];
|
|
19
|
+
export declare function buildGuide(goal: string, stack: Stack, docsBaseUrl: string, dir?: string): string;
|
package/dist/guides.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Integration recipes live as Markdown under `guides/<goal>/`:
|
|
6
|
+
* overview.md frontmatter (title, summary, docs, client) + stack-neutral steps
|
|
7
|
+
* <stack>.md code for one stack; html.md is the fallback for any stack
|
|
8
|
+
* `guides/_client/<stack>.md` is the request-signing client, appended when `client: true`.
|
|
9
|
+
*/
|
|
10
|
+
export const STACKS = ["node", "nextjs", "python", "php"];
|
|
11
|
+
export const STACK_LABELS = {
|
|
12
|
+
node: "Node.js (Express)",
|
|
13
|
+
nextjs: "Next.js (App Router)",
|
|
14
|
+
python: "Python (FastAPI)",
|
|
15
|
+
php: "PHP",
|
|
16
|
+
};
|
|
17
|
+
export const GUIDES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "guides");
|
|
18
|
+
export function listGuides(dir = GUIDES_DIR) {
|
|
19
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
20
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("_") && existsSync(join(dir, d.name, "overview.md")))
|
|
21
|
+
.map((d) => readMeta(dir, d.name))
|
|
22
|
+
.sort((a, b) => a.goal.localeCompare(b.goal));
|
|
23
|
+
}
|
|
24
|
+
export function buildGuide(goal, stack, docsBaseUrl, dir = GUIDES_DIR) {
|
|
25
|
+
const known = listGuides(dir).map((g) => g.goal);
|
|
26
|
+
if (!known.includes(goal)) {
|
|
27
|
+
throw new Error(`Unknown goal "${goal}". Available goals: ${known.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
const meta = readMeta(dir, goal);
|
|
30
|
+
const { body } = splitFrontmatter(readFileSync(join(dir, goal, "overview.md"), "utf8"));
|
|
31
|
+
const parts = [`# ${meta.title}`, `_Stack: ${STACK_LABELS[stack]}_`, body.trim()];
|
|
32
|
+
if (meta.client && goal !== "authentication") {
|
|
33
|
+
parts.push("## Prerequisite: authenticated API client", "Every call below goes through this helper. See the `authentication` guide for creating the API user and key pair.", readFileSync(join(dir, "_client", `${stack}.md`), "utf8").trim());
|
|
34
|
+
}
|
|
35
|
+
else if (goal === "authentication") {
|
|
36
|
+
parts.push("## Code", readFileSync(join(dir, "_client", `${stack}.md`), "utf8").trim());
|
|
37
|
+
}
|
|
38
|
+
const stackFile = [join(dir, goal, `${stack}.md`), join(dir, goal, "html.md")].find((p) => existsSync(p));
|
|
39
|
+
if (stackFile)
|
|
40
|
+
parts.push("## Code", readFileSync(stackFile, "utf8").trim());
|
|
41
|
+
const base = docsBaseUrl.replace(/\/+$/, "");
|
|
42
|
+
parts.push("## Source documentation", "Read these with the `read_doc` tool when you need exact field lists or response shapes:", meta.docs.map((d) => `- ${base}/${d}`).join("\n"));
|
|
43
|
+
return parts.join("\n\n") + "\n";
|
|
44
|
+
}
|
|
45
|
+
function readMeta(dir, goal) {
|
|
46
|
+
const { attrs } = splitFrontmatter(readFileSync(join(dir, goal, "overview.md"), "utf8"));
|
|
47
|
+
return {
|
|
48
|
+
goal,
|
|
49
|
+
title: attrs.title ?? goal,
|
|
50
|
+
summary: attrs.summary ?? "",
|
|
51
|
+
docs: (attrs.docs ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
52
|
+
client: attrs.client === "true",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function splitFrontmatter(text) {
|
|
56
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
57
|
+
if (!m)
|
|
58
|
+
return { attrs: {}, body: text };
|
|
59
|
+
const attrs = {};
|
|
60
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
61
|
+
const i = line.indexOf(":");
|
|
62
|
+
if (i > 0)
|
|
63
|
+
attrs[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
64
|
+
}
|
|
65
|
+
return { attrs, body: text.slice(m[0].length) };
|
|
66
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { DocsClient } from "./docs.js";
|
|
3
|
+
/** Hostnames accepted in the Host header when MCP_ALLOWED_HOSTS is unset. */
|
|
4
|
+
export declare const DEFAULT_ALLOWED_HOSTS: string[];
|
|
5
|
+
/** Parses MCP_ALLOWED_HOSTS: comma-separated hostnames (no ports), or "*" to accept any Host. */
|
|
6
|
+
export declare function parseAllowedHosts(value: string | undefined): string[] | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* @param opts.allowedHosts Host header allowlist (DNS rebinding protection); undefined accepts any Host.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createHttpApp(opts?: {
|
|
11
|
+
docs?: DocsClient;
|
|
12
|
+
allowedHosts?: string[];
|
|
13
|
+
}): import("express-serve-static-core").Express;
|