/** * MCP tools for rWallet (read-only). * * Tools: rwallet_get_safe_balances, rwallet_get_transfers, * rwallet_get_defi_positions, rwallet_get_crdt_balances */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { enrichWithPrices } from "../../modules/rwallet/lib/price-feed"; import { getDefiPositions } from "../../modules/rwallet/lib/defi-positions"; import { getTokenDoc, listTokenDocs, getAllBalances } from "../token-service"; import { verifyToken } from "../auth"; // Safe Global chain prefix map (subset for MCP) const CHAIN_MAP: Record = { "1": "mainnet", "10": "optimism", "100": "gnosis", "137": "polygon", "8453": "base", "42161": "arbitrum", "42220": "celo", "43114": "avalanche", "56": "bsc", "324": "zksync", "11155111": "sepolia", }; const NATIVE_TOKENS: Record = { "1": { name: "Ether", symbol: "ETH", decimals: 18 }, "10": { name: "Ether", symbol: "ETH", decimals: 18 }, "100": { name: "xDAI", symbol: "xDAI", decimals: 18 }, "137": { name: "MATIC", symbol: "MATIC", decimals: 18 }, "8453": { name: "Ether", symbol: "ETH", decimals: 18 }, "42161": { name: "Ether", symbol: "ETH", decimals: 18 }, }; function safeApiBase(prefix: string): string { return `https://api.safe.global/tx-service/${prefix}/api/v1`; } export function registerWalletTools(server: McpServer) { server.tool( "rwallet_get_safe_balances", "Get token balances for a Safe wallet address on a specific chain, enriched with USD prices", { chain_id: z.string().describe("Chain ID (e.g. '1' for Ethereum, '100' for Gnosis, '8453' for Base)"), address: z.string().describe("Wallet address (0x...)"), }, async ({ chain_id, address }) => { const prefix = CHAIN_MAP[chain_id]; if (!prefix) { return { content: [{ type: "text", text: JSON.stringify({ error: "Unsupported chain", supported: Object.keys(CHAIN_MAP) }) }] }; } try { const res = await fetch( `${safeApiBase(prefix)}/safes/${address}/balances/?trusted=true&exclude_spam=true`, { signal: AbortSignal.timeout(15000) }, ); if (!res.ok) { return { content: [{ type: "text", text: JSON.stringify({ error: `Safe API error: ${res.status}` }) }] }; } const raw = await res.json() as any[]; const nativeToken = NATIVE_TOKENS[chain_id] || { name: "ETH", symbol: "ETH", decimals: 18 }; const data = raw.map((item: any) => ({ tokenAddress: item.tokenAddress, token: item.token || nativeToken, balance: item.balance || "0", fiatBalance: item.fiatBalance || "0", fiatConversion: item.fiatConversion || "0", })); const enriched = (await enrichWithPrices(data, chain_id, { filterSpam: true })) .filter(b => BigInt(b.balance || "0") > 0n); return { content: [{ type: "text", text: JSON.stringify(enriched, null, 2) }] }; } catch (e: any) { return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] }; } }, ); server.tool( "rwallet_get_transfers", "Get recent token transfers for a Safe wallet address", { chain_id: z.string().describe("Chain ID"), address: z.string().describe("Wallet address (0x...)"), limit: z.number().optional().describe("Max results (default 20)"), }, async ({ chain_id, address, limit }) => { const prefix = CHAIN_MAP[chain_id]; if (!prefix) { return { content: [{ type: "text", text: JSON.stringify({ error: "Unsupported chain" }) }] }; } try { const maxResults = Math.min(limit || 20, 100); const res = await fetch( `${safeApiBase(prefix)}/safes/${address}/all-transactions/?limit=${maxResults}&executed=true`, { signal: AbortSignal.timeout(15000) }, ); if (!res.ok) { return { content: [{ type: "text", text: JSON.stringify({ error: `Safe API error: ${res.status}` }) }] }; } const data = await res.json() as any; return { content: [{ type: "text", text: JSON.stringify(data.results || [], null, 2) }] }; } catch (e: any) { return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] }; } }, ); server.tool( "rwallet_get_defi_positions", "Get DeFi protocol positions (Aave, Uniswap, etc.) for an address via Zerion", { address: z.string().describe("Wallet address (0x...)"), }, async ({ address }) => { const positions = await getDefiPositions(address); return { content: [{ type: "text", text: JSON.stringify(positions, null, 2) }] }; }, ); server.tool( "rwallet_get_crdt_balances", "Get CRDT token balances (cUSDC, $MYCO) for all holders or a specific DID (requires auth token)", { token: z.string().describe("JWT auth token (required — exposes DID→balance mapping)"), did: z.string().optional().describe("Filter by DID (optional — returns all holders if omitted)"), }, async ({ token, did }) => { try { await verifyToken(token); } catch { return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid or expired token" }) }], isError: true }; } const docIds = listTokenDocs(); const result: Array<{ tokenId: string; name: string; symbol: string; decimals: number; holders: Record; }> = []; for (const docId of docIds) { const tokenId = docId.replace("global:tokens:ledgers:", ""); const doc = getTokenDoc(tokenId); if (!doc) continue; let holders = getAllBalances(doc); if (did) { const filtered: typeof holders = {}; if (holders[did]) filtered[did] = holders[did]; holders = filtered; } result.push({ tokenId, name: doc.token.name, symbol: doc.token.symbol, decimals: doc.token.decimals, holders, }); } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, ); }