- 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>
229 lines
8.4 KiB
TypeScript
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;
|
|
}
|