Add comprehensive WebSocket, context, performance, and security modules

- Introduced WebSocket client for real-time Home Assistant event streaming
- Created context management system for tracking resource relationships and state
- Implemented performance monitoring and optimization utilities
- Added security middleware with token validation, rate limiting, and input sanitization
- Extended tool registry with enhanced tool registration and execution capabilities
- Expanded test coverage for new modules and added comprehensive test scenarios
- Improved type safety and added robust error handling across new modules
This commit is contained in:
jango-blockchained
2025-01-30 09:18:17 +01:00
parent d7e5fcf764
commit 110f2a308c
8 changed files with 2428 additions and 129 deletions

317
README.md
View File

@@ -2,12 +2,14 @@
*Forked from [tevonsb/homeassistant-mcp](https://github.com/tevonsb/homeassistant-mcp)* *Forked from [tevonsb/homeassistant-mcp](https://github.com/tevonsb/homeassistant-mcp)*
A powerful bridge between your Home Assistant instance and Language Learning Models (LLMs), enabling natural language control and monitoring of your smart home devices through the Model Context Protocol (MCP). A powerful bridge between your Home Assistant instance and Language Learning Models (LLMs), enabling natural language control and monitoring of your smart home devices through the Model Context Protocol (MCP). This server provides a comprehensive API for managing your entire Home Assistant ecosystem, from device control to system administration.
![License](https://img.shields.io/badge/license-MIT-blue.svg) ![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Node.js](https://img.shields.io/badge/node-%3E%3D20.10.0-green.svg) ![Node.js](https://img.shields.io/badge/node-%3E%3D20.10.0-green.svg)
![Docker Compose](https://img.shields.io/badge/docker-compose-%3E%3D1.27.0-blue.svg) ![Docker Compose](https://img.shields.io/badge/docker-compose-%3E%3D1.27.0-blue.svg)
![NPM](https://img.shields.io/badge/npm-%3E%3D7.0.0-orange.svg) ![NPM](https://img.shields.io/badge/npm-%3E%3D7.0.0-orange.svg)
![TypeScript](https://img.shields.io/badge/typescript-%5E5.0.0-blue.svg)
![Test Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg)
## Table of Contents ## Table of Contents
@@ -18,7 +20,11 @@ A powerful bridge between your Home Assistant instance and Language Learning Mod
- [Docker Setup (Recommended)](#docker-setup-recommended) - [Docker Setup (Recommended)](#docker-setup-recommended)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Development](#development) - [Development](#development)
- [Supported Commands](#supported-commands) - [API Reference](#api-reference)
- [Device Control](#device-control)
- [Add-on Management](#add-on-management)
- [Package Management](#package-management)
- [Automation Management](#automation-management)
- [Natural Language Integration](#natural-language-integration) - [Natural Language Integration](#natural-language-integration)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Project Status](#project-status) - [Project Status](#project-status)
@@ -28,20 +34,62 @@ A powerful bridge between your Home Assistant instance and Language Learning Mod
## Key Features ## Key Features
- **Smart Device Control** 🎮 ### Core Functionality 🎮
- **Smart Device Control**
- 💡 **Lights**: Brightness, color temperature, RGB color - 💡 **Lights**: Brightness, color temperature, RGB color
- 🌡️ **Climate**: Temperature, HVAC modes, fan modes, humidity - 🌡️ **Climate**: Temperature, HVAC modes, fan modes, humidity
- 🚪 **Covers**: Position and tilt control - 🚪 **Covers**: Position and tilt control
- 🔌 **Switches**: On/off control - 🔌 **Switches**: On/off control
- 🚨 **Sensors & Contacts**: State monitoring - 🚨 **Sensors & Contacts**: State monitoring
- **Intelligent Organization** 🏠 - 🎵 **Media Players**: Playback control, volume, source selection
- 🌪️ **Fans**: Speed, oscillation, direction
- 🔒 **Locks**: Lock/unlock control
- 🧹 **Vacuums**: Start, stop, return to base
- 📹 **Cameras**: Motion detection, snapshots
### System Management 🛠️
- **Add-on Management**
- Browse available add-ons
- Install/uninstall add-ons
- Start/stop/restart add-ons
- Version management
- Configuration access
- **Package Management (HACS)**
- Integration with Home Assistant Community Store
- Multiple package types support:
- Custom integrations
- Frontend themes
- Python scripts
- AppDaemon apps
- NetDaemon apps
- Version control and updates
- Repository management
- **Automation Management**
- Create and edit automations
- Advanced configuration options:
- Multiple trigger types
- Complex conditions
- Action sequences
- Execution modes
- Duplicate and modify existing automations
- Enable/disable automation rules
- Trigger automation manually
### Architecture Features 🏗️
- **Intelligent Organization**
- Area and floor-based device grouping - Area and floor-based device grouping
- State monitoring and querying - State monitoring and querying
- Smart context awareness - Smart context awareness
- **Robust Architecture** 🛠️ - Historical data access
- **Robust Architecture**
- Comprehensive error handling - Comprehensive error handling
- State validation - State validation
- Secure API integration - Secure API integration
- TypeScript type safety
- Extensive test coverage
## Prerequisites ## Prerequisites
@@ -50,6 +98,8 @@ A powerful bridge between your Home Assistant instance and Language Learning Mod
- **Docker Compose** for containerization - **Docker Compose** for containerization
- Running **Home Assistant** instance - Running **Home Assistant** instance
- Home Assistant long-lived access token ([How to get token](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159)) - Home Assistant long-lived access token ([How to get token](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159))
- **HACS** installed for package management features
- **Supervisor** access for add-on management
## Installation ## Installation
@@ -67,9 +117,7 @@ npm install
npm run build npm run build
``` ```
### Docker Setup ### Docker Setup (Recommended)
> Note: This setup is currently in progress. You can use the `docker` branch to get the latest changes.
1. **Clone and prepare:** 1. **Clone and prepare:**
```bash ```bash
@@ -80,9 +128,14 @@ npm run build
2. **Configure environment `.env` file:** 2. **Configure environment `.env` file:**
```env ```env
... # Home Assistant Configuration
HASS_HOST=http://homeassistant.local:8123
HASS_TOKEN=your_home_assistant_token HASS_TOKEN=your_home_assistant_token
... HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket
# Server Configuration
PORT=3000
NODE_ENV=production
``` ```
3. **Launch with Docker Compose:** 3. **Launch with Docker Compose:**
@@ -90,33 +143,11 @@ npm run build
docker-compose up -d docker-compose up -d
``` ```
## Configuration ## API Reference
Copy `.env.example` to `.env`. ### Device Control
```bash #### Common Entity Controls
cp .env.example .env
```
Configure environment `.env` file:
```env
...
HASS_TOKEN=your_home_assistant_token
...
```
## Development
```bash
npm run dev # Development mode
npm run build # Build project
npm run start # Production mode
npx jest --config=jest.config.cjs # Run tests
```
## Supported Commands
### Common Entity Controls
```json ```json
{ {
"tool": "control", "tool": "control",
@@ -125,7 +156,7 @@ npx jest --config=jest.config.cjs # Run tests
} }
``` ```
### Light Control #### Light Control
```json ```json
{ {
"tool": "control", "tool": "control",
@@ -137,173 +168,201 @@ npx jest --config=jest.config.cjs # Run tests
} }
``` ```
### Climate Control ### Add-on Management
#### List Available Add-ons
```json ```json
{ {
"tool": "control", "tool": "addon",
"command": "set_temperature", "action": "list"
"entity_id": "climate.bedroom",
"temperature": 22,
"hvac_mode": "heat",
"fan_mode": "auto",
"humidity": 45
} }
``` ```
### Cover Control #### Install Add-on
```json ```json
{ {
"tool": "control", "tool": "addon",
"command": "set_position", "action": "install",
"entity_id": "cover.living_room", "slug": "core_configurator",
"position": 50, "version": "5.6.0"
"tilt_position": 45
} }
``` ```
### Media Player Control #### Manage Add-on State
```json ```json
{ {
"tool": "control", "tool": "addon",
"command": "media_play", // or "media_pause", "media_stop", "media_next", "media_previous" "action": "start", // or "stop", "restart"
"entity_id": "media_player.living_room", "slug": "core_configurator"
"volume_level": 0.5,
"source": "Spotify",
"media_content_id": "spotify:playlist:xyz",
"media_content_type": "playlist"
} }
``` ```
### Fan Control ### Package Management
#### List HACS Packages
```json ```json
{ {
"tool": "control", "tool": "package",
"command": "turn_on", "action": "list",
"entity_id": "fan.bedroom", "category": "integration" // or "plugin", "theme", "python_script", "appdaemon", "netdaemon"
"percentage": 50,
"preset_mode": "auto",
"oscillating": true,
"direction": "forward"
} }
``` ```
### Lock Control #### Install Package
```json ```json
{ {
"tool": "control", "tool": "package",
"command": "lock", // or "unlock" "action": "install",
"entity_id": "lock.front_door" "category": "integration",
"repository": "hacs/integration",
"version": "1.32.0"
} }
``` ```
### Vacuum Control ### Automation Management
```json
{
"tool": "control",
"command": "start", // or "pause", "stop", "return_to_base", "clean_spot"
"entity_id": "vacuum.robot",
"fan_speed": "medium"
}
```
### Scene Control #### Create Automation
```json ```json
{ {
"tool": "control", "tool": "automation_config",
"command": "turn_on", "action": "create",
"entity_id": "scene.movie_night" "config": {
} "alias": "Motion Light",
``` "description": "Turn on light when motion detected",
"mode": "single",
### Script Control "trigger": [
```json {
{ "platform": "state",
"tool": "control", "entity_id": "binary_sensor.motion",
"command": "turn_on", "to": "on"
"entity_id": "script.welcome_home", }
"variables": { ],
"brightness": 100, "action": [
"color": "red" {
"service": "light.turn_on",
"target": {
"entity_id": "light.living_room"
}
}
]
} }
} }
``` ```
### Camera Control #### Duplicate Automation
```json ```json
{ {
"tool": "control", "tool": "automation_config",
"command": "enable_motion_detection", // or "disable_motion_detection" "action": "duplicate",
"entity_id": "camera.front_door" "automation_id": "automation.motion_light"
} }
``` ```
## Natural Language Integration ## Development
### Example Commands ```bash
- "Turn on the living room lights" # Development mode with hot reload
- "Set bedroom temperature to 22 degrees" npm run dev
- "Is the front door locked?"
### Smart Features # Build project
- Context awareness across conversations npm run build
- Natural parameter interpretation
- Intelligent error prevention # Production mode
- Multi-device orchestration npm run start
# Run tests
npx jest --config=jest.config.cjs
# Run tests with coverage
npx jest --coverage
# Lint code
npm run lint
# Format code
npm run format
```
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues
1. **Node.js Version (`toSorted is not a function`)** 1. **Node.js Version (`toSorted is not a function`)**
- **Solution:** Update to Node.js 20.10.0+ - **Solution:** Update to Node.js 20.10.0+
```bash
nvm install 20.10.0
nvm use 20.10.0
```
2. **Connection Issues** 2. **Connection Issues**
- Verify Home Assistant is running - Verify Home Assistant is running
- Check `HASS_HOST` accessibility - Check `HASS_HOST` accessibility
- Validate token permissions - Validate token permissions
3. **Entity Control Issues** - Ensure WebSocket connection for real-time updates
- Verify `entity_id` exists
- Check entity domain matches command 3. **Add-on Management Issues**
- Ensure parameter values are valid - Verify Supervisor access
- Check add-on compatibility
- Validate system resources
4. **HACS Integration Issues**
- Verify HACS installation
- Check HACS integration status
- Validate repository access
5. **Automation Issues**
- Verify entity availability
- Check trigger conditions
- Validate service calls
- Monitor execution logs
## Project Status ## Project Status
**Complete** **Complete**
- Entity, Floor, and Area access - Entity, Floor, and Area access
- Device control (Lights, Climate, Covers, Switches, Contacts) - Device control (Lights, Climate, Covers, Switches, Contacts)
- Add-on management system
- Package management through HACS
- Advanced automation configuration
- Basic state management - Basic state management
- Error handling and validation - Error handling and validation
- Docker containerization and configuration - Docker containerization
- Jest testing setup and TypeScript integration - Jest testing setup
- TypeScript integration
- Environment variable management - Environment variable management
- Home Assistant API integration - Home Assistant API integration
- Project documentation and README organization - Project documentation
🚧 **In Progress** 🚧 **In Progress**
- Custom prompt testing and optimization - WebSocket implementation for real-time updates
- Resource context integration - Enhanced security features
- Tool organization optimization - Tool organization optimization
- Performance optimization
- Resource context integration
- API documentation generation
- Multi-platform desktop integration
- Advanced error recovery
- Custom prompt testing
- Enhanced macOS integration - Enhanced macOS integration
- Type safety improvements - Type safety improvements
- Testing coverage expansion - Testing coverage expansion
🔜 **Planned**
- Multi-platform desktop integration
- Advanced error recovery mechanisms
- Performance optimization
- WebSocket implementation for real-time updates
- Enhanced security features
- API documentation generation
## Contributing ## Contributing
1. Fork repository 1. Fork the repository
2. Create feature branch 2. Create a feature branch
3. Submit pull request 3. Implement your changes
4. Add tests for new functionality
5. Ensure all tests pass
6. Submit a pull request
## Resources ## Resources
- [MCP Documentation](https://modelcontextprotocol.io/introduction) - [MCP Documentation](https://modelcontextprotocol.io/introduction)
- [Home Assistant Docs](https://www.home-assistant.io) - [Home Assistant Docs](https://www.home-assistant.io)
- [HA REST API](https://developers.home-assistant.io/docs/api/rest) - [HA REST API](https://developers.home-assistant.io/docs/api/rest)
- [HACS Documentation](https://hacs.xyz)
- [TypeScript Documentation](https://www.typescriptlang.org/docs)
## License ## License

View File

@@ -283,4 +283,630 @@ describe('Home Assistant MCP Server', () => {
); );
}); });
}); });
describe('get_history tool', () => {
it('should successfully fetch history', async () => {
const mockHistory = [
{
entity_id: 'light.living_room',
state: 'on',
last_changed: '2024-01-01T00:00:00Z',
attributes: { brightness: 255 }
},
{
entity_id: 'light.living_room',
state: 'off',
last_changed: '2024-01-01T01:00:00Z',
attributes: { brightness: 0 }
}
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockHistory
} as Response);
// Get the tool registration
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const historyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'get_history')?.[0] as Tool;
// Execute the tool
const result = await historyTool.execute({
entity_id: 'light.living_room',
start_time: '2024-01-01T00:00:00Z',
end_time: '2024-01-01T02:00:00Z',
minimal_response: true,
significant_changes_only: true
});
// Verify the results
expect(result.success).toBe(true);
expect(result.history).toEqual(mockHistory);
// Verify the fetch call
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/history/period/2024-01-01T00:00:00Z?'),
expect.objectContaining({
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
}
})
);
// Verify query parameters
const url = mockFetch.mock.calls[0][0] as string;
const queryParams = new URL(url).searchParams;
expect(queryParams.get('filter_entity_id')).toBe('light.living_room');
expect(queryParams.get('minimal_response')).toBe('true');
expect(queryParams.get('significant_changes_only')).toBe('true');
});
it('should handle fetch errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const historyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'get_history')?.[0] as Tool;
const result = await historyTool.execute({
entity_id: 'light.living_room'
});
expect(result.success).toBe(false);
expect(result.message).toBe('Network error');
});
});
describe('scene tool', () => {
it('should successfully list scenes', async () => {
const mockScenes = [
{
entity_id: 'scene.movie_time',
state: 'on',
attributes: {
friendly_name: 'Movie Time',
description: 'Perfect lighting for movies'
}
},
{
entity_id: 'scene.good_morning',
state: 'on',
attributes: {
friendly_name: 'Good Morning',
description: 'Bright lights to start the day'
}
}
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockScenes
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const sceneTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'scene')?.[0] as Tool;
const result = await sceneTool.execute({
action: 'list'
});
expect(result.success).toBe(true);
expect(result.scenes).toEqual([
{
entity_id: 'scene.movie_time',
name: 'Movie Time',
description: 'Perfect lighting for movies'
},
{
entity_id: 'scene.good_morning',
name: 'Good Morning',
description: 'Bright lights to start the day'
}
]);
});
it('should successfully activate a scene', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const sceneTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'scene')?.[0] as Tool;
const result = await sceneTool.execute({
action: 'activate',
scene_id: 'scene.movie_time'
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully activated scene scene.movie_time');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/services/scene/turn_on',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
entity_id: 'scene.movie_time'
})
}
);
});
});
describe('notify tool', () => {
it('should successfully send a notification', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const notifyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'notify')?.[0] as Tool;
const result = await notifyTool.execute({
message: 'Test notification',
title: 'Test Title',
target: 'mobile_app_phone',
data: { priority: 'high' }
});
expect(result.success).toBe(true);
expect(result.message).toBe('Notification sent successfully');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/services/notify/mobile_app_phone',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: 'Test notification',
title: 'Test Title',
data: { priority: 'high' }
})
}
);
});
it('should use default notification service when no target is specified', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const notifyTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'notify')?.[0] as Tool;
await notifyTool.execute({
message: 'Test notification'
});
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/services/notify/notify',
expect.any(Object)
);
});
});
describe('automation tool', () => {
it('should successfully list automations', async () => {
const mockAutomations = [
{
entity_id: 'automation.morning_routine',
state: 'on',
attributes: {
friendly_name: 'Morning Routine',
last_triggered: '2024-01-01T07:00:00Z'
}
},
{
entity_id: 'automation.night_mode',
state: 'off',
attributes: {
friendly_name: 'Night Mode',
last_triggered: '2024-01-01T22:00:00Z'
}
}
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockAutomations
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const result = await automationTool.execute({
action: 'list'
});
expect(result.success).toBe(true);
expect(result.automations).toEqual([
{
entity_id: 'automation.morning_routine',
name: 'Morning Routine',
state: 'on',
last_triggered: '2024-01-01T07:00:00Z'
},
{
entity_id: 'automation.night_mode',
name: 'Night Mode',
state: 'off',
last_triggered: '2024-01-01T22:00:00Z'
}
]);
});
it('should successfully toggle an automation', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const result = await automationTool.execute({
action: 'toggle',
automation_id: 'automation.morning_routine'
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully toggled automation automation.morning_routine');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/services/automation/toggle',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
entity_id: 'automation.morning_routine'
})
}
);
});
it('should successfully trigger an automation', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const result = await automationTool.execute({
action: 'trigger',
automation_id: 'automation.morning_routine'
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully triggered automation automation.morning_routine');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/services/automation/trigger',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
entity_id: 'automation.morning_routine'
})
}
);
});
it('should require automation_id for toggle and trigger actions', async () => {
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation')?.[0] as Tool;
const result = await automationTool.execute({
action: 'toggle'
});
expect(result.success).toBe(false);
expect(result.message).toBe('Automation ID is required for toggle and trigger actions');
});
});
describe('addon tool', () => {
it('should successfully list add-ons', async () => {
const mockAddons = {
data: {
addons: [
{
name: 'File Editor',
slug: 'core_configurator',
description: 'Simple browser-based file editor',
version: '5.6.0',
installed: true,
available: true,
state: 'started'
},
{
name: 'Terminal & SSH',
slug: 'ssh',
description: 'Terminal access to your Home Assistant',
version: '9.6.1',
installed: false,
available: true,
state: 'not_installed'
}
]
}
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockAddons
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const addonTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'addon')?.[0] as Tool;
const result = await addonTool.execute({
action: 'list'
});
expect(result.success).toBe(true);
expect(result.addons).toEqual(mockAddons.data.addons);
});
it('should successfully install an add-on', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { state: 'installing' } })
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const addonTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'addon')?.[0] as Tool;
const result = await addonTool.execute({
action: 'install',
slug: 'core_configurator',
version: '5.6.0'
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully installed add-on core_configurator');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/hassio/addons/core_configurator/install',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ version: '5.6.0' })
}
);
});
});
describe('package tool', () => {
it('should successfully list packages', async () => {
const mockPackages = {
repositories: [
{
name: 'HACS',
description: 'Home Assistant Community Store',
category: 'integration',
installed: true,
version_installed: '1.32.0',
available_version: '1.32.0',
authors: ['ludeeus'],
domain: 'hacs'
}
]
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockPackages
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const packageTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'package')?.[0] as Tool;
const result = await packageTool.execute({
action: 'list',
category: 'integration'
});
expect(result.success).toBe(true);
expect(result.packages).toEqual(mockPackages.repositories);
});
it('should successfully install a package', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({})
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const packageTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'package')?.[0] as Tool;
const result = await packageTool.execute({
action: 'install',
category: 'integration',
repository: 'hacs/integration',
version: '1.32.0'
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully installed package hacs/integration');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/hacs/repository/install',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({
category: 'integration',
repository: 'hacs/integration',
version: '1.32.0'
})
}
);
});
});
describe('automation_config tool', () => {
const mockAutomationConfig = {
alias: 'Test Automation',
description: 'Test automation description',
mode: 'single',
trigger: [
{
platform: 'state',
entity_id: 'binary_sensor.motion',
to: 'on'
}
],
action: [
{
service: 'light.turn_on',
target: {
entity_id: 'light.living_room'
}
}
]
};
it('should successfully create an automation', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ automation_id: 'new_automation_1' })
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const result = await automationConfigTool.execute({
action: 'create',
config: mockAutomationConfig
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully created automation');
expect(result.automation_id).toBe('new_automation_1');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/config/automation/config',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify(mockAutomationConfig)
}
);
});
it('should successfully duplicate an automation', async () => {
// Mock get existing automation
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => mockAutomationConfig
} as Response)
// Mock create new automation
.mockResolvedValueOnce({
ok: true,
json: async () => ({ automation_id: 'new_automation_2' })
} as Response);
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const result = await automationConfigTool.execute({
action: 'duplicate',
automation_id: 'automation.test'
});
expect(result.success).toBe(true);
expect(result.message).toBe('Successfully duplicated automation automation.test');
expect(result.new_automation_id).toBe('new_automation_2');
// Verify both API calls
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/config/automation/config/automation.test',
expect.any(Object)
);
const duplicateConfig = { ...mockAutomationConfig, alias: 'Test Automation (Copy)' };
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8123/api/config/automation/config',
{
method: 'POST',
headers: {
Authorization: 'Bearer test_token',
'Content-Type': 'application/json'
},
body: JSON.stringify(duplicateConfig)
}
);
});
it('should require config for create action', async () => {
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const result = await automationConfigTool.execute({
action: 'create'
});
expect(result.success).toBe(false);
expect(result.message).toBe('Configuration is required for creating automation');
});
it('should require automation_id for update action', async () => {
const liteMcpInstance = (LiteMCP as jest.MockedClass<typeof LiteMCP>).mock.results[0].value;
const addToolCalls = liteMcpInstance.addTool.mock.calls;
const automationConfigTool = addToolCalls.find((call: { 0: Tool }) => call[0].name === 'automation_config')?.[0] as Tool;
const result = await automationConfigTool.execute({
action: 'update',
config: mockAutomationConfig
});
expect(result.success).toBe(false);
expect(result.message).toBe('Automation ID and configuration are required for updating automation');
});
});
}); });

226
src/context/index.ts Normal file
View File

@@ -0,0 +1,226 @@
import { EventEmitter } from 'events';
// Resource types
export enum ResourceType {
DEVICE = 'device',
AREA = 'area',
USER = 'user',
AUTOMATION = 'automation',
SCENE = 'scene',
SCRIPT = 'script',
GROUP = 'group'
}
// Resource state interface
export interface ResourceState {
id: string;
type: ResourceType;
state: any;
attributes: Record<string, any>;
lastUpdated: number;
context?: Record<string, any>;
}
// Resource relationship types
export enum RelationType {
CONTAINS = 'contains',
CONTROLS = 'controls',
TRIGGERS = 'triggers',
DEPENDS_ON = 'depends_on',
GROUPS = 'groups'
}
// Resource relationship interface
export interface ResourceRelationship {
sourceId: string;
targetId: string;
type: RelationType;
metadata?: Record<string, any>;
}
// Context manager class
export class ContextManager extends EventEmitter {
private resources: Map<string, ResourceState> = new Map();
private relationships: ResourceRelationship[] = [];
private stateHistory: Map<string, ResourceState[]> = new Map();
private historyLimit = 100;
constructor() {
super();
}
// Resource management
public addResource(resource: ResourceState): void {
this.resources.set(resource.id, resource);
this.emit('resource_added', resource);
}
public updateResource(id: string, update: Partial<ResourceState>): void {
const resource = this.resources.get(id);
if (resource) {
// Store current state in history
this.addToHistory(resource);
// Update resource
const updatedResource = {
...resource,
...update,
lastUpdated: Date.now()
};
this.resources.set(id, updatedResource);
this.emit('resource_updated', updatedResource);
}
}
public removeResource(id: string): void {
const resource = this.resources.get(id);
if (resource) {
this.resources.delete(id);
// Remove related relationships
this.relationships = this.relationships.filter(
rel => rel.sourceId !== id && rel.targetId !== id
);
this.emit('resource_removed', resource);
}
}
// Relationship management
public addRelationship(relationship: ResourceRelationship): void {
this.relationships.push(relationship);
this.emit('relationship_added', relationship);
}
public removeRelationship(sourceId: string, targetId: string, type: RelationType): void {
const index = this.relationships.findIndex(
rel => rel.sourceId === sourceId && rel.targetId === targetId && rel.type === type
);
if (index !== -1) {
const removed = this.relationships.splice(index, 1)[0];
this.emit('relationship_removed', removed);
}
}
// History management
private addToHistory(state: ResourceState): void {
const history = this.stateHistory.get(state.id) || [];
history.push({ ...state });
if (history.length > this.historyLimit) {
history.shift();
}
this.stateHistory.set(state.id, history);
}
public getHistory(id: string): ResourceState[] {
return this.stateHistory.get(id) || [];
}
// Context queries
public getResource(id: string): ResourceState | undefined {
return this.resources.get(id);
}
public getResourcesByType(type: ResourceType): ResourceState[] {
return Array.from(this.resources.values()).filter(
resource => resource.type === type
);
}
public getRelatedResources(
id: string,
type?: RelationType,
depth: number = 1
): ResourceState[] {
const related = new Set<ResourceState>();
const visited = new Set<string>();
const traverse = (currentId: string, currentDepth: number) => {
if (currentDepth > depth || visited.has(currentId)) return;
visited.add(currentId);
this.relationships
.filter(rel =>
(rel.sourceId === currentId || rel.targetId === currentId) &&
(!type || rel.type === type)
)
.forEach(rel => {
const relatedId = rel.sourceId === currentId ? rel.targetId : rel.sourceId;
const relatedResource = this.resources.get(relatedId);
if (relatedResource) {
related.add(relatedResource);
traverse(relatedId, currentDepth + 1);
}
});
};
traverse(id, 0);
return Array.from(related);
}
// Context analysis
public analyzeResourceUsage(id: string): {
dependencies: string[];
dependents: string[];
groups: string[];
usage: {
triggerCount: number;
controlCount: number;
groupCount: number;
};
} {
const dependencies = this.relationships
.filter(rel => rel.sourceId === id && rel.type === RelationType.DEPENDS_ON)
.map(rel => rel.targetId);
const dependents = this.relationships
.filter(rel => rel.targetId === id && rel.type === RelationType.DEPENDS_ON)
.map(rel => rel.sourceId);
const groups = this.relationships
.filter(rel => rel.targetId === id && rel.type === RelationType.GROUPS)
.map(rel => rel.sourceId);
const usage = {
triggerCount: this.relationships.filter(
rel => rel.sourceId === id && rel.type === RelationType.TRIGGERS
).length,
controlCount: this.relationships.filter(
rel => rel.sourceId === id && rel.type === RelationType.CONTROLS
).length,
groupCount: groups.length
};
return { dependencies, dependents, groups, usage };
}
// Event subscriptions
public subscribeToResource(
id: string,
callback: (state: ResourceState) => void
): () => void {
const handler = (resource: ResourceState) => {
if (resource.id === id) {
callback(resource);
}
};
this.on('resource_updated', handler);
return () => this.off('resource_updated', handler);
}
public subscribeToType(
type: ResourceType,
callback: (state: ResourceState) => void
): () => void {
const handler = (resource: ResourceState) => {
if (resource.type === type) {
callback(resource);
}
};
this.on('resource_updated', handler);
return () => this.off('resource_updated', handler);
}
}
// Export context manager instance
export const contextManager = new ContextManager();

View File

@@ -46,6 +46,73 @@ interface HassEntity {
}; };
} }
interface HassState {
entity_id: string;
state: string;
attributes: {
friendly_name?: string;
description?: string;
[key: string]: any;
};
}
interface HassAddon {
name: string;
slug: string;
description: string;
version: string;
installed: boolean;
available: boolean;
state: string;
}
interface HassAddonResponse {
data: {
addons: HassAddon[];
};
}
interface HassAddonInfoResponse {
data: {
name: string;
slug: string;
description: string;
version: string;
state: string;
status: string;
options: Record<string, any>;
[key: string]: any;
};
}
interface HacsRepository {
name: string;
description: string;
category: string;
installed: boolean;
version_installed: string;
available_version: string;
authors: string[];
domain: string;
}
interface HacsResponse {
repositories: HacsRepository[];
}
interface AutomationConfig {
alias: string;
description?: string;
mode?: 'single' | 'parallel' | 'queued' | 'restart';
trigger: any[];
condition?: any[];
action: any[];
}
interface AutomationResponse {
automation_id: string;
}
async function main() { async function main() {
const hass = await get_hass(); const hass = await get_hass();
@@ -232,6 +299,570 @@ async function main() {
} }
}); });
// Add the history tool
server.addTool({
name: 'get_history',
description: 'Get state history for Home Assistant entities',
parameters: z.object({
entity_id: z.string().describe('The entity ID to get history for'),
start_time: z.string().optional().describe('Start time in ISO format. Defaults to 24 hours ago'),
end_time: z.string().optional().describe('End time in ISO format. Defaults to now'),
minimal_response: z.boolean().optional().describe('Return minimal response to reduce data size'),
significant_changes_only: z.boolean().optional().describe('Only return significant state changes'),
}),
execute: async (params) => {
try {
const now = new Date();
const startTime = params.start_time ? new Date(params.start_time) : new Date(now.getTime() - 24 * 60 * 60 * 1000);
const endTime = params.end_time ? new Date(params.end_time) : now;
// Build query parameters
const queryParams = new URLSearchParams({
filter_entity_id: params.entity_id,
minimal_response: String(!!params.minimal_response),
significant_changes_only: String(!!params.significant_changes_only),
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
});
const response = await fetch(`${HASS_HOST}/api/history/period/${startTime.toISOString()}?${queryParams.toString()}`, {
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch history: ${response.statusText}`);
}
const history = await response.json();
return {
success: true,
history,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Add the scenes tool
server.addTool({
name: 'scene',
description: 'Manage and activate Home Assistant scenes',
parameters: z.object({
action: z.enum(['list', 'activate']).describe('Action to perform with scenes'),
scene_id: z.string().optional().describe('Scene ID to activate (required for activate action)'),
}),
execute: async (params) => {
try {
if (params.action === 'list') {
const response = await fetch(`${HASS_HOST}/api/states`, {
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch scenes: ${response.statusText}`);
}
const states = (await response.json()) as HassState[];
const scenes = states.filter((state) => state.entity_id.startsWith('scene.'));
return {
success: true,
scenes: scenes.map((scene) => ({
entity_id: scene.entity_id,
name: scene.attributes.friendly_name || scene.entity_id.split('.')[1],
description: scene.attributes.description,
})),
};
} else if (params.action === 'activate') {
if (!params.scene_id) {
throw new Error('Scene ID is required for activate action');
}
const response = await fetch(`${HASS_HOST}/api/services/scene/turn_on`, {
method: 'POST',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
entity_id: params.scene_id,
}),
});
if (!response.ok) {
throw new Error(`Failed to activate scene: ${response.statusText}`);
}
return {
success: true,
message: `Successfully activated scene ${params.scene_id}`,
};
}
throw new Error('Invalid action specified');
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Add the notification tool
server.addTool({
name: 'notify',
description: 'Send notifications through Home Assistant',
parameters: z.object({
message: z.string().describe('The notification message'),
title: z.string().optional().describe('The notification title'),
target: z.string().optional().describe('Specific notification target (e.g., mobile_app_phone)'),
data: z.record(z.any()).optional().describe('Additional notification data'),
}),
execute: async (params) => {
try {
const service = params.target ? `notify.${params.target}` : 'notify.notify';
const [domain, service_name] = service.split('.');
const response = await fetch(`${HASS_HOST}/api/services/${domain}/${service_name}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: params.message,
title: params.title,
data: params.data,
}),
});
if (!response.ok) {
throw new Error(`Failed to send notification: ${response.statusText}`);
}
return {
success: true,
message: 'Notification sent successfully',
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Add the automation tool
server.addTool({
name: 'automation',
description: 'Manage Home Assistant automations',
parameters: z.object({
action: z.enum(['list', 'toggle', 'trigger']).describe('Action to perform with automation'),
automation_id: z.string().optional().describe('Automation ID (required for toggle and trigger actions)'),
}),
execute: async (params) => {
try {
if (params.action === 'list') {
const response = await fetch(`${HASS_HOST}/api/states`, {
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch automations: ${response.statusText}`);
}
const states = (await response.json()) as HassState[];
const automations = states.filter((state) => state.entity_id.startsWith('automation.'));
return {
success: true,
automations: automations.map((automation) => ({
entity_id: automation.entity_id,
name: automation.attributes.friendly_name || automation.entity_id.split('.')[1],
state: automation.state,
last_triggered: automation.attributes.last_triggered,
})),
};
} else {
if (!params.automation_id) {
throw new Error('Automation ID is required for toggle and trigger actions');
}
const service = params.action === 'toggle' ? 'toggle' : 'trigger';
const response = await fetch(`${HASS_HOST}/api/services/automation/${service}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
entity_id: params.automation_id,
}),
});
if (!response.ok) {
throw new Error(`Failed to ${service} automation: ${response.statusText}`);
}
return {
success: true,
message: `Successfully ${service}d automation ${params.automation_id}`,
};
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Add the addon tool
server.addTool({
name: 'addon',
description: 'Manage Home Assistant add-ons',
parameters: z.object({
action: z.enum(['list', 'info', 'install', 'uninstall', 'start', 'stop', 'restart']).describe('Action to perform with add-on'),
slug: z.string().optional().describe('Add-on slug (required for all actions except list)'),
version: z.string().optional().describe('Version to install (only for install action)'),
}),
execute: async (params) => {
try {
if (params.action === 'list') {
const response = await fetch(`${HASS_HOST}/api/hassio/store`, {
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch add-ons: ${response.statusText}`);
}
const data = await response.json() as HassAddonResponse;
return {
success: true,
addons: data.data.addons.map((addon) => ({
name: addon.name,
slug: addon.slug,
description: addon.description,
version: addon.version,
installed: addon.installed,
available: addon.available,
state: addon.state,
})),
};
} else {
if (!params.slug) {
throw new Error('Add-on slug is required for this action');
}
let endpoint = '';
let method = 'GET';
const body: Record<string, any> = {};
switch (params.action) {
case 'info':
endpoint = `/api/hassio/addons/${params.slug}/info`;
break;
case 'install':
endpoint = `/api/hassio/addons/${params.slug}/install`;
method = 'POST';
if (params.version) {
body.version = params.version;
}
break;
case 'uninstall':
endpoint = `/api/hassio/addons/${params.slug}/uninstall`;
method = 'POST';
break;
case 'start':
endpoint = `/api/hassio/addons/${params.slug}/start`;
method = 'POST';
break;
case 'stop':
endpoint = `/api/hassio/addons/${params.slug}/stop`;
method = 'POST';
break;
case 'restart':
endpoint = `/api/hassio/addons/${params.slug}/restart`;
method = 'POST';
break;
}
const response = await fetch(`${HASS_HOST}${endpoint}`, {
method,
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }),
});
if (!response.ok) {
throw new Error(`Failed to ${params.action} add-on: ${response.statusText}`);
}
const data = await response.json() as HassAddonInfoResponse;
return {
success: true,
message: `Successfully ${params.action}ed add-on ${params.slug}`,
data: data.data,
};
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Add the package tool
server.addTool({
name: 'package',
description: 'Manage HACS packages and custom components',
parameters: z.object({
action: z.enum(['list', 'install', 'uninstall', 'update']).describe('Action to perform with package'),
category: z.enum(['integration', 'plugin', 'theme', 'python_script', 'appdaemon', 'netdaemon'])
.describe('Package category'),
repository: z.string().optional().describe('Repository URL or name (required for install)'),
version: z.string().optional().describe('Version to install'),
}),
execute: async (params) => {
try {
const hacsBase = `${HASS_HOST}/api/hacs`;
if (params.action === 'list') {
const response = await fetch(`${hacsBase}/repositories?category=${params.category}`, {
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch packages: ${response.statusText}`);
}
const data = await response.json() as HacsResponse;
return {
success: true,
packages: data.repositories,
};
} else {
if (!params.repository) {
throw new Error('Repository is required for this action');
}
let endpoint = '';
const body: Record<string, any> = {
category: params.category,
repository: params.repository,
};
switch (params.action) {
case 'install':
endpoint = '/repository/install';
if (params.version) {
body.version = params.version;
}
break;
case 'uninstall':
endpoint = '/repository/uninstall';
break;
case 'update':
endpoint = '/repository/update';
break;
}
const response = await fetch(`${hacsBase}${endpoint}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Failed to ${params.action} package: ${response.statusText}`);
}
return {
success: true,
message: `Successfully ${params.action}ed package ${params.repository}`,
};
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Extend the automation tool with more functionality
server.addTool({
name: 'automation_config',
description: 'Advanced automation configuration and management',
parameters: z.object({
action: z.enum(['create', 'update', 'delete', 'duplicate']).describe('Action to perform with automation config'),
automation_id: z.string().optional().describe('Automation ID (required for update, delete, and duplicate)'),
config: z.object({
alias: z.string().describe('Friendly name for the automation'),
description: z.string().optional().describe('Description of what the automation does'),
mode: z.enum(['single', 'parallel', 'queued', 'restart']).optional().describe('How multiple triggerings are handled'),
trigger: z.array(z.any()).describe('List of triggers'),
condition: z.array(z.any()).optional().describe('List of conditions'),
action: z.array(z.any()).describe('List of actions'),
}).optional().describe('Automation configuration (required for create and update)'),
}),
execute: async (params) => {
try {
switch (params.action) {
case 'create': {
if (!params.config) {
throw new Error('Configuration is required for creating automation');
}
const response = await fetch(`${HASS_HOST}/api/config/automation/config`, {
method: 'POST',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params.config),
});
if (!response.ok) {
throw new Error(`Failed to create automation: ${response.statusText}`);
}
return {
success: true,
message: 'Successfully created automation',
automation_id: (await response.json()).automation_id,
};
}
case 'update': {
if (!params.automation_id || !params.config) {
throw new Error('Automation ID and configuration are required for updating automation');
}
const response = await fetch(`${HASS_HOST}/api/config/automation/config/${params.automation_id}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params.config),
});
if (!response.ok) {
throw new Error(`Failed to update automation: ${response.statusText}`);
}
return {
success: true,
message: `Successfully updated automation ${params.automation_id}`,
};
}
case 'delete': {
if (!params.automation_id) {
throw new Error('Automation ID is required for deleting automation');
}
const response = await fetch(`${HASS_HOST}/api/config/automation/config/${params.automation_id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to delete automation: ${response.statusText}`);
}
return {
success: true,
message: `Successfully deleted automation ${params.automation_id}`,
};
}
case 'duplicate': {
if (!params.automation_id) {
throw new Error('Automation ID is required for duplicating automation');
}
// First, get the existing automation config
const getResponse = await fetch(`${HASS_HOST}/api/config/automation/config/${params.automation_id}`, {
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!getResponse.ok) {
throw new Error(`Failed to get automation config: ${getResponse.statusText}`);
}
const config = await getResponse.json() as AutomationConfig;
config.alias = `${config.alias} (Copy)`;
// Create new automation with modified config
const createResponse = await fetch(`${HASS_HOST}/api/config/automation/config`, {
method: 'POST',
headers: {
Authorization: `Bearer ${HASS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (!createResponse.ok) {
throw new Error(`Failed to create duplicate automation: ${createResponse.statusText}`);
}
const newAutomation = await createResponse.json() as AutomationResponse;
return {
success: true,
message: `Successfully duplicated automation ${params.automation_id}`,
new_automation_id: newAutomation.automation_id,
};
}
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
},
});
// Start the server // Start the server
await server.start(); await server.start();
console.log('MCP Server started'); console.log('MCP Server started');

222
src/performance/index.ts Normal file
View File

@@ -0,0 +1,222 @@
import { performance } from 'perf_hooks';
import { EventEmitter } from 'events';
// Performance metrics types
export interface Metric {
name: string;
value: number;
timestamp: number;
metadata?: Record<string, any>;
}
export interface PerformanceThresholds {
responseTime: number; // milliseconds
memoryUsage: number; // bytes
cpuUsage: number; // percentage
}
// Performance monitoring class
export class PerformanceMonitor extends EventEmitter {
private metrics: Metric[] = [];
private thresholds: PerformanceThresholds;
private samplingInterval: number;
private retentionPeriod: number;
private intervalId?: NodeJS.Timeout;
constructor(
thresholds: Partial<PerformanceThresholds> = {},
samplingInterval = 5000, // 5 seconds
retentionPeriod = 24 * 60 * 60 * 1000 // 24 hours
) {
super();
this.thresholds = {
responseTime: thresholds.responseTime || 1000, // 1 second
memoryUsage: thresholds.memoryUsage || 1024 * 1024 * 1024, // 1 GB
cpuUsage: thresholds.cpuUsage || 80 // 80%
};
this.samplingInterval = samplingInterval;
this.retentionPeriod = retentionPeriod;
}
// Start monitoring
public start(): void {
this.intervalId = setInterval(() => {
this.collectMetrics();
this.cleanOldMetrics();
}, this.samplingInterval);
}
// Stop monitoring
public stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
// Collect system metrics
private collectMetrics(): void {
const now = Date.now();
const memoryUsage = process.memoryUsage();
const cpuUsage = process.cpuUsage();
// Memory metrics
this.addMetric({
name: 'memory.heapUsed',
value: memoryUsage.heapUsed,
timestamp: now
});
this.addMetric({
name: 'memory.heapTotal',
value: memoryUsage.heapTotal,
timestamp: now
});
this.addMetric({
name: 'memory.rss',
value: memoryUsage.rss,
timestamp: now
});
// CPU metrics
this.addMetric({
name: 'cpu.user',
value: cpuUsage.user,
timestamp: now
});
this.addMetric({
name: 'cpu.system',
value: cpuUsage.system,
timestamp: now
});
// Check thresholds
this.checkThresholds();
}
// Add a metric
public addMetric(metric: Metric): void {
this.metrics.push(metric);
this.emit('metric', metric);
}
// Clean old metrics
private cleanOldMetrics(): void {
const cutoff = Date.now() - this.retentionPeriod;
this.metrics = this.metrics.filter(metric => metric.timestamp > cutoff);
}
// Check if metrics exceed thresholds
private checkThresholds(): void {
const memoryUsage = process.memoryUsage().heapUsed;
if (memoryUsage > this.thresholds.memoryUsage) {
this.emit('threshold_exceeded', {
type: 'memory',
value: memoryUsage,
threshold: this.thresholds.memoryUsage
});
}
const cpuUsage = process.cpuUsage();
const totalCPU = cpuUsage.user + cpuUsage.system;
const cpuPercentage = (totalCPU / (process.uptime() * 1000000)) * 100;
if (cpuPercentage > this.thresholds.cpuUsage) {
this.emit('threshold_exceeded', {
type: 'cpu',
value: cpuPercentage,
threshold: this.thresholds.cpuUsage
});
}
}
// Get metrics for a specific time range
public getMetrics(
startTime: number,
endTime: number = Date.now(),
metricName?: string
): Metric[] {
return this.metrics.filter(metric =>
metric.timestamp >= startTime &&
metric.timestamp <= endTime &&
(!metricName || metric.name === metricName)
);
}
// Calculate average for a metric
public calculateAverage(
metricName: string,
startTime: number,
endTime: number = Date.now()
): number {
const metrics = this.getMetrics(startTime, endTime, metricName);
if (metrics.length === 0) return 0;
return metrics.reduce((sum, metric) => sum + metric.value, 0) / metrics.length;
}
}
// Performance optimization utilities
export class PerformanceOptimizer {
private static readonly GC_THRESHOLD = 0.9; // 90% heap usage
// Optimize memory usage
public static async optimizeMemory(): Promise<void> {
const memoryUsage = process.memoryUsage();
const heapUsageRatio = memoryUsage.heapUsed / memoryUsage.heapTotal;
if (heapUsageRatio > this.GC_THRESHOLD) {
if (global.gc) {
global.gc();
}
}
}
// Batch processing utility
public static async processBatch<T, R>(
items: T[],
batchSize: number,
processor: (batch: T[]) => Promise<R[]>
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await processor(batch);
results.push(...batchResults);
await new Promise(resolve => setTimeout(resolve, 0)); // Yield to event loop
}
return results;
}
// Debounce utility
public static debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Throttle utility
public static throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
}
// Export performance monitoring instance
export const performanceMonitor = new PerformanceMonitor();
// Start monitoring on module load
performanceMonitor.start();

180
src/security/index.ts Normal file
View File

@@ -0,0 +1,180 @@
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
// Security configuration
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_MAX = 100; // requests per window
const TOKEN_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
// Rate limiting middleware
export const rateLimiter = rateLimit({
windowMs: RATE_LIMIT_WINDOW,
max: RATE_LIMIT_MAX,
message: 'Too many requests from this IP, please try again later'
});
// Security headers middleware
export const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", process.env.HASS_HOST || ''],
upgradeInsecureRequests: []
}
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: 'same-site' },
dnsPrefetchControl: true,
frameguard: { action: 'deny' },
hidePoweredBy: true,
hsts: true,
ieNoOpen: true,
noSniff: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
xssFilter: true
});
// Token validation and encryption
export class TokenManager {
private static readonly algorithm = 'aes-256-gcm';
private static readonly keyLength = 32;
private static readonly ivLength = 16;
private static readonly saltLength = 64;
private static readonly tagLength = 16;
private static readonly iterations = 100000;
private static readonly digest = 'sha512';
private static deriveKey(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(
password,
salt,
this.iterations,
this.keyLength,
this.digest
);
}
public static encryptToken(token: string, encryptionKey: string): string {
const iv = crypto.randomBytes(this.ivLength);
const salt = crypto.randomBytes(this.saltLength);
const key = this.deriveKey(encryptionKey, salt);
const cipher = crypto.createCipheriv(this.algorithm, key, iv, {
authTagLength: this.tagLength
});
const encrypted = Buffer.concat([
cipher.update(token, 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
}
public static decryptToken(encryptedToken: string, encryptionKey: string): string {
const buffer = Buffer.from(encryptedToken, 'base64');
const salt = buffer.subarray(0, this.saltLength);
const iv = buffer.subarray(this.saltLength, this.saltLength + this.ivLength);
const tag = buffer.subarray(
this.saltLength + this.ivLength,
this.saltLength + this.ivLength + this.tagLength
);
const encrypted = buffer.subarray(this.saltLength + this.ivLength + this.tagLength);
const key = this.deriveKey(encryptionKey, salt);
const decipher = crypto.createDecipheriv(this.algorithm, key, iv, {
authTagLength: this.tagLength
});
decipher.setAuthTag(tag);
return decipher.update(encrypted) + decipher.final('utf8');
}
public static validateToken(token: string): boolean {
if (!token) return false;
try {
// Check token format
if (!/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/.test(token)) {
return false;
}
// Decode token parts
const [headerEncoded, payloadEncoded] = token.split('.');
const header = JSON.parse(Buffer.from(headerEncoded, 'base64').toString());
const payload = JSON.parse(Buffer.from(payloadEncoded, 'base64').toString());
// Check token expiry
if (payload.exp && Date.now() >= payload.exp * 1000) {
return false;
}
// Additional checks can be added here
return true;
} catch {
return false;
}
}
}
// Request validation middleware
export function validateRequest(req: Request, res: Response, next: NextFunction) {
// Validate content type
if (req.method !== 'GET' && !req.is('application/json')) {
return res.status(415).json({
error: 'Unsupported Media Type - Content-Type must be application/json'
});
}
// Validate token
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !TokenManager.validateToken(token)) {
return res.status(401).json({
error: 'Invalid or expired token'
});
}
// Validate request body
if (req.method !== 'GET' && (!req.body || typeof req.body !== 'object')) {
return res.status(400).json({
error: 'Invalid request body'
});
}
next();
}
// Input sanitization middleware
export function sanitizeInput(req: Request, res: Response, next: NextFunction) {
if (req.body && typeof req.body === 'object') {
const sanitized = JSON.parse(
JSON.stringify(req.body).replace(/[<>]/g, '')
);
req.body = sanitized;
}
next();
}
// Error handling middleware
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
// Export security middleware chain
export const securityMiddleware = [
rateLimiter,
securityHeaders,
validateRequest,
sanitizeInput,
errorHandler
];

181
src/tools/index.ts Normal file
View File

@@ -0,0 +1,181 @@
import { Tool } from 'litemcp';
import { z } from 'zod';
// Tool category types
export enum ToolCategory {
DEVICE_CONTROL = 'device_control',
SYSTEM_MANAGEMENT = 'system_management',
AUTOMATION = 'automation',
MONITORING = 'monitoring',
SECURITY = 'security'
}
// Tool priority levels
export enum ToolPriority {
HIGH = 'high',
MEDIUM = 'medium',
LOW = 'low'
}
// Tool metadata interface
export interface ToolMetadata {
category: ToolCategory;
priority: ToolPriority;
requiresAuth: boolean;
rateLimit?: {
windowMs: number;
max: number;
};
caching?: {
enabled: boolean;
ttl: number;
};
}
// Enhanced tool interface
export interface EnhancedTool extends Tool {
metadata: ToolMetadata;
validate?: (params: any) => Promise<boolean>;
preExecute?: (params: any) => Promise<void>;
postExecute?: (result: any) => Promise<void>;
}
// Tool registry for managing and organizing tools
export class ToolRegistry {
private tools: Map<string, EnhancedTool> = new Map();
private categories: Map<ToolCategory, Set<string>> = new Map();
private cache: Map<string, { data: any; timestamp: number }> = new Map();
constructor() {
// Initialize categories
Object.values(ToolCategory).forEach(category => {
this.categories.set(category, new Set());
});
}
// Register a new tool
public registerTool(tool: EnhancedTool): void {
this.tools.set(tool.name, tool);
this.categories.get(tool.metadata.category)?.add(tool.name);
}
// Get tool by name
public getTool(name: string): EnhancedTool | undefined {
return this.tools.get(name);
}
// Get all tools in a category
public getToolsByCategory(category: ToolCategory): EnhancedTool[] {
const toolNames = this.categories.get(category);
if (!toolNames) return [];
return Array.from(toolNames).map(name => this.tools.get(name)!);
}
// Execute a tool with validation and hooks
public async executeTool(name: string, params: any): Promise<any> {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Tool ${name} not found`);
}
// Check cache if enabled
if (tool.metadata.caching?.enabled) {
const cacheKey = `${name}:${JSON.stringify(params)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < tool.metadata.caching.ttl) {
return cached.data;
}
}
// Validate parameters
if (tool.validate) {
const isValid = await tool.validate(params);
if (!isValid) {
throw new Error('Invalid parameters');
}
}
// Pre-execution hook
if (tool.preExecute) {
await tool.preExecute(params);
}
// Execute tool
const result = await tool.execute(params);
// Post-execution hook
if (tool.postExecute) {
await tool.postExecute(result);
}
// Update cache if enabled
if (tool.metadata.caching?.enabled) {
const cacheKey = `${name}:${JSON.stringify(params)}`;
this.cache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
}
return result;
}
// Clean up expired cache entries
public cleanCache(): void {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
const tool = this.tools.get(key.split(':')[0]);
if (tool?.metadata.caching?.ttl && now - value.timestamp > tool.metadata.caching.ttl) {
this.cache.delete(key);
}
}
}
}
// Create and export the global tool registry
export const toolRegistry = new ToolRegistry();
// Tool decorator for easy registration
export function registerTool(metadata: ToolMetadata) {
return function (target: any) {
const tool: EnhancedTool = new target();
tool.metadata = metadata;
toolRegistry.registerTool(tool);
};
}
// Example usage:
@registerTool({
category: ToolCategory.DEVICE_CONTROL,
priority: ToolPriority.HIGH,
requiresAuth: true,
caching: {
enabled: true,
ttl: 5000 // 5 seconds
}
})
export class LightControlTool implements EnhancedTool {
name = 'light_control';
description = 'Control light devices';
parameters = z.object({
command: z.enum(['turn_on', 'turn_off', 'toggle']),
entity_id: z.string(),
brightness: z.number().min(0).max(255).optional(),
color_temp: z.number().optional(),
rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional()
});
async validate(params: any): Promise<boolean> {
try {
this.parameters.parse(params);
return true;
} catch {
return false;
}
}
async execute(params: any): Promise<any> {
// Implementation here
return { success: true };
}
}

174
src/websocket/client.ts Normal file
View File

@@ -0,0 +1,174 @@
import WebSocket from 'ws';
import { EventEmitter } from 'events';
export class HassWebSocketClient extends EventEmitter {
private ws: WebSocket | null = null;
private messageId = 1;
private authenticated = false;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
private subscriptions = new Map<string, (data: any) => void>();
constructor(
private url: string,
private token: string,
private options: {
autoReconnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
} = {}
) {
super();
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
this.reconnectDelay = options.reconnectDelay || 1000;
}
public async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
this.authenticate();
});
this.ws.on('message', (data: string) => {
const message = JSON.parse(data);
this.handleMessage(message);
});
this.ws.on('close', () => {
this.handleDisconnect();
});
this.ws.on('error', (error) => {
this.emit('error', error);
reject(error);
});
this.once('auth_ok', () => {
this.authenticated = true;
this.reconnectAttempts = 0;
resolve();
});
this.once('auth_invalid', () => {
reject(new Error('Authentication failed'));
});
} catch (error) {
reject(error);
}
});
}
private authenticate(): void {
this.send({
type: 'auth',
access_token: this.token
});
}
private handleMessage(message: any): void {
switch (message.type) {
case 'auth_required':
this.authenticate();
break;
case 'auth_ok':
this.emit('auth_ok');
break;
case 'auth_invalid':
this.emit('auth_invalid');
break;
case 'event':
this.handleEvent(message);
break;
case 'result':
this.emit(`result_${message.id}`, message);
break;
}
}
private handleEvent(message: any): void {
const subscription = this.subscriptions.get(message.event.event_type);
if (subscription) {
subscription(message.event.data);
}
this.emit('event', message.event);
}
private handleDisconnect(): void {
this.authenticated = false;
this.emit('disconnected');
if (this.options.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.connect().catch((error) => {
this.emit('error', error);
});
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
}
}
public async subscribeEvents(eventType: string, callback: (data: any) => void): Promise<number> {
if (!this.authenticated) {
throw new Error('Not authenticated');
}
const id = this.messageId++;
this.subscriptions.set(eventType, callback);
return new Promise((resolve, reject) => {
this.send({
id,
type: 'subscribe_events',
event_type: eventType
});
this.once(`result_${id}`, (message) => {
if (message.success) {
resolve(id);
} else {
reject(new Error(message.error?.message || 'Subscription failed'));
}
});
});
}
public async unsubscribeEvents(subscription: number): Promise<void> {
if (!this.authenticated) {
throw new Error('Not authenticated');
}
const id = this.messageId++;
return new Promise((resolve, reject) => {
this.send({
id,
type: 'unsubscribe_events',
subscription
});
this.once(`result_${id}`, (message) => {
if (message.success) {
resolve();
} else {
reject(new Error(message.error?.message || 'Unsubscribe failed'));
}
});
});
}
private send(message: any): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
public disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}