@x207/mcp 0.0.0-stage → 0.0.2
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 +202 -0
- package/README.md +258 -2
- package/dist/backend.d.ts +25 -0
- package/dist/backend.js +83 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +50 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +31 -0
- package/dist/guards.d.ts +25 -0
- package/dist/guards.js +67 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +94 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/net-guard.d.ts +20 -0
- package/dist/net-guard.js +182 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.js +47 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.js +147 -0
- package/dist/write-config.d.ts +17 -0
- package/dist/write-config.js +72 -0
- package/dist/write-tools.d.ts +43 -0
- package/dist/write-tools.js +443 -0
- package/package.json +59 -4
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// The write tools of the x207 MCP server (SD-082): x207_pay_request, x207_list_offer, x207_make_offer, x207_accept_offer, x207_confirm_milestone, x207_join, x207_settle, x207_claim.
|
|
2
|
+
// They exist only on stdio and only with X207_MCP_WRITE=1 (see write-config.ts). Five of them sign with the VOUCHER key and send nothing to the chain (a voucher, a listing, an offer, an
|
|
3
|
+
// acceptance, a notice: the facilitator or the backend does the rest); three send a NEAR transaction with the NEAR key (join and stake, settle, claim). Every tool first passes the guards of
|
|
4
|
+
// guards.ts, and no result, error or log holds a key.
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { buildClaimCall, buildSettleTransferCall, counterOffer, listingHash, termsHash, nep413SigningHash, onboardMember, postListing, signBundle, signHash, voucherPublicKey, } from "@x207/core";
|
|
7
|
+
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
8
|
+
import { X207PaymentClient, headroomFromRpc } from "@x207/scheme";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { Guards } from "./guards.js";
|
|
11
|
+
import { assertPublicUrl, createPinnedFetch, defaultResolver, readCapped } from "./net-guard.js";
|
|
12
|
+
import { ToolError } from "./tools.js";
|
|
13
|
+
const ACCOUNT = z.string().regex(/^[a-z0-9][a-z0-9._-]{1,63}$/, "a NEAR account id");
|
|
14
|
+
const HASH64 = z.string().regex(/^[0-9a-f]{64}$/, "64 lowercase hex characters");
|
|
15
|
+
const AMOUNT = z.string().regex(/^[1-9]\d{0,37}$/, "a positive integer string in credit units (6 decimals)");
|
|
16
|
+
const EPOCH = z.number().int().min(1).max(4_294_967_295);
|
|
17
|
+
const ASSET_TYPE = z.string().regex(/^[a-z0-9._:-]{1,64}$/, "lowercase asset type such as service:summary");
|
|
18
|
+
const CLASS = z.enum(["A", "B", "C", "D"]);
|
|
19
|
+
/** The most that x207_pay_request reads of one answer. */
|
|
20
|
+
export const MAX_PAID_BODY_BYTES = 1_048_576;
|
|
21
|
+
const w = (ctx) => {
|
|
22
|
+
if (ctx.write === undefined)
|
|
23
|
+
throw new ToolError("the write tools are off: set X207_MCP_WRITE=1 (stdio only)");
|
|
24
|
+
return ctx.write;
|
|
25
|
+
};
|
|
26
|
+
const needPost = (rt, tool) => {
|
|
27
|
+
if (rt.post === null)
|
|
28
|
+
throw new ToolError(`${tool} needs the x207 read API and marketplace book: set X207_BACKEND_URL`);
|
|
29
|
+
return rt.post;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* After a commitment failed: the amount goes back to the budget only when it is certain that nothing was committed (a refusal of ours, a 4xx answer of the book, a contract that said no). A timeout, a 5xx
|
|
33
|
+
* or an unknown error may have committed it, so the amount stays counted and the model is told that the outcome is unknown (cross review UM-112, F3).
|
|
34
|
+
*/
|
|
35
|
+
export function keepIfUnknown(rt, amount, error) {
|
|
36
|
+
const status = error.status;
|
|
37
|
+
const certain = error instanceof ToolError || (error instanceof Error && error.name === "BackendError" && typeof status === "number" && status >= 400 && status < 500)
|
|
38
|
+
|| (error instanceof Error && error.name === "ContractCallError" && error.code !== null && error.code !== undefined);
|
|
39
|
+
if (certain) {
|
|
40
|
+
rt.guards.release(amount);
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
return new ToolError(`the outcome is unknown (${error instanceof Error ? error.message : String(error)}); the amount of ${amount} stays counted in this session until the state is looked at`);
|
|
44
|
+
}
|
|
45
|
+
const needVoucher = (rt, tool) => {
|
|
46
|
+
if (rt.cfg.voucherKey === null)
|
|
47
|
+
throw new ToolError(`${tool} signs with the voucher key: set X207_AGENT_VOUCHER_KEY`);
|
|
48
|
+
return rt.cfg.voucherKey;
|
|
49
|
+
};
|
|
50
|
+
const needGet = (ctx, tool) => {
|
|
51
|
+
if (ctx.backend === null)
|
|
52
|
+
throw new ToolError(`${tool} needs the x207 read API: set X207_BACKEND_URL`);
|
|
53
|
+
return ctx.backend;
|
|
54
|
+
};
|
|
55
|
+
const seconds = (iso) => Math.floor(Date.parse(iso) / 1000);
|
|
56
|
+
const listingOf = (v, circle) => ({
|
|
57
|
+
circle, account: v.account,
|
|
58
|
+
offer: { asset_type: v.offer.assetType, asset_class: v.offer.assetClass, quantity: v.offer.quantity, value: v.offer.value },
|
|
59
|
+
want: { asset_type: v.want.assetType, asset_class: v.want.assetClass, quantity: v.want.quantity, value: v.want.value },
|
|
60
|
+
terms: v.terms, detail: v.detail, expires_at: seconds(v.expiresAt), discount_bps: v.discountBps, accept_payment: v.acceptPayment, discoverable: v.discoverable, nonce: v.nonce,
|
|
61
|
+
});
|
|
62
|
+
const offerOf = (v, listingHash, circle) => ({
|
|
63
|
+
circle, listing_hash: listingHash, proposer: v.proposer, round: v.round, mode: v.mode, value: v.value, quantity: v.quantity, deliver_by: seconds(v.deliverBy),
|
|
64
|
+
});
|
|
65
|
+
/** NEP-413 over sha256(json), recipient the circle contract: the signature of every notice of the book (orders, invoices). */
|
|
66
|
+
const signNotice = (contract, notice, secretKey) => {
|
|
67
|
+
const json = JSON.stringify(notice);
|
|
68
|
+
return signHash(nep413SigningHash(json, createHash("sha256").update(json).digest(), contract), secretKey);
|
|
69
|
+
};
|
|
70
|
+
const circleRecord = async (ctx) => (await ctx.view("get_circle", {}));
|
|
71
|
+
export const WRITE_TOOLS = [
|
|
72
|
+
{
|
|
73
|
+
name: "x207_pay_request",
|
|
74
|
+
title: "Pay an x402 endpoint with a voucher",
|
|
75
|
+
description: "Fetches a URL; if the server answers 402 with an x207 payment request, signs a credit voucher with the agent's voucher key and repeats the request. Nothing is sent to the chain by this tool: the seller's facilitator registers the voucher. Refused when the price is above X207_MAX_PRICE_PER_CALL, when the budget of the session X207_MAX_SPEND_SESSION is used up, or when the payee is not an allowed, active member of the circle. The price in the 402 is not trusted.",
|
|
76
|
+
access: "write",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
url: z.string().url().max(2048).describe("http or https URL of the paid resource."),
|
|
79
|
+
method: z.enum(["GET", "POST"]).default("GET"),
|
|
80
|
+
body: z.string().max(100_000).optional().describe("Body of a POST, sent as it is (JSON text)."),
|
|
81
|
+
},
|
|
82
|
+
handler: async (args, ctx) => {
|
|
83
|
+
const rt = w(ctx);
|
|
84
|
+
needVoucher(rt, "x207_pay_request");
|
|
85
|
+
const url = new URL(args.url);
|
|
86
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
87
|
+
throw new ToolError("refused: the URL must be http or https");
|
|
88
|
+
if (url.username !== "" || url.password !== "")
|
|
89
|
+
throw new ToolError("refused: the URL must not carry credentials");
|
|
90
|
+
// the URL comes from a model: a place that a paid request has no business with (the metadata service of a cloud machine, a service on this machine) is refused before anything is sent
|
|
91
|
+
if (!rt.cfg.allowPrivateUrls) {
|
|
92
|
+
try {
|
|
93
|
+
await assertPublicUrl(url, rt.resolve);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw new ToolError(error.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// no redirect is followed (a redirect to a private address would walk around the guard), the call has a time limit, and the answer is read up to a limit
|
|
100
|
+
const init = { method: args.method, redirect: "manual", signal: AbortSignal.timeout(30_000), ...(args.body !== undefined ? { body: args.body, headers: { "content-type": "application/json" } } : {}) };
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await rt.payFetch()(url.toString(), init);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new ToolError(`payment not made or request failed: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
const { text, cut } = await readCapped(response, MAX_PAID_BODY_BYTES);
|
|
109
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
|
|
110
|
+
const settle = response.headers.get("payment-response");
|
|
111
|
+
let refusal;
|
|
112
|
+
const required = response.headers.get("payment-required");
|
|
113
|
+
if (response.status === 402 && required !== null) {
|
|
114
|
+
try {
|
|
115
|
+
refusal = JSON.parse(Buffer.from(required, "base64").toString("utf8")).error;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// an unreadable header: the status and the body say what there is
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
status: response.status, ok: response.ok, paid: settle !== null, ...(refusal !== undefined ? { paymentRefusal: refusal } : {}), ...(settle !== null ? { paymentResponse: settle.slice(0, 2000) } : {}),
|
|
123
|
+
...(location !== null ? { location, note: "a redirect is not followed by this tool" } : {}),
|
|
124
|
+
contentType: response.headers.get("content-type"), body: text.length > 50_000 ? `${text.slice(0, 50_000)}… cut` : cut ? `${text}… cut at ${MAX_PAID_BODY_BYTES} bytes` : text,
|
|
125
|
+
session: { spent: rt.guards.spent.toString(), remaining: rt.guards.remaining.toString() },
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "x207_list_offer",
|
|
131
|
+
title: "List something on the marketplace",
|
|
132
|
+
description: "Signs a listing with the agent's voucher key (it offers one thing and seeks another) and posts it to the circle's marketplace book. Values are credit units (6 decimals). Moves no money.",
|
|
133
|
+
access: "write",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
offer_asset_type: ASSET_TYPE, offer_class: CLASS, offer_quantity: z.number().int().min(1).max(1_000_000), offer_value: AMOUNT,
|
|
136
|
+
want_asset_type: ASSET_TYPE, want_class: CLASS, want_quantity: z.number().int().min(1).max(1_000_000), want_value: AMOUNT,
|
|
137
|
+
terms: z.string().max(1000).regex(/^[\x20-\x7e]*$/, "printable ASCII").default(""),
|
|
138
|
+
detail: z.string().max(4000).regex(/^[\x20-\x7e]*$/, "printable ASCII").default(""),
|
|
139
|
+
expires_in_hours: z.number().int().min(1).max(24 * 90).default(72),
|
|
140
|
+
discount_bps: z.number().int().min(0).max(10_000).default(0),
|
|
141
|
+
accept_payment: z.boolean().default(true),
|
|
142
|
+
discoverable: z.boolean().default(true),
|
|
143
|
+
},
|
|
144
|
+
handler: async (a, ctx) => {
|
|
145
|
+
const rt = w(ctx);
|
|
146
|
+
const key = needVoucher(rt, "x207_list_offer");
|
|
147
|
+
const post = needPost(rt, "x207_list_offer");
|
|
148
|
+
const listing = {
|
|
149
|
+
circle: ctx.circleContract, account: rt.cfg.account,
|
|
150
|
+
offer: { asset_type: a.offer_asset_type, asset_class: a.offer_class, quantity: a.offer_quantity, value: a.offer_value },
|
|
151
|
+
want: { asset_type: a.want_asset_type, asset_class: a.want_class, quantity: a.want_quantity, value: a.want_value },
|
|
152
|
+
terms: a.terms, detail: a.detail, expires_at: Math.floor(rt.now() / 1000) + a.expires_in_hours * 3600,
|
|
153
|
+
discount_bps: a.discount_bps, accept_payment: a.accept_payment, discoverable: a.discoverable, nonce: `mcp-${randomBytes(6).toString("hex")}`,
|
|
154
|
+
};
|
|
155
|
+
const signed = postListing(listing, key);
|
|
156
|
+
const view = await post("/listings", { listing: signed.listing, signature: signed.signature });
|
|
157
|
+
return { listingHash: signed.listing_hash, listing: view };
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "x207_make_offer",
|
|
162
|
+
title: "Make or counter an offer on a listing",
|
|
163
|
+
description: "Signs an offer (mode pay or barter) on a listing with the voucher key and posts it. For the next round of a negotiation, pass `in_reply_to`, the hash of the offer being answered. An offer is a proposal, not a payment, but its value is held to X207_MAX_PRICE_PER_CALL and the other party must be an allowed, active member.",
|
|
164
|
+
access: "write",
|
|
165
|
+
inputSchema: {
|
|
166
|
+
listing_hash: HASH64, mode: z.enum(["pay", "barter"]), value: AMOUNT, quantity: z.number().int().min(1).max(1_000_000).default(1),
|
|
167
|
+
deliver_in_hours: z.number().int().min(1).max(24 * 365).default(24), in_reply_to: HASH64.optional(),
|
|
168
|
+
},
|
|
169
|
+
handler: async (a, ctx) => {
|
|
170
|
+
const rt = w(ctx);
|
|
171
|
+
const key = needVoucher(rt, "x207_make_offer");
|
|
172
|
+
const post = needPost(rt, "x207_make_offer");
|
|
173
|
+
const get = needGet(ctx, "x207_make_offer");
|
|
174
|
+
const hash = a.listing_hash;
|
|
175
|
+
const lv = (await get(`/listings/${hash}`));
|
|
176
|
+
const listing = listingOf(lv, ctx.circleContract);
|
|
177
|
+
if (listingHash(listing) !== hash)
|
|
178
|
+
throw new ToolError(`refused: the listing the book returned for ${hash} does not hash to ${hash}`);
|
|
179
|
+
let previous;
|
|
180
|
+
let other = listing.account;
|
|
181
|
+
if (a.in_reply_to !== undefined) {
|
|
182
|
+
const items = (await get(`/listings/${hash}/offers`)).items;
|
|
183
|
+
const prev = items.find((o) => o.offerHash === a.in_reply_to);
|
|
184
|
+
if (prev === undefined)
|
|
185
|
+
throw new ToolError(`offer ${a.in_reply_to} is not on listing ${hash}`);
|
|
186
|
+
previous = offerOf(prev, hash, ctx.circleContract);
|
|
187
|
+
other = prev.proposer === rt.cfg.account ? prev.counterparty : prev.proposer;
|
|
188
|
+
}
|
|
189
|
+
// the lister too, not only the party of the offer that is answered: an offer on the listing of somebody outside the list is a proposal to that somebody (cross review UM-112, F5)
|
|
190
|
+
await rt.guards.counterparty(listing.account);
|
|
191
|
+
if (other !== listing.account)
|
|
192
|
+
await rt.guards.counterparty(other);
|
|
193
|
+
rt.guards.price(BigInt(a.value), "the value of the offer");
|
|
194
|
+
const signed = counterOffer({ listing, proposer: rt.cfg.account, mode: a.mode, value: a.value, quantity: a.quantity, deliver_by: Math.floor(rt.now() / 1000) + a.deliver_in_hours * 3600, ...(previous ? { previous } : {}), now: Math.floor(rt.now() / 1000) }, key);
|
|
195
|
+
const view = await post(`/listings/${hash}/offers`, { offer: signed.offer, signature: signed.signature, ...(a.in_reply_to !== undefined ? { inReplyTo: a.in_reply_to } : {}) });
|
|
196
|
+
return { offerHash: signed.offer_hash, round: signed.offer.round, offer: view };
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "x207_accept_offer",
|
|
201
|
+
title: "Accept an offer",
|
|
202
|
+
description: "Accepts an offer on a listing. The backend previews what acceptance does (a payment or a clearing bundle); the tool refuses when the agent would pay more than X207_MAX_PRICE_PER_CALL or more than is left of X207_MAX_SPEND_SESSION, or when another party is not an allowed, active member; otherwise it signs with the voucher key and posts the acceptance.",
|
|
203
|
+
access: "write",
|
|
204
|
+
inputSchema: { listing_hash: HASH64, offer_hash: HASH64 },
|
|
205
|
+
handler: async (a, ctx) => {
|
|
206
|
+
const rt = w(ctx);
|
|
207
|
+
const key = needVoucher(rt, "x207_accept_offer");
|
|
208
|
+
const post = needPost(rt, "x207_accept_offer");
|
|
209
|
+
const base = `/listings/${a.listing_hash}/offers/${a.offer_hash}`;
|
|
210
|
+
const preview = (await post(`${base}/preview`, { account: rt.cfg.account }));
|
|
211
|
+
let outgoing = 0n;
|
|
212
|
+
const others = new Set();
|
|
213
|
+
if (preview.kind === "payment" && preview.payment) {
|
|
214
|
+
if (preview.payment.payer === rt.cfg.account)
|
|
215
|
+
outgoing = BigInt(preview.payment.amount);
|
|
216
|
+
for (const x of [preview.payment.payer, preview.payment.payee])
|
|
217
|
+
if (x !== rt.cfg.account)
|
|
218
|
+
others.add(x);
|
|
219
|
+
}
|
|
220
|
+
else if (preview.bundle) {
|
|
221
|
+
for (const leg of preview.bundle.terms.legs) {
|
|
222
|
+
if (leg.payer === rt.cfg.account)
|
|
223
|
+
outgoing += BigInt(leg.max_amount);
|
|
224
|
+
for (const x of [leg.payer, leg.payee])
|
|
225
|
+
if (x !== rt.cfg.account)
|
|
226
|
+
others.add(x);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else
|
|
230
|
+
throw new ToolError("the backend preview has neither a payment nor a bundle");
|
|
231
|
+
if (preview.bundle && termsHash(preview.bundle.terms) !== preview.bundle.termsHash)
|
|
232
|
+
throw new ToolError("refused: the terms_hash of the backend does not match the bundle terms it sent; nothing was signed");
|
|
233
|
+
for (const x of others)
|
|
234
|
+
await rt.guards.counterparty(x);
|
|
235
|
+
rt.guards.price(outgoing, "what the agent would pay by accepting");
|
|
236
|
+
rt.guards.spend(outgoing, "accepting this offer");
|
|
237
|
+
try {
|
|
238
|
+
const signature = preview.kind === "bundle" && preview.bundle ? signBundle(preview.bundle.terms, rt.cfg.account, key).signature : undefined;
|
|
239
|
+
const result = await post(`${base}/accept`, { account: rt.cfg.account, epoch: preview.epoch, ...(signature ? { signature } : {}) });
|
|
240
|
+
return { kind: preview.kind, outgoing: outgoing.toString(), result, session: { spent: rt.guards.spent.toString(), remaining: rt.guards.remaining.toString() } };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw keepIfUnknown(rt, outgoing, error);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "x207_confirm_milestone",
|
|
249
|
+
title: "Act on a milestone of an order",
|
|
250
|
+
description: "Signs a notice for a milestone of a service order and posts it: the buyer `confirmed` (which commits the milestone amount, held to the price and session limits) or `disputed`; the seller `delivered` (with the hash of the deliverable) or `cancelled`. Moves no money by itself.",
|
|
251
|
+
access: "write",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
order_hash: z.string().regex(/^[A-Za-z0-9_-]{8,128}$/), index: z.number().int().min(1).max(24),
|
|
254
|
+
action: z.enum(["confirmed", "disputed", "delivered", "cancelled"]), reason: z.string().min(1).max(200).optional(), deliverable_hash: HASH64.optional(),
|
|
255
|
+
},
|
|
256
|
+
handler: async (a, ctx) => {
|
|
257
|
+
const rt = w(ctx);
|
|
258
|
+
const key = needVoucher(rt, "x207_confirm_milestone");
|
|
259
|
+
const post = needPost(rt, "x207_confirm_milestone");
|
|
260
|
+
const get = needGet(ctx, "x207_confirm_milestone");
|
|
261
|
+
const hash = a.order_hash;
|
|
262
|
+
const order = (await get(`/orders/${hash}`));
|
|
263
|
+
if (rt.cfg.account !== order.buyer && rt.cfg.account !== order.seller)
|
|
264
|
+
throw new ToolError("refused: the agent is neither buyer nor seller of this order");
|
|
265
|
+
await rt.guards.counterparty(rt.cfg.account === order.buyer ? order.seller : order.buyer);
|
|
266
|
+
const milestone = order.milestones.find((m) => m.index === a.index);
|
|
267
|
+
if (milestone === undefined)
|
|
268
|
+
throw new ToolError(`order ${hash} has no milestone ${a.index}`);
|
|
269
|
+
if (a.action === "delivered" && a.deliverable_hash === undefined)
|
|
270
|
+
throw new ToolError("delivered needs deliverable_hash");
|
|
271
|
+
if (a.action === "disputed" && a.reason === undefined)
|
|
272
|
+
throw new ToolError("disputed needs a reason");
|
|
273
|
+
const amount = a.action === "confirmed" ? BigInt(milestone.amount) : 0n;
|
|
274
|
+
if (amount > 0n) {
|
|
275
|
+
rt.guards.price(amount, `the milestone ${a.index}`);
|
|
276
|
+
rt.guards.spend(amount, `confirming milestone ${a.index}`);
|
|
277
|
+
}
|
|
278
|
+
const notice = {
|
|
279
|
+
circle: ctx.circleContract, order_hash: hash, milestone: a.index, actor: rt.cfg.account, action: a.action, at: Math.floor(rt.now() / 1000),
|
|
280
|
+
deliverable_hash: a.action === "delivered" ? a.deliverable_hash : null, reason: a.action === "disputed" ? a.reason : null,
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
const result = await post(`/orders/${hash}/milestones/${a.index}`, { notice, signature: signNotice(ctx.circleContract, notice, key) });
|
|
284
|
+
return { action: a.action, committed: amount.toString(), order: result, session: { spent: rt.guards.spent.toString(), remaining: rt.guards.remaining.toString() } };
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
throw keepIfUnknown(rt, amount, error);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "x207_join",
|
|
293
|
+
title: "Join the circle and stake",
|
|
294
|
+
description: "Joins the circle as the agent account and stakes USDT: sends join (with the storage deposit of 0.35 NEAR, not returned) and an ft_transfer_call of the stake, signed with the NEAR key. Off unless X207_ALLOW_OUTFLOWS=1 and X207_ALLOW_FULL_ACCESS_KEY=1; the stake counts against X207_MAX_SPEND_SESSION.",
|
|
295
|
+
access: "write",
|
|
296
|
+
inputSchema: { terms_hash: HASH64.describe("sha256 of the circle terms the agent accepts."), stake_amount: AMOUNT.describe("USDT to lock as stake, smallest unit (6 decimals).") },
|
|
297
|
+
handler: async (a, ctx) => {
|
|
298
|
+
const rt = w(ctx);
|
|
299
|
+
rt.guards.outflows("x207_join");
|
|
300
|
+
const voucherKey = needVoucher(rt, "x207_join");
|
|
301
|
+
rt.guards.nearKey(await rt.keyKind(), "x207_join");
|
|
302
|
+
const stake = BigInt(a.stake_amount);
|
|
303
|
+
rt.guards.spend(stake, "the stake of x207_join");
|
|
304
|
+
try {
|
|
305
|
+
const circle = await circleRecord(ctx);
|
|
306
|
+
const result = await onboardMember({ sendCall: rt.send, view: ctx.view }, { circleContract: ctx.circleContract, circleId: ctx.circleId, accountId: rt.cfg.account, termsHash: a.terms_hash, stakeAmount: a.stake_amount, secretKey: voucherKey, usdtTokenId: circle.usdt_token_id });
|
|
307
|
+
return { account: rt.cfg.account, voucherPublicKey: result.publicKey, member: result.member, session: { spent: rt.guards.spent.toString(), remaining: rt.guards.remaining.toString() } };
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw keepIfUnknown(rt, stake, error);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: "x207_settle",
|
|
316
|
+
title: "Pay the residual of an epoch",
|
|
317
|
+
description: "Pays what the agent owes after clearing for one epoch: an ft_transfer_call of USDT to the circle with the `settle` message, signed with the NEAR key. Pays at most the amount due. Off unless X207_ALLOW_OUTFLOWS=1 and X207_ALLOW_FULL_ACCESS_KEY=1; the amount counts against X207_MAX_SPEND_SESSION.",
|
|
318
|
+
access: "write",
|
|
319
|
+
inputSchema: { epoch: EPOCH, amount: AMOUNT.optional().describe("Pay this much, at most the amount due; the whole amount due when left out.") },
|
|
320
|
+
handler: async (a, ctx) => {
|
|
321
|
+
const rt = w(ctx);
|
|
322
|
+
rt.guards.outflows("x207_settle");
|
|
323
|
+
rt.guards.nearKey(await rt.keyKind(), "x207_settle");
|
|
324
|
+
const position = (await ctx.view("net_position", { epoch: a.epoch, account_id: rt.cfg.account }));
|
|
325
|
+
if (position === null || position.role !== "debtor" || position.status !== "due")
|
|
326
|
+
throw new ToolError(`the agent owes nothing for epoch ${a.epoch} (${position === null ? "no position" : `${position.role}, ${position.status}`})`);
|
|
327
|
+
const due = BigInt(position.amount);
|
|
328
|
+
const amount = a.amount === undefined ? due : BigInt(a.amount);
|
|
329
|
+
if (amount > due)
|
|
330
|
+
throw new ToolError(`refused: ${amount} is more than the ${due} due for epoch ${a.epoch}`);
|
|
331
|
+
rt.guards.spend(amount, `settling epoch ${a.epoch}`);
|
|
332
|
+
try {
|
|
333
|
+
const circle = await circleRecord(ctx);
|
|
334
|
+
const result = await rt.send(buildSettleTransferCall({ circleContract: ctx.circleContract, amount: amount.toString(), epoch: a.epoch, usdtTokenId: circle.usdt_token_id }));
|
|
335
|
+
return { epoch: a.epoch, paid: amount.toString(), due: due.toString(), result: result ?? null, session: { spent: rt.guards.spent.toString(), remaining: rt.guards.remaining.toString() } };
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
throw keepIfUnknown(rt, amount, error);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: "x207_claim",
|
|
344
|
+
title: "Claim a credit after clearing",
|
|
345
|
+
description: "Claims what the agent is owed for one epoch, to its own NEAR account (`near`) or its Intents balance (`intents`): sends claim with 1 yoctoNEAR, signed with the NEAR key. The destination is always the agent itself. Needs X207_ALLOW_FULL_ACCESS_KEY=1 because the call attaches a deposit.",
|
|
346
|
+
access: "write",
|
|
347
|
+
inputSchema: { epoch: EPOCH, destination: z.enum(["near", "intents"]).default("near") },
|
|
348
|
+
handler: async (a, ctx) => {
|
|
349
|
+
const rt = w(ctx);
|
|
350
|
+
rt.guards.nearKey(await rt.keyKind(), "x207_claim");
|
|
351
|
+
const position = (await ctx.view("net_position", { epoch: a.epoch, account_id: rt.cfg.account }));
|
|
352
|
+
if (position === null || position.role !== "creditor" || position.status !== "claimable")
|
|
353
|
+
throw new ToolError(`nothing to claim for epoch ${a.epoch} (${position === null ? "no position" : `${position.role}, ${position.status}`})`);
|
|
354
|
+
const result = await rt.send(buildClaimCall({ circleContract: ctx.circleContract, epoch: a.epoch, destination: a.destination }));
|
|
355
|
+
return { epoch: a.epoch, claimable: position.amount, destination: a.destination, result: result ?? null };
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
];
|
|
359
|
+
/** Which write tools this runtime may even list: a tool whose key or switch is missing is not offered (the guard would refuse it anyway). */
|
|
360
|
+
export function availableWriteTools(rt, hasBackend) {
|
|
361
|
+
const voucher = rt.cfg.voucherKey !== null;
|
|
362
|
+
const near = rt.cfg.nearKey !== null && rt.cfg.allowFullAccessKey;
|
|
363
|
+
const book = voucher && hasBackend;
|
|
364
|
+
const ok = {
|
|
365
|
+
x207_pay_request: voucher, x207_list_offer: book, x207_make_offer: book, x207_accept_offer: book, x207_confirm_milestone: book,
|
|
366
|
+
x207_join: voucher && near && rt.cfg.allowOutflows, x207_settle: near && rt.cfg.allowOutflows, x207_claim: near,
|
|
367
|
+
};
|
|
368
|
+
return WRITE_TOOLS.filter((t) => ok[t.name] === true);
|
|
369
|
+
}
|
|
370
|
+
/** Builds the runtime of the write tools from the configuration. The NEAR sender and the payment client are created on first use. */
|
|
371
|
+
export function createWriteRuntime(config, cfg, view, deps = { post: null }) {
|
|
372
|
+
const now = deps.now ?? Date.now;
|
|
373
|
+
const fetchFn = config.fetch ?? fetch;
|
|
374
|
+
// M-2: the paid request connects only to the addresses that the guard checked (one lookup, pinned); X207_ALLOW_PRIVATE_URLS=1 and an injected fetch (tests) keep the plain fetch
|
|
375
|
+
const payBase = config.fetch === undefined && !cfg.allowPrivateUrls ? createPinnedFetch(deps.resolve ?? defaultResolver) : fetchFn;
|
|
376
|
+
let sender = deps.send;
|
|
377
|
+
let kind;
|
|
378
|
+
let pay;
|
|
379
|
+
const guards = new Guards(cfg, view);
|
|
380
|
+
return {
|
|
381
|
+
cfg, guards, post: deps.post, now, resolve: deps.resolve ?? defaultResolver,
|
|
382
|
+
send: async (call) => {
|
|
383
|
+
if (sender === undefined) {
|
|
384
|
+
const { createNearSender } = await import("@x207/core/signer");
|
|
385
|
+
sender = createNearSender({ rpcUrl: config.rpcUrls[0], accountId: cfg.account, secretKey: cfg.nearKey });
|
|
386
|
+
}
|
|
387
|
+
return sender(call);
|
|
388
|
+
},
|
|
389
|
+
keyKind: async () => {
|
|
390
|
+
if (kind !== undefined)
|
|
391
|
+
return kind;
|
|
392
|
+
if (cfg.nearKey === null)
|
|
393
|
+
return (kind = "none");
|
|
394
|
+
kind = await accessKeyKind(config.rpcUrls, cfg.account, voucherPublicKey(cfg.nearKey), fetchFn);
|
|
395
|
+
return kind;
|
|
396
|
+
},
|
|
397
|
+
payFetch: () => {
|
|
398
|
+
if (pay === undefined) {
|
|
399
|
+
const client = new X207PaymentClient({ payer: cfg.account, secretKey: cfg.voucherKey, headroom: headroomFromRpc({ rpcUrls: config.rpcUrls, fetch: fetchFn }) });
|
|
400
|
+
client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
|
|
401
|
+
try {
|
|
402
|
+
if (!String(selectedRequirements.network).startsWith("x207:"))
|
|
403
|
+
throw new ToolError("refused: the payment request is not an x207 payment");
|
|
404
|
+
const amount = BigInt(selectedRequirements.amount);
|
|
405
|
+
await guards.counterparty(selectedRequirements.payTo);
|
|
406
|
+
guards.price(amount, "the price of the request");
|
|
407
|
+
guards.spend(amount, "this payment");
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
return { abort: true, reason: error.message };
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
pay = wrapFetchWithPayment(payBase, client);
|
|
414
|
+
}
|
|
415
|
+
return pay;
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/** `view_access_key` of the NEAR key on the agent account. `unknown` when the key is not on the account or the RPC cannot say. */
|
|
420
|
+
export async function accessKeyKind(rpcUrls, account, publicKey, fetchFn) {
|
|
421
|
+
for (const url of rpcUrls) {
|
|
422
|
+
try {
|
|
423
|
+
const response = await fetchFn(url, {
|
|
424
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
425
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "x207", method: "query", params: { request_type: "view_access_key", finality: "optimistic", account_id: account, public_key: publicKey } }),
|
|
426
|
+
signal: AbortSignal.timeout(10_000),
|
|
427
|
+
});
|
|
428
|
+
const body = (await response.json());
|
|
429
|
+
const permission = body.result?.permission;
|
|
430
|
+
if (permission === "FullAccess")
|
|
431
|
+
return "full-access";
|
|
432
|
+
if (permission !== undefined && typeof permission === "object" && permission !== null && "FunctionCall" in permission)
|
|
433
|
+
return "function-call";
|
|
434
|
+
if (body.result?.error !== undefined || body.error !== undefined)
|
|
435
|
+
return "unknown";
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// try the next endpoint
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return "unknown";
|
|
442
|
+
}
|
|
443
|
+
//# sourceMappingURL=write-tools.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x207/mcp",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "MCP server for x207: read tools for a mutual-credit circle on NEAR (circle, members, epochs, clearing simulation, listings, orders) over stdio and Streamable HTTP",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"x402",
|
|
9
|
+
"x207",
|
|
10
|
+
"near",
|
|
11
|
+
"mutual-credit",
|
|
12
|
+
"agent-payments"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/x207-labs/x207#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/x207-labs/x207/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/x207-labs/sdk.git",
|
|
21
|
+
"directory": "packages/mcp"
|
|
22
|
+
},
|
|
23
|
+
"license": "Apache-2.0",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"bin": {
|
|
29
|
+
"x207-mcp": "dist/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"!dist/**/*.map",
|
|
41
|
+
"!dist/**/*.tsbuildinfo",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.22.2"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -b"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
56
|
+
"@x207/core": "0.0.1",
|
|
57
|
+
"@x207/scheme": "0.0.1",
|
|
58
|
+
"@x402/fetch": "2.29.0",
|
|
59
|
+
"zod": "4.6.7"
|
|
60
|
+
}
|
|
61
|
+
}
|