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)
|
### 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
|
```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
|
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
|
```env
|
||||||
# Home Assistant Configuration
|
# Home Assistant Configuration
|
||||||
HASS_HOST=http://homeassistant.local:8123
|
HASS_HOST=http://homeassistant.local:8123
|
||||||
@@ -170,17 +175,51 @@ npm run build
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DEBUG=false
|
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
|
```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
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|||||||
143
src/index.ts
143
src/index.ts
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { sseManager } from './sse/index.js';
|
import { sseManager } from './sse/index.js';
|
||||||
import { ILogger } from "@digital-alchemy/core";
|
import { ILogger } from "@digital-alchemy/core";
|
||||||
import express from 'express';
|
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
|
// Load environment variables based on NODE_ENV
|
||||||
const envFile = process.env.NODE_ENV === 'production'
|
const envFile = process.env.NODE_ENV === 'production'
|
||||||
@@ -36,10 +36,21 @@ const app = express();
|
|||||||
app.use(securityHeaders);
|
app.use(securityHeaders);
|
||||||
app.use(rateLimiter);
|
app.use(rateLimiter);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(validateRequest);
|
||||||
|
app.use(sanitizeInput);
|
||||||
|
|
||||||
// Initialize LiteMCP
|
// Initialize LiteMCP
|
||||||
const server = new LiteMCP('home-assistant', '0.1.0');
|
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
|
// Define Tool interface
|
||||||
interface Tool {
|
interface Tool {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -48,23 +59,31 @@ interface Tool {
|
|||||||
execute: (params: any) => Promise<any>;
|
execute: (params: any) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Array to track tools (moved outside main function)
|
// Array to track tools
|
||||||
const tools: Tool[] = [];
|
const tools: Tool[] = [];
|
||||||
|
|
||||||
// Create API endpoint for each tool
|
// List devices endpoint
|
||||||
app.post('/api/:tool', async (req, res) => {
|
app.get('/list_devices', async (req, res) => {
|
||||||
const toolName = req.params.tool;
|
try {
|
||||||
const tool = tools.find((t: Tool) => t.name === toolName);
|
// Get token from Authorization header
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
if (!tool) {
|
if (!token || token !== HASS_TOKEN) {
|
||||||
return res.status(404).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Tool '${toolName}' not found`
|
message: 'Unauthorized - Invalid token'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const tool = tools.find(t => t.name === 'list_devices');
|
||||||
const result = await tool.execute(req.body);
|
if (!tool) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Tool not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.execute({ token });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
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 {
|
interface CommandParams {
|
||||||
command: string;
|
command: string;
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ export class TokenManager {
|
|||||||
|
|
||||||
// Request validation middleware
|
// Request validation middleware
|
||||||
export function validateRequest(req: Request, res: Response, next: NextFunction) {
|
export function validateRequest(req: Request, res: Response, next: NextFunction) {
|
||||||
|
// Skip validation for health endpoint
|
||||||
|
if (req.path === '/health') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// Validate content type
|
// Validate content type
|
||||||
if (req.method !== 'GET' && !req.is('application/json')) {
|
if (req.method !== 'GET' && !req.is('application/json')) {
|
||||||
return res.status(415).json({
|
return res.status(415).json({
|
||||||
|
|||||||
Reference in New Issue
Block a user