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:
254
libs/mcp-servers/src/market-analytics/market-analytics.server.ts
Normal file
254
libs/mcp-servers/src/market-analytics/market-analytics.server.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user