From 53c33a1c5025fe84399d24dbdd874a933757b82d Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 05:16:11 +0700 Subject: [PATCH] feat(mcp): add industrial parks and reports MCP tool servers Add IndustrialParkServer for KCN/KCX search and analytics, and ReportsServer for market report generation. Include unit tests for industrial parks server. Co-Authored-By: Paperclip --- .../__tests__/industrial-parks.server.test.ts | 285 ++++++++++++++++ .../src/__tests__/reports.server.test.ts | 273 ++++++++++++++++ libs/mcp-servers/src/index.ts | 5 + .../industrial-parks.server.ts | 307 ++++++++++++++++++ .../src/nestjs/mcp-registry.service.ts | 19 ++ .../mcp-servers/src/reports/reports.server.ts | 195 +++++++++++ libs/mcp-servers/src/shared/types.ts | 28 ++ 7 files changed, 1112 insertions(+) create mode 100644 libs/mcp-servers/src/__tests__/industrial-parks.server.test.ts create mode 100644 libs/mcp-servers/src/__tests__/reports.server.test.ts create mode 100644 libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts create mode 100644 libs/mcp-servers/src/reports/reports.server.ts diff --git a/libs/mcp-servers/src/__tests__/industrial-parks.server.test.ts b/libs/mcp-servers/src/__tests__/industrial-parks.server.test.ts new file mode 100644 index 0000000..7660152 --- /dev/null +++ b/libs/mcp-servers/src/__tests__/industrial-parks.server.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createIndustrialParksServer } from '../industrial-parks/industrial-parks.server'; +import type { IndustrialParksDeps } from '../shared/types'; + +type ToolResult = { content: { type: string; text: string }[]; isError?: boolean }; + +function createMockClient(searchResult: unknown = { hits: [], found: 0 }) { + const search = vi.fn().mockResolvedValue(searchResult); + return { + collections: vi.fn().mockReturnValue({ + documents: vi.fn().mockReturnValue({ search }), + }), + _search: search, + }; +} + +function makeDeps(client: ReturnType): IndustrialParksDeps { + return { + typesenseClient: client as unknown as IndustrialParksDeps['typesenseClient'], + collectionName: 'test-industrial-parks', + aiServiceBaseUrl: 'http://localhost:8000', + }; +} + +function makeHits(docs: Record[]) { + return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 3 }; +} + +function getToolHandler(server: ReturnType, name: string) { + const tools = (server as unknown as { _registeredTools: Record Promise }> })._registeredTools; + const entry = tools[name]; + if (!entry) throw new Error(`Tool "${name}" not found`); + return entry.handler; +} + +function parseResult(result: ToolResult) { + return JSON.parse(result.content[0].text) as Record; +} + +const samplePark = { + parkId: 'park-001', + name: 'KCN VSIP Bình Dương', + nameEn: 'VSIP Binh Duong', + developer: 'VSIP Group', + province: 'Bình Dương', + region: 'south', + status: 'operational', + totalAreaHa: 500, + remainingAreaHa: 80, + occupancyRate: 84, + landRentUsdM2Year: 85, + rbfRentUsdM2Month: 4.5, + rbwRentUsdM2Month: 3.8, + targetIndustries: ['electronics', 'logistics'], + tenantCount: 120, +}; + +describe('IndustrialParksServer', () => { + let client: ReturnType; + let server: ReturnType; + + beforeEach(() => { + client = createMockClient(makeHits([samplePark])); + server = createIndustrialParksServer(makeDeps(client)); + vi.restoreAllMocks(); + }); + + it('creates server with correct name', () => { + expect(server).toBeDefined(); + }); + + describe('search_industrial_parks', () => { + it('searches with basic query', async () => { + const handler = getToolHandler(server, 'search_industrial_parks'); + const result = await handler({ query: 'KCN Bình Dương', page: 1, perPage: 20 }); + + expect(client.collections).toHaveBeenCalledWith('test-industrial-parks'); + expect(client._search).toHaveBeenCalledWith( + expect.objectContaining({ + q: 'KCN Bình Dương', + query_by: 'name,nameEn,developer,operator,province,targetIndustries', + }), + ); + + const data = parseResult(result); + expect(data.totalFound).toBe(1); + expect(data.results).toHaveLength(1); + expect((data.results as Record[])[0].parkId).toBe('park-001'); + }); + + it('applies province and status filters', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_industrial_parks'); + + await handler({ + query: '*', + province: 'Bình Dương', + status: 'operational', + page: 1, + perPage: 20, + }); + + const filterBy = client._search.mock.calls[0][0].filter_by as string; + expect(filterBy).toContain('province:=Bình Dương'); + expect(filterBy).toContain('status:=operational'); + }); + + it('applies area and rent filters', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_industrial_parks'); + + await handler({ + query: '*', + minAreaHa: 10, + maxRentUsdM2: 100, + page: 1, + perPage: 20, + }); + + const filterBy = client._search.mock.calls[0][0].filter_by as string; + expect(filterBy).toContain('remainingAreaHa:>=10'); + expect(filterBy).toContain('landRentUsdM2Year:<=100'); + }); + + it('applies geo filter when coordinates provided', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_industrial_parks'); + + await handler({ + query: '*', + latitude: 11.0, + longitude: 106.6, + radiusKm: 30, + page: 1, + perPage: 20, + }); + + const filterBy = client._search.mock.calls[0][0].filter_by as string; + expect(filterBy).toContain('location:(11, 106.6, 30 km)'); + }); + + it('sorts by rent ascending', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_industrial_parks'); + + await handler({ query: '*', sortBy: 'rent_asc', page: 1, perPage: 20 }); + expect(client._search.mock.calls[0][0].sort_by).toBe('landRentUsdM2Year:asc'); + }); + + it('sorts by remaining area descending', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_industrial_parks'); + + await handler({ query: '*', sortBy: 'area_desc', page: 1, perPage: 20 }); + expect(client._search.mock.calls[0][0].sort_by).toBe('remainingAreaHa:desc'); + }); + + it('returns empty results gracefully', async () => { + client._search.mockResolvedValue(makeHits([])); + const handler = getToolHandler(server, 'search_industrial_parks'); + const result = await handler({ query: 'nonexistent', page: 1, perPage: 20 }); + const data = parseResult(result); + + expect(data.totalFound).toBe(0); + expect(data.results).toHaveLength(0); + }); + }); + + describe('analyze_industrial_location', () => { + it('calls AI service and returns analysis', async () => { + const mockResponse = { + overall_score: 8.5, + connectivity: { + nearest_port: { name: 'Cảng Cát Lái', distanceKm: 25 }, + nearest_airport: { name: 'Tân Sơn Nhất', distanceKm: 30 }, + nearest_highway: { name: 'QL1A', distanceKm: 2 }, + }, + infrastructure: { + power_availability: '110kV substation on-site', + water_supply: 'Municipal + on-site treatment', + wastewater_treatment: 'Central WWTP 5000m³/day', + telecom: 'Fiber optic available', + }, + labor_market: { + worker_pool_radius_30km: 500000, + average_wage_usd: 280, + nearby_universities: ['ĐH Bình Dương', 'ĐH Thủ Dầu Một'], + }, + incentives: ['CIT 10% for 15 years', 'Import duty exemption'], + risks: ['Flooding risk in rainy season'], + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const handler = getToolHandler(server, 'analyze_industrial_location'); + const result = await handler({ latitude: 11.0, longitude: 106.6 }); + const data = parseResult(result); + + expect(data.overallScore).toBe(8.5); + expect((data.connectivity as Record).nearestPort).toEqual({ name: 'Cảng Cát Lái', distanceKm: 25 }); + expect((data.laborMarket as Record).nearbyUniversities).toHaveLength(2); + expect(data.incentives).toHaveLength(2); + }); + + it('returns error on AI service failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + } as Response); + + const handler = getToolHandler(server, 'analyze_industrial_location'); + const result = await handler({ latitude: 11.0, longitude: 106.6 }); + + expect(result.isError).toBe(true); + const data = parseResult(result); + expect(data.error).toContain('Location analysis error'); + }); + }); + + describe('estimate_industrial_rent', () => { + it('calls AI service and returns rent estimate', async () => { + const mockResponse = { + estimated_rent_usd_m2: 4.2, + pricing_unit: 'USD/m²/month', + total_monthly_usd: 42000, + total_lease_usd: 5040000, + management_fee_usd_m2: 0.5, + deposit_months: 3, + market_comparison: { + province_low: 3.0, + province_high: 6.5, + province_avg: 4.5, + }, + breakdown: [ + { item: 'Base rent', amount: 35000 }, + { item: 'Management fee', amount: 5000 }, + { item: 'Utilities surcharge', amount: 2000 }, + ], + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const handler = getToolHandler(server, 'estimate_industrial_rent'); + const result = await handler({ + province: 'Bình Dương', + propertyType: 'ready_built_factory', + areaM2: 10000, + leaseDurationYears: 10, + }); + const data = parseResult(result); + + expect(data.estimatedRentUsdM2).toBe(4.2); + expect(data.totalMonthlyUsd).toBe(42000); + expect((data.marketComparison as Record).provinceAvg).toBe(4.5); + expect(data.breakdown).toHaveLength(3); + }); + + it('returns error on AI service failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 400, + text: async () => 'Invalid province', + } as Response); + + const handler = getToolHandler(server, 'estimate_industrial_rent'); + const result = await handler({ + province: 'Invalid', + propertyType: 'industrial_land', + areaM2: 5000, + leaseDurationYears: 10, + }); + + expect(result.isError).toBe(true); + const data = parseResult(result); + expect(data.error).toContain('Rent estimation error'); + }); + }); +}); diff --git a/libs/mcp-servers/src/__tests__/reports.server.test.ts b/libs/mcp-servers/src/__tests__/reports.server.test.ts new file mode 100644 index 0000000..56bb9e8 --- /dev/null +++ b/libs/mcp-servers/src/__tests__/reports.server.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createReportsServer } from '../reports/reports.server'; +import type { ReportsDeps } from '../shared/types'; + +type ToolResult = { content: { type: string; text: string }[]; isError?: boolean }; + +function makeDeps(): ReportsDeps { + return { aiServiceBaseUrl: 'http://localhost:8000' }; +} + +function getToolHandler(server: ReturnType, name: string) { + const tools = (server as unknown as { _registeredTools: Record Promise }> })._registeredTools; + const entry = tools[name]; + if (!entry) throw new Error(`Tool "${name}" not found`); + return entry.handler; +} + +function parseResult(result: ToolResult) { + return JSON.parse(result.content[0].text) as Record; +} + +describe('ReportsServer', () => { + let server: ReturnType; + + beforeEach(() => { + server = createReportsServer(makeDeps()); + vi.restoreAllMocks(); + }); + + it('creates server with correct name', () => { + expect(server).toBeDefined(); + }); + + describe('generate_report', () => { + it('generates a market overview report', async () => { + const mockResponse = { + report_id: 'rpt-001', + report_type: 'market_overview', + title: 'Báo cáo thị trường BĐS Quận 7', + location: 'Quận 7, Hồ Chí Minh', + generated_at: '2026-04-16T10:00:00Z', + summary: 'Thị trường Q7 tiếp tục tăng trưởng ổn định...', + sections: [ + { + title: 'Tổng quan', + content: 'Quận 7 ghi nhận 1,200 giao dịch...', + charts: [{ type: 'line', title: 'Giá trung bình', data: [] }], + }, + ], + key_metrics: { + avgPriceVND: 4_500_000_000, + totalListings: 1200, + avgPricePerM2: 55_000_000, + }, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const handler = getToolHandler(server, 'generate_report'); + const result = await handler({ + reportType: 'market_overview', + location: 'Quận 7, Hồ Chí Minh', + period: '1y', + includeForecasts: false, + includeMacro: false, + language: 'vi', + }); + const data = parseResult(result); + + expect(data.reportId).toBe('rpt-001'); + expect(data.reportType).toBe('market_overview'); + expect(data.title).toContain('Quận 7'); + expect(data.sections).toHaveLength(1); + expect((data.keyMetrics as Record).totalListings).toBe(1200); + }); + + it('generates report with forecasts', async () => { + const mockResponse = { + report_id: 'rpt-002', + report_type: 'price_forecast', + title: 'Price Forecast - District 2', + location: 'Quận 2', + generated_at: '2026-04-16T10:00:00Z', + summary: 'Prices expected to increase 5-8% over next 12 months.', + sections: [], + key_metrics: {}, + forecasts: { + price_trend: [ + { period: 'Q3 2026', predicted_change_pct: 2.5 }, + { period: 'Q4 2026', predicted_change_pct: 3.1 }, + ], + demand_trend: [ + { period: 'Q3 2026', predicted_change_pct: 1.8 }, + ], + confidence: 0.82, + }, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const handler = getToolHandler(server, 'generate_report'); + const result = await handler({ + reportType: 'price_forecast', + location: 'Quận 2', + includeForecasts: true, + includeMacro: false, + period: '1y', + language: 'en', + }); + const data = parseResult(result); + + expect(data.forecasts).toBeDefined(); + const forecasts = data.forecasts as Record; + expect(forecasts.confidence).toBe(0.82); + expect((forecasts.price_trend as unknown[]).length).toBe(2); + }); + + it('sends correct request body to AI service', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ + report_id: 'rpt-003', + report_type: 'industrial_zone', + title: 'Test', + location: 'Bình Dương', + generated_at: '2026-04-16T10:00:00Z', + summary: 'Test', + }), + } as Response); + + const handler = getToolHandler(server, 'generate_report'); + await handler({ + reportType: 'industrial_zone', + location: 'Bình Dương', + propertyType: 'industrial', + period: '6m', + includeForecasts: true, + includeMacro: true, + language: 'vi', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:8000/reports/generate', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record; + expect(body.report_type).toBe('industrial_zone'); + expect(body.location).toBe('Bình Dương'); + expect(body.property_type).toBe('industrial'); + expect(body.period).toBe('6m'); + expect(body.include_forecasts).toBe(true); + expect(body.include_macro).toBe(true); + expect(body.language).toBe('vi'); + }); + + it('returns error on service failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => 'Service Unavailable', + } as Response); + + const handler = getToolHandler(server, 'generate_report'); + const result = await handler({ + reportType: 'market_overview', + location: 'Test', + period: '1y', + includeForecasts: false, + includeMacro: false, + language: 'vi', + }); + + expect(result.isError).toBe(true); + const data = parseResult(result); + expect(data.error).toContain('Report generation error (503)'); + }); + }); + + describe('get_macro_data', () => { + it('retrieves GDP and population data', async () => { + const mockResponse = { + province: 'Bình Dương', + data: { + gdp: [ + { year: 2023, value: 18.5, unit: 'billion USD', yoy_change: 7.2 }, + { year: 2024, value: 20.1, unit: 'billion USD', yoy_change: 8.6 }, + ], + population: [ + { year: 2023, value: 2800000, unit: 'people', yoy_change: 3.1 }, + { year: 2024, value: 2890000, unit: 'people', yoy_change: 3.2 }, + ], + }, + highlights: [ + 'GDP growth exceeds national average', + 'Strong FDI inflow from electronics sector', + ], + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const handler = getToolHandler(server, 'get_macro_data'); + const result = await handler({ + province: 'Bình Dương', + categories: ['gdp', 'population'], + fromYear: 2023, + toYear: 2024, + }); + const data = parseResult(result); + + expect(data.province).toBe('Bình Dương'); + const macroData = data.data as Record; + expect(macroData.gdp).toHaveLength(2); + expect(macroData.population).toHaveLength(2); + expect((macroData.gdp[0] as Record).year).toBe(2023); + expect((macroData.gdp[0] as Record).yoyChange).toBe(7.2); + expect(data.highlights).toHaveLength(2); + }); + + it('sends correct request body', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ province: 'Hồ Chí Minh', data: {}, highlights: [] }), + } as Response); + + const handler = getToolHandler(server, 'get_macro_data'); + await handler({ + province: 'Hồ Chí Minh', + categories: ['fdi', 'infrastructure'], + fromYear: 2020, + toYear: 2025, + }); + + const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record; + expect(body.province).toBe('Hồ Chí Minh'); + expect(body.categories).toEqual(['fdi', 'infrastructure']); + expect(body.from_year).toBe(2020); + expect(body.to_year).toBe(2025); + }); + + it('returns error on service failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Province not found', + } as Response); + + const handler = getToolHandler(server, 'get_macro_data'); + const result = await handler({ + province: 'Unknown', + categories: ['gdp'], + fromYear: 2020, + toYear: 2025, + }); + + expect(result.isError).toBe(true); + const data = parseResult(result); + expect(data.error).toContain('Macro data error (404)'); + }); + }); +}); diff --git a/libs/mcp-servers/src/index.ts b/libs/mcp-servers/src/index.ts index f255b6d..525ce38 100644 --- a/libs/mcp-servers/src/index.ts +++ b/libs/mcp-servers/src/index.ts @@ -2,6 +2,8 @@ export { createPropertySearchServer } from './property-search/property-search.server'; export { createMarketAnalyticsServer } from './market-analytics/market-analytics.server'; export { createValuationServer } from './valuation/valuation.server'; +export { createIndustrialParksServer } from './industrial-parks/industrial-parks.server'; +export { createReportsServer } from './reports/reports.server'; // MCP SDK re-exports (for host apps that build custom controllers) export { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; @@ -16,6 +18,8 @@ export type { PropertySearchDeps, MarketAnalyticsDeps, ValuationDeps, + IndustrialParksDeps, + ReportsDeps, McpServerConfig, PropertySummary, PropertyComparisonResult, @@ -23,4 +27,5 @@ export type { PriceTrend, ValuationEstimate, FeatureExtractionResult, + IndustrialParkSummary, } from './shared/types'; diff --git a/libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts b/libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts new file mode 100644 index 0000000..2571c0e --- /dev/null +++ b/libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts @@ -0,0 +1,307 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod/v3'; +import type { IndustrialParksDeps } from '../shared/types'; + +const DEFAULT_COLLECTION = 'industrial_parks'; + +const INDUSTRIAL_PROPERTY_TYPES = [ + 'industrial_land', + 'ready_built_factory', + 'ready_built_warehouse', + 'logistics_center', + 'office_in_park', + 'data_center', +] as const; + +const PARK_STATUSES = ['planning', 'under_construction', 'operational', 'full'] as const; + +const REGIONS = ['north', 'central', 'south'] as const; + +const SearchIndustrialParksSchema = { + query: z.string().describe('Natural language search query (e.g. "KCN Bình Dương còn đất trống")'), + province: z.string().optional().describe('Filter by province (e.g. "Bình Dương", "Đồng Nai")'), + region: z.enum(REGIONS).optional().describe('Filter by region'), + status: z.enum(PARK_STATUSES).optional().describe('Filter by park status'), + minAreaHa: z.number().optional().describe('Minimum remaining leasable area in hectares'), + maxRentUsdM2: z.number().optional().describe('Maximum land rent in USD/m²/year'), + targetIndustry: z.string().optional().describe('Target industry (e.g. "electronics", "logistics", "food processing")'), + hasReadyBuilt: z.boolean().optional().describe('Filter parks with ready-built factory/warehouse'), + 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', 'rent_asc', 'rent_desc', 'occupancy_asc', 'area_desc', 'distance']).optional(), + page: z.number().int().min(1).default(1), + perPage: z.number().int().min(1).max(50).default(20), +}; + +const AnalyzeIndustrialLocationSchema = { + parkName: z.string().optional().describe('Industrial park name to analyze'), + latitude: z.number().describe('Latitude of the location'), + longitude: z.number().describe('Longitude of the location'), + targetIndustry: z.string().optional().describe('Industry type for relevance scoring'), +}; + +const EstimateIndustrialRentSchema = { + province: z.string().describe('Province name'), + propertyType: z.enum(INDUSTRIAL_PROPERTY_TYPES).describe('Type of industrial property'), + areaM2: z.number().positive().describe('Required area in m²'), + leaseDurationYears: z.number().int().min(1).max(70).default(10).describe('Lease duration in years'), + parkName: z.string().optional().describe('Specific industrial park name for precise estimate'), + requiresCrane: z.boolean().optional().describe('Requires overhead crane'), + requiredPowerKva: z.number().optional().describe('Required power capacity in KVA'), + requiresWastewater: z.boolean().optional().describe('Requires wastewater treatment'), +}; + +export function createIndustrialParksServer(deps: IndustrialParksDeps): McpServer { + const collectionName = deps.collectionName ?? DEFAULT_COLLECTION; + const client = deps.typesenseClient; + const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, ''); + + const server = new McpServer({ + name: 'goodgo-industrial-parks', + version: '0.1.0', + }); + + server.tool( + 'search_industrial_parks', + 'Search Vietnamese industrial parks and zones with filters for province, area, rent, and target industry.', + SearchIndustrialParksSchema, + async (params: z.infer>) => { + const filters: string[] = []; + + if (params.province) filters.push(`province:=${params.province}`); + if (params.region) filters.push(`region:=${params.region}`); + if (params.status) filters.push(`status:=${params.status}`); + if (params.minAreaHa != null) filters.push(`remainingAreaHa:>=${params.minAreaHa}`); + if (params.maxRentUsdM2 != null) filters.push(`landRentUsdM2Year:<=${params.maxRentUsdM2}`); + if (params.targetIndustry) filters.push(`targetIndustries:=${params.targetIndustry}`); + if (params.hasReadyBuilt) filters.push(`hasReadyBuilt:=true`); + + 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 ? `${filterBy} && ${geoFilter}` : geoFilter; + } + + let sortBy = 'occupancyRate:asc'; + if (params.sortBy === 'rent_asc') sortBy = 'landRentUsdM2Year:asc'; + else if (params.sortBy === 'rent_desc') sortBy = 'landRentUsdM2Year:desc'; + else if (params.sortBy === 'occupancy_asc') sortBy = 'occupancyRate:asc'; + else if (params.sortBy === 'area_desc') sortBy = 'remainingAreaHa: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,occupancyRate:asc'; + } + + const result = await client + .collections(collectionName) + .documents() + .search({ + q: params.query || '*', + query_by: 'name,nameEn,developer,operator,province,targetIndustries', + query_by_weights: '5,4,2,2,3,3', + filter_by: filterBy || undefined, + sort_by: sortBy, + page: params.page, + per_page: params.perPage, + }); + + const hits = (result.hits ?? []).map((hit) => { + const doc = hit.document as Record; + return { + parkId: doc['parkId'], + name: doc['name'], + nameEn: doc['nameEn'] ?? null, + developer: doc['developer'], + province: doc['province'], + region: doc['region'], + status: doc['status'], + totalAreaHa: doc['totalAreaHa'], + remainingAreaHa: doc['remainingAreaHa'], + occupancyRate: doc['occupancyRate'], + landRentUsdM2Year: doc['landRentUsdM2Year'] ?? null, + rbfRentUsdM2Month: doc['rbfRentUsdM2Month'] ?? null, + rbwRentUsdM2Month: doc['rbwRentUsdM2Month'] ?? null, + targetIndustries: doc['targetIndustries'] ?? [], + tenantCount: doc['tenantCount'] ?? 0, + }; + }); + + 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( + 'analyze_industrial_location', + 'Analyze an industrial location for connectivity, infrastructure, and suitability scoring.', + AnalyzeIndustrialLocationSchema, + async (params: z.infer>) => { + const response = await fetch(`${baseUrl}/industrial/analyze-location`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + park_name: params.parkName ?? null, + latitude: params.latitude, + longitude: params.longitude, + target_industry: params.targetIndustry ?? null, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ error: `Location analysis error (${response.status}): ${errorText}` }), + }], + isError: true, + }; + } + + const data = (await response.json()) as IndustrialLocationAnalysis; + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + overallScore: data.overall_score, + connectivity: { + nearestPort: data.connectivity?.nearest_port ?? null, + nearestAirport: data.connectivity?.nearest_airport ?? null, + nearestHighway: data.connectivity?.nearest_highway ?? null, + nearestRailway: data.connectivity?.nearest_railway ?? null, + }, + infrastructure: { + powerAvailability: data.infrastructure?.power_availability ?? null, + waterSupply: data.infrastructure?.water_supply ?? null, + wastewaterTreatment: data.infrastructure?.wastewater_treatment ?? null, + telecom: data.infrastructure?.telecom ?? null, + }, + laborMarket: { + workerPoolRadius30km: data.labor_market?.worker_pool_radius_30km ?? null, + averageWageUsd: data.labor_market?.average_wage_usd ?? null, + nearbyUniversities: data.labor_market?.nearby_universities ?? [], + }, + incentives: data.incentives ?? [], + risks: data.risks ?? [], + }, null, 2), + }], + }; + }, + ); + + server.tool( + 'estimate_industrial_rent', + 'Estimate rental costs for industrial property based on province, type, area, and requirements.', + EstimateIndustrialRentSchema, + async (params: z.infer>) => { + const response = await fetch(`${baseUrl}/industrial/estimate-rent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + province: params.province, + property_type: params.propertyType, + area_m2: params.areaM2, + lease_duration_years: params.leaseDurationYears, + park_name: params.parkName ?? null, + requires_crane: params.requiresCrane ?? false, + required_power_kva: params.requiredPowerKva ?? null, + requires_wastewater: params.requiresWastewater ?? false, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ error: `Rent estimation error (${response.status}): ${errorText}` }), + }], + isError: true, + }; + } + + const data = (await response.json()) as IndustrialRentEstimate; + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + estimatedRentUsdM2: data.estimated_rent_usd_m2, + pricingUnit: data.pricing_unit, + totalMonthlyUsd: data.total_monthly_usd, + totalLeaseUsd: data.total_lease_usd, + managementFeeUsdM2: data.management_fee_usd_m2 ?? null, + depositMonths: data.deposit_months ?? null, + marketComparison: { + provinceLow: data.market_comparison?.province_low ?? null, + provinceHigh: data.market_comparison?.province_high ?? null, + provinceAvg: data.market_comparison?.province_avg ?? null, + }, + breakdown: data.breakdown ?? [], + input: { + province: params.province, + propertyType: params.propertyType, + areaM2: params.areaM2, + leaseDurationYears: params.leaseDurationYears, + }, + }, null, 2), + }], + }; + }, + ); + + return server; +} + +// Response types from the AI service +interface IndustrialLocationAnalysis { + overall_score: number; + connectivity?: { + nearest_port?: { name: string; distanceKm: number }; + nearest_airport?: { name: string; distanceKm: number }; + nearest_highway?: { name: string; distanceKm: number }; + nearest_railway?: { name: string; distanceKm: number }; + }; + infrastructure?: { + power_availability?: string; + water_supply?: string; + wastewater_treatment?: string; + telecom?: string; + }; + labor_market?: { + worker_pool_radius_30km?: number; + average_wage_usd?: number; + nearby_universities?: string[]; + }; + incentives?: string[]; + risks?: string[]; +} + +interface IndustrialRentEstimate { + estimated_rent_usd_m2: number; + pricing_unit: string; + total_monthly_usd: number; + total_lease_usd: number; + management_fee_usd_m2?: number; + deposit_months?: number; + market_comparison?: { + province_low?: number; + province_high?: number; + province_avg?: number; + }; + breakdown?: { item: string; amount: number }[]; +} diff --git a/libs/mcp-servers/src/nestjs/mcp-registry.service.ts b/libs/mcp-servers/src/nestjs/mcp-registry.service.ts index ca1ffc9..6809ece 100644 --- a/libs/mcp-servers/src/nestjs/mcp-registry.service.ts +++ b/libs/mcp-servers/src/nestjs/mcp-registry.service.ts @@ -18,6 +18,8 @@ export class McpRegistryService implements OnModuleInit { 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'); + const { createIndustrialParksServer } = await import('../industrial-parks/industrial-parks.server'); + const { createReportsServer } = await import('../reports/reports.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 @@ -45,6 +47,23 @@ export class McpRegistryService implements OnModuleInit { aiServiceBaseUrl: this.options.aiServiceBaseUrl, }), ); + + if (this.typesenseClient) { + this.servers.set( + 'industrial-parks', + createIndustrialParksServer({ + typesenseClient: this.typesenseClient, + aiServiceBaseUrl: this.options.aiServiceBaseUrl, + }), + ); + } + + this.servers.set( + 'reports', + createReportsServer({ + aiServiceBaseUrl: this.options.aiServiceBaseUrl, + }), + ); } setTypesenseClient(client: TypesenseClient): void { diff --git a/libs/mcp-servers/src/reports/reports.server.ts b/libs/mcp-servers/src/reports/reports.server.ts new file mode 100644 index 0000000..92703c8 --- /dev/null +++ b/libs/mcp-servers/src/reports/reports.server.ts @@ -0,0 +1,195 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod/v3'; +import type { ReportsDeps } from '../shared/types'; + +const REPORT_TYPES = [ + 'market_overview', + 'district_analysis', + 'industrial_zone', + 'investment_feasibility', + 'price_forecast', +] as const; + +const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse', 'industrial'] as const; + +const MACRO_DATA_CATEGORIES = [ + 'gdp', + 'population', + 'fdi', + 'infrastructure', + 'real_estate_index', + 'construction_permits', + 'urbanization', +] as const; + +const GenerateReportSchema = { + reportType: z.enum(REPORT_TYPES).describe('Type of report to generate'), + location: z.string().describe('City, district, or province for the report'), + propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'), + period: z.enum(['1m', '3m', '6m', '1y', '2y', '5y']).default('1y').describe('Time period for data analysis'), + includeForecasts: z.boolean().default(false).describe('Include price/demand forecasts'), + includeMacro: z.boolean().default(false).describe('Include macro-economic data in the report'), + language: z.enum(['vi', 'en']).default('vi').describe('Report language'), +}; + +const GetMacroDataSchema = { + province: z.string().describe('Province name (e.g. "Bình Dương", "Hồ Chí Minh")'), + categories: z.array(z.enum(MACRO_DATA_CATEGORIES)).min(1).describe('Data categories to retrieve'), + fromYear: z.number().int().min(2010).default(2020).describe('Start year'), + toYear: z.number().int().max(2030).default(2025).describe('End year'), +}; + +export function createReportsServer(deps: ReportsDeps): McpServer { + const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, ''); + + const server = new McpServer({ + name: 'goodgo-reports', + version: '0.1.0', + }); + + server.tool( + 'generate_report', + 'Generate a comprehensive real estate market report for a given location and property type.', + GenerateReportSchema, + async (params: z.infer>) => { + const response = await fetch(`${baseUrl}/reports/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + report_type: params.reportType, + location: params.location, + property_type: params.propertyType ?? null, + period: params.period, + include_forecasts: params.includeForecasts, + include_macro: params.includeMacro, + language: params.language, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ error: `Report generation error (${response.status}): ${errorText}` }), + }], + isError: true, + }; + } + + const data = (await response.json()) as GeneratedReport; + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + reportId: data.report_id, + reportType: data.report_type, + title: data.title, + location: data.location, + generatedAt: data.generated_at, + summary: data.summary, + sections: (data.sections ?? []).map((s) => ({ + title: s.title, + content: s.content, + charts: s.charts ?? [], + })), + keyMetrics: data.key_metrics ?? {}, + forecasts: data.forecasts ?? null, + macroData: data.macro_data ?? null, + }, null, 2), + }], + }; + }, + ); + + server.tool( + 'get_macro_data', + 'Retrieve macro-economic data (GDP, population, FDI, infrastructure) for a Vietnamese province.', + GetMacroDataSchema, + async (params: z.infer>) => { + const response = await fetch(`${baseUrl}/reports/macro-data`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + province: params.province, + categories: params.categories, + from_year: params.fromYear, + to_year: params.toYear, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ error: `Macro data error (${response.status}): ${errorText}` }), + }], + isError: true, + }; + } + + const data = (await response.json()) as MacroDataResponse; + + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + province: data.province, + period: { from: params.fromYear, to: params.toYear }, + data: Object.fromEntries( + Object.entries(data.data ?? {}).map(([category, series]) => [ + category, + (series as MacroDataPoint[]).map((point) => ({ + year: point.year, + value: point.value, + unit: point.unit, + yoyChange: point.yoy_change ?? null, + })), + ]), + ), + highlights: data.highlights ?? [], + }, null, 2), + }], + }; + }, + ); + + return server; +} + +// Response types from the AI service +interface GeneratedReport { + report_id: string; + report_type: string; + title: string; + location: string; + generated_at: string; + summary: string; + sections?: { + title: string; + content: string; + charts?: { type: string; title: string; data: unknown }[]; + }[]; + key_metrics?: Record; + forecasts?: { + price_trend: { period: string; predicted_change_pct: number }[]; + demand_trend: { period: string; predicted_change_pct: number }[]; + confidence: number; + }; + macro_data?: Record; +} + +interface MacroDataPoint { + year: number; + value: number; + unit: string; + yoy_change?: number; +} + +interface MacroDataResponse { + province: string; + data?: Record; + highlights?: string[]; +} diff --git a/libs/mcp-servers/src/shared/types.ts b/libs/mcp-servers/src/shared/types.ts index cbc2925..a1d49b1 100644 --- a/libs/mcp-servers/src/shared/types.ts +++ b/libs/mcp-servers/src/shared/types.ts @@ -14,6 +14,16 @@ export interface ValuationDeps { aiServiceBaseUrl: string; } +export interface IndustrialParksDeps { + typesenseClient: TypesenseClient; + collectionName?: string; + aiServiceBaseUrl: string; +} + +export interface ReportsDeps { + aiServiceBaseUrl: string; +} + export interface McpServerConfig { name: string; version: string; @@ -70,6 +80,24 @@ export interface ValuationEstimate { priceRangeHigh: number; } +export interface IndustrialParkSummary { + parkId: string; + name: string; + nameEn: string | null; + developer: string; + province: string; + region: string; + status: string; + totalAreaHa: number; + remainingAreaHa: number; + occupancyRate: number; + landRentUsdM2Year: number | null; + rbfRentUsdM2Month: number | null; + rbwRentUsdM2Month: number | null; + targetIndustries: string[]; + tenantCount: number; +} + export interface FeatureExtractionResult { features: { area: number | null;