@phantom/mcp-server 0.1.0 → 0.1.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
@@ -13,9 +13,11 @@ An MCP (Model Context Protocol) server that provides LLMs like Claude with direc
13
13
  - **SSO Authentication**: Seamless integration with Phantom's embedded wallet SSO flow (Google/Apple login)
14
14
  - **Session Persistence**: Automatic session management with stamper keys stored in `~/.phantom-mcp/session.json`
15
15
  - **Multi-Chain Support**: Works with Solana, Ethereum, Bitcoin, and Sui networks
16
- - **Three MCP Tools**:
16
+ - **Five MCP Tools**:
17
17
  - `get_wallet_addresses` - Get blockchain addresses for the authenticated embedded wallet
18
18
  - `sign_transaction` - Sign transactions across supported chains
19
+ - `transfer_tokens` - Transfer SOL or SPL tokens on Solana (executes immediately)
20
+ - `buy_token` - Fetch a Solana swap quote from the Phantom quotes API (can execute immediately)
19
21
  - `sign_message` - Sign UTF-8 messages with automatic chain-specific routing
20
22
 
21
23
  ## Installation
@@ -179,6 +181,10 @@ This opens an interactive web UI where you can test tool calls without Claude De
179
181
 
180
182
  ## Available Tools
181
183
 
184
+ > **Execution Warning**
185
+ > `transfer_tokens` always submits a transaction immediately, and `buy_token` can submit immediately when `execute: true`.
186
+ > If you need finer control (simulation, review, multi-sig, or custom signing), build your own transaction and use `sign_transaction` instead.
187
+
182
188
  ### 1. get_wallet_addresses
183
189
 
184
190
  Gets all blockchain addresses for the authenticated embedded wallet (Solana, Ethereum, Bitcoin, Sui).
@@ -252,7 +258,134 @@ Signs a transaction using the authenticated embedded wallet. Supports Solana, Et
252
258
  }
