@agentpickup/mcp 0.3.3 → 0.4.1

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,30 @@ 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
+ | `share_agentpickup` | A QR and link for friends to add agentPickup to their assistant (as a card where the chat shows cards), for everyone or with one café picked |
20
21
  | `close_group_order` | Closes a group order early, as its organiser; the shop gets one combined order and the organiser its QR |
21
22
 
23
+ ## The pickup pass card
24
+
25
+ In chat apps that draw [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) cards (Claude and ChatGPT
26
+ among them), `create_pickup_order`, `order_status` and `get_pickup_pass` show the order as a card: a Pay button while
27
+ 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
28
+ tile turns lime. The card is one self-contained page (`ui://agentpickup/pass-<version>.html`) that loads nothing from
29
+ anywhere else. In apps without cards, the agent gives the user the pass link and pickup code. The shop emails the pass
30
+ either way. The card's "Invite a friend" link, and `share_agentpickup`'s own card, point at the add page
31
+ (`addPageUrl`, default https://agentpickup.app/add) so friends can add it too.
32
+
22
33
  ## Use it
23
34
 
24
- Try it against the hosted demo shop first. It pays with the demo's own small wallet, within the demo's caps:
35
+ Try it against the hosted demo shops first. The hosted server holds no wallet: its demo shops take a free test
36
+ payment, the Square sandbox's test card, or USDC from the user's own x402 wallet:
25
37
 
26
38
  ```json
27
39
  { "mcpServers": { "agentpickup": { "type": "http", "url": "https://agentpickup.app/mcp" } } }
package/dist/card.d.ts ADDED
@@ -0,0 +1,27 @@
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
+ /** The add page's share mode, for "Invite a friend". */
24
+ inviteUrl?: string;
25
+ }
26
+ /** The card's page. Its version goes in the handshake, so a host's logs say which card it ran. */
27
+ export declare function passCardHtml(version: string): string;
package/dist/card.js ADDED
@@ -0,0 +1,270 @@
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;display:flex;justify-content:space-between;align-items:center;gap:8px}
58
+ .foot button.link{font-size:12px}
59
+ @media (prefers-reduced-motion:reduce){.pour{transition:none}.wrap.ready::after{animation:none;opacity:1}}
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <div id="root"></div>
64
+ <script>
65
+ (function () {
66
+ var VERSION = "__VERSION__";
67
+ var LABELS = { awaiting_payment: "Waiting for payment", paid: "Paid", ready: "Ready", collected: "Collected", expired: "Expired", cancelled: "Cancelled" };
68
+ var FINAL = { collected: 1, expired: 1, cancelled: 1 };
69
+ var root = document.getElementById("root");
70
+ var state = null, canCall = false, canOpen = false, locale, timeZone;
71
+ var nextId = 1, pending = {}, timer = null, started = Date.now(), lastHtml = "", poured = false, lastHeight = -1, stopped = false;
72
+
73
+ // ---------- the bridge ----------
74
+ var host = window.parent;
75
+ function send(msg) { try { host.postMessage(msg, "*"); } catch (e) {} }
76
+ function request(method, params) {
77
+ var id = nextId++;
78
+ send({ jsonrpc: "2.0", id: id, method: method, params: params });
79
+ return new Promise(function (resolve, reject) {
80
+ pending[id] = { resolve: resolve, reject: reject };
81
+ setTimeout(function () { if (pending[id]) { delete pending[id]; reject(new Error("timed out")); } }, 30000);
82
+ });
83
+ }
84
+ function notify(method, params) { send({ jsonrpc: "2.0", method: method, params: params }); }
85
+ window.addEventListener("message", function (e) {
86
+ if (host !== window && e.source !== host) return;
87
+ var m = e.data;
88
+ if (!m || m.jsonrpc !== "2.0") return;
89
+ if (m.id != null && !m.method) {
90
+ var p = pending[m.id];
91
+ if (p) { delete pending[m.id]; if (m.error) p.reject(m.error); else p.resolve(m.result); }
92
+ return;
93
+ }
94
+ if (m.method === "ui/notifications/tool-result") return onResult(m.params);
95
+ if (m.method === "ui/notifications/host-context-changed") { applyContext(m.params || {}); return render(); }
96
+ if (m.method === "ui/resource-teardown") { stop(); if (m.id != null) send({ jsonrpc: "2.0", id: m.id, result: {} }); return; }
97
+ if (m.method === "ping" && m.id != null) return send({ jsonrpc: "2.0", id: m.id, result: {} });
98
+ if (m.id != null && m.method && m.method.indexOf("ui/notifications/") !== 0) {
99
+ send({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "Method not found" } });
100
+ }
101
+ });
102
+ function applyContext(ctx) {
103
+ if (ctx.theme === "light" || ctx.theme === "dark") document.documentElement.setAttribute("data-theme", ctx.theme);
104
+ var vars = ctx.styles && ctx.styles.variables;
105
+ if (vars) for (var k in vars) if (k.indexOf("--") === 0 && typeof vars[k] === "string") document.documentElement.style.setProperty(k, vars[k]);
106
+ if (ctx.locale) locale = ctx.locale;
107
+ if (ctx.timeZone) timeZone = ctx.timeZone;
108
+ }
109
+ function openLink(url) {
110
+ if (!url) return;
111
+ if (canOpen) request("ui/open-link", { url: url }).catch(function () { window.open(url, "_blank", "noopener"); });
112
+ else window.open(url, "_blank", "noopener");
113
+ }
114
+
115
+ // ---------- results ----------
116
+ function cardOf(result) {
117
+ if (!result) return null;
118
+ var meta = result._meta && result._meta["agentpickup/pass"];
119
+ if (meta && meta.orderId) return meta;
120
+ // A host that drops _meta: the JSON after the tool's summary line still has the basics.
121
+ try {
122
+ var text = result.content && result.content[0] && result.content[0].text;
123
+ var data = JSON.parse(text.slice(text.indexOf("\n\n") + 2));
124
+ var id = data.orderId || data.id || (data.order && data.order.id);
125
+ if (!id) return null;
126
+ return { orderId: id, status: data.status, passUrl: data.passUrl, pickupCode: data.pickupCode, checkoutUrl: data.checkoutUrl, total: data.total, claim: data.claim, shopUrl: data.shop };
127
+ } catch (e) { return null; }
128
+ }
129
+ function onResult(result) {
130
+ var card = cardOf(result);
131
+ if (card) {
132
+ if (state && state.orderId !== card.orderId) state = null;
133
+ var next = state ? Object.assign({}, state) : {};
134
+ for (var k in card) if (card[k] !== undefined && card[k] !== null && card[k] !== "") next[k] = card[k];
135
+ if (card.status && card.status !== "awaiting_payment") delete next.checkoutUrl;
136
+ state = next;
137
+ }
138
+ render();
139
+ schedule();
140
+ }
141
+
142
+ // ---------- keeping up to date ----------
143
+ function stop() { stopped = true; clearTimeout(timer); }
144
+ function schedule() {
145
+ clearTimeout(timer);
146
+ if (stopped || !state || !state.claim || !canCall || FINAL[state.status]) return;
147
+ if (Date.now() - started > 4 * 3600 * 1000) return;
148
+ var wait = document.hidden ? 60000 : state.status === "awaiting_payment" ? 5000 : 15000;
149
+ timer = setTimeout(poll, wait);
150
+ }
151
+ function poll() {
152
+ var args = { order_id: state.orderId, claim: state.claim };
153
+ if (state.shopUrl) args.shop_url = state.shopUrl;
154
+ request("tools/call", { name: "order_status", arguments: args }).then(function (r) {
155
+ if (r && !r.isError) onResult(r); else schedule();
156
+ }, schedule);
157
+ }
158
+ document.addEventListener("visibilitychange", function () { if (!document.hidden && state) { clearTimeout(timer); poll(); } });
159
+
160
+ // ---------- drawing ----------
161
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
162
+ function time(ms) {
163
+ var d = new Date(ms), o = { hour: "numeric", minute: "2-digit" };
164
+ if (timeZone) o.timeZone = timeZone;
165
+ try {
166
+ var s = d.toLocaleTimeString(locale, o);
167
+ if (Math.abs(ms - Date.now()) > 18 * 3600 * 1000) {
168
+ var od = { weekday: "short" };
169
+ if (timeZone) od.timeZone = timeZone;
170
+ s = d.toLocaleDateString(locale, od) + " " + s;
171
+ }
172
+ return s;
173
+ } catch (e) { return d.toLocaleTimeString(); }
174
+ }
175
+ function safeSvg(svg) { return typeof svg === "string" && /^<svg[\s>]/.test(svg) && !/<script|<foreignObject|\son[a-z]+=/i.test(svg) ? svg : ""; }
176
+ function message(s) {
177
+ var live = canCall && s.claim;
178
+ switch (s.status) {
179
+ case "awaiting_payment":
180
+ return s.checkoutUrl
181
+ ? "Pay on the checkout page. " + (live ? "This card turns into your pickup pass once it's paid." : "Then ask for the pickup pass.")
182
+ : "Waiting for the payment. " + (live ? "This card turns into your pickup pass once it's paid." : "");
183
+ case "paid": return "Paid. " + (live ? "This turns green when it's ready. " : "") + "The pass is in your email too.";
184
+ case "ready": return "Ready at the counter. Show the QR or say the code.";
185
+ case "collected": return s.collectedAt ? "Collected at " + time(s.collectedAt) + ". Enjoy." : "Collected. Enjoy.";
186
+ case "expired": return "This order expired before anyone collected it.";
187
+ case "cancelled": return "This order was cancelled.";
188
+ default: return "";
189
+ }
190
+ }
191
+ function view(s) {
192
+ var status = s.status || "awaiting_payment";
193
+ var label = LABELS[status] || status;
194
+ var live = status === "paid" || status === "ready";
195
+ var h = '<div class="card">';
196
+ h += '<div class="head"><div><p class="shop">' + esc(s.shopName || "Your pickup order") + "</p>";
197
+ if (s.items) h += '<p class="items">' + esc(s.items) + (s.total ? " · " + esc(s.total) : "") + "</p>";
198
+ h += '</div><span class="badge' + (status === "ready" ? " ready" : "") + '">' + esc(label) + "</span></div>";
199
+ var svg = safeSvg(s.qrSvg);
200
+ if (live && svg) {
201
+ h += '<div class="wrap' + (status === "ready" && poured ? " ready" : "") + '" id="wrap"><div class="tile"><div class="pour"></div>' +
202
+ svg.replace("<svg", '<svg role="img" aria-label="Pickup QR code"') + "</div></div>";
203
+ } else if (status === "collected") {
204
+ h += '<div class="wrap"><div class="tile plain"><div class="done"><b>&#10003;</b>Collected</div></div></div>';
205
+ }
206
+ if (live && (s.pickupCode || s.collectBy)) {
207
+ h += '<div class="facts">';
208
+ if (s.pickupCode) h += '<div class="fact"><span>Pickup code</span><b class="code">' + esc(s.pickupCode) + "</b></div>";
209
+ if (s.collectBy) h += '<div class="fact"><span>Collect by</span><b>' + esc(time(s.collectBy)) + "</b></div>";
210
+ h += "</div>";
211
+ }
212
+ if (live || status === "collected") {
213
+ var at = { paid: 0, ready: 1, collected: 2 }[status];
214
+ h += '<div class="steps">' + ["Paid", "Ready", "Collected"].map(function (name, i) {
215
+ return '<span class="step' + (i < at ? " past" : i === at ? " now" : "") + '"><span class="dot"></span>' + name + "</span>";
216
+ }).join("") + "</div>";
217
+ }
218
+ var msg = message(s);
219
+ if (msg) h += '<p class="msg">' + esc(msg) + "</p>";
220
+ if (s.rail === "test") h += '<p class="test">Test order: no money moved.</p>';
221
+ var actions = "";
222
+ if (status === "awaiting_payment" && s.checkoutUrl) actions += '<button class="pay" data-open="checkoutUrl">Pay' + (s.total ? " " + esc(s.total) : "") + " by card</button>";
223
+ if (live && s.passUrl) actions += '<button class="link" data-open="passUrl">Open the pass</button>';
224
+ if (actions) h += '<div class="row">' + actions + "</div>";
225
+ h += '<div class="foot">' + (s.inviteUrl ? '<button class="link" data-open="inviteUrl">Invite a friend</button>' : "<span></span>") + "<span>agentPickup</span></div></div>";
226
+ return h;
227
+ }
228
+ function render() {
229
+ var html = state ? view(state) : "";
230
+ if (html !== lastHtml) {
231
+ root.innerHTML = html;
232
+ lastHtml = html;
233
+ var buttons = root.querySelectorAll("[data-open]");
234
+ for (var i = 0; i < buttons.length; i++) buttons[i].addEventListener("click", function (e) { openLink(state[e.currentTarget.getAttribute("data-open")]); });
235
+ }
236
+ // The lime pours in once, the first time the card sees the order ready.
237
+ var wrap = document.getElementById("wrap");
238
+ if (wrap && state && state.status === "ready" && !poured) {
239
+ void wrap.offsetWidth;
240
+ wrap.classList.add("ready");
241
+ poured = true;
242
+ lastHtml = view(state);
243
+ }
244
+ reportSize();
245
+ }
246
+ function reportSize() {
247
+ var height = Math.ceil(document.body.getBoundingClientRect().height);
248
+ if (height !== lastHeight) { lastHeight = height; notify("ui/notifications/size-changed", { height: height }); }
249
+ }
250
+ if (window.ResizeObserver) new ResizeObserver(reportSize).observe(document.body);
251
+
252
+ // ---------- start ----------
253
+ request("ui/initialize", { appInfo: { name: "agentpickup-pass", version: VERSION }, appCapabilities: {}, protocolVersion: "2026-01-26" })
254
+ .then(function (r) {
255
+ var caps = (r && r.hostCapabilities) || {};
256
+ canCall = Boolean(caps.serverTools);
257
+ canOpen = Boolean(caps.openLinks);
258
+ applyContext((r && r.hostContext) || {});
259
+ notify("ui/notifications/initialized", {});
260
+ render();
261
+ schedule();
262
+ }, function () { render(); });
263
+ })();
264
+ </script>
265
+ </body>
266
+ </html>`;
267
+ /** The card's page. Its version goes in the handshake, so a host's logs say which card it ran. */
268
+ export function passCardHtml(version) {
269
+ return PAGE.replace("__VERSION__", version);
270
+ }
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.1";
4
4
  export interface PickupMcpOptions {
5
5
  /** The agentPickup shop this server orders from. */
6
6
  shopUrl: string;
@@ -22,6 +22,11 @@ export interface PickupMcpOptions {
22
22
  payer?: Payer;
23
23
  /** Notes added to the instructions agents read on connecting, e.g. which of this host's shops take what. */
24
24
  instructions?: string;
25
+ /**
26
+ * The page where people add agentPickup to their assistant, which share_agentpickup and the pass card's
27
+ * "Invite a friend" link point to. Default https://agentpickup.app/add.
28
+ */
29
+ addPageUrl?: string;
25
30
  }
26
31
  export declare function createPickupMcpServer(opts: PickupMcpOptions): McpServer;
27
32
  /** A Streamable HTTP endpoint (stateless, JSON responses) for hosting the server, e.g. at /mcp. */
package/dist/server.js CHANGED
@@ -2,27 +2,54 @@
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";
8
+ import { SHARE_META_KEY, shareCardHtml } from "./share-card.js";
6
9
  import { errorOf, usd } from "./wallet.js";
7
- export const VERSION = "0.3.3";
10
+ export const VERSION = "0.4.1";
11
+ // The pickup pass card (MCP Apps). The version is in the URI so hosts that cache cards pick up a new one.
12
+ const CARD_URI = `ui://agentpickup/pass-${VERSION}.html`;
13
+ const CARD_TOOL_META = { ui: { resourceUri: CARD_URI }, "ui/resourceUri": CARD_URI };
14
+ const SHARE_URI = `ui://agentpickup/share-${VERSION}.html`;
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.
18
+ const shopNames = new Map();
19
+ const rememberShop = (shop, name) => {
20
+ if (typeof name !== "string" || !name)
21
+ return;
22
+ if (shopNames.size > 500)
23
+ shopNames.clear();
24
+ shopNames.set(shop, name);
25
+ };
26
+ /** A shop's name as the add page's ?shop= matches it: "Demo Café" -> "demo-cafe". */
27
+ 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" } });
8
30
  class ToolError extends Error {
9
31
  }
10
32
  const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
11
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.
12
34
  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.
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.
14
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
15
38
  own x402 wallet. Where the shop takes cards, payment: "card" on
16
39
  create_pickup_order gives a checkout link for the user to pay on instead. Where it takes test payments (get_shop says
17
40
  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.
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 asks how to share agentPickup or get friends using it, shows a QR and link.
20
46
  Group orders (a coffee run): start_group_order gives a code to share; each person's agent calls join_group_order and
21
47
  pays its own share with pay_order; at closing time the shop gets one combined order and the organiser gets its QR.
22
48
  group_order_status shows who's in; close_group_order closes it early.`;
23
49
  const money = (cents, currency) => new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
24
- const reply = (summary, data) => ({
50
+ const reply = (summary, data, card) => ({
25
51
  content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(data, null, 2)}` }],
52
+ ...(card ? { _meta: { [CARD_META_KEY]: card } } : {}),
26
53
  });
27
54
  const failure = (message) => ({ content: [{ type: "text", text: message }], isError: true });
28
55
  /** 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 +100,56 @@ export function createPickupMcpServer(opts) {
73
100
  return failure(`Couldn't reach the shop: ${err instanceof Error ? err.message : String(err)}`);
74
101
  }
75
102
  };
103
+ /** The pass, for whoever made the order. null while it isn't paid (or is a share of a group order). */
104
+ async function passByClaim(shop, orderId, claim) {
105
+ const res = await doFetch(new Request(`${shop}/api/orders/${encodeURIComponent(orderId)}/pass`, { headers: { "X-Pickup-Claim": claim } }));
106
+ if (res.status === 409)
107
+ return null;
108
+ if (!res.ok)
109
+ throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
110
+ return (await res.json());
111
+ }
112
+ async function shopName(shop) {
113
+ if (!shopNames.has(shop)) {
114
+ const data = await call(shop, "/api/catalog").catch(() => null);
115
+ rememberShop(shop, data?.merchant?.name);
116
+ }
117
+ return shopNames.get(shop);
118
+ }
119
+ /** What the pass card draws for an order, with its QR once there's a pass. */
120
+ async function cardFor(shop, order, extra = {}, pass) {
121
+ return {
122
+ v: 1,
123
+ orderId: order.id,
124
+ shopUrl: shop,
125
+ shopName: shopNames.get(shop),
126
+ status: order.status,
127
+ rail: order.rail,
128
+ items: order.items.map((i) => `${i.qty}× ${i.name}`).join(", "),
129
+ total: money(order.totalCents, order.currency),
130
+ collectBy: order.collectBy,
131
+ collectedAt: order.collectedAt ?? null,
132
+ inviteUrl: addLinks(shop).sharePageUrl,
133
+ ...extra,
134
+ ...(pass ? { passUrl: pass.passUrl, pickupCode: pass.pickupCode, qrSvg: await qrSvg(pass.qrContent) } : {}),
135
+ };
136
+ }
137
+ /** The add page for sharing: for one of this host's shops when given (?shop=<slug>), else everyone's. */
138
+ const addPage = (opts.addPageUrl ?? "https://agentpickup.app/add").replace(/\/$/, "");
139
+ function addLinks(shop) {
140
+ let slug = "";
141
+ if (shop) {
142
+ const u = new URL(shop);
143
+ slug = u.pathname.match(/\/v\/([^/]+)/)?.[1] ?? (u.pathname === "/" || u.pathname === "" ? slugOf(shopNames.get(shop) ?? "") : "");
144
+ }
145
+ const q = slug ? `shop=${encodeURIComponent(slug)}` : "";
146
+ return { url: `${addPage}${q ? `?${q}` : ""}`, sharePageUrl: `${addPage}?share=1${q ? `&${q}` : ""}` };
147
+ }
148
+ const claimArg = z
149
+ .string()
150
+ .max(200)
151
+ .optional()
152
+ .describe("The claim create_pickup_order returned with the order. With it, a paid order's pass comes back too.");
76
153
  const shopArg = opts.allowOtherShops || opts.directoryUrl
77
154
  ? { shop_url: z.string().optional().describe(`An agentPickup shop's URL, as find_shops returns it. Defaults to ${home}.`) }
78
155
  : {};
@@ -83,6 +160,14 @@ export function createPickupMcpServer(opts) {
83
160
  websiteUrl: "https://agentpickup.app",
84
161
  icons: [{ src: "https://agentpickup.app/logo.png", mimeType: "image/png", sizes: ["1024x1024"] }],
85
162
  }, { instructions: opts.instructions ? `${INSTRUCTIONS}\n${opts.instructions}` : INSTRUCTIONS });
163
+ server.registerResource("pickup-pass", CARD_URI, {
164
+ title: "Pickup pass",
165
+ 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.",
166
+ mimeType: CARD_MIME_TYPE,
167
+ _meta: { ui: { prefersBorder: false } },
168
+ }, async () => ({
169
+ contents: [{ uri: CARD_URI, mimeType: CARD_MIME_TYPE, text: passCardHtml(VERSION), _meta: { ui: { prefersBorder: false } } }],
170
+ }));
86
171
  if (opts.directoryUrl) {
87
172
  const directoryUrl = opts.directoryUrl;
88
173
  server.registerTool("find_shops", {
@@ -154,6 +239,7 @@ export function createPickupMcpServer(opts) {
154
239
  const shop = await shopFor(args.shop_url);
155
240
  const data = await call(shop, "/api/catalog");
156
241
  const merchant = data.merchant;
242
+ rememberShop(shop, merchant.name);
157
243
  const currency = String(data.currency ?? "usd");
158
244
  const items = data.items.map((i) => ({
159
245
  id: i.id,
@@ -177,14 +263,14 @@ export function createPickupMcpServer(opts) {
177
263
  }));
178
264
  server.registerTool("create_pickup_order", {
179
265
  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.",
266
+ description: "Create an order to collect in person. Returns the order id, total and a claim (keep it); pay it with pay_order. " +
267
+ "The shop emails the pickup pass to `email` once it's paid, and where the chat shows cards the order shows as one " +
268
+ "that becomes the pass. Where the shop takes cards, `payment: \"card\"` returns a checkout link for the user to pay on instead.",
183
269
  inputSchema: {
184
270
  items: z
185
271
  .array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) }))
186
272
  .min(1),
187
- email: z.string().email().describe("The user's email. The pickup QR goes here."),
273
+ email: z.string().email().describe("The user's email. The pickup pass goes here too."),
188
274
  name: z.string().max(100).optional().describe("Name for the order, shown to staff"),
189
275
  payment: z
190
276
  .enum(["agent", "card", "test"])
@@ -194,11 +280,14 @@ export function createPickupMcpServer(opts) {
194
280
  ...shopArg,
195
281
  },
196
282
  annotations: { title: "Order for pickup", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
283
+ _meta: CARD_TOOL_META,
197
284
  }, guarded(async (args) => {
198
285
  const shop = await shopFor(args.shop_url);
199
286
  // The agent pays over x402 where the shop takes it, a test shop marks the order paid straight away, and a
200
287
  // shop that takes cards gives the user a checkout link.
201
- const { payments } = (await call(shop, "/api/catalog"));
288
+ const catalog = (await call(shop, "/api/catalog"));
289
+ rememberShop(shop, catalog.merchant?.name);
290
+ const { payments } = catalog;
202
291
  const agentRail = payments.x402 ? "x402" : payments.test ? "test" : null;
203
292
  const cardRail = payments.square ? "square" : payments.card ? "card" : null;
204
293
  const rail = args.payment === "test" ? (payments.test ? "test" : null)
@@ -217,16 +306,31 @@ export function createPickupMcpServer(opts) {
217
306
  });
218
307
  const order = data.order;
219
308
  const next = data.next;
309
+ // Older shops don't hand out claims; everything else still works.
310
+ const claim = typeof data.claim === "string" ? data.claim : undefined;
311
+ const what = `${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}`;
312
+ const keep = claim ? " Keep the claim: get_pickup_pass and order_status take it." : "";
220
313
  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 });
314
+ const pass = claim ? await passByClaim(shop, order.id, claim).catch(() => null) : null;
315
+ return reply(`Test order ${order.id}: ${what}, marked paid with no money moving. The pickup pass is at ${next.passUrl}` +
316
+ (pass ? ` (pickup code ${pass.pickupCode})` : "") +
317
+ `, and the shop emails it to ${args.email}.${keep}`, {
318
+ shop,
319
+ orderId: order.id,
320
+ claim,
321
+ status: order.status,
322
+ test: true,
323
+ total: money(order.totalCents, order.currency),
324
+ items: order.items,
325
+ passUrl: next.passUrl,
326
+ pickupCode: pass?.pickupCode,
327
+ }, await cardFor(shop, order, { claim, rail }, pass));
223
328
  }
224
329
  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 });
330
+ return reply(`Order ${order.id}: ${what}. Give the user this link to pay by card: ${next.url}. Once they've paid, the shop emails ` +
331
+ `${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
332
  }
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 });
333
+ 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
334
  }));
231
335
  server.registerTool("pay_order", {
232
336
  title: "Pay for an order",
@@ -279,12 +383,14 @@ export function createPickupMcpServer(opts) {
279
383
  }));
280
384
  server.registerTool("order_status", {
281
385
  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 },
386
+ description: "Whether an order is waiting for payment, paid, ready to collect, collected or expired. With the order's claim, " +
387
+ "a paid order's pass comes back too, and the chat's card for it updates.",
388
+ inputSchema: { order_id: z.string(), claim: claimArg, ...shopArg },
284
389
  annotations: { title: "Check an order", readOnlyHint: true, openWorldHint: true },
390
+ _meta: CARD_TOOL_META,
285
391
  }, guarded(async (args) => {
286
392
  const shop = await shopFor(args.shop_url);
287
- const { order } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
393
+ const { order, checkoutUrl } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
288
394
  const words = {
289
395
  awaiting_payment: "is waiting for payment",
290
396
  paid: "is paid and being prepared",
@@ -293,22 +399,72 @@ export function createPickupMcpServer(opts) {
293
399
  expired: "expired before anyone collected it",
294
400
  cancelled: "was cancelled",
295
401
  };
296
- return reply(`Order ${order.id} ${words[order.status] ?? `is ${order.status}`}.`, order);
402
+ const pass = args.claim && (order.status === "paid" || order.status === "ready") ? await passByClaim(shop, order.id, args.claim).catch(() => null) : null;
403
+ 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
404
  }));
298
405
  server.registerTool("get_pickup_pass", {
299
406
  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 },
407
+ description: "The pass link, pickup code and QR for a paid order: with the claim create_pickup_order returned" +
408
+ (opts.payer ? ", or by signing in with the wallet that paid (Sign-In-With-X)." : "."),
409
+ inputSchema: { order_id: z.string(), claim: claimArg, ...shopArg },
302
410
  annotations: { title: "Get the pickup pass", readOnlyHint: true, openWorldHint: true },
411
+ _meta: CARD_TOOL_META,
303
412
  }, 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
413
  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);
414
+ let pass;
415
+ if (args.claim) {
416
+ pass = await passByClaim(shop, args.order_id, args.claim);
417
+ if (!pass)
418
+ throw new ToolError("This order isn't paid yet, so there's no pass. order_status says where it's at.");
419
+ }
420
+ else if (opts.payer) {
421
+ const res = await opts.payer.signIn(`${shop}/api/orders/${encodeURIComponent(args.order_id)}/pass`, doFetch);
422
+ if (!res.ok)
423
+ throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
424
+ pass = (await res.json());
425
+ }
426
+ else {
427
+ 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.");
428
+ }
429
+ await shopName(shop);
430
+ 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);
431
+ }));
432
+ // ---------- sharing ----------
433
+ server.registerResource("share-agentpickup", SHARE_URI, {
434
+ title: "Share agentPickup",
435
+ description: "A QR and link for friends to add agentPickup to their assistant.",
436
+ mimeType: CARD_MIME_TYPE,
437
+ _meta: { ui: { prefersBorder: false } },
438
+ }, async () => ({
439
+ contents: [{ uri: SHARE_URI, mimeType: CARD_MIME_TYPE, text: shareCardHtml(VERSION), _meta: { ui: { prefersBorder: false } } }],
440
+ }));
441
+ server.registerTool("share_agentpickup", {
442
+ title: "Share agentPickup",
443
+ description: "A QR code and link the user can share so friends can add agentPickup to Claude, ChatGPT or Grok and order too. " +
444
+ "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.",
445
+ inputSchema: { ...shopArg },
446
+ annotations: { title: "Share agentPickup", readOnlyHint: true, openWorldHint: false },
447
+ _meta: SHARE_TOOL_META,
448
+ }, guarded(async (args) => {
449
+ const shop = args.shop_url ? await shopFor(args.shop_url) : undefined;
450
+ const name = shop ? await shopName(shop) : undefined;
451
+ const links = addLinks(shop);
452
+ const card = {
453
+ v: 1,
454
+ ...links,
455
+ qrSvg: await QRCode.toString(links.url, { type: "svg", errorCorrectionLevel: "M", margin: 0, color: { dark: "#0a0b0dff", light: "#00000000" } }),
456
+ ...(name ? { shopName: name } : {}),
457
+ };
458
+ return {
459
+ content: [
460
+ {
461
+ type: "text",
462
+ 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, ` +
463
+ `then just asks for their usual${name ? ` at ${name}` : ""}. ${links.sharePageUrl} opens the phone's share sheet and has a sticker to print.`,
464
+ },
465
+ ],
466
+ _meta: { [SHARE_META_KEY]: card },
467
+ };
312
468
  }));
