@agentpickup/mcp 0.1.1 → 0.3.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 CHANGED
@@ -1,63 +1,69 @@
1
- # @agentpickup/mcp
2
-
3
- An MCP server for [agentPickup](https://github.com/dav3trad3r/agentpickup) shops. It lets an AI agent see
4
- what a shop sells, order it, pay in USDC over x402, and hand its user the QR they show at the counter to
5
- collect.
6
-
7
- ## Tools
8
-
9
- | Tool | What it does |
10
- |---|---|
11
- | `get_shop` | Items and prices, the pickup location, and how the shop takes payment |
12
- | `create_pickup_order` | Creates an order. Needs the user's email, where the shop sends the pickup QR |
13
- | `pay_order` | Pays the order's x402 link from your wallet and returns the pass link. With no wallet set up, returns the price and pay link instead |
14
- | `order_status` | Waiting for payment, paid, ready, collected or expired |
15
- | `get_pickup_pass` | Fetches the pass link, pickup code and QR again by signing in as the paying wallet (Sign-In-With-X) |
16
-
17
- ## Use it
18
-
19
- Try it against the hosted demo shop first. It pays with the demo's own small wallet, within the demo's caps:
20
-
21
- ```json
22
- { "mcpServers": { "agentpickup": { "type": "http", "url": "https://agentpickup.app/mcp" } } }
23
- ```
24
-
25
- Or run it locally against any agentPickup shop, with your own wallet:
26
-
27
- ```json
28
- {
29
- "mcpServers": {
30
- "agentpickup": {
31
- "command": "npx",
32
- "args": ["-y", "@agentpickup/mcp"],
33
- "env": {
34
- "AGENTPICKUP_SHOP_URL": "https://orders.yourshop.com",
35
- "AGENTPICKUP_WALLET_KEY": "0x…",
36
- "AGENTPICKUP_MAX_PER_ORDER": "$25"
37
- }
38
- }
39
- }
40
- }
41
- ```
42
-
43
- | Setting | Default | |
44
- |---|---|---|
45
- | `AGENTPICKUP_SHOP_URL` | the demo shop | The shop to order from. Tool calls can also name another shop with `shop_url` |
46
- | `AGENTPICKUP_WALLET_KEY` | none | An EVM private key holding USDC. Without it, `pay_order` only quotes |
47
- | `AGENTPICKUP_MAX_PER_ORDER` | `$25` | The most the wallet pays for one order |
48
- | `AGENTPICKUP_NETWORKS` | `eip155:84532` | Networks the wallet may pay on. Base Sepolia (test USDC) unless you add `eip155:8453` for Base mainnet |
49
-
50
- Use a wallet that holds only what you're willing to let an agent spend. The tools tell the model to confirm
51
- items and the total with the user before paying.
52
-
53
- ## Host it in your own server
54
-
55
- ```ts
56
- import { createMcpHandler, walletPayer } from "@agentpickup/mcp";
57
-
58
- const mcp = createMcpHandler({ shopUrl: "https://orders.yourshop.com" }); // no wallet: pay_order quotes
59
- app.all("/mcp", (c) => mcp(c.req.raw)); // Streamable HTTP, stateless, any Request/Response runtime
60
- ```
61
-
62
- Node 22.13 or later. Licence: FSL-1.1-ALv2 (free to use and self-host; becomes Apache-2.0 two years after
63
- each release).
1
+ # @agentpickup/mcp
2
+
3
+ An MCP server for [agentPickup](https://github.com/dav3trad3r/agentpickup) shops. It lets an AI agent find
4
+ pickup shops near its user, see what they sell, order, pay in USDC over x402, and hand its user the QR they
5
+ show at the counter to collect.
6
+
7
+ ## Tools
8
+
9
+ | Tool | What it does |
10
+ |---|---|
11
+ | `find_shops` | Shops near a point (`lat`/`lng`), in a suburb (`place`) or selling something (`query`), from the agentPickup directory: each one's `shop_url`, address, hours, open now, distance and matching items |
12
+ | `get_shop` | Items and prices, the pickup location, and how the shop takes payment |
13
+ | `create_pickup_order` | Creates an order. Needs the user's email, where the shop sends the receipt and pickup QR. A test shop marks it paid straight away, with no money moving |
14
+ | `pay_order` | Pays the order's x402 link from your wallet and returns the pass link. With no wallet set up, returns the price and pay link instead |
15
+ | `order_status` | Waiting for payment, paid, ready, collected or expired |
16
+ | `get_pickup_pass` | Fetches the pass link, pickup code and QR again by signing in as the paying wallet (Sign-In-With-X) |
17
+ | `start_group_order` | Starts a group order (a coffee run) with a closing time; returns the code to share and a key to close it early |
18
+ | `join_group_order` | Adds the user's items to a group order by its code; pay the share with `pay_order` before it closes |
19
+ | `group_order_status` | Who's in, what they ordered, who has paid, and whether the shop's order is ready or collected |
20
+ | `close_group_order` | Closes a group order early, as its organiser; the shop gets one combined order and the organiser its QR |
21
+
22
+ ## Use it
23
+
24
+ Try it against the hosted demo shop first. It pays with the demo's own small wallet, within the demo's caps:
25
+
26
+ ```json
27
+ { "mcpServers": { "agentpickup": { "type": "http", "url": "https://agentpickup.app/mcp" } } }
28
+ ```
29
+
30
+ Or run it locally against any agentPickup shop, with your own wallet:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "agentpickup": {
36
+ "command": "npx",
37
+ "args": ["-y", "@agentpickup/mcp"],
38
+ "env": {
39
+ "AGENTPICKUP_SHOP_URL": "https://orders.yourshop.com",
40
+ "AGENTPICKUP_WALLET_KEY": "0x…",
41
+ "AGENTPICKUP_MAX_PER_ORDER": "$25"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ | Setting | Default | |
49
+ |---|---|---|
50
+ | `AGENTPICKUP_SHOP_URL` | the demo shop | The shop to order from. Tool calls can also name another shop with `shop_url` |
51
+ | `AGENTPICKUP_WALLET_KEY` | none | An EVM private key holding USDC. Without it, `pay_order` only quotes |
52
+ | `AGENTPICKUP_MAX_PER_ORDER` | `$25` | The most the wallet pays for one order |
53
+ | `AGENTPICKUP_NETWORKS` | `eip155:84532` | Networks the wallet may pay on. Base Sepolia (test USDC) unless you add `eip155:8453` for Base mainnet |
54
+ | `AGENTPICKUP_DIRECTORY_URL` | `https://agentpickup.app/directory/v1/shops` | Where `find_shops` searches. `off` leaves the tool out |
55
+
56
+ Use a wallet that holds only what you're willing to let an agent spend. The tools tell the model to confirm
57
+ items and the total with the user before paying.
58
+
59
+ ## Host it in your own server
60
+
61
+ ```ts
62
+ import { createMcpHandler, walletPayer } from "@agentpickup/mcp";
63
+
64
+ const mcp = createMcpHandler({ shopUrl: "https://orders.yourshop.com" }); // no wallet: pay_order quotes
65
+ app.all("/mcp", (c) => mcp(c.req.raw)); // Streamable HTTP, stateless, any Request/Response runtime
66
+ ```
67
+
68
+ Node 22.13 or later. Licence: FSL-1.1-ALv2 (free to use and self-host; becomes Apache-2.0 two years after
69
+ each release).
package/dist/cli.js CHANGED
@@ -17,6 +17,9 @@ const payer = key
17
17
  networks: env.AGENTPICKUP_NETWORKS?.split(",").map((n) => n.trim()).filter(Boolean),
18
18
  })
19
19
  : undefined;
20
- const server = createPickupMcpServer({ shopUrl, allowOtherShops: true, payer });
20
+ // The public agentPickup directory, for find_shops. "off" leaves the tool out.
21
+ const directory = env.AGENTPICKUP_DIRECTORY_URL || "https://agentpickup.app/directory/v1/shops";
22
+ const directoryUrl = directory === "off" ? undefined : directory;
23
+ const server = createPickupMcpServer({ shopUrl, allowOtherShops: true, payer, directoryUrl });
21
24
  await server.connect(new StdioServerTransport());
22
25
  console.error(`agentPickup MCP · shop ${shopUrl} · ${payer ? `wallet ${payer.address}` : "no wallet (pay_order returns the pay link)"}`);
package/dist/server.d.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { type Fetch, type Payer } from "./wallet.js";
3
- export declare const VERSION = "0.1.1";
3
+ export declare const VERSION = "0.3.0";
4
4
  export interface PickupMcpOptions {
5
5
  /** The agentPickup shop this server orders from. */
6
6
  shopUrl: string;
7
- /** Let tool calls name another agentPickup shop. Leave off for hosted servers, so callers can't point it anywhere. */
7
+ /**
8
+ * Let tool calls name any agentPickup shop. Leave off for hosted servers, so callers can't point it anywhere:
9
+ * they can still name shops on the same host (venues at /v/<slug>), as find_shops returns them.
10
+ */
8
11
  allowOtherShops?: boolean;
12
+ /** An agentPickup directory (<host>/directory/v1/shops). Adds find_shops: shops near someone that sell what they want. */
13
+ directoryUrl?: string;
14
+ /**
15
+ * Shops on other hosts a hosted server may still order from, such as agentPickup shops its directory has checked.
16
+ * Called with the shop's base URL.
17
+ */
18
+ trustedShop?: (shopBase: string) => boolean | Promise<boolean>;
9
19
  /** How requests reach the shop. Defaults to global fetch; a shop hosting its own server passes its app's fetch. */
10
20
  fetch?: Fetch;
11
21
  /** The wallet that pays. Without one, pay_order returns the price and pay link for another x402 client. */
package/dist/server.js CHANGED
@@ -1,27 +1,37 @@
1
- // The agentPickup MCP server: lets any MCP client (Claude, ChatGPT, Cursor, an agent framework) find what a
2
- // pickup shop sells, order it, pay over x402, and hand its user the QR to collect with.
1
+ // The agentPickup MCP server: lets any MCP client (Claude, ChatGPT, Cursor, an agent framework) find pickup
2
+ // shops near someone, see what they sell, order, pay over x402, and hand its user the QR to collect with.
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5
5
  import { z } from "zod";
6
6
  import { errorOf, usd } from "./wallet.js";
7
- export const VERSION = "0.1.1";
7
+ export const VERSION = "0.3.0";
8
8
  class ToolError extends Error {
9
9
  }
10
- const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
11
- 1. get_shop: see what the shop sells, its pickup location and how it takes payment.
12
- 2. create_pickup_order: needs the user's email, where the shop sends the pickup QR.
13
- 3. pay_order: pays from the configured wallet in USDC over x402. Confirm the items and total with the user first.
14
- 4. Give the user the pass link and pickup code. They show the QR at the counter to collect.
15
- get_pickup_pass fetches the pass again later; order_status says whether it's ready or collected.`;
10
+ const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
11
+ 0. find_shops (when available): shops near the user that sell what they want. Pass a shop's shop_url to the other tools.
12
+ 1. get_shop: see what the shop sells, its pickup location and how it takes payment.
13
+ 2. create_pickup_order: needs the user's email, where the shop sends the pickup QR.
14
+ 3. pay_order: pays from the configured wallet in USDC over x402. Confirm the items and total with the user first.
15
+ 4. Give the user the pass link and pickup code. They show the QR at the counter to collect.
16
+ get_pickup_pass fetches the pass again later; order_status says whether it's ready or collected.
17
+ Group orders (a coffee run): start_group_order gives a code to share; each person's agent calls join_group_order and
18
+ pays its own share with pay_order; at closing time the shop gets one combined order and the organiser gets its QR.
19
+ group_order_status shows who's in; close_group_order closes it early.`;
16
20
  const money = (cents, currency) => new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
17
21
  const reply = (summary, data) => ({
18
22
  content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(data, null, 2)}` }],
19
23
  });
20
24
  const failure = (message) => ({ content: [{ type: "text", text: message }], isError: true });
25
+ /** A shop's base URL: shops can live under a path (https://host/v/cafe). A pasted API or pass link works too. */
26
+ const shopBase = (url) => {
27
+ const path = url.pathname.replace(/\/+$/, "");
28
+ return url.origin + (path.match(/^(.*)\/(?:api|p|c)\/.+$/)?.[1] ?? path.replace(/\/staff$/, ""));
29
+ };
21
30
  export function createPickupMcpServer(opts) {
22
- const home = new URL(opts.shopUrl).origin;
31
+ const homeUrl = new URL(opts.shopUrl);
32
+ const home = shopBase(homeUrl);
23
33
  const doFetch = opts.fetch ?? ((req) => fetch(req));
24
- function shopFor(shopUrl) {
34
+ async function shopFor(shopUrl) {
25
35
  if (!shopUrl)
26
36
  return home;
27
37
  let url;
@@ -31,13 +41,17 @@ export function createPickupMcpServer(opts) {
31
41
  catch {
32
42
  throw new ToolError(`"${shopUrl}" isn't a URL.`);
33
43
  }
34
- if (url.origin === home)
35
- return home;
36
- if (!opts.allowOtherShops)
37
- throw new ToolError(`This server orders from one shop only: ${home}.`);
38
- if (url.protocol !== "https:" && url.hostname !== "localhost")
39
- throw new ToolError("Shops must be served over https.");
40
- return url.origin;
44
+ // Shops on this server's own host: the home shop and its venues.
45
+ if (url.origin === homeUrl.origin)
46
+ return shopBase(url);
47
+ if (opts.allowOtherShops || opts.trustedShop) {
48
+ if (url.protocol !== "https:" && url.hostname !== "localhost")
49
+ throw new ToolError("Shops must be served over https.");
50
+ if (opts.allowOtherShops || (await opts.trustedShop(shopBase(url))))
51
+ return shopBase(url);
52
+ throw new ToolError(`This server orders from shops on ${homeUrl.origin} and agentPickup shops its directory lists.`);
53
+ }
54
+ throw new ToolError(`This server orders from shops on ${homeUrl.origin} only.`);
41
55
  }
42
56
  async function call(shop, path, init) {
43
57
  const res = await doFetch(new Request(`${shop}${path}`, init));
@@ -56,17 +70,79 @@ export function createPickupMcpServer(opts) {
56
70
  return failure(`Couldn't reach the shop: ${err instanceof Error ? err.message : String(err)}`);
57
71
  }
58
72
  };
59
- const shopArg = opts.allowOtherShops
60
- ? { shop_url: z.string().optional().describe(`An agentPickup shop's URL. Defaults to ${home}.`) }
73
+ const shopArg = opts.allowOtherShops || opts.directoryUrl
74
+ ? { shop_url: z.string().optional().describe(`An agentPickup shop's URL, as find_shops returns it. Defaults to ${home}.`) }
61
75
  : {};
62
76
  const server = new McpServer({ name: "agentpickup", title: "agentPickup", version: VERSION }, { instructions: INSTRUCTIONS });
77
+ if (opts.directoryUrl) {
78
+ const directoryUrl = opts.directoryUrl;
79
+ server.registerTool("find_shops", {
80
+ title: "Find pickup shops",
81
+ description: "Find agentPickup shops to order from: near a point (lat/lng), in a suburb or city (place), or selling something (query). " +
82
+ "Returns each shop's shop_url, pickup address, hours, whether it's open now, distance, and matching menu items.",
83
+ inputSchema: {
84
+ query: z.string().max(100).optional().describe('What the user wants, e.g. "flat white" or "croissant". Leave out to list everything.'),
85
+ place: z.string().max(100).optional().describe('A suburb, street or city to match against shop addresses, e.g. "Surry Hills".'),
86
+ lat: z.number().min(-90).max(90).optional().describe("Where the user is: latitude. Send with lng."),
87
+ lng: z.number().min(-180).max(180).optional().describe("Where the user is: longitude. Send with lat."),
88
+ radius_km: z.number().positive().max(100).optional().describe("With lat/lng: how far to look. Default 10."),
89
+ open_now: z.boolean().optional().describe("Only shops open now (shops without listed hours stay in)."),
90
+ limit: z.number().int().min(1).max(50).optional(),
91
+ },
92
+ annotations: { readOnlyHint: true, openWorldHint: true },
93
+ }, guarded(async (args) => {
94
+ if ((args.lat === undefined) !== (args.lng === undefined))
95
+ throw new ToolError("Send lat and lng together.");
96
+ const url = new URL(directoryUrl);
97
+ const params = { q: args.query, place: args.place, lat: args.lat, lng: args.lng, radius_km: args.radius_km, limit: args.limit };
98
+ for (const [k, v] of Object.entries(params))
99
+ if (v !== undefined && v !== "")
100
+ url.searchParams.set(k, String(v));
101
+ if (args.open_now)
102
+ url.searchParams.set("open_now", "true");
103
+ const res = await doFetch(new Request(url));
104
+ if (!res.ok)
105
+ throw new ToolError(await errorOf(res, `The directory answered ${res.status}.`));
106
+ const { shops } = (await res.json());
107
+ if (!shops.length)
108
+ return reply("No agentPickup shops match. Try a wider radius, another place, or no query.", { shops: [] });
109
+ const lines = shops.map((s) => {
110
+ const facts = [
111
+ s.distanceKm !== undefined ? `${s.distanceKm} km` : null,
112
+ s.openNow === true ? "open now" : s.openNow === false ? "closed now" : null,
113
+ s.testOnly ? "test shop, no real money" : null,
114
+ s.source?.kind === "listed" ? `listed by ${s.source.domain}` : null,
115
+ s.order?.via === "ucp" ? "orders through its UCP checkout" : s.order?.via === "web" ? "orders on its own website" : null,
116
+ ].filter(Boolean);
117
+ const items = s.items.slice(0, 3).map((i) => `${i.name} ${money(i.priceCents, s.currency)}`).join(", ");
118
+ return `- ${s.name}${facts.length ? ` (${facts.join(", ")})` : ""}${s.location.address ? `, ${s.location.address}` : ""}${items ? `: ${items}` : ""}`;
119
+ });
120
+ return reply(`${shops.length} shop${shops.length === 1 ? "" : "s"}:\n${lines.join("\n")}\nPass a shop's shop_url to get_shop or create_pickup_order.`, {
121
+ shops: shops.map((s) => ({
122
+ shop_url: s.url,
123
+ name: s.name,
124
+ location: { name: s.location.name, address: s.location.address, hours: s.location.hours },
125
+ distanceKm: s.distanceKm,
126
+ openNow: s.openNow,
127
+ testOnly: s.testOnly,
128
+ payments: { x402: s.payments.x402?.network ?? null, test: s.payments.test },
129
+ listedBy: s.source?.kind === "listed" ? s.source.domain : null,
130
+ // agentpickup: use get_shop and create_pickup_order. ucp or web: order there instead.
131
+ orderVia: s.order?.via ?? "agentpickup",
132
+ ...(s.order?.via === "ucp" ? { ucpProfile: s.order.profile } : s.order?.via === "web" ? { orderUrl: s.order.url } : {}),
133
+ items: s.items.map((i) => ({ id: i.id, name: i.name, price: money(i.priceCents, s.currency) })),
134
+ moreItems: Math.max(0, s.itemCount - s.items.length),
135
+ })),
136
+ });
137
+ }));
138
+ }
63
139
  server.registerTool("get_shop", {
64
140
  title: "See what a shop sells",
65
141
  description: "What the shop sells (item ids and prices), where to collect, and which payments it takes.",
66
142
  inputSchema: { ...shopArg },
67
143
  annotations: { readOnlyHint: true, openWorldHint: true },
68
144
  }, guarded(async (args) => {
69
- const shop = shopFor(args.shop_url);
145
+ const shop = await shopFor(args.shop_url);
70
146
  const data = await call(shop, "/api/catalog");
71
147
  const merchant = data.merchant;
72
148
  const currency = String(data.currency ?? "usd");
@@ -77,31 +153,62 @@ export function createPickupMcpServer(opts) {
77
153
  description: i.description,
78
154
  }));
79
155
  const payments = data.payments;
156
+ const cards = Boolean(payments.square || payments.card);
157
+ const ways = [
158
+ ...(payments.x402 ? [`USDC over x402 on ${payments.x402.network}, paid by the agent`] : []),
159
+ ...(cards ? ["cards, paid by the user on a checkout link"] : []),
160
+ ...(payments.test ? ["test payments, with no money moving"] : []),
161
+ ];
80
162
  return reply(`${merchant.name}${merchant.location?.name ? `, collect at ${merchant.location.name}` : ""}. ` +
81
- (payments.x402 ? `Takes USDC over x402 on ${payments.x402.network}.` : "Doesn't take x402 payments."), { shop, merchant: merchant.name, location: merchant.location, currency, items, x402: payments.x402 ?? null });
163
+ (!ways.length
164
+ ? "Doesn't take agent payments."
165
+ : ways.length === 1 && payments.test
166
+ ? "A test shop: orders are marked paid without money moving."
167
+ : `Takes ${ways.join("; ")}.`), { shop, merchant: merchant.name, location: merchant.location, currency, items, x402: payments.x402 ?? null, cards, test: Boolean(payments.test) });
82
168
  }));
