Files
goodgo-platform/libs/mcp-servers/src/valuation/valuation.server.ts
Ho Ngoc Hai cb00b12d7b feat(mcp): add MCP Server Integration — Property Search, Analytics, Valuation
Implement 3 MCP servers in libs/mcp-servers/ using @modelcontextprotocol/sdk:

- Property Search: NL search via Typesense, property comparison, detail lookup
- Market Analytics: market reports, price trends, district comparison
- Valuation: AVM integration with Python AI service, feature extraction, batch valuation

Includes NestJS integration module with SSE transport for in-process hosting.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 03:22:27 +07:00

235 lines
7.8 KiB
TypeScript

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<string, unknown>): 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<z.ZodObject<typeof EstimateValueSchema>>) => {
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<z.ZodObject<typeof ExtractFeaturesSchema>>) => {
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<z.ZodObject<typeof BatchValuationSchema>>) => {
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;
}