From dbe024c858250aefa89af9207de42e5abdc93b12 Mon Sep 17 00:00:00 2001 From: dschlabach Date: Tue, 22 Apr 2025 12:45:00 -0400 Subject: [PATCH 1/4] fix --- src/main.ts | 35 ++-------- src/tools/erc20/handlers.ts | 79 ---------------------- src/tools/erc20/index.ts | 123 +++++++++++++++++++++++++++++----- src/tools/index.ts | 6 -- src/tools/nft/handlers.ts | 82 ----------------------- src/tools/nft/index.ts | 129 +++++++++++++++++++++++++++++++----- src/tools/nft/types.ts | 3 +- src/tools/nft/utils.ts | 38 +++++------ 8 files changed, 248 insertions(+), 247 deletions(-) delete mode 100644 src/tools/erc20/handlers.ts delete mode 100644 src/tools/nft/handlers.ts diff --git a/src/main.ts b/src/main.ts index f2d6796..d31fd44 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,8 +28,10 @@ import { base } from 'viem/chains'; import { Event, postMetric } from './analytics.js'; import { chainIdToCdpNetworkId, chainIdToChain } from './chains.js'; import { baseMcpContractActionProvider } from './tools/contracts/index.js'; +import { baseMcpErc20ActionProvider } from './tools/erc20/index.js'; import { baseMcpTools, toolToHandler } from './tools/index.js'; import { baseMcpMorphoActionProvider } from './tools/morpho/index.js'; +import { baseMcpNftActionProvider } from './tools/nft/index.js'; import { baseMcpOnrampActionProvider } from './tools/onramp/index.js'; import { generateSessionId, @@ -65,12 +67,6 @@ export async function main() { ); } - const viemClient = createWalletClient({ - account: mnemonicToAccount(seedPhrase ?? fallbackPhrase), - chain, - transport: http(), - }).extend(publicActions) as WalletClient & PublicActions; - const cdpWalletProvider = await CdpWalletProvider.configureWithWallet({ mnemonicPhrase: seedPhrase ?? fallbackPhrase, apiKeyName, @@ -100,6 +96,8 @@ export async function main() { baseMcpMorphoActionProvider(), baseMcpContractActionProvider(), baseMcpOnrampActionProvider(), + baseMcpErc20ActionProvider(), + baseMcpNftActionProvider(), ], }); @@ -128,7 +126,7 @@ export async function main() { console.error('Received ListToolsRequest'); return { - tools: [...baseMcpTools, ...tools], + tools, }; }); @@ -136,29 +134,6 @@ export async function main() { try { postMetric(Event.ToolUsed, { toolName: request.params.name }, sessionId); - // Check if the tool is Base MCP tool - const isBaseMcpTool = baseMcpTools.some( - (tool) => tool.definition.name === request.params.name, - ); - - if (isBaseMcpTool) { - const tool = toolToHandler[request.params.name]; - if (!tool) { - throw new Error(`Tool ${request.params.name} not found`); - } - - const result = await tool(viemClient, request.params.arguments); - - return { - content: [ - { - type: 'text', - text: JSON.stringify(result), - }, - ], - }; - } - // In order for users to use AgentKit tools, they are required to have a SEED_PHRASE and not a ONE_TIME_KEY if (!seedPhrase) { return { diff --git a/src/tools/erc20/handlers.ts b/src/tools/erc20/handlers.ts deleted file mode 100644 index ab325aa..0000000 --- a/src/tools/erc20/handlers.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - erc20Abi, - formatUnits, - isAddress, - parseUnits, - type PublicActions, - type WalletClient, -} from 'viem'; -import { base } from 'viem/chains'; -import type { z } from 'zod'; -import { constructBaseScanUrl } from '../utils/index.js'; -import { Erc20BalanceSchema, Erc20TransferSchema } from './schemas.js'; - -export async function erc20BalanceHandler( - wallet: WalletClient & PublicActions, - args: z.infer, -): Promise { - const { contractAddress } = args; - - if (!isAddress(contractAddress, { strict: false })) { - throw new Error(`Invalid contract address: ${contractAddress}`); - } - - const balance = await wallet.readContract({ - address: contractAddress, - abi: erc20Abi, - functionName: 'balanceOf', - args: [wallet.account?.address ?? '0x'], - }); - - const decimals = await wallet.readContract({ - address: contractAddress, - abi: erc20Abi, - functionName: 'decimals', - }); - - return formatUnits(balance, decimals); -} - -export async function erc20TransferHandler( - wallet: WalletClient & PublicActions, - args: z.infer, -): Promise { - const { contractAddress, toAddress, amount } = args; - - if (!isAddress(contractAddress, { strict: false })) { - throw new Error(`Invalid contract address: ${contractAddress}`); - } - - if (!isAddress(toAddress, { strict: false })) { - throw new Error(`Invalid to address: ${toAddress}`); - } - - // Get decimals for token - const decimals = await wallet.readContract({ - address: contractAddress, - abi: erc20Abi, - functionName: 'decimals', - }); - - // Format units - const atomicUnits = parseUnits(amount, decimals); - - const tx = await wallet.simulateContract({ - address: contractAddress, - abi: erc20Abi, - functionName: 'transfer', - args: [toAddress, atomicUnits], - account: wallet.account, - chain: wallet.chain, - }); - - const txHash = await wallet.writeContract(tx.request); - - return JSON.stringify({ - hash: txHash, - url: constructBaseScanUrl(wallet.chain ?? base, txHash), - }); -} diff --git a/src/tools/erc20/index.ts b/src/tools/erc20/index.ts index 2947120..052a6f2 100644 --- a/src/tools/erc20/index.ts +++ b/src/tools/erc20/index.ts @@ -1,17 +1,110 @@ -import { generateTool } from '../../utils.js'; -import { erc20BalanceHandler, erc20TransferHandler } from './handlers.js'; +import { + ActionProvider, + CreateAction, + EvmWalletProvider, + type Network, +} from '@coinbase/agentkit'; +import { + encodeFunctionData, + erc20Abi, + formatUnits, + isAddress, + parseUnits, +} from 'viem'; +import { base, baseSepolia } from 'viem/chains'; +import type { z } from 'zod'; +import { chainIdToChain } from '../../chains.js'; +import { constructBaseScanUrl } from '../utils/index.js'; import { Erc20BalanceSchema, Erc20TransferSchema } from './schemas.js'; -export const erc20BalanceTool = generateTool({ - name: 'erc20_balance', - description: 'Get the balance of an ERC20 token', - inputSchema: Erc20BalanceSchema, - toolHandler: erc20BalanceHandler, -}); - -export const erc20TransferTool = generateTool({ - name: 'erc20_transfer', - description: 'Transfer an ERC20 token', - inputSchema: Erc20TransferSchema, - toolHandler: erc20TransferHandler, -}); +export class BaseMcpErc20ActionProvider extends ActionProvider { + constructor() { + super('baseMcpErc20', []); + } + + @CreateAction({ + name: 'erc20_balance', + description: 'Get the balance of an ERC20 token', + schema: Erc20BalanceSchema, + }) + async erc20Balance( + walletProvider: EvmWalletProvider, + args: z.infer, + ) { + const { contractAddress } = args; + + if (!isAddress(contractAddress, { strict: false })) { + throw new Error(`Invalid contract address: ${contractAddress}`); + } + + const balance = await walletProvider.readContract({ + address: contractAddress, + abi: erc20Abi, + functionName: 'balanceOf', + args: [(walletProvider.getAddress() as `0x${string}`) ?? '0x'], + }); + + const decimals = await walletProvider.readContract({ + address: contractAddress, + abi: erc20Abi, + functionName: 'decimals', + }); + + return formatUnits(balance, decimals); + } + + @CreateAction({ + name: 'erc20_transfer', + description: 'Transfer an ERC20 token', + schema: Erc20TransferSchema, + }) + async erc20Transfer( + walletProvider: EvmWalletProvider, + args: z.infer, + ) { + const { contractAddress, toAddress, amount } = args; + + if (!isAddress(contractAddress, { strict: false })) { + throw new Error(`Invalid contract address: ${contractAddress}`); + } + + if (!isAddress(toAddress, { strict: false })) { + throw new Error(`Invalid to address: ${toAddress}`); + } + + const decimals = await walletProvider.readContract({ + address: contractAddress, + abi: erc20Abi, + functionName: 'decimals', + }); + + const atomicUnits = parseUnits(amount, decimals); + + const tx = await walletProvider.sendTransaction({ + to: contractAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [toAddress, atomicUnits], + }), + }); + + const chain = + chainIdToChain(walletProvider.getNetwork().chainId ?? base.id) ?? base; + + return JSON.stringify({ + hash: tx, + url: constructBaseScanUrl(chain, tx), + }); + } + + supportsNetwork(network: Network): boolean { + return ( + network.chainId === String(base.id) || + network.chainId === String(baseSepolia.id) + ); + } +} + +export const baseMcpErc20ActionProvider = () => + new BaseMcpErc20ActionProvider(); diff --git a/src/tools/index.ts b/src/tools/index.ts index 35592fe..64c6edf 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,14 +1,8 @@ -import { erc20BalanceTool, erc20TransferTool } from './erc20/index.js'; import { farcasterUsernameTool } from './farcaster/index.js'; -import { listNftsTool, transferNftTool } from './nft/index.js'; import { buyOpenRouterCreditsTool } from './open-router/index.js'; import type { ToolHandler, ToolWithHandler } from './types.js'; export const baseMcpTools: ToolWithHandler[] = [ - erc20BalanceTool, - erc20TransferTool, - listNftsTool, - transferNftTool, buyOpenRouterCreditsTool, farcasterUsernameTool, ]; diff --git a/src/tools/nft/handlers.ts b/src/tools/nft/handlers.ts deleted file mode 100644 index da2368e..0000000 --- a/src/tools/nft/handlers.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { isAddress } from 'viem'; -import type { PublicActions, WalletClient } from 'viem'; -import { base } from 'viem/chains'; -import type { z } from 'zod'; -import { constructBaseScanUrl } from '../utils/index.js'; -import { ListNftsSchema, TransferNftSchema } from './schemas.js'; -import { fetchNftsFromAlchemy, formatNftData, transferNft } from './utils.js'; - -export async function listNftsHandler( - wallet: WalletClient & PublicActions, - args: z.infer, -): Promise { - try { - // Validate owner address - if (!isAddress(args.ownerAddress)) { - throw new Error(`Invalid owner address: ${args.ownerAddress}`); - } - - // Fetch NFTs from Alchemy API - const nftData = await fetchNftsFromAlchemy({ - ownerAddress: args.ownerAddress, - limit: args.limit, - }); - - // Format the NFT data - const nfts = formatNftData({ - nftData, - }); - - // Format the result - if (nfts.length === 0) { - return 'No NFTs found for this address.'; - } - - const formattedNfts = nfts - .map((nft, index) => { - return `${index + 1}. ${nft.title} (${nft.tokenType}) - Contract: ${nft.contractAddress} - Token ID: ${nft.tokenId} - ${nft.imageUrl ? `Image: ${nft.imageUrl}` : ''}`; - }) - .join('\n\n'); - - return `Found ${nfts.length} NFTs:\n\n${formattedNfts}`; - } catch (error) { - console.error('Error listing NFTs:', error); - return `Error listing NFTs: ${error instanceof Error ? error.message : String(error)}`; - } -} - -export async function transferNftHandler( - wallet: WalletClient & PublicActions, - args: z.infer, -): Promise { - try { - // Validate addresses - if (!isAddress(args.contractAddress)) { - throw new Error(`Invalid contract address: ${args.contractAddress}`); - } - - if (!isAddress(args.toAddress)) { - throw new Error(`Invalid recipient address: ${args.toAddress}`); - } - - // Execute the transfer - const txHash = await transferNft({ - wallet, - contractAddress: args.contractAddress, - tokenId: args.tokenId, - toAddress: args.toAddress, - amount: args.amount, - }); - - // Construct transaction URL - const txUrl = constructBaseScanUrl(wallet.chain ?? base, txHash); - - return `NFT transfer initiated!\n\nTransaction: ${txUrl}\n\nPlease wait for the transaction to be confirmed.`; - } catch (error) { - console.error('Error transferring NFT:', error); - return `Error transferring NFT: ${error instanceof Error ? error.message : String(error)}`; - } -} diff --git a/src/tools/nft/index.ts b/src/tools/nft/index.ts index 0766258..da3ff2c 100644 --- a/src/tools/nft/index.ts +++ b/src/tools/nft/index.ts @@ -1,17 +1,116 @@ -import { generateTool } from '../../utils.js'; -import { listNftsHandler, transferNftHandler } from './handlers.js'; +import { + ActionProvider, + CreateAction, + EvmWalletProvider, + type Network, +} from '@coinbase/agentkit'; +import { isAddress } from 'viem'; +import { base } from 'viem/chains'; +import type { z } from 'zod'; +import { chainIdToChain } from '../../chains.js'; +import { constructBaseScanUrl } from '../utils/index.js'; import { ListNftsSchema, TransferNftSchema } from './schemas.js'; +import { + detectStandardAndTransferNft, + fetchNftsFromAlchemy, + formatNftData, +} from './utils.js'; -export const listNftsTool = generateTool({ - name: 'list_nfts', - description: 'List NFTs owned by a specific address', - inputSchema: ListNftsSchema, - toolHandler: listNftsHandler, -}); - -export const transferNftTool = generateTool({ - name: 'transfer_nft', - description: 'Transfer an NFT to another address', - inputSchema: TransferNftSchema, - toolHandler: transferNftHandler, -}); +export class BaseMcpNftActionProvider extends ActionProvider { + constructor() { + super('baseMcpNft', []); + } + + @CreateAction({ + name: 'list_nfts', + description: 'List NFTs owned by a specific address', + schema: ListNftsSchema, + }) + async listNfts( + walletProvider: EvmWalletProvider, + args: z.infer, + ) { + try { + // Validate owner address + if (!isAddress(args.ownerAddress)) { + throw new Error(`Invalid owner address: ${args.ownerAddress}`); + } + + // Fetch NFTs from Alchemy API + const nftData = await fetchNftsFromAlchemy({ + ownerAddress: args.ownerAddress, + limit: args.limit, + }); + + // Format the NFT data + const nfts = formatNftData({ + nftData, + }); + + // Format the result + if (nfts.length === 0) { + return 'No NFTs found for this address.'; + } + + const formattedNfts = nfts + .map((nft, index) => { + return `${index + 1}. ${nft.title} (${nft.tokenType}) + Contract: ${nft.contractAddress} + Token ID: ${nft.tokenId} + ${nft.imageUrl ? `Image: ${nft.imageUrl}` : ''}`; + }) + .join('\n\n'); + + return `Found ${nfts.length} NFTs:\n\n${formattedNfts}`; + } catch (error) { + console.error('Error listing NFTs:', error); + return `Error listing NFTs: ${error instanceof Error ? error.message : String(error)}`; + } + } + + @CreateAction({ + name: 'transfer_nft', + description: 'Transfer an NFT to another address', + schema: TransferNftSchema, + }) + async transferNft( + walletProvider: EvmWalletProvider, + args: z.infer, + ) { + try { + // Validate addresses + if (!isAddress(args.contractAddress)) { + throw new Error(`Invalid contract address: ${args.contractAddress}`); + } + + if (!isAddress(args.toAddress)) { + throw new Error(`Invalid recipient address: ${args.toAddress}`); + } + + // // Execute the transfer + const txHash = await detectStandardAndTransferNft({ + wallet: walletProvider, + contractAddress: args.contractAddress, + tokenId: args.tokenId, + toAddress: args.toAddress, + amount: args.amount, + }); + + const chain = + chainIdToChain(walletProvider.getNetwork().chainId ?? base.id) ?? base; + + const txUrl = constructBaseScanUrl(chain, txHash); + + return `NFT transfer initiated!\n\nTransaction: ${txUrl}\n\nPlease wait for the transaction to be confirmed.`; + } catch (error) { + console.error('Error transferring NFT:', error); + return `Error transferring NFT: ${error instanceof Error ? error.message : String(error)}`; + } + } + + supportsNetwork(network: Network): boolean { + return network.chainId === String(base.id); + } +} + +export const baseMcpNftActionProvider = () => new BaseMcpNftActionProvider(); diff --git a/src/tools/nft/types.ts b/src/tools/nft/types.ts index 3b524f9..18e3bc0 100644 --- a/src/tools/nft/types.ts +++ b/src/tools/nft/types.ts @@ -1,3 +1,4 @@ +import type { EvmWalletProvider } from '@coinbase/agentkit'; import type { PublicActions, WalletClient } from 'viem'; /** @@ -41,7 +42,7 @@ export type FetchNftsParams = { * Parameters for transferring NFTs */ export type TransferNftParams = { - wallet: WalletClient & PublicActions; + wallet: EvmWalletProvider; contractAddress: `0x${string}`; tokenId: string; toAddress: `0x${string}`; diff --git a/src/tools/nft/utils.ts b/src/tools/nft/utils.ts index 24c4fee..d7e2ae5 100644 --- a/src/tools/nft/utils.ts +++ b/src/tools/nft/utils.ts @@ -1,5 +1,5 @@ -import { erc721Abi as viem_erc721Abi } from 'viem'; -import type { PublicActions } from 'viem'; +import type { EvmWalletProvider } from '@coinbase/agentkit'; +import { encodeFunctionData, erc721Abi as viem_erc721Abi } from 'viem'; import { erc1155Abi } from '../../lib/contracts/erc1155.js'; import type { FetchNftsParams, @@ -56,7 +56,7 @@ export function formatNftData({ * @returns The detected NFT standard or "UNKNOWN" */ export async function detectNftStandard( - wallet: PublicActions, + wallet: EvmWalletProvider, contractAddress: `0x${string}`, ): Promise<'ERC721' | 'ERC1155' | 'UNKNOWN'> { try { @@ -143,7 +143,7 @@ export async function fetchNftsFromAlchemy({ * @param amount Amount of tokens to transfer (for ERC1155) * @returns Transaction hash */ -export async function transferNft({ +export async function detectStandardAndTransferNft({ wallet, contractAddress, tokenId, @@ -161,7 +161,7 @@ export async function transferNft({ } // Get the wallet address - const [fromAddress] = await wallet.getAddresses(); + const fromAddress = wallet.getAddress() as `0x${string}`; // Convert values to the correct format const tokenIdBigInt = BigInt(tokenId); @@ -171,23 +171,23 @@ export async function transferNft({ if (nftStandard === 'ERC721') { // Transfer ERC721 NFT - hash = await wallet.writeContract({ - address: contractAddress, - abi: erc721Abi, - functionName: 'safeTransferFrom', - args: [fromAddress, toAddress, tokenIdBigInt], - chain: null, - account: fromAddress, + hash = await wallet.sendTransaction({ + to: contractAddress, + data: encodeFunctionData({ + abi: erc721Abi, + functionName: 'safeTransferFrom', + args: [fromAddress, toAddress, tokenIdBigInt], + }), }); } else { // Transfer ERC1155 NFT - hash = await wallet.writeContract({ - address: contractAddress, - abi: erc1155Abi, - functionName: 'safeTransferFrom', - args: [fromAddress, toAddress, tokenIdBigInt, amountBigInt, '0x'], - chain: null, - account: fromAddress, + hash = await wallet.sendTransaction({ + to: contractAddress, + data: encodeFunctionData({ + abi: erc1155Abi, + functionName: 'safeTransferFrom', + args: [fromAddress, toAddress, tokenIdBigInt, amountBigInt, '0x'], + }), }); } From 93964565b03bdf5ffd464c15c02e169dea770aa3 Mon Sep 17 00:00:00 2001 From: dschlabach Date: Tue, 22 Apr 2025 12:57:15 -0400 Subject: [PATCH 2/4] fix --- src/main.ts | 12 +-- src/tools/farcaster/handlers.ts | 102 --------------------- src/tools/farcaster/index.ts | 114 +++++++++++++++++++++-- src/tools/farcaster/types.ts | 14 +++ src/tools/index.ts | 15 ---- src/tools/open-router/handlers.ts | 144 ------------------------------ src/tools/open-router/index.ts | 143 +++++++++++++++++++++++++++-- src/utils.ts | 52 ----------- 8 files changed, 258 insertions(+), 338 deletions(-) delete mode 100644 src/tools/farcaster/handlers.ts create mode 100644 src/tools/farcaster/types.ts delete mode 100644 src/tools/index.ts delete mode 100644 src/tools/open-router/handlers.ts diff --git a/src/main.ts b/src/main.ts index d31fd44..89c4b4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,23 +16,16 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import * as dotenv from 'dotenv'; -import { - createWalletClient, - http, - publicActions, - type PublicActions, - type WalletClient, -} from 'viem'; -import { english, generateMnemonic, mnemonicToAccount } from 'viem/accounts'; +import { english, generateMnemonic } from 'viem/accounts'; import { base } from 'viem/chains'; import { Event, postMetric } from './analytics.js'; import { chainIdToCdpNetworkId, chainIdToChain } from './chains.js'; import { baseMcpContractActionProvider } from './tools/contracts/index.js'; import { baseMcpErc20ActionProvider } from './tools/erc20/index.js'; -import { baseMcpTools, toolToHandler } from './tools/index.js'; import { baseMcpMorphoActionProvider } from './tools/morpho/index.js'; import { baseMcpNftActionProvider } from './tools/nft/index.js'; import { baseMcpOnrampActionProvider } from './tools/onramp/index.js'; +import { openRouterActionProvider } from './tools/open-router/index.js'; import { generateSessionId, getActionProvidersWithRequiredEnvVars, @@ -98,6 +91,7 @@ export async function main() { baseMcpOnrampActionProvider(), baseMcpErc20ActionProvider(), baseMcpNftActionProvider(), + openRouterActionProvider(), ], }); diff --git a/src/tools/farcaster/handlers.ts b/src/tools/farcaster/handlers.ts deleted file mode 100644 index 210f30c..0000000 --- a/src/tools/farcaster/handlers.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { PublicActions, WalletClient } from 'viem'; -import type { z } from 'zod'; -import { FarcasterUsernameSchema } from './schemas.js'; - -// Neynar API response types -type NeynarUserResponse = { - result: { - users: Array<{ - fid: number; - username: string; - verified_addresses: { - eth_addresses: string[]; - primary: { - eth_address: string; - }; - }; - }>; - }; -}; - -export async function farcasterUsernameHandler( - wallet: WalletClient & PublicActions, - args: z.infer, -): Promise { - const { username } = args; - - // Get Neynar API key from environment variables - const neynarApiKey = process.env.NEYNAR_API_KEY; - - if (!neynarApiKey) { - throw new Error('NEYNAR_API_KEY environment variable is not set'); - } - - try { - // Call Neynar API to search for the user - const response = await fetch( - `https://api.neynar.com/v2/farcaster/user/search?q=${encodeURIComponent(username)}`, - { - headers: { - accept: 'application/json', - 'x-api-key': neynarApiKey, - }, - }, - ); - - if (!response.ok) { - throw new Error( - `Neynar API error: ${response.status} ${response.statusText}`, - ); - } - - const data = (await response.json()) as NeynarUserResponse; - - // Check if any users were found - if (!data.result.users || data.result.users.length === 0) { - return JSON.stringify({ - success: false, - message: `No Farcaster user found with username: ${username}`, - }); - } - - // Find the user with the matching username (case-insensitive) - const user = data.result.users.find( - (u) => u.username.toLowerCase() === username.toLowerCase(), - ); - - if (!user) { - return JSON.stringify({ - success: false, - message: `No Farcaster user found with username: ${username}`, - }); - } - - // Check if the user has any verified Ethereum addresses - if ( - !user.verified_addresses?.eth_addresses || - user.verified_addresses.eth_addresses.length === 0 - ) { - return JSON.stringify({ - success: false, - message: `User ${username} has no verified Ethereum addresses`, - }); - } - - // Return the primary Ethereum address if available, otherwise the first one - const ethAddress = - user.verified_addresses.primary?.eth_address || - user.verified_addresses.eth_addresses[0]; - - return JSON.stringify({ - success: true, - username: user.username, - fid: user.fid, - ethAddress, - }); - } catch (error) { - return JSON.stringify({ - success: false, - message: `Error resolving Farcaster username: ${error instanceof Error ? error.message : String(error)}`, - }); - } -} diff --git a/src/tools/farcaster/index.ts b/src/tools/farcaster/index.ts index e1d9646..52f6e39 100644 --- a/src/tools/farcaster/index.ts +++ b/src/tools/farcaster/index.ts @@ -1,10 +1,108 @@ -import { generateTool } from '../../utils.js'; -import { farcasterUsernameHandler } from './handlers.js'; +import { + ActionProvider, + CreateAction, + EvmWalletProvider, + type Network, +} from '@coinbase/agentkit'; +import { base } from 'viem/chains'; +import type { z } from 'zod'; import { FarcasterUsernameSchema } from './schemas.js'; +import type { NeynarUserResponse } from './types.js'; -export const farcasterUsernameTool = generateTool({ - name: 'farcaster_username', - description: 'Resolve a Farcaster username to an Ethereum address', - inputSchema: FarcasterUsernameSchema, - toolHandler: farcasterUsernameHandler, -}); +export class FarcasterActionProvider extends ActionProvider { + constructor() { + super('farcaster', []); + } + + @CreateAction({ + name: 'farcaster_username', + description: 'Resolve a Farcaster username to an Ethereum address', + schema: FarcasterUsernameSchema, + }) + async farcasterUsername( + walletProvider: EvmWalletProvider, + args: z.infer, + ) { + const { username } = args; + + // Get Neynar API key from environment variables + const neynarApiKey = process.env.NEYNAR_API_KEY; + + if (!neynarApiKey) { + throw new Error('NEYNAR_API_KEY environment variable is not set'); + } + + try { + // Call Neynar API to search for the user + const response = await fetch( + `https://api.neynar.com/v2/farcaster/user/search?q=${encodeURIComponent(username)}`, + { + headers: { + accept: 'application/json', + 'x-api-key': neynarApiKey, + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Neynar API error: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as NeynarUserResponse; + + // Check if any users were found + if (!data.result.users || data.result.users.length === 0) { + return JSON.stringify({ + success: false, + message: `No Farcaster user found with username: ${username}`, + }); + } + + // Find the user with the matching username (case-insensitive) + const user = data.result.users.find( + (u) => u.username.toLowerCase() === username.toLowerCase(), + ); + + if (!user) { + return JSON.stringify({ + success: false, + message: `No Farcaster user found with username: ${username}`, + }); + } + + // Check if the user has any verified Ethereum addresses + if ( + !user.verified_addresses?.eth_addresses || + user.verified_addresses.eth_addresses.length === 0 + ) { + return JSON.stringify({ + success: false, + message: `User ${username} has no verified Ethereum addresses`, + }); + } + + // Return the primary Ethereum address if available, otherwise the first one + const ethAddress = + user.verified_addresses.primary?.eth_address || + user.verified_addresses.eth_addresses[0]; + + return JSON.stringify({ + success: true, + username: user.username, + fid: user.fid, + ethAddress, + }); + } catch (error) { + return JSON.stringify({ + success: false, + message: `Error resolving Farcaster username: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + + supportsNetwork(network: Network): boolean { + return network.chainId === String(base.id); + } +} diff --git a/src/tools/farcaster/types.ts b/src/tools/farcaster/types.ts new file mode 100644 index 0000000..9d01876 --- /dev/null +++ b/src/tools/farcaster/types.ts @@ -0,0 +1,14 @@ +export type NeynarUserResponse = { + result: { + users: { + fid: number; + username: string; + verified_addresses: { + eth_addresses: string[]; + primary: { + eth_address: string; + }; + }; + }[]; + }; +}; diff --git a/src/tools/index.ts b/src/tools/index.ts deleted file mode 100644 index 64c6edf..0000000 --- a/src/tools/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { farcasterUsernameTool } from './farcaster/index.js'; -import { buyOpenRouterCreditsTool } from './open-router/index.js'; -import type { ToolHandler, ToolWithHandler } from './types.js'; - -export const baseMcpTools: ToolWithHandler[] = [ - buyOpenRouterCreditsTool, - farcasterUsernameTool, -]; - -export const toolToHandler: Record = baseMcpTools.reduce< - Record ->((acc, tool) => { - acc[tool.definition.name] = tool.handler; - return acc; -}, {}); diff --git a/src/tools/open-router/handlers.ts b/src/tools/open-router/handlers.ts deleted file mode 100644 index 7d3983d..0000000 --- a/src/tools/open-router/handlers.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - encodeFunctionData, - erc20Abi, - formatUnits, - type Account, - type PublicActions, - type WalletClient, -} from 'viem'; -import { waitForTransactionReceipt } from 'viem/actions'; -import { base } from 'viem/chains'; -import type { z } from 'zod'; -import { USDC_ADDRESS, USDC_DECIMALS } from '../../lib/constants.js'; -import { COINBASE_COMMERCE_ABI } from '../../lib/contracts/coinbase-commerce.js'; -import type { OpenRouterTransferIntentResponse } from '../types.js'; -import { - checkToolSupportsChain, - constructBaseScanUrl, -} from '../utils/index.js'; -import type { BuyOpenRouterCreditsSchema } from './schemas.js'; - -export async function buyOpenRouterCreditsHandler( - wallet: WalletClient & PublicActions, - args: z.infer, -): Promise { - const { amountUsd } = args; - - checkToolSupportsChain({ - chainId: wallet.chain?.id, - supportedChains: [base], - }); - - if (!process.env.OPENROUTER_API_KEY) { - throw new Error('OPENROUTER_API_KEY is not set'); - } - - const address = wallet.account?.address; - - if (!address) { - throw new Error('No address found'); - } - - // Ensure user has enough USDC for txn - const usdcBalance = await wallet.readContract({ - address: USDC_ADDRESS, - abi: erc20Abi, - functionName: 'balanceOf', - args: [address], - }); - - const parsedUnits = formatUnits(usdcBalance, USDC_DECIMALS); - - if (Number(parsedUnits) < amountUsd) { - throw new Error('Insufficient USDC balance'); - } - - const response = await fetch( - 'https://openrouter.ai/api/v1/credits/coinbase', - { - method: 'POST', - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - amount: amountUsd, - sender: address, - chain_id: base.id, // only Base supported - }), - }, - ); - const responseJSON: OpenRouterTransferIntentResponse = await response.json(); - const { - data: { - web3_data: { - transfer_intent: { call_data }, - }, - }, - } = responseJSON; - - console.error(responseJSON); - - // Generate transactions based off intent - const atomicUnits = - BigInt(call_data.recipient_amount) + BigInt(call_data.fee_amount); - - const approvalTxCalldata = encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [ - responseJSON.data.web3_data.transfer_intent.metadata - .contract_address as `0x${string}`, - atomicUnits, - ], - }); - - const transferTokenPreApprovedTxCalldata = encodeFunctionData({ - abi: COINBASE_COMMERCE_ABI, - functionName: 'transferTokenPreApproved', - args: [ - { - id: call_data.id as `0x${string}`, - deadline: BigInt( - Math.floor(new Date(call_data.deadline).getTime() / 1000), - ), - recipient: call_data.recipient as `0x${string}`, - recipientAmount: BigInt(call_data.recipient_amount), - recipientCurrency: call_data.recipient_currency as `0x${string}`, - refundDestination: call_data.refund_destination as `0x${string}`, - feeAmount: BigInt(call_data.fee_amount), - operator: call_data.operator as `0x${string}`, - signature: call_data.signature as `0x${string}`, - prefix: call_data.prefix as `0x${string}`, - }, - ], - }); - - const approval = await wallet.sendTransaction({ - to: USDC_ADDRESS, - data: approvalTxCalldata, - account: wallet.account as Account, - chain: wallet.chain, - }); - - await waitForTransactionReceipt(wallet, { - hash: approval, - }); - - const transfer = await wallet.sendTransaction({ - to: responseJSON.data.web3_data.transfer_intent.metadata - .contract_address as `0x${string}`, - data: transferTokenPreApprovedTxCalldata, - account: wallet.account as Account, - chain: wallet.chain, - }); - - const { transactionHash } = await waitForTransactionReceipt(wallet, { - hash: transfer, - }); - - return JSON.stringify({ - hash: transactionHash, - url: constructBaseScanUrl(wallet.chain ?? base, transactionHash), - }); -} diff --git a/src/tools/open-router/index.ts b/src/tools/open-router/index.ts index c2bc9ce..0f78694 100644 --- a/src/tools/open-router/index.ts +++ b/src/tools/open-router/index.ts @@ -1,10 +1,137 @@ -import { generateTool } from '../../utils.js'; -import { buyOpenRouterCreditsHandler } from './handlers.js'; +import { + ActionProvider, + CreateAction, + EvmWalletProvider, + type Network, +} from '@coinbase/agentkit'; +import { encodeFunctionData, erc20Abi, formatUnits } from 'viem'; +import { base } from 'viem/chains'; +import type { z } from 'zod'; +import { USDC_ADDRESS, USDC_DECIMALS } from '../../lib/constants.js'; +import { COINBASE_COMMERCE_ABI } from '../../lib/contracts/coinbase-commerce.js'; +import type { OpenRouterTransferIntentResponse } from '../types.js'; +import { constructBaseScanUrl } from '../utils/index.js'; import { BuyOpenRouterCreditsSchema } from './schemas.js'; -export const buyOpenRouterCreditsTool = generateTool({ - name: 'buy_openrouter_credits', - description: 'Buy OpenRouter credits with USDC', - inputSchema: BuyOpenRouterCreditsSchema, - toolHandler: buyOpenRouterCreditsHandler, -}); +export class OpenRouterActionProvider extends ActionProvider { + constructor() { + super('openRouter', []); + } + + @CreateAction({ + name: 'buy_openrouter_credits', + description: 'Buy OpenRouter credits with USDC', + schema: BuyOpenRouterCreditsSchema, + }) + async buyOpenRouterCredits( + walletProvider: EvmWalletProvider, + args: z.infer, + ) { + const { amountUsd } = args; + + if (!process.env.OPENROUTER_API_KEY) { + throw new Error('OPENROUTER_API_KEY is not set'); + } + + const address = walletProvider.getAddress() as `0x${string}`; + + // Ensure user has enough USDC for txn + const usdcBalance = await walletProvider.readContract({ + address: USDC_ADDRESS, + abi: erc20Abi, + functionName: 'balanceOf', + args: [address], + }); + + const parsedUnits = formatUnits(usdcBalance, USDC_DECIMALS); + + if (Number(parsedUnits) < amountUsd) { + throw new Error('Insufficient USDC balance'); + } + + const response = await fetch( + 'https://openrouter.ai/api/v1/credits/coinbase', + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: amountUsd, + sender: address, + chain_id: base.id, // only Base supported + }), + }, + ); + + const responseJSON: OpenRouterTransferIntentResponse = + await response.json(); + + const { + data: { + web3_data: { + transfer_intent: { call_data }, + }, + }, + } = responseJSON; + + // Generate transactions based off intent + const atomicUnits = + BigInt(call_data.recipient_amount) + BigInt(call_data.fee_amount); + + const approvalTxCalldata = encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [ + responseJSON.data.web3_data.transfer_intent.metadata + .contract_address as `0x${string}`, + atomicUnits, + ], + }); + + const transferTokenPreApprovedTxCalldata = encodeFunctionData({ + abi: COINBASE_COMMERCE_ABI, + functionName: 'transferTokenPreApproved', + args: [ + { + id: call_data.id as `0x${string}`, + deadline: BigInt( + Math.floor(new Date(call_data.deadline).getTime() / 1000), + ), + recipient: call_data.recipient as `0x${string}`, + recipientAmount: BigInt(call_data.recipient_amount), + recipientCurrency: call_data.recipient_currency as `0x${string}`, + refundDestination: call_data.refund_destination as `0x${string}`, + feeAmount: BigInt(call_data.fee_amount), + operator: call_data.operator as `0x${string}`, + signature: call_data.signature as `0x${string}`, + prefix: call_data.prefix as `0x${string}`, + }, + ], + }); + + // Approve USDC for transfer + await walletProvider.sendTransaction({ + to: USDC_ADDRESS, + data: approvalTxCalldata, + }); + + const transfer = await walletProvider.sendTransaction({ + to: responseJSON.data.web3_data.transfer_intent.metadata + .contract_address as `0x${string}`, + data: transferTokenPreApprovedTxCalldata, + }); + + return JSON.stringify({ + hash: transfer, + url: constructBaseScanUrl(base, transfer), + }); + } + + supportsNetwork(network: Network): boolean { + return network.chainId === String(base.id); + } +} + +export const openRouterActionProvider = () => new OpenRouterActionProvider(); diff --git a/src/utils.ts b/src/utils.ts index 69d0777..f9dfe25 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,57 +1,5 @@ import crypto from 'crypto'; import { flaunchActionProvider } from '@coinbase/agentkit'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; -import { z } from 'zod'; -import { zodToJsonSchema, type JsonSchema7Type } from 'zod-to-json-schema'; -import type { ToolHandler } from './tools/types.js'; - -type GenerateToolParams = { - name: string; - description: string; - inputSchema: z.ZodSchema; - toolHandler: ToolHandler; -}; - -type RawSchemaType = JsonSchema7Type & { - $schema?: string | undefined; - $ref?: string | undefined; - definitions?: - | { - [key: string]: JsonSchema7Type; - } - | undefined; -}; - -function simplifySchema(schema: RawSchemaType): JsonSchema7Type { - const result = { ...schema }; - delete result.$schema; - delete result.$ref; - delete result.definitions; - - return result; -} - -export function generateTool({ - name, - description, - inputSchema: zodSchema, - toolHandler, -}: GenerateToolParams): { - definition: Tool; - handler: ToolHandler; -} { - const rawSchema = zodToJsonSchema(zodSchema); - const inputSchema = simplifySchema(rawSchema) as Tool['inputSchema']; - - return { - definition: { - name, - description, - inputSchema, - }, - handler: toolHandler, - }; -} /** * Some AgentKit action providers throw if a key isn't set From c25aae21d4d61c8e12b18234f3bc66a00ee74f5c Mon Sep 17 00:00:00 2001 From: dschlabach Date: Tue, 22 Apr 2025 13:00:27 -0400 Subject: [PATCH 3/4] readme --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d2c17ce..03a6f7f 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ If you want to add a new tool to the Base MCP server, follow these steps: 1. Create a new directory in the `src/tools` directory for your tool 2. Implement the tool following the existing patterns: - - `index.ts`: Define and export your tools + - `index.ts`: Define and export your tools. Tools are defined as AgentKit ActionProviders. - `schemas.ts`: Define input schemas for your tools - - `handlers.ts`: Implement the functionality of your tools -3. Add your tool to the list of available tools in `src/tools/index.ts` + - `types.ts`: Define types required for your tools + - `utils.ts`: Utilities for your tools +3. Add your tool to the list of available tools in `src/main.ts` 4. Add documentation for your tool in the README.md 5. Add examples of how to use your tool in examples.md 6. Write tests for your tool @@ -50,11 +51,9 @@ The Base MCP server follows this structure for tools: ``` src/ ├── tools/ -│ ├── index.ts (exports toolsets) │ ├── [TOOL_NAME]/ <-------------------------- ADD DIR HERE │ │ ├── index.ts (defines and exports tools) │ │ ├── schemas.ts (defines input schema) -│ │ └── handlers.ts (implements tool functionality) │ └── utils/ (shared tool utilities) ``` From 4168140515d245968343a4bd37dc0b7e0cd2a5a1 Mon Sep 17 00:00:00 2001 From: dschlabach Date: Tue, 22 Apr 2025 13:00:37 -0400 Subject: [PATCH 4/4] lint --- src/tools/nft/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/nft/types.ts b/src/tools/nft/types.ts index 18e3bc0..8241a46 100644 --- a/src/tools/nft/types.ts +++ b/src/tools/nft/types.ts @@ -1,5 +1,4 @@ import type { EvmWalletProvider } from '@coinbase/agentkit'; -import type { PublicActions, WalletClient } from 'viem'; /** * Define a more specific type for NFT data