Add Docker support and enhance server configuration
- Created Dockerfile for containerized deployment - Added .dockerignore to optimize Docker build context - Updated README with comprehensive Docker setup instructions - Implemented new server endpoints: * Health check endpoint * Device listing * Device control * SSE event subscription - Enhanced security middleware with request validation and input sanitization - Added error handling and token-based authentication for new endpoints
This commit is contained in:
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Build output
|
||||
dist
|
||||
|
||||
# Environment files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Test files
|
||||
coverage
|
||||
__tests__
|
||||
jest.config.*
|
||||
*.test.ts
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
.DS_Store
|
||||
*.log
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Use Node.js 20 as the base image
|
||||
FROM node:20-slim
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source code first
|
||||
COPY . .
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/src/index.js"]
|
||||
59
README.md
59
README.md
@@ -152,14 +152,19 @@ npm run build
|
||||
|
||||
### Docker Setup (Recommended)
|
||||
|
||||
1. **Clone and prepare:**
|
||||
The project includes Docker support for easy deployment and consistent environments across different platforms.
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone -b docker https://github.com/jango-blockchained/homeassistant-mcp.git
|
||||
git clone https://github.com/jango-blockchained/homeassistant-mcp.git
|
||||
cd homeassistant-mcp
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Configure environment `.env` file:**
|
||||
2. **Configure environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit the `.env` file with your Home Assistant configuration:
|
||||
```env
|
||||
# Home Assistant Configuration
|
||||
HASS_HOST=http://homeassistant.local:8123
|
||||
@@ -170,17 +175,51 @@ npm run build
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
DEBUG=false
|
||||
|
||||
# Test Configuration
|
||||
TEST_HASS_HOST=http://localhost:8123
|
||||
TEST_HASS_TOKEN=test_token
|
||||
```
|
||||
|
||||
3. **Launch with Docker Compose:**
|
||||
3. **Build and run with Docker Compose:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
# Build and start the containers
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop the service
|
||||
docker compose down
|
||||
```
|
||||
|
||||
4. **Verify the installation:**
|
||||
The server should now be running at `http://localhost:3000`. You can check the health endpoint at `http://localhost:3000/health`.
|
||||
|
||||
5. **Update the application:**
|
||||
```bash
|
||||
# Pull the latest changes
|
||||
git pull
|
||||
|
||||
# Rebuild and restart the containers
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
#### Docker Configuration
|
||||
|
||||
The Docker setup includes:
|
||||
- Multi-stage build for optimal image size
|
||||
- Health checks for container monitoring
|
||||
- Volume mounting for environment configuration
|
||||
- Automatic container restart on failure
|
||||
- Exposed port 3000 for API access
|
||||
|
||||
#### Docker Compose Environment Variables
|
||||
|
||||
All environment variables can be configured in the `.env` file. The following variables are supported:
|
||||
- `HASS_HOST`: Your Home Assistant instance URL
|
||||
- `HASS_TOKEN`: Long-lived access token for Home Assistant
|
||||
- `HASS_SOCKET_URL`: WebSocket URL for Home Assistant
|
||||
- `PORT`: Server port (default: 3000)
|
||||
- `NODE_ENV`: Environment (production/development)
|
||||
- `DEBUG`: Enable debug mode (true/false)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
151
src/index.ts
151
src/index.ts
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { sseManager } from './sse/index.js';
|
||||
import { ILogger } from "@digital-alchemy/core";
|
||||
import express from 'express';
|
||||
import { rateLimiter, securityHeaders } from './security/index.js';
|
||||
import { rateLimiter, securityHeaders, validateRequest, sanitizeInput, errorHandler } from './security/index.js';
|
||||
|
||||
// Load environment variables based on NODE_ENV
|
||||
const envFile = process.env.NODE_ENV === 'production'
|
||||
@@ -36,10 +36,21 @@ const app = express();
|
||||
app.use(securityHeaders);
|
||||
app.use(rateLimiter);
|
||||
app.use(express.json());
|
||||
app.use(validateRequest);
|
||||
app.use(sanitizeInput);
|
||||
|
||||
// Initialize LiteMCP
|
||||
const server = new LiteMCP('home-assistant', '0.1.0');
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.1.0'
|
||||
});
|
||||
});
|
||||
|
||||
// Define Tool interface
|
||||
interface Tool {
|
||||
name: string;
|
||||
@@ -48,23 +59,31 @@ interface Tool {
|
||||
execute: (params: any) => Promise<any>;
|
||||
}
|
||||
|
||||
// Array to track tools (moved outside main function)
|
||||
// Array to track tools
|
||||
const tools: Tool[] = [];
|
||||
|
||||
// Create API endpoint for each tool
|
||||
app.post('/api/:tool', async (req, res) => {
|
||||
const toolName = req.params.tool;
|
||||
const tool = tools.find((t: Tool) => t.name === toolName);
|
||||
|
||||
if (!tool) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Tool '${toolName}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
// List devices endpoint
|
||||
app.get('/list_devices', async (req, res) => {
|
||||
try {
|
||||
const result = await tool.execute(req.body);
|
||||
// Get token from Authorization header
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token || token !== HASS_TOKEN) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized - Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
const tool = tools.find(t => t.name === 'list_devices');
|
||||
if (!tool) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tool not found'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await tool.execute({ token });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
@@ -74,6 +93,108 @@ app.post('/api/:tool', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/control', async (req, res) => {
|
||||
try {
|
||||
// Get token from Authorization header
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token || token !== HASS_TOKEN) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized - Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
const tool = tools.find(t => t.name === 'control');
|
||||
if (!tool) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tool not found'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
...req.body,
|
||||
token
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// SSE endpoints
|
||||
app.get('/subscribe_events', (req, res) => {
|
||||
try {
|
||||
// Get token from query parameter
|
||||
const token = req.query.token?.toString();
|
||||
|
||||
if (!token || token !== HASS_TOKEN) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized - Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
const tool = tools.find(t => t.name === 'subscribe_events');
|
||||
if (!tool) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tool not found'
|
||||
});
|
||||
}
|
||||
|
||||
tool.execute({
|
||||
token,
|
||||
events: req.query.events?.toString().split(','),
|
||||
entity_id: req.query.entity_id?.toString(),
|
||||
domain: req.query.domain?.toString(),
|
||||
response: res
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/get_sse_stats', async (req, res) => {
|
||||
try {
|
||||
// Get token from query parameter
|
||||
const token = req.query.token?.toString();
|
||||
|
||||
if (!token || token !== HASS_TOKEN) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized - Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
const tool = tools.find(t => t.name === 'get_sse_stats');
|
||||
if (!tool) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Tool not found'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await tool.execute({ token });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use(errorHandler);
|
||||
|
||||
interface CommandParams {
|
||||
command: string;
|
||||
entity_id: string;
|
||||
|
||||
@@ -127,6 +127,11 @@ export class TokenManager {
|
||||
|
||||
// Request validation middleware
|
||||
export function validateRequest(req: Request, res: Response, next: NextFunction) {
|
||||
// Skip validation for health endpoint
|
||||
if (req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Validate content type
|
||||
if (req.method !== 'GET' && !req.is('application/json')) {
|
||||
return res.status(415).json({
|
||||
|
||||
Reference in New Issue
Block a user