@agentpickup/mcp 0.3.3 → 0.4.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
@@ -10,18 +10,28 @@ QR they show at the counter to collect.
10
10
  |---|---|
11
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
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. Where the shop takes cards, `payment: "card"` returns a checkout link for the user to pay on instead |
13
+ | `create_pickup_order` | Creates an order and returns a `claim` to keep with it. Needs the user's email, where the shop sends the receipt and pickup pass. A test shop marks it paid straight away, with no money moving. Where the shop takes cards, `payment: "card"` returns a checkout link for the user to pay on instead |
14
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. For a card order, returns its checkout link |
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) |
15
+ | `order_status` | Waiting for payment, paid, ready, collected or expired. With the order's `claim`, a paid order's pass comes back too |
16
+ | `get_pickup_pass` | Fetches the pass link, pickup code and QR again: with the order's `claim`, or by signing in as the paying wallet (Sign-In-With-X) |
17
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
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
19
  | `group_order_status` | Who's in, what they ordered, who has paid, and whether the shop's order is ready or collected |
20
20
  | `close_group_order` | Closes a group order early, as its organiser; the shop gets one combined order and the organiser its QR |
21
21
 
22
+ ## The pickup pass card
23
+
24
+ In chat apps that draw [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) cards (Claude and ChatGPT
25
+ among them), `create_pickup_order`, `order_status` and `get_pickup_pass` show the order as a card: a Pay button while
26
+ it's unpaid, then the QR and pickup code, kept up to date until it's collected. When the shop marks it ready, the QR's
27
+ tile turns lime. The card is one self-contained page (`ui://agentpickup/pass-<version>.html`) that loads nothing from
28
+ anywhere else. In apps without cards, the agent gives the user the pass link and pickup code. The shop emails the pass
29
+ either way.
30
+
22
31
  ## Use it
23
32
 
24
- Try it against the hosted demo shop first. It pays with the demo's own small wallet, within the demo's caps:
33
+ Try it against the hosted demo shops first. The hosted server holds no wallet: its demo shops take a free test
34
+ payment, the Square sandbox's test card, or USDC from the user's own x402 wallet:
25
35
 
