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

@@ -2,7 +2,7 @@
**Last Updated:** 2026-04-08
**Project:** Goodgo Platform AI
**Status:** Phase 0 Complete — Phase 1 ~80% — Phase 2 ~60%
**Status:** Phase 0-2 Complete — Phase 3 Not Started
---
@@ -19,53 +19,44 @@
## Phase 1: Core Auth & Listings (P1)
| Issue | Title | Priority | Status | Commit |
| -------------------------------- | ------------------------------------------------- | -------- | ----------- | ------ |
| [TEC-1421](/TEC/issues/TEC-1421) | Auth Module Backend (Register, Login, JWT, OAuth) | Critical | done | 391c040 |
| [TEC-1422](/TEC/issues/TEC-1422) | Auth Frontend (Login/Register + OAuth) | High | todo | — |
| [TEC-1423](/TEC/issues/TEC-1423) | Listings Module Backend (CRUD, Media, Moderation) | High | done | 8a33aae |
| [TEC-1424](/TEC/issues/TEC-1424) | Search Module Backend (Typesense + Geo) | High | done | 6741592 |
| [TEC-1425](/TEC/issues/TEC-1425) | Security Hardening (Rate Limiting, CORS, Helmet) | High | done | f3081d9 |
| [TEC-1426](/TEC/issues/TEC-1426) | Error Handling & Logging Strategy | High | done | c981bff |
| [TEC-1427](/TEC/issues/TEC-1427) | Listings Frontend (Create/Edit + Detail) | High | done | 207a201 |
| [TEC-1428](/TEC/issues/TEC-1428) | Search + Landing Page Frontend | High | done | 5e44456 |
| Issue | Title | Priority | Status | Commit |
| -------------------------------- | ------------------------------------------------- | -------- | ------ | ------ |
| [TEC-1421](/TEC/issues/TEC-1421) | Auth Module Backend (Register, Login, JWT, OAuth) | Critical | done | 391c040 |
| [TEC-1422](/TEC/issues/TEC-1422) | Auth Frontend (Login/Register + OAuth) | High | done | bfdd2f7 |
| [TEC-1423](/TEC/issues/TEC-1423) | Listings Module Backend (CRUD, Media, Moderation) | High | done | 8a33aae |
| [TEC-1424](/TEC/issues/TEC-1424) | Search Module Backend (Typesense + Geo) | High | done | 6741592 |
| [TEC-1425](/TEC/issues/TEC-1425) | Security Hardening (Rate Limiting, CORS, Helmet) | High | done | f3081d9 |
| [TEC-1426](/TEC/issues/TEC-1426) | Error Handling & Logging Strategy | High | done | c981bff |
| [TEC-1427](/TEC/issues/TEC-1427) | Listings Frontend (Create/Edit + Detail) | High | done | 207a201 |
| [TEC-1428](/TEC/issues/TEC-1428) | Search + Landing Page Frontend | High | done | 5e44456 |
## Phase 2: Monetization & Operations (P2)
| Issue | Title | Priority | Status | Commit |
| -------------------------------- | ----------------------------------------------- | -------- | ----------- | ------ |
| [TEC-1429](/TEC/issues/TEC-1429) | Payments Module (VNPay + MoMo + ZaloPay) | Medium | done | ad77139 |
| [TEC-1430](/TEC/issues/TEC-1430) | Subscriptions Module (Plans, Quotas, Billing) | Medium | done | 9b581b7 |
| [TEC-1431](/TEC/issues/TEC-1431) | Notifications Module (Email, SMS, Zalo OA, FCM) | Medium | done | 0b29fac |
| [TEC-1432](/TEC/issues/TEC-1432) | Admin Module (Backend + Frontend) | Medium | todo | — |
| [TEC-1433](/TEC/issues/TEC-1433) | E2E Testing Setup (Playwright) | Medium | done | 9301f44 |
| Issue | Title | Priority | Status | Commit |
| -------------------------------- | ----------------------------------------------- | -------- | ------ | ------ |
| [TEC-1429](/TEC/issues/TEC-1429) | Payments Module (VNPay + MoMo + ZaloPay) | Medium | done | ad77139 |
| [TEC-1430](/TEC/issues/TEC-1430) | Subscriptions Module (Plans, Quotas, Billing) | Medium | done | 9b581b7 |
| [TEC-1431](/TEC/issues/TEC-1431) | Notifications Module (Email, SMS, Zalo OA, FCM) | Medium | done | 0b29fac |
| [TEC-1432](/TEC/issues/TEC-1432) | Admin Module (Backend + Frontend) | Medium | done | 6123fc4 |
| [TEC-1433](/TEC/issues/TEC-1433) | E2E Testing Setup (Playwright) | Medium | done | 60a0b3c |
## Phase 3: AI & Advanced (P3) — Not yet created
## Phase 3: AI & Advanced (P3) — Not yet started
- AI/ML Services Container (Python FastAPI + XGBoost)
- Analytics Module (Market reports, AVM)
- MCP Server Integration
- Performance Monitoring (Prometheus + Grafana)
| Issue | Title | Priority | Status |
| ----- | ------------------------------------------------ | -------- | ------ |
| — | Analytics Module (Market Reports, Price Index) | High | todo |
| — | AI/ML Services Container (Python FastAPI + XGBoost) | High | todo |
| — | MCP Server Integration (Property Search, Analytics, Valuation) | Medium | todo |
| — | Performance Monitoring (Prometheus + Grafana) | Low | todo |
---
## Remaining Work for MVP
| Item | Priority | Status |
|------|----------|--------|
| Auth Frontend (Login/Register pages) | High | todo |
| Auth OAuth strategies (Google, Zalo) | High | todo |
| Admin Module (Backend + Frontend) | Medium | todo |
| SMS + Zalo OA notification services | Medium | todo |
| E2E test coverage expansion | Medium | todo |
| Docker deploy pipeline completion | Low | todo |
## Summary
| Phase | Total | Done | In Progress | Todo |
| --------- | ------ | ----- | ----------- | ---- |
| Phase 0 | 6 | 6 | 0 | 0 |
| Phase 1 | 8 | 7 | 0 | 1 |
| Phase 2 | 5 | 4 | 0 | 1 |
| Phase 3 | 4 | | | 4 |
| **Total** | **23** | **17**| **0** | **6**|
| Phase 1 | 8 | 8 | 0 | 0 |
| Phase 2 | 5 | 5 | 0 | 0 |
| Phase 3 | 4 | 0 | 0 | 4 |
| **Total** | **23** | **19**| **0** | **4**|

