@aibtc/mcp-server 1.64.1 → 1.65.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 CHANGED
@@ -7,6 +7,7 @@ import { createRequire } from "module";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { registerAllTools } from "./tools/index.js";
10
+ import { installBountyHint } from "./tools/bounty-hint.js";
10
11
  import { NETWORK, API_URL } from "./config/index.js";
11
12
  import { redactSensitive } from "./utils/redact.js";
12
13
  import { initializeStorage } from "./utils/storage.js";
@@ -253,9 +254,26 @@ else {
253
254
  const server = new McpServer({
254
255
  name: "aibtc-mcp-server",
255
256
  version: packageJson.version,
257
+ }, {
258
+ instructions: [
259
+ "This server lets an agent earn and move real Bitcoin/sBTC on Stacks.",
260
+ "",
261
+ "Hunt bounties. The most direct way to earn here is the sBTC bounty board:",
262
+ "call bounty_list to see open bounties, bounty_get for the task detail, do",
263
+ "the work, then bounty_submit your deliverable. Accepted submissions are paid",
264
+ "in sBTC. Check periodically for new open bounties.",
265
+ "",
266
+ "For the full menu of ways to put assets to work (stacking, DeFi yield,",
267
+ "trading, paid endpoints), call earning_opportunities. Submitting to a bounty",
268
+ "requires a Registered (L1+) on-chain identity — see identity_register.",
269
+ ].join("\n"),
256
270
  });
271
+ // Append a bounty-board hint to spend/onboarding-tool output.
272
+ // Must wrap registerTool before registration; restore it after.
273
+ const restoreBountyHint = installBountyHint(server);
257
274
  // Register all tools from the modular registry
258
275
  registerAllTools(server);
276
+ restoreBountyHint();
259
277
  async function main() {
260
278
  await initializeStorage();
261
279
  const transport = new StdioServerTransport();
@@ -1,4 +1,5 @@
1
1
  import { type AxiosInstance } from "axios";
2
+ import { type PaymentRequirementsV2 } from "../utils/x402-protocol.js";
2
3
  import { NETWORK, API_URL, type Network } from "../config/networks.js";
3
4
  import type { Account } from "../transactions/builder.js";
4
5
  /**
@@ -34,6 +35,12 @@ export interface CreateApiClientOptions {
34
35
  */
35
36
  onBeforePayment?: (requirements: PaymentRequirements) => Promise<void>;
36
37
  toolName?: string;
38
+ /**
39
+ * Which asset to pay with when the endpoint offers several — an asset
40
+ * identifier ("SM3V....sbtc-token") or a symbol ("sBTC", "STX", "USDCx").
41
+ * Unset means the first entry this client can pay for.
42
+ */
43
+ asset?: string;
37
44
  }
38
45
  /**
39
46
  * Create an API client with x402 payment interceptor.
@@ -64,9 +71,13 @@ export type ProbeResultPaymentRequired = {
64
71
  type: 'payment_required';
65
72
  amount: string;
66
73
  asset: string;
74
+ /** Display symbol for `asset` (e.g. "USDCx"), when derivable. */
75
+ symbol?: string;
67
76
  recipient: string;
68
77
  network: string;
69
78
  endpoint: string;
79
+ /** Every asset the endpoint accepts, so the caller can pick another. */
80
+ accepts?: PaymentOptionSummary[];
70
81
  resource?: {
71
82
  url: string;
72
83
  description?: string;
@@ -76,18 +87,70 @@ export type ProbeResultPaymentRequired = {
76
87
  };
77
88
  export type ProbeResult = ProbeResultFree | ProbeResultPaymentRequired;
78
89
  /**
79
- * Detect token type from asset identifier
90
+ * Token rails this client can actually build and sign a payment for.
91
+ * Anything else is `unsupported` — see detectTokenType.
92
+ */
93
+ export type PaymentTokenType = 'STX' | 'sBTC' | 'unsupported';
94
+ /**
95
+ * Detect token type from asset identifier.
96
+ *
97
+ * STX must be identified POSITIVELY, never as a fallback. This function used
98
+ * to return 'STX' for any unrecognized asset, which meant a USDCx-denominated
99
+ * 402 (asset `SP120...usdcx`, amount `29000000`) was routed to the interceptor's
100
+ * native-STX branch and signed as a 29 STX transfer — real funds, wrong asset,
101
+ * and the endpoint never credits it. Unknown assets are now `unsupported` so
102
+ * the payment is refused instead of silently mis-sent (#613).
103
+ *
80
104
  * @param asset - Full contract identifier or token name
81
- * @returns 'STX' for native STX, 'sBTC' for sBTC token
82
105
  */
83
- export declare function detectTokenType(asset: string): 'STX' | 'sBTC';
106
+ export declare function detectTokenType(asset: string): PaymentTokenType;
84
107
  /**
85
- * Format payment amount into human-readable string with token symbol
86
- * @param amount - Raw amount string (microSTX or satoshis)
108
+ * Best-effort display symbol for an asset. Servers advertise a friendly name in
109
+ * `extra.tokenType` (arc0btc sends "USDCx"); otherwise fall back to the contract
110
+ * name, then the raw identifier.
111
+ */
112
+ export declare function resolveAssetSymbol(asset: string, extra?: Record<string, unknown>): string;
113
+ /**
114
+ * Format payment amount into human-readable string with token symbol.
115
+ *
116
+ * Only STX and sBTC have known decimal scales here. For any other asset the
117
+ * amount is rendered in raw base units with an explicit marker rather than
118
+ * being run through the STX formatter — dividing a USDCx amount by 1e6 and
119
+ * labelling it "STX" is how `29000000` USDCx base units got reported as
120
+ * "29 STX" (#613).
121
+ *
122
+ * @param amount - Raw amount string (microSTX, satoshis, or token base units)
87
123
  * @param asset - Token asset identifier
88
- * @returns Formatted string like "0.000001 sBTC" or "0.001 STX"
124
+ * @param extra - Scheme-specific `extra` block from the accepts[] entry
125
+ * @returns Formatted string like "0.000001 sBTC", "0.001 STX", or "29000000 USDCx (base units)"
89
126
  */
90
- export declare function formatPaymentAmount(amount: string, asset: string): string;
127
+ export declare function formatPaymentAmount(amount: string, asset: string, extra?: Record<string, unknown>): string;
128
+ /** One payment option, summarised for callers choosing between them. */
129
+ export interface PaymentOptionSummary {
130
+ asset: string;
131
+ symbol: string;
132
+ amount: string;
133
+ formatted: string;
134
+ payTo: string;
135
+ /** False when this client cannot build a transaction for the asset. */
136
+ payable: boolean;
137
+ }
138
+ export declare function summarizePaymentOptions(accepts: PaymentRequirementsV2[]): PaymentOptionSummary[];
139
+ /**
140
+ * Choose which entry of `accepts[]` to pay.
141
+ *
142
+ * Previously this was hardcoded to the first Stacks-network entry, so on a
143
+ * multi-asset endpoint the caller got whatever the server happened to list
144
+ * first — USDCx on arc0btc — with no way to ask for anything else (#613).
145
+ *
146
+ * - `preferredAsset` set: that asset or an explicit error naming what IS on
147
+ * offer. Never silently substitutes a different asset than the one asked for.
148
+ * - unset: the first entry this client can actually pay for, in the server's
149
+ * own preference order. Deliberately NOT balance-aware — auto-selecting by
150
+ * wallet contents would let a manifest reorder change which asset leaves the
151
+ * wallet without the caller ever naming it.
152
+ */
153
+ export declare function selectPaymentOption(accepts: PaymentRequirementsV2[], preferredAsset?: string): PaymentRequirementsV2;
91
154
  /**
92
155
  * Probe an endpoint without payment interceptor
93
156
  * Returns either free response data or payment requirements
@@ -97,6 +160,8 @@ export declare function probeEndpoint(options: {
97
160
  url: string;
98
161
  params?: Record<string, string>;
99
162
  data?: Record<string, unknown>;
163
+ /** Asset to quote (identifier or symbol). Defaults to the first payable entry. */
164
+ asset?: string;
100
165
  }): Promise<ProbeResult>;