26
36
  ```json
27
37
  { "mcpServers": { "agentpickup": { "type": "http", "url": "https://agentpickup.app/mcp" } } }
package/dist/card.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export declare const CARD_MIME_TYPE = "text/html;profile=mcp-app";
2
+ /** Where tools hand their card data: the card reads it, the model doesn't need it. */
3
+ export declare const CARD_META_KEY = "agentpickup/pass";
4
+ /** What the card draws. Every field but orderId is optional: later results fill in or update what's there. */
5
+ export interface PassCard {
6
+ v: 1;
7
+ orderId: string;
8
+ shopUrl?: string;
9
+ shopName?: string;
10
+ /** Lets the card call order_status itself while the order moves along. */
11
+ claim?: string;
12
+ status?: string;
13
+ rail?: string;
14
+ items?: string;
15
+ total?: string;
16
+ collectBy?: number | null;
17
+ collectedAt?: number | null;
18
+ checkoutUrl?: string;
19
+ passUrl?: string;
20
+ pickupCode?: string | null;
21
+ /** The QR as SVG, ink on transparent, so the tile behind it sets the colour. */
22
+ qrSvg?: string;
23
+ }
24
+ /** The card's page. Its version goes in the handshake, so a host's logs say which card it ran. */
25
+ export declare function passCardHtml(version: string): string;
package/dist/card.js ADDED
@@ -0,0 +1,269 @@
1
+ // The pickup pass as an MCP Apps card (ui:// resource, text/html;profile=mcp-app): chat apps that show cards (Claude,
2
+ // ChatGPT) draw it beside the tool result. It shows the order, a Pay button while it's unpaid, then the QR and pickup
3
+ // code, and keeps itself up to date (order_status with the order's claim) until it's collected. At "ready" the QR's
4
+ // tile turns brand lime. One self-contained page: no scripts, fonts or images from anywhere else.
5
+ //
6
+ // The bridge is the MCP Apps postMessage protocol (JSON-RPC 2.0 with the host, protocol 2026-01-26), written out
7
+ // here rather than bundling the 300 KB SDK: ui/initialize, ui/notifications/initialized, tool-result,
8
+ // host-context-changed, resource-teardown, tools/call, ui/open-link and ui/notifications/size-changed.
9
+ export const CARD_MIME_TYPE = "text/html;profile=mcp-app";
10
+ /** Where tools hand their card data: the card reads it, the model doesn't need it. */
11
+ export const CARD_META_KEY = "agentpickup/pass";
12
+ const PAGE = String.raw `<!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8">
16
+ <meta name="viewport" content="width=device-width,initial-scale=1">
17
+ <title>Pickup pass</title>
18
+ <style>
19
+ :root{--bg:#ffffff;--surface:#f4f4f1;--text:#16161a;--muted:#66666d;--line:rgba(10,11,13,.12);--ok:#2e7d32;--lime:#b5f23d;--ink:#0a0b0d;color-scheme:light}
20
+ :root[data-theme=dark]{--bg:#1d1d20;--surface:#2a2a2e;--text:#f2f2f4;--muted:#a3a3ab;--line:rgba(255,255,255,.14);--ok:#8fd47f;color-scheme:dark}
21
+ @media (prefers-color-scheme:dark){:root:not([data-theme]){--bg:#1d1d20;--surface:#2a2a2e;--text:#f2f2f4;--muted:#a3a3ab;--line:rgba(255,255,255,.14);--ok:#8fd47f;color-scheme:dark}}
22
+ html,body{margin:0;padding:0;background:transparent}
23
+ body{font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif);color:var(--text);-webkit-font-smoothing:antialiased;padding:2px}
24
+ .card{box-sizing:border-box;max-width:380px;margin:0 auto;background:var(--bg);border:1px solid var(--line);border-radius:16px;padding:16px 18px}
25
+ .head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px}
26
+ .shop{font-weight:600;font-size:15px;margin:0}
27
+ .items{font-size:13px;color:var(--muted);margin:2px 0 0}
28
+ .badge{font-size:12px;line-height:1;padding:6px 10px;border-radius:999px;background:var(--surface);white-space:nowrap}
29
+ .badge.ready{background:var(--lime);color:var(--ink);font-weight:600}
30
+ .wrap{position:relative;border-radius:12px}
31
+ .tile{position:relative;overflow:hidden;background:#ffffff;border-radius:12px;padding:14px;display:flex;justify-content:center;align-items:center;border:1px solid var(--line);min-height:120px}
32
+ .tile svg{position:relative;display:block;width:100%;max-width:220px;height:auto}
33
+ .pour{position:absolute;left:0;right:0;bottom:0;height:0;background:var(--lime);transition:height 1s cubic-bezier(.5,0,.2,1)}
34
+ .wrap.ready .pour{height:100%}
35
+ .wrap.ready .tile{border-color:var(--lime)}
36
+ .wrap.ready::after{content:"";position:absolute;inset:0;border:2px solid var(--lime);border-radius:12px;animation:ring 1.8s ease-out infinite;pointer-events:none}
37
+ @keyframes ring{from{transform:scale(1);opacity:.9}to{transform:scale(1.08);opacity:0}}
38
+ .tile.plain{background:var(--surface)}
39
+ .done{font-size:15px;color:var(--text);text-align:center}
40
+ .done b{display:block;font-size:34px;line-height:1.2}
41
+ .facts{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px;margin:12px 0 0}
42
+ .fact{background:var(--surface);border-radius:10px;padding:8px 12px}
43
+ .fact span{display:block;font-size:12px;color:var(--muted)}
44
+ .fact b{display:block;font-size:20px;font-weight:600;overflow-wrap:anywhere}
45
+ .code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:2px}
46
+ .steps{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding-top:10px;margin-top:12px}
47
+ .step{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
48
+ .dot{width:10px;height:10px;border-radius:50%;border:1.5px solid currentColor;box-sizing:border-box}
49
+ .step.past{color:var(--ok)}.step.past .dot{background:currentColor}
50
+ .step.now{color:var(--text);font-weight:600}.step.now .dot{background:var(--ok);border-color:var(--ok)}
51
+ .msg{font-size:13px;color:var(--muted);margin:10px 0 0;line-height:1.45}
52
+ .test{font-size:12px;color:var(--muted);margin:6px 0 0}
53
+ .row{display:flex;gap:12px;align-items:center;margin-top:12px;flex-wrap:wrap}
54
+ button{font:inherit;font-size:14px;border-radius:10px;padding:10px 14px;cursor:pointer;border:1px solid var(--line);background:var(--surface);color:var(--text)}
55
+ button.pay{background:var(--lime);color:var(--ink);border-color:var(--lime);font-weight:600}
56
+ button.link{background:none;border:none;padding:0;color:var(--muted);text-decoration:underline;font-size:13px}
57
+ .foot{font-size:11px;color:var(--muted);margin-top:12px;text-align:right}
58
+ @media (prefers-reduced-motion:reduce){.pour{transition:none}.wrap.ready::after{animation:none;opacity:1}}
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <div id="root"></div>
63
+ <script>
64
+ (function () {
65
+ var VERSION = "__VERSION__";
66
+ var LABELS = { awaiting_payment: "Waiting for payment", paid: "Paid", ready: "Ready", collected: "Collected", expired: "Expired", cancelled: "Cancelled" };
67
+ var FINAL = { collected: 1, expired: 1, cancelled: 1 };
68
+ var root = document.getElementById("root");
69
+ var state = null, canCall = false, canOpen = false, locale, timeZone;
70
+ var nextId = 1, pending = {}, timer = null, started = Date.now(), lastHtml = "", poured = false, lastHeight = -1, stopped = false;
71
+
72
+ // ---------- the bridge ----------
73
+ var host = window.parent;
74
+ function send(msg) { try { host.postMessage(msg, "*"); } catch (e) {} }
75
+ function request(method, params) {
76
+ var id = nextId++;
77
+ send({ jsonrpc: "2.0", id: id, method: method, params: params });
78
+ return new Promise(function (resolve, reject) {
79
+ pending[id] = { resolve: resolve, reject: reject };
80
+ setTimeout(function () { if (pending[id]) { delete pending[id]; reject(new Error("timed out")); } }, 30000);
81
+ });
82
+ }
83
+ function notify(method, params) { send({ jsonrpc: "2.0", method: method, params: params }); }
84
+ window.addEventListener("message", function (e) {
85
+ if (host !== window && e.source !== host) return;
86
+ var m = e.data;
87
+ if (!m || m.jsonrpc !== "2.0") return;
88
+ if (m.id != null && !m.method) {
89
+ var p = pending[m.id];
90
+ if (p) { delete pending[m.id]; if (m.error) p.reject(m.error); else p.resolve(m.result); }
91
+ return;
92
+ }
93
+ if (m.method === "ui/notifications/tool-result") return onResult(m.params);
94
+ if (m.method === "ui/notifications/host-context-changed") { applyContext(m.params || {}); return render(); }
95
+ if (m.method === "ui/resource-teardown") { stop(); if (m.id != null) send({ jsonrpc: "2.0", id: m.id, result: {} }); return; }
96
+ if (m.method === "ping" && m.id != null) return send({ jsonrpc: "2.0", id: m.id, result: {} });
97
+ if (m.id != null && m.method && m.method.indexOf("ui/notifications/") !== 0) {
98
+ send({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "Method not found" } });
99
+ }
100
+ });
101
+ function applyContext(ctx) {
102
+ if (ctx.theme === "light" || ctx.theme === "dark") document.documentElement.setAttribute("data-theme", ctx.theme);
103
+ var vars = ctx.styles && ctx.styles.variables;
104
+ if (vars) for (var k in vars) if (k.indexOf("--") === 0 && typeof vars[k] === "string") document.documentElement.style.setProperty(k, vars[k]);
105
+ if (ctx.locale) locale = ctx.locale;
106
+ if (ctx.timeZone) timeZone = ctx.timeZone;
107
+ }
108
+ function openLink(url) {
109
+ if (!url) return;
110
+ if (canOpen) request("ui/open-link", { url: url }).catch(function () { window.open(url, "_blank", "noopener"); });
111
+ else window.open(url, "_blank", "noopener");
112
+ }
113
+
114
+ // ---------- results ----------
115
+ function cardOf(result) {
116
+ if (!result) return null;
117
+ var meta = result._meta && result._meta["agentpickup/pass"];
118
+ if (meta && meta.orderId) return meta;
119
+ // A host that drops _meta: the JSON after the tool's summary line still has the basics.
120
+ try {
121
+ var text = result.content && result.content[0] && result.content[0].text;
122
+ var data = JSON.parse(text.slice(text.indexOf("\n\n") + 2));
123
+ var id = data.orderId || data.id || (data.order && data.order.id);
124
+ if (!id) return null;
125
+ return { orderId: id, status: data.status, passUrl: data.passUrl, pickupCode: data.pickupCode, checkoutUrl: data.checkoutUrl, total: data.total, claim: data.claim, shopUrl: data.shop };
126
+ } catch (e) { return null; }
127
+ }
128
+ function onResult(result) {
129
+ var card = cardOf(result);
130
+ if (card) {
131
+ if (state && state.orderId !== card.orderId) state = null;
132
+ var next = state ? Object.assign({}, state) : {};
133
+ for (var k in card) if (card[k] !== undefined && card[k] !== null && card[k] !== "") next[k] = card[k];
134
+ if (card.status && card.status !== "awaiting_payment") delete next.checkoutUrl;
135
+ state = next;
136
+ }
137
+ render();
138
+ schedule();
139
+ }
140
+
141
+ // ---------- keeping up to date ----------
142
+ function stop() { stopped = true; clearTimeout(timer); }
143
+ function schedule() {
144
+ clearTimeout(timer);
145
+ if (stopped || !state || !state.claim || !canCall || FINAL[state.status]) return;
146
+ if (Date.now() - started > 4 * 3600 * 1000) return;
147
+ var wait = document.hidden ? 60000 : state.status === "awaiting_payment" ? 5000 : 15000;
148
+ timer = setTimeout(poll, wait);
149
+ }
150
+ function poll() {
151
+ var args = { order_id: state.orderId, claim: state.claim };
152
+ if (state.shopUrl) args.shop_url = state.shopUrl;
153
+ request("tools/call", { name: "order_status", arguments: args }).then(function (r) {
154
+ if (r && !r.isError) onResult(r); else schedule();
155
+ }, schedule);
156
+ }
157
+ document.addEventListener("visibilitychange", function () { if (!document.hidden && state) { clearTimeout(timer); poll(); } });
158
+
159
+ // ---------- drawing ----------
160
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
161
+ function time(ms) {
162
+ var d = new Date(ms), o = { hour: "numeric", minute: "2-digit" };
163
+ if (timeZone) o.timeZone = timeZone;
164
+ try {
165
+ var s = d.toLocaleTimeString(locale, o);
166
+ if (Math.abs(ms - Date.now()) > 18 * 3600 * 1000) {
167
+ var od = { weekday: "short" };
168
+ if (timeZone) od.timeZone = timeZone;
169
+ s = d.toLocaleDateString(locale, od) + " " + s;
170
+ }
171
+ return s;
172
+ } catch (e) { return d.toLocaleTimeString(); }
173
+ }
174
+ function safeSvg(svg) { return typeof svg === "string" && /^<svg[\s>]/.test(svg) && !/<script|<foreignObject|\son[a-z]+=/i.test(svg) ? svg : ""; }
175
+ function message(s) {
176
+ var live = canCall && s.claim;
177
+ switch (s.status) {
178
+ case "awaiting_payment":
179
+ return s.checkoutUrl
180
+ ? "Pay on the checkout page. " + (live ? "This card turns into your pickup pass once it's paid." : "Then ask for the pickup pass.")
181
+ : "Waiting for the payment. " + (live ? "This card turns into your pickup pass once it's paid." : "");
182
+ case "paid": return "Paid. " + (live ? "This turns green when it's ready. " : "") + "The pass is in your email too.";
183
+ case "ready": return "Ready at the counter. Show the QR or say the code.";
184
+ case "collected": return s.collectedAt ? "Collected at " + time(s.collectedAt) + ". Enjoy." : "Collected. Enjoy.";
185
+ case "expired": return "This order expired before anyone collected it.";
186
+ case "cancelled": return "This order was cancelled.";
187
+ default: return "";
188
+ }
189
+ }
190
+ function view(s) {
191
+ var status = s.status || "awaiting_payment";
192
+ var label = LABELS[status] || status;
193
+ var live = status === "paid" || status === "ready";
194
+ var h = '<div class="card">';
195
+ h += '<div class="head"><div><p class="shop">' + esc(s.shopName || "Your pickup order") + "</p>";
196
+ if (s.items) h += '<p class="items">' + esc(s.items) + (s.total ? " · " + esc(s.total) : "") + "</p>";
197
+ h += '</div><span class="badge' + (status === "ready" ? " ready" : "") + '">' + esc(label) + "</span></div>";
198
+ var svg = safeSvg(s.qrSvg);
199
+ if (live && svg) {
200
+ h += '<div class="wrap' + (status === "ready" && poured ? " ready" : "") + '" id="wrap"><div class="tile"><div class="pour"></div>' +
201
+ svg.replace("<svg", '<svg role="img" aria-label="Pickup QR code"') + "</div></div>";
202
+ } else if (status === "collected") {
203
+ h += '<div class="wrap"><div class="tile plain"><div class="done"><b>&#10003;</b>Collected</div></div></div>';
204
+ }
205
+ if (live && (s.pickupCode || s.collectBy)) {
206
+ h += '<div class="facts">';
207
+ if (s.pickupCode) h += '<div class="fact"><span>Pickup code</span><b class="code">' + esc(s.pickupCode) + "</b></div>";
208
+ if (s.collectBy) h += '<div class="fact"><span>Collect by</span><b>' + esc(time(s.collectBy)) + "</b></div>";
209
+ h += "</div>";
210
+ }
211
+ if (live || status === "collected") {
212
+ var at = { paid: 0, ready: 1, collected: 2 }[status];
213
+ h += '<div class="steps">' + ["Paid", "Ready", "Collected"].map(function (name, i) {
214
+ return '<span class="step' + (i < at ? " past" : i === at ? " now" : "") + '"><span class="dot"></span>' + name + "</span>";
215
+ }).join("") + "</div>";
216
+ }
217
+ var msg = message(s);
218
+ if (msg) h += '<p class="msg">' + esc(msg) + "</p>";
219
+ if (s.rail === "test") h += '<p class="test">Test order: no money moved.</p>';
220
+ var actions = "";
221
+ if (status === "awaiting_payment" && s.checkoutUrl) actions += '<button class="pay" data-open="checkoutUrl">Pay' + (s.total ? " " + esc(s.total) : "") + " by card</button>";
222
+ if (live && s.passUrl) actions += '<button class="link" data-open="passUrl">Open the pass</button>';
223
+ if (actions) h += '<div class="row">' + actions + "</div>";
224
+ h += '<div class="foot">agentPickup</div></div>';
225
+ return h;
226
+ }
227
+ function render() {
228
+ var html = state ? view(state) : "";
229
+ if (html !== lastHtml) {
230
+ root.innerHTML = html;
231
+ lastHtml = html;
232
+ var buttons = root.querySelectorAll("[data-open]");
233
+ for (var i = 0; i < buttons.length; i++) buttons[i].addEventListener("click", function (e) { openLink(state[e.currentTarget.getAttribute("data-open")]); });
234
+ }
235
+ // The lime pours in once, the first time the card sees the order ready.
236
+ var wrap = document.getElementById("wrap");
237
+ if (wrap && state && state.status === "ready" && !poured) {
238
+ void wrap.offsetWidth;
239
+ wrap.classList.add("ready");
240
+ poured = true;
241
+ lastHtml = view(state);
242
+ }
243
+ reportSize();
244
+ }
245
+ function reportSize() {
246
+ var height = Math.ceil(document.body.getBoundingClientRect().height);
247
+ if (height !== lastHeight) { lastHeight = height; notify("ui/notifications/size-changed", { height: height }); }
248
+ }
249
+ if (window.ResizeObserver) new ResizeObserver(reportSize).observe(document.body);
250
+
251
+ // ---------- start ----------
252
+ request("ui/initialize", { appInfo: { name: "agentpickup-pass", version: VERSION }, appCapabilities: {}, protocolVersion: "2026-01-26" })
253
+ .then(function (r) {
254
+ var caps = (r && r.hostCapabilities) || {};
255
+ canCall = Boolean(caps.serverTools);
256
+ canOpen = Boolean(caps.openLinks);
257
+ applyContext((r && r.hostContext) || {});
258
+ notify("ui/notifications/initialized", {});
259
+ render();
260
+ schedule();
261
+ }, function () { render(); });
262
+ })();
263
+ </script>
264
+ </body>
265
+ </html>`;
266
+ /** The card's page. Its version goes in the handshake, so a host's logs say which card it ran. */
267
+ export function passCardHtml(version) {
268
+ return PAGE.replace("__VERSION__", version);
269
+ }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
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.3.3";
3
+ export declare const VERSION = "0.4.0";
4
4
  export interface PickupMcpOptions {
5
5
  /** The agentPickup shop this server orders from. */
6
6
  shopUrl: string;
package/dist/server.js CHANGED
@@ -2,27 +2,48 @@
2
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
+ import QRCode from "qrcode";
5
6
  import { z } from "zod";
7
+ import { CARD_META_KEY, CARD_MIME_TYPE, passCardHtml } from "./card.js";
6
8
  import { errorOf, usd } from "./wallet.js";
7
- export const VERSION = "0.3.3";
9
+ export const VERSION = "0.4.0";
10
+ // The pickup pass card (MCP Apps). The version is in the URI so hosts that cache cards pick up a new one.
11
+ const CARD_URI = `ui://agentpickup/pass-${VERSION}.html`;
12
+ const CARD_TOOL_META = { ui: { resourceUri: CARD_URI }, "ui/resourceUri": CARD_URI };
13
+ // Shop names for the card, so polling order_status needn't read the catalog each time. Shared across the
14
+ // per-request servers a hosted endpoint makes.
15
+ const shopNames = new Map();
16
+ const rememberShop = (shop, name) => {
17
+ if (typeof name !== "string" || !name)
18
+ return;
19
+ if (shopNames.size > 500)
20
+ shopNames.clear();
21
+ shopNames.set(shop, name);
22
+ };
23
+ /** The QR in ink on transparent: the card's tile behind it is white, or lime once the order is ready. */
24
+ const qrSvg = (content) => QRCode.toString(content, { type: "svg", errorCorrectionLevel: "M", margin: 0, color: { dark: "#0a0b0dff", light: "#00000000" } });
8
25
  class ToolError extends Error {
9
26
  }
10
27
  const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
11
28
  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
29
  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.
30
+ 2. create_pickup_order: needs the user's email; the shop emails the pickup pass there too. It returns a claim:
31
+ keep it with the order id.
14
32
  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
15
33
  own x402 wallet. Where the shop takes cards, payment: "card" on
16
34
  create_pickup_order gives a checkout link for the user to pay on instead. Where it takes test payments (get_shop says
17
35
  so), payment: "test" runs the whole flow with no money moving. Confirm the items and total with the user first.
18
- 4. Give the user the pass link and pickup code. They show the QR at the counter to collect.
19
- get_pickup_pass fetches the pass again later; order_status says whether it's ready or collected.
36
+ 4. The pass: where the chat shows cards, the order appears as a card that becomes the pickup pass (QR and code)
37
+ once it's paid and keeps itself up to date until it's collected. Otherwise give the user the pass link and pickup
38
+ code. They show the QR at the counter, or say the code, to collect.
39
+ get_pickup_pass (with the claim) fetches the pass again; order_status (with the claim) says whether it's ready.
20
40
  Group orders (a coffee run): start_group_order gives a code to share; each person's agent calls join_group_order and
21
41
  pays its own share with pay_order; at closing time the shop gets one combined order and the organiser gets its QR.
22
42
  group_order_status shows who's in; close_group_order closes it early.`;
23
43
  const money = (cents, currency) => new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
24
- const reply = (summary, data) => ({
44
+ const reply = (summary, data, card) => ({
25
45
  content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(data, null, 2)}` }],
46
+ ...(card ? { _meta: { [CARD_META_KEY]: card } } : {}),
26
47
  });
27
48
  const failure = (message) => ({ content: [{ type: "text", text: message }], isError: true });
28
49
  /** A shop's base URL: shops can live under a path (https://host/v/cafe). A pasted API or pass link works too. */
@@ -73,6 +94,44 @@ export function createPickupMcpServer(opts) {
73
94
  return failure(`Couldn't reach the shop: ${err instanceof Error ? err.message : String(err)}`);
74
95
  }
75
96
  };
97
+ /** The pass, for whoever made the order. null while it isn't paid (or is a share of a group order). */
98
+ async function passByClaim(shop, orderId, claim) {
99
+ const res = await doFetch(new Request(`${shop}/api/orders/${encodeURIComponent(orderId)}/pass`, { headers: { "X-Pickup-Claim": claim } }));
100
+ if (res.status === 409)
101
+ return null;
102
+ if (!res.ok)
103
+ throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
104
+ return (await res.json());
105
+ }
106
+ async function shopName(shop) {
107
+ if (!shopNames.has(shop)) {
108
+ const data = await call(shop, "/api/catalog").catch(() => null);
109
+ rememberShop(shop, data?.merchant?.name);
110
+ }
111
+ return shopNames.get(shop);
112
+ }
113
+ /** What the pass card draws for an order, with its QR once there's a pass. */
114
+ async function cardFor(shop, order, extra = {}, pass) {
115
+ return {
116
+ v: 1,
117
+ orderId: order.id,
118
+ shopUrl: shop,
119
+ shopName: shopNames.get(shop),
120
+ status: order.status,
121
+ rail: order.rail,
122
+ items: order.items.map((i) => `${i.qty}× ${i.name}`).join(", "),
123
+ total: money(order.totalCents, order.currency),
124
+ collectBy: order.collectBy,
125
+ collectedAt: order.collectedAt ?? null,
126
+ ...extra,
127
+ ...(pass ? { passUrl: pass.passUrl, pickupCode: pass.pickupCode, qrSvg: await qrSvg(pass.qrContent) } : {}),
128
+ };
129
+ }
130
+ const claimArg = z
131
+ .string()
132
+ .max(200)
133
+ .optional()
134
+ .describe("The claim create_pickup_order returned with the order. With it, a paid order's pass comes back too.");
76
135
  const shopArg = opts.allowOtherShops || opts.directoryUrl
77
136
  ? { shop_url: z.string().optional().describe(`An agentPickup shop's URL, as find_shops returns it. Defaults to ${home}.`) }
78
137
  : {};
@@ -83,6 +142,14 @@ export function createPickupMcpServer(opts) {
83
142
  websiteUrl: "https://agentpickup.app",
84
143
  icons: [{ src: "https://agentpickup.app/logo.png", mimeType: "image/png", sizes: ["1024x1024"] }],
85
144
  }, { instructions: opts.instructions ? `${INSTRUCTIONS}\n${opts.instructions}` : INSTRUCTIONS });
145
+ server.registerResource("pickup-pass", CARD_URI, {
146
+ title: "Pickup pass",
147
+ 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.",
148
+ mimeType: CARD_MIME_TYPE,
149
+ _meta: { ui: { prefersBorder: false } },
150
+ }, async () => ({
151
+ contents: [{ uri: CARD_URI, mimeType: CARD_MIME_TYPE, text: passCardHtml(VERSION), _meta: { ui: { prefersBorder: false } } }],
152
+ }));
86
153
  if (opts.directoryUrl) {
87
154
  const directoryUrl = opts.directoryUrl;
88
155
  server.registerTool("find_shops", {
@@ -154,6 +221,7 @@ export function createPickupMcpServer(opts) {
154
221
  const shop = await shopFor(args.shop_url);
155
222
  const data = await call(shop, "/api/catalog");
156
223
  const merchant = data.merchant;
224
+ rememberShop(shop, merchant.name);
157
225
  const currency = String(data.currency ?? "usd");
158
226
  const items = data.items.map((i) => ({
159
227
  id: i.id,
@@ -177,14 +245,14 @@ export function createPickupMcpServer(opts) {
177
245
  }));
178
246
  server.registerTool("create_pickup_order", {
179
247
  title: "Order for pickup",
180
- description: "Create an order to collect in person. Returns the order id and total; pay it with pay_order. " +
181
- "The shop emails the pickup QR to `email` once it's paid. Where the shop takes cards, `payment: \"card\"` " +
182
- "returns a checkout link for the user to pay on instead.",
248
+ description: "Create an order to collect in person. Returns the order id, total and a claim (keep it); pay it with pay_order. " +
249
+ "The shop emails the pickup pass to `email` once it's paid, and where the chat shows cards the order shows as one " +
250
+ "that becomes the pass. Where the shop takes cards, `payment: \"card\"` returns a checkout link for the user to pay on instead.",
183
251
  inputSchema: {
184
252
  items: z
185
253
  .array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) }))
186
254
  .min(1),
187
- email: z.string().email().describe("The user's email. The pickup QR goes here."),
255
+ email: z.string().email().describe("The user's email. The pickup pass goes here too."),
188
256
  name: z.string().max(100).optional().describe("Name for the order, shown to staff"),
189
257
  payment: z
190
258
  .enum(["agent", "card", "test"])
@@ -194,11 +262,14 @@ export function createPickupMcpServer(opts) {
194
262
  ...shopArg,
195
263
  },
196
264
  annotations: { title: "Order for pickup", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
265
+ _meta: CARD_TOOL_META,
197
266
  }, guarded(async (args) => {
198
267
  const shop = await shopFor(args.shop_url);
199
268
  // The agent pays over x402 where the shop takes it, a test shop marks the order paid straight away, and a
200
269
  // shop that takes cards gives the user a checkout link.
201
- const { payments } = (await call(shop, "/api/catalog"));
270
+ const catalog = (await call(shop, "/api/catalog"));
271
+ rememberShop(shop, catalog.merchant?.name);
272
+ const { payments } = catalog;
202
273
  const agentRail = payments.x402 ? "x402" : payments.test ? "test" : null;
203
274
  const cardRail = payments.square ? "square" : payments.card ? "card" : null;
204
275
  const rail = args.payment === "test" ? (payments.test ? "test" : null)
@@ -217,16 +288,31 @@ export function createPickupMcpServer(opts) {
217
288
  });
218
289
  const order = data.order;
219
290
  const next = data.next;
291
+ // Older shops don't hand out claims; everything else still works.
292
+ const claim = typeof data.claim === "string" ? data.claim : undefined;
293
+ const what = `${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}`;
294
+ const keep = claim ? " Keep the claim: get_pickup_pass and order_status take it." : "";
220
295
  if (rail === "test") {
221
- return reply(`Test order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}, ` +
222
- `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 });
296
+ const pass = claim ? await passByClaim(shop, order.id, claim).catch(() => null) : null;
297
+ return reply(`Test order ${order.id}: ${what}, marked paid with no money moving. The pickup pass is at ${next.passUrl}` +
298
+ (pass ? ` (pickup code ${pass.pickupCode})` : "") +
299
+ `, and the shop emails it to ${args.email}.${keep}`, {
300
+ shop,
301
+ orderId: order.id,
302
+ claim,
303
+ status: order.status,
304
+ test: true,
305
+ total: money(order.totalCents, order.currency),
306
+ items: order.items,
307
+ passUrl: next.passUrl,
308
+ pickupCode: pass?.pickupCode,
309
+ }, await cardFor(shop, order, { claim, rail }, pass));
223
310
  }
224
311
  if (rail === "square" || rail === "card") {
225
- return reply(`Order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}. ` +
226
- `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 });
312
+ return reply(`Order ${order.id}: ${what}. Give the user this link to pay by card: ${next.url}. Once they've paid, the shop emails ` +
313
+ `${args.email} the receipt and pickup pass.${keep}`, { shop, orderId: order.id, claim, total: money(order.totalCents, order.currency), items: order.items, payBy: "card", checkoutUrl: next.url, collectBy: order.collectBy }, await cardFor(shop, order, { claim, rail, checkoutUrl: next.url }));
227
314
  }
228
- return reply(`Order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}. ` +
229
- `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 });
315
+ 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 }));
230
316
  }));
231
317
  server.registerTool("pay_order", {
232
318
  title: "Pay for an order",
@@ -279,12 +365,14 @@ export function createPickupMcpServer(opts) {
279
365
  }));
280
366
  server.registerTool("order_status", {
281
367
  title: "Check an order",
282
- description: "Whether an order is waiting for payment, paid, ready to collect, collected or expired.",
283
- inputSchema: { order_id: z.string(), ...shopArg },
368
+ description: "Whether an order is waiting for payment, paid, ready to collect, collected or expired. With the order's claim, " +
369
+ "a paid order's pass comes back too, and the chat's card for it updates.",
370
+ inputSchema: { order_id: z.string(), claim: claimArg, ...shopArg },
284
371
  annotations: { title: "Check an order", readOnlyHint: true, openWorldHint: true },
372
+ _meta: CARD_TOOL_META,
285
373
  }, guarded(async (args) => {
286
374
  const shop = await shopFor(args.shop_url);
287
- const { order } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
375
+ const { order, checkoutUrl } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
288
376
  const words = {
289
377
  awaiting_payment: "is waiting for payment",
290
378
  paid: "is paid and being prepared",
@@ -293,22 +381,35 @@ export function createPickupMcpServer(opts) {
293
381
  expired: "expired before anyone collected it",
294
382
  cancelled: "was cancelled",
295
383
  };
296
- return reply(`Order ${order.id} ${words[order.status] ?? `is ${order.status}`}.`, order);
384
+ const pass = args.claim && (order.status === "paid" || order.status === "ready") ? await passByClaim(shop, order.id, args.claim).catch(() => null) : null;
385
+ return reply(`Order ${order.id} ${words[order.status] ?? `is ${order.status}`}.` + (pass ? ` Pickup code ${pass.pickupCode}, pass at ${pass.passUrl}.` : ""), pass ? { ...order, passUrl: pass.passUrl, pickupCode: pass.pickupCode } : order, await cardFor(shop, order, { claim: args.claim, checkoutUrl }, pass));
297
386
  }));
298
387
  server.registerTool("get_pickup_pass", {
299
388
  title: "Get the pickup pass",
300
- description: "The pass link, pickup code and QR content for an order this wallet paid, by signing in with the wallet (Sign-In-With-X).",
301
- inputSchema: { order_id: z.string(), ...shopArg },
389
+ description: "The pass link, pickup code and QR for a paid order: with the claim create_pickup_order returned" +
390
+ (opts.payer ? ", or by signing in with the wallet that paid (Sign-In-With-X)." : "."),
391
+ inputSchema: { order_id: z.string(), claim: claimArg, ...shopArg },
302
392
  annotations: { title: "Get the pickup pass", readOnlyHint: true, openWorldHint: true },
393
+ _meta: CARD_TOOL_META,
303
394
  }, guarded(async (args) => {
304
- if (!opts.payer)
305
- throw new ToolError("No wallet is set up here. The pass was emailed to the buyer when the order was paid.");
306
395
  const shop = await shopFor(args.shop_url);
307
- const res = await opts.payer.signIn(`${shop}/api/orders/${encodeURIComponent(args.order_id)}/pass`, doFetch);
308
- if (!res.ok)
309
- throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
310
- const pass = (await res.json());
311
- return reply(`Pickup pass: ${pass.passUrl}, pickup code ${pass.pickupCode}.`, pass);
396
+ let pass;
397
+ if (args.claim) {
398
+ pass = await passByClaim(shop, args.order_id, args.claim);
399
+ if (!pass)
400
+ throw new ToolError("This order isn't paid yet, so there's no pass. order_status says where it's at.");
401
+ }
402
+ else if (opts.payer) {
403
+ const res = await opts.payer.signIn(`${shop}/api/orders/${encodeURIComponent(args.order_id)}/pass`, doFetch);
404
+ if (!res.ok)
405
+ throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
406
+ pass = (await res.json());
407
+ }
408
+ else {
409
+ throw new ToolError("Send the claim create_pickup_order returned with the order. The pass is also in the email the shop sent when it was paid.");
410
+ }
411
+ await shopName(shop);
412
+ return reply(`Pickup pass: ${pass.passUrl}, pickup code ${pass.pickupCode}. The user shows the QR at the counter, or says the code.`, { passUrl: pass.passUrl, pickupCode: pass.pickupCode, qrContent: pass.qrContent, status: pass.order?.status }, pass.order ? await cardFor(shop, pass.order, { claim: args.claim }, pass) : undefined);
312
413
  }));
313
414
  const closesIn = (g) => {
314
415
  const minutes = Math.max(0, Math.round((g.closesAt - Date.now()) / 60_000));
package/package.json CHANGED
@@ -1,62 +1,63 @@
1
- {
2
- "name": "@agentpickup/mcp",
3
- "version": "0.3.3",
4
- "description": "MCP server for agentPickup: let an AI agent find pickup shops near its user, order, pay by card or in USDC 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/Tokenise-au/agentpickup-sdk.git",
43
- "directory": "packages/mcp"
44
- },
45
- "homepage": "https://agentpickup.app",
46
- "bugs": {
47
- "url": "https://github.com/Tokenise-au/agentpickup-sdk/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.4.0",
4
+ "description": "MCP server for agentPickup: let an AI agent find pickup shops near its user, order, pay by card or in USDC 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/Tokenise-au/agentpickup-sdk.git",
43
+ "directory": "packages/mcp"
44
+ },
45
+ "homepage": "https://agentpickup.app",
46
+ "bugs": {
47
+ "url": "https://github.com/Tokenise-au/agentpickup-sdk/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
+ "qrcode": "^1.5.4",
60
+ "viem": "^2.57.14",
61
+ "zod": "^4.6.7"
62
+ }
63
+ }