#!/usr/bin/env python3
"""
MCP VRBO Server
Provides VRBO vacation rental search via browser automation (mcp-browser)
"""
import os
import json
import requests
from flask import Flask, request, jsonify
from flask_cors import CORS
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
CORS(app)
PORT = int(os.getenv('PORT', 3046))
BROWSER_MCP_URL = os.getenv('BROWSER_MCP_URL', 'https://mcp-browser.local.jbmurphy.com')
# Tool definitions
VRBO_SEARCH_TOOL = {
"name": "vrbo_search",
"description": "Search for VRBO vacation rental listings with filters. Returns properties with prices, ratings, and details.",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Location to search (city, state, etc.)"
},
"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 (default: 2)"
},
"children": {
"type": "number",
"description": "Number of children (default: 0)"
},
"min_bedrooms": {
"type": "number",
"description": "Minimum number of bedrooms"
},
"min_bathrooms": {
"type": "number",
"description": "Minimum number of bathrooms"
},
"min_price": {
"type": "number",
"description": "Minimum price per night"
},
"max_price": {
"type": "number",
"description": "Maximum price per night"
}
},
"required": ["location"]
}
}
VRBO_LISTING_DETAILS_TOOL = {
"name": "vrbo_listing_details",
"description": "Get detailed information about a specific VRBO listing by ID",
"inputSchema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The VRBO listing ID (e.g., '4515867')"
},
"checkin": {
"type": "string",
"description": "Check-in date for pricing (YYYY-MM-DD)"
},
"checkout": {
"type": "string",
"description": "Check-out date for pricing (YYYY-MM-DD)"
},
"adults": {
"type": "number",
"description": "Number of adults for pricing"
}
},
"required": ["id"]
}
}
VRBO_TOOLS = [VRBO_SEARCH_TOOL, VRBO_LISTING_DETAILS_TOOL]
def call_browser_tool(tool_name: str, arguments: dict) -> dict:
"""Call a tool on mcp-browser"""
try:
response = requests.post(
f"{BROWSER_MCP_URL}/mcp/call_tool",
json={"name": tool_name, "arguments": arguments},
timeout=120
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Browser tool call failed: {e}")
raise
def extract_vrbo_listings_js():
"""JavaScript code to extract VRBO listings from the page"""
return """
(() => {
const cards = document.querySelectorAll('[data-stid="lodging-card-responsive"]');
const properties = Array.from(cards).map((card, index) => {
const fullText = card.innerText;
const heading = card.querySelector('h3');
// Get ALL links in the card
const allLinks = Array.from(card.querySelectorAll('a')).map(a => a.href);
// Find the main property link (contains vrbo.com/ followed by digits)
const propertyLink = allLinks.find(href => /vrbo\\.com\\/\\d+/.test(href));
// Extract VRBO ID
const vrboIdMatch = propertyLink ? propertyLink.match(/vrbo\\.com\\/(\\d+)/) : null;
// Parse price from text like "$1,137"
const priceMatch = fullText.match(/\\$([\\d,]+)/);
// Parse rating from text like "9.4 out of 10"
const ratingMatch = fullText.match(/(\\d+\\.?\\d*)\\s*out of 10/);
// Parse reviews count
const reviewsMatch = fullText.match(/(\\d+)\\s*reviews/);
// Parse property type and details
const detailsMatch = fullText.match(/(Apartment|House|Condo|Villa|Cabin|Cottage|Townhouse).*?Sleeps\\s*(\\d+).*?(\\d+)\\s*bedrooms?.*?(\\d+\\+?)\\s*bathrooms?/i);
// Check for location info
const locationMatch = fullText.match(/Within\\s+([\\w\\s]+?)\\n/);
// Clean up the name
let name = heading ? heading.innerText : null;
if (name && name.startsWith('Photo gallery for ')) {
name = name.replace('Photo gallery for ', '');
}
return {
id: vrboIdMatch ? vrboIdMatch[1] : null,
name: name,
url: vrboIdMatch ? 'https://www.vrbo.com/' + vrboIdMatch[1] : null,
price: priceMatch ? '$' + priceMatch[1] : null,
priceNumeric: priceMatch ? parseInt(priceMatch[1].replace(',', '')) : null,
rating: ratingMatch ? parseFloat(ratingMatch[1]) : null,
reviews: reviewsMatch ? parseInt(reviewsMatch[1]) : null,
propertyType: detailsMatch ? detailsMatch[1] : null,
sleeps: detailsMatch ? parseInt(detailsMatch[2]) : null,
bedrooms: detailsMatch ? parseInt(detailsMatch[3]) : null,
bathrooms: detailsMatch ? detailsMatch[4] : null,
neighborhood: locationMatch ? locationMatch[1].trim() : null
};
}).filter(p => p.id !== null);
// Get total count from page
const countMatch = document.body.innerText.match(/(\\d+)\\+?\\s*properties/);
const totalCount = countMatch ? countMatch[1] : properties.length;
return {
totalAvailable: totalCount,
returnedCount: properties.length,
properties: properties
};
})()
"""
def extract_vrbo_listing_details_js():
"""JavaScript code to extract VRBO listing details from a property page"""
return """
(() => {
const result = {
id: null,
name: null,
description: null,
propertyType: null,
sleeps: null,
bedrooms: null,
bathrooms: null,
amenities: [],
location: null,
rating: null,
reviews: null,
host: null,
price: null,
policies: []
};
// Get title
const titleEl = document.querySelector('h1');
result.name = titleEl ? titleEl.innerText : null;
// Get ID from URL
const urlMatch = window.location.href.match(/vrbo\\.com\\/(\\d+)/);
result.id = urlMatch ? urlMatch[1] : null;
// Get description
const descEl = document.querySelector('[data-stid="content-hotel-description"]');
result.description = descEl ? descEl.innerText : null;
// Get property details
const detailsText = document.body.innerText;
const sleepsMatch = detailsText.match(/Sleeps\\s*(\\d+)/i);
result.sleeps = sleepsMatch ? parseInt(sleepsMatch[1]) : null;
const bedroomsMatch = detailsText.match(/(\\d+)\\s*bedrooms?/i);
result.bedrooms = bedroomsMatch ? parseInt(bedroomsMatch[1]) : null;
const bathroomsMatch = detailsText.match(/(\\d+\\+?)\\s*bathrooms?/i);
result.bathrooms = bathroomsMatch ? bathroomsMatch[1] : null;
// Get rating
const ratingMatch = detailsText.match(/(\\d+\\.?\\d*)\\s*out of 10/);
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : null;
const reviewsMatch = detailsText.match(/(\\d+)\\s*reviews/);
result.reviews = reviewsMatch ? parseInt(reviewsMatch[1]) : null;
// Get price
const priceMatch = detailsText.match(/\\$([\\d,]+)\\s*(?:per night|avg|total)/i);
result.price = priceMatch ? '$' + priceMatch[1] : null;
// Get amenities
const amenityEls = document.querySelectorAll('[data-stid="content-hotel-amenities"] li, [class*="amenity"] span');
result.amenities = Array.from(amenityEls).map(el => el.innerText).filter(t => t.length > 0 && t.length < 50).slice(0, 20);
// Get location
const locationEl = document.querySelector('[data-stid="content-hotel-address"]');
result.location = locationEl ? locationEl.innerText : null;
return result;
})()
"""
def handle_vrbo_search(params: dict) -> dict:
"""Handle VRBO search request"""
location = params.get('location')
checkin = params.get('checkin', '')
checkout = params.get('checkout', '')
adults = params.get('adults', 2)
children = params.get('children', 0)
min_bedrooms = params.get('min_bedrooms')
min_bathrooms = params.get('min_bathrooms')
min_price = params.get('min_price')
max_price = params.get('max_price')
# Build VRBO search URL
from urllib.parse import quote
search_url = f"https://www.vrbo.com/search?destination={quote(location)}&adults={adults}"
if children:
search_url += f"&children={children}"
if checkin:
search_url += f"&startDate={checkin}"
if checkout:
search_url += f"&endDate={checkout}"
if min_bedrooms:
search_url += f"&bedrooms={min_bedrooms}"
if min_bathrooms:
search_url += f"&bathrooms={min_bathrooms}"
if min_price:
search_url += f"&minPrice={min_price}"
if max_price:
search_url += f"&maxPrice={max_price}"
logger.info(f"Navigating to VRBO search: {search_url}")
try:
# Navigate to VRBO search page (mcp-browser uses tool names without 'browser_' prefix)
nav_result = call_browser_tool("navigate", {
"url": search_url,
"wait_until": "networkidle",
"timeout": 60000
})
# Wait a bit for dynamic content
call_browser_tool("wait_for_timeout", {"timeout": 2000})
# Extract listings using JavaScript
eval_result = call_browser_tool("evaluate", {
"script": extract_vrbo_listings_js()
})
# Parse result
if 'content' in eval_result and len(eval_result['content']) > 0:
content = eval_result['content'][0]
if 'text' in content:
data = json.loads(content['text'])
if 'result' in data:
return {
"searchUrl": search_url,
"location": location,
**data['result']
}
# If we got a direct result
if 'result' in eval_result:
return {
"searchUrl": search_url,
"location": location,
**eval_result['result']
}
return {
"error": "Failed to extract listings",
"searchUrl": search_url,
"rawResult": str(eval_result)[:500]
}
except Exception as e:
logger.error(f"VRBO search failed: {e}")
return {
"error": str(e),
"searchUrl": search_url
}
def handle_vrbo_listing_details(params: dict) -> dict:
"""Handle VRBO listing details request"""
listing_id = params.get('id')
checkin = params.get('checkin', '')
checkout = params.get('checkout', '')
adults = params.get('adults', 2)
# Build listing URL
listing_url = f"https://www.vrbo.com/{listing_id}"
if checkin or checkout or adults:
listing_url += "?"
if checkin:
listing_url += f"startDate={checkin}&"
if checkout:
listing_url += f"endDate={checkout}&"
if adults:
listing_url += f"adults={adults}&"
listing_url = listing_url.rstrip('&')
logger.info(f"Fetching VRBO listing: {listing_url}")
try:
# Navigate to listing page (mcp-browser uses tool names without 'browser_' prefix)
nav_result = call_browser_tool("navigate", {
"url": listing_url,
"wait_until": "networkidle",
"timeout": 60000
})
# Wait for content to load
call_browser_tool("wait_for_timeout", {"timeout": 2000})
# Extract listing details
eval_result = call_browser_tool("evaluate", {
"script": extract_vrbo_listing_details_js()
})
# Parse result
if 'content' in eval_result and len(eval_result['content']) > 0:
content = eval_result['content'][0]
if 'text' in content:
data = json.loads(content['text'])
if 'result' in data:
return {
"listingUrl": listing_url,
**data['result']
}
if 'result' in eval_result:
return {
"listingUrl": listing_url,
**eval_result['result']
}
return {
"error": "Failed to extract listing details",
"listingUrl": listing_url
}
except Exception as e:
logger.error(f"VRBO listing details failed: {e}")
return {
"error": str(e),
"listingUrl": listing_url
}
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({
"status": "healthy",
"service": "mcp-vrbo",
"browser_backend": BROWSER_MCP_URL
}), 200
@app.route('/mcp/list_tools', methods=['GET', 'POST'])
def list_tools():
"""List all available tools"""
return jsonify({"tools": VRBO_TOOLS}), 200
@app.route('/mcp/call_tool', methods=['POST'])
def call_tool():
"""Call a specific tool"""
try:
data = request.json
tool_name = data.get('name')
tool_args = data.get('arguments', {})
if not tool_name:
return jsonify({"error": "Tool name is required"}), 400
if tool_name == "vrbo_search":
result = handle_vrbo_search(tool_args)
elif tool_name == "vrbo_listing_details":
result = handle_vrbo_listing_details(tool_args)
else:
return jsonify({"error": f"Unknown tool: {tool_name}"}), 400
return jsonify({
"content": [{
"type": "text",
"text": json.dumps(result, indent=2)
}]
}), 200
except Exception as e:
logger.error(f"Error calling tool: {e}")
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
logger.info(f"Starting MCP VRBO Server on port {PORT}")
logger.info(f"Browser backend: {BROWSER_MCP_URL}")
app.run(host='0.0.0.0', port=PORT, debug=False)