@codespar/mcp-coinbase-commerce 0.1.0 → 0.2.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
@@ -15,19 +15,28 @@ Coinbase Commerce is the **merchant-accept** side of crypto. Your store prices a
15
15
 
16
16
  Use Coinbase Commerce when an agent needs to **bill a buyer in crypto** — hosted charge page, reusable checkout, or directed invoice.
17
17
 
18
- ## Tools
18
+ ## Tools (18)
19
19
 
20
20
  | Tool | Purpose |
21
- |------|---------|
22
- | `create_charge` | Create a one-time crypto charge priced in fiat |
23
- | `retrieve_charge` | Look up a charge by id or short code |
24
- | `list_charges` | List charges (paginated) |
25
- | `cancel_charge` | Cancel an unpaid charge |
26
- | `resolve_charge` | Manually mark a charge as paid |
27
- | `create_checkout` | Create a reusable hosted checkout (product page) |
28
- | `retrieve_checkout` | Look up a checkout by id |
29
- | `list_events` | List lifecycle events (same payload as webhooks) |
30
- | `create_invoice` | Create an invoice directed at a named recipient |
21
+ |---|---|
22
+ | `create_charge` | Create a crypto charge — a one-time merchant invoice priced in local fiat that a buyer can settle in BTC, E... |
23
+ | `retrieve_charge` | Retrieve a charge by its Coinbase Commerce id OR its short code (the 8-character code embedded in the hoste... |
24
+ | `list_charges` | List charges, newest first. |
25
+ | `cancel_charge` | Cancel a charge that has not yet been paid. |
26
+ | `resolve_charge` | Manually resolve a charge as paid. |
27
+ | `create_checkout` | Create a reusable hosted checkout — think product-page-style link that can be paid multiple times. |
28
+ | `retrieve_checkout` | Retrieve a checkout by id. |
29
+ | `list_checkouts` | List reusable hosted checkouts, newest first. |
30
+ | `update_checkout` | Update an existing reusable checkout. |
31
+ | `delete_checkout` | Delete a reusable checkout. |
32
+ | `list_events` | List events — the lifecycle signals (charge:created, charge:confirmed, charge:failed, charge:delayed, charg... |
33
+ | `retrieve_event` | Retrieve a single event by id. |
34
+ | `create_invoice` | Create an invoice — a directed bill sent to a specific named recipient. |
35
+ | `retrieve_invoice` | Retrieve an invoice by code. |
36
+ | `list_invoices` | List invoices, newest first. |
37
+ | `void_invoice` | Void an unpaid invoice. |
38
+ | `list_exchange_rates` | Fetch current Coinbase exchange rates for a base asset (e.g. |
39
+ | `verify_webhook_signature` | Local helper — verify a Coinbase Commerce webhook payload using HMAC-SHA256. |
31
40
 
32
41
  ## Install
33
42
 
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 (9):
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_events — list webhook-like events (charge:* lifecycle)
25
- * create_invoice create an invoice for a known recipient
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
- * Every request carries two headers:
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 — API key (required, secret)
34
- * COINBASE_COMMERCE_API_VERSION — optional; defaults to 2018-03-22
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
- const server = new Server({ name: "mcp-coinbase-commerce", version: "0.1.0" }, { capabilities: { tools: {} } });
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.1" }, { 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.1.0" }, { capabilities: { tools: {} } });
486
+ const s = new Server({ name: "mcp-coinbase-commerce", version: "0.2.1" }, { 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,7 +1,7 @@
1
1
  {
2
2
  "name": "@codespar/mcp-coinbase-commerce",
3
- "version": "0.1.0",
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.",
3
+ "version": "0.2.1",
4
+ "description": "MCP server for Coinbase Commerce \u2014 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",
7
7
  "bin": {
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/crypto/coinbase-commerce"
9
9
  },
10
- "version": "0.1.0",
10
+ "version": "0.2.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "@codespar/mcp-coinbase-commerce",
15
- "version": "0.1.0",
15
+ "version": "0.2.1",
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 (9):
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_events — list webhook-like events (charge:* lifecycle)
26
- * create_invoice create an invoice for a known recipient
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
- * Every request carries two headers:
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 — API key (required, secret)
35
- * COINBASE_COMMERCE_API_VERSION — optional; defaults to 2018-03-22
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.1.0" },
112
+ { name: "mcp-coinbase-commerce", version: "0.2.1" },
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.1.0" }, { capabilities: { tools: {} } });
491
+ const s = new Server({ name: "mcp-coinbase-commerce", version: "0.2.1" }, { 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);