diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e9ae029 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules +npm-debug.log + +# Build outputs +dist + +# Environment and config +.env +.env.* + +# Version control +.git +.gitignore + +# IDE +.vscode +.idea + +# Testing +coverage +__tests__ + +# Logs +*.log + +# Documentation +README.md +LICENSE +CHANGELOG.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2e337bb..73fbe2c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ wheels/ *.egg-info/ .installed.cfg *.egg - +.cursorrules # Environment variables .env .env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5b3ca2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Build stage +FROM node:20.10.0-alpine AS builder + +WORKDIR /app + +# Install TypeScript globally +RUN npm install -g typescript + +# Copy source files first +COPY . . + +# Install all dependencies (including dev dependencies) +RUN npm install + +# Build the project +RUN npm run build + +# Production stage +FROM node:20.10.0-alpine + +WORKDIR /app + +# Set Node options for better compatibility +ENV NODE_OPTIONS="--experimental-modules" +ENV NODE_ENV="production" + +# Copy package files and install production dependencies +COPY package*.json ./ +RUN npm install --omit=dev --ignore-scripts + +# Copy built files from builder stage +COPY --from=builder /app/dist ./dist + +# Expose default port +EXPOSE 3000 + +# Start the server +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/README.md b/README.md index c748795..fa8db1a 100644 --- a/README.md +++ b/README.md @@ -16,23 +16,99 @@ The server uses the MCP protocol to share access to a local Home Assistant insta ## Prerequisites -- Node.js 20.10.0 or higher +- Node.js 20.10.0 or higher (Required for Array.prototype.toSorted()) - NPM package manager - A running Home Assistant instance - A long-lived access token from Home Assistant +### Node.js Version Management + +If you're using an older version of Node.js, you can use `nvm` (Node Version Manager) to install and use the correct version: + +```bash +# Install nvm (if not already installed) +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash + +# Reload shell configuration +source ~/.bashrc # or source ~/.zshrc for Zsh + +# Install Node.js 20.10.0 +nvm install 20.10.0 + +# Use Node.js 20.10.0 +nvm use 20.10.0 +``` + ## Installation +### Classic Installation + ```bash # Clone the repository git clone https://github.com/jango-blockchained/homeassistant-mcp.git cd homeassistant-mcp # Install dependencies -yarn install +npm install # Build the server -yarn build +npm run build +``` + +### Docker Installation + +#### Using Docker Compose (Recommended) + +1. Clone the repository: + ```bash + git clone https://github.com/jango-blockchained/homeassistant-mcp.git + cd homeassistant-mcp + ``` + +2. Create a `.env` file with your Home Assistant configuration: + ```env + NODE_ENV=production + HASS_HOST=your_home_assistant_url + HASS_TOKEN=your_home_assistant_token + ``` + +3. Start the container: + ```bash + docker-compose up -d + ``` + +#### Using Docker Directly + +1. Build the image: + ```bash + docker build -t homeassistant-mcp . + ``` + +2. Run the container: + ```bash + docker run -d \ + --name homeassistant-mcp \ + -e HASS_HOST=your_home_assistant_url \ + -e HASS_TOKEN=your_home_assistant_token \ + -p 3000:3000 \ + homeassistant-mcp + ``` + +### Docker Management Commands + +```bash +# Stop the container +docker-compose down + +# View logs +docker-compose logs -f + +# Restart the container +docker-compose restart + +# Update to latest version +git pull +docker-compose up -d --build ``` ## Configuration @@ -284,20 +360,16 @@ yarn test ### Common Issues -1. **Connection Errors** +1. **Node.js Version Error (`positive.toSorted is not a function`)** + - This error occurs when using Node.js version lower than 20 + - Solution: Update to Node.js 20.10.0 or higher using nvm (see Prerequisites section) + - Docker users: The container automatically uses the correct Node.js version + +2. **Connection Errors** - Verify your Home Assistant instance is running - Check the HASS_HOST 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 diff --git a/src/config/hass.config.ts b/src/config/hass.config.ts new file mode 100644 index 0000000..43ef4fe --- /dev/null +++ b/src/config/hass.config.ts @@ -0,0 +1,6 @@ +export const HASS_CONFIG = { + BASE_URL: process.env.HASS_HOST || 'http://192.168.178.63:8123', + TOKEN: process.env.HASS_TOKEN, + SOCKET_URL: process.env.HASS_HOST || 'http://192.168.178.63:8123', + SOCKET_TOKEN: process.env.HASS_TOKEN, +}; \ No newline at end of file diff --git a/src/hass/index.ts b/src/hass/index.ts index f70bbec..5e03f07 100644 --- a/src/hass/index.ts +++ b/src/hass/index.ts @@ -1,6 +1,7 @@ import { CreateApplication, TServiceParams, StringConfig } from "@digital-alchemy/core"; import { LIB_HASS, PICK_ENTITY } from "@digital-alchemy/hass"; import { DomainSchema } from "../schemas.js"; +import { HASS_CONFIG } from "../config/hass.config.js"; type Environments = "development" | "production" | "test"; @@ -25,19 +26,39 @@ const MY_APP = CreateApplication({ enum: ["development", "production", "test"], description: "Code runner addon can set with it's own NODE_ENV", } satisfies StringConfig, - HASS_HOST: { - type: "string", - description: "Home Assistant host URL", - required: true - }, - HASS_TOKEN: { - type: "string", - description: "Home Assistant long-lived access token", - required: true - } }, services: {}, - libraries: [LIB_HASS], + libraries: [ + { + ...LIB_HASS, + configuration: { + BASE_URL: { + type: "string", + description: "Home Assistant base URL", + required: true, + default: HASS_CONFIG.BASE_URL + }, + TOKEN: { + type: "string", + description: "Home Assistant long-lived access token", + required: true, + default: HASS_CONFIG.TOKEN + }, + SOCKET_URL: { + type: "string", + description: "Home Assistant WebSocket URL", + required: true, + default: HASS_CONFIG.SOCKET_URL + }, + SOCKET_TOKEN: { + type: "string", + description: "Home Assistant WebSocket token", + required: true, + default: HASS_CONFIG.SOCKET_TOKEN + } + } + } + ], name: 'hass' as const }); diff --git a/src/index.ts b/src/index.ts index 89543f9..1652908 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,10 @@ import { LiteMCP } from 'litemcp'; import { z } from 'zod'; import { DomainSchema } from './schemas.js'; +// Configuration +const HASS_HOST = process.env.HASS_HOST || 'http://192.168.178.63:8123'; +const HASS_TOKEN = process.env.HASS_TOKEN; + interface CommandParams { command: string; entity_id: string; @@ -41,9 +45,9 @@ async function main() { parameters: z.object({}), execute: async () => { try { - const response = await fetch(`${process.env.HASS_HOST}/api/states`, { + const response = await fetch(`${HASS_HOST}/api/states`, { headers: { - Authorization: `Bearer ${process.env.HASS_TOKEN}`, + Authorization: `Bearer ${HASS_TOKEN}`, 'Content-Type': 'application/json', }, }); @@ -185,15 +189,26 @@ async function main() { // Call Home Assistant service try { - await hass.services[domain][service](serviceData); + const response = await fetch(`${HASS_HOST}/api/services/${domain}/${service}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${HASS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(serviceData), + }); + + if (!response.ok) { + throw new Error(`Failed to execute ${service} for ${params.entity_id}: ${response.statusText}`); + } + + return { + success: true, + message: `Successfully executed ${service} for ${params.entity_id}` + }; } 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: `Successfully executed ${service} for ${params.entity_id}` - }; } catch (error) { return { success: false, diff --git a/tsconfig.json b/tsconfig.json index b0bdb59..6b92f23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,14 +9,16 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, "declaration": true, - "allowJs": true + "sourceMap": true }, "include": [ "src/**/*" ], "exclude": [ "node_modules", - "dist" + "dist", + "__tests__" ] } \ No newline at end of file