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>
235 lines
7.8 KiB
TypeScript
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;
|
|
}
|