83
169
  server.registerTool("create_pickup_order", {
84
170
  title: "Order for pickup",
85
171
  description: "Create an order to collect in person. Returns the order id and total; pay it with pay_order. " +
86
- "The shop emails the pickup QR to `email` once it's paid.",
172
+ "The shop emails the pickup QR to `email` once it's paid. Where the shop takes cards, `payment: \"card\"` " +
173
+ "returns a checkout link for the user to pay on instead.",
87
174
  inputSchema: {
88
175
  items: z
89
176
  .array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) }))
90
177
  .min(1),
91
178
  email: z.string().email().describe("The user's email. The pickup QR goes here."),
92
179
  name: z.string().max(100).optional().describe("Name for the order, shown to staff"),
180
+ payment: z
181
+ .enum(["agent", "card"])
182
+ .optional()
183
+ .describe("Who pays: agent (USDC over x402, or a test payment at a test shop) or card (the user pays on a checkout link). Default: agent where the shop takes it, else card."),
93
184
  ...shopArg,
94
185
  },
95
186
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
96
187
  }, guarded(async (args) => {
97
- const shop = shopFor(args.shop_url);
188
+ const shop = await shopFor(args.shop_url);
189
+ // The agent pays over x402 where the shop takes it, a test shop marks the order paid straight away, and a
190
+ // shop that takes cards gives the user a checkout link.
191
+ const { payments } = (await call(shop, "/api/catalog"));
192
+ const agentRail = payments.x402 ? "x402" : payments.test ? "test" : null;
193
+ const cardRail = payments.square ? "square" : payments.card ? "card" : null;
194
+ const rail = args.payment === "card" ? cardRail : args.payment === "agent" ? agentRail : (agentRail ?? cardRail);
195
+ if (!rail)
196
+ throw new ToolError(args.payment === "card" ? "This shop doesn't take cards." : "This shop doesn't take agent payments.");
98
197
  const data = await call(shop, "/api/orders", {
99
198
  method: "POST",
100
199
  headers: { "Content-Type": "application/json" },
101
- body: JSON.stringify({ items: args.items, email: args.email, name: args.name, rail: "x402" }),
200
+ body: JSON.stringify({ items: args.items, email: args.email, name: args.name, rail }),
102
201
  });
103
202
  const order = data.order;
104
203
  const next = data.next;
204
+ if (rail === "test") {
205
+ return reply(`Test order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}, ` +
206
+ `marked paid with no money moving. The shop emails ${args.email} the receipt and pickup QR; the pass is at ${next.passUrl}.`, { shop, orderId: order.id, status: order.status, test: true, total: money(order.totalCents, order.currency), items: order.items, passUrl: next.passUrl });
207
+ }
208
+ if (rail === "square" || rail === "card") {
209
+ return reply(`Order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}. ` +
210
+ `Give the user this link to pay by card: ${next.url}. Once they've paid, the shop emails ${args.email} the receipt and pickup QR.`, { shop, orderId: order.id, total: money(order.totalCents, order.currency), items: order.items, payBy: "card", checkoutUrl: next.url, collectBy: order.collectBy });
211
+ }
105
212
  return reply(`Order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}. ` +
106
213
  `Confirm with the user, then call pay_order.`, { shop, orderId: order.id, total: money(order.totalCents, order.currency), items: order.items, payUrl: next.url, collectBy: order.collectBy });
107
214
  }));