View File

@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@goodgo/mcp-servers": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/cqrs": "^11.0.0",

View File

@@ -0,0 +1 @@
export { McpIntegrationModule } from './mcp.module';

View File

@@ -0,0 +1,35 @@
import { Module, type OnModuleInit } from '@nestjs/common';
import { McpModule as McpCoreModule, McpRegistryService } from '@goodgo/mcp-servers';
import { SearchModule } from '@modules/search';
import { TypesenseClientService } from '@modules/search/infrastructure/services/typesense-client.service';
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
@Module({
imports: [
SearchModule,
McpCoreModule.forRoot({
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
typesenseCollectionName: 'listings',
}),
],
})
export class McpIntegrationModule implements OnModuleInit {
constructor(
private readonly typesenseClient: TypesenseClientService,
private readonly mcpRegistry: McpRegistryService,
private readonly logger: LoggerService,
) {}
async onModuleInit(): Promise<void> {
this.mcpRegistry.setTypesenseClient(this.typesenseClient.getClient());
// Re-initialize servers now that Typesense client is available
await this.mcpRegistry.onModuleInit();
const servers = this.mcpRegistry.getServerNames();
this.logger.log(
`MCP servers initialized: ${servers.join(', ')}`,
'McpIntegrationModule',
);
}
}

View File

@@ -40,7 +40,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
...CommandHandlers,
...QueryHandlers,
],
exports: [ListingIndexerService, SEARCH_REPOSITORY],
exports: [ListingIndexerService, SEARCH_REPOSITORY, TypesenseClientService],
})
export class SearchModule implements OnModuleInit {
constructor(

View File

@@ -15,6 +15,7 @@
"zod": "^3.24.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
@@ -24,7 +25,11 @@
"typesense": "^3.0.0"
},
"peerDependenciesMeta": {
"@nestjs/common": { "optional": true },
"typesense": { "optional": true }
"@nestjs/common": {
"optional": true
},
"typesense": {
"optional": true
}
}
}

View File

