├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .github └── workflows │ ├── deploy-docs.yml │ └── docker-build-push.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── PUBLISHING.md ├── README.md ├── __tests__ ├── ai │ ├── endpoints │ │ └── ai-router.test.ts │ └── nlp │ │ └── intent-classifier.test.ts ├── api │ └── index.test.ts ├── context │ ├── context.test.ts │ └── index.test.ts ├── core │ └── server.test.ts ├── hass │ ├── api.test.ts │ ├── hass.test.ts │ └── index.test.ts ├── helpers.test.ts ├── index.test.ts ├── schemas │ ├── devices.test.js │ ├── devices.test.js.map │ ├── devices.test.ts │ └── hass.test.ts ├── security │ ├── index.test.ts │ ├── middleware.test.ts │ └── token-manager.test.ts ├── server.test.ts ├── speech │ └── speechToText.test.ts ├── tools │ ├── automation-config.test.ts │ ├── automation.test.ts │ ├── device-control.test.ts │ ├── entity-state.test.ts │ ├── scene-control.test.ts │ ├── script-control.test.ts │ └── tool-registry.test.ts ├── types │ └── litemcp.d.ts ├── utils │ └── test-utils.ts └── websocket │ ├── client.test.ts │ └── events.test.ts ├── bin ├── mcp-stdio.cjs ├── mcp-stdio.js ├── npx-entry.cjs └── test-stdio.js ├── bun.lock ├── bunfig.toml ├── docker-build.sh ├── docker-compose.speech.yml ├── docker └── speech │ ├── asound.conf │ ├── setup-audio.sh │ └── wake_word_detector.py ├── extra ├── README.md ├── claude-desktop-macos-setup.sh ├── ha-analyzer-cli.ts └── speech-to-text-example.ts ├── fix-env.js ├── package.json ├── scripts ├── setup-env.sh └── setup.sh ├── search └── scripts │ └── start_mcp.cmd ├── smithery.yaml ├── src ├── .gitignore ├── __mocks__ │ ├── @digital-alchemy │ │ └── hass.ts │ └── litemcp.ts ├── __tests__ │ ├── config.test.ts │ ├── rate-limit.test.ts │ ├── security.test.ts │ └── setup.ts ├── ai │ ├── endpoints │ │ └── ai-router.ts │ ├── nlp │ │ ├── context-analyzer.ts │ │ ├── entity-extractor.ts │ │ ├── intent-classifier.ts │ │ └── processor.ts │ ├── templates │ │ └── prompt-templates.ts │ └── types │ │ └── index.ts ├── api │ └── routes.ts ├── commands.ts ├── config.js ├── config.ts ├── config │ ├── __tests__ │ │ └── test.config.ts │ ├── app.config.ts │ ├── hass.config.ts │ ├── index.ts │ ├── loadEnv.ts │ └── security.config.ts ├── context │ └── index.ts ├── hass │ ├── index.ts │ └── types.ts ├── health-check.ts ├── index.ts ├── interfaces │ ├── hass.ts │ └── index.ts ├── mcp │ ├── BaseTool.ts │ ├── MCPServer.ts │ ├── index.ts │ ├── litemcp.ts │ ├── middleware │ │ └── index.ts │ ├── schema.ts │ ├── transport.ts │ ├── transports │ │ ├── http.transport.ts │ │ └── stdio.transport.ts │ ├── types.ts │ └── utils │ │ ├── claude.ts │ │ ├── cursor.ts │ │ └── error.ts ├── middleware │ ├── __tests__ │ │ └── security.middleware.test.ts │ ├── index.ts │ ├── logging.middleware.ts │ └── rate-limit.middleware.ts ├── openapi.ts ├── platforms │ └── macos │ │ └── integration.ts ├── polyfills.js ├── routes │ ├── health.routes.ts │ ├── index.ts │ ├── mcp.routes.ts │ ├── sse.routes.ts │ └── tool.routes.ts ├── schemas.ts ├── schemas │ ├── config.schema.ts │ └── hass.ts ├── security │ ├── __tests__ │ │ ├── enhanced-middleware.test.ts │ │ └── security.test.ts │ ├── enhanced-middleware.ts │ ├── index.ts │ └── middleware.test.ts ├── speech │ ├── __tests__ │ │ ├── fixtures │ │ │ └── test.wav │ │ └── speechToText.test.ts │ ├── index.ts │ ├── speechToText.ts │ ├── types.ts │ └── wakeWordDetector.ts ├── sse │ ├── __tests__ │ │ ├── sse.features.test.ts │ │ └── sse.security.test.ts │ ├── index.ts │ └── types.ts ├── stdio-server.ts ├── tools │ ├── addon.tool.ts │ ├── automation-config.tool.ts │ ├── automation.tool.ts │ ├── base-tool.ts │ ├── control.tool.ts │ ├── example.tool.ts │ ├── examples │ │ ├── stream-generator.tool.ts │ │ └── validation-demo.tool.ts │ ├── history.tool.ts │ ├── homeassistant │ │ ├── climate.tool.ts │ │ └── lights.tool.ts │ ├── index.ts │ ├── list-devices.tool.ts │ ├── notify.tool.ts │ ├── package.tool.ts │ ├── scene.tool.ts │ ├── sse-stats.tool.ts │ └── subscribe-events.tool.ts ├── types │ ├── bun.d.ts │ ├── hass.d.ts │ ├── hass.ts │ ├── index.ts │ └── node-record-lpcm16.d.ts ├── utils │ ├── helpers.ts │ ├── log-rotation.ts │ ├── logger.ts │ └── stdio-transport.ts └── websocket │ └── client.ts ├── start.sh ├── stdio-start.sh ├── test.wav ├── test └── setup.ts ├── tsconfig.json ├── tsconfig.stdio.json └── tsconfig.test.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | .pnpm-debug.log* 7 | package-lock.json 8 | yarn.lock 9 | pnpm-lock.yaml 10 | 11 | # Build output 12 | dist/ 13 | build/ 14 | *.egg-info/ 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | *.so 19 | 20 | # Environment files 21 | .env 22 | .env.* 23 | !.env.example 24 | venv/ 25 | ENV/ 26 | env/ 27 | 28 | # Version control 29 | .git/ 30 | .gitignore 31 | .gitattributes 32 | 33 | # IDE and editor files 34 | .idea/ 35 | .vscode/ 36 | *.swp 37 | *.swo 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # Test files 42 | coverage/ 43 | __tests__/ 44 | jest.config.* 45 | *.test.ts 46 | .nyc_output 47 | 48 | # Logs 49 | logs/ 50 | *.log 51 | 52 | # Documentation 53 | *.md 54 | docs/ 55 | CHANGELOG 56 | LICENSE 57 | 58 | # Docker 59 | docker-compose*.yml 60 | docker-compose*.yaml 61 | Dockerfile* 62 | 63 | # Temporary files 64 | tmp/ 65 | temp/ 66 | .tmp/ 67 | *.tmp 68 | 69 | # Misc 70 | .cursorrules 71 | .mypy_cache/ 72 | .storage/ 73 | .cloud/ 74 | *.db 75 | *.db-* 76 | .cursor/ 77 | .cursor* 78 | .cursorconfig -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | NODE_ENV=development 3 | PORT=7123 4 | DEBUG=false 5 | LOG_LEVEL=info 6 | MCP_SERVER=http://localhost:7123 7 | USE_STDIO_TRANSPORT=true 8 | 9 | # Home Assistant Configuration 10 | HASS_HOST=http://homeassistant.local:8123 11 | HASS_TOKEN=your_long_lived_token 12 | HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket 13 | 14 | # Security Configuration 15 | JWT_SECRET=your_jwt_secret_key_min_32_chars 16 | JWT_EXPIRY=86400000 17 | JWT_MAX_AGE=2592000000 18 | JWT_ALGORITHM=HS256 19 | 20 | # Rate Limiting 21 | RATE_LIMIT_WINDOW=900000 22 | RATE_LIMIT_MAX_REQUESTS=100 23 | RATE_LIMIT_MAX_AUTH_REQUESTS=5 24 | RATE_LIMIT_REGULAR=100 25 | RATE_LIMIT_WEBSOCKET=1000 26 | 27 | # CORS Configuration 28 | CORS_ORIGINS=http://localhost:3000,http://localhost:8123,http://homeassistant.local:8123 29 | CORS_METHODS=GET,POST,PUT,DELETE,OPTIONS 30 | CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With 31 | CORS_EXPOSED_HEADERS= 32 | CORS_CREDENTIALS=true 33 | CORS_MAX_AGE=86400 34 | 35 | # Cookie Security 36 | COOKIE_SECRET=your_cookie_secret_key_min_32_chars 37 | COOKIE_SECURE=true 38 | COOKIE_HTTP_ONLY=true 39 | COOKIE_SAME_SITE=Strict 40 | 41 | # Request Limits 42 | MAX_REQUEST_SIZE=1048576 43 | MAX_REQUEST_FIELDS=1000 44 | 45 | # AI Configuration 46 | PROCESSOR_TYPE=openai 47 | OPENAI_API_KEY=your_openai_api_key 48 | OPENAI_MODEL=gpt-3.5-turbo 49 | MAX_RETRIES=3 50 | ANALYSIS_TIMEOUT=30000 51 | 52 | # Speech Features Configuration 53 | ENABLE_SPEECH_FEATURES=false 54 | ENABLE_WAKE_WORD=false 55 | ENABLE_SPEECH_TO_TEXT=false 56 | WHISPER_MODEL_PATH=/models 57 | WHISPER_MODEL_TYPE=base 58 | 59 | # Audio Configuration 60 | NOISE_THRESHOLD=0.05 61 | MIN_SPEECH_DURATION=1.0 62 | SILENCE_DURATION=0.5 63 | SAMPLE_RATE=16000 64 | CHANNELS=1 65 | CHUNK_SIZE=1024 66 | PULSE_SERVER=unix:/run/user/1000/pulse/native 67 | 68 | # Whisper Configuration 69 | ASR_MODEL=base 70 | ASR_ENGINE=faster_whisper 71 | WHISPER_BEAM_SIZE=5 72 | COMPUTE_TYPE=float32 73 | LANGUAGE=en 74 | 75 | # SSE Configuration 76 | SSE_MAX_CLIENTS=50 77 | SSE_RECONNECT_TIMEOUT=5000 78 | 79 | # Development Flags 80 | HOT_RELOAD=true 81 | 82 | # Test Configuration (only needed for running tests) 83 | TEST_HASS_HOST=http://homeassistant.local:8123 84 | TEST_HASS_TOKEN=test_token 85 | TEST_HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket 86 | TEST_PORT=3001 87 | 88 | # Version 89 | VERSION=0.1.0 90 | 91 | # Docker Configuration 92 | COMPOSE_PROJECT_NAME=mcp 93 | 94 | # Resource Limits 95 | FAST_WHISPER_CPU_LIMIT=4.0 96 | FAST_WHISPER_MEMORY_LIMIT=2G 97 | MCP_CPU_LIMIT=1.0 98 | MCP_MEMORY_LIMIT=512M -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 11 | ], 12 | "parserOptions": { 13 | "project": "./tsconfig.json", 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "no-console": "warn", 19 | "@typescript-eslint/explicit-function-return-type": "warn", 20 | "@typescript-eslint/no-explicit-any": "warn", 21 | "@typescript-eslint/strict-boolean-expressions": "warn", 22 | "no-unused-vars": "off", 23 | "@typescript-eslint/no-unused-vars": [ 24 | "warn", 25 | { 26 | "argsIgnorePattern": "^_", 27 | "varsIgnorePattern": "^_" 28 | } 29 | ], 30 | "@typescript-eslint/no-misused-promises": [ 31 | "warn", 32 | { 33 | "checksVoidReturn": { 34 | "attributes": false 35 | } 36 | } 37 | ], 38 | "@typescript-eslint/no-unsafe-assignment": "warn", 39 | "@typescript-eslint/no-unsafe-member-access": "warn", 40 | "@typescript-eslint/no-unsafe-call": "warn", 41 | "@typescript-eslint/no-unsafe-return": "warn" 42 | }, 43 | "ignorePatterns": [ 44 | "dist/", 45 | "node_modules/", 46 | "*.js", 47 | "*.d.ts" 48 | ] 49 | } -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'docs/**' 9 | - 'mkdocs.yml' 10 | # Allow manual trigger 11 | workflow_dispatch: 12 | 13 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 32 | 33 | - name: Setup Python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: '3.x' 37 | cache: 'pip' 38 | 39 | - name: Setup Pages 40 | uses: actions/configure-pages@v4 41 | 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install -r docs/requirements.txt 46 | 47 | - name: List mkdocs configuration 48 | run: | 49 | echo "Current directory contents:" 50 | ls -la 51 | echo "MkDocs version:" 52 | mkdocs --version 53 | echo "MkDocs configuration:" 54 | cat mkdocs.yml 55 | 56 | - name: Build documentation 57 | run: | 58 | mkdocs build --strict 59 | echo "Build output contents:" 60 | ls -la site/advanced-homeassistant-mcp 61 | 62 | - name: Upload artifact 63 | uses: actions/upload-pages-artifact@v3 64 | with: 65 | path: ./site/advanced-homeassistant-mcp 66 | 67 | deploy: 68 | environment: 69 | name: github-pages 70 | url: ${{ steps.deployment.outputs.page_url }} 71 | needs: build 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Deploy to GitHub Pages 75 | id: deployment 76 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | tags: 7 | - 'v*.*.*' # Triggers on version tags like v1.0.0 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | packages: write 19 | pull-requests: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 # Required for version detection 26 | 27 | - name: Bump version and push tag 28 | id: tag_version 29 | uses: mathieudutour/github-tag-action@v6.1 30 | with: 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | default_bump: patch 33 | 34 | - name: Create Release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | tag_name: ${{ steps.tag_version.outputs.new_tag }} 40 | release_name: Release ${{ steps.tag_version.outputs.new_tag }} 41 | body: ${{ steps.tag_version.outputs.changelog }} 42 | 43 | - name: Log in to the Container registry 44 | uses: docker/login-action@v3 45 | with: 46 | registry: ${{ env.REGISTRY }} 47 | username: ${{ github.actor }} 48 | password: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | - name: Extract metadata (tags, labels) for Docker 51 | id: meta 52 | uses: docker/metadata-action@v5 53 | with: 54 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 55 | tags: | 56 | type=raw,value=${{ steps.tag_version.outputs.new_tag }} 57 | type=raw,value=latest 58 | 59 | - name: Build and push Docker image 60 | uses: docker/build-push-action@v5 61 | with: 62 | context: . 63 | push: true 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | dist 4 | __pycache__/ 5 | .mypy_cache/ 6 | *.py[cod] 7 | *$py.class 8 | *.so 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | downloads/ 13 | eggs/ 14 | .eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .cursorrules 25 | # Environment variables 26 | .env 27 | .env.local 28 | .env.development 29 | .env.production 30 | .env.test 31 | venv/ 32 | ENV/ 33 | env/ 34 | .venv/ 35 | # Logs 36 | logs 37 | *.log 38 | npm-debug.log* 39 | yarn-debug.log* 40 | yarn-error.log* 41 | 42 | # Docker 43 | docker-compose.yml 44 | docker-compose.yaml 45 | docker-compose.override.yml 46 | docker-compose.override.yaml 47 | 48 | # IDEs and editors 49 | .idea/ 50 | .vscode/ 51 | *.swp 52 | *.swo 53 | .DS_Store 54 | Thumbs.db 55 | 56 | # Home Assistant 57 | .storage 58 | .cloud 59 | .google.token 60 | home-assistant.log* 61 | home-assistant_v2.db 62 | home-assistant_v2.db-* 63 | . 64 | 65 | package-lock.json 66 | yarn.lock 67 | pnpm-lock.yaml 68 | 69 | coverage/* 70 | coverage/ 71 | # Environment files 72 | .env 73 | .env.* 74 | !.env.example 75 | 76 | .cursor/ 77 | .cursor/* 78 | 79 | .bun/ 80 | .cursorconfig 81 | bun.lockb 82 | 83 | # MkDocs 84 | site/ 85 | .site/ 86 | 87 | # Python 88 | __pycache__/ 89 | *.py[cod] 90 | *$py.class 91 | 92 | models/ 93 | 94 | *.code-workspace 95 | *.ttf 96 | *.otf 97 | *.woff 98 | *.woff2 99 | *.eot 100 | *.svg 101 | *.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js as base for building 2 | FROM node:20-slim as builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Install bun with the latest version 8 | RUN npm install -g bun@1.0.35 9 | 10 | # Install Python and other dependencies 11 | RUN apt-get update && apt-get install -y --no-install-recommends \ 12 | python3 \ 13 | python3-pip \ 14 | python3-venv \ 15 | build-essential \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # Create and activate virtual environment 19 | RUN python3 -m venv /opt/venv 20 | ENV PATH="/opt/venv/bin:$PATH" 21 | ENV VIRTUAL_ENV="/opt/venv" 22 | 23 | # Upgrade pip in virtual environment 24 | RUN /opt/venv/bin/python -m pip install --upgrade pip 25 | 26 | # Install Python packages in virtual environment 27 | RUN /opt/venv/bin/python -m pip install --no-cache-dir numpy scipy 28 | 29 | # Copy package.json and install dependencies 30 | COPY package.json ./ 31 | RUN bun install --frozen-lockfile || bun install 32 | 33 | # Copy source files and build 34 | COPY src ./src 35 | COPY tsconfig*.json ./ 36 | RUN bun build ./src/index.ts --target=bun --minify --outdir=./dist 37 | 38 | # Create a smaller production image 39 | FROM node:20-slim as runner 40 | 41 | # Install bun in production image with the latest version 42 | RUN npm install -g bun@1.0.35 43 | 44 | # Install system dependencies 45 | RUN apt-get update && apt-get install -y --no-install-recommends \ 46 | curl \ 47 | python3 \ 48 | python3-pip \ 49 | python3-venv \ 50 | alsa-utils \ 51 | pulseaudio \ 52 | && rm -rf /var/lib/apt/lists/* 53 | 54 | # Configure ALSA 55 | COPY docker/speech/asound.conf /etc/asound.conf 56 | 57 | # Create and activate virtual environment 58 | RUN python3 -m venv /opt/venv 59 | ENV PATH="/opt/venv/bin:$PATH" 60 | ENV VIRTUAL_ENV="/opt/venv" 61 | 62 | # Upgrade pip in virtual environment 63 | RUN /opt/venv/bin/python -m pip install --upgrade pip 64 | 65 | # Install Python packages in virtual environment 66 | RUN /opt/venv/bin/python -m pip install --no-cache-dir numpy scipy 67 | 68 | # Create a non-root user and add to audio group 69 | RUN addgroup --system --gid 1001 nodejs && \ 70 | adduser --system --uid 1001 --gid 1001 bunjs && \ 71 | adduser bunjs audio 72 | 73 | WORKDIR /app 74 | 75 | # Copy Python virtual environment from builder 76 | COPY --from=builder --chown=bunjs:nodejs /opt/venv /opt/venv 77 | 78 | # Copy source files 79 | COPY --chown=bunjs:nodejs . . 80 | 81 | # Copy only the necessary files from builder 82 | COPY --from=builder --chown=bunjs:nodejs /app/dist ./dist 83 | COPY --from=builder --chown=bunjs:nodejs /app/node_modules ./node_modules 84 | 85 | # Ensure audio setup script is executable 86 | RUN chmod +x /app/docker/speech/setup-audio.sh 87 | 88 | # Create logs and audio directories with proper permissions 89 | RUN mkdir -p /app/logs /app/audio && chown -R bunjs:nodejs /app/logs /app/audio 90 | 91 | # Switch to non-root user 92 | USER bunjs 93 | 94 | # Health check 95 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 96 | CMD curl -f http://localhost:4000/health || exit 1 97 | 98 | # Expose port 99 | EXPOSE ${PORT:-4000} 100 | 101 | # Start the application with audio setup 102 | CMD ["/bin/bash", "-c", "/app/docker/speech/setup-audio.sh || echo 'Audio setup failed, continuing anyway' && bun --smol run fix-env.js"] -------------------------------------------------------------------------------- /PUBLISHING.md: -------------------------------------------------------------------------------- 1 | # Publishing to npm 2 | 3 | This document outlines the steps to publish the Home Assistant MCP server to npm. 4 | 5 | ## Prerequisites 6 | 7 | 1. You need an npm account. Create one at [npmjs.com](https://www.npmjs.com/signup) if you don't have one. 8 | 2. You need to be logged in to npm on your local machine: 9 | ```bash 10 | npm login 11 | ``` 12 | 3. You need to have all the necessary dependencies installed: 13 | ```bash 14 | npm install 15 | ``` 16 | 17 | ## Before Publishing 18 | 19 | 1. Make sure all tests pass: 20 | ```bash 21 | npm test 22 | ``` 23 | 24 | 2. Build all the necessary files: 25 | ```bash 26 | npm run build # Build for Bun 27 | npm run build:node # Build for Node.js 28 | npm run build:stdio # Build the stdio server 29 | ``` 30 | 31 | 3. Update the version number in `package.json` following [semantic versioning](https://semver.org/): 32 | - MAJOR version for incompatible API changes 33 | - MINOR version for new functionality in a backward-compatible manner 34 | - PATCH version for backward-compatible bug fixes 35 | 36 | 4. Update the CHANGELOG.md file with the changes in the new version. 37 | 38 | ## Publishing 39 | 40 | 1. Publish to npm: 41 | ```bash 42 | npm publish 43 | ``` 44 | 45 | If you want to publish a beta version: 46 | ```bash 47 | npm publish --tag beta 48 | ``` 49 | 50 | 2. Verify the package is published: 51 | ```bash 52 | npm view homeassistant-mcp 53 | ``` 54 | 55 | ## After Publishing 56 | 57 | 1. Create a git tag for the version: 58 | ```bash 59 | git tag -a v1.0.0 -m "Version 1.0.0" 60 | git push origin v1.0.0 61 | ``` 62 | 63 | 2. Create a GitHub release with the same version number and include the changelog. 64 | 65 | ## Testing the Published Package 66 | 67 | To test the published package: 68 | 69 | ```bash 70 | # Install globally 71 | npm install -g homeassistant-mcp 72 | 73 | # Run the MCP server 74 | homeassistant-mcp 75 | 76 | # Or use npx without installing 77 | npx homeassistant-mcp 78 | ``` 79 | 80 | ## Unpublishing 81 | 82 | If you need to unpublish a version (only possible within 72 hours of publishing): 83 | 84 | ```bash 85 | npm unpublish homeassistant-mcp@1.0.0 86 | ``` 87 | 88 | ## Publishing a New Version 89 | 90 | 1. Update the version in package.json 91 | 2. Update CHANGELOG.md 92 | 3. Build all files 93 | 4. Run tests 94 | 5. Publish to npm 95 | 6. Create a git tag 96 | 7. Create a GitHub release -------------------------------------------------------------------------------- /__tests__/context/context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { jest, describe, beforeEach, it, expect } from '@jest/globals'; 3 | import { z } from 'zod'; 4 | import { DomainSchema } from '../../src/schemas.js'; 5 | 6 | type MockResponse = { success: boolean }; 7 | 8 | // Define types for tool and server 9 | interface Tool { 10 | name: string; 11 | description: string; 12 | execute: (params: any) => Promise; 13 | parameters: z.ZodType; 14 | } 15 | 16 | interface MockService { 17 | [key: string]: jest.Mock>; 18 | } 19 | 20 | interface MockServices { 21 | light: { 22 | turn_on: jest.Mock>; 23 | turn_off: jest.Mock>; 24 | }; 25 | climate: { 26 | set_temperature: jest.Mock>; 27 | }; 28 | } 29 | 30 | interface MockHassInstance { 31 | services: MockServices; 32 | } 33 | 34 | // Mock LiteMCP class 35 | class MockLiteMCP { 36 | private tools: Tool[] = []; 37 | 38 | constructor(public name: string, public version: string) { } 39 | 40 | addTool(tool: Tool) { 41 | this.tools.push(tool); 42 | } 43 | 44 | getTools() { 45 | return this.tools; 46 | } 47 | } 48 | 49 | const createMockFn = (): jest.Mock> => { 50 | return jest.fn<() => Promise>().mockResolvedValue({ success: true }); 51 | }; 52 | 53 | // Mock the Home Assistant instance 54 | const mockHassServices: MockHassInstance = { 55 | services: { 56 | light: { 57 | turn_on: createMockFn(), 58 | turn_off: createMockFn(), 59 | }, 60 | climate: { 61 | set_temperature: createMockFn(), 62 | }, 63 | }, 64 | }; 65 | 66 | // Mock get_hass function 67 | const get_hass = jest.fn<() => Promise>().mockResolvedValue(mockHassServices); 68 | 69 | describe('Context Tests', () => { 70 | let mockTool: Tool; 71 | 72 | beforeEach(() => { 73 | mockTool = { 74 | name: 'test_tool', 75 | description: 'A test tool', 76 | execute: jest.fn<(params: any) => Promise>().mockResolvedValue({ success: true }), 77 | parameters: z.object({ 78 | test: z.string() 79 | }) 80 | }; 81 | }); 82 | 83 | // Add your test cases here 84 | test('should execute tool successfully', async () => { 85 | const result = await mockTool.execute({ test: 'value' }); 86 | expect(result.success).toBe(true); 87 | }); 88 | }); -------------------------------------------------------------------------------- /__tests__/core/server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; 3 | import { 4 | type MockLiteMCPInstance, 5 | type Tool, 6 | createMockLiteMCPInstance, 7 | createMockServices, 8 | setupTestEnvironment, 9 | cleanupMocks 10 | } from '../utils/test-utils'; 11 | import { resolve } from "path"; 12 | import { config } from "dotenv"; 13 | import { Tool as IndexTool, tools as indexTools } from "../../src/index.js"; 14 | 15 | // Load test environment variables 16 | config({ path: resolve(process.cwd(), '.env.test') }); 17 | 18 | describe('Home Assistant MCP Server', () => { 19 | let liteMcpInstance: MockLiteMCPInstance; 20 | let addToolCalls: Tool[]; 21 | let mocks: ReturnType; 22 | 23 | beforeEach(async () => { 24 | // Setup test environment 25 | mocks = setupTestEnvironment(); 26 | liteMcpInstance = createMockLiteMCPInstance(); 27 | 28 | // Import the module which will execute the main function 29 | await import('../../src/index.js'); 30 | 31 | // Get the mock instance and tool calls 32 | addToolCalls = liteMcpInstance.addTool.mock.calls.map(call => call.args[0]); 33 | }); 34 | 35 | afterEach(() => { 36 | cleanupMocks({ liteMcpInstance, ...mocks }); 37 | }); 38 | 39 | test('should connect to Home Assistant', async () => { 40 | await new Promise(resolve => setTimeout(resolve, 0)); 41 | // Verify connection 42 | expect(mocks.mockFetch.mock.calls.length).toBeGreaterThan(0); 43 | expect(liteMcpInstance.start.mock.calls.length).toBeGreaterThan(0); 44 | }); 45 | 46 | test('should handle connection errors', async () => { 47 | // Setup error response 48 | mocks.mockFetch = mock(() => Promise.reject(new Error('Connection failed'))); 49 | globalThis.fetch = mocks.mockFetch; 50 | 51 | // Import module again with error mock 52 | await import('../../src/index.js'); 53 | 54 | // Verify error handling 55 | expect(mocks.mockFetch.mock.calls.length).toBeGreaterThan(0); 56 | expect(liteMcpInstance.start.mock.calls.length).toBe(0); 57 | }); 58 | 59 | test('should register all required tools', () => { 60 | const toolNames = indexTools.map((tool: IndexTool) => tool.name); 61 | 62 | expect(toolNames).toContain('list_devices'); 63 | expect(toolNames).toContain('control'); 64 | }); 65 | 66 | test('should configure tools with correct parameters', () => { 67 | const listDevicesTool = indexTools.find((tool: IndexTool) => tool.name === 'list_devices'); 68 | expect(listDevicesTool).toBeDefined(); 69 | expect(listDevicesTool?.description).toBe('List all available Home Assistant devices'); 70 | 71 | const controlTool = indexTools.find((tool: IndexTool) => tool.name === 'control'); 72 | expect(controlTool).toBeDefined(); 73 | expect(controlTool?.description).toBe('Control Home Assistant devices and services'); 74 | }); 75 | }); -------------------------------------------------------------------------------- /__tests__/hass/hass.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { jest, describe, beforeEach, afterAll, it, expect } from '@jest/globals'; 3 | import type { Mock } from 'jest-mock'; 4 | 5 | // Define types 6 | interface MockResponse { 7 | success: boolean; 8 | } 9 | 10 | type MockFn = () => Promise; 11 | 12 | interface MockService { 13 | [key: string]: Mock; 14 | } 15 | 16 | interface MockServices { 17 | light: { 18 | turn_on: Mock; 19 | turn_off: Mock; 20 | }; 21 | climate: { 22 | set_temperature: Mock; 23 | }; 24 | } 25 | 26 | interface MockHassInstance { 27 | services: MockServices; 28 | } 29 | 30 | // Mock instance 31 | let mockInstance: MockHassInstance | null = null; 32 | 33 | const createMockFn = (): Mock => { 34 | return jest.fn().mockImplementation(async () => ({ success: true })); 35 | }; 36 | 37 | // Mock the digital-alchemy modules before tests 38 | jest.unstable_mockModule('@digital-alchemy/core', () => ({ 39 | CreateApplication: jest.fn(() => ({ 40 | configuration: {}, 41 | bootstrap: async () => mockInstance, 42 | services: {} 43 | })), 44 | TServiceParams: mock() 45 | })); 46 | 47 | jest.unstable_mockModule('@digital-alchemy/hass', () => ({ 48 | LIB_HASS: { 49 | configuration: {}, 50 | services: {} 51 | } 52 | })); 53 | 54 | describe('Home Assistant Connection', () => { 55 | // Backup the original environment 56 | const originalEnv = { ...process.env }; 57 | 58 | beforeEach(() => { 59 | // Clear all mocks 60 | jest.clearAllMocks(); 61 | // Initialize mock instance 62 | mockInstance = { 63 | services: { 64 | light: { 65 | turn_on: createMockFn(), 66 | turn_off: createMockFn(), 67 | }, 68 | climate: { 69 | set_temperature: createMockFn(), 70 | }, 71 | }, 72 | }; 73 | // Reset environment variables 74 | process.env = { ...originalEnv }; 75 | }); 76 | 77 | afterAll(() => { 78 | // Restore original environment 79 | process.env = originalEnv; 80 | }); 81 | 82 | test('should return a Home Assistant instance with services', async () => { 83 | const { get_hass } = await import('../../src/hass/index.js'); 84 | const hass = await get_hass(); 85 | 86 | expect(hass).toBeDefined(); 87 | expect(hass.services).toBeDefined(); 88 | expect(typeof hass.services.light.turn_on).toBe('function'); 89 | expect(typeof hass.services.light.turn_off).toBe('function'); 90 | expect(typeof hass.services.climate.set_temperature).toBe('function'); 91 | }); 92 | 93 | test('should reuse the same instance on subsequent calls', async () => { 94 | const { get_hass } = await import('../../src/hass/index.js'); 95 | const firstInstance = await get_hass(); 96 | const secondInstance = await get_hass(); 97 | 98 | expect(firstInstance).toBe(secondInstance); 99 | }); 100 | }); -------------------------------------------------------------------------------- /__tests__/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { describe, expect, test } from "bun:test"; 3 | import { formatToolCall } from "../src/utils/helpers"; 4 | 5 | describe('helpers', () => { 6 | describe('formatToolCall', () => { 7 | test('should format an object into the correct structure', () => { 8 | const testObj = { name: 'test', value: 123 }; 9 | const result = formatToolCall(testObj); 10 | 11 | expect(result).toEqual({ 12 | content: [{ 13 | type: 'text', 14 | text: JSON.stringify(testObj, null, 2), 15 | isError: false 16 | }] 17 | }); 18 | }); 19 | 20 | test('should handle error cases correctly', () => { 21 | const testObj = { error: 'test error' }; 22 | const result = formatToolCall(testObj, true); 23 | 24 | expect(result).toEqual({ 25 | content: [{ 26 | type: 'text', 27 | text: JSON.stringify(testObj, null, 2), 28 | isError: true 29 | }] 30 | }); 31 | }); 32 | 33 | test('should handle empty objects', () => { 34 | const testObj = {}; 35 | const result = formatToolCall(testObj); 36 | 37 | expect(result).toEqual({ 38 | content: [{ 39 | type: 'text', 40 | text: '{}', 41 | isError: false 42 | }] 43 | }); 44 | }); 45 | 46 | test('should handle null and undefined', () => { 47 | const nullResult = formatToolCall(null); 48 | const undefinedResult = formatToolCall(undefined); 49 | 50 | expect(nullResult).toEqual({ 51 | content: [{ 52 | type: 'text', 53 | text: 'null', 54 | isError: false 55 | }] 56 | }); 57 | 58 | expect(undefinedResult).toEqual({ 59 | content: [{ 60 | type: 'text', 61 | text: 'undefined', 62 | isError: false 63 | }] 64 | }); 65 | }); 66 | }); 67 | }); -------------------------------------------------------------------------------- /__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; 3 | import type { Mock } from "bun:test"; 4 | import { z } from "zod"; 5 | import type { WebSocket } from 'ws'; 6 | import { tools } from "../src/index.js"; 7 | 8 | // Extend the global scope 9 | declare global { 10 | // eslint-disable-next-line no-var 11 | var mockResponse: Response; 12 | } 13 | 14 | // Test configuration 15 | const TEST_CONFIG = { 16 | HASS_HOST: process.env.TEST_HASS_HOST || 'http://localhost:8123', 17 | HASS_TOKEN: process.env.TEST_HASS_TOKEN || 'test_token', 18 | HASS_SOCKET_URL: process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket' 19 | } as const; 20 | 21 | // Setup test environment 22 | Object.entries(TEST_CONFIG).forEach(([key, value]) => { 23 | process.env[key] = value; 24 | }); 25 | 26 | // Modify mock fetch methods to be consistent 27 | const createMockFetch = (data: T) => { 28 | return mock(() => Promise.resolve({ 29 | ok: true, 30 | json: async () => { 31 | return await Promise.resolve(data); 32 | } 33 | } as Response)); 34 | }; 35 | 36 | let mockFetch = createMockFetch([ 37 | { 38 | entity_id: 'light.living_room', 39 | state: 'on', 40 | attributes: { brightness: 255 } 41 | } 42 | ]); 43 | 44 | describe('Home Assistant MCP Server', () => { 45 | beforeEach(async () => { 46 | // Setup default response 47 | mockFetch = createMockFetch({ state: 'connected' }); 48 | globalThis.fetch = mockFetch; 49 | await Promise.resolve(); 50 | }); 51 | 52 | afterEach(() => { 53 | mockFetch = createMockFetch({}); 54 | }); 55 | 56 | describe('Tool Registration', () => { 57 | test('should register all required tools', () => { 58 | const toolNames = tools.map(tool => tool.name); 59 | 60 | expect(toolNames).toContain('list_devices'); 61 | expect(toolNames).toContain('control'); 62 | }); 63 | 64 | test('should configure tools with correct parameters', () => { 65 | const listDevicesTool = tools.find(tool => tool.name === 'list_devices'); 66 | expect(listDevicesTool).toBeDefined(); 67 | expect(listDevicesTool?.parameters).toBeDefined(); 68 | 69 | const controlTool = tools.find(tool => tool.name === 'control'); 70 | expect(controlTool).toBeDefined(); 71 | expect(controlTool?.parameters).toBeDefined(); 72 | }); 73 | }); 74 | 75 | describe('Tool Execution', () => { 76 | test('should execute list_devices tool', async () => { 77 | const listDevicesTool = tools.find(tool => tool.name === 'list_devices'); 78 | expect(listDevicesTool).toBeDefined(); 79 | 80 | if (listDevicesTool) { 81 | const mockDevices = [ 82 | { 83 | entity_id: 'light.living_room', 84 | state: 'on', 85 | attributes: { brightness: 255 } 86 | } 87 | ]; 88 | 89 | mockFetch = createMockFetch(mockDevices); 90 | globalThis.fetch = mockFetch; 91 | 92 | const result = await listDevicesTool.execute({}); 93 | expect(result.success).toBe(true); 94 | expect(result.devices).toBeDefined(); 95 | } 96 | }); 97 | 98 | test('should execute control tool', async () => { 99 | const controlTool = tools.find(tool => tool.name === 'control'); 100 | expect(controlTool).toBeDefined(); 101 | 102 | if (controlTool) { 103 | mockFetch = createMockFetch({ success: true }); 104 | globalThis.fetch = mockFetch; 105 | 106 | const result = await controlTool.execute({ 107 | command: 'turn_on', 108 | entity_id: 'light.living_room', 109 | brightness: 255 110 | }); 111 | 112 | expect(result.success).toBe(true); 113 | expect(mockFetch.mock.calls.length).toBeGreaterThan(0); 114 | } 115 | }); 116 | }); 117 | }); -------------------------------------------------------------------------------- /__tests__/tools/scene-control.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | -------------------------------------------------------------------------------- /__tests__/types/litemcp.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'litemcp' { 2 | export interface Tool { 3 | name: string; 4 | description: string; 5 | parameters: Record; 6 | execute: (params: Record) => Promise; 7 | } 8 | 9 | export interface LiteMCPOptions { 10 | name: string; 11 | version: string; 12 | } 13 | 14 | export class LiteMCP { 15 | constructor(options: LiteMCPOptions); 16 | addTool(tool: Tool): void; 17 | start(): Promise; 18 | } 19 | } -------------------------------------------------------------------------------- /__tests__/websocket/client.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | -------------------------------------------------------------------------------- /bin/mcp-stdio.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const dotenv = require('dotenv'); 6 | 7 | /** 8 | * MCP Server - Stdio Transport Mode (CommonJS) 9 | * 10 | * This is the CommonJS entry point for running the MCP server via NPX in stdio mode. 11 | * It will directly load the stdio-server.js file which is optimized for the CLI usage. 12 | */ 13 | 14 | // Set environment variable for stdio transport 15 | process.env.USE_STDIO_TRANSPORT = 'true'; 16 | 17 | // Load environment variables from .env file (if exists) 18 | try { 19 | const envPath = path.resolve(process.cwd(), '.env'); 20 | if (fs.existsSync(envPath)) { 21 | dotenv.config({ path: envPath }); 22 | } else { 23 | // Load .env.example if it exists 24 | const examplePath = path.resolve(process.cwd(), '.env.example'); 25 | if (fs.existsSync(examplePath)) { 26 | dotenv.config({ path: examplePath }); 27 | } 28 | } 29 | } catch (error) { 30 | // Silent error handling 31 | } 32 | 33 | // Ensure logs directory exists 34 | try { 35 | const logsDir = path.join(process.cwd(), 'logs'); 36 | if (!fs.existsSync(logsDir)) { 37 | fs.mkdirSync(logsDir, { recursive: true }); 38 | } 39 | } catch (error) { 40 | // Silent error handling 41 | } 42 | 43 | // Try to load the server 44 | try { 45 | // Check for simplified stdio server build first (preferred for CLI usage) 46 | const stdioServerPath = path.resolve(__dirname, '../dist/stdio-server.js'); 47 | 48 | if (fs.existsSync(stdioServerPath)) { 49 | // If we're running in Node.js (not Bun), we need to handle ESM imports differently 50 | if (typeof Bun === 'undefined') { 51 | // Use dynamic import for ESM modules in CommonJS 52 | import(stdioServerPath).catch((err) => { 53 | console.error('Failed to import stdio server:', err.message); 54 | process.exit(1); 55 | }); 56 | } else { 57 | // In Bun, we can directly require the module 58 | require(stdioServerPath); 59 | } 60 | } else { 61 | // Fall back to full server if available 62 | const fullServerPath = path.resolve(__dirname, '../dist/index.js'); 63 | 64 | if (fs.existsSync(fullServerPath)) { 65 | console.warn('Warning: stdio-server.js not found, falling back to index.js'); 66 | console.warn('For optimal CLI performance, build with "npm run build:stdio"'); 67 | 68 | if (typeof Bun === 'undefined') { 69 | import(fullServerPath).catch((err) => { 70 | console.error('Failed to import server:', err.message); 71 | process.exit(1); 72 | }); 73 | } else { 74 | require(fullServerPath); 75 | } 76 | } else { 77 | console.error('Error: No server implementation found. Please build the project first.'); 78 | process.exit(1); 79 | } 80 | } 81 | } catch (error) { 82 | console.error('Error starting server:', error.message); 83 | process.exit(1); 84 | } -------------------------------------------------------------------------------- /bin/mcp-stdio.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * MCP Server - Stdio Transport Mode 5 | * 6 | * This is the entry point for running the MCP server via NPX in stdio mode. 7 | * It automatically configures the server to use JSON-RPC 2.0 over stdin/stdout. 8 | */ 9 | 10 | // Set environment variables for stdio transport 11 | process.env.USE_STDIO_TRANSPORT = 'true'; 12 | 13 | // Import and run the MCP server from the compiled output 14 | try { 15 | // First make sure required directories exist 16 | const fs = require('fs'); 17 | const path = require('path'); 18 | 19 | // Ensure logs directory exists 20 | const logsDir = path.join(process.cwd(), 'logs'); 21 | if (!fs.existsSync(logsDir)) { 22 | console.error('Creating logs directory...'); 23 | fs.mkdirSync(logsDir, { recursive: true }); 24 | } 25 | 26 | // Get the entry module path 27 | const entryPath = require.resolve('../dist/index.js'); 28 | 29 | // Print initial message to stderr 30 | console.error('Starting MCP server in stdio transport mode...'); 31 | console.error('Logs will be written to the logs/ directory'); 32 | console.error('Communication will use JSON-RPC 2.0 format via stdin/stdout'); 33 | 34 | // Run the server 35 | require(entryPath); 36 | } catch (error) { 37 | console.error('Failed to start MCP server:', error.message); 38 | console.error('If this is your first run, you may need to build the project first:'); 39 | console.error(' npm run build'); 40 | process.exit(1); 41 | } -------------------------------------------------------------------------------- /bin/test-stdio.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Test script for MCP stdio transport 5 | * 6 | * This script sends JSON-RPC 2.0 requests to the MCP server 7 | * running in stdio mode and displays the responses. 8 | * 9 | * Usage: node test-stdio.js | node bin/mcp-stdio.cjs 10 | */ 11 | 12 | // Send a ping request 13 | const pingRequest = { 14 | jsonrpc: "2.0", 15 | id: 1, 16 | method: "ping" 17 | }; 18 | 19 | // Send an info request 20 | const infoRequest = { 21 | jsonrpc: "2.0", 22 | id: 2, 23 | method: "info" 24 | }; 25 | 26 | // Send an echo request 27 | const echoRequest = { 28 | jsonrpc: "2.0", 29 | id: 3, 30 | method: "echo", 31 | params: { 32 | message: "Hello, MCP!", 33 | timestamp: new Date().toISOString(), 34 | test: true, 35 | count: 42 36 | } 37 | }; 38 | 39 | // Send the requests with a delay between them 40 | setTimeout(() => { 41 | console.log(JSON.stringify(pingRequest)); 42 | }, 500); 43 | 44 | setTimeout(() => { 45 | console.log(JSON.stringify(infoRequest)); 46 | }, 1000); 47 | 48 | setTimeout(() => { 49 | console.log(JSON.stringify(echoRequest)); 50 | }, 1500); 51 | 52 | // Process responses 53 | process.stdin.on('data', (data) => { 54 | try { 55 | const response = JSON.parse(data.toString()); 56 | console.error('Received response:'); 57 | console.error(JSON.stringify(response, null, 2)); 58 | } catch (error) { 59 | console.error('Error parsing response:', error); 60 | console.error('Raw data:', data.toString()); 61 | } 62 | }); -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = ["./test/setup.ts"] 3 | coverage = true 4 | coverageThreshold = { 5 | statements = 80, 6 | branches = 70, 7 | functions = 80, 8 | lines = 80 9 | } 10 | timeout = 10000 11 | testMatch = ["**/__tests__/**/*.test.ts"] 12 | testPathIgnorePatterns = ["/node_modules/", "/dist/"] 13 | collectCoverageFrom = [ 14 | "src/**/*.{ts,tsx}", 15 | "!src/**/*.d.ts", 16 | "!src/**/*.test.ts", 17 | "!src/types/**/*", 18 | "!src/mocks/**/*" 19 | ] 20 | 21 | [build] 22 | target = "bun" 23 | outdir = "./dist" 24 | minify = { 25 | whitespace = true, 26 | syntax = true, 27 | identifiers = true, 28 | module = true 29 | } 30 | sourcemap = "external" 31 | entry = ["./src/index.ts", "./src/stdio-server.ts"] 32 | splitting = true 33 | naming = "[name].[hash].[ext]" 34 | publicPath = "/assets/" 35 | define = { 36 | "process.env.NODE_ENV": "process.env.NODE_ENV" 37 | } 38 | 39 | [build.javascript] 40 | platform = "node" 41 | format = "esm" 42 | treeshaking = true 43 | packages = { 44 | external = ["bun:*"] 45 | } 46 | 47 | [build.typescript] 48 | dts = true 49 | typecheck = true 50 | 51 | [install] 52 | production = false 53 | frozen = true 54 | peer = false 55 | 56 | [install.cache] 57 | dir = ".bun" 58 | disable = false 59 | 60 | [debug] 61 | port = 9229 62 | 63 | [env] 64 | # Environment-specific configurations 65 | development.LOG_LEVEL = "debug" 66 | production.LOG_LEVEL = "warn" 67 | 68 | [hot] 69 | restart = true 70 | reload = true 71 | 72 | [performance] 73 | gc = true 74 | optimize = true 75 | jit = true 76 | smol = true 77 | compact = true 78 | 79 | [test.env] 80 | NODE_ENV = "test" 81 | 82 | [watch] 83 | ignore = ["**/node_modules/**", "**/dist/**", "**/.git/**"] -------------------------------------------------------------------------------- /docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Enable error handling 4 | set -euo pipefail 5 | 6 | # Colors for output 7 | RED='\033[0;31m' 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[1;33m' 10 | NC='\033[0m' 11 | 12 | # Function to print colored messages 13 | print_message() { 14 | local color=$1 15 | local message=$2 16 | echo -e "${color}${message}${NC}" 17 | } 18 | 19 | # Function to clean up on script exit 20 | cleanup() { 21 | print_message "$YELLOW" "Cleaning up..." 22 | docker builder prune -f --filter until=24h 23 | docker image prune -f 24 | } 25 | trap cleanup EXIT 26 | 27 | # Parse command line arguments 28 | ENABLE_SPEECH=false 29 | ENABLE_GPU=false 30 | BUILD_TYPE="standard" 31 | 32 | while [[ $# -gt 0 ]]; do 33 | case $1 in 34 | --speech) 35 | ENABLE_SPEECH=true 36 | BUILD_TYPE="speech" 37 | shift 38 | ;; 39 | --gpu) 40 | ENABLE_GPU=true 41 | shift 42 | ;; 43 | *) 44 | print_message "$RED" "Unknown option: $1" 45 | exit 1 46 | ;; 47 | esac 48 | done 49 | 50 | # Clean up Docker system 51 | print_message "$YELLOW" "Cleaning up Docker system..." 52 | docker system prune -f --volumes 53 | 54 | # Set build arguments for better performance 55 | export DOCKER_BUILDKIT=1 56 | export COMPOSE_DOCKER_CLI_BUILD=1 57 | export BUILDKIT_PROGRESS=plain 58 | 59 | # Calculate available memory and CPU 60 | TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}') 61 | BUILD_MEM=$(( TOTAL_MEM / 2 )) # Use half of available memory 62 | CPU_COUNT=$(nproc) 63 | CPU_QUOTA=$(( CPU_COUNT * 50000 )) # Allow 50% CPU usage per core 64 | 65 | print_message "$YELLOW" "Building with ${BUILD_MEM}MB memory limit and CPU quota ${CPU_QUOTA}" 66 | 67 | # Remove any existing lockfile 68 | rm -f bun.lockb 69 | 70 | # Base build arguments 71 | BUILD_ARGS=( 72 | --memory="${BUILD_MEM}m" 73 | --memory-swap="${BUILD_MEM}m" 74 | --cpu-quota="${CPU_QUOTA}" 75 | --build-arg BUILDKIT_INLINE_CACHE=1 76 | --build-arg DOCKER_BUILDKIT=1 77 | --build-arg NODE_ENV=production 78 | --progress=plain 79 | --no-cache 80 | --compress 81 | ) 82 | 83 | # Add speech-specific build arguments if enabled 84 | if [ "$ENABLE_SPEECH" = true ]; then 85 | BUILD_ARGS+=( 86 | --build-arg ENABLE_SPEECH_FEATURES=true 87 | --build-arg ENABLE_WAKE_WORD=true 88 | --build-arg ENABLE_SPEECH_TO_TEXT=true 89 | ) 90 | 91 | # Add GPU support if requested 92 | if [ "$ENABLE_GPU" = true ]; then 93 | BUILD_ARGS+=( 94 | --build-arg CUDA_VISIBLE_DEVICES=0 95 | --build-arg COMPUTE_TYPE=float16 96 | ) 97 | fi 98 | fi 99 | 100 | # Build the images 101 | print_message "$YELLOW" "Building Docker image (${BUILD_TYPE} build)..." 102 | 103 | # Build main image 104 | DOCKER_BUILDKIT=1 docker build \ 105 | "${BUILD_ARGS[@]}" \ 106 | -t homeassistant-mcp:latest \ 107 | -t homeassistant-mcp:$(date +%Y%m%d) \ 108 | . 109 | 110 | # Check if build was successful 111 | BUILD_EXIT_CODE=$? 112 | if [ $BUILD_EXIT_CODE -eq 124 ]; then 113 | print_message "$RED" "Build timed out after 15 minutes!" 114 | exit 1 115 | elif [ $BUILD_EXIT_CODE -ne 0 ]; then 116 | print_message "$RED" "Build failed with exit code ${BUILD_EXIT_CODE}!" 117 | exit 1 118 | else 119 | print_message "$GREEN" "Main image build completed successfully!" 120 | 121 | # Show image size and layers 122 | docker image ls homeassistant-mcp:latest --format "Image size: {{.Size}}" 123 | echo "Layer count: $(docker history homeassistant-mcp:latest | wc -l)" 124 | fi 125 | 126 | # Build speech-related images if enabled 127 | if [ "$ENABLE_SPEECH" = true ]; then 128 | print_message "$YELLOW" "Building speech-related images..." 129 | 130 | # Build fast-whisper image 131 | print_message "$YELLOW" "Building fast-whisper image..." 132 | docker pull onerahmet/openai-whisper-asr-webservice:latest 133 | 134 | # Build wake-word image 135 | print_message "$YELLOW" "Building wake-word image..." 136 | docker pull rhasspy/wyoming-openwakeword:latest 137 | 138 | print_message "$GREEN" "Speech-related images built successfully!" 139 | fi 140 | 141 | print_message "$GREEN" "All builds completed successfully!" 142 | 143 | # Show final status 144 | print_message "$YELLOW" "Build Summary:" 145 | echo "Build Type: $BUILD_TYPE" 146 | echo "Speech Features: $([ "$ENABLE_SPEECH" = true ] && echo 'Enabled' || echo 'Disabled')" 147 | echo "GPU Support: $([ "$ENABLE_GPU" = true ] && echo 'Enabled' || echo 'Disabled')" 148 | docker image ls | grep -E 'homeassistant-mcp|whisper|openwakeword' -------------------------------------------------------------------------------- /docker-compose.speech.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | homeassistant-mcp: 5 | image: homeassistant-mcp:latest 6 | environment: 7 | # Speech Feature Flags 8 | - ENABLE_SPEECH_FEATURES=${ENABLE_SPEECH_FEATURES:-true} 9 | - ENABLE_WAKE_WORD=${ENABLE_WAKE_WORD:-true} 10 | - ENABLE_SPEECH_TO_TEXT=${ENABLE_SPEECH_TO_TEXT:-true} 11 | 12 | # Audio Configuration 13 | - NOISE_THRESHOLD=${NOISE_THRESHOLD:-0.05} 14 | - MIN_SPEECH_DURATION=${MIN_SPEECH_DURATION:-1.0} 15 | - SILENCE_DURATION=${SILENCE_DURATION:-0.5} 16 | - SAMPLE_RATE=${SAMPLE_RATE:-16000} 17 | - CHANNELS=${CHANNELS:-1} 18 | - CHUNK_SIZE=${CHUNK_SIZE:-1024} 19 | - PULSE_SERVER=${PULSE_SERVER:-unix:/run/user/1000/pulse/native} 20 | 21 | fast-whisper: 22 | image: onerahmet/openai-whisper-asr-webservice:latest 23 | volumes: 24 | - whisper-models:/models 25 | - audio-data:/audio 26 | environment: 27 | - ASR_MODEL=${WHISPER_MODEL_TYPE:-base} 28 | - ASR_ENGINE=faster_whisper 29 | - WHISPER_BEAM_SIZE=5 30 | - COMPUTE_TYPE=float32 31 | - LANGUAGE=en 32 | ports: 33 | - "9000:9000" 34 | deploy: 35 | resources: 36 | limits: 37 | cpus: '4.0' 38 | memory: 2G 39 | healthcheck: 40 | test: [ "CMD", "curl", "-f", "http://localhost:9000/health" ] 41 | interval: 30s 42 | timeout: 10s 43 | retries: 3 44 | 45 | wake-word: 46 | image: rhasspy/wyoming-openwakeword:latest 47 | restart: unless-stopped 48 | devices: 49 | - /dev/snd:/dev/snd 50 | volumes: 51 | - /run/user/1000/pulse/native:/run/user/1000/pulse/native 52 | environment: 53 | - PULSE_SERVER=${PULSE_SERVER:-unix:/run/user/1000/pulse/native} 54 | - PULSE_COOKIE=/run/user/1000/pulse/cookie 55 | - PYTHONUNBUFFERED=1 56 | - OPENWAKEWORD_MODEL=hey_jarvis 57 | - OPENWAKEWORD_THRESHOLD=0.5 58 | - MICROPHONE_COMMAND=arecord -D hw:0,0 -f S16_LE -c 1 -r 16000 -t raw 59 | group_add: 60 | - "${AUDIO_GID:-29}" 61 | network_mode: host 62 | privileged: true 63 | entrypoint: > 64 | /bin/bash -c " apt-get update && apt-get install -y pulseaudio alsa-utils && rm -rf /var/lib/apt/lists/* && /run.sh" 65 | healthcheck: 66 | test: [ "CMD-SHELL", "pactl info > /dev/null 2>&1 || exit 1" ] 67 | interval: 30s 68 | timeout: 10s 69 | retries: 3 70 | 71 | volumes: 72 | whisper-models: 73 | audio-data: 74 | -------------------------------------------------------------------------------- /docker/speech/asound.conf: -------------------------------------------------------------------------------- 1 | pcm.!default { 2 | type pulse 3 | fallback "sysdefault" 4 | hint { 5 | show on 6 | description "Default ALSA Output (currently PulseAudio Sound Server)" 7 | } 8 | } 9 | 10 | ctl.!default { 11 | type pulse 12 | fallback "sysdefault" 13 | } 14 | 15 | # Use PulseAudio by default 16 | pcm.pulse { 17 | type pulse 18 | } 19 | 20 | ctl.pulse { 21 | type pulse 22 | } 23 | 24 | # Explicit device for recording 25 | pcm.microphone { 26 | type hw 27 | card 0 28 | device 0 29 | } 30 | 31 | # Default capture device 32 | pcm.!default { 33 | type pulse 34 | hint.description "Default Audio Device" 35 | } -------------------------------------------------------------------------------- /docker/speech/setup-audio.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # Exit immediately if a command exits with a non-zero status 3 | set -x # Print commands and their arguments as they are executed 4 | 5 | echo "Starting audio setup script at $(date)" 6 | echo "Current user: $(whoami)" 7 | echo "Current directory: $(pwd)" 8 | 9 | # Print environment variables related to audio and speech 10 | echo "ENABLE_WAKE_WORD: ${ENABLE_WAKE_WORD}" 11 | echo "PULSE_SERVER: ${PULSE_SERVER}" 12 | echo "WHISPER_MODEL_PATH: ${WHISPER_MODEL_PATH}" 13 | 14 | # Wait for PulseAudio socket to be available 15 | max_wait=30 16 | wait_count=0 17 | while [ ! -e /run/user/1000/pulse/native ]; do 18 | echo "Waiting for PulseAudio socket... (${wait_count}/${max_wait})" 19 | sleep 1 20 | wait_count=$((wait_count + 1)) 21 | if [ $wait_count -ge $max_wait ]; then 22 | echo "ERROR: PulseAudio socket not available after ${max_wait} seconds" 23 | exit 1 24 | fi 25 | done 26 | 27 | # Verify PulseAudio connection with detailed error handling 28 | if ! pactl info; then 29 | echo "ERROR: Failed to connect to PulseAudio server" 30 | pactl list short modules 31 | pactl list short clients 32 | exit 1 33 | fi 34 | 35 | # List audio devices with error handling 36 | if ! pactl list sources; then 37 | echo "ERROR: Failed to list audio devices" 38 | exit 1 39 | fi 40 | 41 | # Ensure wake word detector script is executable 42 | chmod +x /app/wake_word_detector.py 43 | 44 | # Start the wake word detector with logging 45 | echo "Starting wake word detector at $(date)" 46 | python /app/wake_word_detector.py 2>&1 | tee /audio/wake_word_detector.log & 47 | wake_word_pid=$! 48 | 49 | # Wait and check if the process is still running 50 | sleep 5 51 | if ! kill -0 $wake_word_pid 2>/dev/null; then 52 | echo "ERROR: Wake word detector process died immediately" 53 | cat /audio/wake_word_detector.log 54 | exit 1 55 | fi 56 | 57 | # Mute the monitor to prevent feedback 58 | pactl set-source-mute alsa_output.pci-0000_00_1b.0.analog-stereo.monitor 1 59 | 60 | # Set microphone sensitivity to 65% 61 | pactl set-source-volume alsa_input.pci-0000_00_1b.0.analog-stereo 65% 62 | 63 | # Set speaker volume to 40% 64 | pactl set-sink-volume alsa_output.pci-0000_00_1b.0.analog-stereo 40% 65 | 66 | # Keep the script running to prevent container exit 67 | echo "Audio setup complete. Keeping container alive." 68 | tail -f /dev/null -------------------------------------------------------------------------------- /extra/README.md: -------------------------------------------------------------------------------- 1 | # Speech-to-Text Examples 2 | 3 | This directory contains examples demonstrating how to use the speech-to-text integration with wake word detection. 4 | 5 | ## Prerequisites 6 | 7 | 1. Make sure you have Docker installed and running 8 | 2. Build and start the services: 9 | ```bash 10 | docker-compose up -d 11 | ``` 12 | 13 | ## Running the Example 14 | 15 | 1. Install dependencies: 16 | ```bash 17 | npm install 18 | ``` 19 | 20 | 2. Run the example: 21 | ```bash 22 | npm run example:speech 23 | ``` 24 | 25 | Or using `ts-node` directly: 26 | ```bash 27 | npx ts-node examples/speech-to-text-example.ts 28 | ``` 29 | 30 | ## Features Demonstrated 31 | 32 | 1. **Wake Word Detection** 33 | - Listens for wake words: "hey jarvis", "ok google", "alexa" 34 | - Automatically saves audio when wake word is detected 35 | - Transcribes the detected speech 36 | 37 | 2. **Manual Transcription** 38 | - Example of how to transcribe audio files manually 39 | - Supports different models and configurations 40 | 41 | 3. **Event Handling** 42 | - Wake word detection events 43 | - Transcription results 44 | - Progress updates 45 | - Error handling 46 | 47 | ## Example Output 48 | 49 | When a wake word is detected, you'll see output like this: 50 | 51 | ``` 52 | 🎤 Wake word detected! 53 | Timestamp: 20240203_123456 54 | Audio file: /path/to/audio/wake_word_20240203_123456.wav 55 | Metadata file: /path/to/audio/wake_word_20240203_123456.wav.json 56 | 57 | 📝 Transcription result: 58 | Full text: This is what was said after the wake word. 59 | 60 | Segments: 61 | 1. [0.00s - 1.52s] (95.5% confidence) 62 | "This is what was said" 63 | 2. [1.52s - 2.34s] (98.2% confidence) 64 | "after the wake word." 65 | ``` 66 | 67 | ## Customization 68 | 69 | You can customize the behavior by: 70 | 71 | 1. Changing the wake word models in `docker/speech/Dockerfile` 72 | 2. Modifying transcription options in the example file 73 | 3. Adding your own event handlers 74 | 4. Implementing different audio processing logic 75 | 76 | ## Troubleshooting 77 | 78 | 1. **Docker Issues** 79 | - Make sure Docker is running 80 | - Check container logs: `docker-compose logs fast-whisper` 81 | - Verify container is up: `docker ps` 82 | 83 | 2. **Audio Issues** 84 | - Check audio device permissions 85 | - Verify audio file format (WAV files recommended) 86 | - Check audio file permissions 87 | 88 | 3. **Performance Issues** 89 | - Try using a smaller model (tiny.en or base.en) 90 | - Adjust beam size and patience parameters 91 | - Consider using GPU acceleration if available -------------------------------------------------------------------------------- /fix-env.js: -------------------------------------------------------------------------------- 1 | // This script fixes the NODE_ENV environment variable before any imports 2 | console.log('Setting NODE_ENV to "development" before imports'); 3 | process.env.NODE_ENV = "development"; 4 | 5 | // Add more debugging 6 | console.log(`NODE_ENV is now set to: "${process.env.NODE_ENV}"`); 7 | 8 | // Import the main application 9 | import './dist/index.js'; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homeassistant-mcp", 3 | "version": "1.0.0", 4 | "description": "Home Assistant Model Context Protocol", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "bin": { 8 | "homeassistant-mcp": "./bin/npx-entry.cjs", 9 | "mcp-stdio": "./bin/npx-entry.cjs" 10 | }, 11 | "scripts": { 12 | "start": "bun run dist/index.js", 13 | "start:stdio": "bun run dist/stdio-server.js", 14 | "dev": "bun --hot --watch src/index.ts", 15 | "build": "bun build ./src/index.ts --outdir ./dist --target bun --minify", 16 | "build:all": "bun build ./src/index.ts ./src/stdio-server.ts --outdir ./dist --target bun --minify", 17 | "build:node": "bun build ./src/index.ts --outdir ./dist --target node --minify", 18 | "build:stdio": "bun build ./src/stdio-server.ts --outdir ./dist --target node --minify", 19 | "prepare": "husky install && bun run build:all", 20 | "stdio": "node ./bin/mcp-stdio.js", 21 | "test": "bun test", 22 | "test:watch": "bun test --watch", 23 | "test:coverage": "bun test --coverage", 24 | "test:ci": "bun test --coverage --bail", 25 | "test:update": "bun test --update-snapshots", 26 | "test:clear": "bun test --clear-cache", 27 | "test:staged": "bun test --findRelatedTests", 28 | "lint": "eslint . --ext .ts", 29 | "format": "prettier --write \"src/**/*.ts\"", 30 | "profile": "bun --inspect src/index.ts", 31 | "clean": "rm -rf dist .bun coverage", 32 | "typecheck": "bun x tsc --noEmit", 33 | "example:speech": "bun run extra/speech-to-text-example.ts" 34 | }, 35 | "dependencies": { 36 | "@anthropic-ai/sdk": "^0.39.0", 37 | "@elysiajs/cors": "^1.2.0", 38 | "@elysiajs/swagger": "^1.2.0", 39 | "@types/express-rate-limit": "^5.1.3", 40 | "@types/jsonwebtoken": "^9.0.5", 41 | "@types/node": "^20.11.24", 42 | "@types/sanitize-html": "^2.13.0", 43 | "@types/swagger-ui-express": "^4.1.8", 44 | "@types/ws": "^8.5.10", 45 | "@xmldom/xmldom": "^0.9.7", 46 | "chalk": "^5.4.1", 47 | "cors": "^2.8.5", 48 | "dotenv": "^16.4.7", 49 | "elysia": "^1.2.11", 50 | "express": "^4.21.2", 51 | "express-rate-limit": "^7.5.0", 52 | "helmet": "^7.1.0", 53 | "jsonwebtoken": "^9.0.2", 54 | "node-fetch": "^3.3.2", 55 | "node-record-lpcm16": "^1.0.1", 56 | "openai": "^4.83.0", 57 | "openapi-types": "^12.1.3", 58 | "sanitize-html": "^2.15.0", 59 | "swagger-ui-express": "^5.0.1", 60 | "typescript": "^5.3.3", 61 | "winston": "^3.11.0", 62 | "winston-daily-rotate-file": "^5.0.0", 63 | "ws": "^8.16.0", 64 | "zod": "^3.22.4" 65 | }, 66 | "devDependencies": { 67 | "@jest/globals": "^29.7.0", 68 | "@types/bun": "latest", 69 | "@types/cors": "^2.8.17", 70 | "@types/express": "^5.0.0", 71 | "@types/jest": "^29.5.14", 72 | "@types/supertest": "^6.0.2", 73 | "@types/uuid": "^10.0.0", 74 | "@typescript-eslint/eslint-plugin": "^7.1.0", 75 | "@typescript-eslint/parser": "^7.1.0", 76 | "ajv": "^8.17.1", 77 | "bun-types": "^1.2.2", 78 | "eslint": "^8.57.0", 79 | "eslint-config-prettier": "^9.1.0", 80 | "eslint-plugin-prettier": "^5.1.3", 81 | "husky": "^9.0.11", 82 | "prettier": "^3.2.5", 83 | "supertest": "^7.1.0", 84 | "uuid": "^11.1.0" 85 | }, 86 | "engines": { 87 | "bun": ">=1.0.0", 88 | "node": ">=18.0.0" 89 | }, 90 | "publishConfig": { 91 | "access": "public" 92 | }, 93 | "files": [ 94 | "dist", 95 | "bin", 96 | "README.md", 97 | "LICENSE" 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /scripts/setup-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Colors for output 4 | RED='\033[0;31m' 5 | GREEN='\033[0;32m' 6 | YELLOW='\033[1;33m' 7 | NC='\033[0m' # No Color 8 | 9 | # Function to print colored messages 10 | print_message() { 11 | local color=$1 12 | local message=$2 13 | echo -e "${color}${message}${NC}" 14 | } 15 | 16 | # Function to check if a file exists 17 | check_file() { 18 | if [ -f "$1" ]; then 19 | return 0 20 | else 21 | return 1 22 | fi 23 | } 24 | 25 | # Function to copy environment file 26 | copy_env_file() { 27 | local source=$1 28 | local target=$2 29 | if [ -f "$target" ]; then 30 | print_message "$YELLOW" "Warning: $target already exists. Skipping..." 31 | else 32 | cp "$source" "$target" 33 | if [ $? -eq 0 ]; then 34 | print_message "$GREEN" "Created $target successfully" 35 | else 36 | print_message "$RED" "Error: Failed to create $target" 37 | exit 1 38 | fi 39 | fi 40 | } 41 | 42 | # Main script 43 | print_message "$GREEN" "Setting up environment files..." 44 | 45 | # Check if .env.example exists 46 | if ! check_file ".env.example"; then 47 | print_message "$RED" "Error: .env.example not found!" 48 | exit 1 49 | fi 50 | 51 | # Setup base environment file 52 | if [ "$1" = "--force" ]; then 53 | cp .env.example .env 54 | print_message "$GREEN" "Forced creation of .env file" 55 | else 56 | copy_env_file ".env.example" ".env" 57 | fi 58 | 59 | # Determine environment 60 | ENV=${NODE_ENV:-development} 61 | case "$ENV" in 62 | "development"|"dev") 63 | ENV_FILE=".env.dev" 64 | ;; 65 | "production"|"prod") 66 | ENV_FILE=".env.prod" 67 | ;; 68 | "test") 69 | ENV_FILE=".env.test" 70 | ;; 71 | *) 72 | print_message "$RED" "Error: Invalid environment: $ENV" 73 | exit 1 74 | ;; 75 | esac 76 | 77 | # Copy environment-specific file 78 | if [ -f "$ENV_FILE" ]; then 79 | if [ "$1" = "--force" ]; then 80 | cp "$ENV_FILE" .env 81 | print_message "$GREEN" "Forced override of .env with $ENV_FILE" 82 | else 83 | print_message "$YELLOW" "Do you want to override .env with $ENV_FILE? [y/N] " 84 | read -r response 85 | if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then 86 | cp "$ENV_FILE" .env 87 | print_message "$GREEN" "Copied $ENV_FILE to .env" 88 | else 89 | print_message "$YELLOW" "Keeping existing .env file" 90 | fi 91 | fi 92 | else 93 | print_message "$YELLOW" "Warning: $ENV_FILE not found. Using default .env" 94 | fi 95 | 96 | print_message "$GREEN" "Environment setup complete!" 97 | print_message "$YELLOW" "Remember to set your HASS_TOKEN in .env" -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copy template if .env doesn't exist 4 | if [ ! -f .env ]; then 5 | cp .env.example .env 6 | echo "Created .env file from template. Please update your credentials!" 7 | fi 8 | 9 | # Validate required variables 10 | required_vars=("HASS_HOST" "HASS_TOKEN") 11 | missing_vars=() 12 | 13 | for var in "${required_vars[@]}"; do 14 | if ! grep -q "^$var=" .env; then 15 | missing_vars+=("$var") 16 | fi 17 | done 18 | 19 | if [ ${#missing_vars[@]} -ne 0 ]; then 20 | echo "ERROR: Missing required variables in .env:" 21 | printf '%s\n' "${missing_vars[@]}" 22 | exit 1 23 | fi 24 | 25 | # Check Docker version compatibility 26 | docker_version=$(docker --version | awk '{print $3}' | cut -d',' -f1) 27 | if [ "$(printf '%s\n' "20.10.0" "$docker_version" | sort -V | head -n1)" != "20.10.0" ]; then 28 | echo "ERROR: Docker version 20.10.0 or higher required" 29 | exit 1 30 | fi 31 | 32 | echo "Environment validation successful" -------------------------------------------------------------------------------- /search/scripts/start_mcp.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | 4 | :: Set environment variables 5 | set NODE_ENV=production 6 | 7 | :: Change to the script's directory 8 | cd /d "%~dp0" 9 | cd .. 10 | 11 | :: Start the MCP server 12 | echo Starting Home Assistant MCP Server... 13 | bun run start --port 8080 14 | 15 | if errorlevel 1 ( 16 | echo Error starting MCP server 17 | pause 18 | exit /b 1 19 | ) 20 | 21 | pause -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | yarn.lock 4 | # Build output 5 | 6 | dist/ 7 | build/ 8 | *.tsbuildinfo 9 | 10 | # Environment variables 11 | .env 12 | .env.local 13 | .env.*.local 14 | 15 | # IDE/Editor 16 | .vscode/ 17 | .idea/ 18 | *.swp 19 | *.swo 20 | 21 | # Logs 22 | logs/ 23 | *.log 24 | npm-debug.log* 25 | 26 | # Testing 27 | coverage/ 28 | 29 | # OS generated files 30 | .DS_Store 31 | .DS_Store? 32 | ._* 33 | .Spotlight-V100 34 | .Trashes 35 | ehthumbs.db 36 | Thumbs.db 37 | -------------------------------------------------------------------------------- /src/__mocks__/@digital-alchemy/hass.ts: -------------------------------------------------------------------------------- 1 | import { mock } from "bun:test"; 2 | 3 | export const LIB_HASS = { 4 | configuration: { 5 | name: "Home Assistant", 6 | version: "2024.2.0", 7 | location_name: "Home", 8 | time_zone: "UTC", 9 | components: ["automation", "script", "light", "switch"], 10 | unit_system: { 11 | temperature: "°C", 12 | length: "m", 13 | mass: "kg", 14 | pressure: "hPa", 15 | volume: "L", 16 | }, 17 | }, 18 | services: { 19 | light: { 20 | turn_on: mock(() => Promise.resolve()), 21 | turn_off: mock(() => Promise.resolve()), 22 | toggle: mock(() => Promise.resolve()), 23 | }, 24 | switch: { 25 | turn_on: mock(() => Promise.resolve()), 26 | turn_off: mock(() => Promise.resolve()), 27 | toggle: mock(() => Promise.resolve()), 28 | }, 29 | automation: { 30 | trigger: mock(() => Promise.resolve()), 31 | turn_on: mock(() => Promise.resolve()), 32 | turn_off: mock(() => Promise.resolve()), 33 | }, 34 | script: { 35 | turn_on: mock(() => Promise.resolve()), 36 | turn_off: mock(() => Promise.resolve()), 37 | toggle: mock(() => Promise.resolve()), 38 | }, 39 | }, 40 | states: { 41 | light: { 42 | "light.living_room": { 43 | state: "on", 44 | attributes: { 45 | brightness: 255, 46 | color_temp: 300, 47 | friendly_name: "Living Room Light", 48 | }, 49 | }, 50 | "light.bedroom": { 51 | state: "off", 52 | attributes: { 53 | friendly_name: "Bedroom Light", 54 | }, 55 | }, 56 | }, 57 | switch: { 58 | "switch.tv": { 59 | state: "off", 60 | attributes: { 61 | friendly_name: "TV", 62 | }, 63 | }, 64 | }, 65 | }, 66 | events: { 67 | subscribe: mock(() => Promise.resolve()), 68 | unsubscribe: mock(() => Promise.resolve()), 69 | fire: mock(() => Promise.resolve()), 70 | }, 71 | connection: { 72 | subscribeEvents: mock(() => Promise.resolve()), 73 | subscribeMessage: mock(() => Promise.resolve()), 74 | sendMessage: mock(() => Promise.resolve()), 75 | close: mock(() => Promise.resolve()), 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/__mocks__/litemcp.ts: -------------------------------------------------------------------------------- 1 | export class LiteMCP { 2 | name: string; 3 | version: string; 4 | config: any; 5 | 6 | constructor(config: any = {}) { 7 | this.name = "home-assistant"; 8 | this.version = "1.0.0"; 9 | this.config = config; 10 | } 11 | 12 | async start() { 13 | return Promise.resolve(); 14 | } 15 | 16 | async stop() { 17 | return Promise.resolve(); 18 | } 19 | 20 | async connect() { 21 | return Promise.resolve(); 22 | } 23 | 24 | async disconnect() { 25 | return Promise.resolve(); 26 | } 27 | 28 | async callService(domain: string, service: string, data: any = {}) { 29 | return Promise.resolve({ success: true }); 30 | } 31 | 32 | async getStates() { 33 | return Promise.resolve([]); 34 | } 35 | 36 | async getState(entityId: string) { 37 | return Promise.resolve({ 38 | entity_id: entityId, 39 | state: "unknown", 40 | attributes: {}, 41 | last_changed: new Date().toISOString(), 42 | last_updated: new Date().toISOString(), 43 | }); 44 | } 45 | 46 | async setState(entityId: string, state: string, attributes: any = {}) { 47 | return Promise.resolve({ success: true }); 48 | } 49 | 50 | onStateChanged(callback: (event: any) => void) { 51 | // Mock implementation 52 | } 53 | 54 | onEvent(eventType: string, callback: (event: any) => void) { 55 | // Mock implementation 56 | } 57 | } 58 | 59 | export const createMCP = (config: any = {}) => { 60 | return new LiteMCP(config); 61 | }; 62 | -------------------------------------------------------------------------------- /src/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeEach, afterEach } from 'bun:test'; 2 | import { MCPServerConfigSchema } from '../schemas/config.schema.js'; 3 | 4 | describe('Configuration Validation', () => { 5 | const originalEnv = { ...process.env }; 6 | 7 | beforeEach(() => { 8 | // Reset environment variables before each test 9 | process.env = { ...originalEnv }; 10 | }); 11 | 12 | afterEach(() => { 13 | // Restore original environment after each test 14 | process.env = originalEnv; 15 | }); 16 | 17 | test('validates default configuration', () => { 18 | const config = MCPServerConfigSchema.parse({}); 19 | expect(config).toBeDefined(); 20 | expect(config.port).toBe(3000); 21 | expect(config.environment).toBe('development'); 22 | }); 23 | 24 | test('validates custom port', () => { 25 | const config = MCPServerConfigSchema.parse({ port: 8080 }); 26 | expect(config.port).toBe(8080); 27 | }); 28 | 29 | test('rejects invalid port', () => { 30 | expect(() => MCPServerConfigSchema.parse({ port: 0 })).toThrow(); 31 | expect(() => MCPServerConfigSchema.parse({ port: 70000 })).toThrow(); 32 | }); 33 | 34 | test('validates environment values', () => { 35 | expect(() => MCPServerConfigSchema.parse({ environment: 'development' })).not.toThrow(); 36 | expect(() => MCPServerConfigSchema.parse({ environment: 'production' })).not.toThrow(); 37 | expect(() => MCPServerConfigSchema.parse({ environment: 'test' })).not.toThrow(); 38 | expect(() => MCPServerConfigSchema.parse({ environment: 'invalid' })).toThrow(); 39 | }); 40 | 41 | test('validates rate limiting configuration', () => { 42 | const config = MCPServerConfigSchema.parse({ 43 | rateLimit: { 44 | maxRequests: 50, 45 | maxAuthRequests: 10 46 | } 47 | }); 48 | expect(config.rateLimit.maxRequests).toBe(50); 49 | expect(config.rateLimit.maxAuthRequests).toBe(10); 50 | }); 51 | 52 | test('rejects invalid rate limit values', () => { 53 | expect(() => MCPServerConfigSchema.parse({ 54 | rateLimit: { 55 | maxRequests: 0, 56 | maxAuthRequests: 5 57 | } 58 | })).toThrow(); 59 | 60 | expect(() => MCPServerConfigSchema.parse({ 61 | rateLimit: { 62 | maxRequests: 100, 63 | maxAuthRequests: -1 64 | } 65 | })).toThrow(); 66 | }); 67 | 68 | test('validates execution timeout', () => { 69 | const config = MCPServerConfigSchema.parse({ executionTimeout: 5000 }); 70 | expect(config.executionTimeout).toBe(5000); 71 | }); 72 | 73 | test('rejects invalid execution timeout', () => { 74 | expect(() => MCPServerConfigSchema.parse({ executionTimeout: 500 })).toThrow(); 75 | expect(() => MCPServerConfigSchema.parse({ executionTimeout: 400000 })).toThrow(); 76 | }); 77 | 78 | test('validates transport settings', () => { 79 | const config = MCPServerConfigSchema.parse({ 80 | useStdioTransport: true, 81 | useHttpTransport: false 82 | }); 83 | expect(config.useStdioTransport).toBe(true); 84 | expect(config.useHttpTransport).toBe(false); 85 | }); 86 | 87 | test('validates CORS settings', () => { 88 | const config = MCPServerConfigSchema.parse({ 89 | corsOrigin: 'https://example.com' 90 | }); 91 | expect(config.corsOrigin).toBe('https://example.com'); 92 | }); 93 | 94 | test('validates debug settings', () => { 95 | const config = MCPServerConfigSchema.parse({ 96 | debugMode: true, 97 | debugStdio: true, 98 | debugHttp: true, 99 | silentStartup: false 100 | }); 101 | expect(config.debugMode).toBe(true); 102 | expect(config.debugStdio).toBe(true); 103 | expect(config.debugHttp).toBe(true); 104 | expect(config.silentStartup).toBe(false); 105 | }); 106 | }); -------------------------------------------------------------------------------- /src/__tests__/rate-limit.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, beforeAll, afterAll } from 'bun:test'; 2 | import express from 'express'; 3 | import { apiLimiter, authLimiter } from '../middleware/rate-limit.middleware.js'; 4 | import supertest from 'supertest'; 5 | 6 | describe('Rate Limiting Middleware', () => { 7 | let app: express.Application; 8 | let request: supertest.SuperTest; 9 | 10 | beforeAll(() => { 11 | app = express(); 12 | 13 | // Set up test routes with rate limiting 14 | app.use('/api', apiLimiter); 15 | app.use('/auth', authLimiter); 16 | 17 | // Test endpoints 18 | app.get('/api/test', (req, res) => { 19 | res.json({ message: 'API test successful' }); 20 | }); 21 | 22 | app.post('/auth/login', (req, res) => { 23 | res.json({ message: 'Login successful' }); 24 | }); 25 | 26 | request = supertest(app); 27 | }); 28 | 29 | test('allows requests within API rate limit', async () => { 30 | // Make multiple requests within the limit 31 | for (let i = 0; i < 5; i++) { 32 | const response = await request.get('/api/test'); 33 | expect(response.status).toBe(200); 34 | expect(response.body.message).toBe('API test successful'); 35 | } 36 | }); 37 | 38 | test('enforces API rate limit', async () => { 39 | // Make more requests than the limit allows 40 | const requests = Array(150).fill(null).map(() => 41 | request.get('/api/test') 42 | ); 43 | 44 | const responses = await Promise.all(requests); 45 | 46 | // Some requests should be successful, others should be rate limited 47 | const successfulRequests = responses.filter(r => r.status === 200); 48 | const limitedRequests = responses.filter(r => r.status === 429); 49 | 50 | expect(successfulRequests.length).toBeGreaterThan(0); 51 | expect(limitedRequests.length).toBeGreaterThan(0); 52 | }); 53 | 54 | test('allows requests within auth rate limit', async () => { 55 | // Make multiple requests within the limit 56 | for (let i = 0; i < 3; i++) { 57 | const response = await request.post('/auth/login'); 58 | expect(response.status).toBe(200); 59 | expect(response.body.message).toBe('Login successful'); 60 | } 61 | }); 62 | 63 | test('enforces stricter auth rate limit', async () => { 64 | // Make more requests than the auth limit allows 65 | const requests = Array(10).fill(null).map(() => 66 | request.post('/auth/login') 67 | ); 68 | 69 | const responses = await Promise.all(requests); 70 | 71 | // Some requests should be successful, others should be rate limited 72 | const successfulRequests = responses.filter(r => r.status === 200); 73 | const limitedRequests = responses.filter(r => r.status === 429); 74 | 75 | expect(successfulRequests.length).toBeLessThan(10); 76 | expect(limitedRequests.length).toBeGreaterThan(0); 77 | }); 78 | 79 | test('includes rate limit headers', async () => { 80 | const response = await request.get('/api/test'); 81 | expect(response.headers['ratelimit-limit']).toBeDefined(); 82 | expect(response.headers['ratelimit-remaining']).toBeDefined(); 83 | expect(response.headers['ratelimit-reset']).toBeDefined(); 84 | }); 85 | }); -------------------------------------------------------------------------------- /src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import path from "path"; 3 | import { 4 | beforeAll, 5 | afterAll, 6 | beforeEach, 7 | describe, 8 | expect, 9 | it, 10 | mock, 11 | test, 12 | } from "bun:test"; 13 | 14 | // Type definitions for mocks 15 | type MockFn = ReturnType; 16 | 17 | interface MockInstance { 18 | mock: { 19 | calls: unknown[][]; 20 | results: unknown[]; 21 | instances: unknown[]; 22 | lastCall?: unknown[]; 23 | }; 24 | } 25 | 26 | // Test configuration 27 | const TEST_CONFIG = { 28 | TEST_JWT_SECRET: "test_jwt_secret_key_that_is_at_least_32_chars", 29 | TEST_TOKEN: "test_token_that_is_at_least_32_chars_long", 30 | TEST_CLIENT_IP: "127.0.0.1", 31 | }; 32 | 33 | // Load test environment variables 34 | config({ path: path.resolve(process.cwd(), ".env.test") }); 35 | 36 | // Global test setup 37 | beforeAll(() => { 38 | // Set required environment variables 39 | process.env.NODE_ENV = "test"; 40 | process.env.JWT_SECRET = TEST_CONFIG.TEST_JWT_SECRET; 41 | process.env.TEST_TOKEN = TEST_CONFIG.TEST_TOKEN; 42 | 43 | // Configure console output for tests 44 | if (!process.env.DEBUG) { 45 | console.error = mock(() => { }); 46 | console.warn = mock(() => { }); 47 | console.log = mock(() => { }); 48 | } 49 | }); 50 | 51 | // Reset mocks between tests 52 | beforeEach(() => { 53 | // Clear all mock function calls 54 | const mockFns = Object.values(mock).filter( 55 | (value): value is MockFn => typeof value === "function" && "mock" in value, 56 | ); 57 | mockFns.forEach((mockFn) => { 58 | if (mockFn.mock) { 59 | mockFn.mock.calls = []; 60 | mockFn.mock.results = []; 61 | mockFn.mock.instances = []; 62 | mockFn.mock.lastCall = undefined; 63 | } 64 | }); 65 | }); 66 | 67 | // Custom test utilities 68 | const testUtils = { 69 | // Mock WebSocket for SSE tests 70 | mockWebSocket: () => ({ 71 | on: mock(() => { }), 72 | send: mock(() => { }), 73 | close: mock(() => { }), 74 | readyState: 1, 75 | OPEN: 1, 76 | removeAllListeners: mock(() => { }), 77 | }), 78 | 79 | // Mock HTTP response for API tests 80 | mockResponse: () => { 81 | const res = { 82 | status: mock(() => res), 83 | json: mock(() => res), 84 | send: mock(() => res), 85 | end: mock(() => res), 86 | setHeader: mock(() => res), 87 | writeHead: mock(() => res), 88 | write: mock(() => true), 89 | removeHeader: mock(() => res), 90 | }; 91 | return res; 92 | }, 93 | 94 | // Mock HTTP request for API tests 95 | mockRequest: (overrides: Record = {}) => ({ 96 | headers: { "content-type": "application/json" }, 97 | body: {}, 98 | query: {}, 99 | params: {}, 100 | ip: TEST_CONFIG.TEST_CLIENT_IP, 101 | method: "GET", 102 | path: "/api/test", 103 | is: mock((type: string) => type === "application/json"), 104 | ...overrides, 105 | }), 106 | 107 | // Create test client for SSE tests 108 | createTestClient: (id = "test-client") => ({ 109 | id, 110 | ip: TEST_CONFIG.TEST_CLIENT_IP, 111 | connectedAt: new Date(), 112 | send: mock(() => { }), 113 | rateLimit: { 114 | count: 0, 115 | lastReset: Date.now(), 116 | }, 117 | connectionTime: Date.now(), 118 | }), 119 | 120 | // Create test event for SSE tests 121 | createTestEvent: (type = "test_event", data: unknown = {}) => ({ 122 | event_type: type, 123 | data, 124 | origin: "test", 125 | time_fired: new Date().toISOString(), 126 | context: { id: "test" }, 127 | }), 128 | 129 | // Create test entity for Home Assistant tests 130 | createTestEntity: (entityId = "test.entity", state = "on") => ({ 131 | entity_id: entityId, 132 | state, 133 | attributes: {}, 134 | last_changed: new Date().toISOString(), 135 | last_updated: new Date().toISOString(), 136 | }), 137 | 138 | // Helper to wait for async operations 139 | wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), 140 | }; 141 | 142 | // Export test utilities and Bun test functions 143 | export { beforeAll, afterAll, beforeEach, describe, expect, it, mock, test, testUtils }; 144 | -------------------------------------------------------------------------------- /src/ai/nlp/context-analyzer.ts: -------------------------------------------------------------------------------- 1 | import { AIContext, AIIntent } from "../types/index.js"; 2 | 3 | interface ContextAnalysis { 4 | confidence: number; 5 | relevant_params: Record; 6 | } 7 | 8 | interface ContextRule { 9 | condition: (context: AIContext, intent: AIIntent) => boolean; 10 | relevance: number; 11 | params?: (context: AIContext) => Record; 12 | } 13 | 14 | export class ContextAnalyzer { 15 | private contextRules: ContextRule[]; 16 | 17 | constructor() { 18 | this.contextRules = [ 19 | // Location-based context 20 | { 21 | condition: (context, intent) => 22 | Boolean( 23 | context.location && 24 | intent.target.includes(context.location.toLowerCase()), 25 | ), 26 | relevance: 0.8, 27 | params: (context) => ({ location: context.location }), 28 | }, 29 | 30 | // Time-based context 31 | { 32 | condition: (context) => { 33 | const hour = new Date(context.timestamp).getHours(); 34 | return hour >= 0 && hour <= 23; 35 | }, 36 | relevance: 0.6, 37 | params: (context) => ({ 38 | time_of_day: this.getTimeOfDay(new Date(context.timestamp)), 39 | }), 40 | }, 41 | 42 | // Previous action context 43 | { 44 | condition: (context, intent) => { 45 | const recentActions = context.previous_actions.slice(-3); 46 | return recentActions.some( 47 | (action) => 48 | action.target === intent.target || 49 | action.action === intent.action, 50 | ); 51 | }, 52 | relevance: 0.7, 53 | params: (context) => ({ 54 | recent_action: 55 | context.previous_actions[context.previous_actions.length - 1], 56 | }), 57 | }, 58 | 59 | // Environment state context 60 | { 61 | condition: (context, intent) => { 62 | return Object.keys(context.environment_state).some( 63 | (key) => 64 | intent.target.includes(key) || 65 | intent.parameters[key] !== undefined, 66 | ); 67 | }, 68 | relevance: 0.9, 69 | params: (context) => ({ environment: context.environment_state }), 70 | }, 71 | ]; 72 | } 73 | 74 | async analyze( 75 | intent: AIIntent, 76 | context: AIContext, 77 | ): Promise { 78 | let totalConfidence = 0; 79 | let relevantParams: Record = {}; 80 | let applicableRules = 0; 81 | 82 | for (const rule of this.contextRules) { 83 | if (rule.condition(context, intent)) { 84 | totalConfidence += rule.relevance; 85 | applicableRules++; 86 | 87 | if (rule.params) { 88 | relevantParams = { 89 | ...relevantParams, 90 | ...rule.params(context), 91 | }; 92 | } 93 | } 94 | } 95 | 96 | // Calculate normalized confidence 97 | const confidence = 98 | applicableRules > 0 ? totalConfidence / applicableRules : 0.5; // Default confidence if no rules apply 99 | 100 | return { 101 | confidence, 102 | relevant_params: relevantParams, 103 | }; 104 | } 105 | 106 | private getTimeOfDay(date: Date): string { 107 | const hour = date.getHours(); 108 | 109 | if (hour >= 5 && hour < 12) return "morning"; 110 | if (hour >= 12 && hour < 17) return "afternoon"; 111 | if (hour >= 17 && hour < 22) return "evening"; 112 | return "night"; 113 | } 114 | 115 | async updateContextRules(newRules: ContextRule[]): Promise { 116 | this.contextRules = [...this.contextRules, ...newRules]; 117 | } 118 | 119 | async validateContext(context: AIContext): Promise { 120 | // Validate required context fields 121 | if (!context.timestamp || !context.user_id || !context.session_id) { 122 | return false; 123 | } 124 | 125 | // Validate timestamp format 126 | const timestamp = new Date(context.timestamp); 127 | if (isNaN(timestamp.getTime())) { 128 | return false; 129 | } 130 | 131 | // Validate previous actions array 132 | if (!Array.isArray(context.previous_actions)) { 133 | return false; 134 | } 135 | 136 | // Validate environment state 137 | if ( 138 | typeof context.environment_state !== "object" || 139 | context.environment_state === null 140 | ) { 141 | return false; 142 | } 143 | 144 | return true; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/ai/nlp/entity-extractor.ts: -------------------------------------------------------------------------------- 1 | import { AIContext } from "../types/index.js"; 2 | 3 | interface ExtractedEntities { 4 | primary_target: string; 5 | parameters: Record; 6 | confidence: number; 7 | } 8 | 9 | export class EntityExtractor { 10 | private deviceNameMap: Map; 11 | private parameterPatterns: Map; 12 | 13 | constructor() { 14 | this.deviceNameMap = new Map(); 15 | this.parameterPatterns = new Map(); 16 | this.initializePatterns(); 17 | } 18 | 19 | private initializePatterns(): void { 20 | // Device name variations 21 | this.deviceNameMap.set("living room light", "light.living_room"); 22 | this.deviceNameMap.set("kitchen light", "light.kitchen"); 23 | this.deviceNameMap.set("bedroom light", "light.bedroom"); 24 | 25 | // Parameter patterns 26 | this.parameterPatterns.set( 27 | "brightness", 28 | /(\d+)\s*(%|percent)|bright(ness)?\s+(\d+)/i, 29 | ); 30 | this.parameterPatterns.set("temperature", /(\d+)\s*(degrees?|°)[CF]?/i); 31 | this.parameterPatterns.set("color", /(red|green|blue|white|warm|cool)/i); 32 | } 33 | 34 | async extract(input: string): Promise { 35 | const entities: ExtractedEntities = { 36 | primary_target: "", 37 | parameters: {}, 38 | confidence: 0, 39 | }; 40 | 41 | try { 42 | // Find device name 43 | for (const [key, value] of this.deviceNameMap) { 44 | if (input.toLowerCase().includes(key)) { 45 | entities.primary_target = value; 46 | break; 47 | } 48 | } 49 | 50 | // Extract parameters 51 | for (const [param, pattern] of this.parameterPatterns) { 52 | const match = input.match(pattern); 53 | if (match) { 54 | entities.parameters[param] = this.normalizeParameterValue( 55 | param, 56 | match[1], 57 | ); 58 | } 59 | } 60 | 61 | // Calculate confidence based on matches 62 | entities.confidence = this.calculateConfidence(entities, input); 63 | 64 | return entities; 65 | } catch (error) { 66 | console.error("Entity extraction error:", error); 67 | return { 68 | primary_target: "", 69 | parameters: {}, 70 | confidence: 0, 71 | }; 72 | } 73 | } 74 | 75 | private normalizeParameterValue( 76 | parameter: string, 77 | value: string, 78 | ): number | string { 79 | switch (parameter) { 80 | case "brightness": 81 | return Math.min(100, Math.max(0, parseInt(value))); 82 | case "temperature": 83 | return parseInt(value); 84 | case "color": 85 | return value.toLowerCase(); 86 | default: 87 | return value; 88 | } 89 | } 90 | 91 | private calculateConfidence( 92 | entities: ExtractedEntities, 93 | input: string, 94 | ): number { 95 | let confidence = 0; 96 | 97 | // Device confidence 98 | if (entities.primary_target) { 99 | confidence += 0.5; 100 | } 101 | 102 | // Parameter confidence 103 | const paramCount = Object.keys(entities.parameters).length; 104 | confidence += paramCount * 0.25; 105 | 106 | // Normalize confidence to 0-1 range 107 | return Math.min(1, confidence); 108 | } 109 | 110 | async updateDeviceMap(devices: Record): Promise { 111 | for (const [key, value] of Object.entries(devices)) { 112 | this.deviceNameMap.set(key, value); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/ai/nlp/processor.ts: -------------------------------------------------------------------------------- 1 | import { AIIntent, AIContext, AIConfidence, AIError } from "../types/index.js"; 2 | import { EntityExtractor } from "./entity-extractor.js"; 3 | import { IntentClassifier } from "./intent-classifier.js"; 4 | import { ContextAnalyzer } from "./context-analyzer.js"; 5 | 6 | export class NLPProcessor { 7 | private entityExtractor: EntityExtractor; 8 | private intentClassifier: IntentClassifier; 9 | private contextAnalyzer: ContextAnalyzer; 10 | 11 | constructor() { 12 | this.entityExtractor = new EntityExtractor(); 13 | this.intentClassifier = new IntentClassifier(); 14 | this.contextAnalyzer = new ContextAnalyzer(); 15 | } 16 | 17 | async processCommand( 18 | input: string, 19 | context: AIContext, 20 | ): Promise<{ 21 | intent: AIIntent; 22 | confidence: AIConfidence; 23 | error?: AIError; 24 | }> { 25 | try { 26 | // Extract entities from the input 27 | const entities = await this.entityExtractor.extract(input); 28 | 29 | // Classify the intent 30 | const intent = await this.intentClassifier.classify(input, entities); 31 | 32 | // Analyze context relevance 33 | const contextRelevance = await this.contextAnalyzer.analyze( 34 | intent, 35 | context, 36 | ); 37 | 38 | // Calculate confidence scores 39 | const confidence: AIConfidence = { 40 | overall: 41 | (intent.confidence + 42 | entities.confidence + 43 | contextRelevance.confidence) / 44 | 3, 45 | intent: intent.confidence, 46 | entities: entities.confidence, 47 | context: contextRelevance.confidence, 48 | }; 49 | 50 | // Create structured intent 51 | const structuredIntent: AIIntent = { 52 | action: intent.action, 53 | target: entities.primary_target, 54 | parameters: { 55 | ...entities.parameters, 56 | ...intent.parameters, 57 | context_parameters: contextRelevance.relevant_params, 58 | }, 59 | raw_input: input, 60 | }; 61 | 62 | return { 63 | intent: structuredIntent, 64 | confidence, 65 | }; 66 | } catch (error: unknown) { 67 | const errorMessage = 68 | error instanceof Error ? error.message : "Unknown error occurred"; 69 | return { 70 | intent: { 71 | action: "error", 72 | target: "system", 73 | parameters: {}, 74 | raw_input: input, 75 | }, 76 | confidence: { 77 | overall: 0, 78 | intent: 0, 79 | entities: 0, 80 | context: 0, 81 | }, 82 | error: { 83 | code: "NLP_PROCESSING_ERROR", 84 | message: errorMessage, 85 | suggestion: "Please try rephrasing your command", 86 | recovery_options: [ 87 | "Use simpler language", 88 | "Break down the command into smaller parts", 89 | "Specify the target device explicitly", 90 | ], 91 | context, 92 | }, 93 | }; 94 | } 95 | } 96 | 97 | async validateIntent( 98 | intent: AIIntent, 99 | confidence: AIConfidence, 100 | threshold = 0.7, 101 | ): Promise { 102 | return ( 103 | confidence.overall >= threshold && 104 | confidence.intent >= threshold && 105 | confidence.entities >= threshold && 106 | confidence.context >= threshold 107 | ); 108 | } 109 | 110 | async suggestCorrections(input: string, error: AIError): Promise { 111 | // Implement correction suggestions based on the error 112 | const suggestions: string[] = []; 113 | 114 | if (error.code === "ENTITY_NOT_FOUND") { 115 | suggestions.push( 116 | "Try specifying the device name more clearly", 117 | "Use the exact device name from your Home Assistant setup", 118 | ); 119 | } 120 | 121 | if (error.code === "AMBIGUOUS_INTENT") { 122 | suggestions.push( 123 | "Please specify what you want to do with the device", 124 | 'Use action words like "turn on", "set", "adjust"', 125 | ); 126 | } 127 | 128 | if (error.code === "CONTEXT_MISMATCH") { 129 | suggestions.push( 130 | "Specify the location if referring to a device", 131 | "Clarify which device you mean in the current context", 132 | ); 133 | } 134 | 135 | return suggestions; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/ai/types/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // AI Model Types 4 | export enum AIModel { 5 | CLAUDE = "claude", 6 | GPT4 = "gpt4", 7 | CUSTOM = "custom", 8 | } 9 | 10 | // AI Confidence Level 11 | export interface AIConfidence { 12 | overall: number; 13 | intent: number; 14 | entities: number; 15 | context: number; 16 | } 17 | 18 | // AI Intent 19 | export interface AIIntent { 20 | action: string; 21 | target: string; 22 | parameters: Record; 23 | raw_input: string; 24 | } 25 | 26 | // AI Context 27 | export interface AIContext { 28 | user_id: string; 29 | session_id: string; 30 | timestamp: string; 31 | location: string; 32 | previous_actions: AIIntent[]; 33 | environment_state: Record; 34 | } 35 | 36 | // AI Response 37 | export interface AIResponse { 38 | natural_language: string; 39 | structured_data: { 40 | success: boolean; 41 | action_taken: string; 42 | entities_affected: string[]; 43 | state_changes: Record; 44 | }; 45 | next_suggestions: string[]; 46 | confidence: AIConfidence; 47 | context: AIContext; 48 | } 49 | 50 | // AI Error 51 | export interface AIError { 52 | code: string; 53 | message: string; 54 | suggestion: string; 55 | recovery_options: string[]; 56 | context: AIContext; 57 | } 58 | 59 | // Rate Limiting 60 | export interface AIRateLimit { 61 | requests_per_minute: number; 62 | requests_per_hour: number; 63 | concurrent_requests: number; 64 | model_specific_limits: Record< 65 | AIModel, 66 | { 67 | requests_per_minute: number; 68 | requests_per_hour: number; 69 | } 70 | >; 71 | } 72 | 73 | // Zod Schemas 74 | export const AIConfidenceSchema = z.object({ 75 | overall: z.number().min(0).max(1), 76 | intent: z.number().min(0).max(1), 77 | entities: z.number().min(0).max(1), 78 | context: z.number().min(0).max(1), 79 | }); 80 | 81 | export const AIIntentSchema = z.object({ 82 | action: z.string(), 83 | target: z.string(), 84 | parameters: z.record(z.any()), 85 | raw_input: z.string(), 86 | }); 87 | 88 | export const AIContextSchema = z.object({ 89 | user_id: z.string(), 90 | session_id: z.string(), 91 | timestamp: z.string(), 92 | location: z.string(), 93 | previous_actions: z.array(AIIntentSchema), 94 | environment_state: z.record(z.any()), 95 | }); 96 | 97 | export const AIResponseSchema = z.object({ 98 | natural_language: z.string(), 99 | structured_data: z.object({ 100 | success: z.boolean(), 101 | action_taken: z.string(), 102 | entities_affected: z.array(z.string()), 103 | state_changes: z.record(z.any()), 104 | }), 105 | next_suggestions: z.array(z.string()), 106 | confidence: AIConfidenceSchema, 107 | context: AIContextSchema, 108 | }); 109 | 110 | export const AIErrorSchema = z.object({ 111 | code: z.string(), 112 | message: z.string(), 113 | suggestion: z.string(), 114 | recovery_options: z.array(z.string()), 115 | context: AIContextSchema, 116 | }); 117 | 118 | export const AIRateLimitSchema = z.object({ 119 | requests_per_minute: z.number(), 120 | requests_per_hour: z.number(), 121 | concurrent_requests: z.number(), 122 | model_specific_limits: z.record( 123 | z.object({ 124 | requests_per_minute: z.number(), 125 | requests_per_hour: z.number(), 126 | }), 127 | ), 128 | }); 129 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | // Common commands that work with most entities 2 | export const commonCommands = ["turn_on", "turn_off", "toggle"] as const; 3 | 4 | // Commands specific to cover entities 5 | export const coverCommands = [ 6 | ...commonCommands, 7 | "open", 8 | "close", 9 | "stop", 10 | "set_position", 11 | "set_tilt_position", 12 | ] as const; 13 | 14 | // Commands specific to climate entities 15 | export const climateCommands = [ 16 | ...commonCommands, 17 | "set_temperature", 18 | "set_hvac_mode", 19 | "set_fan_mode", 20 | "set_humidity", 21 | ] as const; 22 | 23 | // Types for command validation 24 | export type CommonCommand = (typeof commonCommands)[number]; 25 | export type CoverCommand = (typeof coverCommands)[number]; 26 | export type ClimateCommand = (typeof climateCommands)[number]; 27 | export type Command = CommonCommand | CoverCommand | ClimateCommand; 28 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MCP Server Configuration 3 | * 4 | * This file contains the configuration for the MCP server. 5 | * Values can be overridden via environment variables. 6 | */ 7 | 8 | // Default values for the application configuration 9 | export const APP_CONFIG = { 10 | // Server configuration 11 | PORT: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000, 12 | NODE_ENV: process.env.NODE_ENV || 'development', 13 | 14 | // Execution settings 15 | EXECUTION_TIMEOUT: process.env.EXECUTION_TIMEOUT ? parseInt(process.env.EXECUTION_TIMEOUT, 10) : 30000, 16 | STREAMING_ENABLED: process.env.STREAMING_ENABLED === 'true', 17 | 18 | // Transport settings 19 | USE_STDIO_TRANSPORT: process.env.USE_STDIO_TRANSPORT === 'true', 20 | USE_HTTP_TRANSPORT: process.env.USE_HTTP_TRANSPORT !== 'false', 21 | 22 | // Debug and logging settings 23 | DEBUG_MODE: process.env.DEBUG_MODE === 'true', 24 | DEBUG_STDIO: process.env.DEBUG_STDIO === 'true', 25 | DEBUG_HTTP: process.env.DEBUG_HTTP === 'true', 26 | SILENT_STARTUP: process.env.SILENT_STARTUP === 'true', 27 | 28 | // CORS settings 29 | CORS_ORIGIN: process.env.CORS_ORIGIN || '*' 30 | }; 31 | 32 | export default APP_CONFIG; -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for the Model Context Protocol (MCP) server 3 | * Values can be overridden using environment variables 4 | */ 5 | 6 | import { MCPServerConfigSchema, MCPServerConfigType } from './schemas/config.schema.js'; 7 | import { logger } from './utils/logger.js'; 8 | 9 | function loadConfig(): MCPServerConfigType { 10 | try { 11 | const rawConfig = { 12 | // Server configuration 13 | port: parseInt(process.env.PORT || '3000', 10), 14 | environment: process.env.NODE_ENV || 'development', 15 | 16 | // Execution settings 17 | executionTimeout: parseInt(process.env.EXECUTION_TIMEOUT || '30000', 10), 18 | streamingEnabled: process.env.STREAMING_ENABLED === 'true', 19 | 20 | // Transport settings 21 | useStdioTransport: process.env.USE_STDIO_TRANSPORT === 'true', 22 | useHttpTransport: process.env.USE_HTTP_TRANSPORT === 'true', 23 | 24 | // Debug and logging 25 | debugMode: process.env.DEBUG_MODE === 'true', 26 | debugStdio: process.env.DEBUG_STDIO === 'true', 27 | debugHttp: process.env.DEBUG_HTTP === 'true', 28 | silentStartup: process.env.SILENT_STARTUP === 'true', 29 | 30 | // CORS settings 31 | corsOrigin: process.env.CORS_ORIGIN || '*', 32 | 33 | // Rate limiting 34 | rateLimit: { 35 | maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10), 36 | maxAuthRequests: parseInt(process.env.RATE_LIMIT_MAX_AUTH_REQUESTS || '5', 10), 37 | }, 38 | }; 39 | 40 | // Validate and parse configuration 41 | const validatedConfig = MCPServerConfigSchema.parse(rawConfig); 42 | 43 | // Log validation success 44 | if (!validatedConfig.silentStartup) { 45 | logger.info('Configuration validated successfully'); 46 | if (validatedConfig.debugMode) { 47 | logger.debug('Current configuration:', validatedConfig); 48 | } 49 | } 50 | 51 | return validatedConfig; 52 | } catch (error) { 53 | // Log validation errors 54 | logger.error('Configuration validation failed:', error); 55 | throw new Error('Invalid configuration. Please check your environment variables.'); 56 | } 57 | } 58 | 59 | export const APP_CONFIG = loadConfig(); 60 | export type { MCPServerConfigType }; 61 | export default APP_CONFIG; -------------------------------------------------------------------------------- /src/config/hass.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import { resolve } from "path"; 3 | 4 | // Load environment variables based on NODE_ENV 5 | const envFile = 6 | process.env.NODE_ENV === "production" 7 | ? ".env" 8 | : process.env.NODE_ENV === "test" 9 | ? ".env.test" 10 | : ".env.development"; 11 | 12 | config({ path: resolve(process.cwd(), envFile) }); 13 | 14 | // Base configuration for Home Assistant 15 | export const HASS_CONFIG = { 16 | // Base configuration 17 | BASE_URL: process.env.HASS_HOST || "http://localhost:8123", 18 | TOKEN: process.env.HASS_TOKEN || "", 19 | SOCKET_URL: process.env.HASS_WS_URL || "ws://localhost:8123/api/websocket", 20 | SOCKET_TOKEN: process.env.HASS_TOKEN || "", 21 | 22 | // Boilerplate configuration 23 | BOILERPLATE: { 24 | CACHE_DIRECTORY: ".cache", 25 | CONFIG_DIRECTORY: ".config", 26 | DATA_DIRECTORY: ".data", 27 | LOG_LEVEL: "debug", 28 | ENVIRONMENT: process.env.NODE_ENV || "development", 29 | }, 30 | 31 | // Application configuration 32 | APP_NAME: "homeassistant-mcp", 33 | APP_VERSION: "1.0.0", 34 | 35 | // API configuration 36 | API_VERSION: "1.0.0", 37 | API_PREFIX: "/api", 38 | 39 | // Security configuration 40 | RATE_LIMIT: { 41 | WINDOW_MS: 15 * 60 * 1000, // 15 minutes 42 | MAX_REQUESTS: 100, 43 | }, 44 | 45 | // WebSocket configuration 46 | WS_CONFIG: { 47 | AUTO_RECONNECT: true, 48 | MAX_RECONNECT_ATTEMPTS: 3, 49 | RECONNECT_DELAY: 1000, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { loadEnvironmentVariables } from "./loadEnv"; 2 | 3 | // Load environment variables from the appropriate files 4 | loadEnvironmentVariables(); 5 | 6 | // Home Assistant Configuration 7 | export const HASS_CONFIG = { 8 | HOST: process.env.HASS_HOST || "http://homeassistant.local:8123", 9 | TOKEN: process.env.HASS_TOKEN, 10 | SOCKET_URL: 11 | process.env.HASS_SOCKET_URL || 12 | "ws://homeassistant.local:8123/api/websocket", 13 | BASE_URL: process.env.HASS_HOST || "http://homeassistant.local:8123", 14 | SOCKET_TOKEN: process.env.HASS_TOKEN, 15 | }; 16 | 17 | // Server Configuration 18 | export const SERVER_CONFIG = { 19 | PORT: parseInt(process.env.PORT || "3000", 10), 20 | NODE_ENV: process.env.NODE_ENV || "development", 21 | DEBUG: process.env.DEBUG === "true", 22 | LOG_LEVEL: process.env.LOG_LEVEL || "info", 23 | }; 24 | 25 | // AI Configuration 26 | export const AI_CONFIG = { 27 | PROCESSOR_TYPE: process.env.PROCESSOR_TYPE || "claude", 28 | OPENAI_API_KEY: process.env.OPENAI_API_KEY, 29 | }; 30 | 31 | // Rate Limiting Configuration 32 | export const RATE_LIMIT_CONFIG = { 33 | REGULAR: parseInt(process.env.RATE_LIMIT_REGULAR || "100", 10), 34 | WEBSOCKET: parseInt(process.env.RATE_LIMIT_WEBSOCKET || "1000", 10), 35 | }; 36 | 37 | // Security Configuration 38 | export const SECURITY_CONFIG = { 39 | JWT_SECRET: 40 | process.env.JWT_SECRET || "default_secret_key_change_in_production", 41 | CORS_ORIGINS: ( 42 | process.env.CORS_ORIGINS || "http://localhost:3000,http://localhost:8123" 43 | ) 44 | .split(",") 45 | .map((origin) => origin.trim()), 46 | }; 47 | 48 | // Test Configuration 49 | export const TEST_CONFIG = { 50 | HASS_HOST: process.env.TEST_HASS_HOST || "http://localhost:8123", 51 | HASS_TOKEN: process.env.TEST_HASS_TOKEN || "test_token", 52 | HASS_SOCKET_URL: 53 | process.env.TEST_HASS_SOCKET_URL || "ws://localhost:8123/api/websocket", 54 | PORT: parseInt(process.env.TEST_PORT || "3001", 10), 55 | }; 56 | 57 | // Mock Configuration (for testing) 58 | export const MOCK_CONFIG = { 59 | SERVICES: process.env.MOCK_SERVICES === "true", 60 | RESPONSES_DIR: process.env.MOCK_RESPONSES_DIR || "__tests__/mock-responses", 61 | }; 62 | 63 | // Validate required configuration 64 | function validateConfig() { 65 | const missingVars: string[] = []; 66 | 67 | if (!HASS_CONFIG.TOKEN) missingVars.push("HASS_TOKEN"); 68 | if (!SECURITY_CONFIG.JWT_SECRET) missingVars.push("JWT_SECRET"); 69 | 70 | if (missingVars.length > 0) { 71 | throw new Error( 72 | `Missing required environment variables: ${missingVars.join(", ")}`, 73 | ); 74 | } 75 | } 76 | 77 | // Export configuration validation 78 | export const validateConfiguration = validateConfig; 79 | 80 | // Export all configurations as a single object 81 | export const AppConfig = { 82 | HASS: HASS_CONFIG, 83 | SERVER: SERVER_CONFIG, 84 | AI: AI_CONFIG, 85 | RATE_LIMIT: RATE_LIMIT_CONFIG, 86 | SECURITY: SECURITY_CONFIG, 87 | TEST: TEST_CONFIG, 88 | MOCK: MOCK_CONFIG, 89 | }; 90 | -------------------------------------------------------------------------------- /src/config/loadEnv.ts: -------------------------------------------------------------------------------- 1 | import { config as dotenvConfig } from "dotenv"; 2 | import { file } from "bun"; 3 | import path from "path"; 4 | 5 | /** 6 | * Maps NODE_ENV values to their corresponding environment file names 7 | */ 8 | const ENV_FILE_MAPPING: Record = { 9 | production: ".env.prod", 10 | development: ".env.dev", 11 | test: ".env.test", 12 | }; 13 | 14 | /** 15 | * Loads environment variables from the appropriate files based on NODE_ENV. 16 | * First loads environment-specific file, then overrides with generic .env if it exists. 17 | */ 18 | export async function loadEnvironmentVariables() { 19 | // Determine the current environment (default to 'development') 20 | const nodeEnv = (process.env.NODE_ENV || "development").toLowerCase(); 21 | 22 | // Get the environment-specific file name 23 | const envSpecificFile = ENV_FILE_MAPPING[nodeEnv]; 24 | if (!envSpecificFile) { 25 | console.warn(`Unknown NODE_ENV value: ${nodeEnv}. Using .env.dev as fallback.`); 26 | } 27 | 28 | const envFile = envSpecificFile || ".env.dev"; 29 | const envPath = path.resolve(process.cwd(), envFile); 30 | 31 | // Load the environment-specific file if it exists 32 | try { 33 | const envFileExists = await file(envPath).exists(); 34 | if (envFileExists) { 35 | dotenvConfig({ path: envPath }); 36 | console.log(`Loaded environment variables from ${envFile}`); 37 | } else { 38 | console.warn(`Environment-specific file ${envFile} not found.`); 39 | } 40 | } catch (error) { 41 | console.warn(`Error checking environment file ${envFile}:`, error); 42 | } 43 | 44 | // Finally, check if there is a generic .env file present 45 | // If so, load it with the override option, so its values take precedence 46 | const genericEnvPath = path.resolve(process.cwd(), ".env"); 47 | try { 48 | const genericEnvExists = await file(genericEnvPath).exists(); 49 | if (genericEnvExists) { 50 | dotenvConfig({ path: genericEnvPath, override: true }); 51 | console.log("Loaded and overrode with generic .env file"); 52 | } 53 | } catch (error) { 54 | console.warn(`Error checking generic .env file:`, error); 55 | } 56 | } 57 | 58 | // Export the environment file mapping for reference 59 | export const ENV_FILES = ENV_FILE_MAPPING; -------------------------------------------------------------------------------- /src/hass/index.ts: -------------------------------------------------------------------------------- 1 | import type { HassEntity } from "../interfaces/hass.js"; 2 | 3 | class HomeAssistantAPI { 4 | private baseUrl: string; 5 | private token: string; 6 | 7 | constructor() { 8 | this.baseUrl = process.env.HASS_HOST || "http://localhost:8123"; 9 | this.token = process.env.HASS_TOKEN || ""; 10 | 11 | if (!this.token || this.token === "your_hass_token_here") { 12 | throw new Error("HASS_TOKEN is required but not set in environment variables"); 13 | } 14 | 15 | console.log(`Initializing Home Assistant API with base URL: ${this.baseUrl}`); 16 | } 17 | 18 | private async fetchApi(endpoint: string, options: RequestInit = {}) { 19 | const url = `${this.baseUrl}/api/${endpoint}`; 20 | console.log(`Making request to: ${url}`); 21 | console.log('Request options:', { 22 | method: options.method || 'GET', 23 | headers: { 24 | Authorization: 'Bearer [REDACTED]', 25 | "Content-Type": "application/json", 26 | ...options.headers, 27 | }, 28 | body: options.body ? JSON.parse(options.body as string) : undefined 29 | }); 30 | 31 | try { 32 | const response = await fetch(url, { 33 | ...options, 34 | headers: { 35 | Authorization: `Bearer ${this.token}`, 36 | "Content-Type": "application/json", 37 | ...options.headers, 38 | }, 39 | }); 40 | 41 | if (!response.ok) { 42 | const errorText = await response.text(); 43 | console.error('Home Assistant API error:', { 44 | status: response.status, 45 | statusText: response.statusText, 46 | error: errorText 47 | }); 48 | throw new Error(`Home Assistant API error: ${response.status} ${response.statusText} - ${errorText}`); 49 | } 50 | 51 | const data = await response.json(); 52 | console.log('Response data:', data); 53 | return data; 54 | } catch (error) { 55 | console.error('Failed to make request:', error); 56 | throw error; 57 | } 58 | } 59 | 60 | async getStates(): Promise { 61 | return this.fetchApi("states"); 62 | } 63 | 64 | async getState(entityId: string): Promise { 65 | return this.fetchApi(`states/${entityId}`); 66 | } 67 | 68 | async callService(domain: string, service: string, data: Record): Promise { 69 | await this.fetchApi(`services/${domain}/${service}`, { 70 | method: "POST", 71 | body: JSON.stringify(data), 72 | }); 73 | } 74 | } 75 | 76 | let instance: HomeAssistantAPI | null = null; 77 | 78 | export async function get_hass() { 79 | if (!instance) { 80 | try { 81 | instance = new HomeAssistantAPI(); 82 | // Verify connection by trying to get states 83 | await instance.getStates(); 84 | console.log('Successfully connected to Home Assistant'); 85 | } catch (error) { 86 | console.error('Failed to initialize Home Assistant connection:', error); 87 | instance = null; 88 | throw error; 89 | } 90 | } 91 | return instance; 92 | } 93 | 94 | // Helper function to call Home Assistant services 95 | export async function call_service( 96 | domain: string, 97 | service: string, 98 | data: Record, 99 | ) { 100 | const hass = await get_hass(); 101 | return hass.callService(domain, service, data); 102 | } 103 | 104 | // Helper function to list devices 105 | export async function list_devices() { 106 | const hass = await get_hass(); 107 | const states = await hass.getStates(); 108 | return states.map((state: HassEntity) => ({ 109 | entity_id: state.entity_id, 110 | state: state.state, 111 | attributes: state.attributes 112 | })); 113 | } 114 | 115 | // Helper function to get entity states 116 | export async function get_states() { 117 | const hass = await get_hass(); 118 | return hass.getStates(); 119 | } 120 | 121 | // Helper function to get a specific entity state 122 | export async function get_state(entity_id: string) { 123 | const hass = await get_hass(); 124 | return hass.getState(entity_id); 125 | } 126 | -------------------------------------------------------------------------------- /src/hass/types.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocket } from 'ws'; 2 | 3 | export interface HassInstanceImpl { 4 | baseUrl: string; 5 | token: string; 6 | connect(): Promise; 7 | disconnect(): Promise; 8 | getStates(): Promise; 9 | callService(domain: string, service: string, data?: any): Promise; 10 | fetchStates(): Promise; 11 | fetchState(entityId: string): Promise; 12 | subscribeEvents(callback: (event: any) => void, eventType?: string): Promise; 13 | unsubscribeEvents(subscriptionId: number): Promise; 14 | } 15 | 16 | export interface HassWebSocketClient { 17 | url: string; 18 | token: string; 19 | socket: WebSocket | null; 20 | connect(): Promise; 21 | disconnect(): Promise; 22 | send(message: any): Promise; 23 | subscribe(callback: (data: any) => void): () => void; 24 | } 25 | 26 | export interface HassState { 27 | entity_id: string; 28 | state: string; 29 | attributes: Record; 30 | last_changed: string; 31 | last_updated: string; 32 | context: { 33 | id: string; 34 | parent_id: string | null; 35 | user_id: string | null; 36 | }; 37 | } 38 | 39 | export interface HassServiceCall { 40 | domain: string; 41 | service: string; 42 | target?: { 43 | entity_id?: string | string[]; 44 | device_id?: string | string[]; 45 | area_id?: string | string[]; 46 | }; 47 | service_data?: Record; 48 | } 49 | 50 | export interface HassEvent { 51 | event_type: string; 52 | data: any; 53 | origin: string; 54 | time_fired: string; 55 | context: { 56 | id: string; 57 | parent_id: string | null; 58 | user_id: string | null; 59 | }; 60 | } 61 | 62 | export type MockFunction any> = { 63 | (...args: Parameters): ReturnType; 64 | mock: { 65 | calls: Parameters[]; 66 | results: { type: 'return' | 'throw'; value: any }[]; 67 | instances: any[]; 68 | mockImplementation(fn: T): MockFunction; 69 | mockReturnValue(value: ReturnType): MockFunction; 70 | mockResolvedValue(value: Awaited>): MockFunction; 71 | mockRejectedValue(value: any): MockFunction; 72 | mockReset(): void; 73 | }; 74 | }; -------------------------------------------------------------------------------- /src/health-check.ts: -------------------------------------------------------------------------------- 1 | const check = async () => { 2 | try { 3 | const response = await fetch("http://localhost:3000/health"); 4 | if (!response.ok) { 5 | console.error("Health check failed:", response.status); 6 | process.exit(1); 7 | } 8 | console.log("Health check passed"); 9 | process.exit(0); 10 | } catch (error) { 11 | console.error("Health check failed:", error); 12 | process.exit(1); 13 | } 14 | }; 15 | 16 | check(); 17 | -------------------------------------------------------------------------------- /src/interfaces/hass.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Home Assistant entity types 4 | export interface HassEntity { 5 | entity_id: string; 6 | state: string; 7 | attributes: Record; 8 | last_changed?: string; 9 | last_updated?: string; 10 | context?: { 11 | id: string; 12 | parent_id?: string; 13 | user_id?: string; 14 | }; 15 | } 16 | 17 | export interface HassState { 18 | entity_id: string; 19 | state: string; 20 | attributes: { 21 | friendly_name?: string; 22 | description?: string; 23 | [key: string]: any; 24 | }; 25 | } 26 | 27 | // Home Assistant instance types 28 | export interface HassInstance { 29 | states: HassStates; 30 | services: HassServices; 31 | connection: HassConnection; 32 | subscribeEvents: ( 33 | callback: (event: HassEvent) => void, 34 | eventType?: string, 35 | ) => Promise; 36 | unsubscribeEvents: (subscription: number) => void; 37 | } 38 | 39 | export interface HassStates { 40 | get: () => Promise; 41 | subscribe: (callback: (states: HassEntity[]) => void) => Promise; 42 | unsubscribe: (subscription: number) => void; 43 | } 44 | 45 | export interface HassServices { 46 | get: () => Promise>>; 47 | call: ( 48 | domain: string, 49 | service: string, 50 | serviceData?: Record, 51 | ) => Promise; 52 | } 53 | 54 | export interface HassConnection { 55 | socket: WebSocket; 56 | subscribeEvents: ( 57 | callback: (event: HassEvent) => void, 58 | eventType?: string, 59 | ) => Promise; 60 | unsubscribeEvents: (subscription: number) => void; 61 | } 62 | 63 | export interface HassService { 64 | name: string; 65 | description: string; 66 | target?: { 67 | entity?: { 68 | domain: string[]; 69 | }; 70 | }; 71 | fields: Record< 72 | string, 73 | { 74 | name: string; 75 | description: string; 76 | required?: boolean; 77 | example?: any; 78 | selector?: any; 79 | } 80 | >; 81 | } 82 | 83 | export interface HassEvent { 84 | event_type: string; 85 | data: Record; 86 | origin: string; 87 | time_fired: string; 88 | context: { 89 | id: string; 90 | parent_id?: string; 91 | user_id?: string; 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Tool interfaces 4 | export interface Tool { 5 | name: string; 6 | description: string; 7 | parameters: z.ZodType; 8 | execute: (params: any) => Promise; 9 | } 10 | 11 | // Command interfaces 12 | export interface CommandParams { 13 | command: string; 14 | entity_id: string; 15 | // Common parameters 16 | state?: string; 17 | // Light parameters 18 | brightness?: number; 19 | color_temp?: number; 20 | rgb_color?: [number, number, number]; 21 | // Cover parameters 22 | position?: number; 23 | tilt_position?: number; 24 | // Climate parameters 25 | temperature?: number; 26 | target_temp_high?: number; 27 | target_temp_low?: number; 28 | hvac_mode?: string; 29 | fan_mode?: string; 30 | humidity?: number; 31 | } 32 | 33 | // Re-export Home Assistant types 34 | export type { 35 | HassInstance, 36 | HassStates, 37 | HassServices, 38 | HassConnection, 39 | HassService, 40 | HassEvent, 41 | HassEntity, 42 | HassState, 43 | } from "./hass.js"; 44 | 45 | // Home Assistant interfaces 46 | export interface HassAddon { 47 | name: string; 48 | slug: string; 49 | description: string; 50 | version: string; 51 | installed: boolean; 52 | available: boolean; 53 | state: string; 54 | } 55 | 56 | export interface HassAddonResponse { 57 | data: { 58 | addons: HassAddon[]; 59 | }; 60 | } 61 | 62 | export interface HassAddonInfoResponse { 63 | data: { 64 | name: string; 65 | slug: string; 66 | description: string; 67 | version: string; 68 | state: string; 69 | status: string; 70 | options: Record; 71 | [key: string]: any; 72 | }; 73 | } 74 | 75 | // HACS interfaces 76 | export interface HacsRepository { 77 | name: string; 78 | description: string; 79 | category: string; 80 | installed: boolean; 81 | version_installed: string; 82 | available_version: string; 83 | authors: string[]; 84 | domain: string; 85 | } 86 | 87 | export interface HacsResponse { 88 | repositories: HacsRepository[]; 89 | } 90 | 91 | // Automation interfaces 92 | export interface AutomationConfig { 93 | alias: string; 94 | description?: string; 95 | mode?: "single" | "parallel" | "queued" | "restart"; 96 | trigger: any[]; 97 | condition?: any[]; 98 | action: any[]; 99 | } 100 | 101 | export interface AutomationResponse { 102 | automation_id: string; 103 | } 104 | 105 | // SSE interfaces 106 | export interface SSEHeaders { 107 | onAbort?: () => void; 108 | } 109 | 110 | export interface SSEParams { 111 | token: string; 112 | events?: string[]; 113 | entity_id?: string; 114 | domain?: string; 115 | } 116 | 117 | // History interfaces 118 | export interface HistoryParams { 119 | entity_id: string; 120 | start_time?: string; 121 | end_time?: string; 122 | minimal_response?: boolean; 123 | significant_changes_only?: boolean; 124 | } 125 | 126 | // Scene interfaces 127 | export interface SceneParams { 128 | action: "list" | "activate"; 129 | scene_id?: string; 130 | } 131 | 132 | // Notification interfaces 133 | export interface NotifyParams { 134 | message: string; 135 | title?: string; 136 | target?: string; 137 | data?: Record; 138 | } 139 | 140 | // Automation parameter interfaces 141 | export interface AutomationParams { 142 | action: "list" | "toggle" | "trigger"; 143 | automation_id?: string; 144 | } 145 | 146 | export interface AddonParams { 147 | action: 148 | | "list" 149 | | "info" 150 | | "install" 151 | | "uninstall" 152 | | "start" 153 | | "stop" 154 | | "restart"; 155 | slug?: string; 156 | version?: string; 157 | } 158 | 159 | export interface PackageParams { 160 | action: "list" | "install" | "uninstall" | "update"; 161 | category: 162 | | "integration" 163 | | "plugin" 164 | | "theme" 165 | | "python_script" 166 | | "appdaemon" 167 | | "netdaemon"; 168 | repository?: string; 169 | version?: string; 170 | } 171 | 172 | export interface AutomationConfigParams { 173 | action: "create" | "update" | "delete" | "duplicate"; 174 | automation_id?: string; 175 | config?: { 176 | alias: string; 177 | description?: string; 178 | mode?: "single" | "parallel" | "queued" | "restart"; 179 | trigger: any[]; 180 | condition?: any[]; 181 | action: any[]; 182 | }; 183 | } 184 | -------------------------------------------------------------------------------- /src/mcp/BaseTool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Tool Implementation for MCP 3 | * 4 | * This base class provides the foundation for all tools in the MCP implementation, 5 | * with typed parameters, validation, and error handling. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { ToolDefinition, ToolMetadata, MCPResponseStream } from './types.js'; 10 | 11 | /** 12 | * Configuration options for creating a tool 13 | */ 14 | export interface ToolOptions

