Skip to main content
Glama
klappe-pm

Real Estate MCP Server

by klappe-pm
data-transformer.ts11.1 kB
import { z } from 'zod'; import { Property, Address, PriceHistoryEntry, ZillowPropertyResponse, RentCastPropertyResponse } from '../types/real-estate.js'; import { ValidationError } from '../utils/errors.js'; /** * Zod schemas for validation */ const AddressSchema = z.object({ street: z.string().optional(), city: z.string().optional(), state: z.string().optional(), postalCode: z.string().optional(), country: z.string().optional(), latitude: z.number().optional(), longitude: z.number().optional(), }); const PropertySchema = z.object({ id: z.string().optional(), address: AddressSchema, price: z.number().optional(), beds: z.number().optional(), baths: z.number().optional(), sqft: z.number().optional(), lotSizeSqft: z.number().optional(), propertyType: z.string().optional(), yearBuilt: z.number().optional(), url: z.string().url().optional(), source: z.enum(['zillow', 'redfin', 'rentcast', 'mls', 'other']).optional(), mlsId: z.string().optional(), daysOnMarket: z.number().optional(), hoaMonthly: z.number().optional(), taxesAnnual: z.number().optional(), estimate: z.number().optional(), description: z.string().optional(), photos: z.array(z.string()).optional(), features: z.array(z.string()).optional(), listingStatus: z.enum(['active', 'pending', 'sold', 'off_market']).optional(), listingDate: z.string().optional(), priceHistory: z.array(z.any()).optional(), schoolDistrict: z.string().optional(), walkScore: z.number().optional(), transitScore: z.number().optional(), crimeScore: z.number().optional(), lastSoldPrice: z.number().optional(), lastSoldDate: z.string().optional(), propertyTaxRate: z.number().optional(), insurance: z.number().optional(), utilities: z.number().optional(), }); /** * Data transformation service for normalizing API responses */ export class DataTransformerService { /** * Transform Zillow API response to Property interface */ transformZillowProperty(zillowData: ZillowPropertyResponse): Property { try { // Extract and normalize address const address: Address = { street: zillowData.address?.streetAddress, city: zillowData.address?.city, state: zillowData.address?.state, postalCode: zillowData.address?.zipcode, country: 'US', }; // Transform price history const priceHistory: PriceHistoryEntry[] | undefined = zillowData.priceHistory?.map(entry => ({ date: new Date(entry.date).toISOString(), price: entry.price, event: this.normalizeZillowPriceEvent(entry.event), })); // Extract features from resoFacts const features: string[] = []; if (zillowData.resoFacts?.hasView) features.push('View'); if (zillowData.resoFacts?.hasGarage) features.push('Garage'); if (zillowData.resoFacts?.parkingFeatures) { features.push(...zillowData.resoFacts.parkingFeatures); } // Get most recent tax amount const mostRecentTax = zillowData.taxHistory?.[0]?.taxPaid; const property: Property = { id: zillowData.zpid, address, price: zillowData.price || undefined, beds: zillowData.bedrooms || undefined, baths: zillowData.bathrooms || undefined, sqft: zillowData.livingArea || undefined, lotSizeSqft: zillowData.lotAreaValue || undefined, propertyType: this.normalizePropertyType(zillowData.propertyType), yearBuilt: zillowData.yearBuilt || undefined, url: zillowData.homeDetailsUrl || undefined, source: 'zillow', mlsId: zillowData.mlsid || undefined, daysOnMarket: zillowData.daysOnZillow || undefined, hoaMonthly: zillowData.monthlyHoaFee || undefined, taxesAnnual: mostRecentTax || undefined, estimate: zillowData.zestimate || undefined, description: zillowData.description || undefined, photos: zillowData.photos && zillowData.photos.length > 0 ? zillowData.photos : undefined, features: features.length > 0 ? features : undefined, priceHistory, }; return this.validateProperty(property); } catch (error) { throw new ValidationError(`Failed to transform Zillow property: ${error}`); } } /** * Transform RentCast API response to Property interface */ transformRentCastProperty(rentcastData: RentCastPropertyResponse): Property { try { const address: Address = { street: rentcastData.address, city: rentcastData.city, state: rentcastData.state, postalCode: rentcastData.zipCode, country: 'US', latitude: rentcastData.latitude, longitude: rentcastData.longitude, }; const property: Property = { id: rentcastData.id, address, price: rentcastData.price || rentcastData.avm?.value, beds: rentcastData.bedrooms, baths: rentcastData.bathrooms, sqft: rentcastData.squareFootage, lotSizeSqft: rentcastData.lotSize, propertyType: this.normalizePropertyType(rentcastData.propertyType), yearBuilt: rentcastData.yearBuilt, source: 'rentcast', estimate: rentcastData.avm?.value, }; return this.validateProperty(property); } catch (error) { throw new ValidationError(`Failed to transform RentCast property: ${error}`); } } /** * Validate property against schema */ private validateProperty(property: Property): Property { try { return PropertySchema.parse(property); } catch (error) { if (error instanceof z.ZodError) { const issues = error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}` ).join(', '); throw new ValidationError(`Property validation failed: ${issues}`); } throw error; } } /** * Normalize property types across different sources */ private normalizePropertyType(type?: string): string | undefined { if (!type) return undefined; const normalized = type.toLowerCase().replace(/[_\s-]/g, '_'); const typeMap: Record<string, string> = { 'single_family': 'single_family', 'singlefamily': 'single_family', 'single_family_home': 'single_family', 'house': 'single_family', 'condo': 'condo', 'condominium': 'condo', 'townhouse': 'townhouse', 'townhome': 'townhouse', 'apartment': 'apartment', 'multi_family': 'multi_family', 'multifamily': 'multi_family', 'duplex': 'duplex', 'land': 'land', 'vacant_land': 'land', 'manufactured': 'manufactured', 'mobile': 'manufactured', }; return typeMap[normalized] || type; } /** * Normalize Zillow price history events */ private normalizeZillowPriceEvent(event: string): PriceHistoryEntry['event'] { const eventLower = event.toLowerCase(); if (eventLower.includes('list') || eventLower.includes('market')) { return 'listed'; } if (eventLower.includes('price') || eventLower.includes('change')) { return 'price_change'; } if (eventLower.includes('sold')) { return 'sold'; } if (eventLower.includes('delist') || eventLower.includes('withdraw')) { return 'delisted'; } return 'price_change'; // default } /** * Standardize address format */ standardizeAddress(address: Address): Address { const standardized = { ...address }; // Capitalize state abbreviations if (standardized.state && standardized.state.length === 2) { standardized.state = standardized.state.toUpperCase(); } // Format postal code if (standardized.postalCode) { // Remove non-digits and format as XXXXX or XXXXX-XXXX const digits = standardized.postalCode.replace(/\D/g, ''); if (digits.length >= 5) { standardized.postalCode = digits.length > 5 ? `${digits.slice(0, 5)}-${digits.slice(5, 9)}` : digits.slice(0, 5); } } // Ensure country is set if (!standardized.country) { standardized.country = 'US'; } return standardized; } /** * Convert units (e.g., square feet to square meters) */ convertUnits(value: number, from: string, to: string): number { const conversions: Record<string, Record<string, number>> = { sqft: { sqm: 0.092903, }, sqm: { sqft: 10.7639, }, }; const conversion = conversions[from]?.[to]; if (!conversion) { throw new ValidationError(`Unit conversion not supported: ${from} to ${to}`); } return Math.round(value * conversion * 100) / 100; // Round to 2 decimal places } /** * Merge properties from different sources */ mergeProperties(properties: Property[]): Property { if (properties.length === 0) { throw new ValidationError('Cannot merge empty property list'); } if (properties.length === 1) { return properties[0]; } // Use the first property as base and merge others const merged = { ...properties[0] }; for (let i = 1; i < properties.length; i++) { const property = properties[i]; // Merge fields, preferring non-null values Object.keys(property).forEach(key => { const typedKey = key as keyof Property; if (property[typedKey] && !merged[typedKey]) { (merged as any)[typedKey] = property[typedKey]; } }); // Merge photos arrays if (property.photos && merged.photos) { merged.photos = [...new Set([...merged.photos, ...property.photos])]; } else if (property.photos) { merged.photos = property.photos; } // Merge features arrays if (property.features && merged.features) { merged.features = [...new Set([...merged.features, ...property.features])]; } else if (property.features) { merged.features = property.features; } } return this.validateProperty(merged); } /** * Calculate property quality score based on available data */ calculateDataQualityScore(property: Property): number { let score = 0; let maxScore = 0; // Essential fields (higher weight) const essentialFields = ['price', 'beds', 'baths', 'sqft', 'address']; essentialFields.forEach(field => { maxScore += 2; if (property[field as keyof Property]) score += 2; }); // Important fields (medium weight) const importantFields = ['yearBuilt', 'propertyType', 'lotSizeSqft', 'estimate']; importantFields.forEach(field => { maxScore += 1.5; if (property[field as keyof Property]) score += 1.5; }); // Nice-to-have fields (lower weight) const optionalFields = ['photos', 'description', 'features', 'priceHistory']; optionalFields.forEach(field => { maxScore += 1; if (property[field as keyof Property]) score += 1; }); return Math.round((score / maxScore) * 100) / 100; // 0-1 score } } // Singleton instance export const dataTransformer = new DataTransformerService();

Latest Blog Posts

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/klappe-pm/Real-Estate-MCP'

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