@@ -0,0 +1,23 @@
// Server factories
export { createPropertySearchServer } from './property-search/property-search.server';
export { createMarketAnalyticsServer } from './market-analytics/market-analytics.server';
export { createValuationServer } from './valuation/valuation.server';
// NestJS integration
export { McpModule, type McpModuleOptions, MCP_MODULE_OPTIONS } from './nestjs/mcp.module';
export { McpRegistryService } from './nestjs/mcp-registry.service';
export { McpTransportController } from './nestjs/mcp-transport.controller';
// Shared types
export type {
PropertySearchDeps,
MarketAnalyticsDeps,
ValuationDeps,
McpServerConfig,
PropertySummary,
PropertyComparisonResult,
MarketReport,
PriceTrend,
ValuationEstimate,
FeatureExtractionResult,
} from './shared/types';

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

View File

@@ -0,0 +1,63 @@
import { Injectable, Inject, type OnModuleInit } from '@nestjs/common';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { MCP_MODULE_OPTIONS, type McpModuleOptions } from './mcp.module';
@Injectable()
export class McpRegistryService implements OnModuleInit {
private readonly servers = new Map<string, McpServer>();
private typesenseClient: import('typesense').Client | null = null;
constructor(
@Inject(MCP_MODULE_OPTIONS) private readonly options: McpModuleOptions,
) {}
async onModuleInit(): Promise<void> {
// Lazy import to avoid hard dependency at module load time
const { createPropertySearchServer } = await import('../property-search/property-search.server');
const { createMarketAnalyticsServer } = await import('../market-analytics/market-analytics.server');
const { createValuationServer } = await import('../valuation/valuation.server');
// Typesense client is injected from the host app via setTypesenseClient
// If not set by the time servers are needed, tools that require it will fail gracefully
if (this.typesenseClient) {
this.servers.set(
'property-search',
createPropertySearchServer({
typesenseClient: this.typesenseClient,
collectionName: this.options.typesenseCollectionName,
}),
);
this.servers.set(
'market-analytics',
createMarketAnalyticsServer({
typesenseClient: this.typesenseClient,
collectionName: this.options.typesenseCollectionName,
}),
);
}
this.servers.set(
'valuation',
createValuationServer({
aiServiceBaseUrl: this.options.aiServiceBaseUrl,
}),
);
}
setTypesenseClient(client: import('typesense').Client): void {
this.typesenseClient = client;
}
getServer(name: string): McpServer | undefined {
return this.servers.get(name);
}
getServerNames(): string[] {
return Array.from(this.servers.keys());
}
getAllServers(): Map<string, McpServer> {
return this.servers;
}
}

View File

@@ -0,0 +1,56 @@
import { Controller, Get, Post, Param, Req, Res, HttpException, HttpStatus } from '@nestjs/common';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import type { Request, Response } from 'express';
import { McpRegistryService } from './mcp-registry.service';
@Controller('mcp')
export class McpTransportController {
private readonly transports = new Map<string, SSEServerTransport>();
constructor(private readonly registry: McpRegistryService) {}
@Get('servers')
listServers(): { servers: string[] } {
return { servers: this.registry.getServerNames() };
}
@Get(':serverName/sse')
async handleSse(
@Param('serverName') serverName: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const server = this.registry.getServer(serverName);
if (!server) {
throw new HttpException(`MCP server "${serverName}" not found`, HttpStatus.NOT_FOUND);
}
const transport = new SSEServerTransport(`/mcp/${serverName}/messages`, res);
this.transports.set(transport.sessionId, transport);
req.on('close', () => {
this.transports.delete(transport.sessionId);
});
await server.connect(transport);
}
@Post(':serverName/messages')
async handleMessage(
@Param('serverName') serverName: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const sessionId = req.query['sessionId'] as string;
if (!sessionId) {
throw new HttpException('Missing sessionId query parameter', HttpStatus.BAD_REQUEST);
}
const transport = this.transports.get(sessionId);
if (!transport) {
throw new HttpException('Session not found or expired', HttpStatus.NOT_FOUND);
}
await transport.handlePostMessage(req, res);
}
}

View File

@@ -0,0 +1,28 @@
import { Module, type DynamicModule, type Provider } from '@nestjs/common';
import { McpRegistryService } from './mcp-registry.service';
import { McpTransportController } from './mcp-transport.controller';
export interface McpModuleOptions {
aiServiceBaseUrl: string;
typesenseCollectionName?: string;
}
export const MCP_MODULE_OPTIONS = Symbol('MCP_MODULE_OPTIONS');
@Module({})
export class McpModule {
static forRoot(options: McpModuleOptions): DynamicModule {
const optionsProvider: Provider = {
provide: MCP_MODULE_OPTIONS,
useValue: options,
};
return {
module: McpModule,
controllers: [McpTransportController],
providers: [optionsProvider, McpRegistryService],
exports: [McpRegistryService],
global: false,
};
}
}

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

