@ecadlabs/tezosx-mcp 1.0.2 → 1.0.3

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.
@@ -6,6 +6,12 @@ export interface LiveConfig {
6
6
  tzktApi: string;
7
7
  configured: boolean;
8
8
  }
9
+ /**
10
+ * Calculate the fee rebate amount to keep the spender address funded for gas.
11
+ * Returns 0 if the spender has sufficient balance, otherwise the amount needed
12
+ * to bring the balance up to the target level.
13
+ */
14
+ export declare function calculateFeeRebate(spenderBalanceMutez: number): number;
9
15
  export declare const NETWORKS: {
10
16
  readonly mainnet: {
11
17
  readonly rpcUrl: "https://mainnet.tezos.ecadinfra.com";
@@ -1,5 +1,19 @@
1
1
  import { TezosToolkit } from "@taquito/taquito";
2
2
  import { InMemorySigner } from "@taquito/signer";
3
+ // Spender fee rebate thresholds (in mutez)
4
+ const SPENDER_TOP_UP_THRESHOLD = 250_000; // 0.25 XTZ — rebate when below this
5
+ const SPENDER_TOP_UP_TARGET = 500_000; // 0.5 XTZ — rebate up to this level
6
+ /**
7
+ * Calculate the fee rebate amount to keep the spender address funded for gas.
8
+ * Returns 0 if the spender has sufficient balance, otherwise the amount needed
9
+ * to bring the balance up to the target level.
10
+ */
11
+ export function calculateFeeRebate(spenderBalanceMutez) {
12
+ if (spenderBalanceMutez < SPENDER_TOP_UP_THRESHOLD) {
13
+ return SPENDER_TOP_UP_TARGET - spenderBalanceMutez;
14
+ }
15
+ return 0;
16
+ }
3
17
  export const NETWORKS = {
4
18
  mainnet: {
5
19
  rpcUrl: 'https://mainnet.tezos.ecadinfra.com',
@@ -29,19 +29,18 @@ export const createCreateX402PaymentTool = (config) => ({
29
29
  if (isNaN(amountMutez) || amountMutez <= 0) {
30
30
  throw new Error(`Invalid amount: ${amount}. Must be a positive integer in mutez.`);
31
31
  }
32
- // Get source address from signer
33
- const source = await Tezos.signer.publicKeyHash();
34
- // Validate source has sufficient funds
35
- const sourceBalance = await Tezos.tz.getBalance(source);
36
- if (sourceBalance.toNumber() < amountMutez + 10000) { // Add buffer for fees
37
- throw new Error(`Insufficient balance. ` +
38
- `Required: ${amountMutez + 10000} mutez (including fees), ` +
39
- `Available: ${sourceBalance.toNumber()} mutez`);
32
+ // Validate contract has sufficient funds for the payment
33
+ const contractBalance = await Tezos.tz.getBalance(config.spendingContract);
34
+ if (contractBalance.toNumber() < amountMutez) {
35
+ throw new Error(`Insufficient contract balance. ` +
36
+ `Required: ${amountMutez} mutez, ` +
37
+ `Available: ${contractBalance.toNumber()} mutez`);
40
38
  }
41
39
  const signed = await signX402Payment(Tezos, {
42
40
  network,
43
41
  amount: amountMutez,
44
42
  recipient,
43
+ spendingContract: config.spendingContract,
45
44
  });
46
45
  return {
47
46
  content: [{
@@ -10,7 +10,7 @@ export declare const createFetchWithX402Tool: (config: LiveConfig) => {
10
10
  maxAmountMutez: z.ZodString;
11
11
  method: z.ZodOptional<z.ZodString>;
12
12
  body: z.ZodOptional<z.ZodString>;
13
- nftRecipientAddress: z.ZodOptional<z.ZodString>;
13
+ queryParams: z.ZodOptional<z.ZodString>;
14
14
  }, z.z.core.$strip>;
15
15
  annotations: {
16
16
  readOnlyHint: boolean;
@@ -7,13 +7,13 @@ export const createFetchWithX402Tool = (config) => ({
7
7
  name: "tezos_fetch_with_x402",
8
8
  config: {
9
9
  title: "Fetch with x402 Payment",
10
- description: "Fetches a URL and automatically handles x402 payment requirements. If the server returns 402, it parses the requirements, creates a signed payment, and retries the request with the X-PAYMENT header. WARNING: For NFT minting endpoints (URLs containing '/mint' or otherwise going to a minter), you MUST set 'nftRecipientAddress' to the user's wallet address, otherwise the NFT will be sent to the spender account instead of the user.",
10
+ description: "Fetches a URL and automatically handles x402 payment requirements. If the server returns 402, it parses the requirements, creates a signed payment, and retries the request with the X-PAYMENT header. Use 'queryParams' to pass dynamic parameters to the endpoint (e.g., for NFT minting, pass {\"recipient\": \"tz1...\"} so the NFT goes to the user's wallet instead of the spender account).",
11
11
  inputSchema: z.object({
12
12
  url: z.string().describe("The URL to fetch"),
13
13
  maxAmountMutez: z.string().describe("Maximum amount in mutez willing to pay (e.g., '500000' for 0.5 XTZ)"),
14
14
  method: z.string().optional().describe("HTTP method (default: GET)"),
15
- body: z.string().optional().describe("Request body for POST/PUT requests"),
16
- nftRecipientAddress: z.string().optional().describe("REQUIRED FOR MINTING: The user's wallet address (tz1...) to receive minted NFTs. If omitted, NFTs go to the spender account, not the user."),
15
+ body: z.string().optional().describe("Request body for POST/PUT requests (JSON string)"),
16
+ queryParams: z.string().optional().describe("JSON object of query parameters to append to the URL (e.g., '{\"recipient\": \"tz1...\", \"amount\": \"100\"}')"),
17
17
  }),
18
18
  annotations: {
19
19
  readOnlyHint: false,
@@ -24,17 +24,27 @@ export const createFetchWithX402Tool = (config) => ({
24
24
  },
25
25
  handler: async (params) => {
26
26
  const { Tezos } = config;
27
- const { url, maxAmountMutez, method = "GET", body, nftRecipientAddress } = params;
27
+ const { url, maxAmountMutez, method = "GET", body, queryParams } = params;
28
28
  const maxAmount = parseInt(maxAmountMutez, 10);
29
29
  if (isNaN(maxAmount) || maxAmount <= 0) {
30
30
  throw new Error(`Invalid maxAmountMutez: ${maxAmountMutez}. Must be a positive integer.`);
31
31
  }
32
- // Build URL with recipient parameter if specified
32
+ // Build URL with query parameters if specified
33
33
  let requestUrl = url;
34
- if (nftRecipientAddress) {
35
- const urlObj = new URL(url);
36
- urlObj.searchParams.set("recipient", nftRecipientAddress);
37
- requestUrl = urlObj.toString();
34
+ if (queryParams) {
35
+ try {
36
+ const parsed = JSON.parse(queryParams);
37
+ if (typeof parsed === "object" && parsed !== null) {
38
+ const urlObj = new URL(url);
39
+ for (const [key, value] of Object.entries(parsed)) {
40
+ urlObj.searchParams.set(key, String(value));
41
+ }
42
+ requestUrl = urlObj.toString();
43
+ }
44
+ }
45
+ catch {
46
+ throw new Error(`Invalid queryParams: must be a valid JSON object (e.g., '{"key": "value"}')`);
47
+ }
38
48
  }
39
49
  // Initial request config
40
50
  const axiosConfig = {
@@ -87,22 +97,21 @@ export const createFetchWithX402Tool = (config) => ({
87
97
  const maxFormatted = maxAmount / Math.pow(10, decimals);
88
98
  throw new Error(`Payment amount ${requiredFormatted} XTZ exceeds maximum allowed ${maxFormatted} XTZ`);
89
99
  }
90
- // Get source address from signer
91
- const source = await Tezos.signer.publicKeyHash();
92
- // Validate source has sufficient funds
93
- const sourceBalance = await Tezos.tz.getBalance(source);
94
- if (sourceBalance.toNumber() < requiredAmount + 10000) { // Add buffer for fees
95
- throw new Error(`Insufficient balance. ` +
96
- `Required: ${requiredAmount + 10000} mutez (including fees), ` +
97
- `Available: ${sourceBalance.toNumber()} mutez`);
100
+ // Validate contract has sufficient funds for the payment
101
+ const contractBalance = await Tezos.tz.getBalance(config.spendingContract);
102
+ if (contractBalance.toNumber() < requiredAmount) {
103
+ throw new Error(`Insufficient contract balance. ` +
104
+ `Required: ${requiredAmount} mutez, ` +
105
+ `Available: ${contractBalance.toNumber()} mutez`);
98
106
  }
99
107
  // Ensure account is revealed before signing
100
108
  await ensureRevealed(Tezos);
101
- // Sign the payment using shared utility
109
+ // Sign the payment as a contract call to the spending wallet
102
110
  const signed = await signX402Payment(Tezos, {
103
111
  network: tezosRequirement.network,
104
112
  amount: requiredAmount,
105
113
  recipient: tezosRequirement.recipient,
114
+ spendingContract: config.spendingContract,
106
115
  });
107
116
  // Retry with payment header
108
117
  const paidConfig = {
@@ -52,7 +52,7 @@ export declare const createTools: (liveConfig: LiveConfig, http: boolean) => ({
52
52
  maxAmountMutez: import("zod").ZodString;
53
53
  method: import("zod").ZodOptional<import("zod").ZodString>;
54
54
  body: import("zod").ZodOptional<import("zod").ZodString>;
55
- nftRecipientAddress: import("zod").ZodOptional<import("zod").ZodString>;
55
+ queryParams: import("zod").ZodOptional<import("zod").ZodString>;
56
56
  }, import("zod/v4/core").$strip>;
57
57
  annotations: {
58
58
  readOnlyHint: boolean;
@@ -1,5 +1,5 @@
1
1
  import z from "zod";
2
- import type { LiveConfig } from "../live-config.js";
2
+ import { type LiveConfig } from "../live-config.js";
3
3
  export declare const createSendXtzTool: (config: LiveConfig) => {
4
4
  name: string;
5
5
  config: {
@@ -1,11 +1,10 @@
1
1
  import z from "zod";
2
2
  import { ensureRevealed } from "./reveal_account.js";
3
+ import { calculateFeeRebate } from "../live-config.js";
3
4
  // Constants
4
5
  const MUTEZ_PER_TEZ = 1_000_000;
5
6
  const CONFIRMATIONS_TO_WAIT = 3;
6
7
  const TZKT_BASE_URL = "https://shadownet.tzkt.io";
7
- const SPENDER_TOP_UP_THRESHOLD = 250_000; // 0.25 XTZ
8
- const SPENDER_TOP_UP_TARGET = 500_000; // 0.5 XTZ
9
8
  // Types
10
9
  const inputSchema = z.object({
11
10
  toAddress: z.string().describe("The address to send Tez to."),
@@ -67,9 +66,7 @@ export const createSendXtzTool = (config) => ({
67
66
  await ensureRevealed(Tezos);
68
67
  // Top up spender from contract if balance is low
69
68
  const spenderMutez = spenderBalance.toNumber();
70
- const feeRebate = spenderMutez < SPENDER_TOP_UP_THRESHOLD
71
- ? SPENDER_TOP_UP_TARGET - spenderMutez
72
- : 0;
69
+ const feeRebate = calculateFeeRebate(spenderMutez);
73
70
  const contract = await Tezos.contract.at(spendingContract);
74
71
  const contractCall = contract.methodsObject.spend({
75
72
  recipient: params.toAddress,
@@ -4,6 +4,7 @@ export interface SignPaymentParams {
4
4
  network: string;
5
5
  amount: number;
6
6
  recipient: string;
7
+ spendingContract: string;
7
8
  }
8
9
  export interface SignedPayment {
9
10
  payload: X402PaymentPayload;
@@ -1,7 +1,8 @@
1
1
  import { OpKind } from "@taquito/taquito";
2
2
  import { LocalForger } from "@taquito/local-forging";
3
+ import { calculateFeeRebate } from "../../live-config.js";
3
4
  export async function signX402Payment(Tezos, params) {
4
- const { network, amount, recipient } = params;
5
+ const { network, amount, recipient, spendingContract } = params;
5
6
  // Get source address and public key from the signer
6
7
  let source;
7
8
  let publicKey;
@@ -24,6 +25,21 @@ export async function signX402Payment(Tezos, params) {
24
25
  if (!publicKey.startsWith('edpk') && !publicKey.startsWith('sppk') && !publicKey.startsWith('p2pk')) {
25
26
  throw new Error(`Invalid public key format: ${publicKey}`);
26
27
  }
28
+ // Calculate fee rebate to keep spender funded for gas
29
+ const spenderBalance = await Tezos.tz.getBalance(source);
30
+ const spenderMutez = spenderBalance.toNumber();
31
+ const feeRebate = calculateFeeRebate(spenderMutez);
32
+ // Build contract call via Taquito to get properly encoded parameters
33
+ const contract = await Tezos.contract.at(spendingContract);
34
+ const contractCall = contract.methodsObject.spend({
35
+ recipient,
36
+ amount,
37
+ fee_rebate: feeRebate,
38
+ });
39
+ // Estimate gas, storage, and fees
40
+ const estimate = await Tezos.estimate.contractCall(contractCall);
41
+ // Get the encoded parameters from the contract call
42
+ const transferParams = contractCall.toTransferParams();
27
43
  // Get the current block hash for the branch
28
44
  const block = await Tezos.rpc.getBlockHeader();
29
45
  const branch = block.hash;
@@ -33,19 +49,20 @@ export async function signX402Payment(Tezos, params) {
33
49
  throw new Error(`Failed to get contract info for ${source}. Account may not exist on chain.`);
34
50
  }
35
51
  const nextCounter = (parseInt(contractInfo.counter || "0") + 1).toString();
36
- // Build the transfer operation
52
+ // Build the contract call operation (funds come from the contract, not the spender)
37
53
  const operation = {
38
54
  branch,
39
55
  contents: [
40
56
  {
41
57
  kind: OpKind.TRANSACTION,
42
58
  source,
43
- fee: "1500",
59
+ fee: estimate.suggestedFeeMutez.toString(),
44
60
  counter: nextCounter,
45
- gas_limit: "1527",
46
- storage_limit: "257",
47
- amount: amount.toString(),
48
- destination: recipient,
61
+ gas_limit: estimate.gasLimit.toString(),
62
+ storage_limit: estimate.storageLimit.toString(),
63
+ amount: "0",
64
+ destination: spendingContract,
65
+ parameters: transferParams.parameter,
49
66
  }
50
67
  ]
51
68
  };
package/dist/webserver.js CHANGED
@@ -13,5 +13,29 @@ export const startWebServer = (port, apiRouter) => {
13
13
  // Static files + SPA fallback
14
14
  const distPath = join(__dirname, "../frontend/dist");
15
15
  app.use(sirv(distPath, { single: true }));
16
- app.listen(port);
16
+ let retries = 0;
17
+ const maxRetries = 3;
18
+ const tryListen = () => {
19
+ const server = app.listen(port);
20
+ server.on("error", (err) => {
21
+ if (err.code === "EADDRINUSE" && retries < maxRetries) {
22
+ retries++;
23
+ console.error(`[tezosx-mcp] Port ${port} in use, retrying (${retries}/${maxRetries})...`);
24
+ setTimeout(tryListen, 500);
25
+ }
26
+ else if (err.code === "EADDRINUSE") {
27
+ console.error(`[tezosx-mcp] Port ${port} in use, frontend dashboard unavailable`);
28
+ }
29
+ else {
30
+ console.error(`[tezosx-mcp] Web server error:`, err.message);
31
+ }
32
+ });
33
+ server.on("listening", () => {
34
+ const shutdown = () => server.close();
35
+ process.on("SIGTERM", shutdown);
36
+ process.on("SIGINT", shutdown);
37
+ process.on("exit", shutdown);
38
+ });
39
+ };
40
+ tryListen();
17
41
  };