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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:16:11 +07:00
parent 2a69736728
commit 53c33a1c50
7 changed files with 1112 additions and 0 deletions

View File

@@ -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<typeof createMockClient>): IndustrialParksDeps {
return {
typesenseClient: client as unknown as IndustrialParksDeps['typesenseClient'],
collectionName: 'test-industrial-parks',
aiServiceBaseUrl: 'http://localhost:8000',
};
}
function makeHits(docs: Record<string, unknown>[]) {
return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 3 };
}
function getToolHandler(server: ReturnType<typeof createIndustrialParksServer>, name: string) {
const tools = (server as unknown as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> })._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<string, unknown>;
}
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<typeof createMockClient>;
let server: ReturnType<typeof createIndustrialParksServer>;
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<string, unknown>[])[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<string, unknown>).nearestPort).toEqual({ name: 'Cảng Cát Lái', distanceKm: 25 });
expect((data.laborMarket as Record<string, unknown>).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<string, unknown>).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');
});
});
});

View File

@@ -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<typeof createReportsServer>, name: string) {
const tools = (server as unknown as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> })._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<string, unknown>;
}
describe('ReportsServer', () => {
let server: ReturnType<typeof createReportsServer>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown[]>;
expect(macroData.gdp).toHaveLength(2);
expect(macroData.population).toHaveLength(2);
expect((macroData.gdp[0] as Record<string, unknown>).year).toBe(2023);
expect((macroData.gdp[0] as Record<string, unknown>).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<string, unknown>;
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)');
});
});
});