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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Last Updated:** 2026-04-08
|
**Last Updated:** 2026-04-08
|
||||||
**Project:** Goodgo Platform AI
|
**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)
|
## Phase 1: Core Auth & Listings (P1)
|
||||||
|
|
||||||
| Issue | Title | Priority | Status | Commit |
|
| Issue | Title | Priority | Status | Commit |
|
||||||
| -------------------------------- | ------------------------------------------------- | -------- | ----------- | ------ |
|
| -------------------------------- | ------------------------------------------------- | -------- | ------ | ------ |
|
||||||
| [TEC-1421](/TEC/issues/TEC-1421) | Auth Module Backend (Register, Login, JWT, OAuth) | Critical | done | 391c040 |
|
| [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-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-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-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-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-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-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 |
|
| [TEC-1428](/TEC/issues/TEC-1428) | Search + Landing Page Frontend | High | done | 5e44456 |
|
||||||
|
|
||||||
## Phase 2: Monetization & Operations (P2)
|
## Phase 2: Monetization & Operations (P2)
|
||||||
|
|
||||||
| Issue | Title | Priority | Status | Commit |
|
| Issue | Title | Priority | Status | Commit |
|
||||||
| -------------------------------- | ----------------------------------------------- | -------- | ----------- | ------ |
|
| -------------------------------- | ----------------------------------------------- | -------- | ------ | ------ |
|
||||||
| [TEC-1429](/TEC/issues/TEC-1429) | Payments Module (VNPay + MoMo + ZaloPay) | Medium | done | ad77139 |
|
| [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-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-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-1432](/TEC/issues/TEC-1432) | Admin Module (Backend + Frontend) | Medium | done | 6123fc4 |
|
||||||
| [TEC-1433](/TEC/issues/TEC-1433) | E2E Testing Setup (Playwright) | Medium | done | 9301f44 |
|
| [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)
|
| Issue | Title | Priority | Status |
|
||||||
- Analytics Module (Market reports, AVM)
|
| ----- | ------------------------------------------------ | -------- | ------ |
|
||||||
- MCP Server Integration
|
| — | Analytics Module (Market Reports, Price Index) | High | todo |
|
||||||
- Performance Monitoring (Prometheus + Grafana)
|
| — | 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
|
## Summary
|
||||||
|
|
||||||
| Phase | Total | Done | In Progress | Todo |
|
| Phase | Total | Done | In Progress | Todo |
|
||||||
| --------- | ------ | ----- | ----------- | ---- |
|
| --------- | ------ | ----- | ----------- | ---- |
|
||||||
| Phase 0 | 6 | 6 | 0 | 0 |
|
| Phase 0 | 6 | 6 | 0 | 0 |
|
||||||
| Phase 1 | 8 | 7 | 0 | 1 |
|
| Phase 1 | 8 | 8 | 0 | 0 |
|
||||||
| Phase 2 | 5 | 4 | 0 | 1 |
|
| Phase 2 | 5 | 5 | 0 | 0 |
|
||||||
| Phase 3 | 4 | — | — | 4 |
|
| Phase 3 | 4 | 0 | 0 | 4 |
|
||||||
| **Total** | **23** | **17**| **0** | **6**|
|
| **Total** | **23** | **19**| **0** | **4**|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@goodgo/mcp-servers": "workspace:*",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/cqrs": "^11.0.0",
|
"@nestjs/cqrs": "^11.0.0",
|
||||||
|
|||||||
1
apps/api/src/modules/mcp/index.ts
Normal file
1
apps/api/src/modules/mcp/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { McpIntegrationModule } from './mcp.module';
|
||||||
35
apps/api/src/modules/mcp/mcp.module.ts
Normal file
35
apps/api/src/modules/mcp/mcp.module.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
|
|||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
],
|
],
|
||||||
exports: [ListingIndexerService, SEARCH_REPOSITORY],
|
exports: [ListingIndexerService, SEARCH_REPOSITORY, TypesenseClientService],
|
||||||
})
|
})
|
||||||
export class SearchModule implements OnModuleInit {
|
export class SearchModule implements OnModuleInit {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
@@ -24,7 +25,11 @@
|
|||||||
"typesense": "^3.0.0"
|
"typesense": "^3.0.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@nestjs/common": { "optional": true },
|
"@nestjs/common": {
|
||||||
"typesense": { "optional": true }
|
"optional": true
|
||||||
|
},
|
||||||
|
"typesense": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
libs/mcp-servers/src/index.ts
Normal file
23
libs/mcp-servers/src/index.ts
Normal 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';
|
||||||
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`;
|
||||||
|
}
|
||||||
63
libs/mcp-servers/src/nestjs/mcp-registry.service.ts
Normal file
63
libs/mcp-servers/src/nestjs/mcp-registry.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
libs/mcp-servers/src/nestjs/mcp-transport.controller.ts
Normal file
56
libs/mcp-servers/src/nestjs/mcp-transport.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
libs/mcp-servers/src/nestjs/mcp.module.ts
Normal file
28
libs/mcp-servers/src/nestjs/mcp.module.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
89
libs/mcp-servers/src/shared/types.ts
Normal file
89
libs/mcp-servers/src/shared/types.ts
Normal 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 }[];
|
||||||
|
}
|
||||||
234
libs/mcp-servers/src/valuation/valuation.server.ts
Normal file
234
libs/mcp-servers/src/valuation/valuation.server.ts
Normal 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;
|
||||||
|
}
|
||||||
21
libs/mcp-servers/tsconfig.json
Normal file
21
libs/mcp-servers/tsconfig.json
Normal 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
6
pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
|||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@goodgo/mcp-servers':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../libs/mcp-servers
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.0
|
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)
|
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
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/express':
|
||||||
|
specifier: ^5.0.6
|
||||||
|
version: 5.0.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.17
|
version: 22.19.17
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'apps/*'
|
- 'apps/*'
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
|
- 'libs/*'
|
||||||
|
|||||||
Reference in New Issue
Block a user