Farcaster MCP Server

  • src
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import axios from "axios"; import { Buffer } from 'buffer'; import * as process from 'process'; // Farcaster Hubble API base URL const HUBBLE_API_BASE = "https://nemes.farcaster.xyz:2281/v1"; // Create MCP server with all capabilities const server = new McpServer({ name: "farcaster-mcp", version: "1.0.0", capabilities: { tools: {}, resources: {}, prompts: {} } }); // Types for Farcaster API responses based on actual API structure interface CastAddBody { text: string; mentions?: number[]; mentionsPositions?: number[]; parentCastId?: { fid: number; hash: string; }; embeds?: any[]; embedsDeprecated?: any[]; } interface CastRemoveBody { targetHash: string; } interface CastData { type: string; fid: number; timestamp: number; network: string; castAddBody?: CastAddBody; castRemoveBody?: CastRemoveBody; } interface Cast { data: CastData; hash: string; hashScheme: string; signature: string; signatureScheme: string; signer: string; } interface UserData { type: number; fid: number; username?: string; displayName?: string; pfpUrl?: string; bio?: string; } // Farcaster user data type constants const USER_DATA_TYPE_DISPLAY = "USER_DATA_TYPE_DISPLAY"; const USER_DATA_TYPE_DISPLAY_NAME = USER_DATA_TYPE_DISPLAY; // Alias for backward compatibility interface FarcasterCastsResponse { messages: Cast[]; nextPageToken?: string; } interface FarcasterUserDataResponse { messages: { data: { type: number; fid: number; userDataBody: { type: number; value: string; } } }[]; } // Helper function to make API requests to Farcaster Hubble async function fetchFromHubble(endpoint: string, params: Record<string, any> = {}) { try { console.error(`Fetching from ${HUBBLE_API_BASE}${endpoint} with params:`, params); const response = await axios.get(`${HUBBLE_API_BASE}${endpoint}`, { params }); console.error(`Response status: ${response.status}`); return response.data; } catch (error) { console.error("Error fetching from Hubble API:", error); if (axios.isAxiosError(error) && error.response) { throw new Error(`Hubble API error: ${error.response.status} - ${error.response.data?.details || error.message}`); } throw new Error(`Failed to fetch from Hubble: ${error instanceof Error ? error.message : String(error)}`); } } // Helper function to get user data (username, display name) async function getUserData(fid: number): Promise<UserData> { try { console.error(`Fetching user data for FID: ${fid}`); const userData: UserData = { fid, type: 0 }; let foundDisplayName = false; // Get user data - only looking for display name try { // Log that we're fetching display name console.error(`Fetching display name for FID ${fid} from userDataByFid endpoint`); // Make the API call with no type filter to get all user data const userDataResponse = await fetchFromHubble(`/userDataByFid`, { fid }) as FarcasterUserDataResponse; console.error(`Got user data response with ${userDataResponse.messages?.length || 0} messages`); if (userDataResponse.messages && userDataResponse.messages.length > 0) { // Process user data messages for (const message of userDataResponse.messages) { if (message.data && message.data.userDataBody) { const type = message.data.userDataBody.type; const value = message.data.userDataBody.value; console.error(`Processing user data message with type ${type} and value "${value}"`); // Check if this is a display name // The API seems to return "USER_DATA_TYPE_DISPLAY" as the type if (String(type) === "USER_DATA_TYPE_DISPLAY" || String(type).includes("DISPLAY")) { userData.displayName = value; foundDisplayName = true; console.error(`Found display name: ${value}`); } } } } else { console.error(`No user data messages found for FID ${fid}`); } // Debug the userData object to see if displayName was set console.error(`After processing messages, userData.displayName = ${userData.displayName || 'undefined'}, foundDisplayName = ${foundDisplayName}`); // If we didn't find a display name, try a different approach if (!foundDisplayName) { console.error(`No display name found for FID ${fid}, trying alternative approach`); // Try fetching with specific type const specificResponse = await fetchFromHubble(`/userDataByFid`, { fid, user_data_type: 2 // Try with numeric value for DISPLAY }) as FarcasterUserDataResponse; if (specificResponse.messages && specificResponse.messages.length > 0) { for (const message of specificResponse.messages) { if (message.data && message.data.userDataBody) { const type = message.data.userDataBody.type; console.error(`Specific query: message type ${type}`); // Check for display name if (String(type) === "USER_DATA_TYPE_DISPLAY" || String(type).includes("DISPLAY") || type === 2) { userData.displayName = message.data.userDataBody.value; foundDisplayName = true; console.error(`Found display name in specific query: ${userData.displayName}`); break; } } } } } } catch (error) { console.error(`Error getting user data: ${error}`); } console.error(`Final user data for FID ${fid}: displayName=${userData.displayName || 'not found'}, foundDisplayName=${foundDisplayName}`); return userData; } catch (error) { console.error("Error fetching user data:", error); return { fid, type: 0 }; } } // Helper function to format casts async function formatCasts(casts: Cast[], limit: number = 10) { if (!casts || casts.length === 0) { return "No casts found."; } console.error(`Formatting ${casts.length} casts`); // Filter out cast removes and keep only cast adds const castAdds = casts.filter(cast => cast.data && cast.data.type === "MESSAGE_TYPE_CAST_ADD" && cast.data.castAddBody ); if (castAdds.length === 0) { return "No cast additions found."; } // Limit the number of casts const limitedCasts = castAdds.slice(0, limit); // First, collect all unique FIDs to fetch user data in batch const uniqueFids = new Set<number>(); limitedCasts.forEach(cast => { if (cast.data && cast.data.fid) { uniqueFids.add(cast.data.fid); } }); console.error(`Found ${uniqueFids.size} unique FIDs, fetching user data for all of them`); // Fetch user data for all FIDs const userDataMap = new Map<number, UserData>(); const userDataPromises = Array.from(uniqueFids).map(async fid => { try { const userData = await getUserData(fid); userDataMap.set(fid, userData); console.error(`Fetched user data for FID ${fid}: displayName=${userData.displayName}`); } catch (error) { console.error(`Error fetching user data for FID ${fid}:`, error); // Add a minimal entry to the map so we don't fail later userDataMap.set(fid, { fid, type: 0 }); } }); // Wait for all user data to be fetched await Promise.all(userDataPromises); console.error(`Completed fetching user data for all ${uniqueFids.size} FIDs`); // Now format all casts with the pre-fetched user data const formattedCastsPromises = limitedCasts.map(async (cast, index) => { try { // Check if cast and cast.data exist if (!cast || !cast.data || !cast.data.castAddBody) { console.error(`Invalid cast at index ${index}:`, cast); return `${index + 1}. [Invalid cast format]`; } const fid = cast.data.fid; const userData = userDataMap.get(fid) || { fid, type: 0 }; // Convert Farcaster epoch timestamp to date const farcasterEpoch = new Date('2021-01-01T00:00:00Z').getTime() / 1000; const date = new Date((cast.data.timestamp + farcasterEpoch) * 1000).toLocaleString(); // Use displayName if available, otherwise just show the index const displayName = userData.displayName || `User ${fid}`; // Check if this is a reply to another cast let replyInfo = ""; if (cast.data.castAddBody.parentCastId) { replyInfo = ` Reply to: ${cast.data.castAddBody.parentCastId.fid}/${cast.data.castAddBody.parentCastId.hash}\n`; } // Check if there are embeds let embedsInfo = ""; if (cast.data.castAddBody.embeds && cast.data.castAddBody.embeds.length > 0) { embedsInfo = " Embeds: " + cast.data.castAddBody.embeds.map((embed: any) => { if (embed.url) return embed.url; return "embedded content"; }).join(", ") + "\n"; } return ` ${index + 1}. ${displayName} FID: ${fid} Time: ${date} Text: ${cast.data.castAddBody.text} ${replyInfo}${embedsInfo} Cast ID: ${cast.hash} `; } catch (error) { console.error(`Error formatting cast at index ${index}:`, error); return `${index + 1}. [Error formatting cast]`; } }); const formattedCasts = await Promise.all(formattedCastsPromises); return formattedCasts.join("\n---\n"); } // Helper function to find FID by username async function getFidByUsername(username: string): Promise<number | null> { try { console.error(`Looking up FID for username: ${username}`); // Try with the original endpoint first try { const response = await fetchFromHubble(`/userNameProofByName`, { name: username }); console.error(`Username proof response:`, JSON.stringify(response, null, 2)); if (response && response.fid) { console.error(`Found FID ${response.fid} for username ${username}`); return response.fid; } if (response && response.proof && response.proof.fid) { console.error(`Found FID ${response.proof.fid} for username ${username}`); return response.proof.fid; } } catch (error) { console.error(`Error with first endpoint attempt:`, error); } // Try with a different case variation try { console.error(`Trying with different case: /usernameproofbyname`); const response2 = await fetchFromHubble(`/usernameproofbyname`, { name: username }); console.error(`Second attempt response:`, JSON.stringify(response2, null, 2)); if (response2 && response2.fid) { console.error(`Found FID ${response2.fid} for username ${username}`); return response2.fid; } if (response2 && response2.proof && response2.proof.fid) { console.error(`Found FID ${response2.proof.fid} for username ${username}`); return response2.proof.fid; } } catch (error) { console.error(`Error with second endpoint attempt:`, error); } // Try with a different endpoint format try { console.error(`Trying with different endpoint format: /usernameProofs/${username}`); const response3 = await fetchFromHubble(`/usernameProofs/${username}`); console.error(`Third attempt response:`, JSON.stringify(response3, null, 2)); if (response3 && response3.fid) { console.error(`Found FID ${response3.fid} for username ${username}`); return response3.fid; } if (response3 && response3.proof && response3.proof.fid) { console.error(`Found FID ${response3.proof.fid} for username ${username}`); return response3.proof.fid; } } catch (error) { console.error(`Error with third endpoint attempt:`, error); } // If we get here, we couldn't find the FID console.error(`No FID found for username ${username} after trying multiple endpoints`); return null; } catch (error) { console.error(`Error finding FID by username ${username}:`, error); return null; } } // Helper function to get channel URL from channel name or ID async function getChannelUrl(channelNameOrId: string): Promise<string | null> { try { // If it's already a full URL or hash format, return it as is if (channelNameOrId.startsWith('https://') || channelNameOrId.startsWith('chain://') || channelNameOrId.startsWith('0x')) { return channelNameOrId; } // First try the direct Warpcast URL approach const warpcastUrl = `https://warpcast.com/~/channel/${channelNameOrId}`; // Try to fetch channel info from Warpcast API as a fallback console.error(`Fetching channel info for "${channelNameOrId}" from Warpcast API`); try { const response = await axios.get('https://api.warpcast.com/v2/all-channels'); if (response.data && response.data.result && response.data.result.channels) { // Look for channel by ID or name (case insensitive) const channelNameLower = channelNameOrId.toLowerCase(); const channel = response.data.result.channels.find((c: any) => c.id.toLowerCase() === channelNameLower || c.name.toLowerCase() === channelNameLower ); if (channel) { console.error(`Found channel: ${channel.name} (${channel.id}) with URL: ${channel.url}`); return channel.url; // Return the hash URL from the API } } console.error(`Channel "${channelNameOrId}" not found in Warpcast API response, using direct Warpcast URL`); return warpcastUrl; // Fall back to direct URL if not found in API } catch (error) { console.error(`Error fetching from Warpcast API: ${error instanceof Error ? error.message : String(error)}`); console.error(`Falling back to direct Warpcast URL: ${warpcastUrl}`); return warpcastUrl; // Fall back to direct URL if API call fails } } catch (error) { console.error(`Error creating channel URL: ${error instanceof Error ? error.message : String(error)}`); return null; } } // Main function to start the server async function main() { try { // Register tool for getting casts by user FID server.tool( "get-user-casts", "Get casts from a specific Farcaster user by FID", { fid: z.number().describe("Farcaster user ID (FID)"), limit: z.number().optional().describe("Maximum number of casts to return (default: 10)") }, async ({ fid, limit = 10 }: { fid: number; limit?: number }) => { try { console.error(`Fetching casts for FID ${fid} with limit ${limit}`); const response = await fetchFromHubble(`/castsByFid`, { fid, pageSize: limit, reverse: 1 // Get newest first }) as FarcasterCastsResponse; console.error(`Got response with ${response.messages?.length || 0} messages`); if (!response.messages || response.messages.length === 0) { return { content: [ { type: "text", text: `No casts found for FID ${fid}` } ] }; } const castsText = await formatCasts(response.messages, limit); return { content: [ { type: "text", text: `# Casts from FID ${fid}\n\n${castsText}` } ] }; } catch (error) { console.error("Error in get-user-casts:", error); return { content: [ { type: "text", text: `Error fetching casts: ${error instanceof Error ? error.message : String(error)}` } ], isError: true }; } } ); // Register tool for getting casts from a channel (parent URL) server.tool( "get-channel-casts", "Get casts from a specific Farcaster channel", { channel: z.string().describe("Channel name (e.g., 'aichannel') or URL"), limit: z.number().optional().describe("Maximum number of casts to return (default: 10)") }, async ({ channel, limit = 10 }: { channel: string; limit?: number }) => { try { // First, determine the channel URL let channelUrl: string | null = null; // If it's already a URL format, use it directly if (channel.startsWith('https://') || channel.startsWith('chain://') || channel.startsWith('0x')) { channelUrl = channel; console.error(`Using provided URL: ${channelUrl}`); } else { // Otherwise, get the URL for the channel name channelUrl = await getChannelUrl(channel); if (!channelUrl) { return { content: [ { type: "text", text: `Failed to create URL for channel "${channel}".` } ], isError: true }; } console.error(`Using URL for channel "${channel}": ${channelUrl}`); } // Set up parameters for the API call const params: Record<string, any> = { pageSize: limit, reverse: 1 // Get newest first }; // Use url parameter for the API call as shown in the example params.url = channelUrl; console.error(`Fetching casts with params:`, params); let response: FarcasterCastsResponse; try { response = await fetchFromHubble(`/castsByParent`, params) as FarcasterCastsResponse; } catch (error) { // If the first attempt fails and we're using a Warpcast URL, try the API lookup approach if (channelUrl.includes('warpcast.com') && !channel.startsWith('https://')) { console.error(`First attempt failed, trying to get hash URL from Warpcast API`); // Try to fetch channel info from Warpcast API try { const apiResponse = await axios.get('https://api.warpcast.com/v2/all-channels'); if (apiResponse.data && apiResponse.data.result && apiResponse.data.result.channels) { // Look for channel by ID or name (case insensitive) const channelNameLower = channel.toLowerCase(); const channelInfo = apiResponse.data.result.channels.find((c: any) => c.id.toLowerCase() === channelNameLower || c.name.toLowerCase() === channelNameLower ); if (channelInfo && channelInfo.url) { console.error(`Found channel in API: ${channelInfo.name} (${channelInfo.id}) with URL: ${channelInfo.url}`); // Try again with the hash URL params.url = channelInfo.url; console.error(`Retrying with hash URL: ${channelInfo.url}`); response = await fetchFromHubble(`/castsByParent`, params) as FarcasterCastsResponse; } else { throw new Error(`Channel "${channel}" not found in Warpcast API`); } } else { throw error; // Re-throw the original error if API response is invalid } } catch (apiError) { console.error(`Error fetching from Warpcast API: ${apiError instanceof Error ? apiError.message : String(apiError)}`); throw error; // Re-throw the original error if API call fails } } else { throw error; // Re-throw the error if it's not a Warpcast URL or if it's already a full URL } } if (!response.messages || response.messages.length === 0) { return { content: [ { type: "text", text: `No casts found for channel "${channel}"` } ] }; } const castsText = await formatCasts(response.messages, limit); return { content: [ { type: "text", text: `# Recent Casts in Channel "${channel}"\n\n${castsText}` } ] }; } catch (error) { console.error("Error in get-channel-casts:", error); return { content: [ { type: "text", text: `Error fetching channel casts: ${error instanceof Error ? error.message : String(error)}` } ], isError: true }; } } ); // Register tool for getting casts by username server.tool( "get-username-casts", "Get casts from a specific Farcaster username", { username: z.string().describe("Farcaster username"), limit: z.number().optional().describe("Maximum number of casts to return (default: 10)") }, async ({ username, limit = 10 }: { username: string; limit?: number }) => { try { console.error(`Looking up casts for username: ${username}`); // First, we need to get the FID for the username const fid = await getFidByUsername(username); if (!fid) { return { content: [ { type: "text", text: `User "${username}" not found.` } ], isError: true }; } console.error(`Found FID ${fid} for username ${username}, fetching user data`); // Get user data to ensure we have the display name const userData = await getUserData(fid); // Use the display name if available, otherwise use the FID const displayName = userData.displayName || `FID: ${fid}`; console.error(`User data: displayName=${displayName}`); // Now get the casts for this FID const response = await fetchFromHubble(`/castsByFid`, { fid, pageSize: limit, reverse: 1 // Get newest first }) as FarcasterCastsResponse; if (!response.messages || response.messages.length === 0) { return { content: [ { type: "text", text: `No casts found for ${displayName} (FID: ${fid})` } ] }; } const castsText = await formatCasts(response.messages, limit); return { content: [ { type: "text", text: `# Casts from ${displayName}\n\n${castsText}` } ] }; } catch (error) { console.error("Error in get-username-casts:", error); return { content: [ { type: "text", text: `Error fetching casts by username: ${error instanceof Error ? error.message : String(error)}` } ], isError: true }; } } ); // Connect to transport const transport = new StdioServerTransport(); await server.connect(transport); console.error("Farcaster MCP Server running on stdio"); } catch (error) { console.error("Fatal error in main():", error); process.exit(1); } } main().catch((error) => { console.error("Unhandled error:", error); process.exit(1); });