253
259
  ```
254
260
 
255
- ### 3. sign_message
261
+ ### 3. transfer_tokens
262
+
263
+ Transfers SOL or SPL tokens on Solana by building, signing, and sending the transaction.
264
+
265
+ **Parameters:**
266
+
267
+ - `walletId` (optional, string): The wallet ID to use for transfer (defaults to authenticated wallet)
268
+ - `networkId` (required, string): Solana network identifier (e.g., "solana:mainnet", "solana:devnet")
269
+ - `to` (required, string): Recipient Solana address
270
+ - `amount` (required, string): Transfer amount as a string (e.g., "0.5" or "1000000")
271
+ - `amountUnit` (optional, string): Amount unit (`ui` for SOL/token units, `base` for lamports/base units; default: `ui`)
272
+ - `tokenMint` (optional, string): SPL token mint address (omit for SOL)
273
+ - `decimals` (optional, number): Token decimals (optional for SPL tokens; fetched from chain if omitted)
274
+ - `derivationIndex` (optional, number): Derivation index for the account (default: 0)
275
+ - `rpcUrl` (optional, string): Solana RPC URL (defaults based on networkId)
276
+ - `createAssociatedTokenAccount` (optional, boolean): Create destination ATA if missing (default: true)
277
+
278
+ **Example (SOL):**
279
+
280
+ ```json
281
+ {
282
+ "networkId": "solana:mainnet",
283
+ "to": "H8FpYTgx4Uy9aF9Nk9fCTqKKFLYQ9KfC6UJhMkMDzCBh",
284
+ "amount": "0.1",
285
+ "amountUnit": "ui"
286
+ }
287
+ ```
288
+
289
+ **Example (SPL Token):**
290
+
291
+ ```json
292
+ {
293
+ "networkId": "solana:devnet",
294
+ "to": "H8FpYTgx4Uy9aF9Nk9fCTqKKFLYQ9KfC6UJhMkMDzCBh",
295
+ "tokenMint": "So11111111111111111111111111111111111111112",
296
+ "amount": "1.5",
297
+ "amountUnit": "ui"
298
+ }
299
+ ```
300
+
301
+ **Response:**
302
+
303
+ ```json
304
+ {
305
+ "walletId": "05307b6d-2d5a-43d6-8d11-08db650a169b",
306
+ "networkId": "solana:mainnet",
307
+ "from": "H8FpYTgx4Uy9aF9Nk9fCTqKKFLYQ9KfC6UJhMkMDzCBh",
308
+ "to": "H8FpYTgx4Uy9aF9Nk9fCTqKKFLYQ9KfC6UJhMkMDzCBh",
309
+ "tokenMint": null,
310
+ "signature": "5oVZJ8b7k2rGm3rP3Gm5J3tFjR6eUpCkG6TGNKxgqQ7s...",
311
+ "rawTransaction": "base64url-encoded-signed-transaction"
312
+ }
313
+ ```
314
+
315
+ ### 4. buy_token
316
+
317
+ Fetches a Solana swap quote from the Phantom quotes API. Optionally signs and sends the first quote transaction.
318
+
319
+ **Parameters:**
320
+
321
+ - `walletId` (optional, string): The wallet ID to use for the taker address (defaults to authenticated wallet)
322
+ - `networkId` (optional, string): Solana network identifier (default: `solana:mainnet`)
323
+ - `buyTokenMint` (optional, string): Mint address of the token to buy (omit if buying native SOL)
324
+ - `buyTokenIsNative` (optional, boolean): Set true to buy native SOL (default: false)
325
+ - `sellTokenMint` (optional, string): Mint address of the token to sell (omit if selling native SOL)
326
+ - `sellTokenIsNative` (optional, boolean): Set true to sell native SOL (default: true if `sellTokenMint` not provided)
327
+ - `amount` (required, string): Sell amount (e.g., "0.5" or "1000000")
328
+ - `amountUnit` (optional, string): Amount unit (`ui` for token units, `base` for atomic units; default: `base`)
329
+ - `buyTokenDecimals` (optional, number): Buy token decimals (used when `amountUnit` is `ui` and `exactOut` is true)
330
+ - `sellTokenDecimals` (optional, number): Sell token decimals (used when `amountUnit` is `ui`)
331
+ - `slippageTolerance` (optional, number): Slippage tolerance in percent (0-100)
332
+ - `exactOut` (optional, boolean): Treat amount as buy amount instead of sell amount
333
+ - `autoSlippage` (optional, boolean): Enable auto slippage calculation
334
+ - `base64EncodedTx` (optional, boolean): Request base64-encoded transaction data in the quote response
335
+ - `execute` (optional, boolean): If true, sign and send the first quote transaction after fetching
336
+ - `taker` (optional, string): Taker address (defaults to wallet's Solana address)
337
+ - `rpcUrl` (optional, string): Solana RPC URL (for mint decimals lookup when `amountUnit` is `ui`)
338
+ - `quoteApiUrl` (optional, string): Quotes API URL override
339
+ - `derivationIndex` (optional, number): Derivation index for the taker address (default: 0)
340
+
341
+ **Example:**
342
+
343
+ ```json
344
+ {
345
+ "networkId": "solana:mainnet",
346
+ "sellTokenIsNative": true,
347
+ "buyTokenMint": "So11111111111111111111111111111111111111112",
348
+ "amount": "0.5",
349
+ "amountUnit": "ui",
350
+ "slippageTolerance": 1,
351
+ "execute": true
352
+ }
353
+ ```
354
+
355
+ **Response:**
356
+
357
+ ```json
358
+ {
359
+ "quoteRequest": {
360
+ "taker": {
361
+ "chainId": "solana:101",
362
+ "resourceType": "address",
363
+ "address": "H8FpYTgx4Uy9aF9Nk9fCTqKKFLYQ9KfC6UJhMkMDzCBh"
364
+ },
365
+ "buyToken": {
366
+ "chainId": "solana:101",
367
+ "resourceType": "address",
368
+ "address": "So11111111111111111111111111111111111111112"
369
+ },
370
+ "sellToken": {
371
+ "chainId": "solana:101",
372
+ "resourceType": "nativeToken",
373
+ "slip44": "501"
374
+ },
375
+ "sellAmount": "500000000"
376
+ },
377
+ "quoteResponse": {
378
+ "type": "quotes",
379
+ "quotes": []
380
+ },
381
+ "execution": {
382
+ "signature": "5oVZJ8b7k2rGm3rP3Gm5J3tFjR6eUpCkG6TGNKxgqQ7s...",
383
+ "rawTransaction": "base64url-encoded-signed-transaction"
384
+ }
385
+ }
386
+ ```
387
+
388
+ ### 5. sign_message
256
389
 
257
390
  Signs a UTF-8 message using the authenticated embedded wallet. Automatically routes to the correct signing method based on the network (Ethereum vs other chains).
258
391
 
package/dist/index.d.ts CHANGED
File without changes
package/dist/index.js CHANGED
@@ -1132,6 +1132,57 @@ var getWalletAddressesTool = {
1132
1132
  }
1133
1133
  };
1134
1134
 
1135
+ // src/tools/sign-transaction.ts
1136
+ var import_utils = require("@phantom/utils");
1137
+
1138
+ // src/utils/network.ts
1139
+ var import_constants = require("@phantom/constants");
1140
+ function normalizeNetworkId(networkId) {
1141
+ const normalized = networkId.toLowerCase();
1142
+ switch (normalized) {
1143
+ case "solana:mainnet":
1144
+ case "solana:mainnet-beta":
1145
+ return import_constants.NetworkId.SOLANA_MAINNET;
1146
+ case "solana:devnet":
1147
+ return import_constants.NetworkId.SOLANA_DEVNET;
1148
+ case "solana:testnet":
1149
+ return import_constants.NetworkId.SOLANA_TESTNET;
1150
+ default:
1151
+ return networkId;
1152
+ }
1153
+ }
1154
+ function normalizeSwapperChainId(networkId) {
1155
+ const normalized = networkId.toLowerCase();
1156
+ switch (normalized) {
1157
+ case "solana:mainnet":
1158
+ case "solana:mainnet-beta":
1159
+ case import_constants.NetworkId.SOLANA_MAINNET.toLowerCase():
1160
+ case "solana:101":
1161
+ return "solana:101";
1162
+ case "solana:devnet":
1163
+ case import_constants.NetworkId.SOLANA_DEVNET.toLowerCase():
1164
+ case "solana:103":
1165
+ return "solana:103";
1166
+ case "solana:testnet":
1167
+ case import_constants.NetworkId.SOLANA_TESTNET.toLowerCase():
1168
+ case "solana:102":
1169
+ return "solana:102";
1170
+ default:
1171
+ return networkId;
1172
+ }
1173
+ }
1174
+
1175
+ // src/utils/solana.ts
1176
+ var import_client2 = require("@phantom/client");
1177
+ async function getSolanaAddress(context, walletId, derivationIndex) {
1178
+ const addresses = await context.client.getWalletAddresses(walletId, void 0, derivationIndex);
1179
+ const solanaAddress = addresses.find((addr) => addr.addressType === import_client2.AddressType.solana) || addresses.find((addr) => addr.addressType.toLowerCase() === "solana");
1180
+ if (!solanaAddress) {
1181
+ throw new Error("No Solana address found for this wallet");
1182
+ }
1183
+ return solanaAddress.address;
1184
+ }
1185
+
1135
1186
  // src/tools/sign-transaction.ts
1136
1187
  var signTransactionTool = {
1137
1188
  name: "sign_transaction",
@@ -1185,9 +1236,12 @@ var signTransactionTool = {
1185
1236
  throw new Error("account must be a string");
1186
1237
  }
1187
1238
  const transaction = params.transaction;
1188
- const networkId = params.networkId;
1239
+ const networkId = normalizeNetworkId(params.networkId);
1189
1240
  const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
1190
- const account = typeof params.account === "string" ? params.account : void 0;
1241
+ let account = typeof params.account === "string" ? params.account : void 0;
1242
+ if (!account && (0, import_utils.isSolanaChain)(networkId)) {
1243
+ account = await getSolanaAddress(context, walletId, derivationIndex);
1244
+ }
1191
1245
  logger2.info(`Signing transaction for wallet ${walletId} on network ${networkId}`);
1192
1246
  try {
1193
1247
  const result = await client.signTransaction({
@@ -1210,7 +1264,7 @@ var signTransactionTool = {
1210
1264
  };
1211
1265
 
1212
1266
  // src/tools/sign-message.ts
1213
- var import_utils = require("@phantom/utils");
1267
+ var import_utils2 = require("@phantom/utils");
1214
1268
  var import_base64url = require("@phantom/base64url");
1215
1269
  var signMessageTool = {
1216
1270
  name: "sign_message",
@@ -1257,12 +1311,12 @@ var signMessageTool = {
1257
1311
  }
1258
1312
  }
1259
1313
  const message = params.message;
1260
- const networkId = params.networkId;
1314
+ const networkId = normalizeNetworkId(params.networkId);
1261
1315
  const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
1262
1316
  logger2.info(`Signing message for wallet ${walletId} on network ${networkId}`);
1263
1317
  try {
1264
1318
  let signature;
1265
- if ((0, import_utils.isEthereumChain)(networkId)) {
1319
+ if ((0, import_utils2.isEthereumChain)(networkId)) {
1266
1320
  const base64Message = (0, import_base64url.stringToBase64url)(message);
1267
1321
  logger2.debug("Using Ethereum message signing");
1268
1322
  signature = await client.ethereumSignMessage({
@@ -1292,8 +1346,619 @@ var signMessageTool = {
1292
1346
  }
1293
1347
  };
1294
1348
 
1349
+ // src/tools/transfer-tokens.ts
1350
+ var import_base64url2 = require("@phantom/base64url");
1351
+ var import_client3 = require("@phantom/client");
1352
+ var import_utils3 = require("@phantom/utils");
1353
+ var import_web3 = require("@solana/web3.js");
1354
+ var import_spl_token = require("@solana/spl-token");
1355
+
1356
+ // src/utils/amount.ts
1357
+ function parseBaseUnitAmount(amount) {
1358
+ if (!/^\d+$/.test(amount)) {
1359
+ throw new Error("amount must be a non-negative integer string when amountUnit is 'base'");
1360
+ }
1361
+ return BigInt(amount);
1362
+ }
1363
+ function parseUiAmount(amount, decimals) {
1364
+ if (!/^\d+(\.\d+)?$/.test(amount)) {
1365
+ throw new Error("amount must be a non-negative decimal string");
1366
+ }
1367
+ if (!Number.isInteger(decimals) || decimals < 0) {
1368
+ throw new Error("decimals must be a non-negative integer");
1369
+ }
1370
+ const [whole, fraction = ""] = amount.split(".");
1371
+ if (fraction.length > decimals) {
1372
+ throw new Error(`amount has too many decimal places for token decimals (${decimals})`);
1373
+ }
1374
+ const paddedFraction = fraction.padEnd(decimals, "0");
1375
+ const combined = `${whole}${paddedFraction}`.replace(/^0+/, "") || "0";
1376
+ return BigInt(combined);
1377
+ }
1378
+ function requirePositiveAmount(amount) {
1379
+ if (amount <= 0n) {
1380
+ throw new Error("amount must be greater than 0");
1381
+ }
1382
+ }
1383
+
1384
+ // src/tools/transfer-tokens.ts
1385
+ var DEFAULT_SOLANA_RPC_URLS = {
1386
+ [import_client3.NetworkId.SOLANA_MAINNET]: "https://api.mainnet-beta.solana.com",
1387
+ [import_client3.NetworkId.SOLANA_DEVNET]: "https://api.devnet.solana.com",
1388
+ [import_client3.NetworkId.SOLANA_TESTNET]: "https://api.testnet.solana.com"
1389
+ };
1390
+ var DEFAULT_COMMITMENT = "confirmed";
1391
+ function resolveSolanaRpcUrl(networkId, rpcUrl) {
1392
+ if (rpcUrl && typeof rpcUrl === "string") {
1393
+ return rpcUrl;
1394
+ }
1395
+ const resolved = DEFAULT_SOLANA_RPC_URLS[networkId];
1396
+ if (!resolved) {
1397
+ throw new Error(
1398
+ `rpcUrl is required for networkId "${networkId}". Supported defaults: ${Object.keys(DEFAULT_SOLANA_RPC_URLS).join(
1399
+ ", "
1400
+ )}`
1401
+ );
1402
+ }
1403
+ return resolved;
1404
+ }
1405
+ var transferTokensTool = {
1406
+ name: "transfer_tokens",
1407
+ description: "Transfers SOL or SPL tokens on Solana using the authenticated embedded wallet. Builds, signs, and sends the transaction.",
1408
+ inputSchema: {
1409
+ type: "object",
1410
+ properties: {
1411
+ walletId: {
1412
+ type: "string",
1413
+ description: "Optional wallet ID to use for transfer (defaults to authenticated wallet)"
1414
+ },
1415
+ networkId: {
1416
+ type: "string",
1417
+ description: 'Solana network identifier (e.g., "solana:mainnet", "solana:devnet")'
1418
+ },
1419
+ to: {
1420
+ type: "string",
1421
+ description: "Recipient Solana address"
1422
+ },
1423
+ amount: {
1424
+ type: "string",
1425
+ description: 'Transfer amount as a string (e.g., "0.5" or "1000000")'
1426
+ },
1427
+ amountUnit: {
1428
+ type: "string",
1429
+ description: "Amount unit: 'ui' for SOL/token units, 'base' for lamports/base units",
1430
+ enum: ["ui", "base"]
1431
+ },
1432
+ tokenMint: {
1433
+ type: "string",
1434
+ description: "Optional SPL token mint address. If omitted, transfers SOL."
1435
+ },
1436
+ decimals: {
1437
+ type: "number",
1438
+ description: "Token decimals (optional for SPL tokens; fetched from chain if omitted)",
1439
+ minimum: 0
1440
+ },
1441
+ derivationIndex: {
1442
+ type: "number",
1443
+ description: "Optional derivation index for the account (default: 0)",
1444
+ minimum: 0
1445
+ },
1446
+ rpcUrl: {
1447
+ type: "string",
1448
+ description: "Optional Solana RPC URL (defaults based on networkId)"
1449
+ },
1450
+ createAssociatedTokenAccount: {
1451
+ type: "boolean",
1452
+ description: "Create destination associated token account if missing (default: true)"
1453
+ }
1454
+ },
1455
+ required: ["networkId", "to", "amount"]
1456
+ },
1457
+ handler: async (params, context) => {
1458
+ const { client, session, logger: logger2 } = context;
1459
+ if (typeof params.networkId !== "string") {
1460
+ throw new Error("networkId must be a string");
1461
+ }
1462
+ const normalizedNetworkId = normalizeNetworkId(params.networkId);
1463
+ if (!(0, import_utils3.isSolanaChain)(normalizedNetworkId)) {
1464
+ throw new Error("transfer_tokens currently supports Solana networks only");
1465
+ }
1466
+ if (typeof params.to !== "string") {
1467
+ throw new Error("to must be a string");
1468
+ }
1469
+ if (typeof params.amount !== "string") {
1470
+ throw new Error("amount must be a string");
1471
+ }
1472
+ const walletId = typeof params.walletId === "string" ? params.walletId : session.walletId;
1473
+ if (!walletId) {
1474
+ throw new Error("walletId is required (missing from session and not provided)");
1475
+ }
1476
+ const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
1477
+ if (derivationIndex !== void 0 && (!Number.isInteger(derivationIndex) || derivationIndex < 0)) {
1478
+ throw new Error("derivationIndex must be a non-negative integer");
1479
+ }
1480
+ const amountUnit = typeof params.amountUnit === "string" ? params.amountUnit : "ui";
1481
+ if (amountUnit !== "ui" && amountUnit !== "base") {
1482
+ throw new Error("amountUnit must be 'ui' or 'base'");
1483
+ }
1484
+ const tokenMint = typeof params.tokenMint === "string" ? params.tokenMint : void 0;
1485
+ const createAta = typeof params.createAssociatedTokenAccount === "boolean" ? params.createAssociatedTokenAccount : true;
1486
+ const rpcUrl = resolveSolanaRpcUrl(
1487
+ normalizedNetworkId,
1488
+ typeof params.rpcUrl === "string" ? params.rpcUrl : void 0
1489
+ );
1490
+ const connection = new import_web3.Connection(rpcUrl, DEFAULT_COMMITMENT);
1491
+ const fromAddress = await getSolanaAddress(context, walletId, derivationIndex);
1492
+ const fromPubkey = new import_web3.PublicKey(fromAddress);
1493
+ const toPubkey = new import_web3.PublicKey(params.to);
1494
+ logger2.info(`Preparing transfer from ${fromAddress} to ${params.to} on ${normalizedNetworkId}`);
1495
+ try {
1496
+ const tx = new import_web3.Transaction();
1497
+ if (!tokenMint) {
1498
+ const lamports = amountUnit === "base" ? parseBaseUnitAmount(params.amount) : parseUiAmount(
1499
+ params.amount,
1500
+ 9
1501
+ /* SOL decimals */
1502
+ );
1503
+ requirePositiveAmount(lamports);
1504
+ tx.add(
1505
+ import_web3.SystemProgram.transfer({
1506
+ fromPubkey,
1507
+ toPubkey,
1508
+ lamports
1509
+ })
1510
+ );
1511
+ } else {
1512
+ const mintPubkey = new import_web3.PublicKey(tokenMint);
1513
+ const sourceAta = await (0, import_spl_token.getAssociatedTokenAddress)(mintPubkey, fromPubkey, false);
1514
+ const destinationAta = await (0, import_spl_token.getAssociatedTokenAddress)(mintPubkey, toPubkey, false);
1515
+ const sourceInfo = await connection.getAccountInfo(sourceAta, DEFAULT_COMMITMENT);
1516
+ if (!sourceInfo) {
1517
+ throw new Error("Source associated token account not found for this wallet");
1518
+ }
1519
+ const destinationInfo = await connection.getAccountInfo(destinationAta, DEFAULT_COMMITMENT);
1520
+ if (!destinationInfo) {
1521
+ if (createAta) {
1522
+ tx.add((0, import_spl_token.createAssociatedTokenAccountInstruction)(fromPubkey, destinationAta, toPubkey, mintPubkey));
1523
+ } else {
1524
+ throw new Error("Destination associated token account does not exist");
1525
+ }
1526
+ }
1527
+ let decimals;
1528
+ if (typeof params.decimals === "number") {
1529
+ if (!Number.isInteger(params.decimals) || params.decimals < 0) {
1530
+ throw new Error("decimals must be a non-negative integer");
1531
+ }
1532
+ decimals = params.decimals;
1533
+ }
1534
+ if (amountUnit === "ui" && decimals === void 0) {
1535
+ const mintInfo = await (0, import_spl_token.getMint)(connection, mintPubkey, DEFAULT_COMMITMENT);
1536
+ decimals = mintInfo.decimals;
1537
+ }
1538
+ if (amountUnit === "ui" && decimals === void 0) {
1539
+ throw new Error("Unable to determine token decimals");
1540
+ }
1541
+ const amountBaseUnits = amountUnit === "base" ? parseBaseUnitAmount(params.amount) : parseUiAmount(params.amount, decimals);
1542
+ requirePositiveAmount(amountBaseUnits);
1543
+ if (amountUnit === "base" || decimals === void 0) {
1544
+ tx.add((0, import_spl_token.createTransferInstruction)(sourceAta, destinationAta, fromPubkey, amountBaseUnits));
1545
+ } else {
1546
+ tx.add(
1547
+ (0, import_spl_token.createTransferCheckedInstruction)(
1548
+ sourceAta,
1549
+ mintPubkey,
1550
+ destinationAta,
1551
+ fromPubkey,
1552
+ amountBaseUnits,
1553
+ decimals
1554
+ )
1555
+ );
1556
+ }
1557
+ }
1558
+ const { blockhash } = await connection.getLatestBlockhash(DEFAULT_COMMITMENT);
1559
+ tx.feePayer = fromPubkey;
1560
+ tx.recentBlockhash = blockhash;
1561
+ const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
1562
+ const encoded = (0, import_base64url2.base64urlEncode)(serialized);
1563
+ const result = await client.signAndSendTransaction({
1564
+ walletId,
1565
+ transaction: encoded,
1566
+ networkId: normalizedNetworkId,
1567
+ derivationIndex,
1568
+ account: fromAddress
1569
+ });
1570
+ logger2.info(`Transfer submitted for wallet ${walletId}`);
1571
+ return {
1572
+ walletId,
1573
+ networkId: normalizedNetworkId,
1574
+ from: fromAddress,
1575
+ to: params.to,
1576
+ tokenMint: tokenMint ?? null,
1577
+ signature: result.hash ?? null,
1578
+ rawTransaction: result.rawTransaction
1579
+ };
1580
+ } catch (error) {
1581
+ const errorMessage = error instanceof Error ? error.message : String(error);
1582
+ logger2.error(`Failed to transfer tokens: ${errorMessage}`);
1583
+ throw new Error(`Failed to transfer tokens: ${errorMessage}`);
1584
+ }
1585
+ }
1586
+ };
1587
+
1588
+ // src/tools/buy-token.ts
1589
+ var import_utils4 = require("@phantom/utils");
1590
+ var import_web32 = require("@solana/web3.js");
1591
+ var import_spl_token2 = require("@solana/spl-token");
1592
+ var import_bs58 = __toESM(require("bs58"));
1593
+ var import_base64url3 = require("@phantom/base64url");
1594
+ var DEFAULT_QUOTES_API_URL = "https://api.phantom.app/swap/v2/quotes";
1595
+ var DEFAULT_SOLANA_RPC_URLS2 = {
1596
+ "solana:101": "https://api.mainnet-beta.solana.com",
1597
+ "solana:103": "https://api.devnet.solana.com",
1598
+ "solana:102": "https://api.testnet.solana.com"
1599
+ };
1600
+ function validateHttpsUrl(url, context) {
1601
+ let parsed;
1602
+ try {
1603
+ parsed = new URL(url);
1604
+ } catch {
1605
+ throw new Error(`${context} URL is not valid: ${url}`);
1606
+ }
1607
+ if (parsed.protocol !== "https:") {
1608
+ throw new Error(`${context} URL must use HTTPS protocol, got: ${parsed.protocol}`);
1609
+ }
1610
+ if (!parsed.hostname) {
1611
+ throw new Error(`${context} URL missing hostname: ${url}`);
1612
+ }
1613
+ }
1614
+ function resolveQuotesApiUrl(override) {
1615
+ let url;
1616
+ if (override && typeof override === "string") {
1617
+ url = override;
1618
+ } else if (process.env.PHANTOM_QUOTES_API_URL) {
1619
+ url = process.env.PHANTOM_QUOTES_API_URL;
1620
+ } else {
1621
+ url = DEFAULT_QUOTES_API_URL;
1622
+ }
1623
+ validateHttpsUrl(url, "Quotes API");
1624
+ return url;
1625
+ }
1626
+ function resolveSolanaRpcUrl2(chainId, override) {
1627
+ let url;
1628
+ if (override && typeof override === "string") {
1629
+ url = override;
1630
+ } else {
1631
+ const defaultUrl = DEFAULT_SOLANA_RPC_URLS2[chainId];
1632
+ if (!defaultUrl) {
1633
+ throw new Error(
1634
+ `rpcUrl is required for chainId "${chainId}". Supported defaults: ${Object.keys(DEFAULT_SOLANA_RPC_URLS2).join(
1635
+ ", "
1636
+ )}`
1637
+ );
1638
+ }
1639
+ url = defaultUrl;
1640
+ }
1641
+ validateHttpsUrl(url, "Solana RPC");
1642
+ return url;
1643
+ }
1644
+ function decodeTransactionData(transactionData, base64Encoded) {
1645
+ if (base64Encoded) {
1646
+ const bytes = Buffer.from(transactionData, "base64");
1647
+ if (!bytes.length) {
1648
+ throw new Error("Failed to decode base64 transaction data");
1649
+ }
1650
+ return bytes;
1651
+ }
1652
+ try {
1653
+ return import_bs58.default.decode(transactionData);
1654
+ } catch (error) {
1655
+ const bytes = Buffer.from(transactionData, "base64");
1656
+ if (!bytes.length) {
1657
+ const errorMessage = error instanceof Error ? error.message : String(error);
1658
+ throw new Error(`Failed to decode transaction data: ${errorMessage}`);
1659
+ }
1660
+ return bytes;
1661
+ }
1662
+ }
1663
+ var buyTokenTool = {
1664
+ name: "buy_token",
1665
+ description: "Fetches a swap quote from Phantom's quotes API for buying a token (Solana only). By default, this tool only fetches a quote and does not submit a transaction. Pass execute: true to sign and send the transaction.",
1666
+ inputSchema: {
1667
+ type: "object",
1668
+ properties: {
1669
+ walletId: {
1670
+ type: "string",
1671
+ description: "Optional wallet ID to use for the taker address (defaults to authenticated wallet)"
1672
+ },
1673
+ networkId: {
1674
+ type: "string",
1675
+ description: 'Solana network identifier (e.g., "solana:mainnet", "solana:devnet")'
1676
+ },
1677
+ buyTokenMint: {
1678
+ type: "string",
1679
+ description: "Mint address of the token to buy (omit if buying native SOL)"
1680
+ },
1681
+ buyTokenIsNative: {
1682
+ type: "boolean",
1683
+ description: "Set true to buy native SOL (default: false)"
1684
+ },
1685
+ sellTokenMint: {
1686
+ type: "string",
1687
+ description: "Mint address of the token to sell (omit if selling native SOL)"
1688
+ },
1689
+ sellTokenIsNative: {
1690
+ type: "boolean",
1691
+ description: "Set true to sell native SOL (default: true if sellTokenMint not provided)"
1692
+ },
1693
+ amount: {
1694
+ type: "string",
1695
+ description: `The amount to swap as a string (e.g., "0.5" or "1000000"). When exactOut is false (default), this is the sell amount. When exactOut is true, this is the buy amount. Interpretation depends on amountUnit: 'ui' interprets as token units (e.g., 0.5 SOL), 'base' interprets as atomic units (e.g., 500000000 lamports).`
1696
+ },
1697
+ amountUnit: {
1698
+ type: "string",
1699
+ description: "Amount unit: 'ui' for token units, 'base' for atomic units (default: 'base')",
1700
+ enum: ["ui", "base"]
1701
+ },
1702
+ buyTokenDecimals: {
1703
+ type: "number",
1704
+ description: "Decimals for the buy token (used when amountUnit is 'ui' and exactOut is true)",
1705
+ minimum: 0
1706
+ },
1707
+ sellTokenDecimals: {
1708
+ type: "number",
1709
+ description: "Decimals for the sell token (optional if amountUnit is 'ui')",
1710
+ minimum: 0
1711
+ },
1712
+ slippageTolerance: {
1713
+ type: "number",
1714
+ description: "Slippage tolerance in percent (0-100)",
1715
+ minimum: 0,
1716
+ maximum: 100
1717
+ },
1718
+ exactOut: {
1719
+ type: "boolean",
1720
+ description: "If true, amount is treated as buy amount instead of sell amount"
1721
+ },
1722
+ autoSlippage: {
1723
+ type: "boolean",
1724
+ description: "Enable auto slippage calculation"
1725
+ },
1726
+ base64EncodedTx: {
1727
+ type: "boolean",
1728
+ description: "Request base64-encoded transaction data in the quote response"
1729
+ },
1730
+ execute: {
1731
+ type: "boolean",
1732
+ description: "If true, sign and send the first quote transaction after fetching"
1733
+ },
1734
+ taker: {
1735
+ type: "string",
1736
+ description: "Taker address (defaults to wallet's Solana address)"
1737
+ },
1738
+ rpcUrl: {
1739
+ type: "string",
1740
+ description: "Optional Solana RPC URL (for mint decimals lookup when amountUnit is 'ui')"
1741
+ },
1742
+ quoteApiUrl: {
1743
+ type: "string",
1744
+ description: "Optional quotes API URL override"
1745
+ },
1746
+ derivationIndex: {
1747
+ type: "number",
1748
+ description: "Optional derivation index for the taker address (default: 0)",
1749
+ minimum: 0
1750
+ }
1751
+ },
1752
+ required: ["amount"]
1753
+ },
1754
+ handler: async (params, context) => {
1755
+ const { session, logger: logger2 } = context;
1756
+ const networkId = typeof params.networkId === "string" ? params.networkId : "solana:mainnet";
1757
+ const normalizedNetworkId = normalizeNetworkId(networkId);
1758
+ const swapperChainId = normalizeSwapperChainId(networkId);
1759
+ if (!(0, import_utils4.isSolanaChain)(networkId) && !(0, import_utils4.isSolanaChain)(swapperChainId)) {
1760
+ throw new Error("buy_token currently supports Solana networks only");
1761
+ }
1762
+ if (typeof params.amount !== "string") {
1763
+ throw new Error("amount must be a string");
1764
+ }
1765
+ const walletId = typeof params.walletId === "string" ? params.walletId : session.walletId;
1766
+ if (!walletId) {
1767
+ throw new Error("walletId is required (missing from session and not provided)");
1768
+ }
1769
+ const derivationIndex = typeof params.derivationIndex === "number" ? params.derivationIndex : void 0;
1770
+ if (derivationIndex !== void 0 && (!Number.isInteger(derivationIndex) || derivationIndex < 0)) {
1771
+ throw new Error("derivationIndex must be a non-negative integer");
1772
+ }
1773
+ const amountUnit = typeof params.amountUnit === "string" ? params.amountUnit : "base";
1774
+ if (amountUnit !== "ui" && amountUnit !== "base") {
1775
+ throw new Error("amountUnit must be 'ui' or 'base'");
1776
+ }
1777
+ const buyTokenIsNative = typeof params.buyTokenIsNative === "boolean" ? params.buyTokenIsNative : false;
1778
+ const sellTokenIsNative = typeof params.sellTokenIsNative === "boolean" ? params.sellTokenIsNative : params.sellTokenMint ? false : true;
1779
+ const buyTokenMint = typeof params.buyTokenMint === "string" ? params.buyTokenMint : void 0;
1780
+ const sellTokenMint = typeof params.sellTokenMint === "string" ? params.sellTokenMint : void 0;
1781
+ if (!buyTokenIsNative && !buyTokenMint) {
1782
+ throw new Error("buyTokenMint is required unless buyTokenIsNative is true");
1783
+ }
1784
+ if (!sellTokenIsNative && !sellTokenMint) {
1785
+ throw new Error("sellTokenMint is required unless sellTokenIsNative is true");
1786
+ }
1787
+ if (sellTokenIsNative && sellTokenMint) {
1788
+ throw new Error("sellTokenMint must be omitted when sellTokenIsNative is true");
1789
+ }
1790
+ if (buyTokenMint) {
1791
+ try {
1792
+ new import_web32.PublicKey(buyTokenMint);
1793
+ } catch {
1794
+ throw new Error("buyTokenMint must be a valid Solana address");
1795
+ }
1796
+ }
1797
+ if (sellTokenMint) {
1798
+ try {
1799
+ new import_web32.PublicKey(sellTokenMint);
1800
+ } catch {
1801
+ throw new Error("sellTokenMint must be a valid Solana address");
1802
+ }
1803
+ }
1804
+ const taker = typeof params.taker === "string" ? params.taker : await getSolanaAddress(context, walletId, derivationIndex);
1805
+ try {
1806
+ new import_web32.PublicKey(taker);
1807
+ } catch {
1808
+ throw new Error("taker must be a valid Solana address");
1809
+ }
1810
+ const exactOut = typeof params.exactOut === "boolean" ? params.exactOut : false;
1811
+ let amountBaseUnits;
1812
+ if (amountUnit === "base") {
1813
+ amountBaseUnits = parseBaseUnitAmount(params.amount);
1814
+ } else {
1815
+ let decimals;
1816
+ if (exactOut) {
1817
+ if (buyTokenIsNative) {
1818
+ decimals = 9;
1819
+ } else if (typeof params.buyTokenDecimals === "number") {
1820
+ if (!Number.isInteger(params.buyTokenDecimals) || params.buyTokenDecimals < 0) {
1821
+ throw new Error("buyTokenDecimals must be a non-negative integer");
1822
+ }
1823
+ decimals = params.buyTokenDecimals;
1824
+ } else if (buyTokenMint) {
1825
+ const rpcUrl = resolveSolanaRpcUrl2(
1826
+ swapperChainId,
1827
+ typeof params.rpcUrl === "string" ? params.rpcUrl : void 0
1828
+ );
1829
+ const connection = new import_web32.Connection(rpcUrl, "confirmed");
1830
+ const mintInfo = await (0, import_spl_token2.getMint)(connection, new import_web32.PublicKey(buyTokenMint), "confirmed");
1831
+ decimals = mintInfo.decimals;
1832
+ } else {
1833
+ throw new Error("buyTokenMint is required to lookup decimals");
1834
+ }
1835
+ } else if (sellTokenIsNative) {
1836
+ decimals = 9;
1837
+ } else if (typeof params.sellTokenDecimals === "number") {
1838
+ if (!Number.isInteger(params.sellTokenDecimals) || params.sellTokenDecimals < 0) {
1839
+ throw new Error("sellTokenDecimals must be a non-negative integer");
1840
+ }
1841
+ decimals = params.sellTokenDecimals;
1842
+ } else if (sellTokenMint) {
1843
+ const rpcUrl = resolveSolanaRpcUrl2(
1844
+ swapperChainId,
1845
+ typeof params.rpcUrl === "string" ? params.rpcUrl : void 0
1846
+ );
1847
+ const connection = new import_web32.Connection(rpcUrl, "confirmed");
1848
+ const mintInfo = await (0, import_spl_token2.getMint)(connection, new import_web32.PublicKey(sellTokenMint), "confirmed");
1849
+ decimals = mintInfo.decimals;
1850
+ } else {
1851
+ throw new Error("sellTokenMint is required to lookup decimals");
1852
+ }
1853
+ amountBaseUnits = parseUiAmount(params.amount, decimals);
1854
+ }
1855
+ requirePositiveAmount(amountBaseUnits);
1856
+ const quoteApiUrl = resolveQuotesApiUrl(typeof params.quoteApiUrl === "string" ? params.quoteApiUrl : void 0);
1857
+ const buyToken = buyTokenIsNative ? { chainId: swapperChainId, resourceType: "nativeToken", slip44: "501" } : { chainId: swapperChainId, resourceType: "address", address: buyTokenMint };
1858
+ const sellToken = sellTokenIsNative ? { chainId: swapperChainId, resourceType: "nativeToken", slip44: "501" } : { chainId: swapperChainId, resourceType: "address", address: sellTokenMint };
1859
+ const body = {
1860
+ taker: { chainId: swapperChainId, resourceType: "address", address: taker },
1861
+ buyToken,
1862
+ sellToken
1863
+ };
1864
+ if (exactOut) {
1865
+ body.buyAmount = amountBaseUnits.toString();
1866
+ } else {
1867
+ body.sellAmount = amountBaseUnits.toString();
1868
+ }
1869
+ if (typeof params.slippageTolerance === "number") {
1870
+ if (!Number.isFinite(params.slippageTolerance) || params.slippageTolerance < 0 || params.slippageTolerance > 100) {
1871
+ throw new Error("slippageTolerance must be a number between 0 and 100");
1872
+ }
1873
+ body.slippageTolerance = params.slippageTolerance;
1874
+ }
1875
+ if (typeof params.exactOut === "boolean") {
1876
+ body.exactOut = exactOut;
1877
+ }
1878
+ if (typeof params.autoSlippage === "boolean") {
1879
+ body.autoSlippage = params.autoSlippage;
1880
+ }
1881
+ if (typeof params.base64EncodedTx === "boolean") {
1882
+ body.base64EncodedTx = params.base64EncodedTx;
1883
+ }
1884
+ const quoteApiOrigin = new URL(quoteApiUrl).origin;
1885
+ logger2.info(`Requesting quote from ${quoteApiOrigin}`);
1886
+ const controller = new AbortController();
1887
+ const timeoutId = setTimeout(() => controller.abort(), 1e4);
1888
+ let response;
1889
+ try {
1890
+ response = await fetch(quoteApiUrl, {
1891
+ method: "POST",
1892
+ headers: {
1893
+ "Content-Type": "application/json"
1894
+ },
1895
+ body: JSON.stringify(body),
1896
+ signal: controller.signal
1897
+ });
1898
+ } catch (error) {
1899
+ if (error instanceof Error && error.name === "AbortError") {
1900
+ throw new Error("Quote API request timed out after 10 seconds");
1901
+ }
1902
+ throw error;
1903
+ } finally {
1904
+ clearTimeout(timeoutId);
1905
+ }
1906
+ const responseText = await response.text();
1907
+ let responseJson;
1908
+ try {
1909
+ responseJson = responseText ? JSON.parse(responseText) : null;
1910
+ } catch (parseError) {
1911
+ logger2.debug(`Failed to parse response as JSON: ${parseError}`);
1912
+ responseJson = responseText;
1913
+ }
1914
+ if (!response.ok) {
1915
+ const message = typeof responseJson === "string" ? responseJson : JSON.stringify(responseJson);
1916
+ throw new Error(`Quote API error (${response.status}): ${message}`);
1917
+ }
1918
+ const execute = typeof params.execute === "boolean" ? params.execute : false;
1919
+ if (!execute) {
1920
+ return {
1921
+ quoteRequest: body,
1922
+ quoteResponse: responseJson
1923
+ };
1924
+ }
1925
+ if (typeof responseJson !== "object" || responseJson === null || !Array.isArray(responseJson.quotes)) {
1926
+ throw new Error("Quote response has unexpected format: missing quotes array");
1927
+ }
1928
+ const quotes = responseJson.quotes;
1929
+ const quote = quotes[0];
1930
+ const transactionData = quote?.transactionData?.[0];
1931
+ if (!transactionData) {
1932
+ throw new Error("Quote response missing transaction data in first quote");
1933
+ }
1934
+ const decoded = decodeTransactionData(transactionData, params.base64EncodedTx);
1935
+ const encoded = (0, import_base64url3.base64urlEncode)(decoded);
1936
+ const result = await context.client.signAndSendTransaction({
1937
+ walletId,
1938
+ transaction: encoded,
1939
+ networkId: normalizedNetworkId,
1940
+ derivationIndex,
1941
+ account: taker
1942
+ });
1943
+ return {
1944
+ quoteRequest: body,
1945
+ quoteResponse: responseJson,
1946
+ execution: {
1947
+ signature: result.hash ?? null,
1948
+ rawTransaction: result.rawTransaction
1949
+ }
1950
+ };
1951
+ }
1952
+ };
1953
+
1295
1954
  // src/tools/index.ts
1296
- var tools = [getWalletAddressesTool, signTransactionTool, signMessageTool];
1955
+ var tools = [
1956
+ getWalletAddressesTool,
1957
+ signTransactionTool,
1958
+ signMessageTool,
1959
+ transferTokensTool,
1960
+ buyTokenTool
1961
+ ];
1297
1962
  function getTool(name) {
1298
1963
  return tools.find((tool) => tool.name === name);
1299
1964
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phantom/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP Server for Phantom Wallet",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,14 +37,17 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.26.0",
40
- "@phantom/api-key-stamper": "workspace:^",
41
- "@phantom/base64url": "workspace:^",
42
- "@phantom/client": "workspace:^",
43
- "@phantom/constants": "workspace:^",
44
- "@phantom/crypto": "workspace:^",
45
- "@phantom/server-sdk": "workspace:^",
46
- "@phantom/utils": "workspace:^",
40
+ "@phantom/api-key-stamper": "^1.0.2",
41
+ "@phantom/base64url": "^1.0.2",
42
+ "@phantom/client": "^1.0.2",
43
+ "@phantom/constants": "^1.0.2",
44
+ "@phantom/crypto": "^1.0.2",
45
+ "@phantom/server-sdk": "^1.0.2",
46
+ "@phantom/utils": "^1.0.2",
47
+ "@solana/spl-token": "^0.4.14",
48
+ "@solana/web3.js": "^1.95.5",
47
49
  "axios": "^1.6.7",
50
+ "bs58": "^6.0.0",
48
51
  "open": "^10.0.3",
49
52
  "openid-client": "^5.6.5"
50
53
  },
@@ -55,4 +58,4 @@
55
58
  "publishConfig": {
56
59
  "directory": "_release/package"
57
60
  }
58
- }
61
+ }