101
166
  /**
102
167
  * Generate a stable deduplication key for a request
@@ -473,12 +473,18 @@ export async function createApiClient(baseUrl, options) {
473
473
  if (!paymentRequired || !paymentRequired.accepts || paymentRequired.accepts.length === 0) {
474
474
  return Promise.reject(new Error("Invalid x402 402 response: missing or empty payment-required header"));
475
475
  }
476
- // Select first Stacks-compatible payment option
477
- const selectedOption = paymentRequired.accepts.find((opt) => opt.network?.startsWith("stacks:"));
478
- if (!selectedOption) {
479
- const networks = paymentRequired.accepts.map((a) => a.network).join(", ");
480
- return Promise.reject(new Error(`No compatible Stacks payment option found. Available networks: ${networks}`));
476
+ // Select the payment option: the caller's requested asset, else the
477
+ // first entry this client can actually sign for. Throws with the list
478
+ // of accepted assets rather than falling back to a different one.
479
+ let selectedOption;
480
+ try {
481
+ selectedOption = selectPaymentOption(paymentRequired.accepts, options?.asset);
481
482
  }
483
+ catch (selectionError) {
484
+ return Promise.reject(selectionError);
485
+ }
486
+ // Guaranteed non-'unsupported' by selectPaymentOption, so the branch
487
+ // below can never route a third-party token into the native-STX path.
482
488
  const tokenType = detectTokenType(selectedOption.asset);
483
489
  const amount = BigInt(selectedOption.amount);
484
490
  const isSbtc = tokenType === "sBTC";
@@ -624,9 +630,16 @@ export async function getAccount() {
624
630
  return mnemonicToAccount(mnemonic, NETWORK);
625
631
  }
626
632
  /**
627
- * Detect token type from asset identifier
633
+ * Detect token type from asset identifier.
634
+ *
635
+ * STX must be identified POSITIVELY, never as a fallback. This function used
636
+ * to return 'STX' for any unrecognized asset, which meant a USDCx-denominated
637
+ * 402 (asset `SP120...usdcx`, amount `29000000`) was routed to the interceptor's
638
+ * native-STX branch and signed as a 29 STX transfer — real funds, wrong asset,
639
+ * and the endpoint never credits it. Unknown assets are now `unsupported` so
640
+ * the payment is refused instead of silently mis-sent (#613).
641
+ *
628
642
  * @param asset - Full contract identifier or token name
629
- * @returns 'STX' for native STX, 'sBTC' for sBTC token
630
643
  */
