Files
goodgo-platform/libs/mcp-servers/src/property-search/property-search.server.ts
Ho Ngoc Hai c478abae38 feat(listings): add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT property types (GOO-20)
- Add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT to PropertyType enum in schema.prisma
- Create migration 20260422010000_add_room_rental_property_types with ALTER TYPE ADD VALUE
- Add DEFAULT_RANGES in PrismaPriceValidator: ROOM_RENTAL 1M-10M VND/month, CONDOTEL 20M-300M, SERVICED_APARTMENT 20M-250M VND/m²
- Add i18n translations: vi "Phòng trọ / Condotel / Căn hộ dịch vụ", en "Room Rental / Condotel / Serviced Apartment"
- Typesense indexes propertyType as a generic string facet — no schema change needed

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 23:26:01 +07:00

229 lines
8.4 KiB
TypeScript

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;
}