# Directory Structure ``` ├── .gitignore ├── Dockerfile ├── example-keypair.json ├── package-lock.json ├── package.json ├── README.md ├── run.sh ├── smithery.yaml ├── src │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | dist 2 | node_modules 3 | keypair.json ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Solana MCP Server 🌱 2 | 3 | [](https://smithery.ai/server/@Grandbusta/solana-mcp) 4 | 5 | A MCP server to interact with the Solana blockchain with your own private key. 6 | 7 | ## 📖 Table of Contents 8 | - [✨Features](#-features) 9 | - [⚙️Setup](#️-setup) 10 | - [Integration with Cursor](#integration-with-cursor) 11 | - [🛠️Available Tools](#️-available-tools) 12 | - [🔖License](#️-license) 13 | 14 | ## ✨ Features 15 | 16 | - Get latest slot 17 | - Get wallet address 18 | - Get wallet balance 19 | - Transfer SOL 20 | 21 | ## ⚙️ Setup 22 | 23 | ### Installing via Smithery 24 | 25 | To install Solana MCP for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@Grandbusta/solana-mcp): 26 | 27 | ```bash 28 | npx -y @smithery/cli install @Grandbusta/solana-mcp --client claude 29 | ``` 30 | 31 | ### Manual Setup 32 | 33 | 1. Clone the repository 34 | ```bash 35 | git clone https://github.com/Grandbusta/solana-mcp.git 36 | ``` 37 | 38 | 2. Install dependencies 39 | ```bash 40 | npm install 41 | ``` 42 | 43 | 3. Build the project 44 | ```bash 45 | npm run build 46 | ``` 47 | 48 | 4. Create a keypair file 49 | Create a file named `keypair.json` anywhere you want and copy your private key into it. Check the example-keypair.json file for an example. 50 | 51 | NB: RPC endpoint is set to `api.devnet.solana.com` by default. If you want to use a different endpoint, you can set it in the `run.sh` file. 52 | 53 | ## Integration with Cursor 54 | 55 | To integrate with Cursor, follow these steps: 56 | 57 | 1. In the Cursor settings, go to MCP 58 | 2. Click "Add new MCP server" 59 | 3. Enter the following information: 60 | - Name: Solana MCP 61 | - Type: command 62 | - Command: ```/path/to/your/solana-mcp/run.sh /path/to/your/keypair.json``` 63 | 64 | Example command: ```/Users/username/projects/solana-mcp/run.sh /Users/username/Documents/keypair.json``` 65 | 66 | 67 | ## 🛠️ Available Tools 68 | 69 | ### 1. get-latest-slot 70 | Returns the latest slot number: 71 | 72 | ```bash 73 | 368202671 74 | ``` 75 | 76 | ### 2. get-wallet-address 77 | Returns the wallet address: 78 | 79 | ```bash 80 | 5GTuMBag1M8tfe736kcV1vcAE734Zf1SRta8pmWf82TJ 81 | ``` 82 | 83 | ### 3. get-wallet-balance 84 | Returns the wallet balance in SOL, Lamports, and USD: 85 | 86 | ```bash 87 | { 88 | "lamportsBalance": "4179966000", 89 | "solanaBalnce": 4.179966, 90 | "usdBalance": "553.0513" 91 | } 92 | ``` 93 | 94 | ### 4. transfer 95 | Transfers SOL to a recipient address: 96 | 97 | ```bash 98 | { 99 | "blockTime": "1742316463", 100 | "meta": { 101 | "computeUnitsConsumed": "150", 102 | "err": null, 103 | "fee": "5000", 104 | "innerInstructions": [], 105 | "loadedAddresses": { 106 | "readonly": [], 107 | "writable": [] 108 | }, 109 | "logMessages": [ 110 | "Program 11111111111111111111111111111111 invoke [1]", 111 | "Program 11111111111111111111111111111111 success" 112 | ], 113 | "postBalances": [ 114 | "4179966000", 115 | "819999000", 116 | "1" 117 | ], 118 | "postTokenBalances": [], 119 | "preBalances": [ 120 | "4399970000", 121 | "600000000", 122 | "1" 123 | ], 124 | "preTokenBalances": [], 125 | "rewards": [], 126 | "status": { 127 | "Ok": null 128 | } 129 | }, 130 | "slot": "368211978", 131 | "transaction": { 132 | "message": { 133 | "accountKeys": [ 134 | "6qhddtBoEHqTc3VM35a3rb3aLUe6vDQfmLigo2G4r5s1", 135 | "5GTuMBag1M8tfe736kcV1vcAE734Zf1SRta8pmWf82TJ", 136 | "11111111111111111111111111111111" 137 | ], 138 | "addressTableLookups": [], 139 | "header": { 140 | "numReadonlySignedAccounts": 0, 141 | "numReadonlyUnsignedAccounts": 1, 142 | "numRequiredSignatures": 1 143 | }, 144 | "instructions": [ 145 | { 146 | "accounts": [ 147 | 0, 148 | 1 149 | ], 150 | "data": "3Bxs452Q9hdvHuwd", 151 | "programIdIndex": 2, 152 | "stackHeight": null 153 | } 154 | ], 155 | "recentBlockhash": "BLqtPS9BHPp9CRFTrVAsrxFMWC98VTUAQ3vi12bSquLo" 156 | }, 157 | "signatures": [ 158 | "3bLyqbPn26ofkaxSAVqadQnHqXu9hyoryixmKCn69nunKg2cSryDVAWnfCcYPcGtjSmXcMHfrzc3bw25zFTabXvs" 159 | ] 160 | }, 161 | "version": "0" 162 | } 163 | ``` 164 | 165 | 166 | ## 🧑💻 Contributing 167 | 168 | Contributions are welcome! Please open an issue or submit a pull request. 169 | 170 | ## 🔖 License 171 | 172 | [WTFPL License](https://www.wtfpl.net/about/) 173 | ``` -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/zsh 2 | 3 | if [ -z "$1" ]; then 4 | echo "Error: Keypair path argument is required" 5 | exit 1 6 | fi 7 | 8 | export KEYPAIR_PATH=$1 9 | export RPC_URL="api.devnet.solana.com" 10 | node "$(dirname "$0")/dist/index.js" ``` -------------------------------------------------------------------------------- /example-keypair.json: -------------------------------------------------------------------------------- ```json 1 | [ 2 | 107, 145, 66, 123, 253, 251, 77, 186, 176, 211, 187, 232, 47, 142, 54, 214, 142, 152, 37, 182, 65, 117, 85, 75, 133, 3 | 97, 107, 11, 180, 24, 73, 245, 160, 114, 3, 57, 51, 114, 113, 153, 78, 211, 199, 86, 240, 220, 223, 19, 254, 107, 4 | 250, 11, 190, 31, 112, 13, 15, 146, 198, 211, 48, 140, 218, 239 5 | ] ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "outDir": "./dist", 12 | "rootDir": "./src" 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules"] 16 | } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "solana-mcp", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build:start": "npm run build && npm start", 7 | "start": "node dist/index.js", 8 | "build": "tsc && shx chmod +x dist/index.js", 9 | "dev": "tsc --watch", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "description": "", 16 | "devDependencies": { 17 | "@types/node": "^20.8.7", 18 | "shx": "^0.3.4", 19 | "typescript": "^5.2.2" 20 | }, 21 | "dependencies": { 22 | "@modelcontextprotocol/sdk": "^1.6.1", 23 | "@solana-program/system": "^0.7.0", 24 | "@solana/kit": "^2.1.0", 25 | "zod": "^3.24.2" 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM node:lts-alpine 3 | 4 | # Create app directory 5 | WORKDIR /app 6 | 7 | # Copy package files 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install --ignore-scripts 12 | 13 | # Copy rest of the source code 14 | COPY . . 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Set executable permission for run.sh 20 | RUN chmod +x ./run.sh 21 | 22 | # Expose the application via an entry point. The run.sh requires a keypair argument, which is passed via command-line args. 23 | ENTRYPOINT ["sh", "./run.sh"] 24 | 25 | # Default command for local testing. This will fail without a keypair argument, so it's expected to be overridden. 26 | CMD ["./example-keypair.json"] 27 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - keypairPath 10 | properties: 11 | keypairPath: 12 | type: string 13 | description: Absolute or relative path to your keypair JSON file 14 | commandFunction: 15 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 16 | |- 17 | (config) => ({ 18 | command: 'sh', 19 | args: ['./run.sh', config.keypairPath], 20 | env: { RPC_URL: process.env.RPC_URL || 'api.devnet.solana.com' } 21 | }) 22 | exampleConfig: 23 | keypairPath: ./example-keypair.json 24 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { z } from "zod"; 6 | import { 7 | createSolanaRpc, 8 | address, 9 | createSolanaRpcSubscriptions, 10 | sendAndConfirmTransactionFactory, 11 | pipe, 12 | setTransactionMessageFeePayer, 13 | createTransactionMessage, 14 | createKeyPairSignerFromBytes, 15 | setTransactionMessageLifetimeUsingBlockhash, 16 | appendTransactionMessageInstruction, 17 | KeyPairSigner, 18 | signTransactionMessageWithSigners, 19 | isSolanaError, 20 | SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, 21 | getSignatureFromTransaction 22 | } from '@solana/kit' 23 | import { getTransferSolInstruction } from '@solana-program/system'; 24 | import { readFile } from 'fs/promises' 25 | import path from "path"; 26 | 27 | const solanaRpc = createSolanaRpc(`https://${process.env.RPC_URL}`); 28 | const solanaRpcSubscription = createSolanaRpcSubscriptions(`wss://${process.env.RPC_URL}`) 29 | const solanaPriceEndpoint = "https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=USD" 30 | const PRICE_CACHE_DURATION = 1 * 60 * 1000 31 | let cachedPrice: { value: number; timestamp: number } | null = null; 32 | 33 | const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ 34 | rpc: solanaRpc, 35 | rpcSubscriptions: solanaRpcSubscription, 36 | }) 37 | 38 | 39 | function bigIntReplacer(_key: string, value: any): any { 40 | return typeof value === 'bigint' ? value.toString() : value; 41 | } 42 | 43 | function solToLamports(sol: number): number { 44 | return sol * 1_000_000_000; 45 | } 46 | 47 | function lamportsToSol(lamports: number): number { 48 | return lamports / 1_000_000_000; 49 | } 50 | 51 | 52 | async function verifyKeypairFile() { 53 | if (!process.env.KEYPAIR_PATH) { 54 | console.error('Error: KEYPAIR_PATH environment variable is not set'); 55 | process.exit(1); 56 | } 57 | 58 | const keyPairPath = path.join(process.env.KEYPAIR_PATH as string); 59 | try { 60 | await readFile(keyPairPath, "utf8"); 61 | } catch (error: any) { 62 | if (error.code === 'ENOENT') { 63 | console.error(`Error: Keypair file not found at ${keyPairPath}`); 64 | } else if (error.code === 'EACCES') { 65 | console.error(`Error: Permission denied reading keypair file at ${keyPairPath}`); 66 | } else { 67 | console.error(`Error reading keypair file: ${error.message}`); 68 | } 69 | process.exit(1); 70 | } 71 | } 72 | 73 | async function loadKeypairFromJson() { 74 | const keyPairPath = path.join(process.env.KEYPAIR_PATH as string); 75 | const keypair = JSON.parse(await readFile(keyPairPath, "utf8")); 76 | return keypair; 77 | } 78 | 79 | 80 | async function getSolanaPrice() { 81 | try { 82 | if (cachedPrice && (Date.now() - cachedPrice.timestamp) < PRICE_CACHE_DURATION) { 83 | return cachedPrice.value; 84 | } 85 | 86 | const response = await fetch(solanaPriceEndpoint); 87 | const data = await response.json(); 88 | 89 | cachedPrice = { 90 | value: data.solana.usd, 91 | timestamp: Date.now() 92 | }; 93 | 94 | return cachedPrice.value; 95 | } catch (error) { 96 | throw new Error("Failed to get Solana price"); 97 | } 98 | } 99 | 100 | async function getSourceAccountSigner() { 101 | try { 102 | const SOURCE_ACCOUNT_SIGNER = await createKeyPairSignerFromBytes( 103 | new Uint8Array(await loadKeypairFromJson()) 104 | ) 105 | return SOURCE_ACCOUNT_SIGNER; 106 | } catch (error:any) { 107 | throw new Error(error?.message); 108 | } 109 | } 110 | 111 | async function getLatestBlockHash() { 112 | try { 113 | const { value: blockHash } = await solanaRpc.getLatestBlockhash().send(); 114 | return blockHash; 115 | } catch (error:any) { 116 | throw new Error(error?.message); 117 | } 118 | } 119 | 120 | 121 | 122 | async function constructTransactionMessage( 123 | sourceAccountSigner: KeyPairSigner<string>, 124 | to: string, 125 | amount: number 126 | ) { 127 | try { 128 | const blockHash = await getLatestBlockHash(); 129 | const lamportsAmount = solToLamports(amount); 130 | const transactionMessage = pipe( 131 | createTransactionMessage({ version: 0 }), 132 | (tx: any) => ( 133 | setTransactionMessageFeePayer(sourceAccountSigner.address, tx) 134 | ), 135 | (tx: any) => ( 136 | setTransactionMessageLifetimeUsingBlockhash(blockHash, tx) 137 | ), 138 | (tx: any) => ( 139 | appendTransactionMessageInstruction( 140 | getTransferSolInstruction({ 141 | amount: lamportsAmount, 142 | source: sourceAccountSigner, 143 | destination: address(to), 144 | }) 145 | , tx) 146 | ) 147 | ) 148 | return transactionMessage; 149 | } catch (error:any) { 150 | throw new Error(error?.message); 151 | } 152 | } 153 | 154 | async function signTransactionMessage(transactionMessage: any) { 155 | try { 156 | const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); 157 | return signedTransaction; 158 | } catch (error:any) { 159 | throw new Error(error?.message); 160 | } 161 | } 162 | 163 | async function sendTransaction(signedTransaction: any) { 164 | try { 165 | const transactionSignature = await sendAndConfirmTransaction(signedTransaction, { commitment: 'confirmed' }); 166 | return transactionSignature; 167 | } catch (e:any) { 168 | if (isSolanaError(e, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE)) { 169 | const preflightErrorContext = e.context; 170 | console.log(preflightErrorContext); 171 | } else { 172 | throw e?.message; 173 | } 174 | } 175 | 176 | } 177 | 178 | 179 | async function transferTool(args: { to: string, amount: number }) { 180 | try { 181 | const sourceAccountSigner = await getSourceAccountSigner() 182 | const transactionMessage = await constructTransactionMessage(sourceAccountSigner, args.to, args.amount) 183 | const signedTransaction = await signTransactionMessage(transactionMessage) 184 | const signature = getSignatureFromTransaction(signedTransaction) 185 | await sendTransaction(signedTransaction) 186 | const transaction = await solanaRpc.getTransaction(signature, { 187 | maxSupportedTransactionVersion: 0 188 | }).send(); 189 | return transaction; 190 | } catch (error: any) { 191 | throw new Error(error?.message); 192 | } 193 | } 194 | 195 | async function getSlotTool() { 196 | try { 197 | const slot = await solanaRpc.getSlot().send(); 198 | return slot; 199 | } catch (error:any) { 200 | throw new Error(error?.message); 201 | } 202 | } 203 | 204 | async function getAddressBalanceTool(add: string) { 205 | try { 206 | const balance = await solanaRpc.getBalance(address(add)).send(); 207 | return balance.value; 208 | } catch (error: any) { 209 | throw new Error(error?.message); 210 | } 211 | } 212 | 213 | 214 | 215 | // Create an MCP server 216 | const server = new McpServer({ 217 | name: "Solana MCP", 218 | version: "1.0.0" 219 | }); 220 | 221 | server.tool( 222 | "get-latest-slot", 223 | async () => { 224 | try { 225 | return { 226 | content: [{ 227 | type: "text", 228 | text: String(await getSlotTool()) 229 | }] 230 | } 231 | } catch (error:any) { 232 | return { 233 | content: [{ type: "text", text: error?.message }], 234 | isError: true 235 | } 236 | } 237 | } 238 | ) 239 | 240 | 241 | server.tool( 242 | "get-wallet-address", 243 | async () => { 244 | try { 245 | let address = (await getSourceAccountSigner()).address as string 246 | return { 247 | content: [{ 248 | type: "text", 249 | text: address 250 | }] 251 | } 252 | } catch (error:any) { 253 | return { 254 | content: [{ type: "text", text: `${error?.message}}` }], 255 | isError: true 256 | } 257 | } 258 | } 259 | ) 260 | 261 | server.tool( 262 | "get-wallet-balance", 263 | async () => { 264 | try { 265 | let address = (await getSourceAccountSigner()).address as string 266 | const lamportsBalance = await getAddressBalanceTool(address) 267 | const solBalance = lamportsToSol(Number(lamportsBalance)) 268 | const price = await getSolanaPrice() 269 | const usdBalance = (solBalance * price).toFixed(4) 270 | return { 271 | content: [{ 272 | type: "text", 273 | text: JSON.stringify({ 274 | lamportsBalance: lamportsBalance, 275 | solanaBalnce: solBalance, 276 | usdBalance: usdBalance 277 | }, bigIntReplacer, 2) 278 | }] 279 | } 280 | } catch (error:any) { 281 | return { 282 | content: [{ type: "text", text: error?.message }], 283 | isError: true 284 | } 285 | } 286 | } 287 | ) 288 | 289 | server.tool("transfer", 290 | { 291 | to: z.string().describe("Recipient wallet address"), 292 | amount: z.number().describe("Amount in SOL") 293 | }, 294 | async (args) => { 295 | try { 296 | const transaction = await transferTool(args); 297 | return { 298 | content: [{ type: "text", text: JSON.stringify(transaction, bigIntReplacer, 2) }] 299 | } 300 | } catch (error:any) { 301 | return { 302 | content: [{ type: "text", text: error?.message }], 303 | isError: true 304 | } 305 | } 306 | } 307 | ); 308 | 309 | // Start receiving messages on stdin and sending messages on stdout 310 | const transport = new StdioServerTransport(); 311 | 312 | async function main() { 313 | await verifyKeypairFile(); 314 | await server.connect(transport); 315 | } 316 | 317 | main().catch(console.error); 318 | ```