631
644
  export function detectTokenType(asset) {
632
645
  const assetLower = asset.trim().toLowerCase();
@@ -636,27 +649,133 @@ export function detectTokenType(asset) {
636
649
  if (assetLower === 'sbtc' || assetLower.endsWith('::token-sbtc') || assetLower.endsWith('.sbtc-token')) {
637
650
  return 'sBTC';
638
651
  }
639
- return 'STX';
652
+ // Native STX: the bare symbol, or a CAIP-19-style native reference
653
+ // ("stacks:1/native" / ".../slip44:5773") used by some manifests.
654
+ if (assetLower === 'stx' || /(^|\/)(native|slip44:5773)$/.test(assetLower)) {
655
+ return 'STX';
656
+ }
657
+ return 'unsupported';
658
+ }
659
+ /**
660
+ * Best-effort display symbol for an asset. Servers advertise a friendly name in
661
+ * `extra.tokenType` (arc0btc sends "USDCx"); otherwise fall back to the contract
662
+ * name, then the raw identifier.
663
+ */
664
+ export function resolveAssetSymbol(asset, extra) {
665
+ const advertised = extra?.tokenType;
666
+ if (typeof advertised === 'string' && advertised.trim()) {
667
+ return advertised.trim();
668
+ }
669
+ const tokenType = detectTokenType(asset);
670
+ if (tokenType !== 'unsupported') {
671
+ return tokenType;
672
+ }
673
+ // "SP120....usdcx" -> "usdcx"; strip any "::asset-name" suffix first.
674
+ const withoutAssetName = asset.split('::')[0];
675
+ const contractName = withoutAssetName.split('.')[1];
676
+ return contractName || asset;
640
677
  }
641
678
  /**
642
- * Format payment amount into human-readable string with token symbol
643
- * @param amount - Raw amount string (microSTX or satoshis)
679
+ * Format payment amount into human-readable string with token symbol.
680
+ *
681
+ * Only STX and sBTC have known decimal scales here. For any other asset the
682
+ * amount is rendered in raw base units with an explicit marker rather than
683
+ * being run through the STX formatter — dividing a USDCx amount by 1e6 and
684
+ * labelling it "STX" is how `29000000` USDCx base units got reported as
685
+ * "29 STX" (#613).
686
+ *
687
+ * @param amount - Raw amount string (microSTX, satoshis, or token base units)
644
688
  * @param asset - Token asset identifier
645
- * @returns Formatted string like "0.000001 sBTC" or "0.001 STX"
689
+ * @param extra - Scheme-specific `extra` block from the accepts[] entry
690
+ * @returns Formatted string like "0.000001 sBTC", "0.001 STX", or "29000000 USDCx (base units)"
646
691
  */
647
- export function formatPaymentAmount(amount, asset) {
692
+ export function formatPaymentAmount(amount, asset, extra) {
648
693
  const tokenType = detectTokenType(asset);
649
694
  if (tokenType === 'sBTC') {
650
695
  return formatSbtc(amount);
651
696
  }
697
+ if (tokenType === 'unsupported') {
698
+ // Decimals are unknown, so do not invent a scaled figure.
699
+ return `${amount} ${resolveAssetSymbol(asset, extra)} (base units)`;
700
+ }
652
701
  return formatStx(amount);
653
702
  }
703
+ /**
704
+ * Does `requested` name this accepts[] entry? Matches on the full asset
705
+ * identifier or on the display symbol, both case-insensitively, so callers can
706
+ * say "sBTC", "usdcx", or the full "SM3V....sbtc-token".
707
+ */
708
+ function assetMatches(option, requested) {
709
+ const want = requested.trim().toLowerCase();
710
+ if (!want)
711
+ return false;
712
+ if (option.asset.trim().toLowerCase() === want)
713
+ return true;
714
+ if (resolveAssetSymbol(option.asset, option.extra).toLowerCase() === want)
715
+ return true;
716
+ // "sbtc" should also match the full sbtc-token contract id, and "stx" the
717
+ // native entry, without the caller needing the exact string.
718
+ const tokenType = detectTokenType(option.asset);
719
+ return tokenType !== 'unsupported' && tokenType.toLowerCase() === want;
720
+ }
721
+ export function summarizePaymentOptions(accepts) {
722
+ return accepts.map((opt) => ({
723
+ asset: opt.asset,
724
+ symbol: resolveAssetSymbol(opt.asset, opt.extra),
725
+ amount: opt.amount,
726
+ formatted: formatPaymentAmount(opt.amount, opt.asset, opt.extra),
727
+ payTo: opt.payTo,
728
+ payable: detectTokenType(opt.asset) !== 'unsupported',
729
+ }));
730
+ }
731
+ /**
732
+ * Choose which entry of `accepts[]` to pay.
733
+ *
734
+ * Previously this was hardcoded to the first Stacks-network entry, so on a
735
+ * multi-asset endpoint the caller got whatever the server happened to list
736
+ * first — USDCx on arc0btc — with no way to ask for anything else (#613).
737
+ *
738
+ * - `preferredAsset` set: that asset or an explicit error naming what IS on
739
+ * offer. Never silently substitutes a different asset than the one asked for.
740
+ * - unset: the first entry this client can actually pay for, in the server's
741
+ * own preference order. Deliberately NOT balance-aware — auto-selecting by
742
+ * wallet contents would let a manifest reorder change which asset leaves the
743
+ * wallet without the caller ever naming it.
744
+ */
745
+ export function selectPaymentOption(accepts, preferredAsset) {
746
+ const stacksOptions = accepts.filter((opt) => opt.network?.startsWith('stacks:'));
747
+ if (stacksOptions.length === 0) {
748
+ const networks = accepts.map((a) => a.network).join(', ') || 'none';
749
+ throw new Error(`No compatible Stacks payment option found. Available networks: ${networks}`);
750
+ }
751
+ const describe = (opts) => opts
752
+ .map((o) => `${resolveAssetSymbol(o.asset, o.extra)} (${o.asset})`)
753
+ .join(', ');
754
+ if (preferredAsset) {
755
+ const match = stacksOptions.find((opt) => assetMatches(opt, preferredAsset));
756
+ if (!match) {
757
+ throw new Error(`Endpoint does not accept "${preferredAsset}". Accepted assets: ${describe(stacksOptions)}.`);
758
+ }
759
+ if (detectTokenType(match.asset) === 'unsupported') {
760
+ throw new Error(`Payment in ${resolveAssetSymbol(match.asset, match.extra)} (${match.asset}) is not supported — ` +
761
+ `this client can only sign STX and sBTC payments. ` +
762
+ `Accepted assets: ${describe(stacksOptions)}.`);
763
+ }
764
+ return match;
765
+ }
766
+ const payable = stacksOptions.find((opt) => detectTokenType(opt.asset) !== 'unsupported');
767
+ if (!payable) {
768
+ throw new Error(`No payable asset offered: this client can only sign STX and sBTC payments, ` +
769
+ `but the endpoint accepts ${describe(stacksOptions)}.`);
770
+ }
771
+ return payable;
772
+ }
654
773
  /**
655
774
  * Probe an endpoint without payment interceptor
656
775
  * Returns either free response data or payment requirements
657
776
  */
658
777
  export async function probeEndpoint(options) {
659
- const { method, url, params, data } = options;
778
+ const { method, url, params, data, asset: preferredAsset } = options;
660
779
  const axiosInstance = createBaseAxiosInstance();
661
780
  try {
662
781
  const response = await axiosInstance.request({ method, url, params, data });
@@ -675,18 +794,22 @@ export async function probeEndpoint(options) {
675
794
  const paymentRequired = decodePaymentRequired(headerValue);
676
795
  // If v2 header is successfully parsed, use it
677
796
  if (paymentRequired?.accepts?.length) {
678
- const acceptedPayment = paymentRequired.accepts[0];
797
+ // Pick the requested asset, or the first one we can actually pay —
798
+ // NOT blindly accepts[0], which quoted USDCx to sBTC-only wallets (#613).
799
+ const acceptedPayment = selectPaymentOption(paymentRequired.accepts, preferredAsset);
679
800
  // Convert CAIP-2 network identifier to human-readable format
680
801
  const network = getNetworkFromStacksChainId(acceptedPayment.network) ?? NETWORK;
681
802
  return {
682
803
  type: 'payment_required',
683
804
  amount: acceptedPayment.amount,
684
805
  asset: acceptedPayment.asset,
806
+ symbol: resolveAssetSymbol(acceptedPayment.asset, acceptedPayment.extra),
685
807
  recipient: acceptedPayment.payTo,
686
808
  network,
687
809
  endpoint: url,
688
810
  resource: paymentRequired.resource,
689
811
  maxTimeoutSeconds: acceptedPayment.maxTimeoutSeconds,
812
+ accepts: summarizePaymentOptions(paymentRequired.accepts),
690
813
  };
691
814
  }
692
815
  // Fall back to v1 body parsing
@@ -750,6 +873,11 @@ export function recordTransaction(key, txid) {
750
873
  export async function checkSufficientBalance(account, amount, asset, sponsored = false) {
751
874
  const tokenType = detectTokenType(asset);
752
875
  const requiredAmount = BigInt(amount);
876
+ if (tokenType === 'unsupported') {
877
+ // No balance rail for third-party tokens. Refuse rather than falling
878
+ // through to the STX check, which would validate the wrong asset (#613).
879
+ throw new Error(`Cannot validate balance for asset ${asset}: this client only supports STX and sBTC payments.`);
880
+ }
753
881
  if (tokenType === 'sBTC') {
754
882
  const sbtcService = getSbtcService(account.network);
755
883
  const balanceInfo = await sbtcService.getBalance(account.address);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Bounty hint injector.
3
+ *
4
+ * Appends a one-line pointer to the sBTC bounty board on the output of two tool
5
+ * groups:
6
+ * - SPEND_TOOLS: money-moving tools (transfers, contract calls, swaps, protocol
7
+ * deposits). When an agent has just moved value, remind it the board pays sBTC.
8
+ * - ONBOARDING_TOOLS: identity registration and wallet creation/import. When an
9
+ * agent has just set itself up, point it at the fastest way to start earning.
10
+ *
11
+ * Deliberately NOT applied to read/query tools an agent calls constantly
12
+ * (balances, status, lists) — that would be noise. Only these two groups get it.
13
+ *
14
+ * The hint is a separate text content block, static (no network call), and only
15
+ * added to successful results — never to errors, and never touching the tool's
16
+ * own JSON payload.
17
+ */
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ /**
20
+ * Tools that move or spend value. A hint is appended to their successful output.
21
+ */
22
+ export declare const SPEND_TOOLS: ReadonlySet<string>;
23
+ /**
24
+ * Onboarding tools. A hint is appended right after an agent registers an identity
25
+ * or creates/imports a wallet — the moment it's deciding what to do next.
26
+ */
27
+ export declare const ONBOARDING_TOOLS: ReadonlySet<string>;
28
+ /**
29
+ * Monkey-patch `server.registerTool` so that any tool in SPEND_TOOLS or
30
+ * ONBOARDING_TOOLS has the relevant bounty hint appended to its (successful)
31
+ * output. Must be called BEFORE registering the tools. No-op for every other tool.
32
+ *
33
+ * Returns a cleanup function that restores the original method, mirroring
34
+ * `withSkillMeta` in tools/index.ts — call it once registration is done.
35
+ */
36
+ export declare function installBountyHint(server: McpServer): () => void;
37
+ //# sourceMappingURL=bounty-hint.d.ts.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tools that move or spend value. A hint is appended to their successful output.
3
+ */
4
+ export const SPEND_TOOLS = new Set([
5
+ // Core transfers / contract / swap chokepoints
6
+ "transfer_stx",
7
+ "transfer_btc",
8
+ "transfer_token",
9
+ "transfer_nft",
10
+ "transfer_rune",
11
+ "sbtc_transfer",
12
+ "call_contract",
13
+ "deploy_contract",
14
+ "bitflow_swap",
15
+ "alex_swap",
16
+ "lightning_pay_invoice",
17
+ // Stacking / PoX
18
+ "stack_stx",
19
+ "extend_stacking",
20
+ "dual_stacking_enroll",
21
+ // Zest lending
22
+ "zest_supply",
23
+ "zest_borrow",
24
+ "zest_repay",
25
+ // Pillar smart wallet (browser-handoff)
26
+ "pillar_supply",
27
+ "pillar_boost",
28
+ "pillar_unwind",
29
+ "pillar_send",
30
+ "pillar_fund",
31
+ "pillar_auto_compound",
32
+ // Pillar smart wallet (agent-signed direct)
33
+ "pillar_direct_supply",
34
+ "pillar_direct_boost",
35
+ "pillar_direct_unwind",
36
+ "pillar_direct_send",
37
+ "pillar_direct_stack_stx",
38
+ // JingSwap batch auction
39
+ "jingswap_deposit_stx",
40
+ "jingswap_deposit_sbtc",
41
+ // Styx BTC→sBTC
42
+ "styx_deposit",
43
+ // StackSpot lottery
44
+ "stackspot_join_pot",
45
+ "stackspot_start_pot",
46
+ // sBTC peg
47
+ "sbtc_deposit",
48
+ "sbtc_withdraw",
49
+ "sbtc_initiate_withdrawal",
50
+ // Inbox (direct sBTC send)
51
+ "send_inbox_message_direct",
52
+ ]);
53
+ /**
54
+ * Onboarding tools. A hint is appended right after an agent registers an identity
55
+ * or creates/imports a wallet — the moment it's deciding what to do next.
56
+ */
57
+ export const ONBOARDING_TOOLS = new Set([
58
+ "identity_register",
59
+ "wallet_create",
60
+ "wallet_import",
61
+ ]);
62
+ const SPEND_HINT = "💰 Tip: the AIBTC bounty board pays sBTC for completed tasks — " +
63
+ "call bounty_list to see open bounties you could earn from.";
64
+ const ONBOARDING_HINT = "💰 You're set up. The fastest way to start earning sBTC is the AIBTC bounty " +
65
+ "board — call bounty_list to see open bounties, or earning_opportunities for " +
66
+ "the full menu of ways to put your assets to work.";
67
+ /** Resolve the hint text for a tool, or null if the tool gets no hint. */
68
+ function hintFor(name) {
69
+ if (SPEND_TOOLS.has(name))
70
+ return SPEND_HINT;
71
+ if (ONBOARDING_TOOLS.has(name))
72
+ return ONBOARDING_HINT;
73
+ return null;
74
+ }
75
+ function appendHint(result, hint) {
76
+ // Never annotate an error result — the agent should see the failure cleanly.
77
+ if (!result || result.isError)
78
+ return result;
79
+ const content = Array.isArray(result.content) ? result.content : [];
80
+ return {
81
+ ...result,
82
+ content: [...content, { type: "text", text: hint }],
83
+ };
84
+ }
85
+ /**
86
+ * Monkey-patch `server.registerTool` so that any tool in SPEND_TOOLS or
87
+ * ONBOARDING_TOOLS has the relevant bounty hint appended to its (successful)
88
+ * output. Must be called BEFORE registering the tools. No-op for every other tool.
89
+ *
90
+ * Returns a cleanup function that restores the original method, mirroring
91
+ * `withSkillMeta` in tools/index.ts — call it once registration is done.
92
+ */
93
+ export function installBountyHint(server) {
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ const original = server.registerTool;
96
+ const hasOwn = Object.prototype.hasOwnProperty.call(server, "registerTool");
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ server.registerTool = function (name, config, cb) {
99
+ const hint = hintFor(name);
100
+ if (!hint) {
101
+ return original.call(server, name, config, cb);
102
+ }
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ const wrapped = async (...args) => {
105
+ const result = await cb(...args);
106
+ return appendHint(result, hint);
107
+ };
108
+ return original.call(server, name, config, wrapped);
109
+ };
110
+ return () => {
111
+ if (hasOwn) {
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
113
+ server.registerTool = original;
114
+ }
115
+ else {
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ delete server.registerTool;
118
+ }
119
+ };
120
+ }
121
+ //# sourceMappingURL=bounty-hint.js.map
@@ -11,9 +11,63 @@ import { createJsonResponse, createErrorResponse } from "../utils/index.js";
11
11
  const DUAL_STACKING_ADDRESS = "SP1HFCRKEJ8BYW4D0E3FAWHFDX8A25PPAA83HWWZ9";
12
12
  const DUAL_STACKING_CONTRACT = "dual-stacking-v2_0_4";
13
13
  const DUAL_STACKING_CONTRACT_ID = `${DUAL_STACKING_ADDRESS}.${DUAL_STACKING_CONTRACT}`;
14
+ /** APR fields are fixed-point, scaled by 1e6 (500000 => 0.5%). */
15
+ const APR_DIVISOR = 1_000_000;
14
16
  // ---------------------------------------------------------------------------
15
17
  // Helpers
16
18
  // ---------------------------------------------------------------------------
19
+ /**
20
+ * Unwrap a `cvToJSON` tuple into its field map.
21
+ *
22
+ * cvToJSON renders a tuple as `{ type: "(tuple ...)", value: { FIELD: { type, value } } }`
23
+ * — the fields sit one level down under `.value`, keyed by their on-wire Clarity
24
+ * name. Reading them off the top-level object silently yields `undefined` for
25
+ * every field, which is how #611 turned real APR/cycle data into zeros.
26
+ */
27
+ function tupleFields(raw) {
28
+ if (!raw || typeof raw !== "object")
29
+ return null;
30
+ const inner = raw.value;
31
+ if (!inner || typeof inner !== "object")
32
+ return null;
33
+ return inner;
34
+ }
35
+ /**
36
+ * Read a uint field out of a tuple field map. Returns null and records the key
37
+ * in `missing` when absent or non-numeric, so a decode miss surfaces as a
38
+ * warning instead of a plausible-looking zero.
39
+ */
40
+ function tupleUint(fields, key, missing) {
41
+ const raw = fields?.[key]?.value;
42
+ if (raw === undefined || raw === null) {
43
+ missing.push(key);
44
+ return null;
45
+ }
46
+ const num = Number(raw);
47
+ if (!Number.isFinite(num)) {
48
+ missing.push(key);
49
+ return null;
50
+ }
51
+ return num;
52
+ }
53
+ /**
54
+ * Unwrap a `cvToJSON` optional. `(some u123)` renders as
55
+ * `{ type: "(optional uint)", value: { type: "uint", value: "123" } }` and
56
+ * `none` as a null/undefined `.value`, so the payload needs one extra hop.
57
+ */
58
+ function optionalUint(raw) {
59
+ if (!raw || typeof raw !== "object")
60
+ return null;
61
+ const inner = raw.value;
62
+ if (inner === undefined || inner === null)
63
+ return null;
64
+ // `some` wraps the payload in another {type, value} envelope.
65
+ const scalar = typeof inner === "object" ? inner.value : inner;
66
+ if (scalar === undefined || scalar === null)
67
+ return null;
68
+ const num = Number(scalar);
69
+ return Number.isFinite(num) ? num : null;
70
+ }
17
71
  /**
18
72
  * Call a read-only function on the dual-stacking contract and return a JSON-friendly value.
19
73
  */
@@ -91,41 +145,53 @@ Note: Dual Stacking is only available on mainnet.`,
91
145
  return null;
92
146
  });
93
147
  const [enrolledThisCycleRaw, enrolledNextCycleRaw, minimumAmountRaw, aprDataRaw, cycleOverviewRaw,] = values;
94
- // Parse APR data — returns {min-apr: uint, max-apr: uint} divided by 1_000_000 for %
95
- let apr = {
96
- minApr: 0,
97
- maxApr: 0,
98
- unit: "%",
99
- note: "Multiplier up to 10x with stacked STX",
100
- };
101
- if (aprDataRaw && typeof aprDataRaw === "object") {
102
- const aprObj = aprDataRaw;
103
- const minAprRaw = aprObj["min-apr"]?.value;
104
- const maxAprRaw = aprObj["max-apr"]?.value;
148
+ // Parse APR data — tuple {MAX_APR: uint, MIN_APR: uint, MULTIPLIER: uint}.
149
+ // Keys are the uppercase on-wire Clarity names, not the camelCase output
150
+ // names. A field that fails to decode stays null and is named in
151
+ // `warnings` — a zero here reads as "0% APR, don't enroll" (#611).
152
+ let apr = null;
153
+ if (aprDataRaw) {
154
+ const missing = [];
155
+ const fields = tupleFields(aprDataRaw);
156
+ const minApr = tupleUint(fields, "MIN_APR", missing);
157
+ const maxApr = tupleUint(fields, "MAX_APR", missing);
158
+ const multiplier = tupleUint(fields, "MULTIPLIER", missing);
159
+ if (missing.length > 0) {
160
+ warnings.push(`get-apr-data: could not decode tuple field(s): ${missing.join(", ")}`);
161
+ }
105
162
  apr = {
106
- minApr: minAprRaw !== undefined ? Number(minAprRaw) / 1_000_000 : 0,
107
- maxApr: maxAprRaw !== undefined ? Number(maxAprRaw) / 1_000_000 : 0,
163
+ minApr: minApr === null ? null : minApr / APR_DIVISOR,
164
+ maxApr: maxApr === null ? null : maxApr / APR_DIVISOR,
165
+ multiplier,
108
166
  unit: "%",
109
- note: "Multiplier up to 10x with stacked STX",
167
+ note: `Multiplier up to ${multiplier ?? 10}x with stacked STX`,
110
168
  };
111
169
  }
112
- // Parse cycle overview — returns tuple with cycle-id, snapshot-index, snapshots-per-cycle
113
- let cycleOverview = { currentCycleId: 0, snapshotIndex: 0, snapshotsPerCycle: 0 };
114
- if (cycleOverviewRaw && typeof cycleOverviewRaw === "object") {
115
- const co = cycleOverviewRaw;
170
+ // Parse cycle overview — tuple {cycle-id, snapshot-index, snapshots-per-cycle}
171
+ let cycleOverview = null;
172
+ if (cycleOverviewRaw) {
173
+ const missing = [];
174
+ const fields = tupleFields(cycleOverviewRaw);
116
175
  cycleOverview = {
117
- currentCycleId: co["cycle-id"]?.value !== undefined ? Number(co["cycle-id"].value) : 0,
118
- snapshotIndex: co["snapshot-index"]?.value !== undefined ? Number(co["snapshot-index"].value) : 0,
119
- snapshotsPerCycle: co["snapshots-per-cycle"]?.value !== undefined
120
- ? Number(co["snapshots-per-cycle"].value)
121
- : 0,
176
+ currentCycleId: tupleUint(fields, "cycle-id", missing),
177
+ snapshotIndex: tupleUint(fields, "snapshot-index", missing),
178
+ snapshotsPerCycle: tupleUint(fields, "snapshots-per-cycle", missing),
122
179
  };
180
+ if (missing.length > 0) {
181
+ warnings.push(`current-overview-data: could not decode tuple field(s): ${missing.join(", ")}`);
182
+ }
123
183
  }
124
- // Parse minimum enrollment amount
125
- let minimumEnrollmentSats = 0;
184
+ // Parse minimum enrollment amount (plain uint — one {type, value} level)
185
+ let minimumEnrollmentSats = null;
126
186
  if (minimumAmountRaw !== null && minimumAmountRaw !== undefined) {
127
- const raw = minimumAmountRaw;
128
- minimumEnrollmentSats = raw.value !== undefined ? Number(raw.value) : 0;
187
+ const raw = minimumAmountRaw.value;
188
+ const num = Number(raw);
189
+ if (raw === undefined || raw === null || !Number.isFinite(num)) {
190
+ warnings.push("get-minimum-enrollment-amount: could not decode uint result");
191
+ }
192
+ else {
193
+ minimumEnrollmentSats = num;
194
+ }
129
195
  }
130
196
  // Parse boolean enrollment flags. A failed read stays null (unknown)
131
197
  // rather than collapsing to a misleading `false`.
@@ -192,12 +258,10 @@ Note: Dual Stacking is only available on mainnet.`,
192
258
  uintCV(BigInt(rollbackValue)),
193
259
  principalCV(resolvedAddress),
194
260
  ]);
195
- let rewardSats = 0;
196
- if (rewardRaw !== null && rewardRaw !== undefined) {
197
- const raw = rewardRaw;
198
- rewardSats = raw.value !== undefined ? Number(raw.value) : 0;
199
- }
200
- const rewardBtc = rewardSats / 100_000_000;
261
+ // Contract returns `(optional uint)` — `none` means no reward recorded
262
+ // for this cycle/address, which is distinct from a zero reward.
263
+ const rewardSats = optionalUint(rewardRaw);
264
+ const rewardBtc = rewardSats === null ? null : rewardSats / 100_000_000;
201
265
  return createJsonResponse({
202
266
  address: resolvedAddress,
203
267
  cycle,
@@ -60,6 +60,9 @@ function buildCallWith(options) {
60
60
  callWith.params = options.params;
61
61
  if (options.data && Object.keys(options.data).length > 0)
62
62
  callWith.data = options.data;
63
+ // Echo the asset so executing the quote pays in the asset that was quoted.
64
+ if (options.asset)
65
+ callWith.asset = options.asset;
63
66
  return callWith;
64
67
  }
65
68
  /**
@@ -98,18 +101,29 @@ function formatProbeResponse(result, method, fullUrl, callWithOptions, messagePr
98
101
  response: result.data,
99
102
  });
100
103
  }
104
+ // Derive the message from the same asset the machine-readable block reports.
105
+ // These used to disagree — the text said "29 STX" while payment.asset was
106
+ // USDCx — because the formatter treated every unknown token as STX (#613).
101
107
  const formattedCost = formatPaymentAmount(result.amount, result.asset);
102
108
  const prefix = messagePrefix ?? 'No payment made. ';
109
+ const alternatives = (result.accepts ?? []).filter((o) => o.asset !== result.asset);
110
+ const altNote = alternatives.length > 0
111
+ ? ` This endpoint also accepts ${alternatives
112
+ .map((o) => `${o.formatted}${o.payable ? '' : ' [not payable by this client]'}`)
113
+ .join(', ')} — pass the \`asset\` parameter to pay with one of those instead.`
114
+ : '';
103
115
  return createJsonResponse({
104
116
  type: 'payment_required',
105
117
  endpoint: `${method} ${fullUrl}`,
106
- message: `${prefix}This endpoint costs ${formattedCost}. To execute and pay, call execute_x402_endpoint with autoApprove: true and the parameters shown in callWith below.`,
118
+ message: `${prefix}This endpoint costs ${formattedCost}. To execute and pay, call execute_x402_endpoint with autoApprove: true and the parameters shown in callWith below.${altNote}`,
107
119
  payment: {
108
120
  amount: result.amount,
109
121
  asset: result.asset,
122
+ ...(result.symbol && { symbol: result.symbol }),
110
123
  recipient: result.recipient,
111
124
  network: result.network,
112
125
  },
126
+ ...(result.accepts && result.accepts.length > 0 && { accepts: result.accepts }),
113
127
  callWith: buildCallWith(callWithOptions),
114
128
  });
115
129
  }
@@ -249,16 +263,20 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
249
263
  .optional()
250
264
  .default(false)
251
265
  .describe("Skip cost probe and execute immediately. When false (default), probes first and returns cost info for paid endpoints. When true, executes atomically like before. Free endpoints always execute transparently."),
266
+ asset: z
267
+ .string()
268
+ .optional()
269
+ .describe("Which asset to pay with when the endpoint accepts several. Accepts a symbol (\"sBTC\", \"STX\") or a full contract identifier. Defaults to the first asset this client can pay. Only STX and sBTC can be signed; requesting any other asset returns an error listing what is accepted."),
252
270
  },
253
- }, async ({ method, url, path, apiUrl, params, data, autoApprove }) => {
271
+ }, async ({ method, url, path, apiUrl, params, data, autoApprove, asset }) => {
254
272
  let fullUrl = "";
255
273
  try {
256
274
  const parsed = parseEndpointUrl({ url, path, apiUrl, params });
257
275
  fullUrl = parsed.fullUrl;
258
276
  params = parsed.params;
259
277
  if (!autoApprove) {
260
- const probeResult = await probeEndpoint({ method, url: fullUrl, params, data });
261
- return formatProbeResponse(probeResult, method, fullUrl, { method, url, path, apiUrl, params, data });
278
+ const probeResult = await probeEndpoint({ method, url: fullUrl, params, data, asset });
279
+ return formatProbeResponse(probeResult, method, fullUrl, { method, url, path, apiUrl, params, data, asset });
262
280
  }
263
281
  // autoApprove=true: check dedup cache before any network request, then execute
264
282
  // with a single request. Balance validation happens inside the onBeforePayment
@@ -275,6 +293,7 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
275
293
  }
276
294
  const api = await createApiClient(parsed.baseUrl, {
277
295
  toolName: "execute_x402_endpoint",
296
+ asset,
278
297
  onBeforePayment: async (requirements) => {
279
298
  // Non-sponsored: sender pays its own gas, so validate STX for the
280
299
  // fee too (sponsored=false is the default, passed explicitly here).
@@ -444,15 +463,19 @@ Supported sources:
444
463
  .record(z.string(), z.unknown())
445
464
  .optional()
446
465
  .describe("Request body for POST/PUT requests"),
466
+ asset: z
467
+ .string()
468
+ .optional()
469
+ .describe("Quote the cost in this asset when the endpoint accepts several. Accepts a symbol (\"sBTC\", \"STX\") or a full contract identifier. Defaults to the first asset this client can pay; the full accepts[] list is always returned."),
447
470
  },
448
- }, async ({ method, url, path, apiUrl, params, data }) => {
471
+ }, async ({ method, url, path, apiUrl, params, data, asset }) => {
449
472
  let fullUrl = "";
450
473
  try {
451
474
  const parsed = parseEndpointUrl({ url, path, apiUrl, params });
452
475
  fullUrl = parsed.fullUrl;
453
476
  params = parsed.params;
454
- const result = await probeEndpoint({ method, url: fullUrl, params, data });
455
- return formatProbeResponse(result, method, fullUrl, { method, url, path, apiUrl, params, data });
477
+ const result = await probeEndpoint({ method, url: fullUrl, params, data, asset });
478
+ return formatProbeResponse(result, method, fullUrl, { method, url, path, apiUrl, params, data, asset });
456
479
  }
457
480
  catch (error) {
458
481
  return formatEndpointError(error, fullUrl || "unknown");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibtc/mcp-server",
3
- "version": "1.64.1",
3
+ "version": "1.65.0",
4
4
  "description": "Bitcoin-native MCP server for AI agents: BTC/STX wallets, DeFi yield, sBTC peg, NFTs, and x402 payments.",
5
5
  "mcpName": "io.github.aibtcdev/mcp-server",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -4,7 +4,7 @@ description: Bitcoin L1 wallet for agents - check balances, send BTC, manage UTX
4
4
  license: MIT
5
5
  metadata:
6
6
  author: aibtcdev
7
- version: 1.64.1 # x-release-please-version
7
+ version: 1.65.0 # x-release-please-version
8
8
  npm: "@aibtc/mcp-server"
9
9
  github: https://github.com/aibtcdev/aibtc-mcp-server
10
10
  ---