@agentpickup/mcp 0.0.0-stage → 0.1.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/LICENSE.md +105 -0
- package/README.md +62 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +179 -0
- package/dist/wallet.d.ts +33 -0
- package/dist/wallet.js +66 -0
- package/package.json +62 -6
package/LICENSE.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, ALv2 Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-ALv2
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 Dave Hallett
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the Apache License, Version 2.0 that is effective on the second anniversary of
|
|
91
|
+
the date we make the Software available. On or after that date, you may use the
|
|
92
|
+
Software under the Apache License, Version 2.0, in which case the following
|
|
93
|
+
will apply:
|
|
94
|
+
|
|
95
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
|
96
|
+
this file except in compliance with the License.
|
|
97
|
+
|
|
98
|
+
You may obtain a copy of the License at
|
|
99
|
+
|
|
100
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
101
|
+
|
|
102
|
+
Unless required by applicable law or agreed to in writing, software distributed
|
|
103
|
+
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
|
+
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
|
+
specific language governing permissions and limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agentpickup/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MCP server for [agentPickup](https://github.com/dav3trad3r/agentpickup) shops. It lets an AI agent see
|
|
4
|
+
what a shop sells, order it, pay in USDC over x402, and hand its user the QR they show at the counter to
|
|
5
|
+
collect.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `get_shop` | Items and prices, the pickup location, and how the shop takes payment |
|
|
12
|
+
| `create_pickup_order` | Creates an order. Needs the user's email, where the shop sends the pickup QR |
|
|
13
|
+
| `pay_order` | Pays the order's x402 link from your wallet and returns the pass link. With no wallet set up, returns the price and pay link instead |
|
|
14
|
+
| `order_status` | Waiting for payment, paid, ready, collected or expired |
|
|
15
|
+
| `get_pickup_pass` | Fetches the pass link, pickup code and QR again by signing in as the paying wallet (Sign-In-With-X) |
|
|
16
|
+
|
|
17
|
+
## Use it
|
|
18
|
+
|
|
19
|
+
Try it against the hosted demo shop first. It pays with the demo's own wallet in Base Sepolia test USDC:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{ "mcpServers": { "agentpickup": { "type": "http", "url": "https://web-production-3cfc1.up.railway.app/mcp" } } }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or run it locally against any agentPickup shop, with your own wallet:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"agentpickup": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "@agentpickup/mcp"],
|
|
33
|
+
"env": {
|
|
34
|
+
"AGENTPICKUP_SHOP_URL": "https://orders.yourshop.com",
|
|
35
|
+
"AGENTPICKUP_WALLET_KEY": "0x…",
|
|
36
|
+
"AGENTPICKUP_MAX_PER_ORDER": "$25"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Setting | Default | |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `AGENTPICKUP_SHOP_URL` | the demo shop | The shop to order from. Tool calls can also name another shop with `shop_url` |
|
|
46
|
+
| `AGENTPICKUP_WALLET_KEY` | none | An EVM private key holding USDC. Without it, `pay_order` only quotes |
|
|
47
|
+
| `AGENTPICKUP_MAX_PER_ORDER` | `$25` | The most the wallet pays for one order |
|
|
48
|
+
| `AGENTPICKUP_NETWORKS` | `eip155:84532` | Networks the wallet may pay on. Base Sepolia (test USDC) unless you add `eip155:8453` for Base mainnet |
|
|
49
|
+
|
|
50
|
+
Use a wallet that holds only what you're willing to let an agent spend. The tools tell the model to confirm
|
|
51
|
+
items and the total with the user before paying.
|
|
52
|
+
|
|
53
|
+
## Host it in your own server
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { createMcpHandler, walletPayer } from "@agentpickup/mcp";
|
|
57
|
+
|
|
58
|
+
const mcp = createMcpHandler({ shopUrl: "https://orders.yourshop.com" }); // no wallet: pay_order quotes
|
|
59
|
+
app.all("/mcp", (c) => mcp(c.req.raw)); // Streamable HTTP, stateless, any Request/Response runtime
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Node 22.13 or later. Licence: FSL-1.1-ALv2 (free to use and self-host; becomes Apache-2.0 two years after
|
|
63
|
+
each release).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stdio entry: `npx @agentpickup/mcp`. Logs go to stderr; stdout belongs to the protocol.
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createPickupMcpServer } from "./server.js";
|
|
5
|
+
import { walletPayer } from "./wallet.js";
|
|
6
|
+
const env = process.env;
|
|
7
|
+
const shopUrl = env.AGENTPICKUP_SHOP_URL || "https://web-production-3cfc1.up.railway.app";
|
|
8
|
+
const key = env.AGENTPICKUP_WALLET_KEY?.trim();
|
|
9
|
+
if (key && !/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
10
|
+
console.error("AGENTPICKUP_WALLET_KEY must be a 0x-prefixed 32-byte hex private key.");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
const payer = key
|
|
14
|
+
? walletPayer({
|
|
15
|
+
privateKey: key,
|
|
16
|
+
maxPerOrder: env.AGENTPICKUP_MAX_PER_ORDER || undefined,
|
|
17
|
+
networks: env.AGENTPICKUP_NETWORKS?.split(",").map((n) => n.trim()).filter(Boolean),
|
|
18
|
+
})
|
|
19
|
+
: undefined;
|
|
20
|
+
const server = createPickupMcpServer({ shopUrl, allowOtherShops: true, payer });
|
|
21
|
+
await server.connect(new StdioServerTransport());
|
|
22
|
+
console.error(`agentPickup MCP · shop ${shopUrl} · ${payer ? `wallet ${payer.address}` : "no wallet (pay_order returns the pay link)"}`);
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type Fetch, type Payer } from "./wallet.js";
|
|
3
|
+
export declare const VERSION = "0.1.0";
|
|
4
|
+
export interface PickupMcpOptions {
|
|
5
|
+
/** The agentPickup shop this server orders from. */
|
|
6
|
+
shopUrl: string;
|
|
7
|
+
/** Let tool calls name another agentPickup shop. Leave off for hosted servers, so callers can't point it anywhere. */
|
|
8
|
+
allowOtherShops?: boolean;
|
|
9
|
+
/** How requests reach the shop. Defaults to global fetch; a shop hosting its own server passes its app's fetch. */
|
|
10
|
+
fetch?: Fetch;
|
|
11
|
+
/** The wallet that pays. Without one, pay_order returns the price and pay link for another x402 client. */
|
|
12
|
+
payer?: Payer;
|
|
13
|
+
}
|
|
14
|
+
export declare function createPickupMcpServer(opts: PickupMcpOptions): McpServer;
|
|
15
|
+
/** A Streamable HTTP endpoint (stateless, JSON responses) for hosting the server, e.g. at /mcp. */
|
|
16
|
+
export declare function createMcpHandler(opts: PickupMcpOptions): (req: Request) => Promise<Response>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The agentPickup MCP server: lets any MCP client (Claude, ChatGPT, Cursor, an agent framework) find what a
|
|
2
|
+
// pickup shop sells, order it, pay over x402, and hand its user the QR to collect with.
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { errorOf, usd } from "./wallet.js";
|
|
7
|
+
export const VERSION = "0.1.0";
|
|
8
|
+
class ToolError extends Error {
|
|
9
|
+
}
|
|
10
|
+
const INSTRUCTIONS = `agentPickup lets you buy things online for the user to collect in person.
|
|
11
|
+
1. get_shop: see what the shop sells, its pickup location and how it takes payment.
|
|
12
|
+
2. create_pickup_order: needs the user's email, where the shop sends the pickup QR.
|
|
13
|
+
3. pay_order: pays from the configured wallet in USDC over x402. Confirm the items and total with the user first.
|
|
14
|
+
4. Give the user the pass link and pickup code. They show the QR at the counter to collect.
|
|
15
|
+
get_pickup_pass fetches the pass again later; order_status says whether it's ready or collected.`;
|
|
16
|
+
const money = (cents, currency) => new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
|
|
17
|
+
const reply = (summary, data) => ({
|
|
18
|
+
content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(data, null, 2)}` }],
|
|
19
|
+
});
|
|
20
|
+
const failure = (message) => ({ content: [{ type: "text", text: message }], isError: true });
|
|
21
|
+
export function createPickupMcpServer(opts) {
|
|
22
|
+
const home = new URL(opts.shopUrl).origin;
|
|
23
|
+
const doFetch = opts.fetch ?? ((req) => fetch(req));
|
|
24
|
+
function shopFor(shopUrl) {
|
|
25
|
+
if (!shopUrl)
|
|
26
|
+
return home;
|
|
27
|
+
let url;
|
|
28
|
+
try {
|
|
29
|
+
url = new URL(shopUrl);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new ToolError(`"${shopUrl}" isn't a URL.`);
|
|
33
|
+
}
|
|
34
|
+
if (url.origin === home)
|
|
35
|
+
return home;
|
|
36
|
+
if (!opts.allowOtherShops)
|
|
37
|
+
throw new ToolError(`This server orders from one shop only: ${home}.`);
|
|
38
|
+
if (url.protocol !== "https:" && url.hostname !== "localhost")
|
|
39
|
+
throw new ToolError("Shops must be served over https.");
|
|
40
|
+
return url.origin;
|
|
41
|
+
}
|
|
42
|
+
async function call(shop, path, init) {
|
|
43
|
+
const res = await doFetch(new Request(`${shop}${path}`, init));
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
// Tool errors go back to the model as text it can act on, not as protocol errors.
|
|
49
|
+
const guarded = (fn) => async (args) => {
|
|
50
|
+
try {
|
|
51
|
+
return await fn(args);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err instanceof ToolError)
|
|
55
|
+
return failure(err.message);
|
|
56
|
+
return failure(`Couldn't reach the shop: ${err instanceof Error ? err.message : String(err)}`);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const shopArg = opts.allowOtherShops
|
|
60
|
+
? { shop_url: z.string().optional().describe(`An agentPickup shop's URL. Defaults to ${home}.`) }
|
|
61
|
+
: {};
|
|
62
|
+
const server = new McpServer({ name: "agentpickup", title: "agentPickup", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
63
|
+
server.registerTool("get_shop", {
|
|
64
|
+
title: "See what a shop sells",
|
|
65
|
+
description: "What the shop sells (item ids and prices), where to collect, and which payments it takes.",
|
|
66
|
+
inputSchema: { ...shopArg },
|
|
67
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
68
|
+
}, guarded(async (args) => {
|
|
69
|
+
const shop = shopFor(args.shop_url);
|
|
70
|
+
const data = await call(shop, "/api/catalog");
|
|
71
|
+
const merchant = data.merchant;
|
|
72
|
+
const currency = String(data.currency ?? "usd");
|
|
73
|
+
const items = data.items.map((i) => ({
|
|
74
|
+
id: i.id,
|
|
75
|
+
name: i.name,
|
|
76
|
+
price: money(i.priceCents, currency),
|
|
77
|
+
description: i.description,
|
|
78
|
+
}));
|
|
79
|
+
const payments = data.payments;
|
|
80
|
+
return reply(`${merchant.name}${merchant.location?.name ? `, collect at ${merchant.location.name}` : ""}. ` +
|
|
81
|
+
(payments.x402 ? `Takes USDC over x402 on ${payments.x402.network}.` : "Doesn't take x402 payments."), { shop, merchant: merchant.name, location: merchant.location, currency, items, x402: payments.x402 ?? null });
|
|
82
|
+
}));
|
|
83
|
+
server.registerTool("create_pickup_order", {
|
|
84
|
+
title: "Order for pickup",
|
|
85
|
+
description: "Create an order to collect in person. Returns the order id and total; pay it with pay_order. " +
|
|
86
|
+
"The shop emails the pickup QR to `email` once it's paid.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
items: z
|
|
89
|
+
.array(z.object({ id: z.string().describe("Item id from get_shop"), qty: z.number().int().min(1).max(20) }))
|
|
90
|
+
.min(1),
|
|
91
|
+
email: z.string().email().describe("The user's email. The pickup QR goes here."),
|
|
92
|
+
name: z.string().max(100).optional().describe("Name for the order, shown to staff"),
|
|
93
|
+
...shopArg,
|
|
94
|
+
},
|
|
95
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
96
|
+
}, guarded(async (args) => {
|
|
97
|
+
const shop = shopFor(args.shop_url);
|
|
98
|
+
const data = await call(shop, "/api/orders", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ items: args.items, email: args.email, name: args.name, rail: "x402" }),
|
|
102
|
+
});
|
|
103
|
+
const order = data.order;
|
|
104
|
+
const next = data.next;
|
|
105
|
+
return reply(`Order ${order.id}: ${order.items.map((i) => `${i.qty}× ${i.name}`).join(", ")}, ${money(order.totalCents, order.currency)}. ` +
|
|
106
|
+
`Confirm with the user, then call pay_order.`, { shop, orderId: order.id, total: money(order.totalCents, order.currency), items: order.items, payUrl: next.url, collectBy: order.collectBy });
|
|
107
|
+
}));
|
|
108
|
+
server.registerTool("pay_order", {
|
|
109
|
+
title: "Pay for an order",
|
|
110
|
+
description: opts.payer
|
|
111
|
+
? `Pay an order in USDC over x402 from wallet ${opts.payer.address}. This spends money: confirm the items and total with the user first. Returns the pickup pass link.`
|
|
112
|
+
: "No wallet is set up here, so this returns the price and pay link for another x402 client to pay.",
|
|
113
|
+
inputSchema: { order_id: z.string(), ...shopArg },
|
|
114
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
115
|
+
}, guarded(async (args) => {
|
|
116
|
+
const shop = shopFor(args.shop_url);
|
|
117
|
+
const payUrl = `${shop}/api/orders/${encodeURIComponent(args.order_id)}/pay`;
|
|
118
|
+
if (!opts.payer) {
|
|
119
|
+
const res = await doFetch(new Request(payUrl, { method: "POST" }));
|
|
120
|
+
if (res.status !== 402)
|
|
121
|
+
throw new ToolError(await errorOf(res, "This order isn't waiting for an x402 payment."));
|
|
122
|
+
// x402 v2 carries the requirements in the PAYMENT-REQUIRED header; older servers put them in the body.
|
|
123
|
+
const header = res.headers.get("PAYMENT-REQUIRED");
|
|
124
|
+
const required = (header ? JSON.parse(Buffer.from(header, "base64").toString("utf8")) : await res.json());
|
|
125
|
+
const accept = required.accepts?.[0];
|
|
126
|
+
return reply(`Waiting for payment: ${usd(accept?.amount)} USDC on ${accept?.network}. No wallet is configured here; ` +
|
|
127
|
+
`POST the pay link with any x402 client, or set AGENTPICKUP_WALLET_KEY.`, { orderId: args.order_id, payUrl, amount: usd(accept?.amount), network: accept?.network, asset: accept?.asset, payTo: accept?.payTo });
|
|
128
|
+
}
|
|
129
|
+
const paid = await opts.payer.pay({ id: args.order_id, payUrl }, doFetch);
|
|
130
|
+
if (!paid.ok)
|
|
131
|
+
throw new ToolError(paid.error);
|
|
132
|
+
return reply(`Paid ${paid.amount ?? ""} USDC. Pickup pass: ${paid.passUrl}` +
|
|
133
|
+
(paid.emailedTo ? ` (also emailed to ${paid.emailedTo})` : "") +
|
|
134
|
+
`. The user shows its QR at the counter.`, { orderId: args.order_id, ...paid, ok: undefined });
|
|
135
|
+
}));
|
|
136
|
+
server.registerTool("order_status", {
|
|
137
|
+
title: "Check an order",
|
|
138
|
+
description: "Whether an order is waiting for payment, paid, ready to collect, collected or expired.",
|
|
139
|
+
inputSchema: { order_id: z.string(), ...shopArg },
|
|
140
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
141
|
+
}, guarded(async (args) => {
|
|
142
|
+
const shop = shopFor(args.shop_url);
|
|
143
|
+
const { order } = (await call(shop, `/api/orders/${encodeURIComponent(args.order_id)}`));
|
|
144
|
+
const words = {
|
|
145
|
+
awaiting_payment: "is waiting for payment",
|
|
146
|
+
paid: "is paid and being prepared",
|
|
147
|
+
ready: "is ready to collect",
|
|
148
|
+
collected: "has been collected",
|
|
149
|
+
expired: "expired before anyone collected it",
|
|
150
|
+
cancelled: "was cancelled",
|
|
151
|
+
};
|
|
152
|
+
return reply(`Order ${order.id} ${words[order.status] ?? `is ${order.status}`}.`, order);
|
|
153
|
+
}));
|
|
154
|
+
server.registerTool("get_pickup_pass", {
|
|
155
|
+
title: "Get the pickup pass",
|
|
156
|
+
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).",
|
|
157
|
+
inputSchema: { order_id: z.string(), ...shopArg },
|
|
158
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
159
|
+
}, guarded(async (args) => {
|
|
160
|
+
if (!opts.payer)
|
|
161
|
+
throw new ToolError("No wallet is set up here. The pass was emailed to the buyer when the order was paid.");
|
|
162
|
+
const shop = shopFor(args.shop_url);
|
|
163
|
+
const res = await opts.payer.signIn(`${shop}/api/orders/${encodeURIComponent(args.order_id)}/pass`, doFetch);
|
|
164
|
+
if (!res.ok)
|
|
165
|
+
throw new ToolError(await errorOf(res, `The shop answered ${res.status}.`));
|
|
166
|
+
const pass = (await res.json());
|
|
167
|
+
return reply(`Pickup pass: ${pass.passUrl}, pickup code ${pass.pickupCode}.`, pass);
|
|
168
|
+
}));
|
|
169
|
+
return server;
|
|
170
|
+
}
|
|
171
|
+
/** A Streamable HTTP endpoint (stateless, JSON responses) for hosting the server, e.g. at /mcp. */
|
|
172
|
+
export function createMcpHandler(opts) {
|
|
173
|
+
return async (req) => {
|
|
174
|
+
const server = createPickupMcpServer(opts);
|
|
175
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
176
|
+
await server.connect(transport);
|
|
177
|
+
return transport.handleRequest(req);
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type Fetch = (req: Request) => Response | Promise<Response>;
|
|
2
|
+
export type PayOutcome = {
|
|
3
|
+
ok: true;
|
|
4
|
+
passUrl: string;
|
|
5
|
+
emailedTo?: string;
|
|
6
|
+
amount?: string;
|
|
7
|
+
transaction?: string;
|
|
8
|
+
network?: string;
|
|
9
|
+
explorer?: string;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
error: string;
|
|
13
|
+
};
|
|
14
|
+
export interface Payer {
|
|
15
|
+
address: string;
|
|
16
|
+
/** Pay an order's x402 pay link. */
|
|
17
|
+
pay(order: {
|
|
18
|
+
id: string;
|
|
19
|
+
payUrl: string;
|
|
20
|
+
}, fetch: Fetch): Promise<PayOutcome>;
|
|
21
|
+
/** Sign in as the paying wallet to read an order's pass again. Returns the shop's response. */
|
|
22
|
+
signIn(passUrl: string, fetch: Fetch): Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
export interface WalletPayerOptions {
|
|
25
|
+
privateKey: `0x${string}`;
|
|
26
|
+
/** Most the wallet pays for one order, as "$25". Stock x402 clients stop at $1. */
|
|
27
|
+
maxPerOrder?: string;
|
|
28
|
+
/** CAIP-2 networks the wallet may pay on. Base Sepolia (test USDC) only unless you say otherwise. */
|
|
29
|
+
networks?: string[];
|
|
30
|
+
}
|
|
31
|
+
export declare const usd: (units: string | undefined) => string | undefined;
|
|
32
|
+
export declare function errorOf(res: Response, fallback: string): Promise<string>;
|
|
33
|
+
export declare function walletPayer(opts: WalletPayerOptions): Payer;
|
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// An x402 wallet for the MCP server: pays an order's pay link and signs in with Sign-In-With-X to read
|
|
2
|
+
// the order's pass again later.
|
|
3
|
+
import { x402Client, x402HTTPClient } from "@x402/core/client";
|
|
4
|
+
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
5
|
+
import { createSIWxPayload, encodeSIWxHeader, SIGN_IN_WITH_X } from "@x402/extensions/sign-in-with-x";
|
|
6
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
7
|
+
const EXPLORERS = {
|
|
8
|
+
"eip155:8453": "https://basescan.org/tx/",
|
|
9
|
+
"eip155:84532": "https://sepolia.basescan.org/tx/",
|
|
10
|
+
};
|
|
11
|
+
export const usd = (units) => (units ? (Number(units) / 1e6).toFixed(2) : undefined);
|
|
12
|
+
export async function errorOf(res, fallback) {
|
|
13
|
+
const body = (await res.clone().json().catch(() => ({})));
|
|
14
|
+
return body.error ?? fallback;
|
|
15
|
+
}
|
|
16
|
+
export function walletPayer(opts) {
|
|
17
|
+
const account = privateKeyToAccount(opts.privateKey);
|
|
18
|
+
const networks = (opts.networks?.length ? opts.networks : ["eip155:84532"]);
|
|
19
|
+
const http = new x402HTTPClient(x402Client.fromConfig({
|
|
20
|
+
schemes: networks.map((network) => ({ network, client: new ExactEvmScheme(account) })),
|
|
21
|
+
spendControls: { maxAmountPerPayment: opts.maxPerOrder ?? "$25" },
|
|
22
|
+
}));
|
|
23
|
+
return {
|
|
24
|
+
address: account.address,
|
|
25
|
+
async pay(order, fetch) {
|
|
26
|
+
const challenge = await fetch(new Request(order.payUrl, { method: "POST" }));
|
|
27
|
+
if (challenge.status !== 402) {
|
|
28
|
+
return { ok: false, error: await errorOf(challenge, "This order isn't waiting for an x402 payment.") };
|
|
29
|
+
}
|
|
30
|
+
const required = http.getPaymentRequiredResponse((n) => challenge.headers.get(n), await challenge.json().catch(() => undefined));
|
|
31
|
+
let payload;
|
|
32
|
+
try {
|
|
33
|
+
payload = await http.createPaymentPayload(required);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return { ok: false, error: `The wallet won't pay this: ${err instanceof Error ? err.message : String(err)}` };
|
|
37
|
+
}
|
|
38
|
+
const paid = await fetch(new Request(order.payUrl, { method: "POST", headers: http.encodePaymentSignatureHeader(payload) }));
|
|
39
|
+
if (!paid.ok)
|
|
40
|
+
return { ok: false, error: await errorOf(paid, `The payment didn't go through (${paid.status}).`) };
|
|
41
|
+
const body = (await paid.json());
|
|
42
|
+
const settled = http.getPaymentSettleResponse((n) => paid.headers.get(n));
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
passUrl: body.passUrl,
|
|
46
|
+
emailedTo: body.emailedTo,
|
|
47
|
+
amount: usd(payload.accepted.amount),
|
|
48
|
+
transaction: settled.transaction,
|
|
49
|
+
network: settled.network,
|
|
50
|
+
explorer: EXPLORERS[settled.network] ? EXPLORERS[settled.network] + settled.transaction : undefined,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
async signIn(passUrl, fetch) {
|
|
54
|
+
const challenge = await fetch(new Request(passUrl));
|
|
55
|
+
if (challenge.status !== 402)
|
|
56
|
+
return challenge;
|
|
57
|
+
const body = (await challenge.clone().json().catch(() => ({})));
|
|
58
|
+
const ext = body.extensions?.[SIGN_IN_WITH_X];
|
|
59
|
+
const chain = ext?.supportedChains.find((c) => c.chainId.startsWith("eip155:"));
|
|
60
|
+
if (!ext || !chain)
|
|
61
|
+
return challenge;
|
|
62
|
+
const signed = await createSIWxPayload({ ...ext.info, chainId: chain.chainId, type: chain.type }, account, passUrl);
|
|
63
|
+
return fetch(new Request(passUrl, { headers: { "SIGN-IN-WITH-X": encodeSIWxHeader(signed) } }));
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@agentpickup/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentpickup/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for agentPickup: let an AI agent order from a shop, pay over x402, and hand its user the QR to collect in person.",
|
|
5
|
+
"mcpName": "io.github.dav3trad3r/agentpickup",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"agentpickup-mcp": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"source": "./src/index.ts",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"LICENSE.md",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.13"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"model-context-protocol",
|
|
34
|
+
"x402",
|
|
35
|
+
"agentic-commerce",
|
|
36
|
+
"agent-payments",
|
|
37
|
+
"click-and-collect",
|
|
38
|
+
"pickup"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/dav3trad3r/agentpickup.git",
|
|
43
|
+
"directory": "packages/mcp"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/dav3trad3r/agentpickup#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/dav3trad3r/agentpickup/issues"
|
|
48
|
+
},
|
|
49
|
+
"author": "Dave Hallett",
|
|
50
|
+
"license": "FSL-1.1-ALv2",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
56
|
+
"@x402/core": "2.29.0",
|
|
57
|
+
"@x402/evm": "2.29.0",
|
|
58
|
+
"@x402/extensions": "2.29.0",
|
|
59
|
+
"viem": "^2.57.14",
|
|
60
|
+
"zod": "^4.6.7"
|
|
61
|
+
}
|
|
62
|
+
}
|