@priors/mcp 0.1.4 → 0.1.5
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/README.md +3 -3
- package/package.json +2 -1
- package/src/server.mjs +39 -11
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ Settings → Developer → Edit Config (`claude_desktop_config.json`), then rest
|
|
|
62
62
|
"mcpServers": {
|
|
63
63
|
"priors": {
|
|
64
64
|
"command": "npx",
|
|
65
|
-
"args": ["-y", "@priors/mcp@0.1.
|
|
65
|
+
"args": ["-y", "@priors/mcp@0.1.5"],
|
|
66
66
|
"env": {
|
|
67
67
|
"PRIORS_KEY": "0xYOUR_AGENT_WALLET_KEY",
|
|
68
68
|
"PRIORS_AGENT_ID": "1234"
|
|
@@ -84,7 +84,7 @@ A project `.mcp.json` that reads the key from your shell's environment, so the f
|
|
|
84
84
|
"mcpServers": {
|
|
85
85
|
"priors": {
|
|
86
86
|
"command": "npx",
|
|
87
|
-
"args": ["-y", "@priors/mcp@0.1.
|
|
87
|
+
"args": ["-y", "@priors/mcp@0.1.5"],
|
|
88
88
|
"env": {
|
|
89
89
|
"PRIORS_KEY": "${PRIORS_KEY}",
|
|
90
90
|
"PRIORS_AGENT_ID": "${PRIORS_AGENT_ID:-}"
|
|
@@ -94,7 +94,7 @@ A project `.mcp.json` that reads the key from your shell's environment, so the f
|
|
|
94
94
|
}
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
or, for your user only: `claude mcp add priors --scope user -e PRIORS_KEY="$PRIORS_KEY" -- npx -y @priors/mcp@0.1.
|
|
97
|
+
or, for your user only: `claude mcp add priors --scope user -e PRIORS_KEY="$PRIORS_KEY" -- npx -y @priors/mcp@0.1.5`
|
|
98
98
|
(the key is expanded by your shell from the environment; do not paste it on the command line).
|
|
99
99
|
|
|
100
100
|
## Try it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@priors/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "MCP server for Priors on Robinhood Chain: pay x402 URLs in USDG, read balances, credit lines and scores, borrow and repay. Key from PRIORS_KEY only.",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
45
45
|
"@priors/x402": "0.1.3",
|
|
46
46
|
"ethers": "^6.13.4",
|
|
47
|
+
"undici": "6.29.0",
|
|
47
48
|
"zod": "^4.6.5"
|
|
48
49
|
}
|
|
49
50
|
}
|
package/src/server.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import { readFileSync } from "node:fs";
|
|
|
19
19
|
import { randomBytes } from "node:crypto";
|
|
20
20
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
21
21
|
import { isIP } from "node:net";
|
|
22
|
+
import { lookup as dnsLookupCb } from "node:dns";
|
|
23
|
+
import { fetch as undiciFetch, Agent } from "undici";
|
|
22
24
|
import { ethers } from "ethers";
|
|
23
25
|
import { z } from "zod";
|
|
24
26
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -79,6 +81,27 @@ export function isPrivateAddress(ip) {
|
|
|
79
81
|
return (x[0] & 0xfe) === 0xfc || (x[0] === 0xfe && (x[1] & 0xc0) === 0x80) || x[0] === 0xff; // fc00::/7, fe80::/10, ff00::/8
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* fetch whose every connection re-checks the address it resolved: a hostname that answered a public address to the
|
|
86
|
+
* pre-check and a private one at connect time (DNS rebinding) is refused there. IP literals skip DNS; the pre-check
|
|
87
|
+
* (checkTarget) refuses the private ones. `resolve` is dns.lookup-shaped (tests swap it).
|
|
88
|
+
*/
|
|
89
|
+
export function guardedFetch(resolve = dnsLookupCb) {
|
|
90
|
+
const lookup = (hostname, options, cb) => resolve(hostname, { ...(options || {}), all: true }, (err, addrs) => {
|
|
91
|
+
if (err) return cb(err);
|
|
92
|
+
const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: isIP(addrs) }];
|
|
93
|
+
const bad = list.find((a) => isPrivateAddress(a.address));
|
|
94
|
+
if (bad || list.length === 0) return cb(Object.assign(new Error(`refused: ${hostname} resolves to a private or local address (${bad?.address ?? "none"})`), { code: "EPRIVATE" }));
|
|
95
|
+
return options?.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
|
|
96
|
+
});
|
|
97
|
+
const dispatcher = new Agent({ connect: { lookup } });
|
|
98
|
+
return async (input, init) => {
|
|
99
|
+
const r = input instanceof Request ? input : new Request(input, init);
|
|
100
|
+
const body = r.body ? await r.arrayBuffer() : undefined;
|
|
101
|
+
return undiciFetch(r.url, { method: r.method, headers: [...r.headers], body, redirect: r.redirect, signal: r.signal, dispatcher });
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
82
105
|
const usd = (units) => `${X.formatUsdg(units)} USDG`;
|
|
83
106
|
const when = (t) => new Date(Number(t) * 1000).toISOString().replace(".000Z", "Z");
|
|
84
107
|
const short = (s, n) => (s.length > n ? `${s.slice(0, n)}… (${s.length - n} more characters cut)` : s);
|
|
@@ -125,6 +148,12 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
125
148
|
// Payments signed and not (yet) counted as paid, by "METHOD url": until validBefore the merchant can still cash
|
|
126
149
|
// them, so another pay_url for the same purchase resends that payment instead of signing a second one.
|
|
127
150
|
const outstanding = new Map();
|
|
151
|
+
// One money call at a time (pay_url, borrow, repay): MCP clients may send tool calls concurrently, and each check
|
|
152
|
+
// above (an outstanding payment for this URL, the session totals) must see the previous call's result (Codex review).
|
|
153
|
+
let moneyQueue = Promise.resolve();
|
|
154
|
+
const serial = (fn) => (args) => { const run = moneyQueue.then(() => fn(args)); moneyQueue = run.catch(() => {}); return run; };
|
|
155
|
+
// The real network goes through the rebinding-proof dispatcher; an injected fetchImpl (tests, embedders) is used as given.
|
|
156
|
+
const payFetch = fetchImpl === globalThis.fetch && !allowLocal ? guardedFetch() : fetchImpl;
|
|
128
157
|
|
|
129
158
|
/** https only (http://localhost only with PRIORS_ALLOW_LOCAL=1), and never a host that resolves to a private address. */
|
|
130
159
|
async function checkTarget(url) {
|
|
@@ -136,9 +165,8 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
136
165
|
}
|
|
137
166
|
if (u.protocol !== "https:") throw new ToolError("pay_url only pays https URLs.");
|
|
138
167
|
if (allowLocal) return u;
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// gaining a per-request lookup hook to pin the address checked here.
|
|
168
|
+
// A clear refusal before anything is sent; the connection itself re-checks the address it resolves (guardedFetch),
|
|
169
|
+
// so a DNS answer that turns private in between (rebinding) is refused there too.
|
|
142
170
|
let addrs;
|
|
143
171
|
if (isIP(host)) addrs = [{ address: host }];
|
|
144
172
|
else { try { addrs = await lookup(host); } catch (_) { throw new ToolError(`could not resolve ${host}. Nothing was fetched.`); } }
|
|
@@ -242,7 +270,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
242
270
|
max_borrow_usd: z.number().nonnegative().optional().describe("Most to borrow from the Priors line if the wallet is short, in US dollars. Default 0: never borrow."),
|
|
243
271
|
},
|
|
244
272
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
245
|
-
}, async ({ url, method = "GET", body, max_price_usd, max_borrow_usd }) => {
|
|
273
|
+
}, serial(async ({ url, method = "GET", body, max_price_usd, max_borrow_usd }) => {
|
|
246
274
|
const signer = needWallet("pay_url");
|
|
247
275
|
const u = await checkTarget(url);
|
|
248
276
|
if (method === "GET" && body !== undefined) throw new ToolError("A GET request cannot carry a body: use POST (or another method) with body.");
|
|
@@ -263,7 +291,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
263
291
|
let r;
|
|
264
292
|
if (prior) {
|
|
265
293
|
// A payment for this purchase is already out and still cashable: send that same one, never a second.
|
|
266
|
-
r = await X.createPayer({ signer, fetchImpl, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(callBudgetMs), ...(sleep ? { sleep } : {}) }).resend(url, prior.paymentHeaders, init);
|
|
294
|
+
r = await X.createPayer({ signer, fetchImpl: payFetch, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(callBudgetMs), ...(sleep ? { sleep } : {}) }).resend(url, prior.paymentHeaders, init);
|
|
267
295
|
r = { ...r, requirement: prior.requirement, paid: r.response.ok ? prior.price : 0n, borrowed: 0n, signed: prior, resent: true, ...(r.response.ok ? { settlement: settlementOf(r.response) } : {}) };
|
|
268
296
|
lines.push("No new payment was signed: the same signed payment was sent again.");
|
|
269
297
|
} else {
|
|
@@ -271,7 +299,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
271
299
|
if (maxBorrow > 0n && session.borrowed + maxBorrow > borrowTotalCap) throw new ToolError(`this could bring what is borrowed in this session to ${usd(session.borrowed + maxBorrow)}, above ${usd(borrowTotalCap)} (PRIORS_MAX_BORROW_TOTAL_USD).`);
|
|
272
300
|
let agentId;
|
|
273
301
|
if (maxBorrow > 0n) { agentId = await resolveAgent(); await needController(agentId); }
|
|
274
|
-
const payer = X.createPayer({ signer, maxPrice, maxBorrow, fetchImpl, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(callBudgetMs), ...(sleep ? { sleep } : {}), ...(maxBorrow > 0n ? { pool: addresses.pool, agentId } : {}) });
|
|
302
|
+
const payer = X.createPayer({ signer, maxPrice, maxBorrow, fetchImpl: payFetch, pendingRetries: 2, maxSleepMs: 10_000, timeoutMs: requestTimeoutMs, signal: AbortSignal.timeout(callBudgetMs), ...(sleep ? { sleep } : {}), ...(maxBorrow > 0n ? { pool: addresses.pool, agentId } : {}) });
|
|
275
303
|
try { r = await payer.pay(url, init); } catch (e) {
|
|
276
304
|
if (e?.name === "TimeoutError" || e?.name === "AbortError") throw new ToolError(`${method} ${u.href} did not answer in time. Nothing was signed or paid.`);
|
|
277
305
|
throw e;
|
|
@@ -300,7 +328,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
300
328
|
try { ({ text: bodyText, cut } = await X.readCapped(r.response)); } catch (_) { /* no body */ }
|
|
301
329
|
if (bodyText) lines.push(`Response body${cut ? " (cut at 256 KB)" : ""}:\n${fenced(clean(bodyText, 20_000), "response body")}`);
|
|
302
330
|
return lines.join("\n");
|
|
303
|
-
});
|
|
331
|
+
}));
|
|
304
332
|
|
|
305
333
|
// ---- wallet_balance ------------------------------------------------------------------------------------------
|
|
306
334
|
tool("wallet_balance", {
|
|
@@ -364,7 +392,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
364
392
|
dry_run: z.boolean().optional().describe("true: only quote the fee and due amount, borrow nothing."),
|
|
365
393
|
},
|
|
366
394
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
367
|
-
}, async ({ amount_usd, days, dry_run }) => {
|
|
395
|
+
}, serial(async ({ amount_usd, days, dry_run }) => {
|
|
368
396
|
needWallet("borrow");
|
|
369
397
|
const amount = dollars(amount_usd, "amount_usd");
|
|
370
398
|
if (amount === 0n) throw new ToolError("amount_usd must be above zero");
|
|
@@ -378,7 +406,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
378
406
|
const r = await credit.borrow(id, amount, term);
|
|
379
407
|
session.borrowed += r.principal;
|
|
380
408
|
return `Borrowed ${usd(r.principal)} for agent #${id}${r.loanId !== null ? ` as loan #${r.loanId}` : ""}: fee ${usd(r.fee)}, so ${usd(r.principal + r.fee)} is due${r.dueAt ? ` by ${when(r.dueAt)}` : ""}. The USDG is in ${wallet.address}. Tx ${r.hash}.`;
|
|
381
|
-
});
|
|
409
|
+
}));
|
|
382
410
|
|
|
383
411
|
// ---- repay ---------------------------------------------------------------------------------------------------
|
|
384
412
|
tool("repay", {
|
|
@@ -389,7 +417,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
389
417
|
all: z.boolean().optional().describe("true: repay every open loan of the agent, earliest due first."),
|
|
390
418
|
},
|
|
391
419
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
392
|
-
}, async ({ loan_id, all }) => {
|
|
420
|
+
}, serial(async ({ loan_id, all }) => {
|
|
393
421
|
needWallet("repay");
|
|
394
422
|
if ((loan_id === undefined) === (all !== true)) throw new ToolError("Give exactly one of loan_id or all: true.");
|
|
395
423
|
// Only the loans of an agent this wallet controls: the pool lets anyone repay any loan, and this wallet's USDG is
|
|
@@ -413,7 +441,7 @@ export async function createPriorsMcpServer({ env = process.env, fetchImpl = glo
|
|
|
413
441
|
const out = [done.length ? `Repaid for agent #${id}:\n- ${done.join("\n- ")}` : `Nothing was repaid for agent #${id}.`];
|
|
414
442
|
if (left.length) out.push(`Still open: ${left.map((l) => `#${l.loanId} (${usd(l.due)} due ${when(l.dueAt)})`).join(", ")}.${stop ? ` Stopped because: ${stop}` : ""}`);
|
|
415
443
|
return out.join("\n");
|
|
416
|
-
});
|
|
444
|
+
}));
|
|
417
445
|
|
|
418
446
|
// ---- find_services -------------------------------------------------------------------------------------------
|
|
419
447
|
tool("find_services", {
|