313
469
  const closesIn = (g) => {
314
470
  const minutes = Math.max(0, Math.round((g.closesAt - Date.now()) / 60_000));
@@ -0,0 +1,13 @@
1
+ export declare const SHARE_META_KEY = "agentpickup/share";
2
+ export interface ShareCard {
3
+ v: 1;
4
+ /** The add page, maybe for one café (?shop=). */
5
+ url: string;
6
+ /** The add page's share mode: the share sheet and the sticker. */
7
+ sharePageUrl: string;
8
+ /** The QR as SVG, ink on transparent, for the white tile. */
9
+ qrSvg: string;
10
+ /** A café to name, when the link is for one. */
11
+ shopName?: string;
12
+ }
13
+ export declare function shareCardHtml(version: string): string;
@@ -0,0 +1,142 @@
1
+ // The share card (MCP Apps): what share_agentpickup shows when someone asks how to get friends using agentPickup.
2
+ // The counter sticker as a card: a QR to the add page that a friend scans off the screen, the link to copy, and
3
+ // buttons that open the add page's share mode (the phone's share sheet, and the printable sticker). Self-contained,
4
+ // like the pass card: no scripts, fonts or images from anywhere else, and the same small MCP Apps bridge.
5
+ export const SHARE_META_KEY = "agentpickup/share";
6
+ const PAGE = String.raw `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width,initial-scale=1">
11
+ <title>Share agentPickup</title>
12
+ <style>
13
+ :root{--bg:#0a0b0d;--text:#eef0f3;--muted:#b7bdc6;--dim:#8b929c;--line:#2a2f36;--lime:#b5f23d;--ink:#0a0b0d}
14
+ html,body{margin:0;padding:0;background:transparent}
15
+ body{font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif);padding:2px;-webkit-font-smoothing:antialiased}
16
+ .card{box-sizing:border-box;max-width:420px;margin:0 auto;background:var(--bg);color:var(--text);border-radius:18px;padding:20px 20px 16px}
17
+ .top{display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px}
18
+ .top svg{width:22px;height:22px;border-radius:5px}
19
+ .top .dim{color:var(--dim)}
20
+ h1{margin:14px 0 0;font-size:34px;line-height:.95;font-weight:850;letter-spacing:-.02em;text-transform:uppercase}
21
+ h1 em{font-style:normal;color:var(--lime)}
22
+ .sub{margin:8px 0 0;color:var(--muted);font-size:15px}
23
+ .row{display:flex;gap:16px;align-items:flex-end;margin-top:16px}
24
+ .tile{background:#fff;border-radius:14px;padding:12px;width:156px;height:156px;flex:none;box-sizing:border-box}
25
+ .tile svg{display:block;width:100%;height:100%}
26
+ ol{list-style:none;margin:0;padding:0;counter-reset:n;color:var(--muted);font-size:15px}
27
+ li{counter-increment:n;margin:0 0 8px;display:flex;align-items:center;gap:8px}
28
+ li::before{content:counter(n);width:22px;height:22px;border-radius:50%;border:1.5px solid var(--line);display:inline-flex;align-items:center;justify-content:center;font:600 12px ui-monospace,Menlo,monospace;color:var(--lime);flex:none}
29
+ .link{margin-top:14px;display:flex;gap:8px;align-items:center}
30
+ .link code{flex:1;min-width:0;overflow-wrap:anywhere;font:13px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text);background:#16191d;border:1px solid var(--line);border-radius:10px;padding:9px 10px;user-select:all;-webkit-user-select:all}
31
+ button{font-weight:600;font-size:14px;font-family:inherit;border-radius:10px;padding:10px 14px;cursor:pointer;border:1px solid var(--line);background:transparent;color:var(--text);white-space:nowrap}
32
+ button.lime{background:var(--lime);color:var(--ink);border-color:var(--lime)}
33
+ .actions{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
34
+ .actions button{flex:1}
35
+ .note{margin:10px 0 0;font-size:12px;color:var(--dim)}
36
+ @media (max-width:360px){.row{flex-direction:column;align-items:flex-start}}
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div id="root"></div>
41
+ <script>
42
+ (function () {
43
+ var VERSION = "__VERSION__";
44
+ var root = document.getElementById("root");
45
+ var state = null, canOpen = false, nextId = 1, pending = {}, lastHeight = -1;
46
+ var host = window.parent;
47
+ function send(msg) { try { host.postMessage(msg, "*"); } catch (e) {} }
48
+ function request(method, params) {
49
+ var id = nextId++;
50
+ send({ jsonrpc: "2.0", id: id, method: method, params: params });
51
+ return new Promise(function (resolve, reject) {
52
+ pending[id] = { resolve: resolve, reject: reject };
53
+ setTimeout(function () { if (pending[id]) { delete pending[id]; reject(new Error("timed out")); } }, 30000);
54
+ });
55
+ }
56
+ function notify(method, params) { send({ jsonrpc: "2.0", method: method, params: params }); }
57
+ window.addEventListener("message", function (e) {
58
+ if (host !== window && e.source !== host) return;
59
+ var m = e.data;
60
+ if (!m || m.jsonrpc !== "2.0") return;
61
+ if (m.id != null && !m.method) {
62
+ var p = pending[m.id];
63
+ if (p) { delete pending[m.id]; if (m.error) p.reject(m.error); else p.resolve(m.result); }
64
+ return;
65
+ }
66
+ if (m.method === "ui/notifications/tool-result") return onResult(m.params);
67
+ if (m.method === "ui/notifications/host-context-changed") { applyContext(m.params || {}); return; }
68
+ if (m.method === "ui/resource-teardown" && m.id != null) return send({ jsonrpc: "2.0", id: m.id, result: {} });
69
+ if (m.method === "ping" && m.id != null) return send({ jsonrpc: "2.0", id: m.id, result: {} });
70
+ if (m.id != null && m.method && m.method.indexOf("ui/notifications/") !== 0) send({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "Method not found" } });
71
+ });
72
+ function applyContext(ctx) {
73
+ var vars = ctx.styles && ctx.styles.variables;
74
+ if (vars && typeof vars["--font-sans"] === "string") document.documentElement.style.setProperty("--font-sans", vars["--font-sans"]);
75
+ }
76
+ function openLink(url) {
77
+ if (!url) return;
78
+ if (canOpen) request("ui/open-link", { url: url }).catch(function () { window.open(url, "_blank", "noopener"); });
79
+ else window.open(url, "_blank", "noopener");
80
+ }
81
+ function onResult(result) {
82
+ var data = result && result._meta && result._meta["agentpickup/share"];
83
+ if (data && data.url) state = data;
84
+ render();
85
+ }
86
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
87
+ function safeSvg(svg) { return typeof svg === "string" && /^<svg[\s>]/.test(svg) && !/<script|<foreignObject|\son[a-z]+=/i.test(svg) ? svg : ""; }
88
+ function copy(text, button) {
89
+ var done = function () { button.textContent = "Copied"; setTimeout(function () { button.textContent = "Copy"; }, 1600); };
90
+ try {
91
+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text).then(done, function () { select(); });
92
+ } catch (e) {}
93
+ select();
94
+ }
95
+ function select() {
96
+ var code = document.getElementById("url");
97
+ if (!code || !window.getSelection) return;
98
+ var range = document.createRange();
99
+ range.selectNodeContents(code);
100
+ var sel = window.getSelection();
101
+ sel.removeAllRanges();
102
+ sel.addRange(range);
103
+ }
104
+ function render() {
105
+ if (!state) { root.innerHTML = ""; return reportSize(); }
106
+ var svg = safeSvg(state.qrSvg);
107
+ var shown = state.url.replace(/^https?:\/\//, "");
108
+ root.innerHTML =
109
+ '<div class="card">' +
110
+ '<div class="top"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" fill="#16191d"/><rect x="7" y="7" width="7" height="7" rx="1.5" fill="#fff"/><rect x="18" y="7" width="7" height="7" rx="1.5" fill="#fff"/><rect x="7" y="18" width="7" height="7" rx="1.5" fill="#fff"/><path d="M18.5 21.5l2.5 2.5 4.5-5" fill="none" stroke="#b5f23d" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg><span><span class="dim">agent</span>Pickup</span></div>' +
111
+ "<h1>" + (state.shopName ? "Order from " + esc(state.shopName) + "<br>with your <em>AI</em>" : "Order ahead<br>from your <em>AI</em>") + "</h1>" +
112
+ '<p class="sub">Claude, ChatGPT or Grok</p>' +
113
+ '<div class="row">' + (svg ? '<div class="tile">' + svg.replace("<svg", '<svg role="img" aria-label="QR code to add agentPickup"') + "</div>" : "") +
114
+ "<ol><li>Scan</li><li>Add agentPickup</li><li>Ask for your usual</li></ol></div>" +
115
+ '<div class="link"><code id="url">' + esc(shown) + '</code><button type="button" id="copy">Copy</button></div>' +
116
+ '<div class="actions"><button class="lime" type="button" id="share">Share</button><button type="button" id="sticker">Get the sticker</button></div>' +
117
+ '<p class="note">A friend scans the QR off your screen, or you send them the link.</p>' +
118
+ "</div>";
119
+ document.getElementById("copy").addEventListener("click", function (e) { copy(state.url, e.currentTarget); });
120
+ document.getElementById("share").addEventListener("click", function () { openLink(state.sharePageUrl); });
121
+ document.getElementById("sticker").addEventListener("click", function () { openLink(state.sharePageUrl + (state.sharePageUrl.indexOf("?") >= 0 ? "&" : "?") + "sticker=1"); });
122
+ reportSize();
123
+ }
124
+ function reportSize() {
125
+ var height = Math.ceil(document.body.getBoundingClientRect().height);
126
+ if (height !== lastHeight) { lastHeight = height; notify("ui/notifications/size-changed", { height: height }); }
127
+ }
128
+ if (window.ResizeObserver) new ResizeObserver(reportSize).observe(document.body);
129
+ request("ui/initialize", { appInfo: { name: "agentpickup-share", version: VERSION }, appCapabilities: {}, protocolVersion: "2026-01-26" })
130
+ .then(function (r) {
131
+ canOpen = Boolean(r && r.hostCapabilities && r.hostCapabilities.openLinks);
132
+ applyContext((r && r.hostContext) || {});
133
+ notify("ui/notifications/initialized", {});
134
+ render();
135
+ }, function () { render(); });
136
+ })();
137
+ </script>
138
+ </body>
139
+ </html>`;
140
+ export function shareCardHtml(version) {
141
+ return PAGE.replace("__VERSION__", version);
142
+ }
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.1",
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
+ }