@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/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.4.3";
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. Shared across the
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 rememberShop = (shop, name) => {
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
- /** The QR in ink on transparent: the card's tile behind it is white, or lime once the order is ready. */
29
- const qrSvg = (content) => QRCode.toString(content, { type: "svg", errorCorrectionLevel: "M", margin: 0, color: { dark: "#0a0b0dff", light: "#00000000" } });
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
- rememberShop(shop, data?.merchant?.name);
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, qrSvg: await qrSvg(pass.qrContent) } : {}),
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
- }, { instructions: opts.instructions ? `${INSTRUCTIONS}\n${opts.instructions}` : INSTRUCTIONS });
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 "croissant". Leave out to list everything.'),
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 { shops } = (await res.json());
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: { x402: s.payments.x402?.network ?? null, test: s.payments.test },
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
- ? "Doesn't take agent payments."
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("; ")}.`), { shop, merchant: merchant.name, location: merchant.location, currency, items, x402: payments.x402 ?? null, cards, test: Boolean(payments.test) });
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: "Create an order to collect in person. Returns the order id, total and a claim (keep it); pay it with pay_order. " +
273
- "The shop emails the pickup pass to `email` once it's paid, and where the chat shows cards the order shows as one " +
274
- "that becomes the pass. Where the shop takes cards, `payment: \"card\"` returns a checkout link for the user to pay on instead.",
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: z
282
- .enum(["agent", "card", "test"])
283
- .optional()
284
- .describe("Who pays: agent (USDC over x402, or a test payment at a test shop), card (the user pays on a checkout link), " +
285
- "or test (where the shop takes test payments: the whole flow with no money moving). Default: agent where the shop takes it, else card."),
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
- annotations: { title: "Order for pickup", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
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
- : (agentRail ?? cardRail);
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 agent payments.");
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
- server.registerTool("pay_order", {
342
- title: "Pay for an order",
343
- description: opts.payer
344
- ? `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.`
345
- : "No wallet is set up here, so this returns the price and pay link for another x402 client to pay.",
346
- inputSchema: { order_id: z.string(), ...shopArg },
347
- annotations: { title: "Pay for an order", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
348
- }, guarded(async (args) => {
349
- const shop = await shopFor(args.shop_url);
350
- const status = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
351
- if (status.order.rail === "square" || status.order.rail === "card") {
352
- if (status.order.status !== "awaiting_payment")
353
- return reply(`Order ${args.order_id} is already paid (${status.order.status}).`, { orderId: args.order_id, status: status.order.status });
354
- return reply(status.checkoutUrl
355
- ? `This order is paid by card: give the user ${status.checkoutUrl} to pay on.`
356
- : "This order is paid by card, on the checkout link create_pickup_order returned.", { orderId: args.order_id, payBy: "card", checkoutUrl: status.checkoutUrl ?? null });
357
- }
358
- const payUrl = `${shop}/api/orders/${encodeURIComponent(args.order_id)}/pay`;
359
- if (!opts.payer) {
360
- const res = await doFetch(new Request(payUrl, { method: "POST" }));
361
- if (res.status !== 402)
362
- throw new ToolError(await errorOf(res, "This order isn't waiting for an x402 payment."));
363
- // x402 v2 carries the requirements in the PAYMENT-REQUIRED header; older servers put them in the body.
364
- const header = res.headers.get("PAYMENT-REQUIRED");
365
- const required = (header ? JSON.parse(Buffer.from(header, "base64").toString("utf8")) : await res.json());
366
- const accept = required.accepts?.[0];
367
- // Other ways to finish, for an agent with no wallet of its own (a chat app on the web, say).
368
- const { payments } = (await call(shop, "/api/catalog").catch(() => ({ payments: {} })));
369
- const others = [
370
- ...(payments.square || payments.card ? ['payment "card" gives the user a checkout link to pay on'] : []),
371
- ...(payments.test ? ['payment "test" runs the whole flow with no money moving'] : []),
372
- ];
373
- return reply(`Waiting for payment: ${usd(accept?.amount)} USDC on ${accept?.network}. No wallet is set up in this server: pay the link ` +
374
- `from the user's own x402 wallet (such as Coinbase's agent wallet), or set AGENTPICKUP_WALLET_KEY where this server runs.` +
375
- (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 });
376
- }
377
- const paid = await opts.payer.pay({ id: args.order_id, payUrl }, doFetch);
378
- if (!paid.ok) {
379
- // A demo or test shop can still show the user the whole flow, with a test payment instead.
380
- const { payments } = (await call(shop, "/api/catalog").catch(() => ({ payments: {} })));
381
- throw new ToolError(paid.error +
382
- (payments.test
383
- ? ' This shop also takes test payments, which move no money: call create_pickup_order again with payment "test" to run the whole flow.'
384
- : ""));
385
- }
386
- return reply(`Paid ${paid.amount ?? ""} USDC. Pickup pass: ${paid.passUrl}` +
387
- (paid.emailedTo ? ` (also emailed to ${paid.emailedTo})` : "") +
388
- `. The user shows its QR at the counter.`, { orderId: args.order_id, ...paid, ok: undefined });
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 being prepared",
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 café picked.",
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
- qrSvg: await QRCode.toString(links.url, { type: "svg", errorCorrectionLevel: "M", margin: 0, color: { dark: "#0a0b0dff", light: "#00000000" } }),
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
- server.registerTool("start_group_order", {
487
- title: "Start a group order",
488
- description: "Start a group order (a coffee run) at a shop, when the user wants to order together with others now. Returns a code to " +
489
- "share: each person's agent joins with it and pays for their own items. When it closes, the shop gets one combined order " +
490
- "and the organiser is emailed its pickup QR. To share agentPickup itself with a friend, use share_agentpickup instead.",
491
- inputSchema: {
492
- organiser_name: z.string().min(1).max(60).describe("Who collects, shown to staff"),
493
- organiser_email: z.string().email().describe("Where the group's pickup QR goes"),
494
- title: z.string().max(60).optional().describe('e.g. "Friday coffee run"'),
495
- closes_in_minutes: z.number().int().min(5).max(240).optional().describe("Default 15"),
496
- ...shopArg,
497
- },
498
- annotations: { title: "Start a group order", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
499
- }, guarded(async (args) => {
500
- const shop = await shopFor(args.shop_url);
501
- const data = (await call(shop, "/api/groups", {
502
- method: "POST",
503
- headers: { "Content-Type": "application/json" },
504
- body: JSON.stringify({ name: args.organiser_name, email: args.organiser_email, title: args.title, closesInMinutes: args.closes_in_minutes }),
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
- 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)}. ` +
507
- `Keep organiser_key to close it early.`, { shop, code: data.group.code, closesAt: data.group.closesAt, organiser_key: data.organiserKey });
508
- }));
509
- server.registerTool("join_group_order", {
510
- title: "Join a group order",
511
- 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. " +
512
- "The organiser collects everything, so the user gets a receipt, not a QR.",
513
- inputSchema: {
514
- code: z.string().min(4).max(12).describe("The group's code"),
515
- items: z.array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) })).min(1),
516
- email: z.string().email().describe("The user's email, for their receipt"),
517
- name: z.string().min(1).max(60).describe("The user's name, shown to staff on their items"),
518
- ...shopArg,
519
- },
520
- annotations: { title: "Join a group order", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
521
- }, guarded(async (args) => {
522
- const shop = await shopFor(args.shop_url);
523
- const { payments } = (await call(shop, "/api/catalog"));
524
- const rail = payments.x402 ? "x402" : payments.test ? "test" : null;
525
- if (!rail)
526
- throw new ToolError("This shop doesn't take agent payments.");
527
- const data = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}/orders`, {
528
- method: "POST",
529
- headers: { "Content-Type": "application/json" },
530
- body: JSON.stringify({ items: args.items, email: args.email, name: args.name, rail }),
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
- const what = `${data.order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(data.order.totalCents, data.order.currency)}`;
533
- return reply(rail === "test"
534
- ? `Added to ${data.group.title}: ${what}, paid with a test payment (no money moved). ${data.group.organiser} collects it.`
535
- : `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" });
536
- }));
537
- server.registerTool("group_order_status", {
538
- title: "Check a group order",
539
- description: "Who's in a group order, what they ordered, who has paid, and whether it has closed and been collected.",
540
- inputSchema: { code: z.string().min(4).max(12), ...shopArg },
541
- annotations: { title: "Check a group order", readOnlyHint: true, openWorldHint: true },
542
- }, guarded(async (args) => {
543
- const shop = await shopFor(args.shop_url);
544
- const { group } = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}`));
545
- return reply(groupSummary(group) + (group.combined ? ` The shop's order is ${group.combined.status}.` : ""), group);
546
- }));
547
- server.registerTool("close_group_order", {
548
- title: "Close a group order now",
549
- description: "Close a group order early, as its organiser. Unpaid shares are left out; the shop gets the rest as one order.",
550
- inputSchema: { code: z.string().min(4).max(12), organiser_key: z.string().min(8), ...shopArg },
551
- annotations: { title: "Close a group order now", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
552
- }, guarded(async (args) => {
553
- const shop = await shopFor(args.shop_url);
554
- const { group } = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}/close`, {
555
- method: "POST",
556
- headers: { "Content-Type": "application/json" },
557
- body: JSON.stringify({ organiserKey: args.organiser_key }),
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. */