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:
Ho Ngoc Hai
2026-04-08 03:22:27 +07:00
parent efa49e225e
commit cb00b12d7b
17 changed files with 1077 additions and 41 deletions

View 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;
}
}

View 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);
}
}

View 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,
};
}
}