View File

@@ -0,0 +1,89 @@
import type { Client as TypesenseClient } from 'typesense';
export interface PropertySearchDeps {
typesenseClient: TypesenseClient;
collectionName?: string;
}
export interface MarketAnalyticsDeps {
typesenseClient: TypesenseClient;
collectionName?: string;
}
export interface ValuationDeps {
aiServiceBaseUrl: string;
}
export interface McpServerConfig {
name: string;
version: string;
}
export interface PropertyComparisonResult {
properties: PropertySummary[];
comparison: {
priceRange: { min: number; max: number; avg: number };
areaRange: { min: number; max: number; avg: number };
pricePerM2Range: { min: number; max: number; avg: number };
};
}
export interface PropertySummary {
listingId: string;
title: string;
propertyType: string;
transactionType: string;
priceVND: number;
pricePerM2: number | null;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
address: string;
district: string;
city: string;
}
export interface MarketReport {
district: string;
city: string;
propertyType: string | null;
totalListings: number;
averagePriceVND: number;
medianPriceVND: number;
averagePricePerM2: number;
averageArea: number;
priceDistribution: { range: string; count: number }[];
}
export interface PriceTrend {
district: string;
city: string;
period: string;
dataPoints: { label: string; avgPrice: number; count: number }[];
}
export interface ValuationEstimate {
estimatedPriceVND: number;
confidence: number;
pricePerM2: number;
priceRangeLow: number;
priceRangeHigh: number;
}
export interface FeatureExtractionResult {
features: {
area: number | null;
district: string | null;
city: string | null;
propertyType: string | null;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
frontage: number | null;
roadWidth: number | null;
priceMentioned: number | null;
hasLegalPaper: boolean | null;
};
tokens: string[];
entities: { text: string; label: string }[];
}

View File

