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>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 03:22:27 +07:00
parent efa49e225e
commit cb00b12d7b
17 changed files with 1077 additions and 41 deletions

View File

@@ -0,0 +1,254 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod/v3';
import type { MarketAnalyticsDeps } from '../shared/types';
const DEFAULT_COLLECTION = 'listings';
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const;
const MarketReportSchema = {
district: z.string().describe('District name (e.g. "Quận 7")'),
city: z.string().describe('City name (e.g. "Hồ Chí Minh")'),
propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'),
transactionType: z.enum(['sale', 'rent']).optional().describe('Filter by transaction type'),
};
const PriceTrendsSchema = {
district: z.string().optional().describe('District name (omit for city-wide)'),
city: z.string().describe('City name'),
propertyType: z.enum(PROPERTY_TYPES).optional(),
transactionType: z.enum(['sale', 'rent']).optional(),
months: z.number().int().min(1).max(24).default(6).describe('Months to look back'),
};
const DistrictComparisonSchema = {
city: z.string().describe('City name'),
districts: z.array(z.string()).min(2).max(10).describe('District names to compare'),
propertyType: z.enum(PROPERTY_TYPES).optional(),
transactionType: z.enum(['sale', 'rent']).optional(),
};
export function createMarketAnalyticsServer(deps: MarketAnalyticsDeps): McpServer {
const collectionName = deps.collectionName ?? DEFAULT_COLLECTION;
const client = deps.typesenseClient;
const server = new McpServer({
name: 'goodgo-market-analytics',
version: '0.1.0',
});
server.tool(
'market_report',
'Generate a market report for a district/city with prices, listing counts, and distribution.',
MarketReportSchema,
async (params: z.infer<z.ZodObject<typeof MarketReportSchema>>) => {
const filters: string[] = [
'status:=active',
`district:=${params.district}`,
`city:=${params.city}`,
];
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
const result = await client
.collections(collectionName)
.documents()
.search({
q: '*',
query_by: 'title',
filter_by: filters.join(' && '),
sort_by: 'priceVND:asc',
per_page: 250,
page: 1,
});
const docs = (result.hits ?? []).map((h) => h.document as Record<string, unknown>);
const totalListings = (result.found as number) ?? 0;
if (docs.length === 0) {
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
district: params.district,
city: params.city,
totalListings: 0,
message: 'No listings found for the specified criteria.',
}, null, 2),
}],
};
}
const prices = docs.map((d) => d['priceVND'] as number);
const areas = docs.map((d) => d['areaM2'] as number);
const pricesPerM2 = docs
.map((d) => d['pricePerM2'] as number | undefined)
.filter((v): v is number => v != null && v > 0);
const sorted = [...prices].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const medianPrice = sorted.length % 2 === 0
? (sorted[mid - 1]! + sorted[mid]!) / 2
: sorted[mid]!;
const maxPrice = sorted[sorted.length - 1]!;
const bucketSize = maxPrice > 10_000_000_000 ? 2_000_000_000 : maxPrice > 1_000_000_000 ? 500_000_000 : 100_000_000;
const buckets = new Map<string, number>();
for (const price of prices) {
const bucketStart = Math.floor(price / bucketSize) * bucketSize;
const label = `${formatVND(bucketStart)}-${formatVND(bucketStart + bucketSize)}`;
buckets.set(label, (buckets.get(label) ?? 0) + 1);
}
const report = {
district: params.district,
city: params.city,
propertyType: params.propertyType ?? 'all',
transactionType: params.transactionType ?? 'all',
totalListings,
sampleSize: docs.length,
averagePriceVND: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
medianPriceVND: Math.round(medianPrice),
averagePricePerM2: pricesPerM2.length > 0
? Math.round(pricesPerM2.reduce((a, b) => a + b, 0) / pricesPerM2.length)
: null,
averageAreaM2: Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100,
priceRange: { min: sorted[0], max: sorted[sorted.length - 1] },
priceDistribution: Array.from(buckets.entries()).map(([range, count]) => ({ range, count })),
};
return {
content: [{ type: 'text' as const, text: JSON.stringify(report, null, 2) }],
};
},
);
server.tool(
'price_trends',
'Analyze price trends by grouping listings by published date for a given area.',
PriceTrendsSchema,
async (params: z.infer<z.ZodObject<typeof PriceTrendsSchema>>) => {
const filters: string[] = ['status:=active'];
if (params.district) filters.push(`district:=${params.district}`);
filters.push(`city:=${params.city}`);
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
const cutoffTs = Math.floor(Date.now() / 1000) - params.months * 30 * 24 * 3600;
filters.push(`publishedAt:>=${cutoffTs}`);
const result = await client
.collections(collectionName)
.documents()
.search({
q: '*',
query_by: 'title',
filter_by: filters.join(' && '),
sort_by: 'publishedAt:asc',
per_page: 250,
page: 1,
});
const docs = (result.hits ?? []).map((h) => h.document as Record<string, unknown>);
const monthlyData = new Map<string, { total: number; count: number }>();
for (const doc of docs) {
const ts = doc['publishedAt'] as number;
const date = new Date(ts * 1000);
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const existing = monthlyData.get(label) ?? { total: 0, count: 0 };
existing.total += doc['priceVND'] as number;
existing.count += 1;
monthlyData.set(label, existing);
}
const dataPoints = Array.from(monthlyData.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, data]) => ({
month: label,
avgPrice: Math.round(data.total / data.count),
listingCount: data.count,
}));
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
district: params.district ?? 'all',
city: params.city,
propertyType: params.propertyType ?? 'all',
periodMonths: params.months,
totalListings: (result.found as number) ?? 0,
dataPoints,
}, null, 2),
}],
};
},
);
server.tool(
'district_comparison',
'Compare real estate metrics across multiple districts in a city.',
DistrictComparisonSchema,
async (params: z.infer<z.ZodObject<typeof DistrictComparisonSchema>>) => {
const results = await Promise.all(
params.districts.map(async (district) => {
const filters: string[] = [
'status:=active',
`district:=${district}`,
`city:=${params.city}`,
];
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
const res = await client
.collections(collectionName)
.documents()
.search({
q: '*',
query_by: 'title',
filter_by: filters.join(' && '),
per_page: 250,
page: 1,
});
const docs = (res.hits ?? []).map((h) => h.document as Record<string, unknown>);
const prices = docs.map((d) => d['priceVND'] as number);
const areas = docs.map((d) => d['areaM2'] as number);
return {
district,
totalListings: (res.found as number) ?? 0,
avgPriceVND: prices.length > 0
? Math.round(prices.reduce((a, b) => a + b, 0) / prices.length)
: null,
avgAreaM2: areas.length > 0
? Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100
: null,
avgPricePerM2: prices.length > 0 && areas.length > 0
? Math.round(prices.reduce((a, b) => a + b, 0) / areas.reduce((a, b) => a + b, 0))
: null,
};
}),
);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
city: params.city,
propertyType: params.propertyType ?? 'all',
districts: results,
}, null, 2),
}],
};
},
);
return server;
}
function formatVND(amount: number): string {
if (amount >= 1_000_000_000) return `${(amount / 1_000_000_000).toFixed(1)} tỷ`;
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(0)} triệu`;
return `${amount.toLocaleString()} VND`;
}