@agentpickup/mcp 0.4.3 → 0.5.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/README.md +85 -82
- package/dist/card.d.ts +25 -3
- package/dist/card.js +117 -62
- package/dist/server.d.ts +7 -1
- package/dist/server.js +287 -174
- package/dist/share-card.d.ts +5 -4
- package/dist/share-card.js +139 -134
- package/package.json +66 -63
package/dist/server.js
CHANGED
|
@@ -7,50 +7,83 @@ import { z } from "zod";
|
|
|
7
7
|
import { CARD_META_KEY, CARD_MIME_TYPE, passCardHtml } from "./card.js";
|
|
8
8
|
import { SHARE_META_KEY, shareCardHtml } from "./share-card.js";
|
|
9
9
|
import { errorOf, usd } from "./wallet.js";
|
|
10
|
-
export const VERSION = "0.
|
|
10
|
+
export const VERSION = "0.5.0";
|
|
11
11
|
// The pickup pass card (MCP Apps). The version is in the URI so hosts that cache cards pick up a new one.
|
|
12
12
|
const CARD_URI = `ui://agentpickup/pass-${VERSION}.html`;
|
|
13
13
|
const CARD_TOOL_META = { ui: { resourceUri: CARD_URI }, "ui/resourceUri": CARD_URI };
|
|
14
14
|
const SHARE_URI = `ui://agentpickup/share-${VERSION}.html`;
|
|
15
15
|
const SHARE_TOOL_META = { ui: { resourceUri: SHARE_URI }, "ui/resourceUri": SHARE_URI };
|
|
16
|
-
// Shop names for the card, so polling order_status needn't read the catalog each time.
|
|
17
|
-
// per-request servers a hosted endpoint makes.
|
|
16
|
+
// Shop names and where to collect, for the card, so polling order_status needn't read the catalog each time.
|
|
17
|
+
// Shared across the per-request servers a hosted endpoint makes.
|
|
18
18
|
const shopNames = new Map();
|
|
19
|
-
const
|
|
19
|
+
const shopWhere = new Map();
|
|
20
|
+
const rememberShop = (shop, name, location) => {
|
|
20
21
|
if (typeof name !== "string" || !name)
|
|
21
22
|
return;
|
|
22
|
-
if (shopNames.size > 500)
|
|
23
|
+
if (shopNames.size > 500) {
|
|
23
24
|
shopNames.clear();
|
|
25
|
+
shopWhere.clear();
|
|
26
|
+
}
|
|
24
27
|
shopNames.set(shop, name);
|
|
28
|
+
const where = location?.address || location?.name;
|
|
29
|
+
if (where)
|
|
30
|
+
shopWhere.set(shop, where);
|
|
25
31
|
};
|
|
26
32
|
/** A shop's name as the add page's ?shop= matches it: "Demo Café" -> "demo-cafe". */
|
|
27
33
|
const slugOf = (name) => name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
28
|
-
/**
|
|
29
|
-
|
|
34
|
+
/**
|
|
35
|
+
* A QR as a compact bit grid for the cards (a few hundred characters, where an SVG is several thousand): the cards
|
|
36
|
+
* draw it, and the assistant that also reads structuredContent isn't handed an image's worth of path data.
|
|
37
|
+
*/
|
|
38
|
+
const qrBits = (content) => {
|
|
39
|
+
const { modules } = QRCode.create(content, { errorCorrectionLevel: "M" });
|
|
40
|
+
const n = modules.size;
|
|
41
|
+
const bytes = new Uint8Array(Math.ceil((n * n) / 8));
|
|
42
|
+
for (let i = 0; i < n * n; i++)
|
|
43
|
+
if (modules.data[i])
|
|
44
|
+
bytes[i >> 3] |= 0x80 >> (i & 7);
|
|
45
|
+
return { size: n, bits: Buffer.from(bytes).toString("base64") };
|
|
46
|
+
};
|
|
30
47
|
class ToolError extends Error {
|
|
31
48
|
}
|
|
32
|
-
const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
|
|
33
|
-
0. find_shops (when available): shops near the user that sell what they want. Pass a shop's shop_url to the other tools.
|
|
34
|
-
1. get_shop: see what the shop sells, its pickup location and how it takes payment.
|
|
35
|
-
2. create_pickup_order: needs the user's email; the shop emails the pickup pass there too. It returns a claim:
|
|
36
|
-
keep it with the order id.
|
|
37
|
-
3. pay_order: pays in USDC over x402 from the wallet set up here; with none, it hands back the pay link for the user's
|
|
38
|
-
own x402 wallet. Where the shop takes cards, payment: "card" on
|
|
39
|
-
create_pickup_order gives a checkout link for the user to pay on instead. Where it takes test payments (get_shop says
|
|
40
|
-
so), payment: "test" runs the whole flow with no money moving. Confirm the items and total with the user first.
|
|
41
|
-
4. The pass: where the chat shows cards, the order appears as a card that becomes the pickup pass (QR and code)
|
|
42
|
-
once it's paid and keeps itself up to date until it's collected. Otherwise give the user the pass link and pickup
|
|
43
|
-
code. They show the QR at the counter, or say the code, to collect.
|
|
44
|
-
get_pickup_pass (with the claim) fetches the pass again; order_status (with the claim) says whether it's ready.
|
|
45
|
-
share_agentpickup: when the user wants to share agentPickup itself, invite a friend or get friends using it, shows a
|
|
46
|
-
QR and link for them. That isn't a group order: start_group_order is only for ordering together at one shop.
|
|
47
|
-
Group orders (a coffee run): start_group_order gives a code to share; each person's agent calls join_group_order and
|
|
48
|
-
pays its own share with pay_order; at closing time the shop gets one combined order and the organiser gets its QR.
|
|
49
|
+
const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
|
|
50
|
+
0. find_shops (when available): shops near the user that sell what they want. Pass a shop's shop_url to the other tools.
|
|
51
|
+
1. get_shop: see what the shop sells, its pickup location and how it takes payment.
|
|
52
|
+
2. create_pickup_order: needs the user's email; the shop emails the pickup pass there too. It returns a claim:
|
|
53
|
+
keep it with the order id.
|
|
54
|
+
3. pay_order: pays in USDC over x402 from the wallet set up here; with none, it hands back the pay link for the user's
|
|
55
|
+
own x402 wallet. Where the shop takes cards, payment: "card" on
|
|
56
|
+
create_pickup_order gives a checkout link for the user to pay on instead. Where it takes test payments (get_shop says
|
|
57
|
+
so), payment: "test" runs the whole flow with no money moving. Confirm the items and total with the user first.
|
|
58
|
+
4. The pass: where the chat shows cards, the order appears as a card that becomes the pickup pass (QR and code)
|
|
59
|
+
once it's paid and keeps itself up to date until it's collected. Otherwise give the user the pass link and pickup
|
|
60
|
+
code. They show the QR at the counter, or say the code, to collect.
|
|
61
|
+
get_pickup_pass (with the claim) fetches the pass again; order_status (with the claim) says whether it's ready.
|
|
62
|
+
share_agentpickup: when the user wants to share agentPickup itself, invite a friend or get friends using it, shows a
|
|
63
|
+
QR and link for them. That isn't a group order: start_group_order is only for ordering together at one shop.
|
|
64
|
+
Group orders (a lunch or coffee run): start_group_order gives a code to share; each person's agent calls join_group_order and
|
|
65
|
+
pays its own share with pay_order; at closing time the shop gets one combined order and the organiser gets its QR.
|
|
49
66
|
group_order_status shows who's in; close_group_order closes it early.`;
|
|
67
|
+
const CARDS_INSTRUCTIONS = `agentPickup lets you order from local shops (cafés, bakeries, stores and more) for the user to collect in person.
|
|
68
|
+
0. find_shops (when available): shops near the user that sell what they want. Pass a shop's shop_url to the other tools.
|
|
69
|
+
1. get_shop: what the shop sells, where to collect, and how it takes payment.
|
|
70
|
+
2. create_pickup_order, once the user has agreed the items and total. It needs the user's email (the shop emails the
|
|
71
|
+
pickup pass there too) and returns a claim: keep it with the order id.
|
|
72
|
+
3. Payment: the user pays by card (or Apple Pay or Google Pay) on the shop's own checkout page, from the link
|
|
73
|
+
create_pickup_order returns or the Pay button on the order's card. Where the shop takes test payments,
|
|
74
|
+
payment: "test" runs the whole flow with no money moving.
|
|
75
|
+
4. The pass: where the chat shows cards, the order's card becomes the pickup pass (QR and code) once it's paid and
|
|
76
|
+
keeps itself up to date until it's collected. Otherwise give the user the pass link and pickup code. They show the
|
|
77
|
+
QR at the counter, or say the code, to collect.
|
|
78
|
+
get_pickup_pass (with the claim) fetches the pass again; order_status (with the claim) says whether it's ready.
|
|
79
|
+
share_agentpickup: when the user wants to share agentPickup itself or invite a friend, shows a QR and link for them.`;
|
|
80
|
+
/** x402 networks that move no real money (the server's directory uses the same list). */
|
|
81
|
+
const TEST_NETWORKS = new Set(["eip155:84532"]);
|
|
50
82
|
const money = (cents, currency) => new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
|
|
83
|
+
/** A tool's answer, and the card's data where there's a card: in structuredContent, which hosts pass to the card. */
|
|
51
84
|
const reply = (summary, data, card) => ({
|
|
52
85
|
content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(data, null, 2)}` }],
|
|
53
|
-
...(card ? { _meta: { [CARD_META_KEY]: card } } : {}),
|
|
86
|
+
...(card ? { structuredContent: { card }, _meta: { [CARD_META_KEY]: card } } : {}),
|
|
54
87
|
});
|
|
55
88
|
const failure = (message) => ({ content: [{ type: "text", text: message }], isError: true });
|
|
56
89
|
/** A shop's base URL: shops can live under a path (https://host/v/cafe). A pasted API or pass link works too. */
|
|
@@ -113,7 +146,8 @@ export function createPickupMcpServer(opts) {
|
|
|
113
146
|
async function shopName(shop) {
|
|
114
147
|
if (!shopNames.has(shop)) {
|
|
115
148
|
const data = await call(shop, "/api/catalog").catch(() => null);
|
|
116
|
-
|
|
149
|
+
const merchant = data?.merchant;
|
|
150
|
+
rememberShop(shop, merchant?.name, merchant?.location);
|
|
117
151
|
}
|
|
118
152
|
return shopNames.get(shop);
|
|
119
153
|
}
|
|
@@ -124,7 +158,14 @@ export function createPickupMcpServer(opts) {
|
|
|
124
158
|
orderId: order.id,
|
|
125
159
|
shopUrl: shop,
|
|
126
160
|
shopName: shopNames.get(shop),
|
|
161
|
+
where: shopWhere.get(shop),
|
|
162
|
+
lines: order.items.map((i) => ({
|
|
163
|
+
qty: i.qty,
|
|
164
|
+
name: i.name,
|
|
165
|
+
...(typeof i.unitCents === "number" ? { price: money(i.unitCents * i.qty, order.currency) } : {}),
|
|
166
|
+
})),
|
|
127
167
|
status: order.status,
|
|
168
|
+
...(order.status === "paid" && order.acceptedAt ? { accepted: true } : {}),
|
|
128
169
|
rail: order.rail,
|
|
129
170
|
items: order.items.map((i) => `${i.qty}× ${i.name}`).join(", "),
|
|
130
171
|
total: money(order.totalCents, order.currency),
|
|
@@ -132,7 +173,7 @@ export function createPickupMcpServer(opts) {
|
|
|
132
173
|
collectedAt: order.collectedAt ?? null,
|
|
133
174
|
inviteUrl: addLinks(shop).sharePageUrl,
|
|
134
175
|
...extra,
|
|
135
|
-
...(pass ? { passUrl: pass.passUrl, pickupCode: pass.pickupCode,
|
|
176
|
+
...(pass ? { passUrl: pass.passUrl, pickupCode: pass.pickupCode, qr: qrBits(pass.qrContent) } : {}),
|
|
136
177
|
};
|
|
137
178
|
}
|
|
138
179
|
/** The add page for sharing: for one of this host's shops when given (?shop=<slug>), else everyone's. */
|
|
@@ -160,7 +201,9 @@ export function createPickupMcpServer(opts) {
|
|
|
160
201
|
version: VERSION,
|
|
161
202
|
websiteUrl: "https://agentpickup.app",
|
|
162
203
|
icons: [{ src: "https://agentpickup.app/logo.png", mimeType: "image/png", sizes: ["1024x1024"] }],
|
|
163
|
-
}, {
|
|
204
|
+
}, {
|
|
205
|
+
instructions: (opts.cardsOnly ? CARDS_INSTRUCTIONS : INSTRUCTIONS) + (opts.instructions ? `\n${opts.instructions}` : ""),
|
|
206
|
+
});
|
|
164
207
|
server.registerResource("pickup-pass", CARD_URI, {
|
|
165
208
|
title: "Pickup pass",
|
|
166
209
|
description: "The order as a card: a Pay button while it's unpaid, then the QR and pickup code, kept up to date until it's collected.",
|
|
@@ -181,7 +224,7 @@ export function createPickupMcpServer(opts) {
|
|
|
181
224
|
description: "Find agentPickup shops to order from: near a point (lat/lng), in a suburb or city (place), or selling something (query). " +
|
|
182
225
|
"Returns each shop's shop_url, pickup address, hours, whether it's open now, distance, and matching menu items.",
|
|
183
226
|
inputSchema: {
|
|
184
|
-
query: z.string().max(100).optional().describe('What the user wants, e.g. "flat white" or "
|
|
227
|
+
query: z.string().max(100).optional().describe('What the user wants, e.g. "flat white", "sourdough" or "AA batteries". Leave out to list everything.'),
|
|
185
228
|
place: z.string().max(100).optional().describe('A suburb, street or city to match against shop addresses, e.g. "Surry Hills".'),
|
|
186
229
|
lat: z.number().min(-90).max(90).optional().describe("Where the user is: latitude. Send with lng."),
|
|
187
230
|
lng: z.number().min(-180).max(180).optional().describe("Where the user is: longitude. Send with lat."),
|
|
@@ -203,7 +246,12 @@ export function createPickupMcpServer(opts) {
|
|
|
203
246
|
const res = await doFetch(new Request(url));
|
|
204
247
|
if (!res.ok)
|
|
205
248
|
throw new ToolError(await errorOf(res, `The directory answered ${res.status}.`));
|
|
206
|
-
const
|
|
249
|
+
const all = (await res.json()).shops;
|
|
250
|
+
// Cards-only: the shops it can order from here, by card or with a test payment. A shop on a test network
|
|
251
|
+
// is there to try USDC, so it isn't one of them.
|
|
252
|
+
const shops = opts.cardsOnly
|
|
253
|
+
? all.filter((s) => s.payments.card || s.payments.cardTest || (s.payments.test && !(s.payments.x402 && TEST_NETWORKS.has(s.payments.x402.network))))
|
|
254
|
+
: all;
|
|
207
255
|
if (!shops.length)
|
|
208
256
|
return reply("No agentPickup shops match. Try a wider radius, another place, or no query.", { shops: [] });
|
|
209
257
|
const lines = shops.map((s) => {
|
|
@@ -211,6 +259,7 @@ export function createPickupMcpServer(opts) {
|
|
|
211
259
|
s.distanceKm !== undefined ? `${s.distanceKm} km` : null,
|
|
212
260
|
s.openNow === true ? "open now" : s.openNow === false ? "closed now" : null,
|
|
213
261
|
s.testOnly ? "test shop, no real money" : null,
|
|
262
|
+
opts.cardsOnly ? (s.payments.card ? "takes cards" : s.payments.cardTest ? "takes test cards" : null) : null,
|
|
214
263
|
s.source?.kind === "listed" ? `listed by ${s.source.domain}` : null,
|
|
215
264
|
s.order?.via === "ucp" ? "orders through its UCP checkout" : s.order?.via === "web" ? "orders on its own website" : null,
|
|
216
265
|
].filter(Boolean);
|
|
@@ -225,7 +274,9 @@ export function createPickupMcpServer(opts) {
|
|
|
225
274
|
distanceKm: s.distanceKm,
|
|
226
275
|
openNow: s.openNow,
|
|
227
276
|
testOnly: s.testOnly,
|
|
228
|
-
payments:
|
|
277
|
+
payments: opts.cardsOnly
|
|
278
|
+
? { card: s.payments.card || Boolean(s.payments.cardTest), ...(s.payments.cardTest ? { testCards: true } : {}), test: s.payments.test }
|
|
279
|
+
: { x402: s.payments.x402?.network ?? null, test: s.payments.test },
|
|
229
280
|
listedBy: s.source?.kind === "listed" ? s.source.domain : null,
|
|
230
281
|
// agentpickup: use get_shop and create_pickup_order. ucp or web: order there instead.
|
|
231
282
|
orderVia: s.order?.via ?? "agentpickup",
|
|
@@ -245,7 +296,12 @@ export function createPickupMcpServer(opts) {
|
|
|
245
296
|
const shop = await shopFor(args.shop_url);
|
|
246
297
|
const data = await call(shop, "/api/catalog");
|
|
247
298
|
const merchant = data.merchant;
|
|
248
|
-
rememberShop(shop, merchant.name);
|
|
299
|
+
rememberShop(shop, merchant.name, merchant.location);
|
|
300
|
+
const NETWORK_NAMES = { instagram: "Instagram", x: "X", facebook: "Facebook", tiktok: "TikTok" };
|
|
301
|
+
const socials = (merchant.socials ?? []).filter((s) => NETWORK_NAMES[s.network]);
|
|
302
|
+
const followNote = socials.length
|
|
303
|
+
? ` On ${socials.map((s) => `${NETWORK_NAMES[s.network]}${s.network === "facebook" ? "" : ` as @${s.handle}`}`).join(", ")}.`
|
|
304
|
+
: "";
|
|
249
305
|
const currency = String(data.currency ?? "usd");
|
|
250
306
|
const items = data.items.map((i) => ({
|
|
251
307
|
id: i.id,
|
|
@@ -256,54 +312,104 @@ export function createPickupMcpServer(opts) {
|
|
|
256
312
|
const payments = data.payments;
|
|
257
313
|
const cards = Boolean(payments.square || payments.card);
|
|
258
314
|
const ways = [
|
|
259
|
-
...(payments.x402 ? [`USDC over x402 on ${payments.x402.network}, paid by the agent`] : []),
|
|
315
|
+
...(payments.x402 && !opts.cardsOnly ? [`USDC over x402 on ${payments.x402.network}, paid by the agent`] : []),
|
|
260
316
|
...(cards ? ["cards, paid by the user on a checkout link"] : []),
|
|
261
317
|
...(payments.test ? ["test payments, with no money moving"] : []),
|
|
262
318
|
];
|
|
319
|
+
// Known hours: say whether it's open, so nobody orders from a closed shop.
|
|
320
|
+
const openNow = typeof data.openNow === "boolean" ? data.openNow : null;
|
|
321
|
+
const hoursNote = openNow === true ? "Open now. " : openNow === false ? `Closed now${data.opensNext ? `; opens ${data.opensNext}` : ""}: orders aren't taken until then. ` : "";
|
|
263
322
|
return reply(`${merchant.name}${merchant.location?.name ? `, collect at ${merchant.location.name}` : ""}. ` +
|
|
323
|
+
hoursNote +
|
|
264
324
|
(!ways.length
|
|
265
|
-
?
|
|
325
|
+
? opts.cardsOnly
|
|
326
|
+
? "Doesn't take card payments here."
|
|
327
|
+
: "Doesn't take agent payments."
|
|
266
328
|
: ways.length === 1 && payments.test
|
|
267
329
|
? "A test shop: orders are marked paid without money moving."
|
|
268
|
-
: `Takes ${ways.join("; ")}.`)
|
|
330
|
+
: `Takes ${ways.join("; ")}.`) +
|
|
331
|
+
followNote, {
|
|
332
|
+
shop,
|
|
333
|
+
merchant: merchant.name,
|
|
334
|
+
location: merchant.location,
|
|
335
|
+
...(socials.length ? { socials } : {}),
|
|
336
|
+
openNow,
|
|
337
|
+
...(openNow === false && data.opensNext ? { opensNext: data.opensNext } : {}),
|
|
338
|
+
currency,
|
|
339
|
+
items,
|
|
340
|
+
...(opts.cardsOnly ? {} : { x402: payments.x402 ?? null }),
|
|
341
|
+
cards,
|
|
342
|
+
test: Boolean(payments.test),
|
|
343
|
+
});
|
|
344
|
+
}));
|
|
345
|
+
server.registerTool("ask_about_shop", {
|
|
346
|
+
title: "Ask about a shop",
|
|
347
|
+
description: "Answer a question about a shop that its menu doesn't: dietary needs and allergies (gluten-free, vegan, nuts), reviews, " +
|
|
348
|
+
"opening hours, where it is, parking, wait times, prices. Use it whenever the user asks something like that about a shop. " +
|
|
349
|
+
"Send the question in a few words, without names or other personal details. It answers from what the shop has said, " +
|
|
350
|
+
"says plainly what the shop hasn't, and tells the shop which topics people ask about (never the question itself).",
|
|
351
|
+
inputSchema: { ...shopArg, question: z.string().min(2).max(300).describe('The question in a few words, e.g. "gluten-free options?" or "is there parking?"') },
|
|
352
|
+
annotations: { title: "Ask about a shop", readOnlyHint: true, openWorldHint: true },
|
|
353
|
+
}, guarded(async (args) => {
|
|
354
|
+
const shop = await shopFor(args.shop_url);
|
|
355
|
+
const data = await call(shop, "/api/question", {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "Content-Type": "application/json" },
|
|
358
|
+
body: JSON.stringify({ question: args.question }),
|
|
359
|
+
});
|
|
360
|
+
const answers = data.answers ?? [];
|
|
361
|
+
return reply(`${String(data.shop ?? "The shop")}: ${answers.map((a) => a.text).join(" ") || "it hasn't said."}`, { shop, topics: data.topics, answers });
|
|
269
362
|
}));
|
|
270
363
|
server.registerTool("create_pickup_order", {
|
|
271
364
|
title: "Order for pickup",
|
|
272
|
-
description:
|
|
273
|
-
"
|
|
274
|
-
|
|
365
|
+
description: opts.cardsOnly
|
|
366
|
+
? "Create an order to collect in person, once the user has agreed the items and total. Returns the order id, total, a " +
|
|
367
|
+
"claim (keep it) and the shop's checkout link, where the user pays by card. The shop emails the pickup pass to `email` " +
|
|
368
|
+
"once it's paid, and where the chat shows cards the order shows as one that becomes the pass. At shops that take test " +
|
|
369
|
+
"payments, `payment: \"test\"` marks it paid with no money moving."
|
|
370
|
+
: "Create an order to collect in person. Returns the order id, total and a claim (keep it); pay it with pay_order. " +
|
|
371
|
+
"The shop emails the pickup pass to `email` once it's paid, and where the chat shows cards the order shows as one " +
|
|
372
|
+
"that becomes the pass. Where the shop takes cards, `payment: \"card\"` returns a checkout link for the user to pay on instead.",
|
|
275
373
|
inputSchema: {
|
|
276
374
|
items: z
|
|
277
375
|
.array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) }))
|
|
278
376
|
.min(1),
|
|
279
377
|
email: z.string().email().describe("The user's email. The pickup pass goes here too."),
|
|
280
378
|
name: z.string().max(100).optional().describe("Name for the order, shown to staff"),
|
|
281
|
-
payment:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
379
|
+
payment: opts.cardsOnly
|
|
380
|
+
? z
|
|
381
|
+
.enum(["card", "test"])
|
|
382
|
+
.optional()
|
|
383
|
+
.describe("card (the user pays on the shop's checkout link) or test (where the shop takes test payments: no money moves). Default: card where the shop takes it, else test.")
|
|
384
|
+
: z
|
|
385
|
+
.enum(["agent", "card", "test"])
|
|
386
|
+
.optional()
|
|
387
|
+
.describe("Who pays: agent (USDC over x402, or a test payment at a test shop), card (the user pays on a checkout link), " +
|
|
388
|
+
"or test (where the shop takes test payments: the whole flow with no money moving). Default: agent where the shop takes it, else card."),
|
|
286
389
|
...shopArg,
|
|
287
390
|
},
|
|
288
|
-
|
|
391
|
+
// It creates a real order (and, paid, a charge): Claude asks before it runs.
|
|
392
|
+
annotations: { title: "Order for pickup", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
289
393
|
_meta: CARD_TOOL_META,
|
|
290
394
|
}, guarded(async (args) => {
|
|
291
395
|
const shop = await shopFor(args.shop_url);
|
|
292
396
|
// The agent pays over x402 where the shop takes it, a test shop marks the order paid straight away, and a
|
|
293
397
|
// shop that takes cards gives the user a checkout link.
|
|
294
398
|
const catalog = (await call(shop, "/api/catalog"));
|
|
295
|
-
rememberShop(shop, catalog.merchant?.name);
|
|
399
|
+
rememberShop(shop, catalog.merchant?.name, catalog.merchant?.location);
|
|
296
400
|
const { payments } = catalog;
|
|
297
401
|
const agentRail = payments.x402 ? "x402" : payments.test ? "test" : null;
|
|
298
402
|
const cardRail = payments.square ? "square" : payments.card ? "card" : null;
|
|
299
403
|
const rail = args.payment === "test" ? (payments.test ? "test" : null)
|
|
300
404
|
: args.payment === "card" ? cardRail
|
|
301
405
|
: args.payment === "agent" ? agentRail
|
|
302
|
-
: (
|
|
406
|
+
: opts.cardsOnly ? (cardRail ?? (payments.test ? "test" : null))
|
|
407
|
+
: (agentRail ?? cardRail);
|
|
303
408
|
if (!rail) {
|
|
304
409
|
throw new ToolError(args.payment === "test" ? "This shop doesn't take test payments."
|
|
305
410
|
: args.payment === "card" ? "This shop doesn't take cards."
|
|
306
|
-
: "This shop doesn't take
|
|
411
|
+
: opts.cardsOnly ? "This shop doesn't take card payments here."
|
|
412
|
+
: "This shop doesn't take agent payments.");
|
|
307
413
|
}
|
|
308
414
|
const data = await call(shop, "/api/orders", {
|
|
309
415
|
method: "POST",
|
|
@@ -338,55 +444,56 @@ export function createPickupMcpServer(opts) {
|
|
|
338
444
|
}
|
|
339
445
|
return reply(`Order ${order.id}: ${what}. Confirm with the user, then call pay_order.${keep}`, { shop, orderId: order.id, claim, total: money(order.totalCents, order.currency), items: order.items, payUrl: next.url, collectBy: order.collectBy }, await cardFor(shop, order, { claim, rail }));
|
|
340
446
|
}));
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (status.order.status
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
`
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
(paid.
|
|
388
|
-
|
|
389
|
-
|
|
447
|
+
if (!opts.cardsOnly)
|
|
448
|
+
server.registerTool("pay_order", {
|
|
449
|
+
title: "Pay for an order",
|
|
450
|
+
description: opts.payer
|
|
451
|
+
? `Pay an order in USDC over x402 from wallet ${opts.payer.address}. This spends money: confirm the items and total with the user first. Returns the pickup pass link.`
|
|
452
|
+
: "No wallet is set up here, so this returns the price and pay link for another x402 client to pay.",
|
|
453
|
+
inputSchema: { order_id: z.string(), ...shopArg },
|
|
454
|
+
annotations: { title: "Pay for an order", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
455
|
+
}, guarded(async (args) => {
|
|
456
|
+
const shop = await shopFor(args.shop_url);
|
|
457
|
+
const status = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
|
|
458
|
+
if (status.order.rail === "square" || status.order.rail === "card") {
|
|
459
|
+
if (status.order.status !== "awaiting_payment")
|
|
460
|
+
return reply(`Order ${args.order_id} is already paid (${status.order.status}).`, { orderId: args.order_id, status: status.order.status });
|
|
461
|
+
return reply(status.checkoutUrl
|
|
462
|
+
? `This order is paid by card: give the user ${status.checkoutUrl} to pay on.`
|
|
463
|
+
: "This order is paid by card, on the checkout link create_pickup_order returned.", { orderId: args.order_id, payBy: "card", checkoutUrl: status.checkoutUrl ?? null });
|
|
464
|
+
}
|
|
465
|
+
const payUrl = `${shop}/api/orders/${encodeURIComponent(args.order_id)}/pay`;
|
|
466
|
+
if (!opts.payer) {
|
|
467
|
+
const res = await doFetch(new Request(payUrl, { method: "POST" }));
|
|
468
|
+
if (res.status !== 402)
|
|
469
|
+
throw new ToolError(await errorOf(res, "This order isn't waiting for an x402 payment."));
|
|
470
|
+
// x402 v2 carries the requirements in the PAYMENT-REQUIRED header; older servers put them in the body.
|
|
471
|
+
const header = res.headers.get("PAYMENT-REQUIRED");
|
|
472
|
+
const required = (header ? JSON.parse(Buffer.from(header, "base64").toString("utf8")) : await res.json());
|
|
473
|
+
const accept = required.accepts?.[0];
|
|
474
|
+
// Other ways to finish, for an agent with no wallet of its own (a chat app on the web, say).
|
|
475
|
+
const { payments } = (await call(shop, "/api/catalog").catch(() => ({ payments: {} })));
|
|
476
|
+
const others = [
|
|
477
|
+
...(payments.square || payments.card ? ['payment "card" gives the user a checkout link to pay on'] : []),
|
|
478
|
+
...(payments.test ? ['payment "test" runs the whole flow with no money moving'] : []),
|
|
479
|
+
];
|
|
480
|
+
return reply(`Waiting for payment: ${usd(accept?.amount)} USDC on ${accept?.network}. No wallet is set up in this server: pay the link ` +
|
|
481
|
+
`from the user's own x402 wallet (such as Coinbase's agent wallet), or set AGENTPICKUP_WALLET_KEY where this server runs.` +
|
|
482
|
+
(others.length ? ` Or create the order again with create_pickup_order: ${others.join("; ")}.` : ""), { orderId: args.order_id, payUrl, amount: usd(accept?.amount), network: accept?.network, asset: accept?.asset, payTo: accept?.payTo });
|
|
483
|
+
}
|
|
484
|
+
const paid = await opts.payer.pay({ id: args.order_id, payUrl }, doFetch);
|
|
485
|
+
if (!paid.ok) {
|
|
486
|
+
// A demo or test shop can still show the user the whole flow, with a test payment instead.
|
|
487
|
+
const { payments } = (await call(shop, "/api/catalog").catch(() => ({ payments: {} })));
|
|
488
|
+
throw new ToolError(paid.error +
|
|
489
|
+
(payments.test
|
|
490
|
+
? ' This shop also takes test payments, which move no money: call create_pickup_order again with payment "test" to run the whole flow.'
|
|
491
|
+
: ""));
|
|
492
|
+
}
|
|
493
|
+
return reply(`Paid ${paid.amount ?? ""} USDC. Pickup pass: ${paid.passUrl}` +
|
|
494
|
+
(paid.emailedTo ? ` (also emailed to ${paid.emailedTo})` : "") +
|
|
495
|
+
`. The user shows its QR at the counter.`, { orderId: args.order_id, ...paid, ok: undefined });
|
|
496
|
+
}));
|
|
390
497
|
server.registerTool("order_status", {
|
|
391
498
|
title: "Check an order",
|
|
392
499
|
description: "Whether an order is waiting for payment, paid, ready to collect, collected or expired. With the order's claim, " +
|
|
@@ -399,7 +506,7 @@ export function createPickupMcpServer(opts) {
|
|
|
399
506
|
const { order, checkoutUrl } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
|
|
400
507
|
const words = {
|
|
401
508
|
awaiting_payment: "is waiting for payment",
|
|
402
|
-
paid: "is paid and
|
|
509
|
+
paid: order.acceptedAt ? "is paid and the shop is making it" : "is paid; the shop has it but hasn't started it yet",
|
|
403
510
|
ready: "is ready to collect",
|
|
404
511
|
collected: "has been collected",
|
|
405
512
|
expired: "expired before anyone collected it",
|
|
@@ -450,7 +557,7 @@ export function createPickupMcpServer(opts) {
|
|
|
450
557
|
server.registerTool("share_agentpickup", {
|
|
451
558
|
title: "Share agentPickup",
|
|
452
559
|
description: "A QR code and link the user can share so friends can add agentPickup to Claude, ChatGPT or Grok and order too. " +
|
|
453
|
-
"Use when the user asks how to share agentPickup, invite friends, or get others using it. Name a shop to share it with that
|
|
560
|
+
"Use when the user asks how to share agentPickup, invite friends, or get others using it. Name a shop to share it with that shop picked.",
|
|
454
561
|
inputSchema: { ...shopArg },
|
|
455
562
|
annotations: { title: "Share agentPickup", readOnlyHint: true, openWorldHint: false },
|
|
456
563
|
_meta: SHARE_TOOL_META,
|
|
@@ -461,7 +568,7 @@ export function createPickupMcpServer(opts) {
|
|
|
461
568
|
const card = {
|
|
462
569
|
v: 1,
|
|
463
570
|
...links,
|
|
464
|
-
|
|
571
|
+
qr: qrBits(links.url),
|
|
465
572
|
...(name ? { shopName: name } : {}),
|
|
466
573
|
};
|
|
467
574
|
return {
|
|
@@ -469,9 +576,11 @@ export function createPickupMcpServer(opts) {
|
|
|
469
576
|
{
|
|
470
577
|
type: "text",
|
|
471
578
|
text: `Share this link: ${links.url}. A friend opens it (or scans its QR off the user's screen), adds agentPickup to Claude, ChatGPT or Grok, ` +
|
|
472
|
-
`then just asks for their usual${name ? ` at ${name}` : ""}. ${links.sharePageUrl} opens the phone's share sheet and has a sticker to print
|
|
579
|
+
`then just asks for their usual${name ? ` at ${name}` : ""}. ${links.sharePageUrl} opens the phone's share sheet and has a sticker to print. ` +
|
|
580
|
+
`News and new shops: @agentPickup on X, https://x.com/agentPickup.`,
|
|
473
581
|
},
|
|
474
582
|
],
|
|
583
|
+
structuredContent: { share: card },
|
|
475
584
|
_meta: { [SHARE_META_KEY]: card },
|
|
476
585
|
};
|
|
477
586
|
}));
|
|
@@ -483,83 +592,87 @@ export function createPickupMcpServer(opts) {
|
|
|
483
592
|
const paid = g.members.filter((m) => m.status !== "awaiting_payment" && m.status !== "cancelled");
|
|
484
593
|
return `${g.title} (code ${g.code}), ${closesIn(g)}: ${paid.length} of ${g.members.length} paid, ${money(g.paidCents, g.currency)} so far.`;
|
|
485
594
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
595
|
+
if (!opts.cardsOnly)
|
|
596
|
+
server.registerTool("start_group_order", {
|
|
597
|
+
title: "Start a group order",
|
|
598
|
+
description: "Start a group order (a lunch or coffee run) at a shop, when the user wants to order together with others now. Returns a code to " +
|
|
599
|
+
"share: each person's agent joins with it and pays for their own items. When it closes, the shop gets one combined order " +
|
|
600
|
+
"and the organiser is emailed its pickup QR. To share agentPickup itself with a friend, use share_agentpickup instead.",
|
|
601
|
+
inputSchema: {
|
|
602
|
+
organiser_name: z.string().min(1).max(60).describe("Who collects, shown to staff"),
|
|
603
|
+
organiser_email: z.string().email().describe("Where the group's pickup QR goes"),
|
|
604
|
+
title: z.string().max(60).optional().describe('e.g. "Friday coffee run"'),
|
|
605
|
+
closes_in_minutes: z.number().int().min(5).max(240).optional().describe("Default 15"),
|
|
606
|
+
...shopArg,
|
|
607
|
+
},
|
|
608
|
+
annotations: { title: "Start a group order", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
609
|
+
}, guarded(async (args) => {
|
|
610
|
+
const shop = await shopFor(args.shop_url);
|
|
611
|
+
const data = (await call(shop, "/api/groups", {
|
|
612
|
+
method: "POST",
|
|
613
|
+
headers: { "Content-Type": "application/json" },
|
|
614
|
+
body: JSON.stringify({ name: args.organiser_name, email: args.organiser_email, title: args.title, closesInMinutes: args.closes_in_minutes }),
|
|
615
|
+
}));
|
|
616
|
+
return reply(`Started ${data.group.title}: share code ${data.group.code}. Everyone's agent joins with join_group_order and the code; it ${closesIn(data.group)}. ` +
|
|
617
|
+
`Keep organiser_key to close it early.`, { shop, code: data.group.code, closesAt: data.group.closesAt, organiser_key: data.organiserKey });
|
|
505
618
|
}));
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
},
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
619
|
+
if (!opts.cardsOnly)
|
|
620
|
+
server.registerTool("join_group_order", {
|
|
621
|
+
title: "Join a group order",
|
|
622
|
+
description: "Add the user's items to a group order with its code. Returns the order id; pay it with pay_order before the group closes. " +
|
|
623
|
+
"The organiser collects everything, so the user gets a receipt, not a QR.",
|
|
624
|
+
inputSchema: {
|
|
625
|
+
code: z.string().min(4).max(12).describe("The group's code"),
|
|
626
|
+
items: z.array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) })).min(1),
|
|
627
|
+
email: z.string().email().describe("The user's email, for their receipt"),
|
|
628
|
+
name: z.string().min(1).max(60).describe("The user's name, shown to staff on their items"),
|
|
629
|
+
...shopArg,
|
|
630
|
+
},
|
|
631
|
+
annotations: { title: "Join a group order", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
632
|
+
}, guarded(async (args) => {
|
|
633
|
+
const shop = await shopFor(args.shop_url);
|
|
634
|
+
const { payments } = (await call(shop, "/api/catalog"));
|
|
635
|
+
const rail = payments.x402 ? "x402" : payments.test ? "test" : null;
|
|
636
|
+
if (!rail)
|
|
637
|
+
throw new ToolError("This shop doesn't take agent payments.");
|
|
638
|
+
const data = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}/orders`, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
headers: { "Content-Type": "application/json" },
|
|
641
|
+
body: JSON.stringify({ items: args.items, email: args.email, name: args.name, rail }),
|
|
642
|
+
}));
|
|
643
|
+
const what = `${data.order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(data.order.totalCents, data.order.currency)}`;
|
|
644
|
+
return reply(rail === "test"
|
|
645
|
+
? `Added to ${data.group.title}: ${what}, paid with a test payment (no money moved). ${data.group.organiser} collects it.`
|
|
646
|
+
: `Added to ${data.group.title}: ${what}. Confirm with the user, then pay it with pay_order (order ${data.order.id}) before the group closes.`, { shop, orderId: data.order.id, group: groupSummary(data.group), payUrl: data.next.url ?? null, test: rail === "test" });
|
|
531
647
|
}));
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
:
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
648
|
+
if (!opts.cardsOnly)
|
|
649
|
+
server.registerTool("group_order_status", {
|
|
650
|
+
title: "Check a group order",
|
|
651
|
+
description: "Who's in a group order, what they ordered, who has paid, and whether it has closed and been collected.",
|
|
652
|
+
inputSchema: { code: z.string().min(4).max(12), ...shopArg },
|
|
653
|
+
annotations: { title: "Check a group order", readOnlyHint: true, openWorldHint: true },
|
|
654
|
+
}, guarded(async (args) => {
|
|
655
|
+
const shop = await shopFor(args.shop_url);
|
|
656
|
+
const { group } = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}`));
|
|
657
|
+
return reply(groupSummary(group) + (group.combined ? ` The shop's order is ${group.combined.status}.` : ""), group);
|
|
658
|
+
}));
|
|
659
|
+
if (!opts.cardsOnly)
|
|
660
|
+
server.registerTool("close_group_order", {
|
|
661
|
+
title: "Close a group order now",
|
|
662
|
+
description: "Close a group order early, as its organiser. Unpaid shares are left out; the shop gets the rest as one order.",
|
|
663
|
+
inputSchema: { code: z.string().min(4).max(12), organiser_key: z.string().min(8), ...shopArg },
|
|
664
|
+
annotations: { title: "Close a group order now", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
665
|
+
}, guarded(async (args) => {
|
|
666
|
+
const shop = await shopFor(args.shop_url);
|
|
667
|
+
const { group } = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}/close`, {
|
|
668
|
+
method: "POST",
|
|
669
|
+
headers: { "Content-Type": "application/json" },
|
|
670
|
+
body: JSON.stringify({ organiserKey: args.organiser_key }),
|
|
671
|
+
}));
|
|
672
|
+
return reply(group.status === "sent"
|
|
673
|
+
? `Closed: ${groupSummary(group)} The shop has the combined order, and ${group.organiser}'s pickup QR is on its way by email.`
|
|
674
|
+
: `Closed with nobody paid, so the group order was cancelled.`, group);
|
|
558
675
|
}));
|
|
559
|
-
return reply(group.status === "sent"
|
|
560
|
-
? `Closed: ${groupSummary(group)} The shop has the combined order, and ${group.organiser}'s pickup QR is on its way by email.`
|
|
561
|
-
: `Closed with nobody paid, so the group order was cancelled.`, group);
|
|
562
|
-
}));
|
|
563
676
|
return server;
|
|
564
677
|
}
|
|
565
678
|
/** A Streamable HTTP endpoint (stateless, JSON responses) for hosting the server, e.g. at /mcp. */
|