@@ -0,0 +1,234 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod/v3';
import type { ValuationDeps } from '../shared/types';
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const;
interface AVMResponse {
estimated_price_vnd: number;
confidence: number;
price_per_m2: number;
price_range_low: number;
price_range_high: number;
}
interface FeatureExtractResponse {
features?: {
area?: number;
district?: string;
city?: string;
property_type?: string;
bedrooms?: number;
bathrooms?: number;
floors?: number;
frontage?: number;
road_width?: number;
price_mentioned?: number;
has_legal_paper?: boolean;
};
tokens?: string[];
entities?: { text: string; label: string }[];
}
const EstimateValueSchema = {
area: z.number().positive().describe('Property area in m²'),
district: z.string().min(1).describe('District name'),
city: z.string().min(1).describe('City name'),
propertyType: z.enum(PROPERTY_TYPES).describe('Property type'),
bedrooms: z.number().int().min(0).default(0),
bathrooms: z.number().int().min(0).default(0),
floors: z.number().int().min(0).default(0),
frontage: z.number().min(0).default(0).describe('Frontage width in meters'),
roadWidth: z.number().min(0).default(0).describe('Adjacent road width in meters'),
yearBuilt: z.number().int().optional().describe('Year the property was built'),
hasLegalPaper: z.boolean().default(true).describe('Has sổ đỏ/sổ hồng'),
};
const ExtractFeaturesSchema = {
text: z.string().min(1).describe('Vietnamese property listing text to analyze'),
};
const BatchValuationSchema = {
properties: z.array(z.object({
area: z.number().positive(),
district: z.string().min(1),
city: z.string().min(1),
propertyType: z.enum(PROPERTY_TYPES),
bedrooms: z.number().int().min(0).default(0),
bathrooms: z.number().int().min(0).default(0),
floors: z.number().int().min(0).default(0),
frontage: z.number().min(0).default(0),
roadWidth: z.number().min(0).default(0),
yearBuilt: z.number().int().optional(),
hasLegalPaper: z.boolean().default(true),
})).min(1).max(20).describe('Array of properties to valuate'),
};
async function callAVM(baseUrl: string, body: Record<string, unknown>): Promise<{ data?: AVMResponse; error?: string }> {
const response = await fetch(`${baseUrl}/avm/predict`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
return { error: `AVM service error (${response.status}): ${errorText}` };
}
const data = (await response.json()) as AVMResponse;
return { data };
}
export function createValuationServer(deps: ValuationDeps): McpServer {
const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, '');
const server = new McpServer({
name: 'goodgo-valuation',
version: '0.1.0',
});
server.tool(
'estimate_property_value',
"Estimate a property's market value using the AVM. Returns price, confidence, and range.",
EstimateValueSchema,
async (params: z.infer<z.ZodObject<typeof EstimateValueSchema>>) => {
const body = {
area: params.area,
district: params.district,
city: params.city,
property_type: params.propertyType,
bedrooms: params.bedrooms,
bathrooms: params.bathrooms,
floors: params.floors,
frontage: params.frontage,
road_width: params.roadWidth,
year_built: params.yearBuilt ?? null,
has_legal_paper: params.hasLegalPaper,
};
const { data, error } = await callAVM(baseUrl, body);
if (error || !data) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error }) }],
isError: true,
};
}
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
estimatedPriceVND: data.estimated_price_vnd,
confidence: data.confidence,
pricePerM2: data.price_per_m2,
priceRangeLow: data.price_range_low,
priceRangeHigh: data.price_range_high,
input: { area: params.area, district: params.district, city: params.city, propertyType: params.propertyType },
}, null, 2),
}],
};
},
);
server.tool(
'extract_listing_features',
'Extract real estate features from Vietnamese listing text using NLP.',
ExtractFeaturesSchema,
async (params: z.infer<z.ZodObject<typeof ExtractFeaturesSchema>>) => {
const response = await fetch(`${baseUrl}/avm/extract-features`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: params.text }),
});
if (!response.ok) {
const errorText = await response.text();
return {
content: [{
type: 'text' as const,
text: JSON.stringify({ error: `Feature extraction error (${response.status}): ${errorText}` }),
}],
isError: true,
};
}
const result = (await response.json()) as FeatureExtractResponse;
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
features: {
area: result.features?.area ?? null,
district: result.features?.district ?? null,
city: result.features?.city ?? null,
propertyType: result.features?.property_type ?? null,
bedrooms: result.features?.bedrooms ?? null,
bathrooms: result.features?.bathrooms ?? null,
floors: result.features?.floors ?? null,
frontage: result.features?.frontage ?? null,
roadWidth: result.features?.road_width ?? null,
priceMentioned: result.features?.price_mentioned ?? null,
hasLegalPaper: result.features?.has_legal_paper ?? null,
},
tokens: result.tokens ?? [],
entities: result.entities ?? [],
}, null, 2),
}],
};
},
);
server.tool(
'batch_valuation',
'Estimate values for multiple properties at once for portfolio or investment analysis.',
BatchValuationSchema,
async (params: z.infer<z.ZodObject<typeof BatchValuationSchema>>) => {
const results = await Promise.all(
params.properties.map(async (prop, index) => {
const body = {
area: prop.area,
district: prop.district,
city: prop.city,
property_type: prop.propertyType,
bedrooms: prop.bedrooms,
bathrooms: prop.bathrooms,
floors: prop.floors,
frontage: prop.frontage,
road_width: prop.roadWidth,
year_built: prop.yearBuilt ?? null,
has_legal_paper: prop.hasLegalPaper,
};
try {
const { data, error } = await callAVM(baseUrl, body);
if (error || !data) return { index, input: prop, error };
return {
index,
input: { area: prop.area, district: prop.district, city: prop.city, propertyType: prop.propertyType },
valuation: {
estimatedPriceVND: data.estimated_price_vnd,
confidence: data.confidence,
pricePerM2: data.price_per_m2,
priceRangeLow: data.price_range_low,
priceRangeHigh: data.price_range_high,
},
};
} catch (err) {
return { index, input: prop, error: String(err) };
}
}),
);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({ valuations: results }, null, 2),
}],
};
},
);
return server;
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/__tests__/**"]
}

6
pnpm-lock.yaml generated
View File

@@ -60,6 +60,9 @@ importers:
apps/api:
dependencies:
'@goodgo/mcp-servers':
specifier: workspace:*
version: link:../../libs/mcp-servers
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -270,6 +273,9 @@ importers:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/express':
specifier: ^5.0.6
version: 5.0.6
'@types/node':
specifier: ^22.0.0
version: 22.19.17

View File

@@ -1,3 +1,4 @@
packages:
- 'apps/*'
- 'packages/*'
- 'libs/*'