{ 15 | name: string; 16 | description: string; 17 | version: string; 18 | parameters?: z.ZodType

; 19 | metadata?: ToolMetadata; 20 | } 21 | 22 | /** 23 | * Base class for all MCP tools 24 | * 25 | * Provides: 26 | * - Parameter validation with Zod 27 | * - Error handling 28 | * - Streaming support 29 | * - Type safety 30 | */ 31 | export abstract class BaseTool

implements ToolDefinition { 32 | public readonly name: string; 33 | public readonly description: string; 34 | public readonly parameters?: z.ZodType

; 35 | public readonly metadata: ToolMetadata; 36 | 37 | /** 38 | * Create a new tool 39 | */ 40 | constructor(options: ToolOptions

) { 41 | this.name = options.name; 42 | this.description = options.description; 43 | this.parameters = options.parameters; 44 | this.metadata = { 45 | version: options.version, 46 | category: options.metadata?.category || 'general', 47 | tags: options.metadata?.tags || [], 48 | examples: options.metadata?.examples || [], 49 | }; 50 | } 51 | 52 | /** 53 | * Execute the tool with the given parameters 54 | * 55 | * @param params The validated parameters for the tool 56 | * @param stream Optional stream for sending partial results 57 | * @returns The result of the tool execution 58 | */ 59 | abstract execute(params: P, stream?: MCPResponseStream): Promise; 60 | 61 | /** 62 | * Get the parameter schema as JSON schema 63 | */ 64 | public getParameterSchema(): Record | undefined { 65 | if (!this.parameters) return undefined; 66 | return this.parameters.isOptional() 67 | ? { type: 'object', properties: {} } 68 | : this.parameters.shape; 69 | } 70 | 71 | /** 72 | * Get tool definition for registration 73 | */ 74 | public getDefinition(): ToolDefinition { 75 | return { 76 | name: this.name, 77 | description: this.description, 78 | parameters: this.parameters, 79 | metadata: this.metadata 80 | }; 81 | } 82 | 83 | /** 84 | * Validate parameters against the schema 85 | * 86 | * @param params Parameters to validate 87 | * @returns Validated parameters 88 | * @throws Error if validation fails 89 | */ 90 | protected validateParams(params: unknown): P { 91 | if (!this.parameters) { 92 | return {} as P; 93 | } 94 | 95 | try { 96 | return this.parameters.parse(params); 97 | } catch (error) { 98 | if (error instanceof z.ZodError) { 99 | const issues = error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join(', '); 100 | throw new Error(`Parameter validation failed: ${issues}`); 101 | } 102 | throw error; 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /src/mcp/litemcp.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | export class LiteMCP extends EventEmitter { 4 | private static instance: LiteMCP; 5 | private constructor() { 6 | super(); 7 | // Initialize with default configuration 8 | this.configure({}); 9 | } 10 | 11 | public static getInstance(): LiteMCP { 12 | if (!LiteMCP.instance) { 13 | LiteMCP.instance = new LiteMCP(); 14 | } 15 | return LiteMCP.instance; 16 | } 17 | 18 | public configure(config: Record): void { 19 | // Store configuration 20 | this.config = { 21 | ...this.defaultConfig, 22 | ...config, 23 | }; 24 | } 25 | 26 | private config: Record = {}; 27 | private defaultConfig = { 28 | maxRetries: 3, 29 | retryDelay: 1000, 30 | timeout: 5000, 31 | }; 32 | 33 | public async execute( 34 | command: string, 35 | params: Record = {}, 36 | ): Promise { 37 | try { 38 | // Emit command execution event 39 | this.emit("command", { command, params }); 40 | 41 | // Execute command logic here 42 | const result = await this.processCommand(command, params); 43 | 44 | // Emit success event 45 | this.emit("success", { command, params, result }); 46 | 47 | return result; 48 | } catch (error) { 49 | // Emit error event 50 | this.emit("error", { command, params, error }); 51 | throw error; 52 | } 53 | } 54 | 55 | private async processCommand( 56 | command: string, 57 | params: Record, 58 | ): Promise { 59 | // Command processing logic 60 | return { command, params, status: "processed" }; 61 | } 62 | 63 | public async shutdown(): Promise { 64 | // Cleanup logic 65 | this.removeAllListeners(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/mcp/transport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Transport for MCP 3 | * 4 | * This module provides a base class for all transport implementations. 5 | */ 6 | 7 | import { TransportLayer, MCPRequest, MCPResponse, MCPStreamPart, MCPNotification } from "./types.js"; 8 | 9 | /** 10 | * Abstract base class for all transports 11 | */ 12 | export abstract class BaseTransport implements TransportLayer { 13 | public name: string = "base"; 14 | protected handler: ((request: MCPRequest) => Promise) | null = null; 15 | 16 | /** 17 | * Initialize the transport with a request handler 18 | */ 19 | public initialize(handler: (request: MCPRequest) => Promise): void { 20 | this.handler = handler; 21 | } 22 | 23 | /** 24 | * Start the transport 25 | */ 26 | public abstract start(): Promise; 27 | 28 | /** 29 | * Stop the transport 30 | */ 31 | public abstract stop(): Promise; 32 | 33 | /** 34 | * Send a notification to a client 35 | */ 36 | public sendNotification?(notification: MCPNotification): void; 37 | 38 | /** 39 | * Send a streaming response part 40 | */ 41 | public sendStreamPart?(streamPart: MCPStreamPart): void; 42 | } -------------------------------------------------------------------------------- /src/mcp/utils/claude.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Claude Integration Utilities 3 | * 4 | * This file contains utilities for integrating with Claude AI models. 5 | */ 6 | 7 | import { z } from 'zod'; 8 | import { ToolDefinition } from '../types.js'; 9 | 10 | /** 11 | * Convert a Zod schema to a JSON Schema for Claude 12 | */ 13 | export function zodToJsonSchema(schema: z.ZodType): any { 14 | if (!schema) return { type: 'object', properties: {} }; 15 | 16 | // Handle ZodObject 17 | if (schema instanceof z.ZodObject) { 18 | const shape = (schema as any)._def.shape(); 19 | const properties: Record = {}; 20 | const required: string[] = []; 21 | 22 | for (const [key, value] of Object.entries(shape)) { 23 | if (!(value instanceof z.ZodOptional)) { 24 | required.push(key); 25 | } 26 | 27 | properties[key] = zodTypeToJsonSchema(value as z.ZodType); 28 | } 29 | 30 | return { 31 | type: 'object', 32 | properties, 33 | required: required.length > 0 ? required : undefined 34 | }; 35 | } 36 | 37 | // Handle other schema types 38 | return { type: 'object', properties: {} }; 39 | } 40 | 41 | /** 42 | * Convert a Zod type to JSON Schema type 43 | */ 44 | export function zodTypeToJsonSchema(zodType: z.ZodType): any { 45 | if (zodType instanceof z.ZodString) { 46 | return { type: 'string' }; 47 | } else if (zodType instanceof z.ZodNumber) { 48 | return { type: 'number' }; 49 | } else if (zodType instanceof z.ZodBoolean) { 50 | return { type: 'boolean' }; 51 | } else if (zodType instanceof z.ZodArray) { 52 | return { 53 | type: 'array', 54 | items: zodTypeToJsonSchema((zodType as any)._def.type) 55 | }; 56 | } else if (zodType instanceof z.ZodEnum) { 57 | return { 58 | type: 'string', 59 | enum: (zodType as any)._def.values 60 | }; 61 | } else if (zodType instanceof z.ZodOptional) { 62 | return zodTypeToJsonSchema((zodType as any)._def.innerType); 63 | } else if (zodType instanceof z.ZodObject) { 64 | return zodToJsonSchema(zodType); 65 | } 66 | 67 | return { type: 'object' }; 68 | } 69 | 70 | /** 71 | * Create Claude-compatible tool definitions from MCP tools 72 | * 73 | * @param tools Array of MCP tool definitions 74 | * @returns Array of Claude-compatible tool definitions 75 | */ 76 | export function createClaudeToolDefinitions(tools: ToolDefinition[]): any[] { 77 | return tools.map(tool => { 78 | const parameters = tool.parameters 79 | ? zodToJsonSchema(tool.parameters) 80 | : { type: 'object', properties: {} }; 81 | 82 | return { 83 | name: tool.name, 84 | description: tool.description, 85 | parameters 86 | }; 87 | }); 88 | } 89 | 90 | /** 91 | * Format an MCP tool execution request for Claude 92 | */ 93 | export function formatToolExecutionRequest(toolName: string, params: Record): any { 94 | return { 95 | type: 'tool_use', 96 | name: toolName, 97 | parameters: params 98 | }; 99 | } 100 | 101 | /** 102 | * Parse a Claude tool execution response 103 | */ 104 | export function parseToolExecutionResponse(response: any): { 105 | success: boolean; 106 | result?: any; 107 | error?: string; 108 | } { 109 | if (!response || typeof response !== 'object') { 110 | return { 111 | success: false, 112 | error: 'Invalid tool execution response' 113 | }; 114 | } 115 | 116 | if ('error' in response) { 117 | return { 118 | success: false, 119 | error: typeof response.error === 'string' 120 | ? response.error 121 | : JSON.stringify(response.error) 122 | }; 123 | } 124 | 125 | return { 126 | success: true, 127 | result: response 128 | }; 129 | } -------------------------------------------------------------------------------- /src/mcp/utils/cursor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cursor Integration Utilities 3 | * 4 | * This file contains utilities for integrating with Cursor IDE. 5 | */ 6 | 7 | import { z } from 'zod'; 8 | import { ToolDefinition } from '../types.js'; 9 | 10 | /** 11 | * Create Cursor-compatible tool definitions from MCP tools 12 | * 13 | * @param tools Array of MCP tool definitions 14 | * @returns Array of Cursor-compatible tool definitions 15 | */ 16 | export function createCursorToolDefinitions(tools: ToolDefinition[]): any[] { 17 | return tools.map(tool => { 18 | // Convert parameters to Cursor format 19 | const parameters = tool.parameters 20 | ? extractParametersFromZod(tool.parameters) 21 | : {}; 22 | 23 | return { 24 | name: tool.name, 25 | description: tool.description, 26 | parameters 27 | }; 28 | }); 29 | } 30 | 31 | /** 32 | * Extract parameters from a Zod schema for Cursor integration 33 | */ 34 | function extractParametersFromZod(schema: z.ZodType): Record { 35 | if (!(schema instanceof z.ZodObject)) { 36 | return {}; 37 | } 38 | 39 | const shape = (schema as any)._def.shape(); 40 | const params: Record = {}; 41 | 42 | for (const [key, value] of Object.entries(shape)) { 43 | const isRequired = !(value instanceof z.ZodOptional); 44 | 45 | let type = 'string'; 46 | let description = ''; 47 | 48 | // Get description if available 49 | try { 50 | description = value._def.description || ''; 51 | } catch (e) { 52 | // Ignore if description is not available 53 | } 54 | 55 | // Determine the type 56 | if (value instanceof z.ZodString) { 57 | type = 'string'; 58 | } else if (value instanceof z.ZodNumber) { 59 | type = 'number'; 60 | } else if (value instanceof z.ZodBoolean) { 61 | type = 'boolean'; 62 | } else if (value instanceof z.ZodArray) { 63 | type = 'array'; 64 | } else if (value instanceof z.ZodEnum) { 65 | type = 'string'; 66 | } else if (value instanceof z.ZodObject) { 67 | type = 'object'; 68 | } else if (value instanceof z.ZodOptional) { 69 | // Get the inner type 70 | const innerValue = value._def.innerType; 71 | if (innerValue instanceof z.ZodString) { 72 | type = 'string'; 73 | } else if (innerValue instanceof z.ZodNumber) { 74 | type = 'number'; 75 | } else if (innerValue instanceof z.ZodBoolean) { 76 | type = 'boolean'; 77 | } else if (innerValue instanceof z.ZodArray) { 78 | type = 'array'; 79 | } else { 80 | type = 'object'; 81 | } 82 | } 83 | 84 | params[key] = { 85 | type, 86 | description, 87 | required: isRequired 88 | }; 89 | } 90 | 91 | return params; 92 | } 93 | 94 | /** 95 | * Format a tool response for Cursor 96 | */ 97 | export function formatCursorResponse(response: any): any { 98 | // For now, just return the response as-is 99 | // Cursor expects a specific format, which may need to be customized 100 | return response; 101 | } 102 | 103 | /** 104 | * Parse a Cursor tool execution request 105 | */ 106 | export function parseCursorRequest(request: any): { 107 | success: boolean; 108 | toolName?: string; 109 | params?: Record; 110 | error?: string; 111 | } { 112 | if (!request || typeof request !== 'object') { 113 | return { 114 | success: false, 115 | error: 'Invalid request format' 116 | }; 117 | } 118 | 119 | if (!request.name || typeof request.name !== 'string') { 120 | return { 121 | success: false, 122 | error: 'Missing or invalid tool name' 123 | }; 124 | } 125 | 126 | return { 127 | success: true, 128 | toolName: request.name, 129 | params: request.parameters || {} 130 | }; 131 | } -------------------------------------------------------------------------------- /src/middleware/logging.middleware.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logging Middleware 3 | * 4 | * This middleware provides request logging functionality. 5 | * It logs incoming requests and their responses. 6 | * 7 | * @module logging-middleware 8 | */ 9 | 10 | import { Request, Response, NextFunction } from "express"; 11 | import { logger } from "../utils/logger.js"; 12 | import { APP_CONFIG } from "../config/app.config.js"; 13 | 14 | /** 15 | * Interface for extended request object with timing information 16 | */ 17 | interface TimedRequest extends Request { 18 | startTime?: number; 19 | } 20 | 21 | /** 22 | * Calculate the response time in milliseconds 23 | * @param startTime - Start time in milliseconds 24 | * @returns Response time in milliseconds 25 | */ 26 | const getResponseTime = (startTime: number): number => { 27 | const NS_PER_SEC = 1e9; // nanoseconds per second 28 | const NS_TO_MS = 1e6; // nanoseconds to milliseconds 29 | const diff = process.hrtime(); 30 | return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS - startTime; 31 | }; 32 | 33 | /** 34 | * Get client IP address from request 35 | * @param req - Express request object 36 | * @returns Client IP address 37 | */ 38 | const getClientIp = (req: Request): string => { 39 | return ( 40 | (req.headers["x-forwarded-for"] as string)?.split(",")[0] || 41 | req.socket.remoteAddress || 42 | "unknown" 43 | ); 44 | }; 45 | 46 | /** 47 | * Format log message for request 48 | * @param req - Express request object 49 | * @returns Formatted log message 50 | */ 51 | const formatRequestLog = (req: TimedRequest): string => { 52 | return `${req.method} ${req.originalUrl} - IP: ${getClientIp(req)}`; 53 | }; 54 | 55 | /** 56 | * Format log message for response 57 | * @param req - Express request object 58 | * @param res - Express response object 59 | * @param time - Response time in milliseconds 60 | * @returns Formatted log message 61 | */ 62 | const formatResponseLog = ( 63 | req: TimedRequest, 64 | res: Response, 65 | time: number, 66 | ): string => { 67 | return `${req.method} ${req.originalUrl} - ${res.statusCode} - ${time.toFixed(2)}ms`; 68 | }; 69 | 70 | /** 71 | * Request logging middleware 72 | * Logs information about incoming requests and their responses 73 | */ 74 | export const requestLogger = ( 75 | req: TimedRequest, 76 | res: Response, 77 | next: NextFunction, 78 | ): void => { 79 | if (!APP_CONFIG.LOGGING.LOG_REQUESTS) { 80 | next(); 81 | return; 82 | } 83 | 84 | // Record start time 85 | req.startTime = Date.now(); 86 | 87 | // Log request 88 | logger.http(formatRequestLog(req)); 89 | 90 | // Log response 91 | res.on("finish", () => { 92 | const responseTime = Date.now() - (req.startTime || 0); 93 | const logLevel = res.statusCode >= 400 ? "warn" : "http"; 94 | logger[logLevel](formatResponseLog(req, res, responseTime)); 95 | }); 96 | 97 | next(); 98 | }; 99 | 100 | /** 101 | * Error logging middleware 102 | * Logs errors that occur during request processing 103 | */ 104 | export const errorLogger = ( 105 | err: Error, 106 | req: Request, 107 | res: Response, 108 | next: NextFunction, 109 | ): void => { 110 | logger.error( 111 | `Error processing ${req.method} ${req.originalUrl}: ${err.message}`, 112 | { 113 | error: err.stack, 114 | method: req.method, 115 | url: req.originalUrl, 116 | body: req.body, 117 | query: req.query, 118 | ip: getClientIp(req), 119 | }, 120 | ); 121 | next(err); 122 | }; 123 | -------------------------------------------------------------------------------- /src/middleware/rate-limit.middleware.ts: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | import { APP_CONFIG } from '../config.js'; 3 | 4 | // Create a limiter for API endpoints 5 | export const apiLimiter = rateLimit({ 6 | windowMs: 15 * 60 * 1000, // 15 minutes 7 | max: APP_CONFIG.rateLimit?.maxRequests || 100, // Limit each IP to 100 requests per windowMs 8 | message: { 9 | status: 'error', 10 | message: 'Too many requests from this IP, please try again later.' 11 | }, 12 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 13 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 14 | }); 15 | 16 | // Create a stricter limiter for authentication endpoints 17 | export const authLimiter = rateLimit({ 18 | windowMs: 60 * 60 * 1000, // 1 hour 19 | max: APP_CONFIG.rateLimit?.maxAuthRequests || 5, // Limit each IP to 5 login requests per hour 20 | message: { 21 | status: 'error', 22 | message: 'Too many login attempts from this IP, please try again later.' 23 | }, 24 | standardHeaders: true, 25 | legacyHeaders: false, 26 | }); -------------------------------------------------------------------------------- /src/polyfills.js: -------------------------------------------------------------------------------- 1 | // Add necessary polyfills for Node.js compatibility in Bun 2 | import { webcrypto } from 'node:crypto'; 3 | 4 | // Polyfill for crypto.subtle in Bun 5 | if (!globalThis.crypto?.subtle) { 6 | globalThis.crypto = webcrypto; 7 | } 8 | 9 | // Add any other necessary polyfills here -------------------------------------------------------------------------------- /src/routes/health.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { APP_CONFIG } from "../config/app.config.js"; 3 | 4 | const router = Router(); 5 | 6 | // Health check endpoint 7 | router.get("/", (_req, res) => { 8 | res.json({ 9 | status: "ok", 10 | timestamp: new Date().toISOString(), 11 | version: APP_CONFIG.VERSION, 12 | }); 13 | }); 14 | 15 | export { router as healthRoutes }; 16 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Routes Module 3 | * 4 | * This module exports the main router that combines all API routes 5 | * into a single router instance. Each route group is mounted under 6 | * its respective path prefix. 7 | * 8 | * @module routes 9 | */ 10 | 11 | import { Router } from "express"; 12 | import { mcpRoutes } from "./mcp.routes.js"; 13 | import { sseRoutes } from "./sse.routes.js"; 14 | import { toolRoutes } from "./tool.routes.js"; 15 | import { healthRoutes } from "./health.routes.js"; 16 | 17 | /** 18 | * Create main router instance 19 | * This router will be mounted at /api in the main application 20 | */ 21 | const router = Router(); 22 | 23 | /** 24 | * Mount all route groups 25 | * - /mcp: MCP schema and execution endpoints 26 | * - /sse: Server-Sent Events endpoints 27 | * - /tools: Tool management endpoints 28 | * - /health: Health check endpoint 29 | */ 30 | router.use("/mcp", mcpRoutes); 31 | router.use("/sse", sseRoutes); 32 | router.use("/tools", toolRoutes); 33 | router.use("/health", healthRoutes); 34 | 35 | /** 36 | * Export the configured router 37 | * This will be mounted in the main application 38 | */ 39 | export { router as apiRoutes }; 40 | -------------------------------------------------------------------------------- /src/routes/mcp.routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MCP Routes Module 3 | * 4 | * This module provides routes for accessing and executing MCP functionality. 5 | * It includes endpoints for retrieving the MCP schema and executing MCP tools. 6 | * 7 | * @module mcp-routes 8 | */ 9 | 10 | import { Router } from "express"; 11 | import { MCP_SCHEMA } from "../mcp/schema.js"; 12 | import { APP_CONFIG } from "../config/app.config.js"; 13 | import { Tool } from "../types/index.js"; 14 | 15 | /** 16 | * Create router instance for MCP routes 17 | */ 18 | const router = Router(); 19 | 20 | /** 21 | * Array to track registered tools 22 | * Tools are added to this array when they are registered with the MCP 23 | */ 24 | const tools: Tool[] = []; 25 | 26 | /** 27 | * GET /mcp 28 | * Returns the MCP schema without requiring authentication 29 | * This endpoint allows clients to discover available tools and their parameters 30 | */ 31 | router.get("/", (_req, res) => { 32 | res.json(MCP_SCHEMA); 33 | }); 34 | 35 | /** 36 | * POST /mcp/execute 37 | * Execute a tool with the provided parameters 38 | * Requires authentication via Bearer token 39 | * 40 | * @param {Object} req.body.tool - Name of the tool to execute 41 | * @param {Object} req.body.parameters - Parameters for the tool 42 | * @returns {Object} Tool execution result 43 | * @throws {401} If authentication fails 44 | * @throws {404} If tool is not found 45 | * @throws {500} If execution fails 46 | */ 47 | router.post("/execute", async (req, res) => { 48 | try { 49 | // Get token from Authorization header 50 | const token = req.headers.authorization?.replace("Bearer ", ""); 51 | 52 | if (!token || token !== APP_CONFIG.HASS_TOKEN) { 53 | return res.status(401).json({ 54 | success: false, 55 | message: "Unauthorized - Invalid token", 56 | }); 57 | } 58 | 59 | const { tool: toolName, parameters } = req.body; 60 | 61 | // Find the requested tool 62 | const tool = tools.find((t) => t.name === toolName); 63 | if (!tool) { 64 | return res.status(404).json({ 65 | success: false, 66 | message: `Tool '${toolName}' not found`, 67 | }); 68 | } 69 | 70 | // Execute the tool with the provided parameters 71 | const result = await tool.execute(parameters); 72 | res.json(result); 73 | } catch (error) { 74 | res.status(500).json({ 75 | success: false, 76 | message: 77 | error instanceof Error ? error.message : "Unknown error occurred", 78 | }); 79 | } 80 | }); 81 | 82 | /** 83 | * Export the configured router 84 | * This will be mounted under /api/mcp in the main application 85 | */ 86 | export { router as mcpRoutes }; 87 | -------------------------------------------------------------------------------- /src/routes/sse.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { sseManager } from "../sse/index.js"; 4 | import { TokenManager } from "../security/index.js"; 5 | import { middleware } from "../middleware/index.js"; 6 | 7 | const router = Router(); 8 | 9 | // SSE endpoints 10 | router.get("/subscribe_events", middleware.wsRateLimiter, (req, res) => { 11 | try { 12 | // Get token from query parameter and validate 13 | const token = req.query.token?.toString() || ""; 14 | const clientIp = req.ip || req.socket.remoteAddress || ""; 15 | const validationResult = TokenManager.validateToken(token, clientIp); 16 | 17 | if (!validationResult.valid) { 18 | return res.status(401).json({ 19 | success: false, 20 | message: "Unauthorized", 21 | error: validationResult.error, 22 | timestamp: new Date().toISOString(), 23 | }); 24 | } 25 | 26 | // Set SSE headers with enhanced security 27 | res.writeHead(200, { 28 | "Content-Type": "text/event-stream", 29 | "Cache-Control": "no-cache, no-transform", 30 | Connection: "keep-alive", 31 | "X-Accel-Buffering": "no", 32 | "Access-Control-Allow-Origin": "*", 33 | "Access-Control-Allow-Credentials": "true", 34 | }); 35 | 36 | // Send initial connection message 37 | res.write( 38 | `data: ${JSON.stringify({ 39 | type: "connection", 40 | status: "connected", 41 | timestamp: new Date().toISOString(), 42 | })}\n\n`, 43 | ); 44 | 45 | const clientId = uuidv4(); 46 | const client = { 47 | id: clientId, 48 | ip: clientIp, 49 | connectedAt: new Date(), 50 | send: (data: string) => { 51 | res.write(`data: ${data}\n\n`); 52 | }, 53 | }; 54 | 55 | // Add client to SSE manager with enhanced tracking 56 | const sseClient = sseManager.addClient(client, token); 57 | if (!sseClient || !sseClient.authenticated) { 58 | const errorMessage = JSON.stringify({ 59 | type: "error", 60 | message: sseClient 61 | ? "Authentication failed" 62 | : "Maximum client limit reached", 63 | timestamp: new Date().toISOString(), 64 | }); 65 | res.write(`data: ${errorMessage}\n\n`); 66 | return res.end(); 67 | } 68 | 69 | // Handle client disconnect 70 | req.on("close", () => { 71 | sseManager.removeClient(clientId); 72 | console.log( 73 | `Client ${clientId} disconnected at ${new Date().toISOString()}`, 74 | ); 75 | }); 76 | 77 | // Handle errors 78 | req.on("error", (error) => { 79 | console.error(`SSE Error for client ${clientId}:`, error); 80 | const errorMessage = JSON.stringify({ 81 | type: "error", 82 | message: "Connection error", 83 | timestamp: new Date().toISOString(), 84 | }); 85 | res.write(`data: ${errorMessage}\n\n`); 86 | sseManager.removeClient(clientId); 87 | res.end(); 88 | }); 89 | } catch (error) { 90 | console.error("SSE Setup Error:", error); 91 | res.status(500).json({ 92 | success: false, 93 | message: "Internal Server Error", 94 | error: 95 | error instanceof Error ? error.message : "An unexpected error occurred", 96 | timestamp: new Date().toISOString(), 97 | }); 98 | } 99 | }); 100 | 101 | // Get SSE stats endpoint 102 | router.get("/stats", async (req, res) => { 103 | try { 104 | const stats = await sseManager.getStatistics(); 105 | res.json(stats); 106 | } catch (error) { 107 | res.status(500).json({ 108 | success: false, 109 | message: 110 | error instanceof Error ? error.message : "Unknown error occurred", 111 | }); 112 | } 113 | }); 114 | 115 | export default router; 116 | -------------------------------------------------------------------------------- /src/routes/tool.routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { APP_CONFIG } from "../config/app.config.js"; 3 | import { Tool } from "../types/index.js"; 4 | 5 | const router = Router(); 6 | 7 | // Array to track tools 8 | const tools: Tool[] = []; 9 | 10 | // List devices endpoint 11 | router.get("/devices", async (req, res) => { 12 | try { 13 | // Get token from Authorization header 14 | const token = req.headers.authorization?.replace("Bearer ", ""); 15 | 16 | if (!token || token !== APP_CONFIG.HASS_TOKEN) { 17 | return res.status(401).json({ 18 | success: false, 19 | message: "Unauthorized - Invalid token", 20 | }); 21 | } 22 | 23 | const tool = tools.find((t) => t.name === "list_devices"); 24 | if (!tool) { 25 | return res.status(404).json({ 26 | success: false, 27 | message: "Tool not found", 28 | }); 29 | } 30 | 31 | const result = await tool.execute({ token }); 32 | res.json(result); 33 | } catch (error) { 34 | res.status(500).json({ 35 | success: false, 36 | message: 37 | error instanceof Error ? error.message : "Unknown error occurred", 38 | }); 39 | } 40 | }); 41 | 42 | // Control device endpoint 43 | router.post("/control", async (req, res) => { 44 | try { 45 | // Get token from Authorization header 46 | const token = req.headers.authorization?.replace("Bearer ", ""); 47 | 48 | if (!token || token !== APP_CONFIG.HASS_TOKEN) { 49 | return res.status(401).json({ 50 | success: false, 51 | message: "Unauthorized - Invalid token", 52 | }); 53 | } 54 | 55 | const tool = tools.find((t) => t.name === "control"); 56 | if (!tool) { 57 | return res.status(404).json({ 58 | success: false, 59 | message: "Tool not found", 60 | }); 61 | } 62 | 63 | const result = await tool.execute({ 64 | ...req.body, 65 | token, 66 | }); 67 | res.json(result); 68 | } catch (error) { 69 | res.status(500).json({ 70 | success: false, 71 | message: 72 | error instanceof Error ? error.message : "Unknown error occurred", 73 | }); 74 | } 75 | }); 76 | 77 | export { router as toolRoutes }; 78 | -------------------------------------------------------------------------------- /src/schemas/config.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const RateLimitSchema = z.object({ 4 | maxRequests: z.number().int().min(1).default(100), 5 | maxAuthRequests: z.number().int().min(1).default(5), 6 | }); 7 | 8 | export const MCPServerConfigSchema = z.object({ 9 | // Server configuration 10 | port: z.number().int().min(1).max(65535).default(3000), 11 | environment: z.enum(['development', 'test', 'production']).default('development'), 12 | 13 | // Execution settings 14 | executionTimeout: z.number().int().min(1000).max(300000).default(30000), 15 | streamingEnabled: z.boolean().default(false), 16 | 17 | // Transport settings 18 | useStdioTransport: z.boolean().default(false), 19 | useHttpTransport: z.boolean().default(true), 20 | 21 | // Debug and logging 22 | debugMode: z.boolean().default(false), 23 | debugStdio: z.boolean().default(false), 24 | debugHttp: z.boolean().default(false), 25 | silentStartup: z.boolean().default(false), 26 | 27 | // CORS settings 28 | corsOrigin: z.string().default('*'), 29 | 30 | // Rate limiting 31 | rateLimit: RateLimitSchema.default({ 32 | maxRequests: 100, 33 | maxAuthRequests: 5, 34 | }), 35 | 36 | // Speech features 37 | speech: z.object({ 38 | enabled: z.boolean().default(false), 39 | wakeWord: z.object({ 40 | enabled: z.boolean().default(false), 41 | threshold: z.number().min(0).max(1).default(0.05), 42 | }), 43 | asr: z.object({ 44 | enabled: z.boolean().default(false), 45 | model: z.enum(['base', 'small', 'medium', 'large']).default('base'), 46 | engine: z.enum(['faster_whisper', 'whisper']).default('faster_whisper'), 47 | beamSize: z.number().int().min(1).max(10).default(5), 48 | computeType: z.enum(['float32', 'float16', 'int8']).default('float32'), 49 | language: z.string().default('en'), 50 | }), 51 | audio: z.object({ 52 | minSpeechDuration: z.number().min(0.1).max(10).default(1.0), 53 | silenceDuration: z.number().min(0.1).max(5).default(0.5), 54 | sampleRate: z.number().int().min(8000).max(48000).default(16000), 55 | channels: z.number().int().min(1).max(2).default(1), 56 | chunkSize: z.number().int().min(256).max(4096).default(1024), 57 | }), 58 | }).default({ 59 | enabled: false, 60 | wakeWord: { enabled: false, threshold: 0.05 }, 61 | asr: { 62 | enabled: false, 63 | model: 'base', 64 | engine: 'faster_whisper', 65 | beamSize: 5, 66 | computeType: 'float32', 67 | language: 'en', 68 | }, 69 | audio: { 70 | minSpeechDuration: 1.0, 71 | silenceDuration: 0.5, 72 | sampleRate: 16000, 73 | channels: 1, 74 | chunkSize: 1024, 75 | }, 76 | }), 77 | }); 78 | 79 | export type MCPServerConfigType = z.infer; -------------------------------------------------------------------------------- /src/schemas/hass.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // Entity Schema 4 | const entitySchema = z.object({ 5 | entity_id: z.string().regex(/^[a-z0-9_]+\.[a-z0-9_]+$/), 6 | state: z.string(), 7 | attributes: z.record(z.any()), 8 | last_changed: z.string(), 9 | last_updated: z.string(), 10 | context: z.object({ 11 | id: z.string(), 12 | parent_id: z.string().nullable(), 13 | user_id: z.string().nullable() 14 | }) 15 | }); 16 | 17 | // Service Schema 18 | const serviceSchema = z.object({ 19 | domain: z.string().min(1), 20 | service: z.string().min(1), 21 | target: z.object({ 22 | entity_id: z.union([z.string(), z.array(z.string())]), 23 | device_id: z.union([z.string(), z.array(z.string())]).optional(), 24 | area_id: z.union([z.string(), z.array(z.string())]).optional() 25 | }).optional(), 26 | service_data: z.record(z.any()).optional() 27 | }); 28 | 29 | // State Changed Event Schema 30 | const stateChangedEventSchema = z.object({ 31 | event_type: z.literal('state_changed'), 32 | data: z.object({ 33 | entity_id: z.string(), 34 | old_state: z.union([entitySchema, z.null()]), 35 | new_state: entitySchema 36 | }), 37 | origin: z.string(), 38 | time_fired: z.string(), 39 | context: z.object({ 40 | id: z.string(), 41 | parent_id: z.string().nullable(), 42 | user_id: z.string().nullable() 43 | }) 44 | }); 45 | 46 | // Config Schema 47 | const configSchema = z.object({ 48 | location_name: z.string(), 49 | time_zone: z.string(), 50 | components: z.array(z.string()), 51 | version: z.string() 52 | }); 53 | 54 | // Device Control Schema 55 | const deviceControlSchema = z.object({ 56 | domain: z.string().min(1), 57 | command: z.string().min(1), 58 | entity_id: z.union([z.string(), z.array(z.string())]), 59 | parameters: z.record(z.any()).optional() 60 | }).refine(data => { 61 | if (typeof data.entity_id === 'string') { 62 | return data.entity_id.startsWith(data.domain + '.'); 63 | } 64 | return data.entity_id.every(id => id.startsWith(data.domain + '.')); 65 | }, { 66 | message: 'entity_id must match the domain' 67 | }); 68 | 69 | // Validation functions 70 | export const validateEntity = (data: unknown) => { 71 | const result = entitySchema.safeParse(data); 72 | return { success: result.success, error: result.success ? undefined : result.error }; 73 | }; 74 | 75 | export const validateService = (data: unknown) => { 76 | const result = serviceSchema.safeParse(data); 77 | return { success: result.success, error: result.success ? undefined : result.error }; 78 | }; 79 | 80 | export const validateStateChangedEvent = (data: unknown) => { 81 | const result = stateChangedEventSchema.safeParse(data); 82 | return { success: result.success, error: result.success ? undefined : result.error }; 83 | }; 84 | 85 | export const validateConfig = (data: unknown) => { 86 | const result = configSchema.safeParse(data); 87 | return { success: result.success, error: result.success ? undefined : result.error }; 88 | }; 89 | 90 | export const validateDeviceControl = (data: unknown) => { 91 | const result = deviceControlSchema.safeParse(data); 92 | return { success: result.success, error: result.success ? undefined : result.error }; 93 | }; -------------------------------------------------------------------------------- /src/speech/__tests__/fixtures/test.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jango-blockchained/advanced-homeassistant-mcp/2368a39d11626bf875840aa515efadbc7bad8c4d/src/speech/__tests__/fixtures/test.wav -------------------------------------------------------------------------------- /src/speech/__tests__/speechToText.test.ts: -------------------------------------------------------------------------------- 1 | import { SpeechToText, WakeWordEvent, TranscriptionError } from '../speechToText'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | describe('SpeechToText', () => { 6 | let speechToText: SpeechToText; 7 | const testAudioDir = path.join(__dirname, 'test_audio'); 8 | 9 | beforeEach(() => { 10 | speechToText = new SpeechToText('fast-whisper'); 11 | // Create test audio directory if it doesn't exist 12 | if (!fs.existsSync(testAudioDir)) { 13 | fs.mkdirSync(testAudioDir, { recursive: true }); 14 | } 15 | }); 16 | 17 | afterEach(() => { 18 | speechToText.stopWakeWordDetection(); 19 | // Clean up test files 20 | if (fs.existsSync(testAudioDir)) { 21 | fs.rmSync(testAudioDir, { recursive: true, force: true }); 22 | } 23 | }); 24 | 25 | describe('checkHealth', () => { 26 | it('should handle Docker not being available', async () => { 27 | const isHealthy = await speechToText.checkHealth(); 28 | expect(isHealthy).toBeDefined(); 29 | expect(isHealthy).toBe(false); 30 | }); 31 | }); 32 | 33 | describe('wake word detection', () => { 34 | it('should detect new audio files and emit wake word events', (done) => { 35 | const testFile = path.join(testAudioDir, 'wake_word_test_123456.wav'); 36 | const testMetadata = `${testFile}.json`; 37 | 38 | speechToText.startWakeWordDetection(testAudioDir); 39 | 40 | speechToText.on('wake_word', (event: WakeWordEvent) => { 41 | expect(event).toBeDefined(); 42 | expect(event.audioFile).toBe(testFile); 43 | expect(event.metadataFile).toBe(testMetadata); 44 | expect(event.timestamp).toBe('123456'); 45 | done(); 46 | }); 47 | 48 | // Create a test audio file to trigger the event 49 | fs.writeFileSync(testFile, 'test audio content'); 50 | }, 1000); 51 | 52 | it('should handle transcription errors when Docker is not available', (done) => { 53 | const testFile = path.join(testAudioDir, 'wake_word_test_123456.wav'); 54 | 55 | let errorEmitted = false; 56 | let wakeWordEmitted = false; 57 | 58 | const checkDone = () => { 59 | if (errorEmitted && wakeWordEmitted) { 60 | done(); 61 | } 62 | }; 63 | 64 | speechToText.on('error', (error) => { 65 | expect(error).toBeDefined(); 66 | expect(error).toBeInstanceOf(TranscriptionError); 67 | expect(error.message).toContain('Failed to start Docker process'); 68 | errorEmitted = true; 69 | checkDone(); 70 | }); 71 | 72 | speechToText.on('wake_word', () => { 73 | wakeWordEmitted = true; 74 | checkDone(); 75 | }); 76 | 77 | speechToText.startWakeWordDetection(testAudioDir); 78 | 79 | // Create a test audio file to trigger the event 80 | fs.writeFileSync(testFile, 'test audio content'); 81 | }, 1000); 82 | }); 83 | 84 | describe('transcribeAudio', () => { 85 | it('should handle Docker not being available for transcription', async () => { 86 | await expect( 87 | speechToText.transcribeAudio('/audio/test.wav') 88 | ).rejects.toThrow(TranscriptionError); 89 | }); 90 | 91 | it('should emit progress events on error', (done) => { 92 | let progressEmitted = false; 93 | let errorThrown = false; 94 | 95 | const checkDone = () => { 96 | if (progressEmitted && errorThrown) { 97 | done(); 98 | } 99 | }; 100 | 101 | speechToText.on('progress', (event: { type: string; data: string }) => { 102 | expect(event.type).toBe('stderr'); 103 | expect(event.data).toBe('Failed to start Docker process'); 104 | progressEmitted = true; 105 | checkDone(); 106 | }); 107 | 108 | speechToText.transcribeAudio('/audio/test.wav') 109 | .catch((error) => { 110 | expect(error).toBeInstanceOf(TranscriptionError); 111 | errorThrown = true; 112 | checkDone(); 113 | }); 114 | }, 1000); 115 | }); 116 | }); -------------------------------------------------------------------------------- /src/speech/index.ts: -------------------------------------------------------------------------------- 1 | import { APP_CONFIG } from "../config/app.config.js"; 2 | import { logger } from "../utils/logger.js"; 3 | import type { IWakeWordDetector, ISpeechToText } from "./types.js"; 4 | 5 | class SpeechService { 6 | private static instance: SpeechService | null = null; 7 | private isInitialized: boolean = false; 8 | private wakeWordDetector: IWakeWordDetector | null = null; 9 | private speechToText: ISpeechToText | null = null; 10 | 11 | private constructor() { } 12 | 13 | public static getInstance(): SpeechService { 14 | if (!SpeechService.instance) { 15 | SpeechService.instance = new SpeechService(); 16 | } 17 | return SpeechService.instance; 18 | } 19 | 20 | public async initialize(): Promise { 21 | if (this.isInitialized) { 22 | return; 23 | } 24 | 25 | if (!APP_CONFIG.SPEECH.ENABLED) { 26 | logger.info("Speech features are disabled. Skipping initialization."); 27 | return; 28 | } 29 | 30 | try { 31 | // Initialize components based on configuration 32 | if (APP_CONFIG.SPEECH.WAKE_WORD_ENABLED) { 33 | logger.info("Initializing wake word detection..."); 34 | // Dynamic import to avoid loading the module if not needed 35 | const { WakeWordDetector } = await import("./wakeWordDetector.js"); 36 | this.wakeWordDetector = new WakeWordDetector() as IWakeWordDetector; 37 | await this.wakeWordDetector.initialize(); 38 | } 39 | 40 | if (APP_CONFIG.SPEECH.SPEECH_TO_TEXT_ENABLED) { 41 | logger.info("Initializing speech-to-text..."); 42 | // Dynamic import to avoid loading the module if not needed 43 | const { SpeechToText } = await import("./speechToText.js"); 44 | this.speechToText = new SpeechToText({ 45 | modelPath: APP_CONFIG.SPEECH.WHISPER_MODEL_PATH, 46 | modelType: APP_CONFIG.SPEECH.WHISPER_MODEL_TYPE, 47 | }) as ISpeechToText; 48 | await this.speechToText.initialize(); 49 | } 50 | 51 | this.isInitialized = true; 52 | logger.info("Speech service initialized successfully"); 53 | } catch (error) { 54 | logger.error("Failed to initialize speech service:", error); 55 | throw error; 56 | } 57 | } 58 | 59 | public async shutdown(): Promise { 60 | if (!this.isInitialized) { 61 | return; 62 | } 63 | 64 | try { 65 | if (this.wakeWordDetector) { 66 | await this.wakeWordDetector.shutdown(); 67 | this.wakeWordDetector = null; 68 | } 69 | 70 | if (this.speechToText) { 71 | await this.speechToText.shutdown(); 72 | this.speechToText = null; 73 | } 74 | 75 | this.isInitialized = false; 76 | logger.info("Speech service shut down successfully"); 77 | } catch (error) { 78 | logger.error("Error during speech service shutdown:", error); 79 | throw error; 80 | } 81 | } 82 | 83 | public isEnabled(): boolean { 84 | return APP_CONFIG.SPEECH.ENABLED; 85 | } 86 | 87 | public isWakeWordEnabled(): boolean { 88 | return APP_CONFIG.SPEECH.WAKE_WORD_ENABLED; 89 | } 90 | 91 | public isSpeechToTextEnabled(): boolean { 92 | return APP_CONFIG.SPEECH.SPEECH_TO_TEXT_ENABLED; 93 | } 94 | 95 | public getWakeWordDetector(): IWakeWordDetector { 96 | if (!this.isInitialized || !this.wakeWordDetector) { 97 | throw new Error("Wake word detector is not initialized"); 98 | } 99 | return this.wakeWordDetector; 100 | } 101 | 102 | public getSpeechToText(): ISpeechToText { 103 | if (!this.isInitialized || !this.speechToText) { 104 | throw new Error("Speech-to-text is not initialized"); 105 | } 106 | return this.speechToText; 107 | } 108 | } 109 | 110 | export const speechService = SpeechService.getInstance(); -------------------------------------------------------------------------------- /src/speech/types.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | export interface IWakeWordDetector { 4 | initialize(): Promise; 5 | shutdown(): Promise; 6 | startListening(): Promise; 7 | stopListening(): Promise; 8 | } 9 | 10 | export interface ISpeechToText extends EventEmitter { 11 | initialize(): Promise; 12 | shutdown(): Promise; 13 | transcribe(audioData: Buffer): Promise; 14 | } 15 | 16 | export interface SpeechToTextConfig { 17 | modelPath: string; 18 | modelType: string; 19 | containerName?: string; 20 | } -------------------------------------------------------------------------------- /src/speech/wakeWordDetector.ts: -------------------------------------------------------------------------------- 1 | import { IWakeWordDetector } from "./types.js"; 2 | 3 | export class WakeWordDetector implements IWakeWordDetector { 4 | private isListening: boolean = false; 5 | private isInitialized: boolean = false; 6 | 7 | public async initialize(): Promise { 8 | if (this.isInitialized) { 9 | return; 10 | } 11 | // Initialization logic will be implemented here 12 | await this.setupDetector(); 13 | this.isInitialized = true; 14 | } 15 | 16 | public async shutdown(): Promise { 17 | if (this.isListening) { 18 | await this.stopListening(); 19 | } 20 | if (this.isInitialized) { 21 | await this.cleanupDetector(); 22 | this.isInitialized = false; 23 | } 24 | } 25 | 26 | public async startListening(): Promise { 27 | if (!this.isInitialized) { 28 | throw new Error("Wake word detector is not initialized"); 29 | } 30 | if (this.isListening) { 31 | return; 32 | } 33 | await this.startDetection(); 34 | this.isListening = true; 35 | } 36 | 37 | public async stopListening(): Promise { 38 | if (!this.isListening) { 39 | return; 40 | } 41 | await this.stopDetection(); 42 | this.isListening = false; 43 | } 44 | 45 | private async setupDetector(): Promise { 46 | // Setup logic will be implemented here 47 | await new Promise(resolve => setTimeout(resolve, 100)); // Placeholder 48 | } 49 | 50 | private async cleanupDetector(): Promise { 51 | // Cleanup logic will be implemented here 52 | await new Promise(resolve => setTimeout(resolve, 100)); // Placeholder 53 | } 54 | 55 | private async startDetection(): Promise { 56 | // Start detection logic will be implemented here 57 | await new Promise(resolve => setTimeout(resolve, 100)); // Placeholder 58 | } 59 | 60 | private async stopDetection(): Promise { 61 | // Stop detection logic will be implemented here 62 | await new Promise(resolve => setTimeout(resolve, 100)); // Placeholder 63 | } 64 | } -------------------------------------------------------------------------------- /src/sse/types.ts: -------------------------------------------------------------------------------- 1 | import type { Mock } from "bun:test"; 2 | 3 | export interface SSEClient { 4 | id: string; 5 | ip: string; 6 | connectedAt: Date; 7 | send: Mock<(data: string) => void>; 8 | rateLimit: { 9 | count: number; 10 | lastReset: number; 11 | }; 12 | connectionTime: number; 13 | } 14 | 15 | export interface HassEventData { 16 | [key: string]: unknown; 17 | } 18 | 19 | export interface SSEEvent { 20 | event_type: string; 21 | data: HassEventData; 22 | origin: string; 23 | time_fired: string; 24 | context: { 25 | id: string; 26 | [key: string]: unknown; 27 | }; 28 | } 29 | 30 | export interface SSEMessage { 31 | type: string; 32 | data?: unknown; 33 | error?: string; 34 | } 35 | 36 | export interface SSEManagerConfig { 37 | maxClients?: number; 38 | pingInterval?: number; 39 | cleanupInterval?: number; 40 | maxConnectionAge?: number; 41 | rateLimitWindow?: number; 42 | maxRequestsPerWindow?: number; 43 | } 44 | 45 | export type MockSendFn = (data: string) => void; 46 | export type MockSend = Mock; 47 | 48 | export type ValidateTokenFn = ( 49 | token: string, 50 | ip?: string, 51 | ) => { valid: boolean; error?: string }; 52 | export type MockValidateToken = Mock; 53 | 54 | // Type guard for mock functions 55 | export function isMockFunction(value: unknown): value is Mock { 56 | return typeof value === "function" && "mock" in value; 57 | } 58 | 59 | // Safe type assertion for mock objects 60 | export function asMockFunction any>( 61 | value: unknown, 62 | ): Mock { 63 | if (!isMockFunction(value)) { 64 | throw new Error("Value is not a mock function"); 65 | } 66 | return value as Mock; 67 | } 68 | -------------------------------------------------------------------------------- /src/tools/addon.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | Tool, 4 | AddonParams, 5 | HassAddonResponse, 6 | HassAddonInfoResponse, 7 | } from "../types/index.js"; 8 | import { APP_CONFIG } from "../config/app.config.js"; 9 | 10 | export const addonTool: Tool = { 11 | name: "addon", 12 | description: "Manage Home Assistant add-ons", 13 | parameters: z.object({ 14 | action: z 15 | .enum([ 16 | "list", 17 | "info", 18 | "install", 19 | "uninstall", 20 | "start", 21 | "stop", 22 | "restart", 23 | ]) 24 | .describe("Action to perform with add-on"), 25 | slug: z 26 | .string() 27 | .optional() 28 | .describe("Add-on slug (required for all actions except list)"), 29 | version: z 30 | .string() 31 | .optional() 32 | .describe("Version to install (only for install action)"), 33 | }), 34 | execute: async (params: AddonParams) => { 35 | try { 36 | if (params.action === "list") { 37 | const response = await fetch( 38 | `${APP_CONFIG.HASS_HOST}/api/hassio/store`, 39 | { 40 | headers: { 41 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 42 | "Content-Type": "application/json", 43 | }, 44 | }, 45 | ); 46 | 47 | if (!response.ok) { 48 | throw new Error(`Failed to fetch add-ons: ${response.statusText}`); 49 | } 50 | 51 | const data = (await response.json()) as HassAddonResponse; 52 | return { 53 | success: true, 54 | addons: data.data.addons.map((addon) => ({ 55 | name: addon.name, 56 | slug: addon.slug, 57 | description: addon.description, 58 | version: addon.version, 59 | installed: addon.installed, 60 | available: addon.available, 61 | state: addon.state, 62 | })), 63 | }; 64 | } else { 65 | if (!params.slug) { 66 | throw new Error("Add-on slug is required for this action"); 67 | } 68 | 69 | let endpoint = ""; 70 | let method = "GET"; 71 | const body: Record = {}; 72 | 73 | switch (params.action) { 74 | case "info": 75 | endpoint = `/api/hassio/addons/${params.slug}/info`; 76 | break; 77 | case "install": 78 | endpoint = `/api/hassio/addons/${params.slug}/install`; 79 | method = "POST"; 80 | if (params.version) { 81 | body.version = params.version; 82 | } 83 | break; 84 | case "uninstall": 85 | endpoint = `/api/hassio/addons/${params.slug}/uninstall`; 86 | method = "POST"; 87 | break; 88 | case "start": 89 | endpoint = `/api/hassio/addons/${params.slug}/start`; 90 | method = "POST"; 91 | break; 92 | case "stop": 93 | endpoint = `/api/hassio/addons/${params.slug}/stop`; 94 | method = "POST"; 95 | break; 96 | case "restart": 97 | endpoint = `/api/hassio/addons/${params.slug}/restart`; 98 | method = "POST"; 99 | break; 100 | } 101 | 102 | const response = await fetch(`${APP_CONFIG.HASS_HOST}${endpoint}`, { 103 | method, 104 | headers: { 105 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 106 | "Content-Type": "application/json", 107 | }, 108 | ...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }), 109 | }); 110 | 111 | if (!response.ok) { 112 | throw new Error( 113 | `Failed to ${params.action} add-on: ${response.statusText}`, 114 | ); 115 | } 116 | 117 | const data = (await response.json()) as HassAddonInfoResponse; 118 | return { 119 | success: true, 120 | message: `Successfully ${params.action}ed add-on ${params.slug}`, 121 | data: data.data, 122 | }; 123 | } 124 | } catch (error) { 125 | return { 126 | success: false, 127 | message: 128 | error instanceof Error ? error.message : "Unknown error occurred", 129 | }; 130 | } 131 | }, 132 | }; 133 | -------------------------------------------------------------------------------- /src/tools/automation.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { 3 | Tool, 4 | AutomationParams, 5 | HassState, 6 | AutomationResponse, 7 | } from "../types/index.js"; 8 | import { APP_CONFIG } from "../config/app.config.js"; 9 | 10 | export const automationTool: Tool = { 11 | name: "automation", 12 | description: "Manage Home Assistant automations", 13 | parameters: z.object({ 14 | action: z 15 | .enum(["list", "toggle", "trigger"]) 16 | .describe("Action to perform with automation"), 17 | automation_id: z 18 | .string() 19 | .optional() 20 | .describe("Automation ID (required for toggle and trigger actions)"), 21 | }), 22 | execute: async (params: AutomationParams) => { 23 | try { 24 | if (params.action === "list") { 25 | const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/states`, { 26 | headers: { 27 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 28 | "Content-Type": "application/json", 29 | }, 30 | }); 31 | 32 | if (!response.ok) { 33 | throw new Error( 34 | `Failed to fetch automations: ${response.statusText}`, 35 | ); 36 | } 37 | 38 | const states = (await response.json()) as HassState[]; 39 | const automations = states.filter((state) => 40 | state.entity_id.startsWith("automation."), 41 | ); 42 | 43 | return { 44 | success: true, 45 | automations: automations.map((automation) => ({ 46 | entity_id: automation.entity_id, 47 | name: 48 | automation.attributes.friendly_name || 49 | automation.entity_id.split(".")[1], 50 | state: automation.state, 51 | last_triggered: automation.attributes.last_triggered, 52 | })), 53 | }; 54 | } else { 55 | if (!params.automation_id) { 56 | throw new Error( 57 | "Automation ID is required for toggle and trigger actions", 58 | ); 59 | } 60 | 61 | const service = params.action === "toggle" ? "toggle" : "trigger"; 62 | const response = await fetch( 63 | `${APP_CONFIG.HASS_HOST}/api/services/automation/${service}`, 64 | { 65 | method: "POST", 66 | headers: { 67 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 68 | "Content-Type": "application/json", 69 | }, 70 | body: JSON.stringify({ 71 | entity_id: params.automation_id, 72 | }), 73 | }, 74 | ); 75 | 76 | if (!response.ok) { 77 | throw new Error( 78 | `Failed to ${service} automation: ${response.statusText}`, 79 | ); 80 | } 81 | 82 | const responseData = (await response.json()) as AutomationResponse; 83 | return { 84 | success: true, 85 | message: `Successfully ${service}d automation ${params.automation_id}`, 86 | automation_id: responseData.automation_id, 87 | }; 88 | } 89 | } catch (error) { 90 | return { 91 | success: false, 92 | message: 93 | error instanceof Error ? error.message : "Unknown error occurred", 94 | }; 95 | } 96 | }, 97 | }; 98 | -------------------------------------------------------------------------------- /src/tools/examples/stream-generator.tool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example Tool: Stream Generator 3 | * 4 | * This tool demonstrates how to implement streaming functionality in MCP tools. 5 | * It generates a stream of data that can be consumed by clients in real-time. 6 | */ 7 | 8 | import { z } from 'zod'; 9 | import { BaseTool } from '../../mcp/BaseTool.js'; 10 | import { MCPResponseStream } from '../../mcp/types.js'; 11 | 12 | // Schema for the stream generator parameters 13 | const streamGeneratorSchema = z.object({ 14 | count: z.number().int().min(1).max(100).default(10) 15 | .describe('Number of items to generate in the stream (1-100)'), 16 | 17 | delay: z.number().int().min(100).max(2000).default(500) 18 | .describe('Delay between items in milliseconds (100-2000)'), 19 | 20 | includeTimestamp: z.boolean().default(false) 21 | .describe('Whether to include timestamp with each streamed item'), 22 | 23 | failAfter: z.number().int().min(0).default(0) 24 | .describe('If greater than 0, fail after this many items (for error handling testing)') 25 | }); 26 | 27 | // Define the parameter and result types 28 | type StreamGeneratorParams = z.infer; 29 | type StreamGeneratorResult = { 30 | message: string; 31 | count: number; 32 | timestamp?: string; 33 | items: string[]; 34 | }; 35 | 36 | /** 37 | * A tool that demonstrates streaming capabilities by generating a stream of data 38 | * with configurable parameters for count, delay, and error scenarios. 39 | */ 40 | export class StreamGeneratorTool extends BaseTool { 41 | constructor() { 42 | super({ 43 | name: 'stream_generator', 44 | description: 'Generates a stream of data with configurable delay and count', 45 | version: '1.0.0', 46 | parameters: streamGeneratorSchema, 47 | }); 48 | } 49 | 50 | /** 51 | * Execute the tool and stream results back to the client 52 | */ 53 | async execute( 54 | params: StreamGeneratorParams, 55 | stream?: MCPResponseStream 56 | ): Promise { 57 | const { count, delay, includeTimestamp, failAfter } = params; 58 | const items: string[] = []; 59 | 60 | // If we have a stream, use it to send intermediate results 61 | if (stream) { 62 | for (let i = 1; i <= count; i++) { 63 | // Simulate a processing delay 64 | await new Promise(resolve => setTimeout(resolve, delay)); 65 | 66 | // Check if we should fail for testing error handling 67 | if (failAfter > 0 && i > failAfter) { 68 | throw new Error(`Intentional failure after ${failAfter} items (for testing)`); 69 | } 70 | 71 | const item = `Item ${i} of ${count}`; 72 | items.push(item); 73 | 74 | // Create the intermediate result 75 | const partialResult: Partial = { 76 | message: `Generated ${i} of ${count} items`, 77 | count: i, 78 | items: [...items] 79 | }; 80 | 81 | // Add timestamp if requested 82 | if (includeTimestamp) { 83 | partialResult.timestamp = new Date().toISOString(); 84 | } 85 | 86 | // Stream the intermediate result 87 | stream.write(partialResult); 88 | } 89 | } else { 90 | // No streaming, generate all items at once with delay between 91 | for (let i = 1; i <= count; i++) { 92 | await new Promise(resolve => setTimeout(resolve, delay)); 93 | 94 | if (failAfter > 0 && i > failAfter) { 95 | throw new Error(`Intentional failure after ${failAfter} items (for testing)`); 96 | } 97 | 98 | items.push(`Item ${i} of ${count}`); 99 | } 100 | } 101 | 102 | // Return the final result 103 | const result: StreamGeneratorResult = { 104 | message: `Successfully generated ${count} items`, 105 | count, 106 | items 107 | }; 108 | 109 | if (includeTimestamp) { 110 | result.timestamp = new Date().toISOString(); 111 | } 112 | 113 | return result; 114 | } 115 | } -------------------------------------------------------------------------------- /src/tools/examples/validation-demo.tool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example Tool: Validation Demo 3 | * 4 | * This tool demonstrates how to implement validation using Zod schemas 5 | * in MCP tools. It provides examples of different validation rules and 6 | * how they can be applied to tool parameters. 7 | */ 8 | 9 | import { z } from 'zod'; 10 | import { BaseTool } from '../../mcp/BaseTool.js'; 11 | 12 | // Define a complex schema with various validation rules 13 | const validationDemoSchema = z.object({ 14 | // String validations 15 | email: z.string().email() 16 | .describe('An email address to validate'), 17 | 18 | url: z.string().url().optional() 19 | .describe('Optional URL to validate'), 20 | 21 | // Number validations 22 | age: z.number().int().min(18).max(120) 23 | .describe('Age (must be between 18-120)'), 24 | 25 | score: z.number().min(0).max(100).default(50) 26 | .describe('Score from 0-100'), 27 | 28 | // Array validations 29 | tags: z.array(z.string().min(2).max(20)) 30 | .min(1).max(5) 31 | .describe('Between 1-5 tags, each 2-20 characters'), 32 | 33 | // Enum validations 34 | role: z.enum(['admin', 'user', 'guest']) 35 | .describe('User role (admin, user, or guest)'), 36 | 37 | // Object validations 38 | preferences: z.object({ 39 | theme: z.enum(['light', 'dark', 'system']).default('system') 40 | .describe('UI theme preference'), 41 | notifications: z.boolean().default(true) 42 | .describe('Whether to enable notifications'), 43 | language: z.string().default('en') 44 | .describe('Preferred language code') 45 | }).optional() 46 | .describe('Optional user preferences') 47 | }); 48 | 49 | // Define types based on the schema 50 | type ValidationDemoParams = z.infer; 51 | type ValidationDemoResult = { 52 | valid: boolean; 53 | message: string; 54 | validatedData: ValidationDemoParams; 55 | metadata: { 56 | fieldsValidated: string[]; 57 | timestamp: string; 58 | }; 59 | }; 60 | 61 | /** 62 | * A tool that demonstrates parameter validation using Zod schemas 63 | */ 64 | export class ValidationDemoTool extends BaseTool { 65 | constructor() { 66 | super({ 67 | name: 'validation_demo', 68 | description: 'Demonstrates parameter validation using Zod schemas', 69 | version: '1.0.0', 70 | parameters: validationDemoSchema, 71 | }); 72 | } 73 | 74 | /** 75 | * Execute the validation demo tool 76 | */ 77 | async execute(params: ValidationDemoParams): Promise { 78 | // Get all field names that were validated 79 | const fieldsValidated = Object.keys(params); 80 | 81 | // Process the validated data (in a real tool, this would do something useful) 82 | return { 83 | valid: true, 84 | message: 'All parameters successfully validated', 85 | validatedData: params, 86 | metadata: { 87 | fieldsValidated, 88 | timestamp: new Date().toISOString() 89 | } 90 | }; 91 | } 92 | } -------------------------------------------------------------------------------- /src/tools/history.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool, HistoryParams } from "../types/index.js"; 3 | import { APP_CONFIG } from "../config/app.config.js"; 4 | 5 | export const historyTool: Tool = { 6 | name: "get_history", 7 | description: "Get state history for Home Assistant entities", 8 | parameters: z.object({ 9 | entity_id: z.string().describe("The entity ID to get history for"), 10 | start_time: z 11 | .string() 12 | .optional() 13 | .describe("Start time in ISO format. Defaults to 24 hours ago"), 14 | end_time: z 15 | .string() 16 | .optional() 17 | .describe("End time in ISO format. Defaults to now"), 18 | minimal_response: z 19 | .boolean() 20 | .optional() 21 | .describe("Return minimal response to reduce data size"), 22 | significant_changes_only: z 23 | .boolean() 24 | .optional() 25 | .describe("Only return significant state changes"), 26 | }), 27 | execute: async (params: HistoryParams) => { 28 | try { 29 | const now = new Date(); 30 | const startTime = params.start_time 31 | ? new Date(params.start_time) 32 | : new Date(now.getTime() - 24 * 60 * 60 * 1000); 33 | const endTime = params.end_time ? new Date(params.end_time) : now; 34 | 35 | // Build query parameters 36 | const queryParams = new URLSearchParams({ 37 | filter_entity_id: params.entity_id, 38 | minimal_response: String(!!params.minimal_response), 39 | significant_changes_only: String(!!params.significant_changes_only), 40 | start_time: startTime.toISOString(), 41 | end_time: endTime.toISOString(), 42 | }); 43 | 44 | const response = await fetch( 45 | `${APP_CONFIG.HASS_HOST}/api/history/period/${startTime.toISOString()}?${queryParams.toString()}`, 46 | { 47 | headers: { 48 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 49 | "Content-Type": "application/json", 50 | }, 51 | }, 52 | ); 53 | 54 | if (!response.ok) { 55 | throw new Error(`Failed to fetch history: ${response.statusText}`); 56 | } 57 | 58 | const history = await response.json(); 59 | return { 60 | success: true, 61 | history, 62 | }; 63 | } catch (error) { 64 | return { 65 | success: false, 66 | message: 67 | error instanceof Error ? error.message : "Unknown error occurred", 68 | }; 69 | } 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "../types/index.js"; 2 | import { listDevicesTool } from "./list-devices.tool.js"; 3 | import { controlTool } from "./control.tool.js"; 4 | import { historyTool } from "./history.tool.js"; 5 | import { sceneTool } from "./scene.tool.js"; 6 | import { notifyTool } from "./notify.tool.js"; 7 | import { automationTool } from "./automation.tool.js"; 8 | import { addonTool } from "./addon.tool.js"; 9 | import { packageTool } from "./package.tool.js"; 10 | import { automationConfigTool } from "./automation-config.tool.js"; 11 | import { subscribeEventsTool } from "./subscribe-events.tool.js"; 12 | import { getSSEStatsTool } from "./sse-stats.tool.js"; 13 | 14 | // Tool category types 15 | export enum ToolCategory { 16 | DEVICE = "device", 17 | SYSTEM = "system", 18 | AUTOMATION = "automation", 19 | } 20 | 21 | // Tool priority levels 22 | export enum ToolPriority { 23 | HIGH = "high", 24 | MEDIUM = "medium", 25 | LOW = "low", 26 | } 27 | 28 | interface ToolMetadata { 29 | category: ToolCategory; 30 | platform: string; 31 | version: string; 32 | caching?: { 33 | enabled: boolean; 34 | ttl: number; 35 | }; 36 | } 37 | 38 | // Array to track all tools 39 | export const tools: Tool[] = [ 40 | listDevicesTool, 41 | controlTool, 42 | historyTool, 43 | sceneTool, 44 | notifyTool, 45 | automationTool, 46 | addonTool, 47 | packageTool, 48 | automationConfigTool, 49 | subscribeEventsTool, 50 | getSSEStatsTool, 51 | ]; 52 | 53 | // Function to get a tool by name 54 | export function getToolByName(name: string): Tool | undefined { 55 | return tools.find((tool) => tool.name === name); 56 | } 57 | 58 | // Function to get all tools 59 | export function getAllTools(): Tool[] { 60 | return [...tools]; 61 | } 62 | 63 | // Export all tools individually 64 | export { 65 | listDevicesTool, 66 | controlTool, 67 | historyTool, 68 | sceneTool, 69 | notifyTool, 70 | automationTool, 71 | addonTool, 72 | packageTool, 73 | automationConfigTool, 74 | subscribeEventsTool, 75 | getSSEStatsTool, 76 | }; 77 | -------------------------------------------------------------------------------- /src/tools/list-devices.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool } from "../types/index.js"; 3 | import { APP_CONFIG } from "../config/app.config.js"; 4 | import { HassState } from "../types/index.js"; 5 | 6 | export const listDevicesTool: Tool = { 7 | name: "list_devices", 8 | description: "List all available Home Assistant devices", 9 | parameters: z.object({ 10 | domain: z.enum([ 11 | "light", 12 | "climate", 13 | "alarm_control_panel", 14 | "cover", 15 | "switch", 16 | "contact", 17 | "media_player", 18 | "fan", 19 | "lock", 20 | "vacuum", 21 | "scene", 22 | "script", 23 | "camera", 24 | ]).optional(), 25 | area: z.string().optional(), 26 | floor: z.string().optional(), 27 | }).describe("Filter devices by domain, area, or floor"), 28 | execute: async (params: { domain?: string; area?: string; floor?: string }) => { 29 | try { 30 | const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/states`, { 31 | headers: { 32 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 33 | "Content-Type": "application/json", 34 | }, 35 | }); 36 | 37 | if (!response.ok) { 38 | throw new Error(`Failed to fetch devices: ${response.statusText}`); 39 | } 40 | 41 | const states = (await response.json()) as HassState[]; 42 | let filteredStates = states; 43 | 44 | // Apply filters 45 | if (params.domain) { 46 | filteredStates = filteredStates.filter(state => state.entity_id.startsWith(`${params.domain}.`)); 47 | } 48 | if (params.area) { 49 | filteredStates = filteredStates.filter(state => state.attributes?.area_id === params.area); 50 | } 51 | if (params.floor) { 52 | filteredStates = filteredStates.filter(state => state.attributes?.floor === params.floor); 53 | } 54 | 55 | const devices: Record = {}; 56 | 57 | // Group devices by domain 58 | filteredStates.forEach(state => { 59 | const [domain] = state.entity_id.split('.'); 60 | if (!devices[domain]) { 61 | devices[domain] = []; 62 | } 63 | devices[domain].push(state); 64 | }); 65 | 66 | // Calculate device statistics 67 | const deviceStats = Object.entries(devices).map(([domain, entities]) => { 68 | const activeStates = ['on', 'home', 'unlocked', 'open']; 69 | const active = entities.filter(e => activeStates.includes(e.state)).length; 70 | const uniqueStates = [...new Set(entities.map(e => e.state))]; 71 | 72 | return { 73 | domain, 74 | count: entities.length, 75 | active, 76 | inactive: entities.length - active, 77 | states: uniqueStates, 78 | sample: entities.slice(0, 2).map(e => ({ 79 | id: e.entity_id, 80 | state: e.state, 81 | name: e.attributes?.friendly_name || e.entity_id, 82 | area: e.attributes?.area_id, 83 | floor: e.attributes?.floor, 84 | })) 85 | }; 86 | }); 87 | 88 | const totalDevices = filteredStates.length; 89 | const deviceTypes = Object.keys(devices); 90 | 91 | const deviceSummary = { 92 | total_devices: totalDevices, 93 | device_types: deviceTypes, 94 | by_domain: Object.fromEntries( 95 | deviceStats.map(stat => [ 96 | stat.domain, 97 | { 98 | count: stat.count, 99 | active: stat.active, 100 | states: stat.states, 101 | sample: stat.sample 102 | } 103 | ]) 104 | ) 105 | }; 106 | 107 | return { 108 | success: true, 109 | devices, 110 | device_summary: deviceSummary 111 | }; 112 | } catch (error) { 113 | console.error('Error in list devices tool:', error); 114 | return { 115 | success: false, 116 | message: error instanceof Error ? error.message : "Unknown error occurred", 117 | devices: {}, 118 | device_summary: { 119 | total_devices: 0, 120 | device_types: [], 121 | by_domain: {} 122 | } 123 | }; 124 | } 125 | }, 126 | }; 127 | -------------------------------------------------------------------------------- /src/tools/notify.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool, NotifyParams } from "../types/index.js"; 3 | import { APP_CONFIG } from "../config/app.config.js"; 4 | 5 | export const notifyTool: Tool = { 6 | name: "notify", 7 | description: "Send notifications through Home Assistant", 8 | parameters: z.object({ 9 | message: z.string().describe("The notification message"), 10 | title: z.string().optional().describe("The notification title"), 11 | target: z 12 | .string() 13 | .optional() 14 | .describe("Specific notification target (e.g., mobile_app_phone)"), 15 | data: z.record(z.any()).optional().describe("Additional notification data"), 16 | }), 17 | execute: async (params: NotifyParams) => { 18 | try { 19 | const service = params.target 20 | ? `notify.${params.target}` 21 | : "notify.notify"; 22 | const [domain, service_name] = service.split("."); 23 | 24 | const response = await fetch( 25 | `${APP_CONFIG.HASS_HOST}/api/services/${domain}/${service_name}`, 26 | { 27 | method: "POST", 28 | headers: { 29 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 30 | "Content-Type": "application/json", 31 | }, 32 | body: JSON.stringify({ 33 | message: params.message, 34 | title: params.title, 35 | data: params.data, 36 | }), 37 | }, 38 | ); 39 | 40 | if (!response.ok) { 41 | throw new Error(`Failed to send notification: ${response.statusText}`); 42 | } 43 | 44 | return { 45 | success: true, 46 | message: "Notification sent successfully", 47 | }; 48 | } catch (error) { 49 | return { 50 | success: false, 51 | message: 52 | error instanceof Error ? error.message : "Unknown error occurred", 53 | }; 54 | } 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/tools/package.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool, PackageParams, HacsResponse } from "../types/index.js"; 3 | import { APP_CONFIG } from "../config/app.config.js"; 4 | 5 | export const packageTool: Tool = { 6 | name: "package", 7 | description: "Manage HACS packages and custom components", 8 | parameters: z.object({ 9 | action: z 10 | .enum(["list", "install", "uninstall", "update"]) 11 | .describe("Action to perform with package"), 12 | category: z 13 | .enum([ 14 | "integration", 15 | "plugin", 16 | "theme", 17 | "python_script", 18 | "appdaemon", 19 | "netdaemon", 20 | ]) 21 | .describe("Package category"), 22 | repository: z 23 | .string() 24 | .optional() 25 | .describe("Repository URL or name (required for install)"), 26 | version: z.string().optional().describe("Version to install"), 27 | }), 28 | execute: async (params: PackageParams) => { 29 | try { 30 | const hacsBase = `${APP_CONFIG.HASS_HOST}/api/hacs`; 31 | 32 | if (params.action === "list") { 33 | const response = await fetch( 34 | `${hacsBase}/repositories?category=${params.category}`, 35 | { 36 | headers: { 37 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 38 | "Content-Type": "application/json", 39 | }, 40 | }, 41 | ); 42 | 43 | if (!response.ok) { 44 | throw new Error(`Failed to fetch packages: ${response.statusText}`); 45 | } 46 | 47 | const data = (await response.json()) as HacsResponse; 48 | return { 49 | success: true, 50 | packages: data.repositories, 51 | }; 52 | } else { 53 | if (!params.repository) { 54 | throw new Error("Repository is required for this action"); 55 | } 56 | 57 | let endpoint = ""; 58 | const body: Record = { 59 | category: params.category, 60 | repository: params.repository, 61 | }; 62 | 63 | switch (params.action) { 64 | case "install": 65 | endpoint = "/repository/install"; 66 | if (params.version) { 67 | body.version = params.version; 68 | } 69 | break; 70 | case "uninstall": 71 | endpoint = "/repository/uninstall"; 72 | break; 73 | case "update": 74 | endpoint = "/repository/update"; 75 | break; 76 | } 77 | 78 | const response = await fetch(`${hacsBase}${endpoint}`, { 79 | method: "POST", 80 | headers: { 81 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 82 | "Content-Type": "application/json", 83 | }, 84 | body: JSON.stringify(body), 85 | }); 86 | 87 | if (!response.ok) { 88 | throw new Error( 89 | `Failed to ${params.action} package: ${response.statusText}`, 90 | ); 91 | } 92 | 93 | return { 94 | success: true, 95 | message: `Successfully ${params.action}ed package ${params.repository}`, 96 | }; 97 | } 98 | } catch (error) { 99 | return { 100 | success: false, 101 | message: 102 | error instanceof Error ? error.message : "Unknown error occurred", 103 | }; 104 | } 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /src/tools/scene.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool, SceneParams, HassState } from "../types/index.js"; 3 | import { APP_CONFIG } from "../config/app.config.js"; 4 | 5 | export const sceneTool: Tool = { 6 | name: "scene", 7 | description: "Manage and activate Home Assistant scenes", 8 | parameters: z.object({ 9 | action: z 10 | .enum(["list", "activate"]) 11 | .describe("Action to perform with scenes"), 12 | scene_id: z 13 | .string() 14 | .optional() 15 | .describe("Scene ID to activate (required for activate action)"), 16 | }), 17 | execute: async (params: SceneParams) => { 18 | try { 19 | if (params.action === "list") { 20 | const response = await fetch(`${APP_CONFIG.HASS_HOST}/api/states`, { 21 | headers: { 22 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 23 | "Content-Type": "application/json", 24 | }, 25 | }); 26 | 27 | if (!response.ok) { 28 | throw new Error(`Failed to fetch scenes: ${response.statusText}`); 29 | } 30 | 31 | const states = (await response.json()) as HassState[]; 32 | const scenes = states.filter((state) => 33 | state.entity_id.startsWith("scene."), 34 | ); 35 | 36 | return { 37 | success: true, 38 | scenes: scenes.map((scene) => ({ 39 | entity_id: scene.entity_id, 40 | name: 41 | scene.attributes.friendly_name || scene.entity_id.split(".")[1], 42 | description: scene.attributes.description, 43 | })), 44 | }; 45 | } else if (params.action === "activate") { 46 | if (!params.scene_id) { 47 | throw new Error("Scene ID is required for activate action"); 48 | } 49 | 50 | const response = await fetch( 51 | `${APP_CONFIG.HASS_HOST}/api/services/scene/turn_on`, 52 | { 53 | method: "POST", 54 | headers: { 55 | Authorization: `Bearer ${APP_CONFIG.HASS_TOKEN}`, 56 | "Content-Type": "application/json", 57 | }, 58 | body: JSON.stringify({ 59 | entity_id: params.scene_id, 60 | }), 61 | }, 62 | ); 63 | 64 | if (!response.ok) { 65 | throw new Error(`Failed to activate scene: ${response.statusText}`); 66 | } 67 | 68 | return { 69 | success: true, 70 | message: `Successfully activated scene ${params.scene_id}`, 71 | }; 72 | } 73 | 74 | throw new Error("Invalid action specified"); 75 | } catch (error) { 76 | return { 77 | success: false, 78 | message: 79 | error instanceof Error ? error.message : "Unknown error occurred", 80 | }; 81 | } 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/tools/sse-stats.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { Tool } from "../types/index.js"; 3 | import { APP_CONFIG } from "../config/app.config.js"; 4 | import { sseManager } from "../sse/index.js"; 5 | 6 | export const getSSEStatsTool: Tool = { 7 | name: "get_sse_stats", 8 | description: "Get SSE connection statistics", 9 | parameters: z.object({ 10 | token: z.string().describe("Authentication token (required)"), 11 | }), 12 | execute: async (params: { token: string }) => { 13 | try { 14 | if (params.token !== APP_CONFIG.HASS_TOKEN) { 15 | return { 16 | success: false, 17 | message: "Authentication failed", 18 | }; 19 | } 20 | 21 | const stats = await sseManager.getStatistics(); 22 | return { 23 | success: true, 24 | statistics: stats, 25 | }; 26 | } catch (error) { 27 | return { 28 | success: false, 29 | message: 30 | error instanceof Error ? error.message : "Unknown error occurred", 31 | }; 32 | } 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/tools/subscribe-events.tool.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { Tool, SSEParams } from "../types/index.js"; 4 | import { sseManager } from "../sse/index.js"; 5 | 6 | export const subscribeEventsTool: Tool = { 7 | name: "subscribe_events", 8 | description: 9 | "Subscribe to Home Assistant events via Server-Sent Events (SSE)", 10 | parameters: z.object({ 11 | token: z.string().describe("Authentication token (required)"), 12 | events: z 13 | .array(z.string()) 14 | .optional() 15 | .describe("List of event types to subscribe to"), 16 | entity_id: z 17 | .string() 18 | .optional() 19 | .describe("Specific entity ID to monitor for state changes"), 20 | domain: z 21 | .string() 22 | .optional() 23 | .describe('Domain to monitor (e.g., "light", "switch", etc.)'), 24 | }), 25 | execute: async (params: SSEParams) => { 26 | const clientId = uuidv4(); 27 | 28 | // Set up SSE headers 29 | const responseHeaders = { 30 | "Content-Type": "text/event-stream", 31 | "Cache-Control": "no-cache", 32 | Connection: "keep-alive", 33 | }; 34 | 35 | // Create SSE client 36 | const client = { 37 | id: clientId, 38 | send: (data: string) => { 39 | return { 40 | headers: responseHeaders, 41 | body: `data: ${data}\n\n`, 42 | keepAlive: true, 43 | }; 44 | }, 45 | }; 46 | 47 | // Add client to SSE manager with authentication 48 | const sseClient = sseManager.addClient(client, params.token); 49 | 50 | if (!sseClient || !sseClient.authenticated) { 51 | return { 52 | success: false, 53 | message: sseClient 54 | ? "Authentication failed" 55 | : "Maximum client limit reached", 56 | }; 57 | } 58 | 59 | // Subscribe to specific events if provided 60 | if (params.events?.length) { 61 | console.log(`Client ${clientId} subscribing to events:`, params.events); 62 | for (const eventType of params.events) { 63 | sseManager.subscribeToEvent(clientId, eventType); 64 | } 65 | } 66 | 67 | // Subscribe to specific entity if provided 68 | if (params.entity_id) { 69 | console.log( 70 | `Client ${clientId} subscribing to entity:`, 71 | params.entity_id, 72 | ); 73 | sseManager.subscribeToEntity(clientId, params.entity_id); 74 | } 75 | 76 | // Subscribe to domain if provided 77 | if (params.domain) { 78 | console.log(`Client ${clientId} subscribing to domain:`, params.domain); 79 | sseManager.subscribeToDomain(clientId, params.domain); 80 | } 81 | 82 | return { 83 | headers: responseHeaders, 84 | body: `data: ${JSON.stringify({ 85 | type: "connection", 86 | status: "connected", 87 | id: clientId, 88 | authenticated: true, 89 | subscriptions: { 90 | events: params.events || [], 91 | entities: params.entity_id ? [params.entity_id] : [], 92 | domains: params.domain ? [params.domain] : [], 93 | }, 94 | timestamp: new Date().toISOString(), 95 | })}\n\n`, 96 | keepAlive: true, 97 | }; 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /src/types/bun.d.ts: -------------------------------------------------------------------------------- 1 | declare module "bun:test" { 2 | export interface Mock any> { 3 | (...args: Parameters): ReturnType; 4 | mock: { 5 | calls: Array<{ args: Parameters; returned: ReturnType }>; 6 | results: Array<{ type: "return" | "throw"; value: any }>; 7 | instances: any[]; 8 | lastCall: { args: Parameters; returned: ReturnType } | undefined; 9 | }; 10 | mockImplementation(fn: T): this; 11 | mockReturnValue(value: ReturnType): this; 12 | mockResolvedValue(value: U): Mock<() => Promise>; 13 | mockRejectedValue(value: any): Mock<() => Promise>; 14 | mockReset(): void; 15 | } 16 | 17 | export function mock any>( 18 | implementation?: T, 19 | ): Mock; 20 | 21 | export function describe(name: string, fn: () => void): void; 22 | export function it(name: string, fn: () => void | Promise): void; 23 | export function test(name: string, fn: () => void | Promise): void; 24 | export function expect(actual: any): { 25 | toBe(expected: any): void; 26 | toEqual(expected: any): void; 27 | toBeDefined(): void; 28 | toBeUndefined(): void; 29 | toBeNull(): void; 30 | toBeTruthy(): void; 31 | toBeFalsy(): void; 32 | toBeGreaterThan(expected: number): void; 33 | toBeLessThan(expected: number): void; 34 | toContain(expected: any): void; 35 | toHaveLength(expected: number): void; 36 | toHaveBeenCalled(): void; 37 | toHaveBeenCalledTimes(expected: number): void; 38 | toHaveBeenCalledWith(...args: any[]): void; 39 | toThrow(expected?: string | RegExp): void; 40 | resolves: any; 41 | rejects: any; 42 | }; 43 | export function beforeAll(fn: () => void | Promise): void; 44 | export function afterAll(fn: () => void | Promise): void; 45 | export function beforeEach(fn: () => void | Promise): void; 46 | export function afterEach(fn: () => void | Promise): void; 47 | export const mock: { 48 | resetAll(): void; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/types/hass.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace HomeAssistant { 2 | interface Entity { 3 | entity_id: string; 4 | state: string; 5 | attributes: Record; 6 | last_changed: string; 7 | last_updated: string; 8 | context: { 9 | id: string; 10 | parent_id?: string; 11 | user_id?: string; 12 | }; 13 | } 14 | 15 | interface Service { 16 | domain: string; 17 | service: string; 18 | target?: { 19 | entity_id?: string | string[]; 20 | device_id?: string | string[]; 21 | area_id?: string | string[]; 22 | }; 23 | service_data?: Record; 24 | } 25 | 26 | interface WebsocketMessage { 27 | type: string; 28 | id?: number; 29 | [key: string]: any; 30 | } 31 | 32 | interface AuthMessage extends WebsocketMessage { 33 | type: "auth"; 34 | access_token: string; 35 | } 36 | 37 | interface SubscribeEventsMessage extends WebsocketMessage { 38 | type: "subscribe_events"; 39 | event_type?: string; 40 | } 41 | 42 | interface StateChangedEvent { 43 | event_type: "state_changed"; 44 | data: { 45 | entity_id: string; 46 | new_state: Entity | null; 47 | old_state: Entity | null; 48 | }; 49 | origin: string; 50 | time_fired: string; 51 | context: { 52 | id: string; 53 | parent_id?: string; 54 | user_id?: string; 55 | }; 56 | } 57 | 58 | interface Config { 59 | latitude: number; 60 | longitude: number; 61 | elevation: number; 62 | unit_system: { 63 | length: string; 64 | mass: string; 65 | temperature: string; 66 | volume: string; 67 | }; 68 | location_name: string; 69 | time_zone: string; 70 | components: string[]; 71 | version: string; 72 | } 73 | 74 | interface ApiError { 75 | code: string; 76 | message: string; 77 | details?: Record; 78 | } 79 | } 80 | 81 | export = HomeAssistant; 82 | -------------------------------------------------------------------------------- /src/types/hass.ts: -------------------------------------------------------------------------------- 1 | export interface AuthMessage { 2 | type: "auth"; 3 | access_token: string; 4 | } 5 | 6 | export interface ResultMessage { 7 | id: number; 8 | type: "result"; 9 | success: boolean; 10 | result?: any; 11 | } 12 | 13 | export interface WebSocketError { 14 | code: string; 15 | message: string; 16 | } 17 | 18 | export interface Event { 19 | event_type: string; 20 | data: any; 21 | origin: string; 22 | time_fired: string; 23 | context: { 24 | id: string; 25 | parent_id: string | null; 26 | user_id: string | null; 27 | }; 28 | } 29 | 30 | export interface Entity { 31 | entity_id: string; 32 | state: string; 33 | attributes: Record; 34 | last_changed: string; 35 | last_updated: string; 36 | context: { 37 | id: string; 38 | parent_id: string | null; 39 | user_id: string | null; 40 | }; 41 | } 42 | 43 | export interface StateChangedEvent extends Event { 44 | event_type: "state_changed"; 45 | data: { 46 | entity_id: string; 47 | new_state: Entity | null; 48 | old_state: Entity | null; 49 | }; 50 | } 51 | 52 | export interface HassEntity { 53 | entity_id: string; 54 | state: string; 55 | attributes: Record; 56 | last_changed?: string; 57 | last_updated?: string; 58 | context?: { 59 | id: string; 60 | parent_id?: string; 61 | user_id?: string; 62 | }; 63 | } 64 | 65 | export interface HassState { 66 | entity_id: string; 67 | state: string; 68 | attributes: { 69 | friendly_name?: string; 70 | description?: string; 71 | [key: string]: any; 72 | }; 73 | } 74 | 75 | export interface HassEvent { 76 | event_type: string; 77 | data: any; 78 | origin: string; 79 | time_fired: string; 80 | context: { 81 | id: string; 82 | parent_id?: string; 83 | user_id?: string; 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/types/node-record-lpcm16.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'node-record-lpcm16' { 2 | import { Readable } from 'stream'; 3 | 4 | interface RecordOptions { 5 | sampleRate?: number; 6 | channels?: number; 7 | audioType?: string; 8 | threshold?: number; 9 | thresholdStart?: number; 10 | thresholdEnd?: number; 11 | silence?: number; 12 | verbose?: boolean; 13 | recordProgram?: string; 14 | } 15 | 16 | interface Recording { 17 | stream(): Readable; 18 | stop(): void; 19 | } 20 | 21 | export function record(options?: RecordOptions): Recording; 22 | } -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a tool call response into a standardized structure 3 | * @param obj The object to format 4 | * @param isError Whether this is an error response 5 | * @returns Formatted response object 6 | */ 7 | export const formatToolCall = (obj: any, isError: boolean = false) => { 8 | const text = obj === undefined ? 'undefined' : JSON.stringify(obj, null, 2); 9 | return { 10 | content: [{ type: "text", text, isError }], 11 | }; 12 | }; -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger Module 3 | * 4 | * This module provides a consistent logging interface for all MCP components. 5 | * It handles log formatting, error handling, and ensures log output is directed 6 | * to the appropriate destination based on the runtime environment. 7 | */ 8 | 9 | import winston from 'winston'; 10 | import path from 'path'; 11 | import fs from 'fs'; 12 | 13 | // Ensure logs directory exists 14 | const logsDir = path.join(process.cwd(), 'logs'); 15 | if (!fs.existsSync(logsDir)) { 16 | fs.mkdirSync(logsDir, { recursive: true }); 17 | } 18 | 19 | // Special handling for stdio mode to ensure stdout stays clean for JSON-RPC 20 | const isStdioMode = process.env.USE_STDIO_TRANSPORT === 'true'; 21 | const isDebugStdio = process.env.DEBUG_STDIO === 'true'; 22 | 23 | // Create base format that works with TypeScript 24 | const baseFormat = winston.format.combine( 25 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 26 | winston.format.errors({ stack: true }), 27 | winston.format.json() 28 | ); 29 | 30 | // Create logger with appropriate transports 31 | const logger = winston.createLogger({ 32 | level: process.env.LOG_LEVEL || 'error', 33 | format: baseFormat, 34 | defaultMeta: { service: 'mcp-server' }, 35 | transports: [ 36 | // Always log to files 37 | new winston.transports.File({ filename: path.join(logsDir, 'error.log'), level: 'error' }), 38 | new winston.transports.File({ filename: path.join(logsDir, 'combined.log') }) 39 | ] 40 | }); 41 | 42 | // Handle console output based on environment 43 | if (process.env.NODE_ENV !== 'production' || process.env.CONSOLE_LOGGING === 'true') { 44 | // In stdio mode with debug enabled, ensure logs only go to stderr to keep stdout clean for JSON-RPC 45 | if (isStdioMode && isDebugStdio) { 46 | // Use stderr stream transport in stdio debug mode 47 | logger.add(new winston.transports.Stream({ 48 | stream: process.stderr, 49 | format: winston.format.combine( 50 | winston.format.simple() 51 | ) 52 | })); 53 | } else { 54 | // Use console transport in normal mode 55 | logger.add(new winston.transports.Console({ 56 | format: winston.format.combine( 57 | winston.format.colorize(), 58 | winston.format.simple() 59 | ) 60 | })); 61 | } 62 | } 63 | 64 | // Custom logger interface 65 | export interface MCPLogger { 66 | debug: (message: string, meta?: Record) => void; 67 | info: (message: string, meta?: Record) => void; 68 | warn: (message: string, meta?: Record) => void; 69 | error: (message: string, meta?: Record) => void; 70 | child: (options: Record) => MCPLogger; 71 | } 72 | 73 | // Export the winston logger with MCPLogger interface 74 | export { logger }; 75 | 76 | // Export default logger for convenience 77 | export default logger; 78 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export NODE_ENV=development 3 | exec bun --smol run start 4 | -------------------------------------------------------------------------------- /stdio-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # MCP Server Stdio Transport Launcher 4 | # This script builds and runs the MCP server using stdin/stdout JSON-RPC 2.0 transport 5 | 6 | # ANSI colors for prettier output 7 | RED='\033[0;31m' 8 | GREEN='\033[0;32m' 9 | YELLOW='\033[0;33m' 10 | BLUE='\033[0;34m' 11 | NC='\033[0m' # No Color 12 | 13 | # Show usage information 14 | function show_usage { 15 | echo -e "${BLUE}Usage:${NC} $0 [options]" 16 | echo 17 | echo "Options:" 18 | echo " --debug Enable debug mode" 19 | echo " --rebuild Force rebuild even if dist exists" 20 | echo " --help Show this help message" 21 | echo 22 | echo "Examples:" 23 | echo " $0 # Normal start" 24 | echo " $0 --debug # Start with debug logging" 25 | echo " $0 --rebuild # Force rebuild" 26 | echo 27 | echo "This script runs the MCP server with JSON-RPC 2.0 stdio transport." 28 | echo "Logs will be written to the logs directory but not to stdout." 29 | echo 30 | } 31 | 32 | # Process command line arguments 33 | REBUILD=false 34 | DEBUG=false 35 | 36 | for arg in "$@"; do 37 | case $arg in 38 | --help) 39 | show_usage 40 | exit 0 41 | ;; 42 | --debug) 43 | DEBUG=true 44 | shift 45 | ;; 46 | --rebuild) 47 | REBUILD=true 48 | shift 49 | ;; 50 | *) 51 | echo -e "${RED}Unknown option:${NC} $arg" 52 | show_usage 53 | exit 1 54 | ;; 55 | esac 56 | done 57 | 58 | # Check for errors 59 | if [ ! -f ".env" ]; then 60 | echo -e "${RED}Error:${NC} .env file not found. Please create one from .env.example." >&2 61 | exit 1 62 | fi 63 | 64 | # Set environment variables 65 | export USE_STDIO_TRANSPORT=true 66 | 67 | # Set debug mode if requested 68 | if [ "$DEBUG" = true ]; then 69 | export DEBUG=true 70 | echo -e "${YELLOW}Debug mode enabled${NC}" >&2 71 | fi 72 | 73 | # Check if we need to build 74 | if [ ! -d "dist" ] || [ "$REBUILD" = true ]; then 75 | echo -e "${BLUE}Building MCP server with stdio transport...${NC}" >&2 76 | bun build ./src/index.ts --outdir ./dist --target bun || { 77 | echo -e "${RED}Build failed!${NC}" >&2 78 | exit 1 79 | } 80 | else 81 | echo -e "${GREEN}Using existing build in dist/ directory${NC}" >&2 82 | echo -e "${YELLOW}Use --rebuild flag to force a rebuild${NC}" >&2 83 | fi 84 | 85 | # Create logs directory if it doesn't exist 86 | mkdir -p logs 87 | 88 | # Run the application with stdio transport 89 | echo -e "${GREEN}Starting MCP server with stdio transport...${NC}" >&2 90 | echo -e "${YELLOW}Note: All logs will be written to logs/ directory${NC}" >&2 91 | echo -e "${YELLOW}Press Ctrl+C to stop${NC}" >&2 92 | 93 | # Execute the server 94 | exec bun run dist/index.js 95 | 96 | # The exec replaces this shell with the server process 97 | # so any code after this point will not be executed -------------------------------------------------------------------------------- /test.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jango-blockchained/advanced-homeassistant-mcp/2368a39d11626bf875840aa515efadbc7bad8c4d/test.wav -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, afterAll } from 'bun:test'; 2 | 3 | // Mock environment variables for testing 4 | const TEST_ENV = { 5 | NODE_ENV: 'test', 6 | PORT: '3000', 7 | EXECUTION_TIMEOUT: '30000', 8 | STREAMING_ENABLED: 'false', 9 | USE_STDIO_TRANSPORT: 'false', 10 | USE_HTTP_TRANSPORT: 'true', 11 | DEBUG_MODE: 'false', 12 | DEBUG_STDIO: 'false', 13 | DEBUG_HTTP: 'false', 14 | SILENT_STARTUP: 'false', 15 | CORS_ORIGIN: '*', 16 | RATE_LIMIT_MAX_REQUESTS: '100', 17 | RATE_LIMIT_MAX_AUTH_REQUESTS: '5' 18 | }; 19 | 20 | beforeAll(() => { 21 | // Store original environment 22 | process.env = { 23 | ...process.env, 24 | ...TEST_ENV 25 | }; 26 | }); 27 | 28 | afterAll(() => { 29 | // Clean up test environment 30 | Object.keys(TEST_ENV).forEach(key => { 31 | delete process.env[key]; 32 | }); 33 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "lib": [ 6 | "esnext", 7 | "dom" 8 | ], 9 | "strict": true, 10 | "strictNullChecks": false, 11 | "strictFunctionTypes": false, 12 | "strictPropertyInitialization": false, 13 | "noImplicitAny": false, 14 | "noImplicitThis": false, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "moduleResolution": "node", 19 | "allowImportingTsExtensions": true, 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "types": [ 25 | "bun-types", 26 | "@types/node", 27 | "@types/ws", 28 | "@types/jsonwebtoken", 29 | "@types/sanitize-html", 30 | "@types/jest", 31 | "@types/express" 32 | ], 33 | "baseUrl": ".", 34 | "paths": { 35 | "@/*": [ 36 | "src/*" 37 | ], 38 | "@test/*": [ 39 | "test/*" 40 | ] 41 | }, 42 | "experimentalDecorators": true, 43 | "emitDecoratorMetadata": true, 44 | "sourceMap": true, 45 | "declaration": true, 46 | "declarationMap": true, 47 | "allowUnreachableCode": true, 48 | "allowUnusedLabels": true, 49 | "outDir": "dist", 50 | "rootDir": "." 51 | }, 52 | "include": [ 53 | "src/**/*", 54 | "test/**/*", 55 | "__tests__/**/*", 56 | "*.d.ts" 57 | ], 58 | "exclude": [ 59 | "node_modules", 60 | "dist", 61 | "coverage" 62 | ] 63 | } -------------------------------------------------------------------------------- /tsconfig.stdio.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "sourceMap": true 7 | }, 8 | "include": [ 9 | "src/stdio-server.ts", 10 | "src/mcp/**/*.ts", 11 | "src/utils/**/*.ts", 12 | "src/tools/homeassistant/**/*.ts" 13 | ], 14 | "exclude": [ 15 | "node_modules", 16 | "dist", 17 | "**/*.test.ts" 18 | ] 19 | } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | // Inherit base configuration, but override with more relaxed settings for tests 5 | "strict": false, 6 | "strictNullChecks": false, 7 | "strictFunctionTypes": false, 8 | "strictPropertyInitialization": false, 9 | "noImplicitAny": false, 10 | "noImplicitThis": false, 11 | // Additional relaxations for test files 12 | "allowUnreachableCode": true, 13 | "allowUnusedLabels": true, 14 | // Specific test-related compiler options 15 | "types": [ 16 | "bun-types", 17 | "@types/jest" 18 | ] 19 | }, 20 | "include": [ 21 | "__tests__/**/*" 22 | ] 23 | } --------------------------------------------------------------------------------