@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 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.4"],
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.4"],
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.4`
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.4",
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
- // SHORTCUT: checked once before the request; a DNS answer that changes between this lookup and the fetch
140
- // (rebinding) is not caught. Ceiling: a hostile DNS server. Upgrade trigger: a report of it, or Node's fetch
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", {