diff --git a/README.md b/README.md index 2994eb9..7abce2e 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,250 @@ # A Model Context Protocol Server for Home Assistant -The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application. +The server uses the MCP protocol to share access to a local Home Assistant instance with an LLM application. It provides a comprehensive interface for controlling various Home Assistant entities through natural language. -More about MCP here: https://modelcontextprotocol.io/introduction +## Features -More about Home Assistant here: https://www.home-assistant.io +- **Entity Control**: Full support for controlling common Home Assistant entities: + - 💡 **Lights**: Brightness, color temperature, RGB color + - 🌡️ **Climate**: Temperature, HVAC modes, fan modes, humidity + - 🚪 **Covers**: Position control, tilt control + - 🔌 **Switches**: Basic on/off control + - 🚨 **Contacts**: State monitoring +- **Entity State Access**: Query and monitor entity states +- **Area and Floor Organization**: Logical grouping of devices +- **Robust Error Handling**: Clear error messages and state validation -## Usage +## Prerequisites -First build the server +- Node.js 16 or higher +- Yarn package manager +- A running Home Assistant instance +- A long-lived access token from Home Assistant -``` +## Installation + +```bash +# Clone the repository +git clone https://github.com/yourusername/homeassistant-mcp.git +cd homeassistant-mcp + +# Install dependencies +yarn install + +# Build the server yarn build ``` -Then configure your application (like Claude Desktop) to use it. +## Configuration -``` -{ - "mcpServers": { - "homeassistant": { - "command": "node", - "args": [ - "/Users/tevonsb/Desktop/mcp/dist/index.js" - ], - "env": { - "TOKEN": , - "BASE_URL": - } - } - } -} +1. **Home Assistant Token** + - Get a long-lived access token from Home Assistant + - Guide: [How to get long-lived access token](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159) + +2. **Environment Variables** + Create a `.env` file with: + + ```env + TOKEN=your_home_assistant_token + BASE_URL=your_home_assistant_url # e.g., http://homeassistant.local:8123 + PORT=3000 # Optional, defaults to 3000 + ``` + +3. **MCP Client Configuration** + Configure your MCP client (like Claude Desktop) with: + + ```json + { + "mcpServers": { + "homeassistant": { + "command": "node", + "args": [ + "/path/to/dist/index.js" + ], + "env": { + "TOKEN": "your_home_assistant_token", + "BASE_URL": "your_home_assistant_url" + } + } + } + } + ``` + +## Supported Commands + +### Common Commands (All Entities) + +- `turn_on`: Turn entity on +- `turn_off`: Turn entity off +- `toggle`: Toggle entity state + +### Light-Specific Commands + +- Control brightness (0-255) + + ```json + { + "command": "turn_on", + "entity_id": "light.living_room", + "brightness": 128 + } + ``` + +- Set color temperature + + ```json + { + "command": "turn_on", + "entity_id": "light.living_room", + "color_temp": 4000 + } + ``` + +- Set RGB color values + + ```json + { + "command": "turn_on", + "entity_id": "light.living_room", + "rgb_color": [255, 0, 0] + } + ``` + +### Cover Commands + +- `open`: Open cover +- `close`: Close cover +- `stop`: Stop cover movement +- `set_position`: Set cover position (0-100) + + ```json + { + "command": "set_position", + "entity_id": "cover.living_room", + "position": 50 + } + ``` + +- `set_tilt_position`: Set cover tilt (0-100) + + ```json + { + "command": "set_tilt_position", + "entity_id": "cover.living_room", + "tilt_position": 45 + } + ``` + +### Climate Commands + +- `set_temperature`: Set target temperature + + ```json + { + "command": "set_temperature", + "entity_id": "climate.living_room", + "temperature": 22 + } + ``` + +- `set_hvac_mode`: Set mode (off, heat, cool, heat_cool, auto, dry, fan_only) + + ```json + { + "command": "set_hvac_mode", + "entity_id": "climate.living_room", + "hvac_mode": "heat" + } + ``` + +- `set_fan_mode`: Set fan mode (auto, low, medium, high) + + ```json + { + "command": "set_fan_mode", + "entity_id": "climate.living_room", + "fan_mode": "auto" + } + ``` + +- `set_humidity`: Set target humidity (0-100) + + ```json + { + "command": "set_humidity", + "entity_id": "climate.living_room", + "humidity": 45 + } + ``` + +## Development + +```bash +# Run in development mode +yarn dev + +# Build and start +yarn build:start + +# Run tests +yarn test ``` -You'll need a personal access token from home assistant. +## Troubleshooting -Get one using this guide: https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159 +### Common Issues -## In Progress +1. **Connection Errors** + - Verify your Home Assistant instance is running + - Check the BASE_URL is correct and accessible + - Ensure your token has the required permissions + +2. **Entity Control Issues** + - Verify the entity_id exists in Home Assistant + - Check the entity domain matches the command + - Ensure parameter values are within valid ranges + +3. **Permission Issues** + - Verify your token has write permissions for the entity + - Check Home Assistant logs for authorization errors + +## Project Status + +### Completed - [x] Access to entities - [x] Access to Floors - [x] Access to Areas -- [ ] Control for entities - - [ ] Lights - - [ ] Thermostats - - [ ] Covers +- [x] Control for entities + - [x] Lights + - [x] Thermostats + - [x] Covers + - [x] Contacts + - [x] Climates + - [x] Switches + +### In Progress + - [ ] Testing / writing custom prompts - [ ] Testing using resources for high-level context - [ ] Test varying tool organization + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Links + +- [Model Context Protocol Documentation](https://modelcontextprotocol.io/introduction) +- [Home Assistant Documentation](https://www.home-assistant.io) +- [Home Assistant REST API](https://developers.home-assistant.io/docs/api/rest) diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..3e89817 --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,20 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@digital-alchemy)/.*)', + ], +}; \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..9940988 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,17 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, +}; \ No newline at end of file diff --git a/src/__tests__/context.test.ts b/src/__tests__/context.test.ts new file mode 100644 index 0000000..0637045 --- /dev/null +++ b/src/__tests__/context.test.ts @@ -0,0 +1,202 @@ +import { z } from 'zod'; +import { DomainSchema } from '../schemas.js'; + +// Define types for tool and server +interface Tool { + name: string; + execute: (params: any) => Promise; + parameters: z.ZodType; +} + +// Mock LiteMCP class +class MockLiteMCP { + private tools: Tool[] = []; + + constructor(public name: string, public version: string) { } + + addTool(tool: Tool) { + this.tools.push(tool); + } + + getTools() { + return this.tools; + } +} + +// Mock the Home Assistant instance +jest.mock('../hass/index.js', () => ({ + get_hass: jest.fn().mockResolvedValue({ + services: { + light: { + turn_on: jest.fn().mockResolvedValue(undefined), + turn_off: jest.fn().mockResolvedValue(undefined), + }, + climate: { + set_temperature: jest.fn().mockResolvedValue(undefined), + }, + }, + }), +})); + +describe('MCP Server Context and Tools', () => { + let server: MockLiteMCP; + + beforeEach(async () => { + server = new MockLiteMCP('home-assistant', '0.1.0'); + + // Add the control tool to the server + server.addTool({ + name: 'control', + description: 'Control Home Assistant devices and services', + execute: async (params: any) => { + const domain = params.entity_id.split('.')[0]; + if (params.command === 'set_temperature' && domain !== 'climate') { + return { + success: false, + message: `Unsupported operation for domain: ${domain}`, + }; + } + return { + success: true, + message: `Successfully executed ${params.command} for ${params.entity_id}`, + }; + }, + parameters: z.object({ + command: z.string(), + 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(), + temperature: z.number().optional(), + hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional(), + fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional(), + position: z.number().min(0).max(100).optional(), + tilt_position: z.number().min(0).max(100).optional(), + area: z.string().optional(), + }), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Custom Prompts', () => { + it('should handle natural language commands for lights', async () => { + const tools = server.getTools(); + const tool = tools.find(t => t.name === 'control'); + expect(tool).toBeDefined(); + + // Test natural language command execution + const result = await tool!.execute({ + command: 'turn_on', + entity_id: 'light.living_room', + brightness: 128, + }); + + expect(result).toEqual({ + success: true, + message: expect.stringContaining('Successfully executed turn_on for light.living_room'), + }); + }); + + it('should handle natural language commands for climate control', async () => { + const tools = server.getTools(); + const tool = tools.find(t => t.name === 'control'); + expect(tool).toBeDefined(); + + // Test temperature control command + const result = await tool!.execute({ + command: 'set_temperature', + entity_id: 'climate.living_room', + temperature: 22, + }); + + expect(result).toEqual({ + success: true, + message: expect.stringContaining('Successfully executed set_temperature for climate.living_room'), + }); + }); + }); + + describe('High-Level Context', () => { + it('should validate domain-specific commands', async () => { + const tools = server.getTools(); + const tool = tools.find(t => t.name === 'control'); + expect(tool).toBeDefined(); + + // Test invalid command for domain + const result = await tool!.execute({ + command: 'set_temperature', // Climate command + entity_id: 'light.living_room', // Light entity + temperature: 22, + }); + + expect(result).toEqual({ + success: false, + message: expect.stringContaining('Unsupported operation'), + }); + }); + + it('should handle area-based commands', async () => { + const tools = server.getTools(); + const tool = tools.find(t => t.name === 'control'); + expect(tool).toBeDefined(); + + // Test command with area context + const result = await tool!.execute({ + command: 'turn_on', + entity_id: 'light.living_room', + area: 'Living Room', + }); + + expect(result).toEqual({ + success: true, + message: expect.stringContaining('Successfully executed turn_on for light.living_room'), + }); + }); + }); + + describe('Tool Organization', () => { + it('should have all required tools available', () => { + const tools = server.getTools(); + const toolNames = tools.map(t => t.name); + expect(toolNames).toContain('control'); + }); + + it('should support all defined domains', () => { + const tools = server.getTools(); + const tool = tools.find(t => t.name === 'control'); + expect(tool).toBeDefined(); + + // Check if tool supports all domains from DomainSchema + const supportedDomains = Object.values(DomainSchema.Values); + const schema = tool!.parameters as z.ZodObject; + const shape = schema.shape; + + expect(shape).toBeDefined(); + expect(shape.entity_id).toBeDefined(); + expect(shape.command).toBeDefined(); + + // Test each domain has its specific parameters + supportedDomains.forEach(domain => { + switch (domain) { + case 'light': + expect(shape.brightness).toBeDefined(); + expect(shape.color_temp).toBeDefined(); + expect(shape.rgb_color).toBeDefined(); + break; + case 'climate': + expect(shape.temperature).toBeDefined(); + expect(shape.hvac_mode).toBeDefined(); + expect(shape.fan_mode).toBeDefined(); + break; + case 'cover': + expect(shape.position).toBeDefined(); + expect(shape.tilt_position).toBeDefined(); + break; + } + }); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/hass.test.ts b/src/__tests__/hass.test.ts new file mode 100644 index 0000000..c52f5d1 --- /dev/null +++ b/src/__tests__/hass.test.ts @@ -0,0 +1,48 @@ +import { get_hass } from '../hass/index.js'; + +// Mock the entire module +jest.mock('../hass/index.js', () => { + let mockInstance: any = null; + + return { + get_hass: jest.fn(async () => { + if (!mockInstance) { + mockInstance = { + services: { + light: { + turn_on: jest.fn().mockResolvedValue(undefined), + turn_off: jest.fn().mockResolvedValue(undefined), + }, + climate: { + set_temperature: jest.fn().mockResolvedValue(undefined), + }, + }, + }; + } + return mockInstance; + }), + }; +}); + +describe('Home Assistant Connection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a Home Assistant instance with services', async () => { + const hass = await get_hass(); + + expect(hass).toBeDefined(); + expect(hass.services).toBeDefined(); + expect(typeof hass.services.light.turn_on).toBe('function'); + expect(typeof hass.services.light.turn_off).toBe('function'); + expect(typeof hass.services.climate.set_temperature).toBe('function'); + }); + + it('should reuse the same instance on multiple calls', async () => { + const firstInstance = await get_hass(); + const secondInstance = await get_hass(); + + expect(firstInstance).toBe(secondInstance); + }); +}); \ No newline at end of file diff --git a/src/hass/index.ts b/src/hass/index.ts index 05cfc18..f70bbec 100644 --- a/src/hass/index.ts +++ b/src/hass/index.ts @@ -1,8 +1,21 @@ import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core"; import { LIB_HASS, PICK_ENTITY } from "@digital-alchemy/hass"; +import { DomainSchema } from "../schemas.js"; type Environments = "development" | "production" | "test"; +// Define the type for Home Assistant services +type HassServices = { + [K in keyof typeof DomainSchema.Values]: { + [service: string]: (data: Record) => Promise; + }; +}; + +// Define the type for Home Assistant instance +interface HassInstance extends TServiceParams { + services: HassServices; +} + // application const MY_APP = CreateApplication({ configuration: { @@ -28,11 +41,12 @@ const MY_APP = CreateApplication({ name: 'hass' as const }); -let hassInstance: Awaited>; +let hassInstance: HassInstance; -export async function get_hass() { +export async function get_hass(): Promise { if (!hassInstance) { - hassInstance = await MY_APP.bootstrap(); + const instance = await MY_APP.bootstrap(); + hassInstance = instance as unknown as HassInstance; } return hassInstance; } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5645fc8..0acf388 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,41 +1,165 @@ import { get_hass } from './hass/index.js'; -import { Server as ModelContextProtocolServer } from 'litemcp'; +import { LiteMCP } from 'litemcp'; import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; +import { DomainSchema } from './schemas.js'; interface CommandParams { command: string; - entity_id?: string; + entity_id: string; + // Common parameters + state?: string; + // Light parameters + brightness?: number; + color_temp?: number; + rgb_color?: [number, number, number]; + // Cover parameters + position?: number; + tilt_position?: number; + // Climate parameters + temperature?: number; + target_temp_high?: number; + target_temp_low?: number; + hvac_mode?: string; + fan_mode?: string; + humidity?: number; } +const commonCommands = ['turn_on', 'turn_off', 'toggle'] as const; +const coverCommands = [...commonCommands, 'open', 'close', 'stop', 'set_position', 'set_tilt_position'] as const; +const climateCommands = [...commonCommands, 'set_temperature', 'set_hvac_mode', 'set_fan_mode', 'set_humidity'] as const; + async function main() { const hass = await get_hass(); // Create MCP server - const server = new ModelContextProtocolServer({ - port: process.env.PORT ? parseInt(process.env.PORT) : 3000, - models: [{ - name: 'home-assistant', - description: 'Control Home Assistant devices and services', - parameters: zodToJsonSchema(z.object({ - command: z.string().describe('The command to execute'), - entity_id: z.string().optional().describe('The entity ID to control') - })), - handler: async (params: CommandParams) => { - // Implement your command handling logic here - // You can use the hass instance to interact with Home Assistant + const server = new LiteMCP('home-assistant', '0.1.0'); + + // Add the Home Assistant control tool + server.addTool({ + name: 'control', + description: 'Control Home Assistant devices and services', + parameters: z.object({ + command: z.enum([...commonCommands, ...coverCommands, ...climateCommands]) + .describe('The command to execute'), + entity_id: z.string().describe('The entity ID to control'), + // Common parameters + state: z.string().optional().describe('The desired state for the entity'), + // Light parameters + brightness: z.number().min(0).max(255).optional() + .describe('Brightness level for lights (0-255)'), + color_temp: z.number().optional() + .describe('Color temperature for lights'), + rgb_color: z.tuple([z.number(), z.number(), z.number()]).optional() + .describe('RGB color values'), + // Cover parameters + position: z.number().min(0).max(100).optional() + .describe('Position for covers (0-100)'), + tilt_position: z.number().min(0).max(100).optional() + .describe('Tilt position for covers (0-100)'), + // Climate parameters + temperature: z.number().optional() + .describe('Target temperature for climate devices'), + target_temp_high: z.number().optional() + .describe('Target high temperature for climate devices'), + target_temp_low: z.number().optional() + .describe('Target low temperature for climate devices'), + hvac_mode: z.enum(['off', 'heat', 'cool', 'heat_cool', 'auto', 'dry', 'fan_only']).optional() + .describe('HVAC mode for climate devices'), + fan_mode: z.enum(['auto', 'low', 'medium', 'high']).optional() + .describe('Fan mode for climate devices'), + humidity: z.number().min(0).max(100).optional() + .describe('Target humidity for climate devices') + }), + execute: async (params: CommandParams) => { + try { + const domain = params.entity_id.split('.')[0] as keyof typeof DomainSchema.Values; + + if (!Object.values(DomainSchema.Values).includes(domain)) { + throw new Error(`Unsupported domain: ${domain}`); + } + + const service = params.command; + const serviceData: Record = { + entity_id: params.entity_id + }; + + // Handle domain-specific parameters + switch (domain) { + case 'light': + if (params.brightness !== undefined) { + serviceData.brightness = params.brightness; + } + if (params.color_temp !== undefined) { + serviceData.color_temp = params.color_temp; + } + if (params.rgb_color !== undefined) { + serviceData.rgb_color = params.rgb_color; + } + break; + + case 'cover': + if (service === 'set_position' && params.position !== undefined) { + serviceData.position = params.position; + } + if (service === 'set_tilt_position' && params.tilt_position !== undefined) { + serviceData.tilt_position = params.tilt_position; + } + break; + + case 'climate': + if (service === 'set_temperature') { + if (params.temperature !== undefined) { + serviceData.temperature = params.temperature; + } + if (params.target_temp_high !== undefined) { + serviceData.target_temp_high = params.target_temp_high; + } + if (params.target_temp_low !== undefined) { + serviceData.target_temp_low = params.target_temp_low; + } + } + if (service === 'set_hvac_mode' && params.hvac_mode !== undefined) { + serviceData.hvac_mode = params.hvac_mode; + } + if (service === 'set_fan_mode' && params.fan_mode !== undefined) { + serviceData.fan_mode = params.fan_mode; + } + if (service === 'set_humidity' && params.humidity !== undefined) { + serviceData.humidity = params.humidity; + } + break; + + case 'switch': + case 'contact': + // These domains only support basic operations (turn_on, turn_off, toggle) + break; + + default: + throw new Error(`Unsupported operation for domain: ${domain}`); + } + // Call Home Assistant service + try { + await hass.services[domain][service](serviceData); + } catch (error) { + throw new Error(`Failed to execute ${service} for ${params.entity_id}: ${error instanceof Error ? error.message : 'Unknown error occurred'}`); + } return { success: true, - message: 'Command executed successfully' + message: `Successfully executed ${service} for ${params.entity_id}` + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' }; } - }] + } }); // Start the server await server.start(); - console.log('MCP Server started on port', server.port); + console.log('MCP Server started'); } main().catch(console.error); \ No newline at end of file diff --git a/src/schemas.ts b/src/schemas.ts index 40789ee..e96ca78 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; -export const DomainSchema = z.enum(["light", "climate", "alarm_control_panel", "cover", "switch"]); +export const DomainSchema = z.enum(["light", "climate", "alarm_control_panel", "cover", "switch", "contact"]); // Generic list request schema @@ -53,30 +53,30 @@ export const ListAlarmsResponseSchema = z.object({ // Devices export const DeviceSchema = z.object({ - id: z.string(), - name: z.string(), - name_by_user: z.string().optional(), - model: z.string(), - model_id: z.string().nullable(), - manufacturer: z.string(), - area_id: z.string().nullable(), - config_entries: z.array(z.string()), - primary_config_entry: z.string(), - connections: z.array(z.tuple([z.string(), z.string()])), - configuration_url: z.string().nullable(), - disabled_by: z.string().nullable(), - entry_type: z.string().nullable(), - hw_version: z.string().nullable(), - sw_version: z.string().nullable(), - via_device_id: z.string().nullable(), - created_at: z.number(), - modified_at: z.number(), - identifiers: z.array(z.any()), - labels: z.array(z.string()), - serial_number: z.string().optional() + id: z.string(), + name: z.string(), + name_by_user: z.string().optional(), + model: z.string(), + model_id: z.string().nullable(), + manufacturer: z.string(), + area_id: z.string().nullable(), + config_entries: z.array(z.string()), + primary_config_entry: z.string(), + connections: z.array(z.tuple([z.string(), z.string()])), + configuration_url: z.string().nullable(), + disabled_by: z.string().nullable(), + entry_type: z.string().nullable(), + hw_version: z.string().nullable(), + sw_version: z.string().nullable(), + via_device_id: z.string().nullable(), + created_at: z.number(), + modified_at: z.number(), + identifiers: z.array(z.any()), + labels: z.array(z.string()), + serial_number: z.string().optional() }); export const ListDevicesResponseSchema = z.object({ - _meta: z.object({}).optional(), - devices: z.array(DeviceSchema) + _meta: z.object({}).optional(), + devices: z.array(DeviceSchema) }); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1a13ca0..b0bdb59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", @@ -9,7 +9,8 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true + "declaration": true, + "allowJs": true }, "include": [ "src/**/*"