# Directory Structure
```
├── .gitignore
├── Dockerfile
├── index.ts
├── LICENSE
├── manifest.json
├── package-lock.json
├── package.json
├── README.md
├── smithery.yaml
├── sync-version.js
├── test-extension.js
├── tsconfig.json
├── types.d.ts
└── util.ts
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
dist
node_modules
*.dxt
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Airbnb Search & Listings - Desktop Extension (DXT)
A comprehensive Desktop Extension for searching Airbnb listings with advanced filtering capabilities and detailed property information retrieval. Built as a Model Context Protocol (MCP) server packaged in the Desktop Extension (DXT) format for easy installation and use with compatible AI applications.
## Features
### 🔍 Advanced Search Capabilities
- **Location-based search** with support for cities, states, and regions
- **Google Maps Place ID** integration for precise location targeting
- **Date filtering** with check-in and check-out date support
- **Guest configuration** including adults, children, infants, and pets
- **Price range filtering** with minimum and maximum price constraints
- **Pagination support** for browsing through large result sets
### 🏠 Detailed Property Information
- **Comprehensive listing details** including amenities, policies, and highlights
- **Location information** with coordinates and neighborhood details
- **House rules and policies** for informed booking decisions
- **Property descriptions** and key features
- **Direct links** to Airbnb listings for easy booking
### 🛡️ Security & Compliance
- **Robots.txt compliance** with configurable override for testing
- **Request timeout management** to prevent hanging requests
- **Enhanced error handling** with detailed logging
- **Rate limiting awareness** and respectful API usage
- **Secure configuration** through DXT user settings
## Installation
### For Claude Desktop
This extension is packaged as a Desktop Extension (DXT) file. To install:
1. Download the `.dxt` file from the releases page
2. Open your compatible AI application (e.g., Claude Desktop)
3. Install the extension through the application's extension manager
4. Configure the extension settings as needed
### For Cursor, etc.
Before starting make sure [Node.js](https://nodejs.org/) is installed on your desktop for `npx` to work.
1. Go to: Cursor Settings > Tools & Integrations > New MCP Server
2. Add one the following to your `mcp.json`:
```json
{
"mcpServers": {
"airbnb": {
"command": "npx",
"args": [
"-y",
"@openbnb/mcp-server-airbnb"
]
}
}
}
```
To ignore robots.txt for all requests, use this version with `--ignore-robots-txt` args
```json
{
"mcpServers": {
"airbnb": {
"command": "npx",
"args": [
"-y",
"@openbnb/mcp-server-airbnb",
"--ignore-robots-txt"
]
}
}
}
```
3. Restart.
## Configuration
The extension provides the following user-configurable options:
### Ignore robots.txt
- **Type**: Boolean (checkbox)
- **Default**: `false`
- **Description**: Bypass robots.txt restrictions when making requests to Airbnb
- **Recommendation**: Keep disabled unless needed for testing purposes
## Tools
### `airbnb_search`
Search for Airbnb listings with comprehensive filtering options.
**Parameters:**
- `location` (required): Location to search (e.g., "San Francisco, CA")
- `placeId` (optional): Google Maps Place ID (overrides location)
- `checkin` (optional): Check-in date in YYYY-MM-DD format
- `checkout` (optional): Check-out date in YYYY-MM-DD format
- `adults` (optional): Number of adults (default: 1)
- `children` (optional): Number of children (default: 0)
- `infants` (optional): Number of infants (default: 0)
- `pets` (optional): Number of pets (default: 0)
- `minPrice` (optional): Minimum price per night
- `maxPrice` (optional): Maximum price per night
- `cursor` (optional): Pagination cursor for browsing results
- `ignoreRobotsText` (optional): Override robots.txt for this request
**Returns:**
- Search results with property details, pricing, and direct links
- Pagination information for browsing additional results
- Search URL for reference
### `airbnb_listing_details`
Get detailed information about a specific Airbnb listing.
**Parameters:**
- `id` (required): Airbnb listing ID
- `checkin` (optional): Check-in date in YYYY-MM-DD format
- `checkout` (optional): Check-out date in YYYY-MM-DD format
- `adults` (optional): Number of adults (default: 1)
- `children` (optional): Number of children (default: 0)
- `infants` (optional): Number of infants (default: 0)
- `pets` (optional): Number of pets (default: 0)
- `ignoreRobotsText` (optional): Override robots.txt for this request
**Returns:**
- Detailed property information including:
- Location details with coordinates
- Amenities and facilities
- House rules and policies
- Property highlights and descriptions
- Direct link to the listing
## Technical Details
### Architecture
- **Runtime**: Node.js 18+
- **Protocol**: Model Context Protocol (MCP) via stdio transport
- **Format**: Desktop Extension (DXT) v0.1
- **Dependencies**: Minimal external dependencies for security and reliability
### Error Handling
- Comprehensive error logging with timestamps
- Graceful degradation when Airbnb's page structure changes
- Timeout protection for network requests
- Detailed error messages for troubleshooting
### Security Measures
- Robots.txt compliance by default
- Request timeout limits
- Input validation and sanitization
- Secure environment variable handling
- No sensitive data storage
### Performance
- Efficient HTML parsing with Cheerio
- Request caching where appropriate
- Minimal memory footprint
- Fast startup and response times
## Compatibility
- **Platforms**: macOS, Windows, Linux
- **Node.js**: 18.0.0 or higher
- **Claude Desktop**: 0.10.0 or higher
- **Other MCP clients**: Compatible with any MCP-supporting application
## Development
### Building from Source
```bash
# Install dependencies
npm install
# Build the project
npm run build
# Watch for changes during development
npm run watch
```
### Testing
The extension can be tested by running the MCP server directly:
```bash
# Run with robots.txt compliance (default)
node dist/index.js
# Run with robots.txt ignored (for testing)
node dist/index.js --ignore-robots-txt
```
## Legal and Ethical Considerations
- **Respect Airbnb's Terms of Service**: This extension is for legitimate research and booking assistance
- **Robots.txt Compliance**: The extension respects robots.txt by default
- **Rate Limiting**: Be mindful of request frequency to avoid overwhelming Airbnb's servers
- **Data Usage**: Only extract publicly available information for legitimate purposes
## Support
- **Issues**: Report bugs and feature requests on [GitHub Issues](https://github.com/openbnb-org/mcp-server-airbnb/issues)
- **Documentation**: Additional documentation available in the repository
- **Community**: Join discussions about MCP and DXT development
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please read the contributing guidelines and submit pull requests for any improvements.
---
**Note**: This extension is not affiliated with Airbnb, Inc. It is an independent tool designed to help users search and analyze publicly available Airbnb listings.
```
--------------------------------------------------------------------------------
/types.d.ts:
--------------------------------------------------------------------------------
```typescript
declare module 'robots-parser' {
interface RobotsParser {
isAllowed(url: string, userAgent?: string): boolean;
}
function robotsParser(url: string, content: string): RobotsParser;
export default robotsParser;
}
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": ".",
"declaration": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
FROM node:lts-alpine
WORKDIR /app
# Copy package files and install dependencies without running prepare scripts
COPY package*.json ./
RUN npm install --ignore-scripts
# Copy rest of the source code
COPY . .
# Build the project explicitly
RUN npm run build
# Expose the MCP server on stdio
CMD [ "node", "dist/index.js" ]
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
startCommand:
type: stdio
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
properties:
ignoreRobotsTxt:
type: boolean
default: false
description: If set to true, the server will ignore robots.txt rules.
commandFunction:
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|-
(config) => { const args = ['dist/index.js']; if (config.ignoreRobotsTxt) { args.push('--ignore-robots-txt'); } return { command: 'node', args }; }
exampleConfig:
ignoreRobotsTxt: false
```
--------------------------------------------------------------------------------
/sync-version.js:
--------------------------------------------------------------------------------
```javascript
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Read package.json
const packageJsonPath = path.join(__dirname, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Read manifest.json
const manifestJsonPath = path.join(__dirname, 'manifest.json');
const manifestJson = JSON.parse(fs.readFileSync(manifestJsonPath, 'utf8'));
// Update manifest version to match package.json version
manifestJson.version = packageJson.version;
// Write updated manifest.json
fs.writeFileSync(manifestJsonPath, JSON.stringify(manifestJson, null, 2) + '\n');
console.log(`✅ Synced version to ${packageJson.version} in manifest.json`);
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@openbnb/mcp-server-airbnb",
"version": "0.1.3",
"description": "MCP server for Airbnb search and listing details",
"license": "MIT",
"type": "module",
"author": "OpenBnB (https://openbnb.org)",
"keywords": [
"airbnb",
"vacation rental",
"travel"
],
"publishConfig": {
"access": "public"
},
"bin": {
"mcp-server-airbnb": "dist/index.js"
},
"files": [
"dist",
"sync-version.js"
],
"scripts": {
"build": "node sync-version.js && tsc && shx chmod +x dist/*.js",
"prepare": "npm run build",
"watch": "tsc --watch",
"sync-version": "node sync-version.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.1",
"cheerio": "^1.0.0",
"node-fetch": "^3.3.2",
"robots-parser": "^3.0.1"
},
"devDependencies": {
"@types/node": "^22.13.9",
"@types/node-fetch": "^2.6.12",
"shx": "^0.3.4",
"typescript": "^5.8.2"
}
}
```
--------------------------------------------------------------------------------
/util.ts:
--------------------------------------------------------------------------------
```typescript
export function cleanObject(obj: any) {
Object.keys(obj).forEach(key => {
if (!obj[key] || key === "__typename") {
delete obj[key];
} else if (typeof obj[key] === "object") {
cleanObject(obj[key]);
}
});
}
export function pickBySchema(obj: any, schema: any): any {
if (typeof obj !== 'object' || obj === null) return obj;
// If the object is an array, process each item
if (Array.isArray(obj)) {
return obj.map(item => pickBySchema(item, schema));
}
const result: Record<string, any> = {};
for (const key in schema) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const rule = schema[key];
// If the rule is true, copy the value as-is
if (rule === true) {
result[key] = obj[key];
}
// If the rule is an object, apply the schema recursively
else if (typeof rule === 'object' && rule !== null) {
result[key] = pickBySchema(obj[key], rule);
}
}
}
return result;
}
export function flattenArraysInObject(input: any, inArray: boolean = false): any {
if (Array.isArray(input)) {
// Process each item in the array with inArray=true so that any object
// inside the array is flattened to a string.
const flatItems = input.map(item => flattenArraysInObject(item, true));
return flatItems.join(', ');
} else if (typeof input === 'object' && input !== null) {
if (inArray) {
// When inside an array, ignore the keys and flatten the object's values.
const values = Object.values(input).map(value => flattenArraysInObject(value, true));
return values.join(': ');
} else {
// When not in an array, process each property recursively.
const result: Record<string, any> = {};
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
result[key] = flattenArraysInObject(input[key], false);
}
}
return result;
}
} else {
// For primitives, simply return the value.
return input;
}
}
```
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
```json
{
"dxt_version": "0.1",
"name": "airbnb-search",
"display_name": "Airbnb Search & Listings",
"version": "0.1.3",
"description": "Search Airbnb listings with advanced filtering and get detailed property information",
"long_description": "A comprehensive Desktop Extension for searching Airbnb listings with various filters including location, dates, guest count, price range, and more. Get detailed information about specific properties including amenities, policies, location details, and highlights. Respects robots.txt by default with option to override for testing purposes.",
"author": {
"name": "OpenBnB",
"email": "[email protected]",
"url": "https://www.openbnb.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/openbnb-org/mcp-server-airbnb"
},
"homepage": "https://github.com/openbnb-org/mcp-server-airbnb",
"documentation": "https://github.com/openbnb-org/mcp-server-airbnb#readme",
"support": "https://github.com/openbnb-org/mcp-server-airbnb/issues",
"license": "MIT",
"keywords": [
"airbnb",
"vacation rental",
"travel"
],
"server": {
"type": "node",
"entry_point": "dist/index.js",
"mcp_config": {
"command": "node",
"args": [
"${__dirname}/dist/index.js"
],
"env": {
"NODE_ENV": "production",
"IGNORE_ROBOTS_TXT": "${user_config.ignore_robots_txt}"
}
}
},
"tools": [
{
"name": "airbnb_search",
"description": "Search for Airbnb listings with various filters including location, dates, guests, and price range. Returns paginated results with direct links."
},
{
"name": "airbnb_listing_details",
"description": "Get detailed information about a specific Airbnb listing including amenities, policies, location details, and highlights."
}
],
"tools_generated": false,
"prompts_generated": false,
"compatibility": {
"claude_desktop": ">=0.10.0",
"platforms": [
"darwin",
"win32",
"linux"
],
"runtimes": {
"node": ">=18.0.0"
}
},
"user_config": {
"ignore_robots_txt": {
"type": "boolean",
"title": "Ignore robots.txt",
"description": "Bypass robots.txt restrictions when making requests to Airbnb. Use with caution and respect Airbnb's terms of service.",
"default": false,
"required": false
}
}
}
```
--------------------------------------------------------------------------------
/test-extension.js:
--------------------------------------------------------------------------------
```javascript
#!/usr/bin/env node
/**
* Simple test script for the Airbnb DXT extension
* This script validates that the MCP server responds correctly to tool calls
*/
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Test configuration
const TEST_TIMEOUT = 30000; // 30 seconds
const SERVER_PATH = join(__dirname, 'dist', 'index.js');
class MCPTester {
constructor() {
this.server = null;
this.requestId = 1;
}
async startServer() {
console.log('🚀 Starting MCP server...');
this.server = spawn('node', [SERVER_PATH, '--ignore-robots-txt'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, IGNORE_ROBOTS_TXT: 'true' }
});
this.server.stderr.on('data', (data) => {
console.log('📋 Server log:', data.toString().trim());
});
// Wait for server to start
await new Promise(resolve => setTimeout(resolve, 2000));
if (this.server.killed) {
throw new Error('Server failed to start');
}
console.log('✅ Server started successfully');
}
async sendRequest(method, params = {}) {
return new Promise((resolve, reject) => {
const request = {
jsonrpc: '2.0',
id: this.requestId++,
method,
params
};
const timeout = setTimeout(() => {
reject(new Error(`Request timeout after ${TEST_TIMEOUT}ms`));
}, TEST_TIMEOUT);
let responseData = '';
const onData = (data) => {
responseData += data.toString();
// Check if we have a complete JSON response
try {
const lines = responseData.split('\n').filter(line => line.trim());
for (const line of lines) {
const response = JSON.parse(line);
if (response.id === request.id) {
clearTimeout(timeout);
this.server.stdout.off('data', onData);
resolve(response);
return;
}
}
} catch (e) {
// Not a complete JSON yet, continue waiting
}
};
this.server.stdout.on('data', onData);
console.log(`📤 Sending request: ${method}`);
this.server.stdin.write(JSON.stringify(request) + '\n');
});
}
async testListTools() {
console.log('\n🔧 Testing list_tools...');
try {
const response = await this.sendRequest('tools/list');
if (response.error) {
throw new Error(`Server error: ${response.error.message}`);
}
const tools = response.result?.tools || [];
console.log(`✅ Found ${tools.length} tools:`);
tools.forEach(tool => {
console.log(` - ${tool.name}: ${tool.description}`);
});
// Validate expected tools
const expectedTools = ['airbnb_search', 'airbnb_listing_details'];
const foundTools = tools.map(t => t.name);
for (const expectedTool of expectedTools) {
if (!foundTools.includes(expectedTool)) {
throw new Error(`Missing expected tool: ${expectedTool}`);
}
}
return true;
} catch (error) {
console.error('❌ list_tools test failed:', error.message);
return false;
}
}
async testSearchTool() {
console.log('\n🔍 Testing airbnb_search tool...');
try {
const response = await this.sendRequest('tools/call', {
name: 'airbnb_search',
arguments: {
location: 'San Francisco, CA',
adults: 2,
ignoreRobotsText: true
}
});
if (response.error) {
throw new Error(`Server error: ${response.error.message}`);
}
const result = response.result;
if (!result || !result.content || !result.content[0]) {
throw new Error('Invalid response format');
}
const content = JSON.parse(result.content[0].text);
if (content.error) {
console.log('⚠️ Search returned error (expected for robots.txt):', content.error);
return true; // This is expected behavior
}
if (content.searchResults) {
console.log(`✅ Search successful, found ${content.searchResults.length} results`);
if (content.searchResults.length > 0) {
console.log(` First result: ${content.searchResults[0].id}`);
}
}
return true;
} catch (error) {
console.error('❌ airbnb_search test failed:', error.message);
return false;
}
}
async testListingDetailsTool() {
console.log('\n🏠 Testing airbnb_listing_details tool...');
try {
const response = await this.sendRequest('tools/call', {
name: 'airbnb_listing_details',
arguments: {
id: '670214003022775198',
ignoreRobotsText: true
}
});
if (response.error) {
throw new Error(`Server error: ${response.error.message}`);
}
const result = response.result;
if (!result || !result.content || !result.content[0]) {
throw new Error('Invalid response format');
}
const content = JSON.parse(result.content[0].text);
if (content.error) {
console.log('⚠️ Listing details returned error (expected for dummy ID):', content.error);
return true; // This is expected behavior
}
console.log('✅ Listing details tool responded correctly');
return true;
} catch (error) {
console.error('❌ airbnb_listing_details test failed:', error.message);
return false;
}
}
async stopServer() {
if (this.server && !this.server.killed) {
console.log('\n🛑 Stopping server...');
this.server.kill('SIGTERM');
// Wait for graceful shutdown
await new Promise(resolve => {
this.server.on('exit', resolve);
setTimeout(() => {
if (!this.server.killed) {
this.server.kill('SIGKILL');
}
resolve();
}, 5000);
});
console.log('✅ Server stopped');
}
}
async runTests() {
let allPassed = true;
try {
await this.startServer();
// Run all tests
const tests = [
() => this.testListTools(),
() => this.testSearchTool(),
() => this.testListingDetailsTool()
];
for (const test of tests) {
const passed = await test();
allPassed = allPassed && passed;
}
} catch (error) {
console.error('❌ Test suite failed:', error.message);
allPassed = false;
} finally {
await this.stopServer();
}
console.log('\n' + '='.repeat(50));
if (allPassed) {
console.log('🎉 All tests passed! Extension is ready for use.');
} else {
console.log('❌ Some tests failed. Please check the issues above.');
process.exit(1);
}
}
}
// Run tests if this script is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
const tester = new MCPTester();
tester.runTests().catch(error => {
console.error('💥 Test runner crashed:', error);
process.exit(1);
});
}
export default MCPTester;
```
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/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);
});
```