agenticbtc-mcp 1.0.7 → 1.0.10

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +325 -131
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenticbtc-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
4
4
  "description": "Privacy-intelligent payments for AI agents — your privacy, your choice. Universal payment router with Lightning, Strike, Coinbase, PayPal, Venmo support.",
5
5
  "keywords": [
6
6
  "bitcoin",
package/src/server.js CHANGED
@@ -7,12 +7,62 @@
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { z } from "zod";
10
+ import crypto from "crypto";
10
11
 
11
12
  const API_URL = process.env.AGENTICBTC_API_URL || process.env.AGENTBTC_API_URL || "http://localhost:8000";
12
13
  const API_KEY = process.env.AGENTICBTC_API_KEY || process.env.AGENTBTC_API_KEY || "";
13
14
  const LND_HOST = process.env.AGENTBTC_LND_HOST || "";
14
15
  const LND_MACAROON = process.env.AGENTBTC_LND_MACAROON || "";
15
16
 
17
+ // X (Twitter) credentials — optional, enables social posting tools
18
+ const X_CONSUMER_KEY = process.env.TWITTER_CONSUMER_KEY || "";
19
+ const X_CONSUMER_SECRET = process.env.TWITTER_CONSUMER_SECRET || "";
20
+ const X_ACCESS_TOKEN = process.env.TWITTER_ACCESS_TOKEN || "";
21
+ const X_ACCESS_SECRET = process.env.TWITTER_ACCESS_SECRET || "";
22
+ const X_BEARER_TOKEN = process.env.TWITTER_BEARER_TOKEN || "";
23
+ const X_USER_ID = X_ACCESS_TOKEN ? X_ACCESS_TOKEN.split("-")[0] : "";
24
+ const X_ENABLED = !!(X_CONSUMER_KEY && X_ACCESS_TOKEN);
25
+
26
+ // OAuth 1.0a helper for X write operations
27
+ function buildXOAuthHeader(method, url) {
28
+ const timestamp = Math.floor(Date.now() / 1000).toString();
29
+ const nonce = crypto.randomBytes(16).toString("hex");
30
+ const oauthParams = {
31
+ oauth_consumer_key: X_CONSUMER_KEY,
32
+ oauth_nonce: nonce,
33
+ oauth_signature_method: "HMAC-SHA1",
34
+ oauth_timestamp: timestamp,
35
+ oauth_token: X_ACCESS_TOKEN,
36
+ oauth_version: "1.0",
37
+ };
38
+ const sorted = Object.entries(oauthParams).sort(([a], [b]) => a.localeCompare(b));
39
+ const paramStr = sorted.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
40
+ const baseStr = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramStr)}`;
41
+ const signingKey = `${encodeURIComponent(X_CONSUMER_SECRET)}&${encodeURIComponent(X_ACCESS_SECRET)}`;
42
+ const signature = crypto.createHmac("sha1", signingKey).update(baseStr).digest("base64");
43
+ return "OAuth " + Object.entries({ ...oauthParams, oauth_signature: signature })
44
+ .sort(([a], [b]) => a.localeCompare(b))
45
+ .map(([k, v]) => `${encodeURIComponent(k)}="${encodeURIComponent(v)}"`)
46
+ .join(", ");
47
+ }
48
+
49
+ async function xPost(endpoint, body) {
50
+ const url = `https://api.x.com${endpoint}`;
51
+ const auth = buildXOAuthHeader("POST", url);
52
+ const res = await fetch(url, {
53
+ method: "POST",
54
+ headers: { Authorization: auth, "Content-Type": "application/json" },
55
+ body: JSON.stringify(body),
56
+ });
57
+ return { status: res.status, data: await res.json() };
58
+ }
59
+
60
+ async function xGet(endpoint) {
61
+ const url = `https://api.x.com${endpoint}`;
62
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${X_BEARER_TOKEN}` } });
63
+ return { status: res.status, data: await res.json() };
64
+ }
65
+
16
66
  // BK Block routing node — all payments route through this node as first hop
17
67
  // Routing fee: 0.5% (5000ppm) + 1 sat base
18
68
  const BK_BLOCK_NODE_PUBKEY = process.env.BK_BLOCK_NODE_PUBKEY || "031aef3a70c08a6e2d96ba1c78eec66092723cdc41d546329df3f065b0f200bd3b";
@@ -302,87 +352,42 @@ server.tool(
302
352
  fee_limit_sats: z.number().optional().default(100).describe("Max fee in sats"),
303
353
  },
304
354
  async ({ invoice, agent, fee_limit_sats }) => {
305
- if (!LND_HOST) {
306
- return { content: [{ type: "text", text: "Error: LND node not configured. Set AGENTBTC_LND_HOST environment variable." }] };
307
- }
308
-
309
- // Resolve agent for spending policy enforcement
310
- let resolvedAgent = null;
311
- if (agent) {
312
- resolvedAgent = await resolveAgent(agent);
313
- if (!resolvedAgent) {
314
- return { content: [{ type: "text", text: `Error: Agent '${agent}' not found` }] };
315
- }
316
- if (!resolvedAgent.enabled) {
317
- return { content: [{ type: "text", text: `Error: Agent '${resolvedAgent.name}' is disabled — payments blocked` }] };
318
- }
319
- }
320
-
321
- // Auto-detect agent from API key auth level
322
- if (!resolvedAgent) {
323
- const auth = await getAuthLevel();
324
- if (auth.agent_id) {
325
- resolvedAgent = { id: auth.agent_id, name: auth.agent_name || auth.agent_id };
326
- }
327
- }
328
-
329
355
  try {
330
- // Decode invoice first to get amount for policy check
331
- const decodeRes = await fetch(`${LND_HOST}/v1/payreq/${invoice}`, {
332
- headers: { "Grpc-Metadata-macaroon": LND_MACAROON },
333
- });
334
- const decoded = await decodeRes.json();
335
- const amountSats = parseInt(decoded.num_satoshis || 0);
336
-
337
- // Check spending policy if agent is identified
338
- if (resolvedAgent && amountSats > 0) {
339
- const policy = await checkSpendingPolicy(resolvedAgent.id, amountSats);
340
- if (!policy.allowed) {
341
- return { content: [{ type: "text", text: `Payment blocked: ${policy.reason}` }] };
342
- }
343
- }
344
-
345
- // Pay invoice directly via LND REST API
346
- const res = await fetch(`${LND_HOST}/v1/channels/transactions`, {
356
+ // Route through the AgenticBTC payment router (POST /api/v1/payments).
357
+ // The router enforces spending policy, collects the 0.5% router fee,
358
+ // performs privacy-aware rail selection, and provides multi-rail failover.
359
+ // Do NOT call LND directly here.
360
+ const { status, data } = await apiCall("/api/v1/payments", {
347
361
  method: "POST",
348
- headers: {
349
- "Grpc-Metadata-macaroon": LND_MACAROON,
350
- "Content-Type": "application/json",
351
- },
352
362
  body: JSON.stringify({
353
- payment_request: invoice,
354
- fee_limit: { fixed: fee_limit_sats },
363
+ invoice,
364
+ fee_limit_sats: fee_limit_sats ?? 100,
365
+ // Pass agent hint in metadata so the server can scope logging
366
+ ...(agent ? { agent_hint: agent } : {}),
355
367
  }),
356
368
  });
357
- const data = await res.json();
358
-
359
- if (res.ok && !data.payment_error) {
360
- const paidAmount = parseInt(data.value || data.value_sat || amountSats || 0);
361
-
362
- // Log transaction against agent
363
- if (resolvedAgent) {
364
- await logTransaction(resolvedAgent.id, paidAmount, decoded.destination || "", decoded.description || "", "completed");
365
- }
366
369
 
370
+ if (status === 200 && data.success) {
367
371
  return {
368
372
  content: [{
369
373
  type: "text",
370
374
  text: JSON.stringify({
371
375
  success: true,
372
- payment_hash: data.payment_hash,
373
- amount_sats: paidAmount,
374
- fee_sats: parseInt(data.fee || data.fee_sat || 0),
375
- agent: resolvedAgent?.name || null,
376
+ payment_hash: data.payment_hash || data.payment_id,
377
+ amount_sats: data.amount_sats,
378
+ fee_sats: data.fee_sats,
379
+ rail: data.rail,
376
380
  status: data.status || "SUCCEEDED",
377
- message: `Paid ${paidAmount} sats ⚡`,
381
+ message: `Paid ${data.amount_sats} sats via router (${data.rail || "lightning"}) ⚡`,
378
382
  }, null, 2),
379
383
  }],
380
384
  };
381
385
  }
382
- const errMsg = data.payment_error || data.message || JSON.stringify(data);
386
+
387
+ const errMsg = data?.detail || data?.error || data?.message || JSON.stringify(data);
383
388
  return { content: [{ type: "text", text: `Payment failed: ${errMsg}` }] };
384
389
  } catch (e) {
385
- return { content: [{ type: "text", text: `LND connection error: ${e.message}` }] };
390
+ return { content: [{ type: "text", text: `Router connection error: ${e.message}` }] };
386
391
  }
387
392
  }
388
393
  );
@@ -425,14 +430,13 @@ server.tool(
425
430
  const macaroon = data.macaroon;
426
431
  const amount = data.amount || 0;
427
432
 
428
- if (!invoice || !LND_HOST) {
433
+ if (!invoice) {
429
434
  return {
430
435
  content: [{
431
436
  type: "text",
432
437
  text: JSON.stringify({
433
438
  success: false,
434
- error: "Need LND node configured to pay invoice",
435
- invoice,
439
+ error: "No invoice returned from L402 challenge — cannot pay",
436
440
  amount,
437
441
  }, null, 2),
438
442
  }],
@@ -442,31 +446,29 @@ server.tool(
442
446
  // Check routing status before paying
443
447
  const routingStatus = await checkRoutingStatus();
444
448
  if (routingStatus === "suspended") {
445
- return { content: [{ type: "text", text: "Account suspended — routing verification failed. Contact support." }] };
449
+ return { content: [{ type: "text", text: "Account suspended — routing verification failed. Contact support." }] };
446
450
  }
447
451
 
448
- // Force routing through BK Block node as first hop
449
- const payRes = await fetch(`${LND_HOST}/v1/channels/transactions`, {
452
+ // Pay the L402 invoice via the AgenticBTC payment router (NOT directly via LND).
453
+ // The router collects the 0.5% fee, applies privacy-aware rail selection, and
454
+ // provides multi-rail failover.
455
+ const { status: payStatus, data: payData } = await apiCall("/api/v1/payments", {
450
456
  method: "POST",
451
- headers: {
452
- "Grpc-Metadata-macaroon": LND_MACAROON,
453
- "Content-Type": "application/json",
454
- },
455
457
  body: JSON.stringify({
456
- payment_request: invoice,
457
- outgoing_chan_id: "", // LND will use available channels
458
- fee_limit: { fixed: 5000 }, // Allow routing fees
458
+ invoice,
459
+ fee_limit_sats: 5000,
459
460
  }),
460
461
  });
461
- const payData = await payRes.json();
462
462
 
463
- if (payData.payment_error) {
464
- return { content: [{ type: "text", text: `Lightning payment failed: ${payData.payment_error}` }] };
463
+ if (payStatus !== 200 || !payData.success) {
464
+ const errMsg = payData?.detail || payData?.error || payData?.payment_error || JSON.stringify(payData);
465
+ return { content: [{ type: "text", text: `Lightning payment failed: ${errMsg}` }] };
465
466
  }
466
467
 
467
- const preimage = payData.payment_preimage;
468
+ const preimage = payData.preimage || payData.payment_preimage;
468
469
  if (!preimage) {
469
- return { content: [{ type: "text", text: "No preimage returned from payment" }] };
470
+ // Payment succeeded at router level but no preimage returned report best-effort
471
+ return { content: [{ type: "text", text: "Payment succeeded but no preimage returned from router. L402 access may require preimage — check router logs." }] };
470
472
  }
471
473
 
472
474
  // Step 3: Access with L402 token
@@ -479,22 +481,16 @@ server.tool(
479
481
 
480
482
  if (authRes.status === 200) {
481
483
  const authData = await authRes.json();
482
- // Report L402 payment for routing verification
483
- await reportPayment(
484
- payData.payment_hash || "",
485
- amount,
486
- "",
487
- [BK_BLOCK_NODE_PUBKEY]
488
- );
489
484
  return {
490
485
  content: [{
491
486
  type: "text",
492
487
  text: JSON.stringify({
493
488
  success: true,
494
489
  data: authData,
495
- amount_paid_sats: amount,
490
+ amount_paid_sats: payData.amount_sats || amount,
496
491
  payment_preimage: preimage,
497
- message: `Paid ${amount} sats ⚡ for ${endpoint} API access`,
492
+ rail: payData.rail,
493
+ message: `Paid ${payData.amount_sats || amount} sats via router for ${endpoint} API access`,
498
494
  }, null, 2),
499
495
  }],
500
496
  };
@@ -680,9 +676,9 @@ server.tool(
680
676
  comment: z.string().optional().default("").describe("Optional comment for the recipient"),
681
677
  },
682
678
  async ({ address, amount_sats, agent, comment }) => {
683
- if (!LND_HOST) {
684
- return { content: [{ type: "text", text: "Error: LND node not configured." }] };
685
- }
679
+ // No LND_HOST check needed — payment routes through the AgenticBTC REST API router.
680
+ // LNURL resolution (steps 1 & 2 below) is external HTTP; only step 3 (the payment)
681
+ // has changed to go through POST /api/v1/payments instead of directly to LND.
686
682
 
687
683
  // Resolve agent for spending policy
688
684
  let resolvedAgent = null;
@@ -739,25 +735,18 @@ server.tool(
739
735
  return { content: [{ type: "text", text: `Error: No invoice returned from ${domain}` }] };
740
736
  }
741
737
 
742
- // Step 3: Pay the invoice via LND
743
- const payRes = await fetch(`${LND_HOST}/v1/channels/transactions`, {
738
+ // Step 3: Pay the invoice via the AgenticBTC payment router (NOT directly via LND).
739
+ // This ensures the 0.5% router fee is collected, privacy-aware rail selection is
740
+ // applied, and multi-rail failover is available.
741
+ const { status: payStatus, data: payData } = await apiCall("/api/v1/payments", {
744
742
  method: "POST",
745
- headers: {
746
- "Grpc-Metadata-macaroon": LND_MACAROON,
747
- "Content-Type": "application/json",
748
- },
749
743
  body: JSON.stringify({
750
- payment_request: invoiceData.pr,
751
- fee_limit: { fixed: Math.max(100, Math.floor(amount_sats * 0.01)) },
744
+ invoice: invoiceData.pr,
745
+ fee_limit_sats: Math.max(100, Math.floor(amount_sats * 0.01)),
752
746
  }),
753
747
  });
754
- const payData = await payRes.json();
755
748
 
756
- if (payRes.ok && !payData.payment_error) {
757
- // Log transaction
758
- if (resolvedAgent) {
759
- await logTransaction(resolvedAgent.id, amount_sats, address, comment || `Lightning address payment to ${address}`, "completed");
760
- }
749
+ if (payStatus === 200 && payData.success) {
761
750
  return {
762
751
  content: [{
763
752
  type: "text",
@@ -765,21 +754,124 @@ server.tool(
765
754
  success: true,
766
755
  recipient: address,
767
756
  amount_sats: amount_sats,
768
- fee_sats: parseInt(payData.fee || payData.fee_sat || 0),
757
+ fee_sats: payData.fee_sats || 0,
758
+ rail: payData.rail,
769
759
  agent: resolvedAgent?.name || null,
770
- payment_hash: payData.payment_hash,
771
- message: `Sent ${amount_sats} sats to ${address} ⚡`,
760
+ payment_hash: payData.payment_hash || payData.payment_id,
761
+ message: `Sent ${amount_sats} sats to ${address} via router ⚡`,
772
762
  }, null, 2),
773
763
  }],
774
764
  };
775
765
  }
776
- return { content: [{ type: "text", text: `Payment failed: ${payData.payment_error || JSON.stringify(payData)}` }] };
766
+ const errMsg = payData?.detail || payData?.error || payData?.payment_error || JSON.stringify(payData);
767
+ return { content: [{ type: "text", text: `Payment failed: ${errMsg}` }] };
777
768
  } catch (e) {
778
769
  return { content: [{ type: "text", text: `Error: ${e.message}` }] };
779
770
  }
780
771
  }
781
772
  );
782
773
 
774
+ // Tool: Universal send_payment — auto-detects recipient type
775
+ server.tool(
776
+ "send_payment",
777
+ "Send a payment to any recipient. Automatically detects recipient type: BOLT11 Lightning invoice (lnbc.../lntb...), Lightning address (user@domain.com), or Strike handle ($username). Routes through the optimal payment rail.",
778
+ {
779
+ recipient: z.string().describe("Where to send: Lightning invoice (lnbc.../lntb...), Lightning address (user@domain.com), or Strike handle ($username)"),
780
+ amount_sats: z.number().optional().describe("Amount in satoshis (required for Lightning address and Strike handle; optional for BOLT11 invoices which encode the amount)"),
781
+ amount_usd: z.number().optional().describe("Amount in USD (for Strike handle payments; alternative to amount_sats)"),
782
+ memo: z.string().optional().default("").describe("Optional payment memo or comment"),
783
+ agent: z.string().optional().describe("Agent wallet name or ID for spending policy enforcement"),
784
+ },
785
+ async ({ recipient, amount_sats, amount_usd, memo, agent }) => {
786
+ // Detect recipient type
787
+ const isBolt11 = /^lnbc|^lntb|^lnbcrt/i.test(recipient);
788
+ const isStrikeHandle = recipient.startsWith("$");
789
+ const isLightningAddress = !isBolt11 && !isStrikeHandle && recipient.includes("@");
790
+
791
+ // Resolve agent for spending policy
792
+ let resolvedAgent = null;
793
+ if (agent) {
794
+ resolvedAgent = await resolveAgent(agent);
795
+ if (!resolvedAgent) return { content: [{ type: "text", text: `Error: Agent '${agent}' not found` }] };
796
+ if (!resolvedAgent.enabled) return { content: [{ type: "text", text: `Error: Agent '${resolvedAgent.name}' is disabled` }] };
797
+ }
798
+ if (!resolvedAgent) {
799
+ const auth = await getAuthLevel();
800
+ if (auth.agent_id) resolvedAgent = { id: auth.agent_id, name: auth.agent_name || auth.agent_id };
801
+ }
802
+
803
+ if (isBolt11) {
804
+ // Delegate to router via pay_lightning_invoice logic
805
+ if (resolvedAgent) {
806
+ const policy = await checkSpendingPolicy(resolvedAgent.id, amount_sats || 0);
807
+ if (!policy.allowed) return { content: [{ type: "text", text: `Payment blocked: ${policy.reason}` }] };
808
+ }
809
+ const { status, data } = await apiCall("/api/v1/payments", {
810
+ method: "POST",
811
+ body: JSON.stringify({ invoice: recipient, fee_limit_sats: 100 }),
812
+ });
813
+ if (status === 200 && data.success) {
814
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, type: "lightning_invoice", amount_sats: data.amount_sats, fee_sats: data.fee_sats || 0, rail: data.rail, payment_hash: data.payment_hash }) }] };
815
+ }
816
+ return { content: [{ type: "text", text: `Payment failed: ${data?.detail || data?.error || JSON.stringify(data)}` }] };
817
+
818
+ } else if (isLightningAddress) {
819
+ if (!amount_sats) return { content: [{ type: "text", text: "Error: amount_sats required for Lightning address payments" }] };
820
+ if (resolvedAgent) {
821
+ const policy = await checkSpendingPolicy(resolvedAgent.id, amount_sats);
822
+ if (!policy.allowed) return { content: [{ type: "text", text: `Payment blocked: ${policy.reason}` }] };
823
+ }
824
+ // LNURL resolve → pay via router
825
+ const [user, domain] = recipient.split("@");
826
+ const lnurlRes = await fetch(`https://${domain}/.well-known/lnurlp/${user}`);
827
+ if (!lnurlRes.ok) return { content: [{ type: "text", text: `Error: Could not resolve Lightning address ${recipient}` }] };
828
+ const lnurlData = await lnurlRes.json();
829
+ const minSats = Math.ceil((lnurlData.minSendable || 1000) / 1000);
830
+ const maxSats = Math.floor((lnurlData.maxSendable || 100000000000) / 1000);
831
+ if (amount_sats < minSats || amount_sats > maxSats) {
832
+ return { content: [{ type: "text", text: `Error: Amount must be between ${minSats} and ${maxSats} sats for this address` }] };
833
+ }
834
+ let callbackUrl = `${lnurlData.callback}${lnurlData.callback.includes("?") ? "&" : "?"}amount=${amount_sats * 1000}`;
835
+ if (memo && lnurlData.commentAllowed) callbackUrl += `&comment=${encodeURIComponent(memo)}`;
836
+ const invoiceRes = await fetch(callbackUrl);
837
+ if (!invoiceRes.ok) return { content: [{ type: "text", text: `Error: Failed to get invoice from ${domain}` }] };
838
+ const invoiceData = await invoiceRes.json();
839
+ if (!invoiceData.pr) return { content: [{ type: "text", text: `Error: No invoice returned from ${domain}` }] };
840
+ const { status, data } = await apiCall("/api/v1/payments", {
841
+ method: "POST",
842
+ body: JSON.stringify({ invoice: invoiceData.pr, fee_limit_sats: Math.max(100, Math.floor(amount_sats * 0.01)) }),
843
+ });
844
+ if (status === 200 && data.success) {
845
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, type: "lightning_address", recipient, amount_sats, fee_sats: data.fee_sats || 0, payment_hash: data.payment_hash, message: `Sent ${amount_sats} sats to ${recipient} ⚡` }) }] };
846
+ }
847
+ return { content: [{ type: "text", text: `Payment failed: ${data?.detail || data?.error || JSON.stringify(data)}` }] };
848
+
849
+ } else if (isStrikeHandle) {
850
+ const handle = recipient.slice(1); // strip "$"
851
+ if (!amount_usd && !amount_sats) return { content: [{ type: "text", text: "Error: amount_usd or amount_sats required for Strike payments" }] };
852
+ if (resolvedAgent && amount_sats) {
853
+ const policy = await checkSpendingPolicy(resolvedAgent.id, amount_sats);
854
+ if (!policy.allowed) return { content: [{ type: "text", text: `Payment blocked: ${policy.reason}` }] };
855
+ }
856
+ const body = { handle, description: memo || `Payment to $${handle}` };
857
+ if (amount_usd) body.amount_usd = amount_usd;
858
+ if (amount_sats) body.amount_sats = amount_sats;
859
+ if (resolvedAgent) body.agent_id = resolvedAgent.id;
860
+ const { status, data } = await apiCall("/api/v1/strike/pay", {
861
+ method: "POST",
862
+ body: JSON.stringify(body),
863
+ });
864
+ if (status === 200 && data.success) {
865
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, type: "strike_handle", recipient, amount_usd: data.amount_usd, fee_usd: data.fee_usd || 0, payment_id: data.payment_id, message: `Sent to ${recipient} via Strike 💸` }) }] };
866
+ }
867
+ return { content: [{ type: "text", text: `Payment failed: ${data?.detail || data?.error || JSON.stringify(data)}` }] };
868
+
869
+ } else {
870
+ return { content: [{ type: "text", text: `Error: Unrecognized recipient format. Use a BOLT11 invoice (lnbc.../lntb...), Lightning address (user@domain.com), or Strike handle ($username).` }] };
871
+ }
872
+ }
873
+ );
874
+
783
875
  // Tool: Decode Lightning invoice
