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:
228
libs/mcp-servers/src/property-search/property-search.server.ts
Normal file
228
libs/mcp-servers/src/property-search/property-search.server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod/v3';
|
||||
import type { PropertySearchDeps, PropertySummary } from '../shared/types';
|
||||
|
||||
const DEFAULT_COLLECTION = 'listings';
|
||||
|
||||
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const;
|
||||
|
||||
const SearchPropertiesSchema = {
|
||||
query: z.string().describe('Natural language search query (e.g. "căn hộ 2 phòng ngủ Quận 7")'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'),
|
||||
transactionType: z.enum(['sale', 'rent']).optional().describe('Filter by transaction type'),
|
||||
minPrice: z.number().optional().describe('Minimum price in VND'),
|
||||
maxPrice: z.number().optional().describe('Maximum price in VND'),
|
||||
bedrooms: z.number().int().optional().describe('Exact number of bedrooms'),
|
||||
district: z.string().optional().describe('Filter by district name'),
|
||||
city: z.string().optional().describe('Filter by city name'),
|
||||
latitude: z.number().optional().describe('Center latitude for geo search'),
|
||||
longitude: z.number().optional().describe('Center longitude for geo search'),
|
||||
radiusKm: z.number().optional().describe('Radius in km for geo search'),
|
||||
sortBy: z.enum(['relevance', 'price_asc', 'price_desc', 'date_desc', 'distance']).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(50).default(20),
|
||||
};
|
||||
|
||||
const ComparePropertiesSchema = {
|
||||
listingIds: z.array(z.string()).min(2).max(10).describe('Array of listing IDs to compare'),
|
||||
};
|
||||
|
||||
const GetPropertyDetailsSchema = {
|
||||
listingId: z.string().describe('The listing ID to retrieve'),
|
||||
};
|
||||
|
||||
export function createPropertySearchServer(deps: PropertySearchDeps): McpServer {
|
||||
const collectionName = deps.collectionName ?? DEFAULT_COLLECTION;
|
||||
const client = deps.typesenseClient;
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'goodgo-property-search',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'search_properties',
|
||||
'Search property listings using natural language queries with filters.',
|
||||
SearchPropertiesSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof SearchPropertiesSchema>>) => {
|
||||
const filters: string[] = ['status:=active'];
|
||||
|
||||
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
||||
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
||||
if (params.minPrice != null) filters.push(`priceVND:>=${params.minPrice}`);
|
||||
if (params.maxPrice != null) filters.push(`priceVND:<=${params.maxPrice}`);
|
||||
if (params.bedrooms != null) filters.push(`bedrooms:=${params.bedrooms}`);
|
||||
if (params.district) filters.push(`district:=${params.district}`);
|
||||
if (params.city) filters.push(`city:=${params.city}`);
|
||||
|
||||
let filterBy = filters.join(' && ');
|
||||
|
||||
if (params.latitude != null && params.longitude != null && params.radiusKm) {
|
||||
const geoFilter = `location:(${params.latitude}, ${params.longitude}, ${params.radiusKm} km)`;
|
||||
filterBy = `${filterBy} && ${geoFilter}`;
|
||||
}
|
||||
|
||||
let sortBy = 'publishedAt:desc';
|
||||
if (params.sortBy === 'price_asc') sortBy = 'priceVND:asc';
|
||||
else if (params.sortBy === 'price_desc') sortBy = 'priceVND:desc';
|
||||
else if (params.sortBy === 'date_desc') sortBy = 'publishedAt:desc';
|
||||
else if (params.sortBy === 'distance' && params.latitude != null && params.longitude != null) {
|
||||
sortBy = `location(${params.latitude}, ${params.longitude}):asc`;
|
||||
} else if (params.sortBy === 'relevance' && params.query) {
|
||||
sortBy = '_text_match:desc,publishedAt:desc';
|
||||
}
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: params.query || '*',
|
||||
query_by: 'title,description,address,district,city,projectName',
|
||||
query_by_weights: '5,3,2,2,1,2',
|
||||
filter_by: filterBy,
|
||||
sort_by: sortBy,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
highlight_full_fields: 'title,description',
|
||||
});
|
||||
|
||||
const hits = (result.hits ?? []).map((hit) => {
|
||||
const doc = hit.document as Record<string, unknown>;
|
||||
return {
|
||||
listingId: doc['listingId'],
|
||||
title: doc['title'],
|
||||
propertyType: doc['propertyType'],
|
||||
transactionType: doc['transactionType'],
|
||||
priceVND: doc['priceVND'],
|
||||
pricePerM2: doc['pricePerM2'] ?? null,
|
||||
areaM2: doc['areaM2'],
|
||||
bedrooms: doc['bedrooms'] ?? null,
|
||||
bathrooms: doc['bathrooms'] ?? null,
|
||||
address: doc['address'],
|
||||
district: doc['district'],
|
||||
city: doc['city'],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
totalFound: result.found ?? 0,
|
||||
page: params.page,
|
||||
perPage: params.perPage,
|
||||
searchTimeMs: result.search_time_ms ?? 0,
|
||||
results: hits,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'compare_properties',
|
||||
'Compare multiple property listings side by side with price and area analysis.',
|
||||
ComparePropertiesSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof ComparePropertiesSchema>>) => {
|
||||
const filterBy = `listingId:[${params.listingIds.join(',')}]`;
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: filterBy,
|
||||
per_page: params.listingIds.length,
|
||||
});
|
||||
|
||||
const properties: PropertySummary[] = (result.hits ?? []).map((hit) => {
|
||||
const doc = hit.document as Record<string, unknown>;
|
||||
return {
|
||||
listingId: doc['listingId'] as string,
|
||||
title: doc['title'] as string,
|
||||
propertyType: doc['propertyType'] as string,
|
||||
transactionType: doc['transactionType'] as string,
|
||||
priceVND: doc['priceVND'] as number,
|
||||
pricePerM2: (doc['pricePerM2'] as number) ?? null,
|
||||
areaM2: doc['areaM2'] as number,
|
||||
bedrooms: (doc['bedrooms'] as number) ?? null,
|
||||
bathrooms: (doc['bathrooms'] as number) ?? null,
|
||||
address: doc['address'] as string,
|
||||
district: doc['district'] as string,
|
||||
city: doc['city'] as string,
|
||||
};
|
||||
});
|
||||
|
||||
if (properties.length < 2) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: 'Need at least 2 valid listings to compare', found: properties.length }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const prices = properties.map((p) => p.priceVND);
|
||||
const areas = properties.map((p) => p.areaM2);
|
||||
const pricesPerM2 = properties.map((p) => p.pricePerM2).filter((v): v is number => v != null);
|
||||
|
||||
const comparison = {
|
||||
properties,
|
||||
comparison: {
|
||||
priceRange: {
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices),
|
||||
avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
|
||||
},
|
||||
areaRange: {
|
||||
min: Math.min(...areas),
|
||||
max: Math.max(...areas),
|
||||
avg: Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100,
|
||||
},
|
||||
pricePerM2Range: pricesPerM2.length > 0 ? {
|
||||
min: Math.min(...pricesPerM2),
|
||||
max: Math.max(...pricesPerM2),
|
||||
avg: Math.round(pricesPerM2.reduce((a, b) => a + b, 0) / pricesPerM2.length),
|
||||
} : null,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(comparison, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_property_details',
|
||||
'Get detailed information about a specific property listing by its ID.',
|
||||
GetPropertyDetailsSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof GetPropertyDetailsSchema>>) => {
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: `listingId:=${params.listingId}`,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const hit = result.hits?.[0];
|
||||
if (!hit) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Listing not found' }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(hit.document, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
Reference in New Issue
Block a user