@@ -113,7 +220,15 @@ export function createPickupMcpServer(opts) {
113
220
  inputSchema: { order_id: z.string(), ...shopArg },
114
221
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
115
222
  }, guarded(async (args) => {
116
- const shop = shopFor(args.shop_url);
223
+ const shop = await shopFor(args.shop_url);
224
+ const status = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
225
+ if (status.order.rail === "square" || status.order.rail === "card") {
226
+ if (status.order.status !== "awaiting_payment")
227
+ return reply(`Order ${args.order_id} is already paid (${status.order.status}).`, { orderId: args.order_id, status: status.order.status });
228
+ return reply(status.checkoutUrl
229
+ ? `This order is paid by card: give the user ${status.checkoutUrl} to pay on.`
230
+ : "This order is paid by card, on the checkout link create_pickup_order returned.", { orderId: args.order_id, payBy: "card", checkoutUrl: status.checkoutUrl ?? null });
231
+ }
117
232
  const payUrl = `${shop}/api/orders/${encodeURIComponent(args.order_id)}/pay`;
118
233
  if (!opts.payer) {
119
234
  const res = await doFetch(new Request(payUrl, { method: "POST" }));
@@ -139,7 +254,7 @@ export function createPickupMcpServer(opts) {
139
254
  inputSchema: { order_id: z.string(), ...shopArg },
140
255
  annotations: { readOnlyHint: true, openWorldHint: true },
141
256
  }, guarded(async (args) => {
142
- const shop = shopFor(args.shop_url);
257
+ const shop = await shopFor(args.shop_url);
143
258
  const { order } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
144
259
  const words = {
145
260
  awaiting_payment: "is waiting for payment",
@@ -159,13 +274,97 @@ export function createPickupMcpServer(opts) {
159
274
  }, guarded(async (args) => {
160
275
  if (!opts.payer)
161
276
  throw new ToolError("No wallet is set up here. The pass was emailed to the buyer when the order was paid.");
162
- const shop = shopFor(args.shop_url);
277
+ const shop = await shopFor(args.shop_url);
163
278
  const res = await opts.payer.signIn(`${shop}/api/orders/${encodeURIComponent(args.order_id)}/pass`, doFetch);
164
279
  if (!res.ok)
165
280
  throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
166
281
  const pass = (await res.json());
167
282
  return reply(`Pickup pass: ${pass.passUrl}, pickup code ${pass.pickupCode}.`, pass);
168
283
  }));
284
+ const closesIn = (g) => {
285
+ const minutes = Math.max(0, Math.round((g.closesAt - Date.now()) / 60_000));
286
+ return g.status === "open" ? `closes in ${minutes} min` : g.status === "sent" ? "closed and sent to the shop" : "cancelled";
287
+ };
288
+ const groupSummary = (g) => {
289
+ const paid = g.members.filter((m) => m.status !== "awaiting_payment" && m.status !== "cancelled");
290
+ return `${g.title} (code ${g.code}), ${closesIn(g)}: ${paid.length} of ${g.members.length} paid, ${money(g.paidCents, g.currency)} so far.`;
291
+ };
292
+ server.registerTool("start_group_order", {
293
+ title: "Start a group order",
294
+ description: "Start a group order (a coffee run) at a shop. Returns a code to share: each person's agent joins with it and pays for " +
295
+ "their own items. When it closes, the shop gets one combined order and the organiser is emailed its pickup QR.",
296
+ inputSchema: {
297
+ organiser_name: z.string().min(1).max(60).describe("Who collects, shown to staff"),
298
+ organiser_email: z.string().email().describe("Where the group's pickup QR goes"),
299
+ title: z.string().max(60).optional().describe('e.g. "Friday coffee run"'),
300
+ closes_in_minutes: z.number().int().min(5).max(240).optional().describe("Default 15"),
301
+ ...shopArg,
302
+ },
303
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
304
+ }, guarded(async (args) => {
305
+ const shop = await shopFor(args.shop_url);
306
+ const data = (await call(shop, "/api/groups", {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({ name: args.organiser_name, email: args.organiser_email, title: args.title, closesInMinutes: args.closes_in_minutes }),
310
+ }));
311
+ 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)}. ` +
312
+ `Keep organiser_key to close it early.`, { shop, code: data.group.code, closesAt: data.group.closesAt, organiser_key: data.organiserKey });
313
+ }));
314
+ server.registerTool("join_group_order", {
315
+ title: "Join a group order",
316
+ 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. " +
317
+ "The organiser collects everything, so the user gets a receipt, not a QR.",
318
+ inputSchema: {
319
+ code: z.string().min(4).max(12).describe("The group's code"),
320
+ items: z.array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) })).min(1),
321
+ email: z.string().email().describe("The user's email, for their receipt"),
322
+ name: z.string().min(1).max(60).describe("The user's name, shown to staff on their items"),
323
+ ...shopArg,
324
+ },
325
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
326
+ }, guarded(async (args) => {
327
+ const shop = await shopFor(args.shop_url);
328
+ const { payments } = (await call(shop, "/api/catalog"));
329
+ const rail = payments.x402 ? "x402" : payments.test ? "test" : null;
330
+ if (!rail)
331
+ throw new ToolError("This shop doesn't take agent payments.");
332
+ const data = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}/orders`, {
333
+ method: "POST",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify({ items: args.items, email: args.email, name: args.name, rail }),
336
+ }));
337
+ const what = `${data.order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(data.order.totalCents, data.order.currency)}`;
338
+ return reply(rail === "test"
339
+ ? `Added to ${data.group.title}: ${what}, paid with a test payment (no money moved). ${data.group.organiser} collects it.`
340
+ : `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" });
341
+ }));
342
+ server.registerTool("group_order_status", {
343
+ title: "Check a group order",
344
+ description: "Who's in a group order, what they ordered, who has paid, and whether it has closed and been collected.",
345
+ inputSchema: { code: z.string().min(4).max(12), ...shopArg },
346
+ annotations: { readOnlyHint: true, openWorldHint: true },
347
+ }, guarded(async (args) => {
348
+ const shop = await shopFor(args.shop_url);
349
+ const { group } = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}`));
350
+ return reply(groupSummary(group) + (group.combined ? ` The shop's order is ${group.combined.status}.` : ""), group);
351
+ }));
352
+ server.registerTool("close_group_order", {
353
+ title: "Close a group order now",
354
+ description: "Close a group order early, as its organiser. Unpaid shares are left out; the shop gets the rest as one order.",
355
+ inputSchema: { code: z.string().min(4).max(12), organiser_key: z.string().min(8), ...shopArg },
356
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
357
+ }, guarded(async (args) => {
358
+ const shop = await shopFor(args.shop_url);
359
+ const { group } = (await call(shop, `/api/groups/${encodeURIComponent(args.code)}/close`, {
360
+ method: "POST",
361
+ headers: { "Content-Type": "application/json" },
362
+ body: JSON.stringify({ organiserKey: args.organiser_key }),
363
+ }));
364
+ return reply(group.status === "sent"
365
+ ? `Closed: ${groupSummary(group)} The shop has the combined order, and ${group.organiser}'s pickup QR is on its way by email.`
366
+ : `Closed with nobody paid, so the group order was cancelled.`, group);
367
+ }));
169
368
  return server;
