Skip to main content
Glama

Airbnb Search & Listings

by ampcome-mcps
index.ts21.7 kB
#!/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"; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; // Get version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); function getVersion(): string { try { const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')); return process.env.MCP_SERVER_VERSION || packageJson.version || "unknown"; } catch (error) { return process.env.MCP_SERVER_VERSION || "unknown"; } } const VERSION = getVersion(); // 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"; // Configuration from environment variables (set by DXT host) const IGNORE_ROBOTS_TXT = process.env.IGNORE_ROBOTS_TXT === "true" || process.argv.slice(2).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 = ""; // Enhanced robots.txt fetch with timeout and error handling async function fetchRobotsTxt() { if (IGNORE_ROBOTS_TXT) { log('info', 'Skipping robots.txt fetch (ignored by configuration)'); return; } try { log('info', 'Fetching robots.txt from Airbnb'); // Add timeout to prevent hanging const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout const response = await fetch(`${BASE_URL}/robots.txt`, { headers: { "User-Agent": USER_AGENT, }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } robotsTxtContent = await response.text(); log('info', 'Successfully fetched robots.txt'); } catch (error) { log('warn', 'Error fetching robots.txt, assuming all paths allowed', { error: error instanceof Error ? error.message : String(error) }); robotsTxtContent = ""; // Empty robots.txt means everything is allowed } } function isPathAllowed(path: string): boolean { if (!robotsTxtContent) { return true; // If we couldn't fetch robots.txt, assume allowed } try { const robots = robotsParser(`${BASE_URL}/robots.txt`, robotsTxtContent); const allowed = robots.isAllowed(path, USER_AGENT); if (!allowed) { log('warn', 'Path disallowed by robots.txt', { path, userAgent: USER_AGENT }); } return allowed; } catch (error) { log('warn', 'Error parsing robots.txt, allowing path', { path, error: error instanceof Error ? error.message : String(error) }); return true; // If parsing fails, be permissive } } async function fetchWithUserAgent(url: string, timeout: number = 30000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Cache-Control": "no-cache", }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${timeout}ms`); } throw error; } } // 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)) { log('warn', 'Search blocked by robots.txt', { path, url: searchUrl.toString() }); return { content: [{ type: "text", text: JSON.stringify({ error: robotsErrorMessage, url: searchUrl.toString(), suggestion: "Consider enabling 'ignore_robots_txt' in extension settings if needed for testing" }, null, 2) }], isError: true }; } const allowSearchResultSchema: Record<string, any> = { demandStayListing : { id: true, description: true, location: true, }, badges: { text: 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 { log('info', 'Performing Airbnb search', { location, checkin, checkout, adults, children }); const response = await fetchWithUserAgent(searchUrl.toString()); const html = await response.text(); const $ = cheerio.load(html); let staysSearchResults: any = {}; try { const scriptElement = $("#data-deferred-state-0").first(); if (scriptElement.length === 0) { throw new Error("Could not find data script element - page structure may have changed"); } const scriptContent = $(scriptElement).text(); if (!scriptContent) { throw new Error("Data script element is empty"); } const clientData = JSON.parse(scriptContent).niobeClientData[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) => { const id = atob(result.demandStayListing.id).split(":")[1]; return {id, url: `${BASE_URL}/rooms/${id}`, ...result } }), paginationInfo: results.paginationInfo } log('info', 'Search completed successfully', { resultCount: staysSearchResults.searchResults?.length || 0 }); } catch (parseError) { log('error', 'Failed to parse search results', { error: parseError instanceof Error ? parseError.message : String(parseError), url: searchUrl.toString() }); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to parse search results from Airbnb. The page structure may have changed.", details: parseError instanceof Error ? parseError.message : String(parseError), searchUrl: searchUrl.toString() }, null, 2) }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify({ searchUrl: searchUrl.toString(), ...staysSearchResults }, null, 2) }], isError: false }; } catch (error) { log('error', 'Search request failed', { error: error instanceof Error ? error.message : String(error), url: searchUrl.toString() }); return { content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), searchUrl: searchUrl.toString(), timestamp: new Date().toISOString() }, 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)) { log('warn', 'Listing details blocked by robots.txt', { path, url: listingUrl.toString() }); return { content: [{ type: "text", text: JSON.stringify({ error: robotsErrorMessage, url: listingUrl.toString(), suggestion: "Consider enabling 'ignore_robots_txt' in extension settings if needed for testing" }, 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 { log('info', 'Fetching listing details', { id, checkin, checkout, adults, children }); 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(); if (scriptElement.length === 0) { throw new Error("Could not find data script element - page structure may have changed"); } const scriptContent = $(scriptElement).text(); if (!scriptContent) { throw new Error("Data script element is empty"); } const clientData = JSON.parse(scriptContent).niobeClientData[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])) } }); log('info', 'Listing details fetched successfully', { id, sectionsFound: Array.isArray(details) ? details.length : 0 }); } catch (parseError) { log('error', 'Failed to parse listing details', { error: parseError instanceof Error ? parseError.message : String(parseError), id, url: listingUrl.toString() }); return { content: [{ type: "text", text: JSON.stringify({ error: "Failed to parse listing details from Airbnb. The page structure may have changed.", details: parseError instanceof Error ? parseError.message : String(parseError), listingUrl: listingUrl.toString() }, null, 2) }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify({ listingUrl: listingUrl.toString(), details: details }, null, 2) }], isError: false }; } catch (error) { log('error', 'Listing details request failed', { error: error instanceof Error ? error.message : String(error), id, url: listingUrl.toString() }); return { content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), listingUrl: listingUrl.toString(), timestamp: new Date().toISOString() }, null, 2) }], isError: true }; } } // Server setup const server = new Server( { name: "airbnb", version: VERSION, }, { capabilities: { tools: {}, }, }, ); // Enhanced logging for DXT function log(level: 'info' | 'warn' | 'error', message: string, data?: any) { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; if (data) { console.error(`${logMessage}:`, JSON.stringify(data, null, 2)); } else { console.error(logMessage); } } log('info', 'Airbnb MCP Server starting', { version: VERSION, ignoreRobotsTxt: IGNORE_ROBOTS_TXT, nodeVersion: process.version, platform: process.platform }); // Set up request handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: AIRBNB_TOOLS, })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const startTime = Date.now(); try { // Validate request parameters if (!request.params.name) { throw new McpError(ErrorCode.InvalidParams, "Tool name is required"); } if (!request.params.arguments) { throw new McpError(ErrorCode.InvalidParams, "Tool arguments are required"); } log('info', 'Tool call received', { tool: request.params.name, arguments: request.params.arguments }); // Ensure robots.txt is loaded if (!robotsTxtContent && !IGNORE_ROBOTS_TXT) { await fetchRobotsTxt(); } let result; switch (request.params.name) { case "airbnb_search": { result = await handleAirbnbSearch(request.params.arguments); break; } case "airbnb_listing_details": { result = await handleAirbnbListingDetails(request.params.arguments); break; } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } const duration = Date.now() - startTime; log('info', 'Tool call completed', { tool: request.params.name, duration: `${duration}ms`, success: !result.isError }); return result; } catch (error) { const duration = Date.now() - startTime; log('error', 'Tool call failed', { tool: request.params.name, duration: `${duration}ms`, error: error instanceof Error ? error.message : String(error) }); if (error instanceof McpError) { throw error; } return { content: [{ type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString() }, null, 2) }], isError: true }; } }); async function runServer() { try { // Initialize robots.txt on startup await fetchRobotsTxt(); const transport = new StdioServerTransport(); await server.connect(transport); log('info', 'Airbnb MCP Server running on stdio', { version: VERSION, robotsRespected: !IGNORE_ROBOTS_TXT }); // Graceful shutdown handling process.on('SIGINT', () => { log('info', 'Received SIGINT, shutting down gracefully'); process.exit(0); }); process.on('SIGTERM', () => { log('info', 'Received SIGTERM, shutting down gracefully'); process.exit(0); }); } catch (error) { log('error', 'Failed to start server', { error: error instanceof Error ? error.message : String(error) }); process.exit(1); } } runServer().catch((error) => { log('error', 'Fatal error running server', { error: error instanceof Error ? error.message : String(error) }); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ampcome-mcps/airbnb-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server