@codespar/mcp-coinbase-commerce 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +210 -16
- package/package.json +1 -1
- package/server.json +9 -2
- package/src/index.ts +208 -16
package/dist/index.js
CHANGED
|
@@ -13,35 +13,49 @@
|
|
|
13
13
|
* - Coinbase — merchants ACCEPT crypto from buyers at checkout
|
|
14
14
|
* Commerce (this package)
|
|
15
15
|
*
|
|
16
|
-
* Tools (
|
|
17
|
-
* create_charge
|
|
18
|
-
* retrieve_charge
|
|
19
|
-
* list_charges
|
|
20
|
-
* cancel_charge
|
|
21
|
-
* resolve_charge
|
|
22
|
-
* create_checkout
|
|
23
|
-
* retrieve_checkout
|
|
24
|
-
*
|
|
25
|
-
*
|
|
16
|
+
* Tools (18):
|
|
17
|
+
* create_charge — create a crypto charge (merchant invoice)
|
|
18
|
+
* retrieve_charge — look up a charge by id or short code
|
|
19
|
+
* list_charges — list charges (paginated)
|
|
20
|
+
* cancel_charge — cancel a no-longer-needed charge (before payment)
|
|
21
|
+
* resolve_charge — manually mark a charge as paid
|
|
22
|
+
* create_checkout — create a reusable hosted checkout (product page)
|
|
23
|
+
* retrieve_checkout — look up a checkout by id
|
|
24
|
+
* list_checkouts — list checkouts (paginated)
|
|
25
|
+
* update_checkout — update an existing checkout's name/description/price/fields
|
|
26
|
+
* delete_checkout — delete a checkout
|
|
27
|
+
* list_events — list webhook-like events (charge:* lifecycle)
|
|
28
|
+
* retrieve_event — retrieve a single event by id (webhook audit)
|
|
29
|
+
* create_invoice — create an invoice for a known recipient
|
|
30
|
+
* retrieve_invoice — retrieve an invoice by code
|
|
31
|
+
* list_invoices — list invoices (paginated)
|
|
32
|
+
* void_invoice — void an unpaid invoice
|
|
33
|
+
* list_exchange_rates — current Coinbase exchange rates (BTC, ETH, USDC, ...)
|
|
34
|
+
* verify_webhook_signature — local HMAC-SHA256 verifier for X-CC-Webhook-Signature
|
|
26
35
|
*
|
|
27
36
|
* Authentication
|
|
28
|
-
*
|
|
37
|
+
* Most requests carry two headers:
|
|
29
38
|
* X-CC-Api-Key: <COINBASE_COMMERCE_API_KEY>
|
|
30
39
|
* X-CC-Version: 2018-03-22 (version header is required)
|
|
40
|
+
* The exchange-rates endpoint is public and does not require the API key.
|
|
41
|
+
* verify_webhook_signature runs locally and uses COINBASE_COMMERCE_WEBHOOK_SECRET.
|
|
31
42
|
*
|
|
32
43
|
* Environment
|
|
33
|
-
* COINBASE_COMMERCE_API_KEY
|
|
34
|
-
* COINBASE_COMMERCE_API_VERSION
|
|
44
|
+
* COINBASE_COMMERCE_API_KEY — API key (required for merchant tools, secret)
|
|
45
|
+
* COINBASE_COMMERCE_API_VERSION — optional; defaults to 2018-03-22
|
|
46
|
+
* COINBASE_COMMERCE_WEBHOOK_SECRET — optional; needed only for verify_webhook_signature
|
|
35
47
|
*
|
|
36
|
-
* Docs: https://docs.cdp.coinbase.com/commerce (base: https://api.commerce.coinbase.com)
|
|
48
|
+
* Docs: https://docs.cdp.coinbase.com/commerce-onchain (base: https://api.commerce.coinbase.com)
|
|
37
49
|
*/
|
|
38
50
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
39
51
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
40
52
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
41
53
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
42
54
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
55
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
43
56
|
const API_KEY = process.env.COINBASE_COMMERCE_API_KEY || "";
|
|
44
57
|
const API_VERSION = process.env.COINBASE_COMMERCE_API_VERSION || "2018-03-22";
|
|
58
|
+
const WEBHOOK_SECRET = process.env.COINBASE_COMMERCE_WEBHOOK_SECRET || "";
|
|
45
59
|
const BASE_URL = "https://api.commerce.coinbase.com";
|
|
46
60
|
async function coinbaseRequest(method, path, body) {
|
|
47
61
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
@@ -76,7 +90,22 @@ function qs(params) {
|
|
|
76
90
|
search.set(k, String(v));
|
|
77
91
|
return `?${search.toString()}`;
|
|
78
92
|
}
|
|
79
|
-
|
|
93
|
+
function verifyWebhook(rawBody, signature, secret) {
|
|
94
|
+
if (!rawBody || !signature || !secret)
|
|
95
|
+
return false;
|
|
96
|
+
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
|
|
97
|
+
const a = Buffer.from(expected, "utf8");
|
|
98
|
+
const b = Buffer.from(signature, "utf8");
|
|
99
|
+
if (a.length !== b.length)
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
return timingSafeEqual(a, b);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const server = new Server({ name: "mcp-coinbase-commerce", version: "0.2.0" }, { capabilities: { tools: {} } });
|
|
80
109
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
81
110
|
tools: [
|
|
82
111
|
{
|
|
@@ -188,6 +217,58 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
188
217
|
required: ["id"],
|
|
189
218
|
},
|
|
190
219
|
},
|
|
220
|
+
{
|
|
221
|
+
name: "list_checkouts",
|
|
222
|
+
description: "List reusable hosted checkouts, newest first. Cursor pagination via starting_after / ending_before.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: {
|
|
226
|
+
limit: { type: "number", description: "Max results per page (default 25, max 100)" },
|
|
227
|
+
starting_after: { type: "string", description: "Cursor: return results after this checkout id" },
|
|
228
|
+
ending_before: { type: "string", description: "Cursor: return results before this checkout id" },
|
|
229
|
+
order: { type: "string", enum: ["asc", "desc"], description: "Sort order by created_at. Defaults to desc." },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "update_checkout",
|
|
235
|
+
description: "Update an existing reusable checkout. Supply only the fields you want to change (Coinbase replaces the supplied fields). Use to retitle a product, change the price, or adjust which buyer fields are collected.",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
id: { type: "string", description: "Checkout id to update" },
|
|
240
|
+
name: { type: "string", description: "New checkout / product name" },
|
|
241
|
+
description: { type: "string", description: "New checkout description" },
|
|
242
|
+
pricing_type: { type: "string", enum: ["fixed_price", "no_price"], description: "Update pricing model" },
|
|
243
|
+
local_price: {
|
|
244
|
+
type: "object",
|
|
245
|
+
description: "New fiat-denominated price",
|
|
246
|
+
properties: {
|
|
247
|
+
amount: { type: "string", description: "Amount as decimal string" },
|
|
248
|
+
currency: { type: "string", description: "ISO-4217 fiat currency code" },
|
|
249
|
+
},
|
|
250
|
+
required: ["amount", "currency"],
|
|
251
|
+
},
|
|
252
|
+
requested_info: {
|
|
253
|
+
type: "array",
|
|
254
|
+
description: "New list of buyer fields to collect",
|
|
255
|
+
items: { type: "string" },
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
required: ["id"],
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "delete_checkout",
|
|
263
|
+
description: "Delete a reusable checkout. The hosted URL stops accepting new payments. Existing charges spawned by the checkout are unaffected.",
|
|
264
|
+
inputSchema: {
|
|
265
|
+
type: "object",
|
|
266
|
+
properties: {
|
|
267
|
+
id: { type: "string", description: "Checkout id to delete" },
|
|
268
|
+
},
|
|
269
|
+
required: ["id"],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
191
272
|
{
|
|
192
273
|
name: "list_events",
|
|
193
274
|
description: "List events — the lifecycle signals (charge:created, charge:confirmed, charge:failed, charge:delayed, charge:pending, charge:resolved) that Coinbase Commerce also delivers via webhook. Useful for reconciliation and agent polling.",
|
|
@@ -201,6 +282,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
201
282
|
},
|
|
202
283
|
},
|
|
203
284
|
},
|
|
285
|
+
{
|
|
286
|
+
name: "retrieve_event",
|
|
287
|
+
description: "Retrieve a single event by id. Useful when auditing a webhook delivery or replaying state — fetch the event Coinbase Commerce recorded server-side and compare against what your endpoint received.",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
type: "object",
|
|
290
|
+
properties: {
|
|
291
|
+
id: { type: "string", description: "Event id (the id field on a webhook payload)" },
|
|
292
|
+
},
|
|
293
|
+
required: ["id"],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
204
296
|
{
|
|
205
297
|
name: "create_invoice",
|
|
206
298
|
description: "Create an invoice — a directed bill sent to a specific named recipient. Unlike a charge, an invoice captures who it was issued to and has its own draft / viewed / paid lifecycle.",
|
|
@@ -224,6 +316,64 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
224
316
|
required: ["business_name", "customer_email", "customer_name", "local_price"],
|
|
225
317
|
},
|
|
226
318
|
},
|
|
319
|
+
{
|
|
320
|
+
name: "retrieve_invoice",
|
|
321
|
+
description: "Retrieve an invoice by code. Returns recipient details, status (DRAFT, OPEN, VIEWED, PAID, VOID), and the linked charge once payment begins.",
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
code: { type: "string", description: "Invoice short code" },
|
|
326
|
+
},
|
|
327
|
+
required: ["code"],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "list_invoices",
|
|
332
|
+
description: "List invoices, newest first. Cursor pagination via starting_after / ending_before.",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
limit: { type: "number", description: "Max results per page (default 25, max 100)" },
|
|
337
|
+
starting_after: { type: "string", description: "Cursor: return results after this invoice id" },
|
|
338
|
+
ending_before: { type: "string", description: "Cursor: return results before this invoice id" },
|
|
339
|
+
order: { type: "string", enum: ["asc", "desc"], description: "Sort order by created_at. Defaults to desc." },
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "void_invoice",
|
|
345
|
+
description: "Void an unpaid invoice. The recipient can no longer pay it. Already-paid invoices cannot be voided — refund out-of-band if needed.",
|
|
346
|
+
inputSchema: {
|
|
347
|
+
type: "object",
|
|
348
|
+
properties: {
|
|
349
|
+
code: { type: "string", description: "Invoice short code to void" },
|
|
350
|
+
},
|
|
351
|
+
required: ["code"],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "list_exchange_rates",
|
|
356
|
+
description: "Fetch current Coinbase exchange rates for a base asset (e.g. BTC, ETH, USDC) against every supported fiat and crypto. Useful for quoting or reconciling fiat-equivalent amounts. This endpoint is public and does not require the API key.",
|
|
357
|
+
inputSchema: {
|
|
358
|
+
type: "object",
|
|
359
|
+
properties: {
|
|
360
|
+
currency: { type: "string", description: "Base currency code (e.g. BTC, ETH, USDC, USD). Defaults to USD." },
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "verify_webhook_signature",
|
|
366
|
+
description: "Local helper — verify a Coinbase Commerce webhook payload using HMAC-SHA256. Pass the EXACT raw request body string (do not re-stringify the parsed JSON, byte-equivalence matters) and the X-CC-Webhook-Signature header value. The shared secret comes from COINBASE_COMMERCE_WEBHOOK_SECRET unless overridden. Returns { valid: boolean }.",
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: "object",
|
|
369
|
+
properties: {
|
|
370
|
+
raw_body: { type: "string", description: "Exact raw request body bytes as a string" },
|
|
371
|
+
signature: { type: "string", description: "X-CC-Webhook-Signature header value (hex)" },
|
|
372
|
+
secret: { type: "string", description: "Override webhook shared secret. Defaults to COINBASE_COMMERCE_WEBHOOK_SECRET env var." },
|
|
373
|
+
},
|
|
374
|
+
required: ["raw_body", "signature"],
|
|
375
|
+
},
|
|
376
|
+
},
|
|
227
377
|
],
|
|
228
378
|
}));
|
|
229
379
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -252,6 +402,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
252
402
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("POST", "/checkouts", a), null, 2) }] };
|
|
253
403
|
case "retrieve_checkout":
|
|
254
404
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/checkouts/${encodeURIComponent(String(a.id ?? ""))}`), null, 2) }] };
|
|
405
|
+
case "list_checkouts": {
|
|
406
|
+
const query = qs({
|
|
407
|
+
limit: a.limit,
|
|
408
|
+
starting_after: a.starting_after,
|
|
409
|
+
ending_before: a.ending_before,
|
|
410
|
+
order: a.order,
|
|
411
|
+
});
|
|
412
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/checkouts${query}`), null, 2) }] };
|
|
413
|
+
}
|
|
414
|
+
case "update_checkout": {
|
|
415
|
+
const { id, ...body } = a;
|
|
416
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("PUT", `/checkouts/${encodeURIComponent(String(id ?? ""))}`, body), null, 2) }] };
|
|
417
|
+
}
|
|
418
|
+
case "delete_checkout":
|
|
419
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("DELETE", `/checkouts/${encodeURIComponent(String(a.id ?? ""))}`), null, 2) }] };
|
|
255
420
|
case "list_events": {
|
|
256
421
|
const query = qs({
|
|
257
422
|
limit: a.limit,
|
|
@@ -261,8 +426,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
261
426
|
});
|
|
262
427
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/events${query}`), null, 2) }] };
|
|
263
428
|
}
|
|
429
|
+
case "retrieve_event":
|
|
430
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/events/${encodeURIComponent(String(a.id ?? ""))}`), null, 2) }] };
|
|
264
431
|
case "create_invoice":
|
|
265
432
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("POST", "/invoices", a), null, 2) }] };
|
|
433
|
+
case "retrieve_invoice":
|
|
434
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/invoices/${encodeURIComponent(String(a.code ?? ""))}`), null, 2) }] };
|
|
435
|
+
case "list_invoices": {
|
|
436
|
+
const query = qs({
|
|
437
|
+
limit: a.limit,
|
|
438
|
+
starting_after: a.starting_after,
|
|
439
|
+
ending_before: a.ending_before,
|
|
440
|
+
order: a.order,
|
|
441
|
+
});
|
|
442
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/invoices${query}`), null, 2) }] };
|
|
443
|
+
}
|
|
444
|
+
case "void_invoice":
|
|
445
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("PUT", `/invoices/${encodeURIComponent(String(a.code ?? ""))}/void`), null, 2) }] };
|
|
446
|
+
case "list_exchange_rates": {
|
|
447
|
+
const query = qs({ currency: a.currency });
|
|
448
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/exchange-rates${query}`), null, 2) }] };
|
|
449
|
+
}
|
|
450
|
+
case "verify_webhook_signature": {
|
|
451
|
+
const rawBody = String(a.raw_body ?? "");
|
|
452
|
+
const signature = String(a.signature ?? "");
|
|
453
|
+
const secret = String(a.secret ?? WEBHOOK_SECRET ?? "");
|
|
454
|
+
if (!secret) {
|
|
455
|
+
return { content: [{ type: "text", text: "Error: webhook secret missing — set COINBASE_COMMERCE_WEBHOOK_SECRET or pass `secret`." }], isError: true };
|
|
456
|
+
}
|
|
457
|
+
const valid = verifyWebhook(rawBody, signature, secret);
|
|
458
|
+
return { content: [{ type: "text", text: JSON.stringify({ valid }, null, 2) }] };
|
|
459
|
+
}
|
|
266
460
|
default:
|
|
267
461
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
268
462
|
}
|
|
@@ -289,7 +483,7 @@ async function main() {
|
|
|
289
483
|
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
|
|
290
484
|
t.onclose = () => { if (t.sessionId)
|
|
291
485
|
transports.delete(t.sessionId); };
|
|
292
|
-
const s = new Server({ name: "mcp-coinbase-commerce", version: "0.
|
|
486
|
+
const s = new Server({ name: "mcp-coinbase-commerce", version: "0.2.0" }, { capabilities: { tools: {} } });
|
|
293
487
|
server._requestHandlers.forEach((v, k) => s._requestHandlers.set(k, v));
|
|
294
488
|
server._notificationHandlers?.forEach((v, k) => s._notificationHandlers.set(k, v));
|
|
295
489
|
await s.connect(t);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codespar/mcp-coinbase-commerce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for Coinbase Commerce — global crypto merchant payments. Accept BTC, ETH, USDC and more at checkout with hosted charges, checkouts, and invoices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/crypto/coinbase-commerce"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.2.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@codespar/mcp-coinbase-commerce",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.2.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
"isRequired": false,
|
|
31
31
|
"format": "string",
|
|
32
32
|
"isSecret": false
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "COINBASE_COMMERCE_WEBHOOK_SECRET",
|
|
36
|
+
"description": "Shared secret used to verify X-CC-Webhook-Signature on incoming webhooks. Only required for the verify_webhook_signature tool.",
|
|
37
|
+
"isRequired": false,
|
|
38
|
+
"format": "string",
|
|
39
|
+
"isSecret": true
|
|
33
40
|
}
|
|
34
41
|
]
|
|
35
42
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,27 +14,39 @@
|
|
|
14
14
|
* - Coinbase — merchants ACCEPT crypto from buyers at checkout
|
|
15
15
|
* Commerce (this package)
|
|
16
16
|
*
|
|
17
|
-
* Tools (
|
|
18
|
-
* create_charge
|
|
19
|
-
* retrieve_charge
|
|
20
|
-
* list_charges
|
|
21
|
-
* cancel_charge
|
|
22
|
-
* resolve_charge
|
|
23
|
-
* create_checkout
|
|
24
|
-
* retrieve_checkout
|
|
25
|
-
*
|
|
26
|
-
*
|
|
17
|
+
* Tools (18):
|
|
18
|
+
* create_charge — create a crypto charge (merchant invoice)
|
|
19
|
+
* retrieve_charge — look up a charge by id or short code
|
|
20
|
+
* list_charges — list charges (paginated)
|
|
21
|
+
* cancel_charge — cancel a no-longer-needed charge (before payment)
|
|
22
|
+
* resolve_charge — manually mark a charge as paid
|
|
23
|
+
* create_checkout — create a reusable hosted checkout (product page)
|
|
24
|
+
* retrieve_checkout — look up a checkout by id
|
|
25
|
+
* list_checkouts — list checkouts (paginated)
|
|
26
|
+
* update_checkout — update an existing checkout's name/description/price/fields
|
|
27
|
+
* delete_checkout — delete a checkout
|
|
28
|
+
* list_events — list webhook-like events (charge:* lifecycle)
|
|
29
|
+
* retrieve_event — retrieve a single event by id (webhook audit)
|
|
30
|
+
* create_invoice — create an invoice for a known recipient
|
|
31
|
+
* retrieve_invoice — retrieve an invoice by code
|
|
32
|
+
* list_invoices — list invoices (paginated)
|
|
33
|
+
* void_invoice — void an unpaid invoice
|
|
34
|
+
* list_exchange_rates — current Coinbase exchange rates (BTC, ETH, USDC, ...)
|
|
35
|
+
* verify_webhook_signature — local HMAC-SHA256 verifier for X-CC-Webhook-Signature
|
|
27
36
|
*
|
|
28
37
|
* Authentication
|
|
29
|
-
*
|
|
38
|
+
* Most requests carry two headers:
|
|
30
39
|
* X-CC-Api-Key: <COINBASE_COMMERCE_API_KEY>
|
|
31
40
|
* X-CC-Version: 2018-03-22 (version header is required)
|
|
41
|
+
* The exchange-rates endpoint is public and does not require the API key.
|
|
42
|
+
* verify_webhook_signature runs locally and uses COINBASE_COMMERCE_WEBHOOK_SECRET.
|
|
32
43
|
*
|
|
33
44
|
* Environment
|
|
34
|
-
* COINBASE_COMMERCE_API_KEY
|
|
35
|
-
* COINBASE_COMMERCE_API_VERSION
|
|
45
|
+
* COINBASE_COMMERCE_API_KEY — API key (required for merchant tools, secret)
|
|
46
|
+
* COINBASE_COMMERCE_API_VERSION — optional; defaults to 2018-03-22
|
|
47
|
+
* COINBASE_COMMERCE_WEBHOOK_SECRET — optional; needed only for verify_webhook_signature
|
|
36
48
|
*
|
|
37
|
-
* Docs: https://docs.cdp.coinbase.com/commerce (base: https://api.commerce.coinbase.com)
|
|
49
|
+
* Docs: https://docs.cdp.coinbase.com/commerce-onchain (base: https://api.commerce.coinbase.com)
|
|
38
50
|
*/
|
|
39
51
|
|
|
40
52
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -45,9 +57,11 @@ import {
|
|
|
45
57
|
CallToolRequestSchema,
|
|
46
58
|
ListToolsRequestSchema,
|
|
47
59
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
60
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
48
61
|
|
|
49
62
|
const API_KEY = process.env.COINBASE_COMMERCE_API_KEY || "";
|
|
50
63
|
const API_VERSION = process.env.COINBASE_COMMERCE_API_VERSION || "2018-03-22";
|
|
64
|
+
const WEBHOOK_SECRET = process.env.COINBASE_COMMERCE_WEBHOOK_SECRET || "";
|
|
51
65
|
const BASE_URL = "https://api.commerce.coinbase.com";
|
|
52
66
|
|
|
53
67
|
async function coinbaseRequest(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
@@ -81,8 +95,21 @@ function qs(params: Record<string, unknown>): string {
|
|
|
81
95
|
return `?${search.toString()}`;
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
|
|
99
|
+
if (!rawBody || !signature || !secret) return false;
|
|
100
|
+
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
|
|
101
|
+
const a = Buffer.from(expected, "utf8");
|
|
102
|
+
const b = Buffer.from(signature, "utf8");
|
|
103
|
+
if (a.length !== b.length) return false;
|
|
104
|
+
try {
|
|
105
|
+
return timingSafeEqual(a, b);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
const server = new Server(
|
|
85
|
-
{ name: "mcp-coinbase-commerce", version: "0.
|
|
112
|
+
{ name: "mcp-coinbase-commerce", version: "0.2.0" },
|
|
86
113
|
{ capabilities: { tools: {} } }
|
|
87
114
|
);
|
|
88
115
|
|
|
@@ -197,6 +224,58 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
197
224
|
required: ["id"],
|
|
198
225
|
},
|
|
199
226
|
},
|
|
227
|
+
{
|
|
228
|
+
name: "list_checkouts",
|
|
229
|
+
description: "List reusable hosted checkouts, newest first. Cursor pagination via starting_after / ending_before.",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
limit: { type: "number", description: "Max results per page (default 25, max 100)" },
|
|
234
|
+
starting_after: { type: "string", description: "Cursor: return results after this checkout id" },
|
|
235
|
+
ending_before: { type: "string", description: "Cursor: return results before this checkout id" },
|
|
236
|
+
order: { type: "string", enum: ["asc", "desc"], description: "Sort order by created_at. Defaults to desc." },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "update_checkout",
|
|
242
|
+
description: "Update an existing reusable checkout. Supply only the fields you want to change (Coinbase replaces the supplied fields). Use to retitle a product, change the price, or adjust which buyer fields are collected.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
id: { type: "string", description: "Checkout id to update" },
|
|
247
|
+
name: { type: "string", description: "New checkout / product name" },
|
|
248
|
+
description: { type: "string", description: "New checkout description" },
|
|
249
|
+
pricing_type: { type: "string", enum: ["fixed_price", "no_price"], description: "Update pricing model" },
|
|
250
|
+
local_price: {
|
|
251
|
+
type: "object",
|
|
252
|
+
description: "New fiat-denominated price",
|
|
253
|
+
properties: {
|
|
254
|
+
amount: { type: "string", description: "Amount as decimal string" },
|
|
255
|
+
currency: { type: "string", description: "ISO-4217 fiat currency code" },
|
|
256
|
+
},
|
|
257
|
+
required: ["amount", "currency"],
|
|
258
|
+
},
|
|
259
|
+
requested_info: {
|
|
260
|
+
type: "array",
|
|
261
|
+
description: "New list of buyer fields to collect",
|
|
262
|
+
items: { type: "string" },
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
required: ["id"],
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: "delete_checkout",
|
|
270
|
+
description: "Delete a reusable checkout. The hosted URL stops accepting new payments. Existing charges spawned by the checkout are unaffected.",
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: "object",
|
|
273
|
+
properties: {
|
|
274
|
+
id: { type: "string", description: "Checkout id to delete" },
|
|
275
|
+
},
|
|
276
|
+
required: ["id"],
|
|
277
|
+
},
|
|
278
|
+
},
|
|
200
279
|
{
|
|
201
280
|
name: "list_events",
|
|
202
281
|
description: "List events — the lifecycle signals (charge:created, charge:confirmed, charge:failed, charge:delayed, charge:pending, charge:resolved) that Coinbase Commerce also delivers via webhook. Useful for reconciliation and agent polling.",
|
|
@@ -210,6 +289,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
210
289
|
},
|
|
211
290
|
},
|
|
212
291
|
},
|
|
292
|
+
{
|
|
293
|
+
name: "retrieve_event",
|
|
294
|
+
description: "Retrieve a single event by id. Useful when auditing a webhook delivery or replaying state — fetch the event Coinbase Commerce recorded server-side and compare against what your endpoint received.",
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
id: { type: "string", description: "Event id (the id field on a webhook payload)" },
|
|
299
|
+
},
|
|
300
|
+
required: ["id"],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
213
303
|
{
|
|
214
304
|
name: "create_invoice",
|
|
215
305
|
description: "Create an invoice — a directed bill sent to a specific named recipient. Unlike a charge, an invoice captures who it was issued to and has its own draft / viewed / paid lifecycle.",
|
|
@@ -233,6 +323,64 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
233
323
|
required: ["business_name", "customer_email", "customer_name", "local_price"],
|
|
234
324
|
},
|
|
235
325
|
},
|
|
326
|
+
{
|
|
327
|
+
name: "retrieve_invoice",
|
|
328
|
+
description: "Retrieve an invoice by code. Returns recipient details, status (DRAFT, OPEN, VIEWED, PAID, VOID), and the linked charge once payment begins.",
|
|
329
|
+
inputSchema: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: {
|
|
332
|
+
code: { type: "string", description: "Invoice short code" },
|
|
333
|
+
},
|
|
334
|
+
required: ["code"],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "list_invoices",
|
|
339
|
+
description: "List invoices, newest first. Cursor pagination via starting_after / ending_before.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
limit: { type: "number", description: "Max results per page (default 25, max 100)" },
|
|
344
|
+
starting_after: { type: "string", description: "Cursor: return results after this invoice id" },
|
|
345
|
+
ending_before: { type: "string", description: "Cursor: return results before this invoice id" },
|
|
346
|
+
order: { type: "string", enum: ["asc", "desc"], description: "Sort order by created_at. Defaults to desc." },
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: "void_invoice",
|
|
352
|
+
description: "Void an unpaid invoice. The recipient can no longer pay it. Already-paid invoices cannot be voided — refund out-of-band if needed.",
|
|
353
|
+
inputSchema: {
|
|
354
|
+
type: "object",
|
|
355
|
+
properties: {
|
|
356
|
+
code: { type: "string", description: "Invoice short code to void" },
|
|
357
|
+
},
|
|
358
|
+
required: ["code"],
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: "list_exchange_rates",
|
|
363
|
+
description: "Fetch current Coinbase exchange rates for a base asset (e.g. BTC, ETH, USDC) against every supported fiat and crypto. Useful for quoting or reconciling fiat-equivalent amounts. This endpoint is public and does not require the API key.",
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
currency: { type: "string", description: "Base currency code (e.g. BTC, ETH, USDC, USD). Defaults to USD." },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: "verify_webhook_signature",
|
|
373
|
+
description: "Local helper — verify a Coinbase Commerce webhook payload using HMAC-SHA256. Pass the EXACT raw request body string (do not re-stringify the parsed JSON, byte-equivalence matters) and the X-CC-Webhook-Signature header value. The shared secret comes from COINBASE_COMMERCE_WEBHOOK_SECRET unless overridden. Returns { valid: boolean }.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {
|
|
377
|
+
raw_body: { type: "string", description: "Exact raw request body bytes as a string" },
|
|
378
|
+
signature: { type: "string", description: "X-CC-Webhook-Signature header value (hex)" },
|
|
379
|
+
secret: { type: "string", description: "Override webhook shared secret. Defaults to COINBASE_COMMERCE_WEBHOOK_SECRET env var." },
|
|
380
|
+
},
|
|
381
|
+
required: ["raw_body", "signature"],
|
|
382
|
+
},
|
|
383
|
+
},
|
|
236
384
|
],
|
|
237
385
|
}));
|
|
238
386
|
|
|
@@ -263,6 +411,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
263
411
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("POST", "/checkouts", a), null, 2) }] };
|
|
264
412
|
case "retrieve_checkout":
|
|
265
413
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/checkouts/${encodeURIComponent(String(a.id ?? ""))}`), null, 2) }] };
|
|
414
|
+
case "list_checkouts": {
|
|
415
|
+
const query = qs({
|
|
416
|
+
limit: a.limit,
|
|
417
|
+
starting_after: a.starting_after,
|
|
418
|
+
ending_before: a.ending_before,
|
|
419
|
+
order: a.order,
|
|
420
|
+
});
|
|
421
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/checkouts${query}`), null, 2) }] };
|
|
422
|
+
}
|
|
423
|
+
case "update_checkout": {
|
|
424
|
+
const { id, ...body } = a;
|
|
425
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("PUT", `/checkouts/${encodeURIComponent(String(id ?? ""))}`, body), null, 2) }] };
|
|
426
|
+
}
|
|
427
|
+
case "delete_checkout":
|
|
428
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("DELETE", `/checkouts/${encodeURIComponent(String(a.id ?? ""))}`), null, 2) }] };
|
|
266
429
|
case "list_events": {
|
|
267
430
|
const query = qs({
|
|
268
431
|
limit: a.limit,
|
|
@@ -272,8 +435,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
272
435
|
});
|
|
273
436
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/events${query}`), null, 2) }] };
|
|
274
437
|
}
|
|
438
|
+
case "retrieve_event":
|
|
439
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/events/${encodeURIComponent(String(a.id ?? ""))}`), null, 2) }] };
|
|
275
440
|
case "create_invoice":
|
|
276
441
|
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("POST", "/invoices", a), null, 2) }] };
|
|
442
|
+
case "retrieve_invoice":
|
|
443
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/invoices/${encodeURIComponent(String(a.code ?? ""))}`), null, 2) }] };
|
|
444
|
+
case "list_invoices": {
|
|
445
|
+
const query = qs({
|
|
446
|
+
limit: a.limit,
|
|
447
|
+
starting_after: a.starting_after,
|
|
448
|
+
ending_before: a.ending_before,
|
|
449
|
+
order: a.order,
|
|
450
|
+
});
|
|
451
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/invoices${query}`), null, 2) }] };
|
|
452
|
+
}
|
|
453
|
+
case "void_invoice":
|
|
454
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("PUT", `/invoices/${encodeURIComponent(String(a.code ?? ""))}/void`), null, 2) }] };
|
|
455
|
+
case "list_exchange_rates": {
|
|
456
|
+
const query = qs({ currency: a.currency });
|
|
457
|
+
return { content: [{ type: "text", text: JSON.stringify(await coinbaseRequest("GET", `/exchange-rates${query}`), null, 2) }] };
|
|
458
|
+
}
|
|
459
|
+
case "verify_webhook_signature": {
|
|
460
|
+
const rawBody = String(a.raw_body ?? "");
|
|
461
|
+
const signature = String(a.signature ?? "");
|
|
462
|
+
const secret = String(a.secret ?? WEBHOOK_SECRET ?? "");
|
|
463
|
+
if (!secret) {
|
|
464
|
+
return { content: [{ type: "text", text: "Error: webhook secret missing — set COINBASE_COMMERCE_WEBHOOK_SECRET or pass `secret`." }], isError: true };
|
|
465
|
+
}
|
|
466
|
+
const valid = verifyWebhook(rawBody, signature, secret);
|
|
467
|
+
return { content: [{ type: "text", text: JSON.stringify({ valid }, null, 2) }] };
|
|
468
|
+
}
|
|
277
469
|
default:
|
|
278
470
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
279
471
|
}
|
|
@@ -296,7 +488,7 @@ async function main() {
|
|
|
296
488
|
if (!sid && isInitializeRequest(req.body)) {
|
|
297
489
|
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
|
|
298
490
|
t.onclose = () => { if (t.sessionId) transports.delete(t.sessionId); };
|
|
299
|
-
const s = new Server({ name: "mcp-coinbase-commerce", version: "0.
|
|
491
|
+
const s = new Server({ name: "mcp-coinbase-commerce", version: "0.2.0" }, { capabilities: { tools: {} } });
|
|
300
492
|
(server as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.forEach((v, k) => (s as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.set(k, v));
|
|
301
493
|
(server as unknown as { _notificationHandlers?: Map<unknown, unknown> })._notificationHandlers?.forEach((v, k) => (s as unknown as { _notificationHandlers: Map<unknown, unknown> })._notificationHandlers.set(k, v));
|
|
302
494
|
await s.connect(t);
|