170
369
  }
171
370
  /** A Streamable HTTP endpoint (stateless, JSON responses) for hosting the server, e.g. at /mcp. */
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "@agentpickup/mcp",
3
- "version": "0.1.1",
4
- "description": "MCP server for agentPickup: let an AI agent order from a shop, pay over x402, and hand its user the QR to collect in person.",
5
- "mcpName": "io.github.dav3trad3r/agentpickup",
6
- "type": "module",
7
- "main": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
- "bin": {
10
- "agentpickup-mcp": "dist/cli.js"
11
- },
12
- "exports": {
13
- ".": {
14
- "source": "./src/index.ts",
15
- "types": "./dist/index.d.ts",
16
- "default": "./dist/index.js"
17
- }
18
- },
19
- "files": [
20
- "dist",
21
- "LICENSE.md",
22
- "README.md"
23
- ],
24
- "scripts": {
25
- "build": "tsc -p tsconfig.build.json",
26
- "prepublishOnly": "npm run build"
27
- },
28
- "engines": {
29
- "node": ">=22.13"
30
- },
31
- "keywords": [
32
- "mcp",
33
- "model-context-protocol",
34
- "x402",
35
- "agentic-commerce",
36
- "agent-payments",
37
- "click-and-collect",
38
- "pickup"
39
- ],
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/dav3trad3r/agentpickup.git",
43
- "directory": "packages/mcp"
44
- },
45
- "homepage": "https://agentpickup.app",
46
- "bugs": {
47
- "url": "https://github.com/dav3trad3r/agentpickup/issues"
48
- },
49
- "author": "Dave Hallett",
50
- "license": "FSL-1.1-ALv2",
51
- "publishConfig": {
52
- "access": "public"
53
- },
54
- "dependencies": {
55
- "@modelcontextprotocol/sdk": "^1.32.2",
56
- "@x402/core": "2.29.0",
57
- "@x402/evm": "2.29.0",
58
- "@x402/extensions": "2.29.0",
59
- "viem": "^2.57.14",
60
- "zod": "^4.6.7"
61
- }
62
- }
1
+ {
2
+ "name": "@agentpickup/mcp",
3
+ "version": "0.3.0",
4
+ "description": "MCP server for agentPickup: let an AI agent find pickup shops near its user, order, pay over x402, and hand its user the QR to collect in person.",
5
+ "mcpName": "io.github.dav3trad3r/agentpickup",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "agentpickup-mcp": "dist/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "source": "./src/index.ts",
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "LICENSE.md",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.13"
30
+ },
31
+ "keywords": [
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "x402",
35
+ "agentic-commerce",
36
+ "agent-payments",
37
+ "click-and-collect",
38
+ "pickup"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/dav3trad3r/agentpickup.git",
43
+ "directory": "packages/mcp"
44
+ },
45
+ "homepage": "https://agentpickup.app",
46
+ "bugs": {
47
+ "url": "https://github.com/dav3trad3r/agentpickup/issues"
48
+ },
49
+ "author": "Dave Hallett",
50
+ "license": "FSL-1.1-ALv2",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.32.2",
56
+ "@x402/core": "2.29.0",
57
+ "@x402/evm": "2.29.0",
58
+ "@x402/extensions": "2.29.0",
59
+ "viem": "^2.57.14",
60
+ "zod": "^4.6.7"
61
+ }
62
+ }