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:
307
libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts
Normal file
307
libs/mcp-servers/src/industrial-parks/industrial-parks.server.ts
Normal 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 }[];
|
||||
}
|
||||
Reference in New Issue
Block a user