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>
196 lines
6.0 KiB
TypeScript
196 lines
6.0 KiB
TypeScript
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<z.ZodObject<typeof GenerateReportSchema>>) => {
|
|
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<z.ZodObject<typeof GetMacroDataSchema>>) => {
|
|
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<string, unknown>;
|
|
forecasts?: {
|
|
price_trend: { period: string; predicted_change_pct: number }[];
|
|
demand_trend: { period: string; predicted_change_pct: number }[];
|
|
confidence: number;
|
|
};
|
|
macro_data?: Record<string, unknown>;
|
|
}
|
|
|
|
interface MacroDataPoint {
|
|
year: number;
|
|
value: number;
|
|
unit: string;
|
|
yoy_change?: number;
|
|
}
|
|
|
|
interface MacroDataResponse {
|
|
province: string;
|
|
data?: Record<string, MacroDataPoint[]>;
|
|
highlights?: string[];
|
|
}
|