diff --git a/docs/api/TOOLS.md b/docs/api/TOOLS.md deleted file mode 100644 index d9c4f20..0000000 --- a/docs/api/TOOLS.md +++ /dev/null @@ -1,650 +0,0 @@ -# MCP Tools Reference - -Complete list of available tools in the IoT Cloud MCP Server. - -## Tool Count: 14 - ---- - -## Authentication (1 tool) - -### 1. login - -**MUST be called first** to authenticate end-users. - -**Input:** - -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -**Output:** - -```json -{ - "success": true, - "message": "Login successful...", - "token_type": "Bearer", - "expires_in": 3600, - "user_id": "user-uuid" -} -``` - -Token is automatically stored in session context for subsequent calls. - ---- - -## Search & Discovery (2 tools) - -### 2. search - -Search across devices, locations, and groups by keyword. Returns ChatGPT-compatible search results. - -**Input:** - -```json -{ - "query": "living room" -} -``` - -**Output:** - -```json -{ - "total": 5, - "locations": [ - { - "uuid": "6694e3a0093cf477c3122bfd", - "label": "Living Room", - "desc": "Main living area", - "userId": "user-id", - "extraInfo": {...}, - "createdAt": "2024-07-15T08:53:52.285Z", - "updatedAt": "2024-07-15T09:55:05.965Z" - } - ] -} -``` - -### 3. fetch - -Retrieve complete details by ID (ChatGPT-compatible). - -**Input:** - -```json -{ - "id": "device:abc-123" // Format: "type:uuid" -} -``` - -**Output:** - -```json -{ - "id": "device:abc-123", - "title": "Device: Living Room Light", - "text": "{...full JSON...}", - "metadata": {...} -} -``` - ---- - -## List All Resources (3 tools) - -### 4. list_devices - -Get ALL devices without filtering. - -**Input:** - -```json -{} -``` - -**Output:** - -```json -{ - "total": 10, - "devices": [ - { - "uuid": "abc-123", - "label": "Living Room Light", - "mac": "AA:BB:CC:DD:EE:FF", - ... - } - ] -} -``` - -### 5. list_locations - -Get ALL locations. - -**Input:** - -```json -{} -``` - -**Output:** - -```json -{ - "total": 5, - "locations": [ - { - "_id": "loc-123", - "label": "Living Room", - ... - } - ] -} -``` - -### 6. list_groups - -Get ALL groups. - -**Input:** - -```json -{} -``` - -**Output:** - -```json -{ - "total": 3, - "groups": [ - { - "uuid": "6694e3a0093cf477c3122c03", - "label": "Smart Lights", - "desc": "Office", - "userId": "user-id", - "locationId": "location-uuid", - "type": 0, - "elementId": 49500, - "extraInfo": {}, - "createdAt": "2024-07-15T08:53:52.710Z", - "updatedAt": "2024-07-15T08:53:52.710Z" - } - ] -} -``` - ---- - -## Device Management (3 tools) - -### 7. get_device - -Get detailed information about a specific device by UUID. - -**Input:** - -```json -{ - "uuid": "abc-123" -} -``` - -**Output:** - -```json -{ - "uuid": "abc-123", - "label": "Living Room Light", - "mac": "AA:BB:CC:DD:EE:FF", - "eid": 12345, - "elementIds": [123, 456], - "endpoint": "example", - "partnerId": "rogo", - "rootUuid": "root-uuid", - "protocolCtl": 1, - ... -} -``` - -### 8. update_device - -Update device properties (label, description, location, group). - -**Input:** - -```json -{ - "uuid": "abc-123", - "updates": { - "label": "New Name", - "desc": "New description" - } -} -``` - -**Output:** - -```json -{ - "success": true, - "uuid": "abc-123", - "updated_fields": ["label", "desc"] -} -``` - -### 9. delete_device - -Delete a device permanently. **Cannot be undone.** - -**Input:** - -```json -{ - "uuid": "abc-123" -} -``` - -**Output:** - -```json -{ - "success": true, - "uuid": "abc-123", - "message": "Device deleted successfully" -} -``` - ---- - -## Device State (3 tools) - -### 10. get_device_state - -Get current state of a specific device by UUID. - -**Input:** - -```json -{ - "uuid": "abc-123" -} -``` - -**Output:** - -```json -{ - "uuid": "abc-123", - "mac": "AA:BB:CC:DD:EE:FF", - "loc": "location-id", - "devId": "device-id", - "state": { - "deviceId": { - "1": { - "1": [1, 1], // Element 1: ON_OFF: ON - "31": [31, 0, 0, 0] // Element 1: COLOR_HSV - }, - "2": { - "1": [1, 1], // Element 2: ON_OFF: ON - "28": [28, 700] // Element 2: BRIGHTNESS: 700 - } - } - }, - "updatedAt": "2026-02-12T10:00:00Z" -} -``` - -**State Structure:** - -- `state[deviceId][elementId][attributeId] = [attributeId, ...values]` -- See attribute reference below - -### 11. get_location_state - -Get states of all devices in a specific location. - -**Input:** - -```json -{ - "locationUuid": "loc-123" // Use uuid from list_locations -} -``` - -**Output:** Array of device state objects (same structure as `get_device_state`). - -```json -[ - { - "uuid": "abc-123", - "mac": "AA:BB:CC:DD:EE:FF", - "loc": "location-id", - "state": {...} - }, - { - "uuid": "def-456", - "mac": "11:22:33:44:55:66", - "loc": "location-id", - "state": {...} - } -] -``` - -### 12. get_device_state_by_mac - -Get state of a specific device by MAC address within a location. - -**Input:** - -```json -{ - "locationUuid": "loc-123", - "macAddress": "AA:BB:CC:DD:EE:FF" -} -``` - -**Output:** Single device state object. - ---- - -## Device Control (2 tools) - -### 13. control_device - -Send raw control commands to a device. - -**Input:** - -```json -{ - "uuid": "abc-123", - "elementIds": [123, 456], - "command": [1, 1] // [attributeId, value, ...] -} -``` - -**Command Examples:** - -- `[1, 1]` - Turn ON (ON_OFF=1, value=1) -- `[1, 0]` - Turn OFF (ON_OFF=1, value=0) -- `[28, 700]` - Set brightness to 700 (BRIGHTNESS=28) -- `[29, 45000]` - Set kelvin to 45000 (KELVIN=29) -- `[20, 22]` - Set AC temperature to 22°C (TEMP_SET=20) -- `[17, 1]` - Set AC to COOLING mode (MODE=17, value=1) - -**Output:** - -```json -{ - "success": true, - "device": { - "uuid": "abc-123", - "label": "Living Room Light", - "mac": "AA:BB:CC:DD:EE:FF" - }, - "command_sent": { - "elementIds": [123, 456], - "command": [1, 1] - }, - "note": "Control command published to MQTT. Device state change is not guaranteed - check device state after a few seconds.", - "response": {...} -} -``` - -**Important Notes:** - -- Must get device details first to retrieve control parameters -- Command format: `[attributeId, value, attributeId2, value2, ...]` -- API only publishes MQTT message - doesn't validate or guarantee device state change -- Check device state after 2-3 seconds to verify - -### 14. control_device_simple - -Simplified control for common operations. Easier than `control_device`. - -**Input:** - -```json -{ - "uuid": "abc-123", - "action": "turn_on", // or: turn_off, set_brightness, set_kelvin, set_temperature, set_mode - "value": 700, // Optional: for set_* actions - "elementId": 123 // Optional: controls all elements if not specified -} -``` - -**Available Actions:** - -| Action | Description | Value Range | -| ----------------- | --------------------- | ---------------------------------- | -| `turn_on` | Turn device ON | N/A | -| `turn_off` | Turn device OFF | N/A | -| `set_brightness` | Set brightness level | 0-1000 | -| `set_kelvin` | Set color temperature | 0-65000 | -| `set_temperature` | Set AC temperature | 15-30 (°C) | -| `set_mode` | Set AC mode | 0-4 (AUTO/COOLING/DRY/HEATING/FAN) | - -**Examples:** - -```json -// Turn on all elements -{"uuid": "abc-123", "action": "turn_on"} - -// Set brightness of specific element -{"uuid": "abc-123", "action": "set_brightness", "value": 700, "elementId": 123} - -// Set AC temperature -{"uuid": "abc-123", "action": "set_temperature", "value": 22} - -// Set AC to COOLING mode -{"uuid": "abc-123", "action": "set_mode", "value": 1} -``` - -**Output:** Same structure as `control_device`. - ---- - -## Attribute Reference - -Common device attributes (from `device-attr-and-control.csv`): - -### Lights - -| Attribute | ID | Values | Example | -| ----------------- | --- | ------------------------------------ | ---------------------- | -| ON_OFF | 1 | 0 (Off), 1 (On) | `[1, 1]` | -| BRIGHTNESS | 28 | 0-1000 | `[28, 700]` | -| KELVIN | 29 | 0-65000 | `[29, 45000]` | -| BRIGHTNESS-KELVIN | 30 | [brightness, kelvin] | `[30, 500, 40000]` | -| COLOR_HSV | 31 | [hue 0-3600, sat 0-1000, val 0-1000] | `[31, 1800, 800, 600]` | - -### Switches - -| Attribute | ID | Values | Example | -| --------- | --- | --------------- | -------- | -| ON_OFF | 1 | 0 (Off), 1 (On) | `[1, 1]` | - -### Door/Gate - -| Attribute | ID | Values | Example | -| ---------- | --- | ------------------- | -------- | -| OPEN_CLOSE | 2 | 0 (Close), 1 (Open) | `[2, 1]` | - -### Door Lock - -| Attribute | ID | Values | Example | -| ----------- | --- | -------------------- | -------- | -| LOCK_UNLOCK | 3 | 0 (Lock), 1 (Unlock) | `[3, 1]` | - -### Air Conditioner - -| Attribute | ID | Values | Example | -| ------------- | --- | -------------------------------------------------------------- | ----------------------- | -| MODE | 17 | 0 (AUTO), 1 (COOLING), 2 (DRY), 3 (HEATING), 4 (FAN) | `[17, 1]` | -| FAN_SWING | 18 | 0 (Auto), 255 (Off) | `[18, 0]` | -| FAN_SPEED | 19 | 0 (Auto), 1 (Low), 2 (Normal), 3 (High), 4 (Max), 255 (Custom) | `[19, 3]` | -| TEMP_SET | 20 | 15-30 (°C) | `[20, 22]` | -| AC (Combined) | 257 | [on/off, mode, temp, fan, swing] | `[257, 1, 1, 24, 2, 0]` | - -### IR Remote - -| Attribute | ID | Values | Description | -| --------- | --- | ------------- | ----------------------------------------------- | -| IR_SE | 82 | 0-68, 256-258 | IR button codes (0-9, POWER, VOL_UP/DOWN, etc.) | - ---- - -## Usage Examples - -### Example 1: Login and List Devices - -```json -// Step 1: Login -{"tool": "login", "email": "user@example.com", "password": "pass123"} - -// Step 2: List all devices -{"tool": "list_devices"} -``` - -### Example 2: Control a Light - -```json -// Step 1: Login (already done) - -// Step 2: Turn on light -{"tool": "control_device_simple", "uuid": "abc-123", "action": "turn_on"} - -// Step 3: Set brightness -{"tool": "control_device_simple", "uuid": "abc-123", "action": "set_brightness", "value": 700} - -// Step 4: Check state (wait 2-3 seconds) -{"tool": "get_device_state", "uuid": "abc-123"} -``` - -### Example 3: Control Air Conditioner - -```json -// Turn on AC and set to 22°C cooling -{ - "tool": "control_device", - "uuid": "ac-uuid", - "elementIds": [123], - "command": [257, 1, 1, 22, 2, 0] -} -// Command breakdown: [257=AC, 1=ON, 1=COOLING, 22=temp, 2=normal fan, 0=auto swing] - -// Or use simple command: -{"tool": "control_device_simple", "uuid": "ac-uuid", "action": "set_temperature", "value": 22} -{"tool": "control_device_simple", "uuid": "ac-uuid", "action": "set_mode", "value": 1} -``` - -### Example 4: Search and Fetch - -```json -// Search for devices -{"tool": "search", "query": "living room"} - -// Fetch specific device details -{"tool": "fetch", "id": "device:abc-123"} -``` - ---- - -## Error Handling - -All tools return error information in this format: - -```json -{ - "content": [ - { - "type": "text", - "text": "Failed to control device: Device not found" - } - ], - "isError": true -} -``` - -Common errors: - -- **"Authentication required"** - Must call `login` tool first -- **"Device not found"** - Invalid UUID or device doesn't exist -- **"Missing required control fields"** - Device lacks eid/endpoint/partnerId/protocolCtl -- **"Value must be between X and Y"** - Invalid parameter value for control command - ---- - -## Best Practices - -### 1. Always Login First - -```json -{"tool": "login", ...} -``` - -### 2. Get Device Details Before Control - -```json -// Good: Get device first -{"tool": "get_device", "uuid": "abc-123"} -{"tool": "control_device", ...} - -// Also works: control_device fetches details automatically -{"tool": "control_device", ...} -``` - -### 3. Wait Before Checking State - -Control commands are asynchronous (MQTT). Wait 2-3 seconds before checking state. - -### 4. Use Simple Commands When Possible - -```json -// Easier -{"tool": "control_device_simple", "uuid": "abc-123", "action": "turn_on"} - -// vs Manual -{"tool": "control_device", "uuid": "abc-123", "elementIds": [123], "command": [1, 1]} -``` - -### 5. Handle Control Uncertainty - -The control API only publishes MQTT messages - it doesn't guarantee device state changes. Always verify state after control commands. - ---- - -## Endpoints Reference - -### State Endpoints - -- `GET /iot-core/state/devId/{deviceUuid}` - Single device state by UUID -- `GET /iot-core/state/{locationUuid}` - All device states in a location -- `GET /iot-core/state/{locationUuid}/{macAddress}` - Single device state by MAC address - -### Control Endpoint - -- `POST /iot-core/control/device` - Send control command - -**Control Payload:** - -```json -{ - "eid": 12345, - "elementIds": [123, 456], - "command": [1, 1], - "endpoint": "example", - "partnerId": "rogo", - "rootUuid": "device-root-uuid", - "protocolCtl": 123 -} -``` - -All control fields are retrieved automatically from device details. diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts deleted file mode 100644 index d6fc755..0000000 --- a/src/admin/admin.controller.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Controller, Post, Body, UseGuards, HttpCode, Logger } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiSecurity } from '@nestjs/swagger'; - -import { AdminAuthGuard } from './guards/admin-auth.guard'; -import { UpdateConfigDto, UpdateConfigResponseDto } from './dto/update-config.dto'; -import { ApiClientService } from '../mcp/services/api-client.service'; - -@ApiTags('Admin') -@Controller('admin') -@UseGuards(AdminAuthGuard) -@ApiSecurity('x-admin-api-key') -export class AdminController { - private readonly logger = new Logger(AdminController.name); - - constructor(private readonly apiClientService: ApiClientService) {} - - @Post('config') - @HttpCode(200) - @ApiOperation({ - summary: 'Update runtime configuration', - description: - 'Update IOT_API_BASE_URL and IOT_API_KEY without restarting the service. ' + - 'Requires x-admin-api-key header for authentication.', - }) - @ApiResponse({ - status: 200, - description: 'Configuration updated successfully', - type: UpdateConfigResponseDto, - }) - @ApiResponse({ - status: 400, - description: 'Invalid request - at least one field must be provided', - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - missing or invalid admin API key', - }) - updateConfig(@Body() dto: UpdateConfigDto): UpdateConfigResponseDto { - const { iotApiBaseUrl, iotApiKey } = dto; - - // Validate that at least one field is provided - if (!iotApiBaseUrl && !iotApiKey) { - this.logger.warn('Config update attempted with no fields'); - return { - success: false, - message: 'At least one configuration field must be provided', - }; - } - - // Update configuration - const updatedFields: string[] = []; - - if (iotApiBaseUrl) { - this.apiClientService.updateBaseUrl(iotApiBaseUrl); - updatedFields.push('iotApiBaseUrl'); - this.logger.log(`Updated IOT_API_BASE_URL to: ${iotApiBaseUrl}`); - } - - if (iotApiKey) { - this.apiClientService.updateApiKey(iotApiKey); - updatedFields.push('iotApiKey'); - this.logger.log('Updated IOT_API_KEY (value hidden for security)'); - } - - // Build response - const response: UpdateConfigResponseDto = { - success: true, - message: `Configuration updated successfully: ${updatedFields.join(', ')}`, - updatedConfig: { - ...(iotApiBaseUrl && { iotApiBaseUrl }), - ...(iotApiKey && { iotApiKeyUpdated: true }), - }, - }; - - return response; - } -} diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts deleted file mode 100644 index 9f6c803..0000000 --- a/src/admin/admin.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; - -import { AdminController } from './admin.controller'; -import { AdminAuthGuard } from './guards/admin-auth.guard'; -import { McpModule } from '../mcp/mcp.module'; - -@Module({ - imports: [ConfigModule, McpModule], - controllers: [AdminController], - providers: [AdminAuthGuard], -}) -export class AdminModule {} diff --git a/src/admin/dto/update-config.dto.ts b/src/admin/dto/update-config.dto.ts deleted file mode 100644 index 2edfc1d..0000000 --- a/src/admin/dto/update-config.dto.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { IsString, IsOptional } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateConfigDto { - @ApiProperty({ - description: 'IoT API base URL (e.g., https://api.iot-cloud.com)', - required: false, - example: 'https://api.iot-cloud.com', - }) - @IsString() - @IsOptional() - iotApiBaseUrl?: string; - - @ApiProperty({ - description: 'IoT API key for authentication', - required: false, - example: 'sk_live_1234567890abcdef', - }) - @IsString() - @IsOptional() - iotApiKey?: string; -} - -export class UpdateConfigResponseDto { - @ApiProperty({ - description: 'Operation success status', - example: true, - }) - success: boolean; - - @ApiProperty({ - description: 'Success or error message', - example: 'Configuration updated successfully', - }) - message: string; - - @ApiProperty({ - description: 'Updated configuration values (without sensitive data)', - example: { - iotApiBaseUrl: 'https://api.iot-cloud.com', - iotApiKeyUpdated: true, - }, - }) - updatedConfig?: { - iotApiBaseUrl?: string; - iotApiKeyUpdated?: boolean; - }; -} diff --git a/src/admin/guards/admin-auth.guard.ts b/src/admin/guards/admin-auth.guard.ts deleted file mode 100644 index 4dd4a97..0000000 --- a/src/admin/guards/admin-auth.guard.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; - -@Injectable() -export class AdminAuthGuard implements CanActivate { - private readonly logger = new Logger(AdminAuthGuard.name); - - constructor(private configService: ConfigService) {} - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const adminKey = request.headers['x-admin-api-key']; - const expectedKey = this.configService.get('ADMIN_API_KEY'); - - if (!expectedKey) { - this.logger.error('ADMIN_API_KEY is not configured in environment'); - throw new UnauthorizedException('Admin API is not configured'); - } - - if (!adminKey) { - this.logger.warn('Admin request without x-admin-api-key header'); - throw new UnauthorizedException('Missing admin API key'); - } - - if (adminKey !== expectedKey) { - this.logger.warn('Admin request with invalid API key'); - throw new UnauthorizedException('Invalid admin API key'); - } - - this.logger.debug('Admin authentication successful'); - return true; - } -} diff --git a/src/api/api.module.ts b/src/api/api.module.ts deleted file mode 100644 index d2c951f..0000000 --- a/src/api/api.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { AuthModule } from '../auth/auth.module'; -import { McpModule } from '../mcp/mcp.module'; -import { ApiClientService } from '../mcp/services/api-client.service'; -import { McpController } from './controllers/mcp.controller'; - -@Module({ - imports: [HttpModule, AuthModule, McpModule], - controllers: [McpController], - providers: [ApiClientService], -}) -export class ApiModule {} diff --git a/src/api/controllers/mcp.controller.ts b/src/api/controllers/mcp.controller.ts deleted file mode 100644 index 67543c6..0000000 --- a/src/api/controllers/mcp.controller.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Controller, All, Req, Res } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiExcludeEndpoint } from '@nestjs/swagger'; -import { Response, Request } from 'express'; -import { randomUUID } from 'node:crypto'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { McpService } from '../../mcp/services/mcp.service'; - -@ApiTags('MCP Protocol') -@Controller('mcp') -export class McpController { - // Store transports by session ID (stateful mode) - private readonly transports = new Map(); - - constructor(private mcpService: McpService) {} - - /** - * Main MCP endpoint - handles all HTTP methods - * GET: Establishes SSE stream for server-to-client messages - * POST: Receives client-to-server JSON-RPC messages - * DELETE: Terminates session - * - * NO AUTHENTICATION - Clients connect here, then use login tool - */ - @All() - @ApiOperation({ - summary: 'MCP Streamable HTTP endpoint', - description: - 'Main MCP endpoint using official SDK transport. No authentication required - use login tool to authenticate. ' + - 'Supports GET (SSE stream), POST (JSON-RPC messages), DELETE (session termination).', - }) - @ApiResponse({ - status: 200, - description: 'Request handled successfully', - }) - @ApiExcludeEndpoint() // Hide from Swagger since it's a special protocol endpoint - async handleMcpRequest(@Req() req: Request, @Res() res: Response): Promise { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - console.log(`[MCP] ${req.method} request`, { - sessionId, - hasBody: !!req.body, - url: req.url, - }); - - try { - let transport: StreamableHTTPServerTransport; - - if (sessionId && this.transports.has(sessionId)) { - // Reuse existing transport for this session - transport = this.transports.get(sessionId)!; - console.log(`[MCP] Reusing transport for session: ${sessionId}`); - } else { - // Create new transport (stateful mode with session management) - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - // Store transport when session is initialized - console.log(`[MCP] Session initialized: ${newSessionId}`); - this.transports.set(newSessionId, transport); - }, - }); - - // Set up cleanup handler - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && this.transports.has(sid)) { - console.log(`[MCP] Transport closed for session: ${sid}`); - this.transports.delete(sid); - } - }; - - // CRITICAL FIX: Create NEW server instance per transport - // Each transport needs its own server connection - console.log('[MCP] Creating new server instance for transport'); - const server = await this.mcpService.createServer(); - await server.connect(transport); - } - - // Handle the request using the SDK transport - // The transport handles SSE streaming, JSON-RPC parsing, session validation, etc. - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('[MCP] Error handling request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error', - }, - id: null, - }); - } - } - } -} diff --git a/src/app.module.ts b/src/app.module.ts index f42a823..23dcbfc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,11 +2,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ThrottlerModule } from '@nestjs/throttler'; import { HttpModule } from '@nestjs/axios'; - -import { AuthModule } from './auth/auth.module'; -import { ApiModule } from './api/api.module'; -import { AdminModule } from './admin/admin.module'; import { HealthController } from './health.controller'; +import { McpV2Module } from '@/mcp_v2/mcp-v2.module'; @Module({ imports: [ @@ -29,11 +26,7 @@ import { HealthController } from './health.controller'; timeout: 30000, maxRedirects: 5, }), - - // Feature modules - AuthModule, - ApiModule, - AdminModule, + McpV2Module ], controllers: [HealthController], }) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts deleted file mode 100644 index 18bd6a2..0000000 --- a/src/auth/auth.controller.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Controller, Post, Body, HttpCode, HttpStatus, HttpException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsString, MinLength } from 'class-validator'; -import { AuthService } from './auth.service'; -import { Throttle } from '@nestjs/throttler'; - -class LoginDto { - @ApiProperty({ description: 'User email address', example: 'user@example.com' }) - @IsEmail() - email: string; - - @ApiProperty({ description: 'User password', example: '123456', minLength: 6 }) - @IsString() - @MinLength(6) - password: string; -} - -class RefreshTokenDto { - @ApiProperty({ description: 'Refresh token from previous login' }) - @IsString() - refresh_token: string; -} - -class LoginResponseDto { - access_token: string; - token_type: string; - refresh_token: string; - expires_in: number; - id_token?: string; -} - -@ApiTags('Authentication') -@Controller('auth') -export class AuthController { - constructor(private authService: AuthService) {} - - @Post('login') - @HttpCode(HttpStatus.OK) - @Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 attempts per minute - @ApiOperation({ - summary: 'Login with email and password', - description: - 'Authenticates user with IoT Cloud API and returns Firebase JWT token. Use this token in Authorization header for subsequent requests.', - }) - @ApiResponse({ - status: 200, - description: 'Login successful', - type: LoginResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Invalid credentials', - }) - @ApiResponse({ - status: 429, - description: 'Too many login attempts', - }) - async login(@Body() loginDto: LoginDto): Promise { - try { - return await this.authService.login(loginDto.email, loginDto.password); - } catch (error) { - throw new HttpException(error.message, error.status || HttpStatus.UNAUTHORIZED); - } - } - - @Post('refresh') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Refresh access token', - description: 'Obtains a new access token using a valid refresh token.', - }) - @ApiResponse({ - status: 200, - description: 'Token refreshed successfully', - type: LoginResponseDto, - }) - @ApiResponse({ - status: 401, - description: 'Invalid refresh token', - }) - async refresh(@Body() refreshDto: RefreshTokenDto): Promise { - try { - return await this.authService.refreshToken(refreshDto.refresh_token); - } catch (error) { - throw new HttpException(error.message, error.status || HttpStatus.UNAUTHORIZED); - } - } -} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts deleted file mode 100644 index 0d2faad..0000000 --- a/src/auth/auth.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { AuthController } from './auth.controller'; -import { AuthService } from './auth.service'; - -@Module({ - imports: [HttpModule], - controllers: [AuthController], - providers: [AuthService], - exports: [AuthService], -}) -export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts deleted file mode 100644 index 6c911a1..0000000 --- a/src/auth/auth.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Injectable, UnauthorizedException, Logger } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; -import { AxiosError } from 'axios'; - -export interface LoginResponse { - access_token: string; - token_type: string; - refresh_token: string; - expires_in: number; - id_token?: string; -} - -@Injectable() -export class AuthService { - private readonly logger = new Logger(AuthService.name); - - constructor(private httpService: HttpService, private configService: ConfigService) {} - - async login(email: string, password: string): Promise { - const apiUrl = this.configService.get('IOT_API_BASE_URL'); - const apiKey = this.configService.get('IOT_API_KEY'); - - this.logger.log(`Attempting login for user: ${email}`); - - try { - const response = await firstValueFrom( - this.httpService.post( - `${apiUrl}/authen/login`, - { email, password }, - { - headers: { - 'x-header-apikey': apiKey, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - }, - ), - ); - - this.logger.log(`Login successful for user: ${email}`); - return response.data; - } catch (error) { - this.handleAuthError(error, 'Login'); - } - } - - async refreshToken(refreshToken: string): Promise { - const apiUrl = this.configService.get('IOT_API_BASE_URL'); - const apiKey = this.configService.get('IOT_API_KEY'); - - this.logger.log('Attempting token refresh'); - - try { - const response = await firstValueFrom( - this.httpService.post( - `${apiUrl}/authen/refresh`, - { refresh_token: refreshToken }, - { - headers: { - 'x-header-apikey': apiKey, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - }, - ), - ); - - this.logger.log('Token refresh successful'); - return response.data; - } catch (error) { - this.handleAuthError(error, 'Token refresh'); - } - } - - private handleAuthError(error: any, operation: string): never { - const axiosError = error as AxiosError; - - this.logger.error(`${operation} failed:`, axiosError.response?.data || axiosError.message); - - if (axiosError.response?.status === 401) { - throw new UnauthorizedException('Invalid credentials'); - } - - if (axiosError.response?.status === 400) { - throw new UnauthorizedException('Invalid request format'); - } - - throw new UnauthorizedException(`${operation} failed. Please try again.`); - } -} diff --git a/src/main.ts b/src/main.ts index a4094b4..ba1a787 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; @@ -67,9 +67,10 @@ async function bootstrap() { await app.listen(port, host); - console.log(`🚀 IoT Cloud MCP Bridge Server running on http://${host}:${port}`); - console.log(`📚 API Documentation available at http://${host}:${port}/api/docs`); - console.log(`📋 OpenAPI JSON at http://${host}:${port}/api/docs-json`); + const logger = new Logger('Bootstrap'); + logger.log(`IoT Cloud MCP Bridge Server running on http://${host}:${port}`); + logger.log(`API Documentation available at http://${host}:${port}/api/docs`); + logger.log(`OpenAPI JSON at http://${host}:${port}/api/docs-json`); } bootstrap(); diff --git a/src/mcp/mcp.module.ts b/src/mcp/mcp.module.ts deleted file mode 100644 index 54544e6..0000000 --- a/src/mcp/mcp.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; -import { McpService } from './services/mcp.service'; -import { AuthModule } from '@/auth/auth.module'; -import { ApiClientService } from '@/mcp/services/api-client.service'; -import { RedisService } from '@/mcp/services/redis.service'; - -@Module({ - imports: [HttpModule, AuthModule], - providers: [McpService, ApiClientService, RedisService], - exports: [McpService, ApiClientService], -}) -export class McpModule {} diff --git a/src/mcp/services/api-client.service.ts b/src/mcp/services/api-client.service.ts deleted file mode 100644 index be99479..0000000 --- a/src/mcp/services/api-client.service.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { firstValueFrom } from 'rxjs'; -import { AxiosError, AxiosRequestConfig } from 'axios'; - -export interface ApiRequestOptions { - method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; - path: string; - firebaseToken: string; - data?: any; - params?: any; -} - -@Injectable() -export class ApiClientService { - private readonly logger = new Logger(ApiClientService.name); - private baseUrl: string; - private apiKey: string; - private readonly timeout: number; - - constructor( - private httpService: HttpService, - private configService: ConfigService, - ) { - this.baseUrl = this.configService.get('IOT_API_BASE_URL') || ''; - this.apiKey = this.configService.get('IOT_API_KEY') || ''; - this.timeout = this.configService.get('IOT_API_TIMEOUT') || 30000; - - if (!this.baseUrl || !this.apiKey) { - throw new Error('IOT_API_BASE_URL and IOT_API_KEY must be configured'); - } - } - - /** - * Update the IoT API base URL at runtime - * @param url - New base URL (e.g., https://api.iot-cloud.com) - */ - updateBaseUrl(url: string): void { - if (!url || typeof url !== 'string') { - throw new Error('Base URL must be a non-empty string'); - } - this.logger.log(`Updating base URL from ${this.baseUrl} to ${url}`); - this.baseUrl = url; - } - - /** - * Update the IoT API key at runtime - * @param key - New API key - */ - updateApiKey(key: string): void { - if (!key || typeof key !== 'string') { - throw new Error('API key must be a non-empty string'); - } - this.logger.log('Updating API key (value hidden for security)'); - this.apiKey = key; - } - - /** - * Update multiple configuration values at runtime - * @param config - Configuration object with baseUrl and/or apiKey - */ - updateConfig(config: { baseUrl?: string; apiKey?: string }): void { - if (config.baseUrl) { - this.updateBaseUrl(config.baseUrl); - } - if (config.apiKey) { - this.updateApiKey(config.apiKey); - } - } - - async request(options: ApiRequestOptions): Promise { - const { method, path, firebaseToken, data, params } = options; - - const config: AxiosRequestConfig = { - method, - url: `${this.baseUrl}${path}`, - headers: { - Authorization: `Bearer ${firebaseToken}`, - 'x-header-apikey': this.apiKey, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - timeout: this.timeout, - data, - params, - }; - - this.logger.debug(`${method} ${path}`); - - try { - const response = await firstValueFrom(this.httpService.request(config)); - - // Log response summary for debugging - const dataType = typeof response.data; - const isArray = Array.isArray(response.data); - const dataLength = isArray ? (response.data as any[]).length : 'N/A'; - this.logger.debug( - `${method} ${path} -> ${response.status} (type: ${dataType}, isArray: ${isArray}, length: ${dataLength})`, - ); - - return response.data; - } catch (error) { - return this.handleError(error, `${method} ${path}`); - } - } - - async get(path: string, firebaseToken: string, params?: any): Promise { - return this.request({ - method: 'GET', - path, - firebaseToken, - params, - }); - } - - async post(path: string, firebaseToken: string, data?: any): Promise { - return this.request({ - method: 'POST', - path, - firebaseToken, - data, - }); - } - - async patch(path: string, firebaseToken: string, data?: any): Promise { - return this.request({ - method: 'PATCH', - path, - firebaseToken, - data, - }); - } - - async delete(path: string, firebaseToken: string, data?: any): Promise { - return this.request({ - method: 'DELETE', - path, - firebaseToken, - data, - }); - } - - private handleError(error: any, context: string): never { - const axiosError = error as AxiosError; - - this.logger.error(`API Error [${context}]:`, axiosError.response?.data || axiosError.message); - - const status = axiosError.response?.status; - const message = this.getErrorMessage(axiosError); - - throw { - status: status || 500, - message, - context, - details: axiosError.response?.data, - }; - } - - private getErrorMessage(error: AxiosError): string { - if (error.response) { - const data = error.response.data as any; - - // Handle IoT API error format - if (data?.message) { - return data.message; - } - - // Handle common HTTP errors - switch (error.response.status) { - case 400: - return 'Invalid request parameters'; - case 401: - return 'Unauthorized - invalid or expired token'; - case 403: - return 'Forbidden - insufficient permissions'; - case 404: - return 'Resource not found'; - case 429: - return 'Too many requests - rate limit exceeded'; - case 500: - return 'Internal server error'; - default: - return `Request failed with status ${error.response.status}`; - } - } - - if (error.code === 'ECONNABORTED') { - return 'Request timeout - IoT API did not respond in time'; - } - - if (error.code === 'ECONNREFUSED') { - return 'Cannot connect to IoT API'; - } - - return error.message || 'Unknown error occurred'; - } -} diff --git a/src/mcp/services/mcp.service.ts b/src/mcp/services/mcp.service.ts deleted file mode 100644 index 800709a..0000000 --- a/src/mcp/services/mcp.service.ts +++ /dev/null @@ -1,1099 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApiClientService } from './api-client.service'; -import { AuthService } from '../../auth/auth.service'; -import { RedisService, ConnectionState } from './redis.service'; -import * as z from 'zod'; - -/** - * Authenticated state with guaranteed non-null values - */ -interface AuthenticatedState { - token: string; - userId: string; -} - -/** - * MCP Service using official SDK - * Manages MCP server instance and tool registrations - */ -@Injectable() -export class McpService { - private readonly logger = new Logger(McpService.name); - - constructor( - private apiClient: ApiClientService, - private authService: AuthService, - private redisService: RedisService, - ) {} - - /** - * Helper: Extract session key from MCP extra context - * @throws Error if sessionId is missing - */ - private getSessionKey(extra: any): string { - const sid = extra?.sessionId; - if (!sid) { - throw new Error('Missing MCP sessionId'); - } - return sid; - } - - /** - * Helper: Create standard "authentication required" error response - */ - private authRequired(): { content: Array<{ type: 'text'; text: string }>; isError: boolean } { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Authentication required. Please use the login tool first.', - }, - ], - }; - } - - /** - * Helper: Create standard "Redis unavailable" error response - */ - private redisUnavailable(): { - content: Array<{ type: 'text'; text: string }>; - isError: boolean; - } { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Session store unavailable. Please retry in a moment.', - }, - ], - }; - } - - /** - * Helper: Require authentication and return connection state - * @throws Error with message 'REDIS_UNAVAILABLE' if Redis fails - * @returns AuthenticatedState with non-null values if authenticated, null if not authenticated - */ - private async requireAuth(extra: any): Promise { - const sessionKey = this.getSessionKey(extra); - try { - const state = await this.redisService.getSessionState(sessionKey); - if (!state?.token || !state?.userId) { - return null; - } - // Type assertion safe because we checked both values are non-null - return state as AuthenticatedState; - } catch (e) { - this.logger.error('Redis error during authentication check', e); - throw new Error('REDIS_UNAVAILABLE'); - } - } - - /** - * Helper: Wrap tool handler with authentication check - * Automatically handles auth validation and error responses - */ - private withAuth( - fn: ( - args: TArgs, - state: AuthenticatedState, - extra: any, - ) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }>, - ) { - return async ( - args: TArgs, - extra: any, - ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> => { - try { - const state = await this.requireAuth(extra); - if (!state) { - return this.authRequired(); - } - return await fn(args, state, extra); - } catch (e: any) { - if (e?.message === 'REDIS_UNAVAILABLE') { - return this.redisUnavailable(); - } - return { - isError: true, - content: [ - { - type: 'text', - text: `Error: ${e?.message ?? e}`, - }, - ], - }; - } - }; - } - - /** - * Create a NEW MCP Server instance - * IMPORTANT: Each transport needs its own server instance - * The official SDK's server.connect() can only be called once per server - */ - async createServer(): Promise { - // Create MCP server with official SDK - const server = new McpServer( - { - name: 'IoT Cloud MCP Bridge', - version: '1.0.0', - }, - { - capabilities: { - tools: {}, - logging: {}, - }, - }, - ); - - // Register all tools - this.registerTools(server); - - this.logger.log('MCP Server instance created with official SDK'); - return server; - } - - /** - * Register all MCP tools using the SDK's high-level API - */ - private registerTools(server: McpServer): void { - // Tool 1: login - Authenticate users - server.registerTool( - 'login', - { - description: - 'Authenticate with email and password to get access to IoT devices. MUST be called first before using other tools.', - inputSchema: z.object({ - email: z.string().describe('User email address'), - password: z.string().describe('User password'), - }), - }, - async ({ email, password }, extra) => { - try { - const loginResult = await this.authService.login(email, password); - - // Decode JWT to extract userId - let userId: string | null = null; - try { - const tokenParts = loginResult.access_token.split('.'); - if (tokenParts.length === 3) { - const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); - userId = payload.user_id || payload.sub || null; - } - } catch (error) { - this.logger.warn('Could not decode JWT token', error); - } - - // Store in Redis session state - const sessionKey = this.getSessionKey(extra); - await this.redisService.setSessionState(sessionKey, { - token: loginResult.access_token, - userId, - }); - - this.logger.log( - `User ${email} logged in successfully, userId: ${userId}, session: ${sessionKey}`, - ); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - success: true, - message: - 'Login successful. You can now use other tools to interact with your IoT devices.', - token_type: loginResult.token_type, - expires_in: loginResult.expires_in, - user_id: userId, - }, - null, - 2, - ), - }, - ], - }; - } catch (error) { - this.logger.error('Login failed:', error); - return { - content: [ - { - type: 'text' as const, - text: `Login failed: ${error.message}`, - }, - ], - isError: true, - }; - } - }, - ); - - // Tool 2: search (ChatGPT-compatible) - server.registerTool( - 'search', - { - description: - 'Search/filter IoT devices, locations, and groups by name, description, or ID. Use this when user wants to FIND SPECIFIC items matching a keyword. For listing ALL items without filtering, use list_devices, list_locations, or list_groups instead.', - inputSchema: z.object({ - query: z - .string() - .describe( - 'Search keyword to filter items (e.g., "living room", "temperature", "gateway"). Leave empty or use "*" to return all items.', - ), - }), - }, - this.withAuth(async ({ query }, state) => { - // Empty query or "*" means return all items - const isListAll = !query || query.trim() === '' || query === '*'; - const lowerQuery = isListAll ? '' : query.toLowerCase(); - const results: any[] = []; - - // Search devices - this.logger.debug(`[search] Fetching devices for userId: ${state.userId}`); - const devices = await this.apiClient.get(`/device/${state.userId}`, state.token); - this.logger.debug( - `[search] Devices response type: ${typeof devices}, isArray: ${Array.isArray(devices)}, length: ${Array.isArray(devices) ? devices.length : 'N/A'}`, - ); - if (devices && typeof devices === 'object') { - this.logger.debug(`[search] Devices response keys: ${Object.keys(devices).join(', ')}`); - this.logger.debug( - `[search] Devices response sample: ${JSON.stringify(devices).substring(0, 500)}`, - ); - } - - if (Array.isArray(devices)) { - const filteredDevices = isListAll - ? devices - : devices.filter( - (d) => - d.label?.toLowerCase().includes(lowerQuery) || - d.mac?.toLowerCase().includes(lowerQuery) || - d.desc?.toLowerCase().includes(lowerQuery) || - d.productId?.toLowerCase().includes(lowerQuery), - ); - this.logger.debug( - `[search] Filtered ${filteredDevices.length} devices matching query "${query}" (listAll: ${isListAll})`, - ); - - filteredDevices.forEach((device) => { - results.push({ - id: `device:${device.uuid}`, - title: `Device: ${device.label || device.uuid}`, - url: `https://mcp.dash.id.vn/device/${device.uuid}`, - }); - }); - } else { - this.logger.warn(`[search] Devices response is not an array!`); - } - - // Search locations - this.logger.debug(`[search] Fetching locations for userId: ${state.userId}`); - const locations = await this.apiClient.get(`/location/${state.userId}`, state.token); - this.logger.debug( - `[search] Locations response type: ${typeof locations}, isArray: ${Array.isArray(locations)}, length: ${Array.isArray(locations) ? locations.length : 'N/A'}`, - ); - if (locations && typeof locations === 'object' && !Array.isArray(locations)) { - this.logger.debug( - `[search] Locations response keys: ${Object.keys(locations).join(', ')}`, - ); - this.logger.debug( - `[search] Locations response sample: ${JSON.stringify(locations).substring(0, 500)}`, - ); - } - - if (Array.isArray(locations)) { - const filteredLocations = isListAll - ? locations - : locations.filter( - (l) => - l.label?.toLowerCase().includes(lowerQuery) || - l.desc?.toLowerCase().includes(lowerQuery) || - l.uuid?.toLowerCase().includes(lowerQuery), - ); - this.logger.debug( - `[search] Filtered ${filteredLocations.length} locations matching query "${query}" (listAll: ${isListAll})`, - ); - - filteredLocations.forEach((location) => { - results.push({ - id: `location:${location.uuid}`, - title: `Location: ${location.label || location.uuid}`, - url: `https://mcp.dash.id.vn/location/${location.uuid}`, - }); - }); - } else { - this.logger.warn(`[search] Locations response is not an array!`); - } - - // Search groups - this.logger.debug(`[search] Fetching groups for userId: ${state.userId}`); - const groups = await this.apiClient.get(`/group/${state.userId}`, state.token); - this.logger.debug( - `[search] Groups response type: ${typeof groups}, isArray: ${Array.isArray(groups)}, length: ${Array.isArray(groups) ? groups.length : 'N/A'}`, - ); - if (groups && typeof groups === 'object' && !Array.isArray(groups)) { - this.logger.debug(`[search] Groups response keys: ${Object.keys(groups).join(', ')}`); - this.logger.debug( - `[search] Groups response sample: ${JSON.stringify(groups).substring(0, 500)}`, - ); - } - - if (Array.isArray(groups)) { - const filteredGroups = isListAll - ? groups - : groups.filter( - (g) => - g.label?.toLowerCase().includes(lowerQuery) || - g.desc?.toLowerCase().includes(lowerQuery) || - g.uuid?.toLowerCase().includes(lowerQuery), - ); - this.logger.debug( - `[search] Filtered ${filteredGroups.length} groups matching query "${query}" (listAll: ${isListAll})`, - ); - - filteredGroups.forEach((group) => { - results.push({ - id: `group:${group.uuid}`, - title: `Group: ${group.label || group.uuid}`, - url: `https://mcp.dash.id.vn/group/${group.uuid}`, - }); - }); - } else { - this.logger.warn(`[search] Groups response is not an array!`); - } - - this.logger.log(`[search] Total results found: ${results.length} (query: "${query}")`); - if (results.length > 0) { - this.logger.debug(`[search] First result sample: ${JSON.stringify(results[0])}`); - } - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ results }), - }, - ], - }; - }), - ); - - // Tool 3: fetch (ChatGPT-compatible) - server.registerTool( - 'fetch', - { - description: - 'Retrieve complete details of a specific IoT device, location, or group by ID.', - inputSchema: z.object({ - id: z - .string() - .describe( - 'The unique identifier (format: type:id, e.g., device:uuid, location:uuid, group:uuid)', - ), - }), - }, - this.withAuth(async ({ id }, state) => { - const [type, uuid] = id.split(':'); - - if (!type || !uuid) { - throw new Error('Invalid ID format. Expected format: type:uuid (e.g., device:abc-123)'); - } - - let fetchedData: any; - let title: string; - let url: string; - - this.logger.debug( - `[fetch] Fetching resource: type=${type}, uuid=${uuid}, userId=${state.userId}`, - ); - - switch (type) { - case 'device': - this.logger.debug(`[fetch] Fetching device: /device/${state.userId}/${uuid}`); - fetchedData = await this.apiClient.get(`/device/${state.userId}/${uuid}`, state.token); - this.logger.debug( - `[fetch] Device response type: ${typeof fetchedData}, keys: ${fetchedData ? Object.keys(fetchedData).slice(0, 10).join(', ') : 'null'}`, - ); - title = `Device: ${fetchedData.label || uuid}`; - url = `https://mcp.dash.id.vn/device/${uuid}`; - break; - - case 'location': - this.logger.debug(`[fetch] Fetching all locations to find uuid: ${uuid}`); - const allLocations = await this.apiClient.get(`/location/${state.userId}`, state.token); - this.logger.debug( - `[fetch] Locations response isArray: ${Array.isArray(allLocations)}, length: ${Array.isArray(allLocations) ? allLocations.length : 'N/A'}`, - ); - fetchedData = Array.isArray(allLocations) - ? allLocations.find((l) => l.uuid === uuid) - : null; - if (!fetchedData) { - this.logger.warn( - `[fetch] Location ${uuid} not found in ${Array.isArray(allLocations) ? allLocations.length : 0} locations`, - ); - throw new Error(`Location ${uuid} not found`); - } - title = `Location: ${fetchedData.label || uuid}`; - url = `https://mcp.dash.id.vn/location/${uuid}`; - break; - - case 'group': - this.logger.debug(`[fetch] Fetching all groups to find uuid: ${uuid}`); - const allGroups = await this.apiClient.get(`/group/${state.userId}`, state.token); - this.logger.debug( - `[fetch] Groups response isArray: ${Array.isArray(allGroups)}, length: ${Array.isArray(allGroups) ? allGroups.length : 'N/A'}`, - ); - fetchedData = Array.isArray(allGroups) ? allGroups.find((g) => g.uuid === uuid) : null; - if (!fetchedData) { - this.logger.warn( - `[fetch] Group ${uuid} not found in ${Array.isArray(allGroups) ? allGroups.length : 0} groups`, - ); - throw new Error(`Group ${uuid} not found`); - } - title = `Group: ${fetchedData.label || uuid}`; - url = `https://mcp.dash.id.vn/group/${uuid}`; - break; - - default: - throw new Error(`Unknown resource type: ${type}`); - } - - this.logger.log(`[fetch] Successfully fetched ${type}:${uuid} - ${title}`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - id, - title, - text: JSON.stringify(fetchedData, null, 2), - url, - metadata: { - type, - uuid, - retrieved_at: new Date().toISOString(), - }, - }), - }, - ], - }; - }), - ); - - // Tool 4: list_devices - List ALL devices without filtering - server.registerTool( - 'list_devices', - { - description: - 'List ALL IoT devices for the authenticated user. Use this when user asks to "show my devices", "list devices", "what devices do I have", etc. Returns complete device list without filtering.', - inputSchema: z.object({}), - }, - this.withAuth(async (args, state) => { - this.logger.debug(`[list_devices] Fetching all devices for userId: ${state.userId}`); - const devices = await this.apiClient.get(`/device/${state.userId}`, state.token); - - if (!Array.isArray(devices)) { - this.logger.warn(`[list_devices] Response is not an array`); - return { - content: [ - { - type: 'text' as const, - text: 'No devices found or invalid response format.', - }, - ], - }; - } - - this.logger.log(`[list_devices] Found ${devices.length} devices`); - - const deviceList = devices.map((d) => ({ - uuid: d.uuid, - mac: d.mac, - name: d.label || d.uuid, - description: d.desc || '', - productId: d.productId, - locationId: d.locationId, - groupId: d.groupId, - })); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - total: devices.length, - devices: deviceList, - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - // Tool 5: list_locations - List ALL locations - server.registerTool( - 'list_locations', - { - description: - 'List ALL location groups for the authenticated user. Use this when user asks to "show my locations", "list locations", "what locations do I have", etc.', - inputSchema: z.object({}), - }, - this.withAuth(async (args, state) => { - this.logger.debug(`[list_locations] Fetching all locations for userId: ${state.userId}`); - const locations = await this.apiClient.get(`/location/${state.userId}`, state.token); - - if (!Array.isArray(locations)) { - this.logger.warn(`[list_locations] Response is not an array`); - return { - content: [ - { - type: 'text' as const, - text: 'No locations found or invalid response format.', - }, - ], - }; - } - - this.logger.log(`[list_locations] Found ${locations.length} locations`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - total: locations.length, - locations: locations, - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - // Tool 6: list_groups - List ALL device groups - server.registerTool( - 'list_groups', - { - description: - 'List ALL device groups for the authenticated user. Use this when user asks to "show my groups", "list groups", "what groups do I have", etc.', - inputSchema: z.object({}), - }, - this.withAuth(async (args, state) => { - this.logger.debug(`[list_groups] Fetching all groups for userId: ${state.userId}`); - const groups = await this.apiClient.get(`/group/${state.userId}`, state.token); - - if (!Array.isArray(groups)) { - this.logger.warn(`[list_groups] Response is not an array`); - return { - content: [ - { - type: 'text' as const, - text: 'No groups found or invalid response format.', - }, - ], - }; - } - - this.logger.log(`[list_groups] Found ${groups.length} groups`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - total: groups.length, - groups: groups, - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - // Tool 7: get_device - Get device details by UUID - server.registerTool( - 'get_device', - { - description: - 'Get detailed information about a specific IoT device by its UUID. Returns complete device data including properties, state, and configuration.', - inputSchema: z.object({ - uuid: z.string().describe('Device UUID (unique identifier)'), - }), - }, - this.withAuth(async ({ uuid }, state) => { - this.logger.debug(`[get_device] Fetching device uuid: ${uuid}`); - const device = await this.apiClient.get(`/device/${state.userId}/${uuid}`, state.token); - - this.logger.log(`[get_device] Retrieved device: ${device.label || uuid}`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(device, null, 2), - }, - ], - }; - }), - ); - - // Tool 8: update_device - Update device properties - server.registerTool( - 'update_device', - { - description: - 'Update device properties such as name (label), description, group assignment, or favorite status.', - inputSchema: z.object({ - uuid: z.string().describe('Device UUID to update'), - label: z.string().optional().describe('New device name/label (max 255 chars)'), - desc: z.string().optional().describe('New device description (max 255 chars)'), - groupId: z.string().optional().describe('Group UUID to assign device to'), - vgroupId: z.string().optional().describe('Virtual group UUID'), - fav: z.boolean().optional().describe('Mark as favorite (true/false)'), - }), - }, - this.withAuth(async ({ uuid, label, desc, groupId, vgroupId, fav }, state) => { - // Build update payload - const updateData: any = { uuid }; - if (label !== undefined) updateData.label = label; - if (desc !== undefined) updateData.desc = desc; - if (groupId !== undefined) updateData.groupId = groupId; - if (vgroupId !== undefined) updateData.vgroupId = vgroupId; - if (fav !== undefined) updateData.fav = fav; - - this.logger.debug(`[update_device] Updating device ${uuid} with:`, updateData); - - const result = await this.apiClient.patch( - `/device/${state.userId}`, - state.token, - updateData, - ); - - this.logger.log(`[update_device] Updated device ${uuid} successfully`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - success: true, - uuid: uuid, - message: 'Device updated successfully', - updated: updateData, - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - // Tool 9: delete_device - Delete a device - server.registerTool( - 'delete_device', - { - description: - 'Delete an IoT device permanently. This action cannot be undone. Use with caution.', - inputSchema: z.object({ - uuid: z.string().describe('Device UUID to delete'), - }), - }, - this.withAuth(async ({ uuid }, state) => { - this.logger.debug(`[delete_device] Deleting device uuid: ${uuid}`); - - const result = await this.apiClient.delete(`/device/${state.userId}`, state.token, { - uuid, - }); - - this.logger.log(`[delete_device] Deleted device ${uuid} successfully`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - success: true, - uuid: uuid, - message: 'Device deleted successfully', - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - // Tool 10: get_device_state - Get state of a single device by UUID - server.registerTool( - 'get_device_state', - { - description: - 'Get current state of a specific IoT device by its UUID. Returns state information including attributes, values, and last update time for the device.', - inputSchema: z.object({ - uuid: z.string().describe('Device UUID (unique identifier)'), - }), - }, - this.withAuth(async ({ uuid }, state) => { - this.logger.debug(`[get_device_state] Fetching state for device: ${uuid}`); - const deviceState = await this.apiClient.get(`/state/devId/${uuid}`, state.token); - - this.logger.log(`[get_device_state] Retrieved device state successfully`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(deviceState, null, 2), - }, - ], - }; - }), - ); - - // Tool 11: get_location_state - Get states for all devices in a location - server.registerTool( - 'get_location_state', - { - description: - 'Get current states of all IoT devices in a specific location. Returns state information for all devices within the specified location.', - inputSchema: z.object({ - locationUuid: z.string().describe('Location UUID (use uuid field from list_locations)'), - }), - }, - this.withAuth(async ({ locationUuid }, state) => { - this.logger.debug(`[get_location_state] Fetching state for location: ${locationUuid}`); - const states = await this.apiClient.get(`/state/${locationUuid}`, state.token); - - this.logger.log(`[get_location_state] Retrieved location state successfully`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(states, null, 2), - }, - ], - }; - }), - ); - - // Tool 12: get_device_state_by_mac - Get state of a specific device by MAC address - server.registerTool( - 'get_device_state_by_mac', - { - description: - 'Get current state of a specific IoT device by its MAC address within a location. Useful when you need state info for a single device.', - inputSchema: z.object({ - locationUuid: z.string().describe('Location UUID where the device is located'), - macAddress: z.string().describe('Device MAC address (physical identifier)'), - }), - }, - this.withAuth(async ({ locationUuid, macAddress }, state) => { - this.logger.debug( - `[get_device_state_by_mac] Fetching state for location ${locationUuid}, device ${macAddress}`, - ); - const deviceState = await this.apiClient.get( - `/state/${locationUuid}/${macAddress}`, - state.token, - ); - - this.logger.log(`[get_device_state_by_mac] Retrieved device state successfully`); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify(deviceState, null, 2), - }, - ], - }; - }), - ); - - // Tool 13: control_device - Control an IoT device - server.registerTool( - 'control_device', - { - description: - 'Send control command to an IoT device. You must first get device details to retrieve required fields (eid, rootUuid, endpoint, partnerId, protocolCtl). ' + - 'Command format: [attributeId, value, ...] e.g., [1, 1] for ON, [1, 0] for OFF, [28, 700] for brightness. ' + - 'See device-attr-and-control.csv for attribute IDs: ON_OFF=1, BRIGHTNESS=28, KELVIN=29, COLOR_HSV=31, MODE=17, TEMP_SET=20, etc. ' + - 'Note: This API only publishes MQTT messages - it does not validate or guarantee device state changes.', - inputSchema: z.object({ - uuid: z.string().describe('Device UUID to control (first get device details)'), - elementIds: z - .array(z.number()) - .describe('Element IDs to control (from device.elementIds, use all if unsure)'), - command: z - .array(z.number()) - .describe( - 'Command array: [attributeId, value, ...]. Examples: [1,1]=ON, [1,0]=OFF, [28,700]=brightness, [20,22]=temp 22°C', - ), - }), - }, - this.withAuth(async ({ uuid, elementIds, command }, state) => { - this.logger.debug(`[control_device] Getting device details for uuid: ${uuid}`); - - // First, get device details to retrieve control parameters - const device = await this.apiClient.get(`/device/${state.userId}/${uuid}`, state.token); - - if (!device) { - throw new Error(`Device ${uuid} not found`); - } - - // Extract required fields - const eid = device.eid; - const rootUuid = device.rootUuid || device.uuid; // Use device uuid if no rootUuid - const endpoint = device.endpoint; - const partnerId = device.partnerId; - const protocolCtl = device.protocolCtl; - - // Validate required fields - if (!eid || !endpoint || !partnerId || protocolCtl === undefined) { - this.logger.error('[control_device] Missing required device fields:', { - eid, - endpoint, - partnerId, - protocolCtl, - }); - throw new Error( - 'Device is missing required control fields (eid, endpoint, partnerId, or protocolCtl)', - ); - } - - // Build control payload - const payload = { - eid, - elementIds, - command, - endpoint, - partnerId, - rootUuid, - protocolCtl, - }; - - this.logger.debug('[control_device] Sending control command:', payload); - - // Send control command - const result = await this.apiClient.post('/control/device', state.token, payload); - - this.logger.log( - `[control_device] Control command sent successfully for device ${device.label || uuid}`, - ); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - success: true, - device: { - uuid: device.uuid, - label: device.label, - mac: device.mac, - }, - command_sent: { - elementIds, - command, - }, - note: 'Control command published to MQTT. Device state change is not guaranteed - check device state after a few seconds.', - response: result, - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - // Tool 14: control_device_simple - Simplified device control helpers - server.registerTool( - 'control_device_simple', - { - description: - 'Simplified device control for common operations. Automatically builds the correct command array for common actions. ' + - 'Actions: turn_on, turn_off, set_brightness (0-1000), set_kelvin (0-65000), set_temperature (15-30 for AC), set_mode (0-4 for AC). ' + - 'If elementId not specified, controls all elements. This is easier than control_device for basic operations.', - inputSchema: z.object({ - uuid: z.string().describe('Device UUID to control'), - action: z - .enum([ - 'turn_on', - 'turn_off', - 'set_brightness', - 'set_kelvin', - 'set_temperature', - 'set_mode', - ]) - .describe( - 'Action to perform: turn_on, turn_off, set_brightness, set_kelvin, set_temperature (AC), set_mode (AC)', - ), - value: z - .number() - .optional() - .describe( - 'Value for set_* actions: brightness (0-1000), kelvin (0-65000), temperature (15-30), mode (0-4)', - ), - elementId: z - .number() - .optional() - .describe('Specific element ID to control (optional, controls all if not specified)'), - }), - }, - this.withAuth(async ({ uuid, action, value, elementId }, state) => { - this.logger.debug(`[control_device_simple] Getting device details for uuid: ${uuid}`); - - // Get device details - const device = await this.apiClient.get(`/device/${state.userId}/${uuid}`, state.token); - - if (!device) { - throw new Error(`Device ${uuid} not found`); - } - - // Determine element IDs - const elementIds = elementId ? [elementId] : device.elementIds || []; - - if (elementIds.length === 0) { - throw new Error('No element IDs available for this device'); - } - - // Build command based on action - let command: number[]; - let actionDescription: string; - - switch (action) { - case 'turn_on': - command = [1, 1]; // ON_OFF=1, value=1 (ON) - actionDescription = 'Turn ON'; - break; - case 'turn_off': - command = [1, 0]; // ON_OFF=1, value=0 (OFF) - actionDescription = 'Turn OFF'; - break; - case 'set_brightness': - if (value === undefined || value < 0 || value > 1000) { - throw new Error('Brightness value must be between 0 and 1000'); - } - command = [28, value]; // BRIGHTNESS=28 - actionDescription = `Set brightness to ${value}`; - break; - case 'set_kelvin': - if (value === undefined || value < 0 || value > 65000) { - throw new Error('Kelvin value must be between 0 and 65000'); - } - command = [29, value]; // KELVIN=29 - actionDescription = `Set kelvin to ${value}`; - break; - case 'set_temperature': - if (value === undefined || value < 15 || value > 30) { - throw new Error('Temperature must be between 15 and 30 (Celsius)'); - } - command = [20, value]; // TEMP_SET=20 - actionDescription = `Set temperature to ${value}°C`; - break; - case 'set_mode': - if (value === undefined || value < 0 || value > 4) { - throw new Error('Mode must be 0-4 (0=AUTO, 1=COOLING, 2=DRY, 3=HEATING, 4=FAN)'); - } - command = [17, value]; // MODE=17 - const modes = ['AUTO', 'COOLING', 'DRY', 'HEATING', 'FAN']; - actionDescription = `Set mode to ${modes[value]}`; - break; - default: - throw new Error(`Unknown action: ${action}`); - } - - // Extract required control fields - const eid = device.eid; - const rootUuid = device.rootUuid || device.uuid; - const endpoint = device.endpoint; - const partnerId = device.partnerId; - const protocolCtl = device.protocolCtl; - - if (!eid || !endpoint || !partnerId || protocolCtl === undefined) { - throw new Error( - 'Device is missing required control fields (eid, endpoint, partnerId, or protocolCtl)', - ); - } - - // Build control payload - const payload = { - eid, - elementIds, - command, - endpoint, - partnerId, - rootUuid, - protocolCtl, - }; - - this.logger.debug('[control_device_simple] Sending control command:', payload); - - // Send control command - const result = await this.apiClient.post('/control/device', state.token, payload); - - this.logger.log( - `[control_device_simple] ${actionDescription} command sent for device ${device.label || uuid}`, - ); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify( - { - success: true, - device: { - uuid: device.uuid, - label: device.label, - mac: device.mac, - }, - action: actionDescription, - command_sent: { - elementIds, - command, - }, - note: 'Control command published to MQTT. Device state change is not guaranteed - check device state after a few seconds.', - response: result, - }, - null, - 2, - ), - }, - ], - }; - }), - ); - - this.logger.log( - 'MCP tools registered successfully: login, search, fetch, list_devices, list_locations, list_groups, get_device, update_device, delete_device, get_device_state, get_location_state, get_device_state_by_mac, control_device, control_device_simple', - ); - } -} diff --git a/src/mcp/services/redis.service.ts b/src/mcp/services/redis.service.ts deleted file mode 100644 index 757b9d4..0000000 --- a/src/mcp/services/redis.service.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import Redis from 'ioredis'; - -export interface ConnectionState { - token: string | null; - userId: string | null; -} - -/** - * Redis Service for managing session state with TTL - * Handles connection lifecycle and provides typed methods for session management - */ -@Injectable() -export class RedisService implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(RedisService.name); - private client: Redis; - private readonly sessionTtl: number; - private readonly sessionPrefix = 'mcp:session:'; - - constructor(private configService: ConfigService) { - this.sessionTtl = this.configService.get('REDIS_SESSION_TTL') || 3600; // 1 hour default - } - - async onModuleInit() { - const host = this.configService.get('REDIS_HOST') || 'localhost'; - const port = this.configService.get('REDIS_PORT') || 6379; - const password = this.configService.get('REDIS_PASSWORD') || undefined; - const db = this.configService.get('REDIS_DB') || 0; - - this.logger.log(`Connecting to Redis at ${host}:${port} (db: ${db})`); - - this.client = new Redis({ - host, - port, - password, - db, - retryStrategy: (times) => { - const delay = Math.min(times * 50, 2000); - this.logger.warn(`Redis connection attempt ${times}, retrying in ${delay}ms`); - return delay; - }, - maxRetriesPerRequest: 3, - }); - - this.client.on('connect', () => { - this.logger.log('Redis client connected'); - }); - - this.client.on('ready', () => { - this.logger.log('Redis client ready'); - }); - - this.client.on('error', (error) => { - this.logger.error('Redis client error:', error); - }); - - this.client.on('close', () => { - this.logger.warn('Redis client connection closed'); - }); - - this.client.on('reconnecting', () => { - this.logger.log('Redis client reconnecting'); - }); - - // Wait for connection to be ready - await new Promise((resolve, reject) => { - this.client.once('ready', () => resolve()); - this.client.once('error', (error) => reject(error)); - // Timeout after 5 seconds - setTimeout(() => reject(new Error('Redis connection timeout')), 5000); - }); - - this.logger.log('Redis service initialized successfully'); - } - - async onModuleDestroy() { - this.logger.log('Disconnecting Redis client'); - await this.client.quit(); - } - - /** - * Get session state by session ID - * Uses GETEX for atomic get-and-reset-TTL operation - */ - async getSessionState(sessionId: string): Promise { - const key = this.sessionPrefix + sessionId; - - try { - // GETEX atomically gets value and resets TTL (Redis 6.2+) - // Falls back to GET + EXPIRE for older Redis versions - const data = await this.client.getex(key, 'EX', this.sessionTtl).catch(async () => { - // Fallback for Redis < 6.2 - const value = await this.client.get(key); - if (value) { - // Best-effort TTL refresh - don't fail if expire fails - await this.client.expire(key, this.sessionTtl).catch((err) => { - this.logger.warn(`Failed to refresh TTL for session ${sessionId} (best-effort):`, err); - }); - } - return value; - }); - - if (!data) { - return null; - } - - const state = JSON.parse(data) as ConnectionState; - this.logger.debug(`Session ${sessionId} accessed, TTL reset to ${this.sessionTtl}s`); - return state; - } catch (error) { - this.logger.error(`Failed to get session data for ${sessionId}:`, error); - throw error; // Let caller handle Redis unavailability - } - } - - /** - * Set session state with TTL - */ - async setSessionState(sessionId: string, state: ConnectionState): Promise { - const key = this.sessionPrefix + sessionId; - const data = JSON.stringify(state); - - await this.client.setex(key, this.sessionTtl, data); - this.logger.debug(`Session ${sessionId} saved with TTL ${this.sessionTtl}s`); - } - - /** - * Update session state (partial update with TTL reset) - */ - async updateSessionState(sessionId: string, updates: Partial): Promise { - const existingState = await this.getSessionState(sessionId); - - if (!existingState) { - throw new Error(`Session ${sessionId} not found`); - } - - const newState: ConnectionState = { - ...existingState, - ...updates, - }; - - await this.setSessionState(sessionId, newState); - } - - /** - * Delete session state - */ - async deleteSessionState(sessionId: string): Promise { - const key = this.sessionPrefix + sessionId; - await this.client.del(key); - this.logger.debug(`Session ${sessionId} deleted`); - } - - /** - * Check if session exists - */ - async hasSession(sessionId: string): Promise { - const key = this.sessionPrefix + sessionId; - const exists = await this.client.exists(key); - return exists === 1; - } - - /** - * Get all session IDs (for debugging/monitoring) - */ - async getAllSessionIds(): Promise { - const pattern = this.sessionPrefix + '*'; - const keys = await this.client.keys(pattern); - return keys.map((key) => key.replace(this.sessionPrefix, '')); - } - - /** - * Get session TTL in seconds - */ - async getSessionTtl(sessionId: string): Promise { - const key = this.sessionPrefix + sessionId; - return await this.client.ttl(key); - } - - /** - * Get Redis client for advanced operations - */ - getClient(): Redis { - return this.client; - } -} diff --git a/src/mcp/types/mcp.types.ts b/src/mcp/types/mcp.types.ts deleted file mode 100644 index 7a6bd11..0000000 --- a/src/mcp/types/mcp.types.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * MCP Protocol Types - * Model Context Protocol v2024-11-05 - * https://spec.modelcontextprotocol.io/ - */ - -// Request/Response base structures -export interface McpRequest { - jsonrpc: '2.0'; - id: string | number; - method: string; - params?: Record; -} - -export interface McpResponse { - jsonrpc: '2.0'; - id: string | number; - result?: Record; - error?: McpError; -} - -export interface McpError { - code: number; - message: string; - data?: any; -} - -// Server capabilities -export interface ServerCapabilities { - logging?: Record; - resources?: { - listChanged?: boolean; - }; - tools?: { - listChanged?: boolean; - }; - prompts?: { - listChanged?: boolean; - }; - sampling?: Record; -} - -export interface InitializeResponse { - protocolVersion: string; - capabilities: ServerCapabilities; - serverInfo: { - name: string; - version: string; - }; -} - -// Resources -export interface Resource { - uri: string; - name: string; - description?: string; - mimeType?: string; -} - -export interface ResourceContent { - uri: string; - mimeType: string; - text?: string; - blob?: string; -} - -export interface ListResourcesResponse { - resources: Resource[]; -} - -export interface ReadResourceResponse { - contents: ResourceContent[]; -} - -// Tools -export interface Tool { - name: string; - description?: string; - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} - -export interface ListToolsResponse { - tools: Tool[]; -} - -export interface CallToolResponse { - content: Array<{ - type: 'text' | 'image' | 'resource'; - text?: string; - data?: string; - mimeType?: string; - uri?: string; - }>; - isError?: boolean; -} - -// Prompts -export interface Prompt { - name: string; - description?: string; - arguments?: Array<{ - name: string; - description?: string; - required?: boolean; - }>; -} - -export interface GetPromptResponse { - messages: Array<{ - role: 'user' | 'assistant'; - content: { - type: 'text' | 'resource'; - text?: string; - resource?: { - uri: string; - mimeType: string; - }; - }; - }>; -} - -// Logging -export interface LogMessageParams { - level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency'; - logger?: string; - data?: any; -} - -// Progress updates -export interface ProgressParams { - token: string; - progress: number; - total?: number; -} - -// Common MCP methods -export const MCP_METHODS = { - INITIALIZE: 'initialize', - LIST_RESOURCES: 'resources/list', - READ_RESOURCE: 'resources/read', - LIST_TOOLS: 'tools/list', - CALL_TOOL: 'tools/call', - LIST_PROMPTS: 'prompts/list', - GET_PROMPT: 'prompts/get', - LOG_MESSAGE: 'logging/setLevel', -}; - -export const MCP_ERROR_CODES = { - PARSE_ERROR: -32700, - INVALID_REQUEST: -32600, - METHOD_NOT_FOUND: -32601, - INVALID_PARAMS: -32602, - INTERNAL_ERROR: -32603, - SERVER_ERROR_START: -32099, - SERVER_ERROR_END: -32000, - RESOURCE_NOT_FOUND: -32001, - INVALID_REQUEST_ID: -32002, -}; diff --git a/src/mcp_v2/controllers/mcp-v2.controller.ts b/src/mcp_v2/controllers/mcp-v2.controller.ts new file mode 100644 index 0000000..0cdb598 --- /dev/null +++ b/src/mcp_v2/controllers/mcp-v2.controller.ts @@ -0,0 +1,65 @@ +import { Controller, All, Req, Res, Logger } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Response, Request } from 'express'; +import { randomUUID } from 'node:crypto'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { McpV2Service } from '../services/mcp-v2.service'; +import { SessionStoreService } from '../services/session-store.service'; + +@ApiTags('MCP Protocol V2') +@Controller('mcp') +export class McpV2Controller { + private readonly transports = new Map(); + private readonly logger = new Logger(McpV2Controller.name); + + constructor(private mcpService: McpV2Service, private sessionStore: SessionStoreService) {} + + @All() + @ApiOperation({ summary: 'MCP v2 Streamable HTTP endpoint' }) + @ApiResponse({ status: 200, description: 'Request handled' }) + @ApiExcludeEndpoint() + async handle(@Req() req: Request, @Res() res: Response): Promise { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + this.logger.debug(`[MCP-v2] request ${req.method} session=${sessionId} url=${req.url}`); + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && this.transports.has(sessionId)) { + transport = this.transports.get(sessionId)!; + } else { + // Create new session id generator transport (no apiKey required on connect) + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (newSessionId) => { + this.logger.log(`[MCP-v2] session initialized: ${newSessionId}`); + this.transports.set(newSessionId, transport); + }, + }); + + transport.onclose = async () => { + const sid = transport.sessionId; + if (sid && this.transports.has(sid)) { + this.logger.log(`[MCP-v2] transport closed ${sid}`); + this.transports.delete(sid); + await this.sessionStore.delete(sid); + } + }; + + // Note: apiKey stored in onsessioninitialized above + + // Create server and connect + const server = await this.mcpService.createServer(); + await server.connect(transport); + } + + await transport.handleRequest(req, res, req.body); + } catch (err) { + this.logger.error('[MCP-v2] error', err as any); + if (!res.headersSent) { + res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null }); + } + } + } +} diff --git a/src/mcp_v2/mcp-v2.module.ts b/src/mcp_v2/mcp-v2.module.ts new file mode 100644 index 0000000..4bed4d9 --- /dev/null +++ b/src/mcp_v2/mcp-v2.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { McpV2Service } from './services/mcp-v2.service'; +import { ApiClientV2Service } from './services/api-client-v2.service'; +import { SessionStoreService } from './services/session-store.service'; +import { McpV2Controller } from './controllers/mcp-v2.controller'; + +@Module({ + imports: [HttpModule, ConfigModule], + controllers: [McpV2Controller], + providers: [McpV2Service, ApiClientV2Service, SessionStoreService], + exports: [McpV2Service], +}) +export class McpV2Module {} diff --git a/src/mcp_v2/services/api-client-v2.service.ts b/src/mcp_v2/services/api-client-v2.service.ts new file mode 100644 index 0000000..b3315f7 --- /dev/null +++ b/src/mcp_v2/services/api-client-v2.service.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { AxiosError, AxiosRequestConfig } from 'axios'; + +export interface ApiRequestOptions { + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; + path: string; + apiKey?: string; // per-session api key + data?: any; + params?: any; +} + +@Injectable() +export class ApiClientV2Service { + private readonly logger = new Logger(ApiClientV2Service.name); + private baseUrl: string; + private readonly timeout: number; + + constructor(private httpService: HttpService, private configService: ConfigService) { + this.baseUrl = this.configService.get('IOT_API_BASE_URL') || ''; + this.timeout = this.configService.get('IOT_API_TIMEOUT') || 30000; + + if (!this.baseUrl) { + throw new Error('IOT_API_BASE_URL must be configured for ApiClientV2'); + } + } + + async request(options: ApiRequestOptions): Promise { + const { method, path, apiKey, data, params } = options; + + const headers: Record = { + 'Content-Type': 'application/json', + accept: 'application/json', + }; + + if (apiKey) { + headers['x-api-key'] = apiKey; + headers['x-header-apikey'] = apiKey; + } + + const config: AxiosRequestConfig = { + method, + url: `${this.baseUrl}${path}`, + headers, + timeout: this.timeout, + data, + params, + }; + + this.logger.debug(`${method} ${path}`); + + try { + const response = await firstValueFrom(this.httpService.request(config)); + return response.data; + } catch (error) { + const axiosError = error as AxiosError; + this.logger.error(`API Error [${method} ${path}]:`, axiosError.response?.data || axiosError.message); + throw axiosError.response?.data || axiosError; + } + } + + async get(path: string, apiKey?: string, params?: any): Promise { + return this.request({ method: 'GET', path, apiKey, params }); + } + + async post(path: string, apiKey?: string, data?: any): Promise { + return this.request({ method: 'POST', path, apiKey, data }); + } + + async patch(path: string, apiKey?: string, data?: any): Promise { + return this.request({ method: 'PATCH', path, apiKey, data }); + } + + async delete(path: string, apiKey?: string, data?: any): Promise { + return this.request({ method: 'DELETE', path, apiKey, data }); + } +} diff --git a/src/mcp_v2/services/mcp-v2.service.ts b/src/mcp_v2/services/mcp-v2.service.ts new file mode 100644 index 0000000..d2d51d2 --- /dev/null +++ b/src/mcp_v2/services/mcp-v2.service.ts @@ -0,0 +1,244 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ApiClientV2Service } from './api-client-v2.service'; +import { SessionStoreService } from './session-store.service'; +import { ToolsListV2 } from '../tools/tools-list-v2'; + +@Injectable() +export class McpV2Service { + private readonly logger = new Logger(McpV2Service.name); + + constructor(private apiClient: ApiClientV2Service, private sessionStore: SessionStoreService) {} + + async createServer(): Promise { + const server = new McpServer( + { name: 'IoT Cloud MCP Bridge v2', version: '2.0.0' }, + { capabilities: { tools: {}, logging: {} } }, + ); + + // Register tools with handlers bound to this service + this.registerTools(server); + + this.logger.log('MCP v2 Server created'); + return server; + } + + private registerTools(server: McpServer) { + // Helper to wrap handlers that require a session apiKey + const withApiKey = (fn: (args: any, apiKey: string, extra: any) => Promise): any => { + return async (args: any, extra: any): Promise => { + try { + const sessionId = extra?.sessionId; + if (!sessionId) { + return { isError: true, content: [{ type: 'text', text: 'Missing sessionId in request' }] } as any; + } + + const apiKey = await this.sessionStore.getApiKey(sessionId); + if (!apiKey) { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Missing session API key. Run init_api_key tool to set x-api-key for this session.', + }, + ], + }; + } + + return await fn(args, apiKey, extra); + } catch (err: any) { + return { isError: true, content: [{ type: 'text', text: `Error: ${err?.message || err}` }] } as any; + } + }; + }; + // init_api_key - MUST be called first by the admin client for this session + server.registerTool('init_api_key', ToolsListV2.init_api_key, async (args: any, extra: any) => { + const apiKey = args?.apiKey; + const sessionId = extra?.sessionId; + if (!sessionId) throw new Error('Missing sessionId'); + if (!apiKey) throw new Error('apiKey is required'); + + await this.sessionStore.setApiKey(sessionId, apiKey); + this.logger.log(`Initialized apiKey for session ${sessionId}`); + + return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'apiKey initialized for session' }) }] } as any; + }); + + // find_user_id + server.registerTool( + 'find_user_id', + ToolsListV2.find_user_id, + withApiKey(async ({ data }, apiKey) => { + // Call the upstream API to find user id by email/phone + const resp: any = await this.apiClient.post('/user/findUserId', apiKey, { data }); + const userId = resp?.userId || resp?.user_id || resp?.data?.userId; + if (!userId) throw new Error('UserId not found'); + + return { content: [{ type: 'text', text: JSON.stringify({ userId }) }] } as any; + }), + ) as any; + + // list_devices + server.registerTool( + 'list_devices', + ToolsListV2.list_devices, + withApiKey(async ({ userId, data }, apiKey) => { + // If admin provided email/phone in `data`, resolve it first + let uid = userId; + if (!uid && data) { + const r: any = await this.apiClient.post('/user/findUserId', apiKey, { data }); + uid = r?.userId || r?.user_id || r?.data?.userId; + if (!uid) throw new Error('UserId not found'); + } + + if (!uid) throw new Error('userId is required'); + + const devices: any = await this.apiClient.get(`/device/${uid}`, apiKey); + return { content: [{ type: 'text', text: JSON.stringify({ total: Array.isArray(devices) ? devices.length : 0, devices }) }] } as any; + }), + ) as any; + + // list_locations + server.registerTool( + 'list_locations', + ToolsListV2.list_locations, + withApiKey(async ({ userId, data }, apiKey) => { + let uid = userId; + if (!uid && data) { + const r: any = await this.apiClient.post('/user/findUserId', apiKey, { data }); + uid = r?.userId || r?.user_id || r?.data?.userId; + if (!uid) throw new Error('UserId not found'); + } + + if (!uid) throw new Error('userId is required'); + + const locations: any = await this.apiClient.get(`/location/${uid}`, apiKey); + return { content: [{ type: 'text', text: JSON.stringify({ total: Array.isArray(locations) ? locations.length : 0, locations }) }] } as any; + }), + ) as any; + + // list_groups + server.registerTool( + 'list_groups', + ToolsListV2.list_groups, + withApiKey(async ({ userId, data }, apiKey) => { + let uid = userId; + if (!uid && data) { + const r: any = await this.apiClient.post('/user/findUserId', apiKey, { data }); + uid = r?.userId || r?.user_id || r?.data?.userId; + if (!uid) throw new Error('UserId not found'); + } + + if (!uid) throw new Error('userId is required'); + + const groups: any = await this.apiClient.get(`/group/${uid}`, apiKey); + return { content: [{ type: 'text', text: JSON.stringify({ total: Array.isArray(groups) ? groups.length : 0, groups }) }] } as any; + }), + ) as any; + + // get_device + server.registerTool( + 'get_device', + ToolsListV2.get_device, + withApiKey(async ({ userId, uuid }, apiKey) => { + if (!userId) throw new Error('userId is required'); + if (!uuid) throw new Error('uuid is required'); + + const device: any = await this.apiClient.get(`/device/${userId}/${uuid}`, apiKey); + return { content: [{ type: 'text', text: JSON.stringify({ device }) }] } as any; + }), + ) as any; + + // get_state_by_location + server.registerTool( + 'get_state_by_location', + ToolsListV2.get_state_by_location, + withApiKey(async ({ locationUuid }, apiKey) => { + if (!locationUuid) throw new Error('locationUuid is required'); + + const state: any = await this.apiClient.get(`/state/${locationUuid}`, apiKey); + return { content: [{ type: 'text', text: JSON.stringify({ state }) }] } as any; + }), + ) as any; + + // get_state_by_devId + server.registerTool( + 'get_state_by_devId', + ToolsListV2.get_state_by_devId, + withApiKey(async ({ devId }, apiKey) => { + if (!devId) throw new Error('devId is required'); + + const state: any = await this.apiClient.get(`/state/devId/${devId}`, apiKey); + return { content: [{ type: 'text', text: JSON.stringify({ state }) }] } as any; + }), + ) as any; + + // control_device_simple + server.registerTool( + 'control_device_simple', + ToolsListV2.control_device_simple, + withApiKey(async (args: any, apiKey: string) => { + // Resolve userId if needed + let uid = args.userId; + if (!uid && args.data) { + const r: any = await this.apiClient.post('/user/findUserId', apiKey, { data: args.data }); + uid = r?.userId || r?.user_id || r?.data?.userId; + if (!uid) throw new Error('UserId not found'); + } + if (!uid) throw new Error('userId or data (email/phone) required'); + + // Fetch device + const device: any = await this.apiClient.get(`/device/${uid}/${args.uuid}`, apiKey); + if (!device) throw new Error('Device not found'); + + // Determine elementIds + const elementIds = args.elementId ? [args.elementId] : device.elementIds || []; + if (elementIds.length === 0) throw new Error('No element IDs available'); + + // Build command + let command: number[]; + switch (args.action) { + case 'turn_on': + command = [1, 1]; + break; + case 'turn_off': + command = [1, 0]; + break; + case 'set_brightness': + if (args.value === undefined) throw new Error('value required'); + command = [28, args.value]; + break; + case 'set_kelvin': + if (args.value === undefined) throw new Error('value required'); + command = [29, args.value]; + break; + case 'set_temperature': + if (args.value === undefined) throw new Error('value required'); + command = [20, args.value]; + break; + case 'set_mode': + if (args.value === undefined) throw new Error('value required'); + command = [17, args.value]; + break; + default: + throw new Error('Unknown action'); + } + + const payload = { + eid: device.eid, + elementIds, + command, + endpoint: device.endpoint, + partnerId: device.partnerId, + rootUuid: device.rootUuid || device.uuid, + protocolCtl: device.protocolCtl, + }; + + const result = await this.apiClient.post('/control/device', apiKey, payload); + + return { content: [{ type: 'text', text: JSON.stringify({ success: true, response: result }) }] } as any; + }), + ) as any; + } +} diff --git a/src/mcp_v2/services/session-store.service.ts b/src/mcp_v2/services/session-store.service.ts new file mode 100644 index 0000000..cac8c6e --- /dev/null +++ b/src/mcp_v2/services/session-store.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SessionStoreService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SessionStoreService.name); + private client: Redis; + private readonly sessionTtl: number; + private readonly prefix = 'mcp_v2:session:'; + + constructor(private configService: ConfigService) { + this.sessionTtl = this.configService.get('REDIS_SESSION_TTL') || 3600; + } + + async onModuleInit() { + const host = this.configService.get('REDIS_HOST') || 'localhost'; + const port = this.configService.get('REDIS_PORT') || 6379; + const password = this.configService.get('REDIS_PASSWORD') || undefined; + const db = this.configService.get('REDIS_DB') || 0; + + this.client = new Redis({ host, port, password, db }); + await new Promise((resolve, reject) => { + this.client.once('ready', () => resolve()); + this.client.once('error', (e) => reject(e)); + setTimeout(() => reject(new Error('Redis connect timeout')), 5000); + }); + this.logger.log('SessionStore Redis ready'); + } + + async onModuleDestroy() { + await this.client.quit(); + } + + private key(sessionId: string) { + return this.prefix + sessionId; + } + + async setApiKey(sessionId: string, apiKey: string): Promise { + await this.client.setex(this.key(sessionId), this.sessionTtl, JSON.stringify({ apiKey })); + this.logger.debug(`Saved apiKey for session ${sessionId}`); + } + + async getApiKey(sessionId: string): Promise { + const val = await this.client.get(this.key(sessionId)); + if (!val) return null; + try { + const parsed = JSON.parse(val); + return parsed.apiKey || null; + } catch (e) { + this.logger.warn('Failed to parse session value', e); + return null; + } + } + + async delete(sessionId: string): Promise { + await this.client.del(this.key(sessionId)); + } +} diff --git a/src/mcp_v2/tools/tools-list-v2.ts b/src/mcp_v2/tools/tools-list-v2.ts new file mode 100644 index 0000000..7ab3724 --- /dev/null +++ b/src/mcp_v2/tools/tools-list-v2.ts @@ -0,0 +1,55 @@ +import * as z from 'zod'; + +export interface ToolMeta { + description: string; + inputSchema: z.ZodTypeAny; +} + +export const ToolsListV2: Record = { + init_api_key: { + description: 'Initialize this MCP session with a project API key. MUST be called before other tools.', + inputSchema: z.object({ apiKey: z.string().describe('Project API key (x-api-key)') }), + }, + find_user_id: { + description: 'Resolve an end-user ID by email or phone. Admin provides email/phone in `data`.', + inputSchema: z.object({ data: z.string().describe('End-user email or phone') }), + }, + list_devices: { + description: 'List devices for a given end-user `userId` or resolve by email/phone using `data`.', + inputSchema: z.object({ userId: z.string().optional(), data: z.string().optional().describe('End-user email or phone') }), + }, + list_locations: { + description: 'List locations for a given end-user `userId` or resolve by email/phone using `data`.', + inputSchema: z.object({ userId: z.string().optional(), data: z.string().optional().describe('End-user email or phone') }), + }, + list_groups: { + description: "List user's groups for a given end-user `userId` or resolve by email/phone using `data`.", + inputSchema: z.object({ userId: z.string().optional(), data: z.string().optional().describe('End-user email or phone') }), + }, + get_device: { + description: 'Get a single device by `userId` and `uuid`.', + inputSchema: z.object({ userId: z.string(), uuid: z.string() }), + }, + get_state_by_location: { + description: 'Get device states for a `locationUuid`.', + inputSchema: z.object({ locationUuid: z.string() }), + }, + get_state_by_devId: { + description: 'Get device state by `devId`.', + inputSchema: z.object({ devId: z.string() }), + }, + control_device_simple: { + description: + 'Simplified device control. Provide `userId` (or email/phone to be resolved), `uuid`, `action`, and optional `value`/`elementId`.', + inputSchema: z.object({ + userId: z.string().optional(), + data: z.string().optional().describe('End-user email or phone (optional, will be resolved)'), + uuid: z.string().describe('Device UUID'), + action: z.enum(['turn_on', 'turn_off', 'set_brightness', 'set_kelvin', 'set_temperature', 'set_mode']), + value: z.number().optional(), + elementId: z.number().optional(), + }), + }, +}; + +export const ToolNamesV2 = Object.keys(ToolsListV2);