784
876
  server.tool(
785
877
  "decode_invoice",
@@ -788,43 +880,37 @@ server.tool(
788
880
  invoice: z.string().describe("BOLT11 Lightning invoice to decode"),
789
881
  },
790
882
  async ({ invoice }) => {
791
- if (!LND_HOST) {
792
- return { content: [{ type: "text", text: "Error: LND node not configured." }] };
793
- }
883
+ // Route through the AgenticBTC REST API (POST /api/v1/decode) instead of calling
884
+ // LND directly, so the server-side LND credentials are used and no client-side
885
+ // LND config is required.
794
886
  try {
795
- const res = await fetch(`${LND_HOST}/v1/payreq/${invoice}`, {
796
- headers: { "Grpc-Metadata-macaroon": LND_MACAROON },
887
+ const { status, data } = await apiCall("/api/v1/decode", {
888
+ method: "POST",
889
+ body: JSON.stringify({ invoice }),
797
890
  });
798
- const data = await res.json();
799
891
 
800
- if (!res.ok) {
801
- return { content: [{ type: "text", text: `Error decoding invoice: ${JSON.stringify(data)}` }] };
892
+ if (status !== 200 || !data.success) {
893
+ const errMsg = data?.detail || data?.error || JSON.stringify(data);
894
+ return { content: [{ type: "text", text: `Error decoding invoice: ${errMsg}` }] };
802
895
  }
803
896
 
804
- const expiry = parseInt(data.expiry || 3600);
805
- const timestamp = parseInt(data.timestamp || 0);
806
- const expiresAt = new Date((timestamp + expiry) * 1000).toISOString();
807
- const isExpired = Date.now() > (timestamp + expiry) * 1000;
808
-
809
897
  return {
810
898
  content: [{
811
899
  type: "text",
812
900
  text: JSON.stringify({
813
901
  success: true,
814
- amount_sats: parseInt(data.num_satoshis || 0),
902
+ amount_sats: data.amount_sats,
815
903
  destination: data.destination,
816
904
  description: data.description || "",
817
905
  payment_hash: data.payment_hash,
818
- timestamp: new Date(timestamp * 1000).toISOString(),
819
- expires_at: expiresAt,
820
- is_expired: isExpired,
821
- cltv_expiry: parseInt(data.cltv_expiry || 0),
822
- num_route_hints: (data.route_hints || []).length,
906
+ expires_at: data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null,
907
+ is_expired: data.is_expired,
908
+ cltv_expiry: data.cltv_expiry,
823
909
  }, null, 2),
824
910
  }],
825
911
  };
826
912
  } catch (e) {
827
- return { content: [{ type: "text", text: `LND error: ${e.message}` }] };
913
+ return { content: [{ type: "text", text: `Router error: ${e.message}` }] };
828
914
  }
829
915
  }
830
916
  );
@@ -1388,6 +1474,114 @@ server.tool(
1388
1474
  }
1389
1475
  );
1390
1476
 
1477
+ // ─── X (Twitter) Tools ────────────────────────────────────────────────────────
1478
+
1479
+ server.tool(
1480
+ "post_tweet",
1481
+ "Post a tweet from @agentbtcio. Use for announcing launches, updates, or responding to relevant discussions. Keep under 280 chars.",
1482
+ {
1483
+ text: z.string().max(280).describe("Tweet text (max 280 characters)"),
1484
+ reply_to_tweet_id: z.string().optional().describe("Tweet ID to reply to (optional)"),
1485
+ },
1486
+ async ({ text, reply_to_tweet_id }) => {
1487
+ if (!X_ENABLED) {
1488
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "X credentials not configured. Set TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET in Claude Desktop config." }) }] };
1489
+ }
1490
+ const body = { text };
1491
+ if (reply_to_tweet_id) body.reply = { in_reply_to_tweet_id: reply_to_tweet_id };
1492
+ const { status, data } = await xPost("/2/tweets", body);
1493
+ if (status === 201) {
1494
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, tweet_id: data.data.id, url: `https://x.com/agentbtcio/status/${data.data.id}`, text }) }] };
1495
+ }
1496
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: data.detail || data.title || "Post failed", status }) }] };
1497
+ }
1498
+ );
1499
+
1500
+ server.tool(
1501
+ "get_mentions",
1502
+ "Get recent @mentions and replies to @agentbtcio. Use to monitor engagement and find conversations to join.",
1503
+ {
1504
+ limit: z.number().int().min(1).max(20).default(10).describe("Number of recent mentions to fetch (max 20)"),
1505
+ },
1506
+ async ({ limit }) => {
1507
+ if (!X_BEARER_TOKEN || !X_USER_ID) {
1508
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "X bearer token or user ID not configured." }) }] };
1509
+ }
1510
+ const { status, data } = await xGet(
1511
+ `/2/users/${X_USER_ID}/mentions?max_results=${limit}&tweet.fields=created_at,author_id,conversation_id&expansions=author_id&user.fields=username`
1512
+ );
1513
+ if (status === 200) {
1514
+ const users = Object.fromEntries((data.includes?.users || []).map(u => [u.id, u.username]));
1515
+ const mentions = (data.data || []).map(t => ({
1516
+ id: t.id,
1517
+ text: t.text,
1518
+ from: "@" + (users[t.author_id] || t.author_id),
1519
+ created_at: t.created_at,
1520
+ url: `https://x.com/i/status/${t.id}`,
1521
+ }));
1522
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, count: mentions.length, mentions }) }] };
1523
+ }
1524
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: data.title || "Failed to fetch mentions", status }) }] };
1525
+ }
1526
+ );
1527
+
1528
+ server.tool(
1529
+ "search_x",
1530
+ "Search recent X posts by keyword. Use to find conversations about AI agents, Lightning Network, Bitcoin payments, or AgenticBTC.",
1531
+ {
1532
+ query: z.string().describe("Search query (e.g. 'AI agents Lightning payments', 'agenticbtc', 'MCP tools bitcoin')"),
1533
+ limit: z.number().int().min(1).max(20).default(10).describe("Number of results (max 20)"),
1534
+ },
1535
+ async ({ query, limit }) => {
1536
+ if (!X_BEARER_TOKEN) {
1537
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "X bearer token not configured." }) }] };
1538
+ }
1539
+ const params = new URLSearchParams({ query: `${query} -is:retweet lang:en`, max_results: limit, "tweet.fields": "created_at,author_id", expansions: "author_id", "user.fields": "username" });
1540
+ const { status, data } = await xGet(`/2/tweets/search/recent?${params}`);
1541
+ if (status === 200) {
1542
+ const users = Object.fromEntries((data.includes?.users || []).map(u => [u.id, u.username]));
1543
+ const tweets = (data.data || []).map(t => ({
1544
+ id: t.id,
1545
+ text: t.text,
1546
+ from: "@" + (users[t.author_id] || t.author_id),
1547
+ created_at: t.created_at,
1548
+ url: `https://x.com/i/status/${t.id}`,
1549
+ }));
1550
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, query, count: tweets.length, tweets }) }] };
1551
+ }
1552
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: data.title || "Search failed", status }) }] };
1553
+ }
1554
+ );
1555
+
1556
+ server.tool(
1557
+ "get_own_timeline",
1558
+ "Get recent tweets posted by @agentbtcio. Use to review posting history before drafting new content.",
1559
+ {
1560
+ limit: z.number().int().min(1).max(20).default(10).describe("Number of recent tweets to fetch"),
1561
+ },
1562
+ async ({ limit }) => {
1563
+ if (!X_BEARER_TOKEN || !X_USER_ID) {
1564
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: "X credentials not configured." }) }] };
1565
+ }
1566
+ const { status, data } = await xGet(
1567
+ `/2/users/${X_USER_ID}/tweets?max_results=${limit}&tweet.fields=created_at,public_metrics&exclude=retweets,replies`
1568
+ );
1569
+ if (status === 200) {
1570
+ const tweets = (data.data || []).map(t => ({
1571
+ id: t.id,
1572
+ text: t.text,
1573
+ created_at: t.created_at,
1574
+ likes: t.public_metrics?.like_count || 0,
1575
+ retweets: t.public_metrics?.retweet_count || 0,
1576
+ replies: t.public_metrics?.reply_count || 0,
1577
+ url: `https://x.com/agentbtcio/status/${t.id}`,
1578
+ }));
1579
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, count: tweets.length, tweets }) }] };
1580
+ }
1581
+ return { content: [{ type: "text", text: JSON.stringify({ success: false, error: data.title || "Failed to fetch timeline", status }) }] };
1582
+ }
1583
+ );
1584
+
1391
1585
  // Start server
1392
1586
  const transport = new StdioServerTransport();
1393
1587
  await server.connect(transport);