From cb00b12d7bcf6de8e71c8510590137a10cf417dc Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 03:22:27 +0700 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20add=20MCP=20Server=20Integration?= =?UTF-8?q?=20=E2=80=94=20Property=20Search,=20Analytics,=20Valuation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT_TRACKER.md | 67 ++--- apps/api/package.json | 1 + apps/api/src/modules/mcp/index.ts | 1 + apps/api/src/modules/mcp/mcp.module.ts | 35 +++ apps/api/src/modules/search/search.module.ts | 2 +- libs/mcp-servers/package.json | 9 +- libs/mcp-servers/src/index.ts | 23 ++ .../market-analytics.server.ts | 254 ++++++++++++++++++ .../src/nestjs/mcp-registry.service.ts | 63 +++++ .../src/nestjs/mcp-transport.controller.ts | 56 ++++ libs/mcp-servers/src/nestjs/mcp.module.ts | 28 ++ .../property-search/property-search.server.ts | 228 ++++++++++++++++ libs/mcp-servers/src/shared/types.ts | 89 ++++++ .../src/valuation/valuation.server.ts | 234 ++++++++++++++++ libs/mcp-servers/tsconfig.json | 21 ++ pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 1 + 17 files changed, 1077 insertions(+), 41 deletions(-) create mode 100644 apps/api/src/modules/mcp/index.ts create mode 100644 apps/api/src/modules/mcp/mcp.module.ts create mode 100644 libs/mcp-servers/src/index.ts create mode 100644 libs/mcp-servers/src/market-analytics/market-analytics.server.ts create mode 100644 libs/mcp-servers/src/nestjs/mcp-registry.service.ts create mode 100644 libs/mcp-servers/src/nestjs/mcp-transport.controller.ts create mode 100644 libs/mcp-servers/src/nestjs/mcp.module.ts create mode 100644 libs/mcp-servers/src/property-search/property-search.server.ts create mode 100644 libs/mcp-servers/src/shared/types.ts create mode 100644 libs/mcp-servers/src/valuation/valuation.server.ts create mode 100644 libs/mcp-servers/tsconfig.json diff --git a/PROJECT_TRACKER.md b/PROJECT_TRACKER.md index 0bb491f..7433d85 100644 --- a/PROJECT_TRACKER.md +++ b/PROJECT_TRACKER.md @@ -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**| diff --git a/apps/api/package.json b/apps/api/package.json index ef8061c..78a3a45 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/modules/mcp/index.ts b/apps/api/src/modules/mcp/index.ts new file mode 100644 index 0000000..2bde72f --- /dev/null +++ b/apps/api/src/modules/mcp/index.ts @@ -0,0 +1 @@ +export { McpIntegrationModule } from './mcp.module'; diff --git a/apps/api/src/modules/mcp/mcp.module.ts b/apps/api/src/modules/mcp/mcp.module.ts new file mode 100644 index 0000000..dc86d83 --- /dev/null +++ b/apps/api/src/modules/mcp/mcp.module.ts @@ -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 { + 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', + ); + } +} diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index 57deafe..ace0127 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -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( diff --git a/libs/mcp-servers/package.json b/libs/mcp-servers/package.json index c580582..94631ef 100644 --- a/libs/mcp-servers/package.json +++ b/libs/mcp-servers/package.json @@ -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 + } } } diff --git a/libs/mcp-servers/src/index.ts b/libs/mcp-servers/src/index.ts new file mode 100644 index 0000000..d14584f --- /dev/null +++ b/libs/mcp-servers/src/index.ts @@ -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'; diff --git a/libs/mcp-servers/src/market-analytics/market-analytics.server.ts b/libs/mcp-servers/src/market-analytics/market-analytics.server.ts new file mode 100644 index 0000000..de2067d --- /dev/null +++ b/libs/mcp-servers/src/market-analytics/market-analytics.server.ts @@ -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>) => { + 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); + 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(); + 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>) => { + 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); + + const monthlyData = new Map(); + 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>) => { + 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); + 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`; +} diff --git a/libs/mcp-servers/src/nestjs/mcp-registry.service.ts b/libs/mcp-servers/src/nestjs/mcp-registry.service.ts new file mode 100644 index 0000000..8605780 --- /dev/null +++ b/libs/mcp-servers/src/nestjs/mcp-registry.service.ts @@ -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(); + private typesenseClient: import('typesense').Client | null = null; + + constructor( + @Inject(MCP_MODULE_OPTIONS) private readonly options: McpModuleOptions, + ) {} + + async onModuleInit(): Promise { + // 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 { + return this.servers; + } +} diff --git a/libs/mcp-servers/src/nestjs/mcp-transport.controller.ts b/libs/mcp-servers/src/nestjs/mcp-transport.controller.ts new file mode 100644 index 0000000..3d0eacc --- /dev/null +++ b/libs/mcp-servers/src/nestjs/mcp-transport.controller.ts @@ -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(); + + 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 { + 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 { + 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); + } +} diff --git a/libs/mcp-servers/src/nestjs/mcp.module.ts b/libs/mcp-servers/src/nestjs/mcp.module.ts new file mode 100644 index 0000000..6f85429 --- /dev/null +++ b/libs/mcp-servers/src/nestjs/mcp.module.ts @@ -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, + }; + } +} diff --git a/libs/mcp-servers/src/property-search/property-search.server.ts b/libs/mcp-servers/src/property-search/property-search.server.ts new file mode 100644 index 0000000..62d2fe3 --- /dev/null +++ b/libs/mcp-servers/src/property-search/property-search.server.ts @@ -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>) => { + 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; + 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>) => { + 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; + 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>) => { + 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; +} diff --git a/libs/mcp-servers/src/shared/types.ts b/libs/mcp-servers/src/shared/types.ts new file mode 100644 index 0000000..cbc2925 --- /dev/null +++ b/libs/mcp-servers/src/shared/types.ts @@ -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 }[]; +} diff --git a/libs/mcp-servers/src/valuation/valuation.server.ts b/libs/mcp-servers/src/valuation/valuation.server.ts new file mode 100644 index 0000000..c87ee61 --- /dev/null +++ b/libs/mcp-servers/src/valuation/valuation.server.ts @@ -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): 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>) => { + 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>) => { + 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>) => { + 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; +} diff --git a/libs/mcp-servers/tsconfig.json b/libs/mcp-servers/tsconfig.json new file mode 100644 index 0000000..b3ec41b --- /dev/null +++ b/libs/mcp-servers/tsconfig.json @@ -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__/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f8b45c..954eb71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9b0dad..4693470 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'apps/*' - 'packages/*' + - 'libs/*'