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,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<z.ZodObject<typeof SearchIndustrialParksSchema>>) => {
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<string, unknown>;
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<z.ZodObject<typeof AnalyzeIndustrialLocationSchema>>) => {
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<z.ZodObject<typeof EstimateIndustrialRentSchema>>) => {
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 }[];
}