mcp-server-airbnb

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, McpError, ErrorCode, } from "@modelcontextprotocol/sdk/types.js"; import fetch from "node-fetch"; import * as cheerio from "cheerio"; import { cleanObject, flattenArraysInObject, pickBySchema } from "./util.js"; import robotsParser from "robots-parser"; // Tool definitions const AIRBNB_SEARCH_TOOL: Tool = { name: "airbnb_search", description: "Search for Airbnb listings with various filters and pagination. Provide direct links to the user", inputSchema: { type: "object", properties: { location: { type: "string", description: "Location to search for (city, state, etc.)" }, placeId: { type: "string", description: "Google Maps Place ID (overrides the location parameter)" }, checkin: { type: "string", description: "Check-in date (YYYY-MM-DD)" }, checkout: { type: "string", description: "Check-out date (YYYY-MM-DD)" }, adults: { type: "number", description: "Number of adults" }, children: { type: "number", description: "Number of children" }, infants: { type: "number", description: "Number of infants" }, pets: { type: "number", description: "Number of pets" }, minPrice: { type: "number", description: "Minimum price for the stay" }, maxPrice: { type: "number", description: "Maximum price for the stay" }, cursor: { type: "string", description: "Base64-encoded string used for Pagination" }, ignoreRobotsText: { type: "boolean", description: "Ignore robots.txt rules for this request" } }, required: ["location"] } }; const AIRBNB_LISTING_DETAILS_TOOL: Tool = { name: "airbnb_listing_details", description: "Get detailed information about a specific Airbnb listing. Provide direct links to the user", inputSchema: { type: "object", properties: { id: { type: "string", description: "The Airbnb listing ID" }, checkin: { type: "string", description: "Check-in date (YYYY-MM-DD)" }, checkout: { type: "string", description: "Check-out date (YYYY-MM-DD)" }, adults: { type: "number", description: "Number of adults" }, children: { type: "number", description: "Number of children" }, infants: { type: "number", description: "Number of infants" }, pets: { type: "number", description: "Number of pets" }, ignoreRobotsText: { type: "boolean", description: "Ignore robots.txt rules for this request" } }, required: ["id"] } }; const AIRBNB_TOOLS = [ AIRBNB_SEARCH_TOOL, AIRBNB_LISTING_DETAILS_TOOL, ] as const; // Utility functions const USER_AGENT = "ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)"; const BASE_URL = "https://www.airbnb.com"; const args = process.argv.slice(2); const IGNORE_ROBOTS_TXT = args.includes("--ignore-robots-txt"); const robotsErrorMessage = "This path is disallowed by Airbnb's robots.txt to this User-agent. You may or may not want to run the server with '--ignore-robots-txt' args" let robotsTxtContent = ""; // Simple robots.txt fetch async function fetchRobotsTxt() { if (IGNORE_ROBOTS_TXT) { return; } try { const response = await fetchWithUserAgent(`${BASE_URL}/robots.txt`); robotsTxtContent = await response.text(); } catch (error) { console.error("Error fetching robots.txt:", error); robotsTxtContent = ""; // Empty robots.txt means everything is allowed } } function isPathAllowed(path: string) { if (!robotsTxtContent) { return true; // If we couldn't fetch robots.txt, assume allowed } const robots = robotsParser(path, robotsTxtContent); if (!robots.isAllowed(path, USER_AGENT)) { console.error(robotsErrorMessage); return false; } return true; } async function fetchWithUserAgent(url: string) { return fetch(url, { headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9", }, }); } // API handlers async function handleAirbnbSearch(params: any) { const { location, placeId, checkin, checkout, adults = 1, children = 0, infants = 0, pets = 0, minPrice, maxPrice, cursor, ignoreRobotsText = false, } = params; // Build search URL const searchUrl = new URL(`${BASE_URL}/s/${encodeURIComponent(location)}/homes`); // Add placeId if (placeId) searchUrl.searchParams.append("place_id", placeId); // Add query parameters if (checkin) searchUrl.searchParams.append("checkin", checkin); if (checkout) searchUrl.searchParams.append("checkout", checkout); // Add guests const adults_int = parseInt(adults.toString()); const children_int = parseInt(children.toString()); const infants_int = parseInt(infants.toString()); const pets_int = parseInt(pets.toString()); const totalGuests = adults_int + children_int; if (totalGuests > 0) { searchUrl.searchParams.append("adults", adults_int.toString()); searchUrl.searchParams.append("children", children_int.toString()); searchUrl.searchParams.append("infants", infants_int.toString()); searchUrl.searchParams.append("pets", pets_int.toString()); } // Add price range if (minPrice) searchUrl.searchParams.append("price_min", minPrice.toString()); if (maxPrice) searchUrl.searchParams.append("price_max", maxPrice.toString()); // Add room type // if (roomType) { // const roomTypeParam = roomType.toLowerCase().replace(/\s+/g, '_'); // searchUrl.searchParams.append("room_types[]", roomTypeParam); // } // Add cursor for pagination if (cursor) { searchUrl.searchParams.append("cursor", cursor); } // Check if path is allowed by robots.txt const path = searchUrl.pathname + searchUrl.search; if (!ignoreRobotsText && !isPathAllowed(path)) { return { content: [{ type: "text", text: JSON.stringify({ error: robotsErrorMessage, url: searchUrl.toString() }, null, 2) }], isError: true }; } const allowSearchResultSchema: Record<string, any> = { listing : { id: true, name: true, title: true, coordinate: true, structuredContent: { mapCategoryInfo: { body: true }, mapSecondaryLine: { body: true }, primaryLine: { body: true }, secondaryLine: { body: true }, } }, avgRatingA11yLabel: true, listingParamOverrides: true, structuredDisplayPrice: { primaryLine: { accessibilityLabel: true, }, secondaryLine: { accessibilityLabel: true, }, explanationData: { title: true, priceDetails: { items: { description: true, priceString: true } } } }, // contextualPictures: { // picture: true // } }; try { const response = await fetchWithUserAgent(searchUrl.toString()); const html = await response.text(); const $ = cheerio.load(html); let staysSearchResults = {}; try { const scriptElement = $("#data-deferred-state-0").first(); const clientData = JSON.parse($(scriptElement).text()).niobeMinimalClientData[0][1]; const results = clientData.data.presentation.staysSearch.results; cleanObject(results); staysSearchResults = { searchResults: results.searchResults .map((result: any) => flattenArraysInObject(pickBySchema(result, allowSearchResultSchema))) .map((result: any) => { return {url: `${BASE_URL}/rooms/${result.listing.id}`, ...result }}), paginationInfo: results.paginationInfo } } catch (e) { console.error(e); } return { content: [{ type: "text", text: JSON.stringify({ searchUrl: searchUrl.toString(), ...staysSearchResults }, null, 2) }], isError: false }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), searchUrl: searchUrl.toString() }, null, 2) }], isError: true }; } } async function handleAirbnbListingDetails(params: any) { const { id, checkin, checkout, adults = 1, children = 0, infants = 0, pets = 0, ignoreRobotsText = false, } = params; // Build listing URL const listingUrl = new URL(`${BASE_URL}/rooms/${id}`); // Add query parameters if (checkin) listingUrl.searchParams.append("check_in", checkin); if (checkout) listingUrl.searchParams.append("check_out", checkout); // Add guests const adults_int = parseInt(adults.toString()); const children_int = parseInt(children.toString()); const infants_int = parseInt(infants.toString()); const pets_int = parseInt(pets.toString()); const totalGuests = adults_int + children_int; if (totalGuests > 0) { listingUrl.searchParams.append("adults", adults_int.toString()); listingUrl.searchParams.append("children", children_int.toString()); listingUrl.searchParams.append("infants", infants_int.toString()); listingUrl.searchParams.append("pets", pets_int.toString()); } // Check if path is allowed by robots.txt const path = listingUrl.pathname + listingUrl.search; if (!ignoreRobotsText && !isPathAllowed(path)) { return { content: [{ type: "text", text: JSON.stringify({ error: robotsErrorMessage, url: listingUrl.toString() }, null, 2) }], isError: true }; } const allowSectionSchema: Record<string, any> = { "LOCATION_DEFAULT": { lat: true, lng: true, subtitle: true, title: true }, "POLICIES_DEFAULT": { title: true, houseRulesSections: { title: true, items : { title: true } } }, "HIGHLIGHTS_DEFAULT": { highlights: { title: true } }, "DESCRIPTION_DEFAULT": { htmlDescription: { htmlText: true } }, "AMENITIES_DEFAULT": { title: true, seeAllAmenitiesGroups: { title: true, amenities: { title: true } } }, //"AVAILABLITY_CALENDAR_DEFAULT": true, }; try { const response = await fetchWithUserAgent(listingUrl.toString()); const html = await response.text(); const $ = cheerio.load(html); let details = {}; try { const scriptElement = $("#data-deferred-state-0").first(); const clientData = JSON.parse($(scriptElement).text()).niobeMinimalClientData[0][1]; const sections = clientData.data.presentation.stayProductDetailPage.sections.sections; sections.forEach((section: any) => cleanObject(section)); details = sections .filter((section: any) => allowSectionSchema.hasOwnProperty(section.sectionId)) .map((section: any) => { return { id: section.sectionId, ...flattenArraysInObject(pickBySchema(section.section, allowSectionSchema[section.sectionId])) } }); } catch (e) { console.error(e); } return { content: [{ type: "text", text: JSON.stringify({ listingUrl: listingUrl.toString(), details: details }, null, 2) }], isError: false }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), listingUrl: listingUrl.toString() }, null, 2) }], isError: true }; } } // Server setup const server = new Server( { name: "airbnb", version: "0.1.0", }, { capabilities: { tools: {}, }, }, ); console.error( `Server started with options: ${IGNORE_ROBOTS_TXT ? "ignore-robots-txt" : "respect-robots-txt"}` ); // Set up request handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: AIRBNB_TOOLS, })); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { // Ensure robots.txt is loaded if (!robotsTxtContent) { await fetchRobotsTxt(); } switch (request.params.name) { case "airbnb_search": { return await handleAirbnbSearch(request.params.arguments); } case "airbnb_listing_details": { return await handleAirbnbListingDetails(request.params.arguments); } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } }); async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Airbnb MCP Server running on stdio"); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); });