feat(mcp): add MCP Server Integration — Property Search, Analytics, Valuation
Implement 3 MCP servers in libs/mcp-servers/ using @modelcontextprotocol/sdk: - Property Search: NL search via Typesense, property comparison, detail lookup - Market Analytics: market reports, price trends, district comparison - Valuation: AVM integration with Python AI service, feature extraction, batch valuation Includes NestJS integration module with SSE transport for in-process hosting. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
**Last Updated:** 2026-04-08
|
||||
**Project:** Goodgo Platform AI
|
||||
**Status:** Phase 0 Complete — Phase 1 ~80% — Phase 2 ~60%
|
||||
**Status:** Phase 0-2 Complete — Phase 3 Not Started
|
||||
|
||||
---
|
||||
|
||||
@@ -19,53 +19,44 @@
|
||||
|
||||
## Phase 1: Core Auth & Listings (P1)
|
||||
|
||||
| Issue | Title | Priority | Status | Commit |
|
||||
| -------------------------------- | ------------------------------------------------- | -------- | ----------- | ------ |
|
||||
| [TEC-1421](/TEC/issues/TEC-1421) | Auth Module Backend (Register, Login, JWT, OAuth) | Critical | done | 391c040 |
|
||||
| [TEC-1422](/TEC/issues/TEC-1422) | Auth Frontend (Login/Register + OAuth) | High | todo | — |
|
||||
| [TEC-1423](/TEC/issues/TEC-1423) | Listings Module Backend (CRUD, Media, Moderation) | High | done | 8a33aae |
|
||||
| [TEC-1424](/TEC/issues/TEC-1424) | Search Module Backend (Typesense + Geo) | High | done | 6741592 |
|
||||
| [TEC-1425](/TEC/issues/TEC-1425) | Security Hardening (Rate Limiting, CORS, Helmet) | High | done | f3081d9 |
|
||||
| [TEC-1426](/TEC/issues/TEC-1426) | Error Handling & Logging Strategy | High | done | c981bff |
|
||||
| [TEC-1427](/TEC/issues/TEC-1427) | Listings Frontend (Create/Edit + Detail) | High | done | 207a201 |
|
||||
| [TEC-1428](/TEC/issues/TEC-1428) | Search + Landing Page Frontend | High | done | 5e44456 |
|
||||
| Issue | Title | Priority | Status | Commit |
|
||||
| -------------------------------- | ------------------------------------------------- | -------- | ------ | ------ |
|
||||
| [TEC-1421](/TEC/issues/TEC-1421) | Auth Module Backend (Register, Login, JWT, OAuth) | Critical | done | 391c040 |
|
||||
| [TEC-1422](/TEC/issues/TEC-1422) | Auth Frontend (Login/Register + OAuth) | High | done | bfdd2f7 |
|
||||
| [TEC-1423](/TEC/issues/TEC-1423) | Listings Module Backend (CRUD, Media, Moderation) | High | done | 8a33aae |
|
||||
| [TEC-1424](/TEC/issues/TEC-1424) | Search Module Backend (Typesense + Geo) | High | done | 6741592 |
|
||||
| [TEC-1425](/TEC/issues/TEC-1425) | Security Hardening (Rate Limiting, CORS, Helmet) | High | done | f3081d9 |
|
||||
| [TEC-1426](/TEC/issues/TEC-1426) | Error Handling & Logging Strategy | High | done | c981bff |
|
||||
| [TEC-1427](/TEC/issues/TEC-1427) | Listings Frontend (Create/Edit + Detail) | High | done | 207a201 |
|
||||
| [TEC-1428](/TEC/issues/TEC-1428) | Search + Landing Page Frontend | High | done | 5e44456 |
|
||||
|
||||
## Phase 2: Monetization & Operations (P2)
|
||||
|
||||
| Issue | Title | Priority | Status | Commit |
|
||||
| -------------------------------- | ----------------------------------------------- | -------- | ----------- | ------ |
|
||||
| [TEC-1429](/TEC/issues/TEC-1429) | Payments Module (VNPay + MoMo + ZaloPay) | Medium | done | ad77139 |
|
||||
| [TEC-1430](/TEC/issues/TEC-1430) | Subscriptions Module (Plans, Quotas, Billing) | Medium | done | 9b581b7 |
|
||||
| [TEC-1431](/TEC/issues/TEC-1431) | Notifications Module (Email, SMS, Zalo OA, FCM) | Medium | done | 0b29fac |
|
||||
| [TEC-1432](/TEC/issues/TEC-1432) | Admin Module (Backend + Frontend) | Medium | todo | — |
|
||||
| [TEC-1433](/TEC/issues/TEC-1433) | E2E Testing Setup (Playwright) | Medium | done | 9301f44 |
|
||||
| Issue | Title | Priority | Status | Commit |
|
||||
| -------------------------------- | ----------------------------------------------- | -------- | ------ | ------ |
|
||||
| [TEC-1429](/TEC/issues/TEC-1429) | Payments Module (VNPay + MoMo + ZaloPay) | Medium | done | ad77139 |
|
||||
| [TEC-1430](/TEC/issues/TEC-1430) | Subscriptions Module (Plans, Quotas, Billing) | Medium | done | 9b581b7 |
|
||||
| [TEC-1431](/TEC/issues/TEC-1431) | Notifications Module (Email, SMS, Zalo OA, FCM) | Medium | done | 0b29fac |
|
||||
| [TEC-1432](/TEC/issues/TEC-1432) | Admin Module (Backend + Frontend) | Medium | done | 6123fc4 |
|
||||
| [TEC-1433](/TEC/issues/TEC-1433) | E2E Testing Setup (Playwright) | Medium | done | 60a0b3c |
|
||||
|
||||
## Phase 3: AI & Advanced (P3) — Not yet created
|
||||
## Phase 3: AI & Advanced (P3) — Not yet started
|
||||
|
||||
- AI/ML Services Container (Python FastAPI + XGBoost)
|
||||
- Analytics Module (Market reports, AVM)
|
||||
- MCP Server Integration
|
||||
- Performance Monitoring (Prometheus + Grafana)
|
||||
| Issue | Title | Priority | Status |
|
||||
| ----- | ------------------------------------------------ | -------- | ------ |
|
||||
| — | Analytics Module (Market Reports, Price Index) | High | todo |
|
||||
| — | AI/ML Services Container (Python FastAPI + XGBoost) | High | todo |
|
||||
| — | MCP Server Integration (Property Search, Analytics, Valuation) | Medium | todo |
|
||||
| — | Performance Monitoring (Prometheus + Grafana) | Low | todo |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work for MVP
|
||||
|
||||
| Item | Priority | Status |
|
||||
|------|----------|--------|
|
||||
| Auth Frontend (Login/Register pages) | High | todo |
|
||||
| Auth OAuth strategies (Google, Zalo) | High | todo |
|
||||
| Admin Module (Backend + Frontend) | Medium | todo |
|
||||
| SMS + Zalo OA notification services | Medium | todo |
|
||||
| E2E test coverage expansion | Medium | todo |
|
||||
| Docker deploy pipeline completion | Low | todo |
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Total | Done | In Progress | Todo |
|
||||
| --------- | ------ | ----- | ----------- | ---- |
|
||||
| Phase 0 | 6 | 6 | 0 | 0 |
|
||||
| Phase 1 | 8 | 7 | 0 | 1 |
|
||||
| Phase 2 | 5 | 4 | 0 | 1 |
|
||||
| Phase 3 | 4 | — | — | 4 |
|
||||
| **Total** | **23** | **17**| **0** | **6**|
|
||||
| Phase 1 | 8 | 8 | 0 | 0 |
|
||||
| Phase 2 | 5 | 5 | 0 | 0 |
|
||||
| Phase 3 | 4 | 0 | 0 | 4 |
|
||||
| **Total** | **23** | **19**| **0** | **4**|
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@goodgo/mcp-servers": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/cqrs": "^11.0.0",
|
||||
|
||||
1
apps/api/src/modules/mcp/index.ts
Normal file
1
apps/api/src/modules/mcp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { McpIntegrationModule } from './mcp.module';
|
||||
35
apps/api/src/modules/mcp/mcp.module.ts
Normal file
35
apps/api/src/modules/mcp/mcp.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Module, type OnModuleInit } from '@nestjs/common';
|
||||
import { McpModule as McpCoreModule, McpRegistryService } from '@goodgo/mcp-servers';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { TypesenseClientService } from '@modules/search/infrastructure/services/typesense-client.service';
|
||||
import { LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SearchModule,
|
||||
McpCoreModule.forRoot({
|
||||
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
|
||||
typesenseCollectionName: 'listings',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class McpIntegrationModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly typesenseClient: TypesenseClientService,
|
||||
private readonly mcpRegistry: McpRegistryService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.mcpRegistry.setTypesenseClient(this.typesenseClient.getClient());
|
||||
|
||||
// Re-initialize servers now that Typesense client is available
|
||||
await this.mcpRegistry.onModuleInit();
|
||||
|
||||
const servers = this.mcpRegistry.getServerNames();
|
||||
this.logger.log(
|
||||
`MCP servers initialized: ${servers.join(', ')}`,
|
||||
'McpIntegrationModule',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const QueryHandlers = [SearchPropertiesHandler, GeoSearchHandler];
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [ListingIndexerService, SEARCH_REPOSITORY],
|
||||
exports: [ListingIndexerService, SEARCH_REPOSITORY, TypesenseClientService],
|
||||
})
|
||||
export class SearchModule implements OnModuleInit {
|
||||
constructor(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
@@ -24,7 +25,11 @@
|
||||
"typesense": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nestjs/common": { "optional": true },
|
||||
"typesense": { "optional": true }
|
||||
"@nestjs/common": {
|
||||
"optional": true
|
||||
},
|
||||
"typesense": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
libs/mcp-servers/src/index.ts
Normal file
23
libs/mcp-servers/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Server factories
|
||||
export { createPropertySearchServer } from './property-search/property-search.server';
|
||||
export { createMarketAnalyticsServer } from './market-analytics/market-analytics.server';
|
||||
export { createValuationServer } from './valuation/valuation.server';
|
||||
|
||||
// NestJS integration
|
||||
export { McpModule, type McpModuleOptions, MCP_MODULE_OPTIONS } from './nestjs/mcp.module';
|
||||
export { McpRegistryService } from './nestjs/mcp-registry.service';
|
||||
export { McpTransportController } from './nestjs/mcp-transport.controller';
|
||||
|
||||
// Shared types
|
||||
export type {
|
||||
PropertySearchDeps,
|
||||
MarketAnalyticsDeps,
|
||||
ValuationDeps,
|
||||
McpServerConfig,
|
||||
PropertySummary,
|
||||
PropertyComparisonResult,
|
||||
MarketReport,
|
||||
PriceTrend,
|
||||
ValuationEstimate,
|
||||
FeatureExtractionResult,
|
||||
} from './shared/types';
|
||||
254
libs/mcp-servers/src/market-analytics/market-analytics.server.ts
Normal file
254
libs/mcp-servers/src/market-analytics/market-analytics.server.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod/v3';
|
||||
import type { MarketAnalyticsDeps } from '../shared/types';
|
||||
|
||||
const DEFAULT_COLLECTION = 'listings';
|
||||
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const;
|
||||
|
||||
const MarketReportSchema = {
|
||||
district: z.string().describe('District name (e.g. "Quận 7")'),
|
||||
city: z.string().describe('City name (e.g. "Hồ Chí Minh")'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'),
|
||||
transactionType: z.enum(['sale', 'rent']).optional().describe('Filter by transaction type'),
|
||||
};
|
||||
|
||||
const PriceTrendsSchema = {
|
||||
district: z.string().optional().describe('District name (omit for city-wide)'),
|
||||
city: z.string().describe('City name'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional(),
|
||||
transactionType: z.enum(['sale', 'rent']).optional(),
|
||||
months: z.number().int().min(1).max(24).default(6).describe('Months to look back'),
|
||||
};
|
||||
|
||||
const DistrictComparisonSchema = {
|
||||
city: z.string().describe('City name'),
|
||||
districts: z.array(z.string()).min(2).max(10).describe('District names to compare'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional(),
|
||||
transactionType: z.enum(['sale', 'rent']).optional(),
|
||||
};
|
||||
|
||||
export function createMarketAnalyticsServer(deps: MarketAnalyticsDeps): McpServer {
|
||||
const collectionName = deps.collectionName ?? DEFAULT_COLLECTION;
|
||||
const client = deps.typesenseClient;
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'goodgo-market-analytics',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'market_report',
|
||||
'Generate a market report for a district/city with prices, listing counts, and distribution.',
|
||||
MarketReportSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof MarketReportSchema>>) => {
|
||||
const filters: string[] = [
|
||||
'status:=active',
|
||||
`district:=${params.district}`,
|
||||
`city:=${params.city}`,
|
||||
];
|
||||
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
||||
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: filters.join(' && '),
|
||||
sort_by: 'priceVND:asc',
|
||||
per_page: 250,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const docs = (result.hits ?? []).map((h) => h.document as Record<string, unknown>);
|
||||
const totalListings = (result.found as number) ?? 0;
|
||||
|
||||
if (docs.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
district: params.district,
|
||||
city: params.city,
|
||||
totalListings: 0,
|
||||
message: 'No listings found for the specified criteria.',
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const prices = docs.map((d) => d['priceVND'] as number);
|
||||
const areas = docs.map((d) => d['areaM2'] as number);
|
||||
const pricesPerM2 = docs
|
||||
.map((d) => d['pricePerM2'] as number | undefined)
|
||||
.filter((v): v is number => v != null && v > 0);
|
||||
|
||||
const sorted = [...prices].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const medianPrice = sorted.length % 2 === 0
|
||||
? (sorted[mid - 1]! + sorted[mid]!) / 2
|
||||
: sorted[mid]!;
|
||||
|
||||
const maxPrice = sorted[sorted.length - 1]!;
|
||||
const bucketSize = maxPrice > 10_000_000_000 ? 2_000_000_000 : maxPrice > 1_000_000_000 ? 500_000_000 : 100_000_000;
|
||||
const buckets = new Map<string, number>();
|
||||
for (const price of prices) {
|
||||
const bucketStart = Math.floor(price / bucketSize) * bucketSize;
|
||||
const label = `${formatVND(bucketStart)}-${formatVND(bucketStart + bucketSize)}`;
|
||||
buckets.set(label, (buckets.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const report = {
|
||||
district: params.district,
|
||||
city: params.city,
|
||||
propertyType: params.propertyType ?? 'all',
|
||||
transactionType: params.transactionType ?? 'all',
|
||||
totalListings,
|
||||
sampleSize: docs.length,
|
||||
averagePriceVND: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
|
||||
medianPriceVND: Math.round(medianPrice),
|
||||
averagePricePerM2: pricesPerM2.length > 0
|
||||
? Math.round(pricesPerM2.reduce((a, b) => a + b, 0) / pricesPerM2.length)
|
||||
: null,
|
||||
averageAreaM2: Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100,
|
||||
priceRange: { min: sorted[0], max: sorted[sorted.length - 1] },
|
||||
priceDistribution: Array.from(buckets.entries()).map(([range, count]) => ({ range, count })),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(report, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'price_trends',
|
||||
'Analyze price trends by grouping listings by published date for a given area.',
|
||||
PriceTrendsSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof PriceTrendsSchema>>) => {
|
||||
const filters: string[] = ['status:=active'];
|
||||
if (params.district) filters.push(`district:=${params.district}`);
|
||||
filters.push(`city:=${params.city}`);
|
||||
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
||||
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
||||
|
||||
const cutoffTs = Math.floor(Date.now() / 1000) - params.months * 30 * 24 * 3600;
|
||||
filters.push(`publishedAt:>=${cutoffTs}`);
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: filters.join(' && '),
|
||||
sort_by: 'publishedAt:asc',
|
||||
per_page: 250,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const docs = (result.hits ?? []).map((h) => h.document as Record<string, unknown>);
|
||||
|
||||
const monthlyData = new Map<string, { total: number; count: number }>();
|
||||
for (const doc of docs) {
|
||||
const ts = doc['publishedAt'] as number;
|
||||
const date = new Date(ts * 1000);
|
||||
const label = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const existing = monthlyData.get(label) ?? { total: 0, count: 0 };
|
||||
existing.total += doc['priceVND'] as number;
|
||||
existing.count += 1;
|
||||
monthlyData.set(label, existing);
|
||||
}
|
||||
|
||||
const dataPoints = Array.from(monthlyData.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([label, data]) => ({
|
||||
month: label,
|
||||
avgPrice: Math.round(data.total / data.count),
|
||||
listingCount: data.count,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
district: params.district ?? 'all',
|
||||
city: params.city,
|
||||
propertyType: params.propertyType ?? 'all',
|
||||
periodMonths: params.months,
|
||||
totalListings: (result.found as number) ?? 0,
|
||||
dataPoints,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'district_comparison',
|
||||
'Compare real estate metrics across multiple districts in a city.',
|
||||
DistrictComparisonSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof DistrictComparisonSchema>>) => {
|
||||
const results = await Promise.all(
|
||||
params.districts.map(async (district) => {
|
||||
const filters: string[] = [
|
||||
'status:=active',
|
||||
`district:=${district}`,
|
||||
`city:=${params.city}`,
|
||||
];
|
||||
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
||||
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
||||
|
||||
const res = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: filters.join(' && '),
|
||||
per_page: 250,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const docs = (res.hits ?? []).map((h) => h.document as Record<string, unknown>);
|
||||
const prices = docs.map((d) => d['priceVND'] as number);
|
||||
const areas = docs.map((d) => d['areaM2'] as number);
|
||||
|
||||
return {
|
||||
district,
|
||||
totalListings: (res.found as number) ?? 0,
|
||||
avgPriceVND: prices.length > 0
|
||||
? Math.round(prices.reduce((a, b) => a + b, 0) / prices.length)
|
||||
: null,
|
||||
avgAreaM2: areas.length > 0
|
||||
? Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100
|
||||
: null,
|
||||
avgPricePerM2: prices.length > 0 && areas.length > 0
|
||||
? Math.round(prices.reduce((a, b) => a + b, 0) / areas.reduce((a, b) => a + b, 0))
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
city: params.city,
|
||||
propertyType: params.propertyType ?? 'all',
|
||||
districts: results,
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function formatVND(amount: number): string {
|
||||
if (amount >= 1_000_000_000) return `${(amount / 1_000_000_000).toFixed(1)} tỷ`;
|
||||
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(0)} triệu`;
|
||||
return `${amount.toLocaleString()} VND`;
|
||||
}
|
||||
63
libs/mcp-servers/src/nestjs/mcp-registry.service.ts
Normal file
63
libs/mcp-servers/src/nestjs/mcp-registry.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable, Inject, type OnModuleInit } from '@nestjs/common';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { MCP_MODULE_OPTIONS, type McpModuleOptions } from './mcp.module';
|
||||
|
||||
@Injectable()
|
||||
export class McpRegistryService implements OnModuleInit {
|
||||
private readonly servers = new Map<string, McpServer>();
|
||||
private typesenseClient: import('typesense').Client | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(MCP_MODULE_OPTIONS) private readonly options: McpModuleOptions,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Lazy import to avoid hard dependency at module load time
|
||||
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');
|
||||
|
||||
// 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
|
||||
if (this.typesenseClient) {
|
||||
this.servers.set(
|
||||
'property-search',
|
||||
createPropertySearchServer({
|
||||
typesenseClient: this.typesenseClient,
|
||||
collectionName: this.options.typesenseCollectionName,
|
||||
}),
|
||||
);
|
||||
|
||||
this.servers.set(
|
||||
'market-analytics',
|
||||
createMarketAnalyticsServer({
|
||||
typesenseClient: this.typesenseClient,
|
||||
collectionName: this.options.typesenseCollectionName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.servers.set(
|
||||
'valuation',
|
||||
createValuationServer({
|
||||
aiServiceBaseUrl: this.options.aiServiceBaseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setTypesenseClient(client: import('typesense').Client): void {
|
||||
this.typesenseClient = client;
|
||||
}
|
||||
|
||||
getServer(name: string): McpServer | undefined {
|
||||
return this.servers.get(name);
|
||||
}
|
||||
|
||||
getServerNames(): string[] {
|
||||
return Array.from(this.servers.keys());
|
||||
}
|
||||
|
||||
getAllServers(): Map<string, McpServer> {
|
||||
return this.servers;
|
||||
}
|
||||
}
|
||||
56
libs/mcp-servers/src/nestjs/mcp-transport.controller.ts
Normal file
56
libs/mcp-servers/src/nestjs/mcp-transport.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Controller, Get, Post, Param, Req, Res, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import { McpRegistryService } from './mcp-registry.service';
|
||||
|
||||
@Controller('mcp')
|
||||
export class McpTransportController {
|
||||
private readonly transports = new Map<string, SSEServerTransport>();
|
||||
|
||||
constructor(private readonly registry: McpRegistryService) {}
|
||||
|
||||
@Get('servers')
|
||||
listServers(): { servers: string[] } {
|
||||
return { servers: this.registry.getServerNames() };
|
||||
}
|
||||
|
||||
@Get(':serverName/sse')
|
||||
async handleSse(
|
||||
@Param('serverName') serverName: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const server = this.registry.getServer(serverName);
|
||||
if (!server) {
|
||||
throw new HttpException(`MCP server "${serverName}" not found`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport(`/mcp/${serverName}/messages`, res);
|
||||
this.transports.set(transport.sessionId, transport);
|
||||
|
||||
req.on('close', () => {
|
||||
this.transports.delete(transport.sessionId);
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
@Post(':serverName/messages')
|
||||
async handleMessage(
|
||||
@Param('serverName') serverName: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const sessionId = req.query['sessionId'] as string;
|
||||
if (!sessionId) {
|
||||
throw new HttpException('Missing sessionId query parameter', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const transport = this.transports.get(sessionId);
|
||||
if (!transport) {
|
||||
throw new HttpException('Session not found or expired', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
await transport.handlePostMessage(req, res);
|
||||
}
|
||||
}
|
||||
28
libs/mcp-servers/src/nestjs/mcp.module.ts
Normal file
28
libs/mcp-servers/src/nestjs/mcp.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module, type DynamicModule, type Provider } from '@nestjs/common';
|
||||
import { McpRegistryService } from './mcp-registry.service';
|
||||
import { McpTransportController } from './mcp-transport.controller';
|
||||
|
||||
export interface McpModuleOptions {
|
||||
aiServiceBaseUrl: string;
|
||||
typesenseCollectionName?: string;
|
||||
}
|
||||
|
||||
export const MCP_MODULE_OPTIONS = Symbol('MCP_MODULE_OPTIONS');
|
||||
|
||||
@Module({})
|
||||
export class McpModule {
|
||||
static forRoot(options: McpModuleOptions): DynamicModule {
|
||||
const optionsProvider: Provider = {
|
||||
provide: MCP_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
};
|
||||
|
||||
return {
|
||||
module: McpModule,
|
||||
controllers: [McpTransportController],
|
||||
providers: [optionsProvider, McpRegistryService],
|
||||
exports: [McpRegistryService],
|
||||
global: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
228
libs/mcp-servers/src/property-search/property-search.server.ts
Normal file
228
libs/mcp-servers/src/property-search/property-search.server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod/v3';
|
||||
import type { PropertySearchDeps, PropertySummary } from '../shared/types';
|
||||
|
||||
const DEFAULT_COLLECTION = 'listings';
|
||||
|
||||
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const;
|
||||
|
||||
const SearchPropertiesSchema = {
|
||||
query: z.string().describe('Natural language search query (e.g. "căn hộ 2 phòng ngủ Quận 7")'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).optional().describe('Filter by property type'),
|
||||
transactionType: z.enum(['sale', 'rent']).optional().describe('Filter by transaction type'),
|
||||
minPrice: z.number().optional().describe('Minimum price in VND'),
|
||||
maxPrice: z.number().optional().describe('Maximum price in VND'),
|
||||
bedrooms: z.number().int().optional().describe('Exact number of bedrooms'),
|
||||
district: z.string().optional().describe('Filter by district name'),
|
||||
city: z.string().optional().describe('Filter by city name'),
|
||||
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', 'price_asc', 'price_desc', 'date_desc', 'distance']).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(50).default(20),
|
||||
};
|
||||
|
||||
const ComparePropertiesSchema = {
|
||||
listingIds: z.array(z.string()).min(2).max(10).describe('Array of listing IDs to compare'),
|
||||
};
|
||||
|
||||
const GetPropertyDetailsSchema = {
|
||||
listingId: z.string().describe('The listing ID to retrieve'),
|
||||
};
|
||||
|
||||
export function createPropertySearchServer(deps: PropertySearchDeps): McpServer {
|
||||
const collectionName = deps.collectionName ?? DEFAULT_COLLECTION;
|
||||
const client = deps.typesenseClient;
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'goodgo-property-search',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'search_properties',
|
||||
'Search property listings using natural language queries with filters.',
|
||||
SearchPropertiesSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof SearchPropertiesSchema>>) => {
|
||||
const filters: string[] = ['status:=active'];
|
||||
|
||||
if (params.propertyType) filters.push(`propertyType:=${params.propertyType}`);
|
||||
if (params.transactionType) filters.push(`transactionType:=${params.transactionType}`);
|
||||
if (params.minPrice != null) filters.push(`priceVND:>=${params.minPrice}`);
|
||||
if (params.maxPrice != null) filters.push(`priceVND:<=${params.maxPrice}`);
|
||||
if (params.bedrooms != null) filters.push(`bedrooms:=${params.bedrooms}`);
|
||||
if (params.district) filters.push(`district:=${params.district}`);
|
||||
if (params.city) filters.push(`city:=${params.city}`);
|
||||
|
||||
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} && ${geoFilter}`;
|
||||
}
|
||||
|
||||
let sortBy = 'publishedAt:desc';
|
||||
if (params.sortBy === 'price_asc') sortBy = 'priceVND:asc';
|
||||
else if (params.sortBy === 'price_desc') sortBy = 'priceVND:desc';
|
||||
else if (params.sortBy === 'date_desc') sortBy = 'publishedAt: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,publishedAt:desc';
|
||||
}
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: params.query || '*',
|
||||
query_by: 'title,description,address,district,city,projectName',
|
||||
query_by_weights: '5,3,2,2,1,2',
|
||||
filter_by: filterBy,
|
||||
sort_by: sortBy,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
highlight_full_fields: 'title,description',
|
||||
});
|
||||
|
||||
const hits = (result.hits ?? []).map((hit) => {
|
||||
const doc = hit.document as Record<string, unknown>;
|
||||
return {
|
||||
listingId: doc['listingId'],
|
||||
title: doc['title'],
|
||||
propertyType: doc['propertyType'],
|
||||
transactionType: doc['transactionType'],
|
||||
priceVND: doc['priceVND'],
|
||||
pricePerM2: doc['pricePerM2'] ?? null,
|
||||
areaM2: doc['areaM2'],
|
||||
bedrooms: doc['bedrooms'] ?? null,
|
||||
bathrooms: doc['bathrooms'] ?? null,
|
||||
address: doc['address'],
|
||||
district: doc['district'],
|
||||
city: doc['city'],
|
||||
};
|
||||
});
|
||||
|
||||
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(
|
||||
'compare_properties',
|
||||
'Compare multiple property listings side by side with price and area analysis.',
|
||||
ComparePropertiesSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof ComparePropertiesSchema>>) => {
|
||||
const filterBy = `listingId:[${params.listingIds.join(',')}]`;
|
||||
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: filterBy,
|
||||
per_page: params.listingIds.length,
|
||||
});
|
||||
|
||||
const properties: PropertySummary[] = (result.hits ?? []).map((hit) => {
|
||||
const doc = hit.document as Record<string, unknown>;
|
||||
return {
|
||||
listingId: doc['listingId'] as string,
|
||||
title: doc['title'] as string,
|
||||
propertyType: doc['propertyType'] as string,
|
||||
transactionType: doc['transactionType'] as string,
|
||||
priceVND: doc['priceVND'] as number,
|
||||
pricePerM2: (doc['pricePerM2'] as number) ?? null,
|
||||
areaM2: doc['areaM2'] as number,
|
||||
bedrooms: (doc['bedrooms'] as number) ?? null,
|
||||
bathrooms: (doc['bathrooms'] as number) ?? null,
|
||||
address: doc['address'] as string,
|
||||
district: doc['district'] as string,
|
||||
city: doc['city'] as string,
|
||||
};
|
||||
});
|
||||
|
||||
if (properties.length < 2) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: 'Need at least 2 valid listings to compare', found: properties.length }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const prices = properties.map((p) => p.priceVND);
|
||||
const areas = properties.map((p) => p.areaM2);
|
||||
const pricesPerM2 = properties.map((p) => p.pricePerM2).filter((v): v is number => v != null);
|
||||
|
||||
const comparison = {
|
||||
properties,
|
||||
comparison: {
|
||||
priceRange: {
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices),
|
||||
avg: Math.round(prices.reduce((a, b) => a + b, 0) / prices.length),
|
||||
},
|
||||
areaRange: {
|
||||
min: Math.min(...areas),
|
||||
max: Math.max(...areas),
|
||||
avg: Math.round((areas.reduce((a, b) => a + b, 0) / areas.length) * 100) / 100,
|
||||
},
|
||||
pricePerM2Range: pricesPerM2.length > 0 ? {
|
||||
min: Math.min(...pricesPerM2),
|
||||
max: Math.max(...pricesPerM2),
|
||||
avg: Math.round(pricesPerM2.reduce((a, b) => a + b, 0) / pricesPerM2.length),
|
||||
} : null,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(comparison, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_property_details',
|
||||
'Get detailed information about a specific property listing by its ID.',
|
||||
GetPropertyDetailsSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof GetPropertyDetailsSchema>>) => {
|
||||
const result = await client
|
||||
.collections(collectionName)
|
||||
.documents()
|
||||
.search({
|
||||
q: '*',
|
||||
query_by: 'title',
|
||||
filter_by: `listingId:=${params.listingId}`,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const hit = result.hits?.[0];
|
||||
if (!hit) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Listing not found' }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(hit.document, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
89
libs/mcp-servers/src/shared/types.ts
Normal file
89
libs/mcp-servers/src/shared/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Client as TypesenseClient } from 'typesense';
|
||||
|
||||
export interface PropertySearchDeps {
|
||||
typesenseClient: TypesenseClient;
|
||||
collectionName?: string;
|
||||
}
|
||||
|
||||
export interface MarketAnalyticsDeps {
|
||||
typesenseClient: TypesenseClient;
|
||||
collectionName?: string;
|
||||
}
|
||||
|
||||
export interface ValuationDeps {
|
||||
aiServiceBaseUrl: string;
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface PropertyComparisonResult {
|
||||
properties: PropertySummary[];
|
||||
comparison: {
|
||||
priceRange: { min: number; max: number; avg: number };
|
||||
areaRange: { min: number; max: number; avg: number };
|
||||
pricePerM2Range: { min: number; max: number; avg: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PropertySummary {
|
||||
listingId: string;
|
||||
title: string;
|
||||
propertyType: string;
|
||||
transactionType: string;
|
||||
priceVND: number;
|
||||
pricePerM2: number | null;
|
||||
areaM2: number;
|
||||
bedrooms: number | null;
|
||||
bathrooms: number | null;
|
||||
address: string;
|
||||
district: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
export interface MarketReport {
|
||||
district: string;
|
||||
city: string;
|
||||
propertyType: string | null;
|
||||
totalListings: number;
|
||||
averagePriceVND: number;
|
||||
medianPriceVND: number;
|
||||
averagePricePerM2: number;
|
||||
averageArea: number;
|
||||
priceDistribution: { range: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface PriceTrend {
|
||||
district: string;
|
||||
city: string;
|
||||
period: string;
|
||||
dataPoints: { label: string; avgPrice: number; count: number }[];
|
||||
}
|
||||
|
||||
export interface ValuationEstimate {
|
||||
estimatedPriceVND: number;
|
||||
confidence: number;
|
||||
pricePerM2: number;
|
||||
priceRangeLow: number;
|
||||
priceRangeHigh: number;
|
||||
}
|
||||
|
||||
export interface FeatureExtractionResult {
|
||||
features: {
|
||||
area: number | null;
|
||||
district: string | null;
|
||||
city: string | null;
|
||||
propertyType: string | null;
|
||||
bedrooms: number | null;
|
||||
bathrooms: number | null;
|
||||
floors: number | null;
|
||||
frontage: number | null;
|
||||
roadWidth: number | null;
|
||||
priceMentioned: number | null;
|
||||
hasLegalPaper: boolean | null;
|
||||
};
|
||||
tokens: string[];
|
||||
entities: { text: string; label: string }[];
|
||||
}
|
||||
234
libs/mcp-servers/src/valuation/valuation.server.ts
Normal file
234
libs/mcp-servers/src/valuation/valuation.server.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod/v3';
|
||||
import type { ValuationDeps } from '../shared/types';
|
||||
|
||||
const PROPERTY_TYPES = ['apartment', 'house', 'townhouse', 'villa', 'land', 'shophouse'] as const;
|
||||
|
||||
interface AVMResponse {
|
||||
estimated_price_vnd: number;
|
||||
confidence: number;
|
||||
price_per_m2: number;
|
||||
price_range_low: number;
|
||||
price_range_high: number;
|
||||
}
|
||||
|
||||
interface FeatureExtractResponse {
|
||||
features?: {
|
||||
area?: number;
|
||||
district?: string;
|
||||
city?: string;
|
||||
property_type?: string;
|
||||
bedrooms?: number;
|
||||
bathrooms?: number;
|
||||
floors?: number;
|
||||
frontage?: number;
|
||||
road_width?: number;
|
||||
price_mentioned?: number;
|
||||
has_legal_paper?: boolean;
|
||||
};
|
||||
tokens?: string[];
|
||||
entities?: { text: string; label: string }[];
|
||||
}
|
||||
|
||||
const EstimateValueSchema = {
|
||||
area: z.number().positive().describe('Property area in m²'),
|
||||
district: z.string().min(1).describe('District name'),
|
||||
city: z.string().min(1).describe('City name'),
|
||||
propertyType: z.enum(PROPERTY_TYPES).describe('Property type'),
|
||||
bedrooms: z.number().int().min(0).default(0),
|
||||
bathrooms: z.number().int().min(0).default(0),
|
||||
floors: z.number().int().min(0).default(0),
|
||||
frontage: z.number().min(0).default(0).describe('Frontage width in meters'),
|
||||
roadWidth: z.number().min(0).default(0).describe('Adjacent road width in meters'),
|
||||
yearBuilt: z.number().int().optional().describe('Year the property was built'),
|
||||
hasLegalPaper: z.boolean().default(true).describe('Has sổ đỏ/sổ hồng'),
|
||||
};
|
||||
|
||||
const ExtractFeaturesSchema = {
|
||||
text: z.string().min(1).describe('Vietnamese property listing text to analyze'),
|
||||
};
|
||||
|
||||
const BatchValuationSchema = {
|
||||
properties: z.array(z.object({
|
||||
area: z.number().positive(),
|
||||
district: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
propertyType: z.enum(PROPERTY_TYPES),
|
||||
bedrooms: z.number().int().min(0).default(0),
|
||||
bathrooms: z.number().int().min(0).default(0),
|
||||
floors: z.number().int().min(0).default(0),
|
||||
frontage: z.number().min(0).default(0),
|
||||
roadWidth: z.number().min(0).default(0),
|
||||
yearBuilt: z.number().int().optional(),
|
||||
hasLegalPaper: z.boolean().default(true),
|
||||
})).min(1).max(20).describe('Array of properties to valuate'),
|
||||
};
|
||||
|
||||
async function callAVM(baseUrl: string, body: Record<string, unknown>): Promise<{ data?: AVMResponse; error?: string }> {
|
||||
const response = await fetch(`${baseUrl}/avm/predict`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: `AVM service error (${response.status}): ${errorText}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AVMResponse;
|
||||
return { data };
|
||||
}
|
||||
|
||||
export function createValuationServer(deps: ValuationDeps): McpServer {
|
||||
const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, '');
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'goodgo-valuation',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
server.tool(
|
||||
'estimate_property_value',
|
||||
"Estimate a property's market value using the AVM. Returns price, confidence, and range.",
|
||||
EstimateValueSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof EstimateValueSchema>>) => {
|
||||
const body = {
|
||||
area: params.area,
|
||||
district: params.district,
|
||||
city: params.city,
|
||||
property_type: params.propertyType,
|
||||
bedrooms: params.bedrooms,
|
||||
bathrooms: params.bathrooms,
|
||||
floors: params.floors,
|
||||
frontage: params.frontage,
|
||||
road_width: params.roadWidth,
|
||||
year_built: params.yearBuilt ?? null,
|
||||
has_legal_paper: params.hasLegalPaper,
|
||||
};
|
||||
|
||||
const { data, error } = await callAVM(baseUrl, body);
|
||||
if (error || !data) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
estimatedPriceVND: data.estimated_price_vnd,
|
||||
confidence: data.confidence,
|
||||
pricePerM2: data.price_per_m2,
|
||||
priceRangeLow: data.price_range_low,
|
||||
priceRangeHigh: data.price_range_high,
|
||||
input: { area: params.area, district: params.district, city: params.city, propertyType: params.propertyType },
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'extract_listing_features',
|
||||
'Extract real estate features from Vietnamese listing text using NLP.',
|
||||
ExtractFeaturesSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof ExtractFeaturesSchema>>) => {
|
||||
const response = await fetch(`${baseUrl}/avm/extract-features`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: params.text }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ error: `Feature extraction error (${response.status}): ${errorText}` }),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = (await response.json()) as FeatureExtractResponse;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
features: {
|
||||
area: result.features?.area ?? null,
|
||||
district: result.features?.district ?? null,
|
||||
city: result.features?.city ?? null,
|
||||
propertyType: result.features?.property_type ?? null,
|
||||
bedrooms: result.features?.bedrooms ?? null,
|
||||
bathrooms: result.features?.bathrooms ?? null,
|
||||
floors: result.features?.floors ?? null,
|
||||
frontage: result.features?.frontage ?? null,
|
||||
roadWidth: result.features?.road_width ?? null,
|
||||
priceMentioned: result.features?.price_mentioned ?? null,
|
||||
hasLegalPaper: result.features?.has_legal_paper ?? null,
|
||||
},
|
||||
tokens: result.tokens ?? [],
|
||||
entities: result.entities ?? [],
|
||||
}, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'batch_valuation',
|
||||
'Estimate values for multiple properties at once for portfolio or investment analysis.',
|
||||
BatchValuationSchema,
|
||||
async (params: z.infer<z.ZodObject<typeof BatchValuationSchema>>) => {
|
||||
const results = await Promise.all(
|
||||
params.properties.map(async (prop, index) => {
|
||||
const body = {
|
||||
area: prop.area,
|
||||
district: prop.district,
|
||||
city: prop.city,
|
||||
property_type: prop.propertyType,
|
||||
bedrooms: prop.bedrooms,
|
||||
bathrooms: prop.bathrooms,
|
||||
floors: prop.floors,
|
||||
frontage: prop.frontage,
|
||||
road_width: prop.roadWidth,
|
||||
year_built: prop.yearBuilt ?? null,
|
||||
has_legal_paper: prop.hasLegalPaper,
|
||||
};
|
||||
|
||||
try {
|
||||
const { data, error } = await callAVM(baseUrl, body);
|
||||
if (error || !data) return { index, input: prop, error };
|
||||
return {
|
||||
index,
|
||||
input: { area: prop.area, district: prop.district, city: prop.city, propertyType: prop.propertyType },
|
||||
valuation: {
|
||||
estimatedPriceVND: data.estimated_price_vnd,
|
||||
confidence: data.confidence,
|
||||
pricePerM2: data.price_per_m2,
|
||||
priceRangeLow: data.price_range_low,
|
||||
priceRangeHigh: data.price_range_high,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { index, input: prop, error: String(err) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({ valuations: results }, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
21
libs/mcp-servers/tsconfig.json
Normal file
21
libs/mcp-servers/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/__tests__/**"]
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@goodgo/mcp-servers':
|
||||
specifier: workspace:*
|
||||
version: link:../../libs/mcp-servers
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -270,6 +273,9 @@ importers:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.17
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
- 'libs/*'
|
||||
|
||||
Reference in New Issue
Block a user