import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod/v3'; import type { PropertySearchDeps, PropertySummary } from '../shared/types'; const DEFAULT_COLLECTION = 'listings'; const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const; const SearchPropertiesSchema = { query: z.string().describe('Natural language search query (e.g. "căn hộ 2 phòng ngủ Quận 7")'), propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'), transactionType: z.enum(['sale', 'rent']).optional().describe('Filter by transaction type'), minPrice: z.number().optional().describe('Minimum price in VND'), maxPrice: z.number().optional().describe('Maximum price in VND'), bedrooms: z.number().int().optional().describe('Exact number of bedrooms'), district: z.string().optional().describe('Filter by district name'), city: z.string().optional().describe('Filter by city name'), latitude: z.number().optional().describe('Center latitude for geo search'), longitude: z.number().optional().describe('Center longitude for geo search'), radiusKm: z.number().optional().describe('Radius in km for geo search'), sortBy: z.enum(['relevance', 'price_asc', 'price_desc', 'date_desc', 'distance']).optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(50).default(20), }; const ComparePropertiesSchema = { listingIds: z.array(z.string()).min(2).max(10).describe('Array of listing IDs to compare'), }; const GetPropertyDetailsSchema = { listingId: z.string().describe('The listing ID to retrieve'), }; export function createPropertySearchServer(deps: PropertySearchDeps): McpServer { const collectionName = deps.collectionName ?? DEFAULT_COLLECTION; const client = deps.typesenseClient; const server = new McpServer({ name: 'goodgo-property-search', version: '0.1.0', }); server.tool( 'search_properties', 'Search property listings using natural language queries with filters.', SearchPropertiesSchema, async (params: z.infer>) => { const filters: string[] = ['status:=ACTIVE']; if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`); if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`); if (params.minPrice != null) filters.push(`priceVND:>=${params.minPrice}`); if (params.maxPrice != null) filters.push(`priceVND:<=${params.maxPrice}`); if (params.bedrooms != null) filters.push(`bedrooms:=${params.bedrooms}`); if (params.district) filters.push(`district:=${params.district}`); if (params.city) filters.push(`city:=${params.city}`); let filterBy = filters.join(' && '); if (params.latitude != null && params.longitude != null && params.radiusKm) { const geoFilter = `location:(${params.latitude}, ${params.longitude}, ${params.radiusKm} km)`; filterBy = `${filterBy} && ${geoFilter}`; } let sortBy = 'publishedAt:desc'; if (params.sortBy === 'price_asc') sortBy = 'priceVND:asc'; else if (params.sortBy === 'price_desc') sortBy = 'priceVND:desc'; else if (params.sortBy === 'date_desc') sortBy = 'publishedAt:desc'; else if (params.sortBy === 'distance' && params.latitude != null && params.longitude != null) { sortBy = `location(${params.latitude}, ${params.longitude}):asc`; } else if (params.sortBy === 'relevance' && params.query) { sortBy = '_text_match:desc,publishedAt:desc'; } const result = await client .collections(collectionName) .documents() .search({ q: params.query || '*', query_by: 'title,description,address,district,city,projectName', query_by_weights: '5,3,2,2,1,2', filter_by: filterBy, sort_by: sortBy, page: params.page, per_page: params.perPage, highlight_full_fields: 'title,description', }); const hits = (result.hits ?? []).map((hit) => { const doc = hit.document as Record; return { listingId: doc['listingId'], title: doc['title'], propertyType: doc['propertyType'], transactionType: doc['transactionType'], priceVND: doc['priceVND'], pricePerM2: doc['pricePerM2'] ?? null, areaM2: doc['areaM2'], bedrooms: doc['bedrooms'] ?? null, bathrooms: doc['bathrooms'] ?? null, address: doc['address'], district: doc['district'], city: doc['city'], }; }); return { content: [{ type: 'text' as const, text: JSON.stringify({ totalFound: result.found ?? 0, page: params.page, perPage: params.perPage, searchTimeMs: result.search_time_ms ?? 0, results: hits, }, null, 2), }], }; }, ); server.tool( 'compare_properties', 'Compare multiple property listings side by side with price and area analysis.', ComparePropertiesSchema, async (params: z.infer>) => { const filterBy = `listingId:[${params.listingIds.join(',')}]`; const result = await client .collections(collectionName) .documents() .search({ q: '*', query_by: 'title', filter_by: filterBy, per_page: params.listingIds.length, }); const properties: PropertySummary[] = (result.hits ?? []).map((hit) => { const doc = hit.document as Record; return { listingId: doc['listingId'] as string, title: doc['title'] as string, propertyType: doc['propertyType'] as string, transactionType: doc['transactionType'] as string, priceVND: doc['priceVND'] as number, pricePerM2: (doc['pricePerM2'] as number) ?? null, areaM2: doc['areaM2'] as number, bedrooms: (doc['bedrooms'] as number) ?? null, bathrooms: (doc['bathrooms'] as number) ?? null, address: doc['address'] as string, district: doc['district'] as string, city: doc['city'] as string, }; }); if (properties.length < 2) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Need at least 2 valid listings to compare', found: properties.length }), }], isError: true, }; } const prices = properties.map((p) => p.priceVND); const areas = properties.map((p) => p.areaM2); const pricesPerM2 = properties.map((p) => p.pricePerM2).filter((v): v is number => v != null); const comparison = { properties, comparison: { priceRange: { min: Math.min(...prices), max: Math.max(...prices), avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length), }, areaRange: { min: Math.min(...areas), max: Math.max(...areas), avg: Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100, }, pricePerM2Range: pricesPerM2.length > 0 ? { min: Math.min(...pricesPerM2), max: Math.max(...pricesPerM2), avg: Math.round(pricesPerM2.reduce((a, b) => a + b, 0) / pricesPerM2.length), } : null, }, }; return { content: [{ type: 'text' as const, text: JSON.stringify(comparison, null, 2) }], }; }, ); server.tool( 'get_property_details', 'Get detailed information about a specific property listing by its ID.', GetPropertyDetailsSchema, async (params: z.infer>) => { const result = await client .collections(collectionName) .documents() .search({ q: '*', query_by: 'title', filter_by: `listingId:=${params.listingId}`, per_page: 1, }); const hit = result.hits?.[0]; if (!hit) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Listing not found' }) }], isError: true, }; } return { content: [{ type: 'text' as const, text: JSON.stringify(hit.document, null, 2) }], }; }, ); return server; }