import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod/v3'; import type { ValuationDeps } from '../shared/types'; const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const; interface AVMResponse { estimated_price_vnd: number; confidence: number; price_per_m2: number; price_range_low: number; price_range_high: number; } interface FeatureExtractResponse { features?: { area?: number; district?: string; city?: string; property_type?: string; bedrooms?: number; bathrooms?: number; floors?: number; frontage?: number; road_width?: number; price_mentioned?: number; has_legal_paper?: boolean; }; tokens?: string[]; entities?: { text: string; label: string }[]; } const EstimateValueSchema = { area: z.number().positive().describe('Property area in m²'), district: z.string().min(1).describe('District name'), city: z.string().min(1).describe('City name'), propertyType: z.enum(PROPERTY_TYPES).describe('Property type'), bedrooms: z.number().int().min(0).default(0), bathrooms: z.number().int().min(0).default(0), floors: z.number().int().min(0).default(0), frontage: z.number().min(0).default(0).describe('Frontage width in meters'), roadWidth: z.number().min(0).default(0).describe('Adjacent road width in meters'), yearBuilt: z.number().int().optional().describe('Year the property was built'), hasLegalPaper: z.boolean().default(true).describe('Has sổ đỏ/sổ hồng'), }; const ExtractFeaturesSchema = { text: z.string().min(1).describe('Vietnamese property listing text to analyze'), }; const BatchValuationSchema = { properties: z.array(z.object({ area: z.number().positive(), district: z.string().min(1), city: z.string().min(1), propertyType: z.enum(PROPERTY_TYPES), bedrooms: z.number().int().min(0).default(0), bathrooms: z.number().int().min(0).default(0), floors: z.number().int().min(0).default(0), frontage: z.number().min(0).default(0), roadWidth: z.number().min(0).default(0), yearBuilt: z.number().int().optional(), hasLegalPaper: z.boolean().default(true), })).min(1).max(20).describe('Array of properties to valuate'), }; async function callAVM(baseUrl: string, body: Record): Promise<{ data?: AVMResponse; error?: string }> { const response = await fetch(`${baseUrl}/avm/predict`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { const errorText = await response.text(); return { error: `AVM service error (${response.status}): ${errorText}` }; } const data = (await response.json()) as AVMResponse; return { data }; } export function createValuationServer(deps: ValuationDeps): McpServer { const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, ''); const server = new McpServer({ name: 'goodgo-valuation', version: '0.1.0', }); server.tool( 'estimate_property_value', "Estimate a property's market value using the AVM. Returns price, confidence, and range.", EstimateValueSchema, async (params: z.infer>) => { const body = { area: params.area, district: params.district, city: params.city, property_type: params.propertyType, bedrooms: params.bedrooms, bathrooms: params.bathrooms, floors: params.floors, frontage: params.frontage, road_width: params.roadWidth, year_built: params.yearBuilt ?? null, has_legal_paper: params.hasLegalPaper, }; const { data, error } = await callAVM(baseUrl, body); if (error || !data) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error }) }], isError: true, }; } return { content: [{ type: 'text' as const, text: JSON.stringify({ estimatedPriceVND: data.estimated_price_vnd, confidence: data.confidence, pricePerM2: data.price_per_m2, priceRangeLow: data.price_range_low, priceRangeHigh: data.price_range_high, input: { area: params.area, district: params.district, city: params.city, propertyType: params.propertyType }, }, null, 2), }], }; }, ); server.tool( 'extract_listing_features', 'Extract real estate features from Vietnamese listing text using NLP.', ExtractFeaturesSchema, async (params: z.infer>) => { const response = await fetch(`${baseUrl}/avm/extract-features`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: params.text }), }); if (!response.ok) { const errorText = await response.text(); return { content: [{ type: 'text' as const, text: JSON.stringify({ error: `Feature extraction error (${response.status}): ${errorText}` }), }], isError: true, }; } const result = (await response.json()) as FeatureExtractResponse; return { content: [{ type: 'text' as const, text: JSON.stringify({ features: { area: result.features?.area ?? null, district: result.features?.district ?? null, city: result.features?.city ?? null, propertyType: result.features?.property_type ?? null, bedrooms: result.features?.bedrooms ?? null, bathrooms: result.features?.bathrooms ?? null, floors: result.features?.floors ?? null, frontage: result.features?.frontage ?? null, roadWidth: result.features?.road_width ?? null, priceMentioned: result.features?.price_mentioned ?? null, hasLegalPaper: result.features?.has_legal_paper ?? null, }, tokens: result.tokens ?? [], entities: result.entities ?? [], }, null, 2), }], }; }, ); server.tool( 'batch_valuation', 'Estimate values for multiple properties at once for portfolio or investment analysis.', BatchValuationSchema, async (params: z.infer>) => { const results = await Promise.all( params.properties.map(async (prop, index) => { const body = { area: prop.area, district: prop.district, city: prop.city, property_type: prop.propertyType, bedrooms: prop.bedrooms, bathrooms: prop.bathrooms, floors: prop.floors, frontage: prop.frontage, road_width: prop.roadWidth, year_built: prop.yearBuilt ?? null, has_legal_paper: prop.hasLegalPaper, }; try { const { data, error } = await callAVM(baseUrl, body); if (error || !data) return { index, input: prop, error }; return { index, input: { area: prop.area, district: prop.district, city: prop.city, propertyType: prop.propertyType }, valuation: { estimatedPriceVND: data.estimated_price_vnd, confidence: data.confidence, pricePerM2: data.price_per_m2, priceRangeLow: data.price_range_low, priceRangeHigh: data.price_range_high, }, }; } catch (err) { return { index, input: prop, error: String(err) }; } }), ); return { content: [{ type: 'text' as const, text: JSON.stringify({ valuations: results }, null, 2), }], }; }, ); return server; }