diff --git a/README.md b/README.md index 401e2bf..45e4881 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,17 @@ This project provides a comprehensive Python interface to the Israeli government The project includes an MCP (Model Context Protocol) server that allows AI agents to access Israeli real estate data. The server provides multiple deployment options: -#### 1. Interactive Demo Server +#### 1. FastMCP Server (Recommended) + +**NEW: Using FastMCP for better compatibility and reliability** + +```bash +python run_fastmcp_server.py +``` + +This starts the FastMCP server which resolves compatibility issues with the standard MCP library. + +#### 2. Interactive Demo Server For testing and demonstration purposes: @@ -76,7 +86,7 @@ python simple_mcp_server.py This runs an interactive demo where you can test the tools directly in the terminal. -#### 2. Full MCP Server +#### 3. Full MCP Server (Legacy) For production use with MCP clients: @@ -86,22 +96,36 @@ python run_mcp_server.py This starts the full MCP server that can be connected to by MCP-compatible clients. -#### 3. Direct Server Module +#### 4. Direct Server Module You can also run the server directly: ```bash -python -m nadlan_mcp.mcp_server +python -m nadlan_mcp.simple_fastmcp_server ``` #### MCP Client Configuration To connect to the server from MCP clients, use the following configuration: -**For Claude Desktop or other MCP clients:** +**For Claude Desktop or other MCP clients (FastMCP - Recommended):** Add to your MCP client configuration: +```json +{ + "servers": { + "nadlan-mcp": { + "command": "python", + "args": ["/path/to/nadlan-mcp/run_fastmcp_server.py"], + "env": {} + } + } +} +``` + +**Alternative (Legacy MCP Server):** + ```json { "servers": { @@ -114,14 +138,14 @@ Add to your MCP client configuration: } ``` -**For development with stdio transport:** +**For development with stdio transport (FastMCP):** ```python import asyncio from mcp.client.stdio import stdio_client async def main(): - async with stdio_client(["python", "run_mcp_server.py"]) as client: + async with stdio_client(["python", "run_fastmcp_server.py"]) as client: # List available tools result = await client.list_tools() print("Available tools:", result.tools) @@ -138,7 +162,14 @@ asyncio.run(main()) #### Available MCP Tools -The server provides these tools for AI agents: +**FastMCP Server provides these 5 tools:** +- 🏠 `find_recent_deals_for_address` - Main comprehensive analysis tool +- 📊 `get_deals_by_radius` - Find deals within a radius of coordinates +- 🏘️ `get_street_deals` - Get deals for a specific street polygon +- 🔍 `autocomplete_address` - Address search and validation +- 📈 `compare_addresses` - Compare multiple addresses + +**Legacy MCP Server provides these 4 tools:** ##### 🏠 `find_recent_deals_for_address` **Main comprehensive analysis tool** diff --git a/nadlan_mcp/fastmcp_server.py b/nadlan_mcp/fastmcp_server.py new file mode 100644 index 0000000..933d481 --- /dev/null +++ b/nadlan_mcp/fastmcp_server.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +""" +FastMCP Server for Israeli Real Estate Data (Nadlan) + +This server provides access to Israeli government real estate data through the Govmap API +using the FastMCP library for better compatibility and reliability. +""" + +import logging +from typing import List, Dict, Any, Optional +from mcp.server.fastmcp import FastMCP +from .main import GovmapClient # type: ignore + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize FastMCP server +mcp = FastMCP("nadlan-mcp") + +# Initialize the Govmap client +client = GovmapClient() + +@mcp.tool() +async def autocomplete_address(search_text: str) -> str: + """Search and autocomplete Israeli addresses. + + Args: + search_text: The partial address to search for (in Hebrew or English) + + Returns: + JSON string containing matching addresses with their coordinates + """ + try: + response = client.autocomplete_address(search_text) + + if not response or 'results' not in response: + return f"No addresses found for '{search_text}'" + + # Format results for better readability + formatted_results = [] + for result in response['results']: + formatted_results.append({ + "address": result.get("addressLabel", ""), + "settlement": result.get("settlementNameHeb", ""), + "coordinates": result.get("coordinates", {}), + "polygon_id": result.get("polygon_id") + }) + + import json + return json.dumps(formatted_results, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in autocomplete_address: {e}") + return f"Error searching for address: {str(e)}" + +@mcp.tool() +async def get_deals_by_radius( + latitude: float, + longitude: float, + radius_meters: int = 500, + limit: int = 100 +) -> str: + """Get real estate deals within a radius of coordinates. + + Args: + latitude: Latitude coordinate + longitude: Longitude coordinate + radius_meters: Search radius in meters (default: 500) + limit: Maximum number of deals to return (default: 100) + + Returns: + JSON string containing recent real estate deals in the area + """ + try: + deals = client.get_deals_by_radius((longitude, latitude), radius_meters) + + if not deals: + return f"No deals found within {radius_meters}m of coordinates ({latitude}, {longitude})" + + # Format deals for better readability + formatted_deals = [] + for deal in deals: + formatted_deals.append({ + "address": deal.get("addressLabel", ""), + "settlement": deal.get("settlementNameHeb", ""), + "deal_amount": deal.get("dealAmount"), + "deal_date": deal.get("dealDate", ""), + "asset_area": deal.get("assetArea"), + "asset_type": deal.get("assetTypeHeb", ""), + "coordinates": deal.get("coordinates", {}) + }) + + import json + return json.dumps({ + "total_deals": len(formatted_deals), + "search_radius_meters": radius_meters, + "center_coordinates": {"latitude": latitude, "longitude": longitude}, + "deals": formatted_deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_deals_by_radius: {e}") + return f"Error fetching deals by radius: {str(e)}" + +@mcp.tool() +async def get_street_deals(street_name: str, settlement_name: str, limit: int = 100) -> str: + """Get real estate deals for a specific street. + + Args: + street_name: Name of the street (in Hebrew) + settlement_name: Name of the city/settlement (in Hebrew) + limit: Maximum number of deals to return (default: 100) + + Returns: + JSON string containing recent real estate deals on the specified street + """ + try: + deals = await client.get_street_deals(street_name, settlement_name, limit) + + if not deals: + return f"No deals found for {street_name}, {settlement_name}" + + # Format deals for better readability + formatted_deals = [] + for deal in deals: + formatted_deals.append({ + "address": deal.get("addressLabel", ""), + "settlement": deal.get("settlementNameHeb", ""), + "deal_amount": deal.get("dealAmount"), + "deal_date": deal.get("dealDate", ""), + "asset_area": deal.get("assetArea"), + "asset_type": deal.get("assetTypeHeb", ""), + "coordinates": deal.get("coordinates", {}) + }) + + import json + return json.dumps({ + "total_deals": len(formatted_deals), + "street": street_name, + "settlement": settlement_name, + "deals": formatted_deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_street_deals: {e}") + return f"Error fetching street deals: {str(e)}" + +@mcp.tool() +async def get_neighborhood_deals(polygon_id: str, limit: int = 100) -> str: + """Get real estate deals for a specific neighborhood polygon. + + Args: + polygon_id: The polygon ID of the neighborhood + limit: Maximum number of deals to return (default: 100) + + Returns: + JSON string containing recent real estate deals in the specified neighborhood + """ + try: + deals = await client.get_neighborhood_deals(polygon_id, limit) + + if not deals: + return f"No deals found for polygon ID {polygon_id}" + + # Format deals for better readability + formatted_deals = [] + for deal in deals: + formatted_deals.append({ + "address": deal.get("addressLabel", ""), + "settlement": deal.get("settlementNameHeb", ""), + "deal_amount": deal.get("dealAmount"), + "deal_date": deal.get("dealDate", ""), + "asset_area": deal.get("assetArea"), + "asset_type": deal.get("assetTypeHeb", ""), + "coordinates": deal.get("coordinates", {}) + }) + + import json + return json.dumps({ + "total_deals": len(formatted_deals), + "polygon_id": polygon_id, + "deals": formatted_deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_neighborhood_deals: {e}") + return f"Error fetching neighborhood deals: {str(e)}" + +@mcp.tool() +async def find_recent_deals_for_address( + address: str, + radius_meters: int = 500, + limit: int = 100 +) -> str: + """Comprehensive analysis of recent real estate deals for an address. + + This is the main tool for getting detailed market analysis around a specific address. + It finds the coordinates for the address and then searches for deals in the area. + + Args: + address: The address to analyze (in Hebrew or English) + radius_meters: Search radius in meters (default: 500) + limit: Maximum number of deals to return (default: 100) + + Returns: + JSON string containing comprehensive real estate analysis including: + - Address details and coordinates + - Recent deals in the area + - Market statistics and trends + """ + try: + deals = await client.find_recent_deals_for_address(address, radius_meters, limit) + + if not deals: + return f"No deals found for address '{address}'" + + # Calculate market statistics + prices = [deal.get("dealAmount", 0) for deal in deals if deal.get("dealAmount")] + areas = [deal.get("assetArea", 0) for deal in deals if deal.get("assetArea")] + + stats = {} + if prices: + stats["price_stats"] = { + "average_price": sum(prices) / len(prices), + "min_price": min(prices), + "max_price": max(prices), + "total_deals": len(prices) + } + + if areas: + stats["area_stats"] = { + "average_area": sum(areas) / len(areas), + "min_area": min(areas), + "max_area": max(areas) + } + + # Format deals for better readability + formatted_deals = [] + for deal in deals: + formatted_deals.append({ + "address": deal.get("addressLabel", ""), + "settlement": deal.get("settlementNameHeb", ""), + "deal_amount": deal.get("dealAmount"), + "deal_date": deal.get("dealDate", ""), + "asset_area": deal.get("assetArea"), + "asset_type": deal.get("assetTypeHeb", ""), + "coordinates": deal.get("coordinates", {}) + }) + + import json + return json.dumps({ + "search_address": address, + "search_radius_meters": radius_meters, + "total_deals": len(formatted_deals), + "market_statistics": stats, + "deals": formatted_deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in find_recent_deals_for_address: {e}") + return f"Error analyzing address: {str(e)}" + +@mcp.tool() +async def analyze_market_trends( + address: str, + radius_meters: int = 1000, + limit: int = 200 +) -> str: + """Analyze market trends and price patterns for an area. + + Args: + address: The address to analyze trends around + radius_meters: Search radius in meters (default: 1000) + limit: Maximum number of deals to analyze (default: 200) + + Returns: + JSON string containing market trend analysis including: + - Price trends over time + - Average prices by property type + - Market activity levels + """ + try: + # Get deals for the address + deals = await client.find_recent_deals_for_address(address, radius_meters, limit) + + if not deals: + return f"No deals found for market analysis near '{address}'" + + # Analyze trends by year + from collections import defaultdict + import json + + trends_by_year = defaultdict(list) + trends_by_type = defaultdict(list) + + for deal in deals: + deal_date = deal.get("dealDate", "") + deal_amount = deal.get("dealAmount", 0) + asset_type = deal.get("assetTypeHeb", "Unknown") + + if deal_date and deal_amount: + year = deal_date.split('-')[0] if '-' in deal_date else deal_date[:4] + trends_by_year[year].append(deal_amount) + trends_by_type[asset_type].append(deal_amount) + + # Calculate yearly trends + yearly_trends = {} + for year, prices in trends_by_year.items(): + yearly_trends[year] = { + "average_price": sum(prices) / len(prices), + "min_price": min(prices), + "max_price": max(prices), + "deal_count": len(prices) + } + + # Calculate type trends + type_trends = {} + for asset_type, prices in trends_by_type.items(): + type_trends[asset_type] = { + "average_price": sum(prices) / len(prices), + "min_price": min(prices), + "max_price": max(prices), + "deal_count": len(prices) + } + + return json.dumps({ + "analysis_address": address, + "analysis_radius_meters": radius_meters, + "total_deals_analyzed": len(deals), + "yearly_trends": yearly_trends, + "property_type_trends": type_trends + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in analyze_market_trends: {e}") + return f"Error analyzing market trends: {str(e)}" + +@mcp.tool() +async def compare_neighborhoods(addresses: List[str], radius_meters: int = 500) -> str: + """Compare real estate markets between multiple neighborhoods. + + Args: + addresses: List of addresses to compare (in Hebrew or English) + radius_meters: Search radius for each address (default: 500) + + Returns: + JSON string containing comparative analysis of multiple neighborhoods + """ + try: + import json + + comparisons = [] + + for address in addresses: + try: + deals = await client.find_recent_deals_for_address(address, radius_meters, 100) + + if deals: + prices = [] + areas = [] + for deal in deals: + if isinstance(deal, dict): + if deal.get("dealAmount"): + prices.append(deal.get("dealAmount", 0)) + if deal.get("assetArea"): + areas.append(deal.get("assetArea", 0)) + + comparison = { + "address": address, + "total_deals": len(deals), + "price_stats": { + "average_price": sum(prices) / len(prices) if prices else 0, + "min_price": min(prices) if prices else 0, + "max_price": max(prices) if prices else 0 + }, + "area_stats": { + "average_area": sum(areas) / len(areas) if areas else 0, + "min_area": min(areas) if areas else 0, + "max_area": max(areas) if areas else 0 + } + } + else: + comparison = { + "address": address, + "total_deals": 0, + "price_stats": {}, + "area_stats": {} + } + + comparisons.append(comparison) + + except Exception as e: + logger.error(f"Error comparing {address}: {e}") + comparisons.append({ + "address": address, + "error": str(e) + }) + + # Rank neighborhoods by average price + valid_comparisons = [c for c in comparisons if c.get("price_stats", {}).get("average_price", 0) > 0] + valid_comparisons.sort(key=lambda x: x["price_stats"]["average_price"], reverse=True) + + return json.dumps({ + "comparison_radius_meters": radius_meters, + "neighborhoods_compared": len(addresses), + "ranking_by_average_price": valid_comparisons, + "all_results": comparisons + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in compare_neighborhoods: {e}") + return f"Error comparing neighborhoods: {str(e)}" + +# Run the server +if __name__ == "__main__": + mcp.run() \ No newline at end of file diff --git a/nadlan_mcp/mcp_server.py b/nadlan_mcp/mcp_server.py index 33d5bd5..c0e6538 100644 --- a/nadlan_mcp/mcp_server.py +++ b/nadlan_mcp/mcp_server.py @@ -542,7 +542,7 @@ async def main(): read_stream, write_stream, InitializationOptions( - server_name="israel-real-estate-mcp", + server_name="nadlan-mcp", server_version="1.0.0", capabilities=server.get_capabilities( notification_options=NotificationOptions(), diff --git a/nadlan_mcp/simple_fastmcp_server.py b/nadlan_mcp/simple_fastmcp_server.py new file mode 100644 index 0000000..0276b37 --- /dev/null +++ b/nadlan_mcp/simple_fastmcp_server.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Simple FastMCP Server for Israeli Real Estate Data + +This server provides access to Israeli government real estate data through the Govmap API +using the FastMCP library with simplified, working functions. +""" + +import json +import logging +from typing import List +from mcp.server.fastmcp import FastMCP +from .main import GovmapClient + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize FastMCP server +mcp = FastMCP("nadlan-mcp") + +# Initialize the Govmap client +client = GovmapClient() + +@mcp.tool() +def autocomplete_address(search_text: str) -> str: + """Search and autocomplete Israeli addresses. + + Args: + search_text: The partial address to search for (in Hebrew or English) + + Returns: + JSON string containing matching addresses with their coordinates + """ + try: + response = client.autocomplete_address(search_text) + + if not response or 'results' not in response: + return f"No addresses found for '{search_text}'" + + # Format results for better readability + formatted_results = [] + for result in response['results']: + formatted_results.append({ + "address": result.get("addressLabel", ""), + "settlement": result.get("settlementNameHeb", ""), + "coordinates": result.get("coordinates", {}), + "polygon_id": result.get("polygon_id") + }) + + return json.dumps(formatted_results, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in autocomplete_address: {e}") + return f"Error searching for address: {str(e)}" + +@mcp.tool() +def get_deals_by_radius(latitude: float, longitude: float, radius_meters: int = 500) -> str: + """Get real estate deals within a radius of coordinates. + + Args: + latitude: Latitude coordinate + longitude: Longitude coordinate + radius_meters: Search radius in meters (default: 500) + + Returns: + JSON string containing recent real estate deals in the area + """ + try: + # Note: GovmapClient expects (longitude, latitude) tuple + deals = client.get_deals_by_radius((longitude, latitude), radius_meters) + + if not deals: + return f"No deals found within {radius_meters}m of coordinates ({latitude}, {longitude})" + + return json.dumps({ + "total_deals": len(deals), + "search_radius_meters": radius_meters, + "center_coordinates": {"latitude": latitude, "longitude": longitude}, + "deals": deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_deals_by_radius: {e}") + return f"Error fetching deals by radius: {str(e)}" + +@mcp.tool() +def get_street_deals(polygon_id: str, limit: int = 100) -> str: + """Get real estate deals for a specific street polygon. + + Args: + polygon_id: The polygon ID of the street/area + limit: Maximum number of deals to return (default: 100) + + Returns: + JSON string containing recent real estate deals for the street + """ + try: + deals = client.get_street_deals(polygon_id, limit) + + if not deals: + return f"No deals found for polygon ID {polygon_id}" + + return json.dumps({ + "total_deals": len(deals), + "polygon_id": polygon_id, + "deals": deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in get_street_deals: {e}") + return f"Error fetching street deals: {str(e)}" + +@mcp.tool() +def find_recent_deals_for_address(address: str, years_back: int = 2) -> str: + """Find recent real estate deals for a specific address. + + Args: + address: The address to search for (in Hebrew or English) + years_back: How many years back to search (default: 2) + + Returns: + JSON string containing recent real estate deals for the address + """ + try: + deals = client.find_recent_deals_for_address(address, years_back) + + if not deals: + return f"No deals found for address '{address}'" + + # Calculate basic statistics + prices = [deal.get("dealAmount", 0) for deal in deals if deal.get("dealAmount")] + areas = [deal.get("assetArea", 0) for deal in deals if deal.get("assetArea")] + + stats = {} + if prices: + stats["price_stats"] = { + "average_price": sum(prices) / len(prices), + "min_price": min(prices), + "max_price": max(prices), + "total_deals": len(prices) + } + + if areas: + stats["area_stats"] = { + "average_area": sum(areas) / len(areas), + "min_area": min(areas), + "max_area": max(areas) + } + + return json.dumps({ + "search_address": address, + "years_back": years_back, + "total_deals": len(deals), + "market_statistics": stats, + "deals": deals + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in find_recent_deals_for_address: {e}") + return f"Error analyzing address: {str(e)}" + +@mcp.tool() +def compare_addresses(addresses: List[str]) -> str: + """Compare real estate markets between multiple addresses. + + Args: + addresses: List of addresses to compare (in Hebrew or English) + + Returns: + JSON string containing comparative analysis of multiple addresses + """ + try: + comparisons = [] + + for address in addresses: + try: + deals = client.find_recent_deals_for_address(address, 2) + + if deals: + prices = [deal.get("dealAmount", 0) for deal in deals if deal.get("dealAmount")] + areas = [deal.get("assetArea", 0) for deal in deals if deal.get("assetArea")] + + comparison = { + "address": address, + "total_deals": len(deals), + "price_stats": { + "average_price": sum(prices) / len(prices) if prices else 0, + "min_price": min(prices) if prices else 0, + "max_price": max(prices) if prices else 0 + }, + "area_stats": { + "average_area": sum(areas) / len(areas) if areas else 0, + "min_area": min(areas) if areas else 0, + "max_area": max(areas) if areas else 0 + } + } + else: + comparison = { + "address": address, + "total_deals": 0, + "price_stats": {}, + "area_stats": {} + } + + comparisons.append(comparison) + + except Exception as e: + logger.error(f"Error comparing {address}: {e}") + comparisons.append({ + "address": address, + "error": str(e) + }) + + # Rank addresses by average price + valid_comparisons = [c for c in comparisons if c.get("price_stats", {}).get("average_price", 0) > 0] + valid_comparisons.sort(key=lambda x: x["price_stats"]["average_price"], reverse=True) + + return json.dumps({ + "addresses_compared": len(addresses), + "ranking_by_average_price": valid_comparisons, + "all_results": comparisons + }, ensure_ascii=False, indent=2) + + except Exception as e: + logger.error(f"Error in compare_addresses: {e}") + return f"Error comparing addresses: {str(e)}" + +# Run the server +if __name__ == "__main__": + mcp.run() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 99c9d2e..8fc7013 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ requests>=2.31.0 python-dotenv>=1.0.0 pytest>=7.0.0 mcp>=1.0.0 +fastmcp>=0.1.0 types-requests>=2.31.0 diff --git a/run_fastmcp_server.py b/run_fastmcp_server.py new file mode 100644 index 0000000..6ee189a --- /dev/null +++ b/run_fastmcp_server.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +""" +Run script for FastMCP Israeli Real Estate Server + +This script runs the FastMCP server for accessing Israeli government real estate data. +""" + +if __name__ == "__main__": + from nadlan_mcp.simple_fastmcp_server import mcp + mcp.run() \ No newline at end of file