# Directory Structure ``` ├── .gitignore ├── bun.lock ├── func-calling │ ├── .env.example │ ├── .gitignore │ ├── bun.lock │ ├── package.json │ ├── src │ │ ├── data-manager │ │ │ ├── config.ts │ │ │ ├── data-manager.ts │ │ │ └── data.ts │ │ ├── hass-ws-client │ │ │ └── client.ts │ │ └── index.ts │ └── tsconfig.json ├── mcp-server │ ├── .env.example │ ├── .gitignore │ ├── bun.lock │ ├── package.json │ ├── src │ │ ├── data-manager │ │ │ ├── config.ts │ │ │ ├── data-manager.ts │ │ │ └── data.ts │ │ ├── hass-ws-client │ │ │ └── client.ts │ │ └── index.ts │ └── tsconfig.json └── README.md ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | .env 2 | dist/ 3 | node_modules/ 4 | ``` -------------------------------------------------------------------------------- /mcp-server/.env.example: -------------------------------------------------------------------------------- ``` 1 | HOME_ASSISTANT_TOKEN="<your-home-assistant-token>" 2 | HOME_ASSISTANT_HOST="homeassistant.local:8123" 3 | HOME_ASSISTANT_SECURE="false" 4 | ``` -------------------------------------------------------------------------------- /func-calling/.env.example: -------------------------------------------------------------------------------- ``` 1 | OPEN_AI_API_KEY="your-openai-api-key" 2 | 3 | HOME_ASSISTANT_TOKEN="<your-home-assistant-token>" 4 | HOME_ASSISTANT_HOST="homeassistant.local:8123" 5 | HOME_ASSISTANT_SECURE="false" 6 | ``` -------------------------------------------------------------------------------- /func-calling/.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | ``` -------------------------------------------------------------------------------- /mcp-server/.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Function Calling vs MCP Server 2 | 3 | This repository is meant to illustrate the difference between LLM function calling and the Model Context Protocol (MCP). 4 | Function calling has been around for a while, while MCP is a newer standardization attempt. 5 | Comparing the two approaches showcases the value of MCP and how it builds on top of function calling. 6 | 7 | This repository contains two examples: 8 | 9 | - `/func-calling`: CLI app using OpenAI's function calling to control Home Assistant lights 10 | - `/mcp-server`: Node.js MCP server exposing a `control_lights` function to LLMs that use the MCP protocol 11 | 12 | Want to see it in action? Check out my walkthrough on YouTube: [MCP vs. Function Calling - Controlling my office lights with Cursor](https://www.youtube.com/watch?v=DCp3SkPPq2A) 13 | 14 | ## Home Assistant 15 | 16 | [Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform. I run it on a Raspberry Pi in my home. 17 | Home Assistant controls my lights, and you can control it via the Home Assistant WebSocket API. 18 | 19 | I built out the `./data-manager` and `./hass-ws-client` utils while playing around with Home Assistant a while ago. I thought it would be a fun example for an external tool. However, the Home Assistant code isn't the focus of this repository. 20 | 21 | ## Function Calling 22 | 23 | [OpenAI function calling docs](https://platform.openai.com/docs/guides/function-calling) 24 | 25 | Function calling lets AI assistants invoke predefined functions or tools. These functions run directly in the assistant's environment and can do anything from file searches to API calls. The LLM receives function descriptions in JSON format and specifies which function to call with what arguments. The application then handles the execution. 26 | 27 | -> Functions live in your LLM application code. 28 | 29 | ## MCP Server 30 | 31 | [MCP docs](https://modelcontextprotocol.io/introduction) 32 | 33 | MCP servers bridge AI applications with third-party services. They expose functions through a standardized protocol that any MCP-compatible LLM can use. While function calling happens locally, MCP servers handle external service communication, auth, and command execution separately. 34 | 35 | -> MCP servers are standalone apps any MCP-compatible LLM can use. 36 | 37 | ### Setting up the MCP server 38 | 39 | 1. Create a `.env` file in the `mcp-server` directory: 40 | 41 | ```bash 42 | cp mcp-server/.env.example mcp-server/.env 43 | ``` 44 | 45 | 2. Add your Home Assistant API token to the `.env` file: 46 | 47 | ```bash 48 | HOME_ASSISTANT_API_TOKEN=<your-home-assistant-api-token> 49 | ``` 50 | 51 | 3. Build the MCP server: 52 | 53 | ```bash 54 | bun i 55 | bun run build 56 | ``` 57 | 58 | 4. Add the MCP server to your LLM app config (e.g., Cursor): 59 | 60 | ```json 61 | { 62 | "name": "home-assistant", 63 | "command": "node /Users/andrelandgraf/workspaces/mcps/mcp-server/dist/index.js" 64 | } 65 | ``` 66 | 67 | That's it! Your LLM app can now control Home Assistant lights through the MCP server. 68 | ``` -------------------------------------------------------------------------------- /func-calling/src/data-manager/config.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type AreaConfig = { 2 | areaId: string; 3 | }; 4 | 5 | export const dashboardConfigs: AreaConfig[] = [ 6 | { 7 | areaId: "living_room", 8 | }, 9 | { 10 | areaId: "kitchen", 11 | }, 12 | { 13 | areaId: "bedroom", 14 | }, 15 | { 16 | areaId: "office", 17 | }, 18 | ]; 19 | ``` -------------------------------------------------------------------------------- /mcp-server/src/data-manager/config.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type AreaConfig = { 2 | areaId: string; 3 | }; 4 | 5 | export const dashboardConfigs: AreaConfig[] = [ 6 | { 7 | areaId: "living_room", 8 | }, 9 | { 10 | areaId: "kitchen", 11 | }, 12 | { 13 | areaId: "bedroom", 14 | }, 15 | { 16 | areaId: "office", 17 | }, 18 | ]; 19 | ``` -------------------------------------------------------------------------------- /func-calling/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "func-calling", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.0.0" 10 | }, 11 | "dependencies": { 12 | "commander": "^13.1.0", 13 | "inquirer": "^12.4.2", 14 | "openai": "^4.86.1", 15 | "tiny-invariant": "^1.3.3" 16 | } 17 | } 18 | ``` -------------------------------------------------------------------------------- /mcp-server/package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "mcp-server", 3 | "module": "index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "build": "bun build --target node --outfile dist/index.js --env inline src/index.ts" 7 | }, 8 | "devDependencies": { 9 | "@types/bun": "latest" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5.0.0" 13 | }, 14 | "dependencies": { 15 | "@modelcontextprotocol/sdk": "^1.6.1", 16 | "tiny-invariant": "^1.3.3", 17 | "ws": "^8.18.1", 18 | "zod": "^3.24.2" 19 | } 20 | } 21 | ``` -------------------------------------------------------------------------------- /func-calling/tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /mcp-server/tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /func-calling/src/data-manager/data.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type Area = { 2 | id: string; 3 | name: string; 4 | floorId: string | null; 5 | lights: Light[]; 6 | }; 7 | 8 | export type Light = { 9 | areaId: string; 10 | areaName: string; 11 | deviceId: string | null; 12 | deviceName: string; 13 | entityId: string; 14 | state: "on" | "off" | "unavailable"; 15 | brightnessPercentage: number | null; 16 | rgbColor: [number, number, number] | null; 17 | }; 18 | 19 | export const EntityTypes = { 20 | light: "light", 21 | } as const; 22 | 23 | export type HomeAssistantData = { 24 | areas: Area[]; 25 | }; 26 | 27 | export function getLightState(state?: string): Light["state"] { 28 | return state === "on" ? "on" : state === "off" ? "off" : "unavailable"; 29 | } 30 | 31 | export function getBrightnessPercentage(brightness: unknown): number | null { 32 | const maxBrightness = 255; 33 | let brightnessValue: number | null = null; 34 | if (typeof brightness === "string") { 35 | brightnessValue = Number.parseInt(brightness, 10); 36 | if (Number.isNaN(brightnessValue)) { 37 | return null; 38 | } 39 | } else if (typeof brightness === "number") { 40 | brightnessValue = brightness; 41 | } else { 42 | return null; 43 | } 44 | return Math.round((brightnessValue / maxBrightness) * 100); 45 | } 46 | 47 | export function getBrightnessValue( 48 | brightnessPercentage: number | null, 49 | ): number { 50 | if (brightnessPercentage === null) { 51 | return 0; 52 | } 53 | const maxBrightness = 255; 54 | return Math.round((brightnessPercentage / 100) * maxBrightness); 55 | } 56 | 57 | export function getRBGColor( 58 | rgbColor: unknown, 59 | ): [number, number, number] | null { 60 | if (!rgbColor || !Array.isArray(rgbColor)) return null; 61 | return rgbColor as [number, number, number]; 62 | } 63 | ``` -------------------------------------------------------------------------------- /mcp-server/src/data-manager/data.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type Area = { 2 | id: string; 3 | name: string; 4 | floorId: string | null; 5 | lights: Light[]; 6 | }; 7 | 8 | export type Light = { 9 | areaId: string; 10 | areaName: string; 11 | deviceId: string | null; 12 | deviceName: string; 13 | entityId: string; 14 | state: "on" | "off" | "unavailable"; 15 | brightnessPercentage: number | null; 16 | rgbColor: [number, number, number] | null; 17 | }; 18 | 19 | export const EntityTypes = { 20 | light: "light", 21 | } as const; 22 | 23 | export type HomeAssistantData = { 24 | areas: Area[]; 25 | }; 26 | 27 | export function getLightState(state?: string): Light["state"] { 28 | return state === "on" ? "on" : state === "off" ? "off" : "unavailable"; 29 | } 30 | 31 | export function getBrightnessPercentage(brightness: unknown): number | null { 32 | const maxBrightness = 255; 33 | let brightnessValue: number | null = null; 34 | if (typeof brightness === "string") { 35 | brightnessValue = Number.parseInt(brightness, 10); 36 | if (Number.isNaN(brightnessValue)) { 37 | return null; 38 | } 39 | } else if (typeof brightness === "number") { 40 | brightnessValue = brightness; 41 | } else { 42 | return null; 43 | } 44 | return Math.round((brightnessValue / maxBrightness) * 100); 45 | } 46 | 47 | export function getBrightnessValue( 48 | brightnessPercentage: number | null, 49 | ): number { 50 | if (brightnessPercentage === null) { 51 | return 0; 52 | } 53 | const maxBrightness = 255; 54 | return Math.round((brightnessPercentage / 100) * maxBrightness); 55 | } 56 | 57 | export function getRBGColor( 58 | rgbColor: unknown, 59 | ): [number, number, number] | null { 60 | if (!rgbColor || !Array.isArray(rgbColor)) return null; 61 | return rgbColor as [number, number, number]; 62 | } 63 | ``` -------------------------------------------------------------------------------- /mcp-server/src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { HomeAssistantWebSocketClient } from "./hass-ws-client/client"; 2 | import { DataManager } from "./data-manager/data-manager"; 3 | import { dashboardConfigs } from "./data-manager/config"; 4 | import invariant from "tiny-invariant"; 5 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 6 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 7 | import { z } from "zod"; 8 | 9 | // Validate environment variables 10 | invariant(process.env.HOME_ASSISTANT_HOST, "HOME_ASSISTANT_HOST must be set"); 11 | invariant(process.env.HOME_ASSISTANT_TOKEN, "HOME_ASSISTANT_TOKEN must be set"); 12 | invariant( 13 | process.env.HOME_ASSISTANT_SECURE, 14 | "HOME_ASSISTANT_SECURE must be set", 15 | ); 16 | 17 | // Initialize Home Assistant client 18 | const hassClient = new HomeAssistantWebSocketClient( 19 | process.env.HOME_ASSISTANT_HOST, 20 | process.env.HOME_ASSISTANT_TOKEN, 21 | { 22 | isSecure: process.env.HOME_ASSISTANT_SECURE === "true", 23 | shouldLog: false, 24 | }, 25 | ); 26 | const dataManager = new DataManager(hassClient); 27 | dataManager.start(); 28 | await new Promise((resolve) => setTimeout(resolve, 2000)); 29 | 30 | async function controlLight(params: { 31 | areaId: string; 32 | state: "on" | "off"; 33 | }) { 34 | if (params.state === "on") { 35 | await dataManager.turnOnAllLights(params.areaId); 36 | } else { 37 | await dataManager.turnOffAllLights(params.areaId); 38 | } 39 | } 40 | 41 | // Define the light control schema 42 | const lightControlSchema = { 43 | areaId: z 44 | .string() 45 | .describe( 46 | "The area ID of the light in Home Assistant (e.g., office, kitchen)", 47 | ), 48 | state: z.enum(["on", "off"]).describe("Whether to turn the light on or off"), 49 | } as const; 50 | 51 | // Create server instance 52 | const server = new McpServer({ 53 | name: "home-assistant", 54 | version: "1.0.0", 55 | }); 56 | 57 | // Register the light control function 58 | server.tool( 59 | "control_light", 60 | "Control a light in Home Assistant (turn on/off)", 61 | lightControlSchema, 62 | async (params) => { 63 | await controlLight(params); 64 | return { 65 | content: [ 66 | { 67 | type: "text", 68 | text: "Light control command executed successfully", 69 | }, 70 | ], 71 | }; 72 | }, 73 | ); 74 | 75 | // Create transport and start server 76 | const transport = new StdioServerTransport(); 77 | await server.connect(transport); 78 | 79 | console.log("🏠 Home Assistant MCP Server Started!"); 80 | console.log( 81 | "Available areas:", 82 | dashboardConfigs.map((config) => config.areaId), 83 | ); 84 | ``` -------------------------------------------------------------------------------- /func-calling/src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import OpenAI from "openai"; 2 | import { HomeAssistantWebSocketClient } from "./hass-ws-client/client"; 3 | import invariant from "tiny-invariant"; 4 | import inquirer from "inquirer"; 5 | import { DataManager } from "./data-manager/data-manager"; 6 | import { dashboardConfigs } from "./data-manager/config"; 7 | 8 | // Validate environment variables 9 | invariant(process.env.OPEN_AI_API_KEY, "OPEN_AI_API_KEY must be set"); 10 | invariant(process.env.HOME_ASSISTANT_HOST, "HOME_ASSISTANT_HOST must be set"); 11 | invariant(process.env.HOME_ASSISTANT_TOKEN, "HOME_ASSISTANT_TOKEN must be set"); 12 | invariant( 13 | process.env.HOME_ASSISTANT_SECURE, 14 | "HOME_ASSISTANT_SECURE must be set", 15 | ); 16 | 17 | const openAiClient = new OpenAI({ 18 | apiKey: process.env.OPEN_AI_API_KEY, 19 | }); 20 | 21 | // Initialize Home Assistant client 22 | const hassClient = new HomeAssistantWebSocketClient( 23 | process.env.HOME_ASSISTANT_HOST, 24 | process.env.HOME_ASSISTANT_TOKEN, 25 | { 26 | isSecure: process.env.HOME_ASSISTANT_SECURE === "true", 27 | shouldLog: false, 28 | }, 29 | ); 30 | const dataManager = new DataManager(hassClient); 31 | dataManager.start(); 32 | await new Promise((resolve) => setTimeout(resolve, 2000)); 33 | 34 | const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ 35 | { 36 | type: "function", 37 | function: { 38 | name: "control_light", 39 | description: "Control a light in Home Assistant (turn on/off)", 40 | parameters: { 41 | type: "object", 42 | properties: { 43 | areaId: { 44 | type: "string", 45 | description: 46 | "The area ID of the light in Home Assistant (e.g., office, kitchen)", 47 | }, 48 | state: { 49 | type: "string", 50 | enum: ["on", "off"], 51 | description: "Whether to turn the light on or off", 52 | }, 53 | }, 54 | required: ["areaId", "state"], 55 | additionalProperties: false, 56 | }, 57 | strict: true, 58 | }, 59 | }, 60 | ]; 61 | 62 | // Get list of available area IDs 63 | const availableAreaIds = dashboardConfigs 64 | .map((config) => config.areaId) 65 | .join(", "); 66 | 67 | // Initialize chat history for OpenAI 68 | const chatHistory: OpenAI.Chat.ChatCompletionMessageParam[] = [ 69 | { 70 | role: "system", 71 | content: `Available area IDs in the system are: ${availableAreaIds}. If the user's request doesn't specify an area, ask them to specify one from this list.`, 72 | }, 73 | ]; 74 | 75 | async function controlLight(params: { 76 | areaId: string; 77 | state: "on" | "off"; 78 | }) { 79 | if (params.state === "on") { 80 | await dataManager.turnOnAllLights(params.areaId); 81 | } else { 82 | await dataManager.turnOffAllLights(params.areaId); 83 | } 84 | } 85 | 86 | async function processCommand(command: string) { 87 | try { 88 | // Add user's command to history 89 | chatHistory.push({ 90 | role: "user", 91 | content: command, 92 | }); 93 | 94 | const completion = await openAiClient.chat.completions.create({ 95 | model: "gpt-4", 96 | messages: chatHistory, 97 | tools: tools, 98 | }); 99 | 100 | const replyText = completion.choices[0].message.content; 101 | if (replyText) { 102 | console.log("\n🤖 Assistant:", replyText); 103 | } 104 | 105 | const toolCalls = completion.choices[0].message.tool_calls; 106 | if (toolCalls) { 107 | console.log("toolCalls", toolCalls); 108 | } 109 | if (toolCalls && toolCalls.length > 0) { 110 | const call = toolCalls[0]; 111 | if (call.function.name === "control_light") { 112 | const params = JSON.parse(call.function.arguments); 113 | await controlLight(params); 114 | 115 | // Add the assistant's message with tool calls to chat history 116 | chatHistory.push({ 117 | role: "assistant", 118 | content: replyText, 119 | tool_calls: toolCalls, 120 | }); 121 | 122 | // Add the tool response to chat history 123 | const toolResponse: OpenAI.Chat.ChatCompletionMessageParam = { 124 | role: "tool", 125 | content: "Command executed successfully", 126 | tool_call_id: call.id, 127 | }; 128 | chatHistory.push(toolResponse); 129 | } 130 | } 131 | } catch (error) { 132 | const errorMessage = `Error: ${error instanceof Error ? error.message : "Unknown error occurred"}`; 133 | console.error("\nError processing command:", error); 134 | 135 | // Add the error message to chat history 136 | chatHistory.push({ 137 | role: "developer", 138 | content: errorMessage, 139 | }); 140 | } 141 | } 142 | 143 | async function main() { 144 | console.log("🏠 Welcome to Home Assistant Light Control!"); 145 | console.log("Available areas:", availableAreaIds, "\n"); 146 | 147 | while (true) { 148 | const { command } = await inquirer.prompt([ 149 | { 150 | type: "input", 151 | name: "command", 152 | message: "Enter your command:", 153 | }, 154 | ]); 155 | 156 | await processCommand(command); 157 | console.log(); // Empty line for better readability 158 | } 159 | } 160 | 161 | main().catch((error) => { 162 | console.error("Fatal error:", error); 163 | process.exit(1); 164 | }); 165 | ``` -------------------------------------------------------------------------------- /func-calling/src/data-manager/data-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | EntityTypes, 3 | getBrightnessPercentage, 4 | getBrightnessValue, 5 | getLightState, 6 | getRBGColor, 7 | type Light, 8 | type HomeAssistantData, 9 | } from "./data"; 10 | import { 11 | type HassArea, 12 | type HassDevice, 13 | type HassEntity, 14 | type HassEntityState, 15 | HomeAssistantWebSocketClient, 16 | } from "../hass-ws-client/client"; 17 | 18 | function getStateEntityDeviceForEntityId( 19 | entityId: string, 20 | devices: HassDevice[], 21 | entities: HassEntity[], 22 | entityStates: Record<string, HassEntityState>, 23 | ) { 24 | const state = entityStates[entityId]; 25 | const entity = entities.find((e) => e.entity_id === entityId); 26 | if (!entity) { 27 | throw Error(`Entity not found: ${entityId}`); 28 | } 29 | const device = devices.find((d) => d.id === entity.device_id); 30 | if (!device) { 31 | throw Error(`Device not found: ${entity.device_id} for entity ${entityId}`); 32 | } 33 | return { state, entity, device }; 34 | } 35 | 36 | export class DataManager { 37 | private wsClient: HomeAssistantWebSocketClient; 38 | data: HomeAssistantData; 39 | incomingData: { 40 | areas: HassArea[] | null; 41 | devices: HassDevice[] | null; 42 | entities: HassEntity[] | null; 43 | entityStates: Record<string, HassEntityState> | null; 44 | } = { 45 | areas: null, 46 | devices: null, 47 | entities: null, 48 | entityStates: null, 49 | }; 50 | 51 | constructor(wsClient: HomeAssistantWebSocketClient) { 52 | this.wsClient = wsClient; 53 | this.data = { 54 | areas: [], 55 | }; 56 | } 57 | 58 | start() { 59 | this.wsClient.connect(); 60 | this.wsClient.eventEmitter.on("areas", (areas) => { 61 | this.incomingData.areas = areas; 62 | this.syncData(); 63 | }); 64 | this.wsClient.eventEmitter.on("devices", (devices) => { 65 | this.incomingData.devices = devices; 66 | this.syncData(); 67 | }); 68 | this.wsClient.eventEmitter.on("entities", (entities) => { 69 | this.incomingData.entities = entities; 70 | this.syncData(); 71 | }); 72 | this.wsClient.eventEmitter.on("entity_states", (entitiesMap) => { 73 | this.incomingData.entityStates = entitiesMap; 74 | this.syncData(); 75 | }); 76 | this.wsClient.eventEmitter.on("entity_state_change", (changes) => { 77 | if (this.incomingData.entityStates) { 78 | for (const entityId of Object.keys(changes)) { 79 | const change = changes[entityId]; 80 | const currentState = this.incomingData.entityStates[entityId]; 81 | this.incomingData.entityStates[entityId] = { 82 | ...currentState, 83 | ...change["+"], 84 | }; 85 | } 86 | } else { 87 | for (const entityId of Object.keys(changes)) { 88 | if (entityId.startsWith(`${EntityTypes.light}.`)) { 89 | const change = changes[entityId]; 90 | this.updateLightState(entityId, change["+"]); 91 | } 92 | } 93 | } 94 | }); 95 | } 96 | 97 | async cleanup() { 98 | this.wsClient.close(); 99 | } 100 | 101 | private syncData() { 102 | if ( 103 | this.incomingData.areas && 104 | this.incomingData.devices && 105 | this.incomingData.entities && 106 | this.incomingData.entityStates 107 | ) { 108 | this.updateAreas(this.incomingData.areas); 109 | this.updateLights( 110 | this.incomingData.devices, 111 | this.incomingData.entities, 112 | this.incomingData.entityStates, 113 | ); 114 | this.incomingData.areas = null; 115 | this.incomingData.devices = null; 116 | this.incomingData.entities = null; 117 | this.incomingData.entityStates = null; 118 | } 119 | } 120 | 121 | private updateAreas(areas: HassArea[]) { 122 | const staleAreas = this.data.areas; 123 | this.data.areas = areas.map((area) => { 124 | const staleArea = staleAreas.find((a) => a.id === area.area_id); 125 | return { 126 | lights: [], 127 | ...staleArea, 128 | id: area.area_id, 129 | name: area.name, 130 | floorId: area.floor_id, 131 | }; 132 | }); 133 | } 134 | 135 | private updateLights( 136 | devices: HassDevice[], 137 | entities: HassEntity[], 138 | entityStates: Record<string, HassEntityState>, 139 | ) { 140 | for (const entityId of Object.keys(entityStates)) { 141 | if (!entityId.startsWith(`${EntityTypes.light}.`)) { 142 | continue; 143 | } 144 | const { state, device } = getStateEntityDeviceForEntityId( 145 | entityId, 146 | devices, 147 | entities, 148 | entityStates, 149 | ); 150 | const area = this.data.areas.find((a) => a.id === device.area_id); 151 | if (!area) { 152 | throw Error(`Area not found: ${device.area_id} for light ${entityId}`); 153 | } 154 | const light: Light = { 155 | areaId: area.id, 156 | areaName: area.name, 157 | deviceId: device.id, 158 | deviceName: device.name, 159 | entityId: entityId, 160 | state: getLightState(state.s), 161 | brightnessPercentage: getBrightnessPercentage(state.a.brightness), 162 | rgbColor: getRBGColor(state.a.rgb_color), 163 | }; 164 | const existingLightIndex = area.lights.findIndex( 165 | (l) => l.entityId === entityId, 166 | ); 167 | if (existingLightIndex !== -1) { 168 | area.lights[existingLightIndex] = light; 169 | } else { 170 | area.lights.push(light); 171 | } 172 | } 173 | } 174 | 175 | /** 176 | * @returns {string | null} areaId of the area where the light is located or null if not found 177 | */ 178 | private updateLightState( 179 | entityId: string, 180 | entityState: HassEntityState, 181 | ): string | null { 182 | for (const area of this.data.areas) { 183 | const light = area.lights.find((l) => l.entityId === entityId); 184 | if (light) { 185 | light.state = getLightState(entityState.s); 186 | if (entityState.a) { 187 | light.brightnessPercentage = getBrightnessPercentage( 188 | entityState.a.brightness, 189 | ); 190 | light.rgbColor = getRBGColor(entityState.a.rgb_color); 191 | } 192 | return area.id; 193 | } 194 | } 195 | return null; 196 | } 197 | 198 | getLights(areaId: string) { 199 | const area = this.data.areas.find((area) => area.id === areaId); 200 | if (!area) { 201 | throw new Error(`Area not found: ${areaId}`); 202 | } 203 | return area.lights; 204 | } 205 | 206 | getAverageBrightness(areaId: string) { 207 | const lights = this.getLights(areaId); 208 | if (lights.length === 0) { 209 | return 0; 210 | } 211 | const totalBrightness = lights.reduce( 212 | (acc, light) => acc + (light.brightnessPercentage || 0), 213 | 0, 214 | ); 215 | return totalBrightness / lights.length; 216 | } 217 | 218 | turnOffLight(entityId: string) { 219 | this.wsClient.sendTurnOffLight(entityId); 220 | } 221 | 222 | turnOnLight(entityId: string) { 223 | this.wsClient.sendTurnOnLight(entityId); 224 | } 225 | 226 | dimLight(entityId: string, brightnessPercentage: number) { 227 | const brightness = getBrightnessValue(brightnessPercentage); 228 | if (brightness === null || brightness === 0) { 229 | this.turnOffLight(entityId); 230 | } else { 231 | this.wsClient.sendTurnOnLight(entityId, { brightness }); 232 | } 233 | } 234 | 235 | async turnOffAllLights(areaId: string) { 236 | // console.log("Turning off all lights in area", areaId); 237 | const lights = this.getLights(areaId); 238 | for (const light of lights) { 239 | if (light.state === "on") { 240 | this.turnOffLight(light.entityId); 241 | } 242 | } 243 | } 244 | 245 | async turnOnAllLights(areaId: string) { 246 | const lights = this.getLights(areaId); 247 | for (const light of lights) { 248 | if (light.state === "off") { 249 | this.turnOnLight(light.entityId); 250 | } 251 | } 252 | } 253 | 254 | async dimAllLights(areaId: string, brightnessPercentage: number) { 255 | const lights = this.getLights(areaId); 256 | for (const light of lights) { 257 | this.dimLight(light.entityId, brightnessPercentage); 258 | } 259 | } 260 | } 261 | ``` -------------------------------------------------------------------------------- /mcp-server/src/data-manager/data-manager.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | EntityTypes, 3 | getBrightnessPercentage, 4 | getBrightnessValue, 5 | getLightState, 6 | getRBGColor, 7 | type Light, 8 | type HomeAssistantData, 9 | } from "./data"; 10 | import { 11 | type HassArea, 12 | type HassDevice, 13 | type HassEntity, 14 | type HassEntityState, 15 | HomeAssistantWebSocketClient, 16 | } from "../hass-ws-client/client"; 17 | 18 | function getStateEntityDeviceForEntityId( 19 | entityId: string, 20 | devices: HassDevice[], 21 | entities: HassEntity[], 22 | entityStates: Record<string, HassEntityState>, 23 | ) { 24 | const state = entityStates[entityId]; 25 | const entity = entities.find((e) => e.entity_id === entityId); 26 | if (!entity) { 27 | throw Error(`Entity not found: ${entityId}`); 28 | } 29 | const device = devices.find((d) => d.id === entity.device_id); 30 | if (!device) { 31 | throw Error(`Device not found: ${entity.device_id} for entity ${entityId}`); 32 | } 33 | return { state, entity, device }; 34 | } 35 | 36 | export class DataManager { 37 | private wsClient: HomeAssistantWebSocketClient; 38 | data: HomeAssistantData; 39 | incomingData: { 40 | areas: HassArea[] | null; 41 | devices: HassDevice[] | null; 42 | entities: HassEntity[] | null; 43 | entityStates: Record<string, HassEntityState> | null; 44 | } = { 45 | areas: null, 46 | devices: null, 47 | entities: null, 48 | entityStates: null, 49 | }; 50 | 51 | constructor(wsClient: HomeAssistantWebSocketClient) { 52 | this.wsClient = wsClient; 53 | this.data = { 54 | areas: [], 55 | }; 56 | } 57 | 58 | start() { 59 | this.wsClient.connect(); 60 | this.wsClient.eventEmitter.on("areas", (areas) => { 61 | this.incomingData.areas = areas; 62 | this.syncData(); 63 | }); 64 | this.wsClient.eventEmitter.on("devices", (devices) => { 65 | this.incomingData.devices = devices; 66 | this.syncData(); 67 | }); 68 | this.wsClient.eventEmitter.on("entities", (entities) => { 69 | this.incomingData.entities = entities; 70 | this.syncData(); 71 | }); 72 | this.wsClient.eventEmitter.on("entity_states", (entitiesMap) => { 73 | this.incomingData.entityStates = entitiesMap; 74 | this.syncData(); 75 | }); 76 | this.wsClient.eventEmitter.on("entity_state_change", (changes) => { 77 | if (this.incomingData.entityStates) { 78 | for (const entityId of Object.keys(changes)) { 79 | const change = changes[entityId]; 80 | const currentState = this.incomingData.entityStates[entityId]; 81 | this.incomingData.entityStates[entityId] = { 82 | ...currentState, 83 | ...change["+"], 84 | }; 85 | } 86 | } else { 87 | for (const entityId of Object.keys(changes)) { 88 | if (entityId.startsWith(`${EntityTypes.light}.`)) { 89 | const change = changes[entityId]; 90 | this.updateLightState(entityId, change["+"]); 91 | } 92 | } 93 | } 94 | }); 95 | } 96 | 97 | async cleanup() { 98 | this.wsClient.close(); 99 | } 100 | 101 | private syncData() { 102 | if ( 103 | this.incomingData.areas && 104 | this.incomingData.devices && 105 | this.incomingData.entities && 106 | this.incomingData.entityStates 107 | ) { 108 | this.updateAreas(this.incomingData.areas); 109 | this.updateLights( 110 | this.incomingData.devices, 111 | this.incomingData.entities, 112 | this.incomingData.entityStates, 113 | ); 114 | this.incomingData.areas = null; 115 | this.incomingData.devices = null; 116 | this.incomingData.entities = null; 117 | this.incomingData.entityStates = null; 118 | } 119 | } 120 | 121 | private updateAreas(areas: HassArea[]) { 122 | const staleAreas = this.data.areas; 123 | this.data.areas = areas.map((area) => { 124 | const staleArea = staleAreas.find((a) => a.id === area.area_id); 125 | return { 126 | lights: [], 127 | ...staleArea, 128 | id: area.area_id, 129 | name: area.name, 130 | floorId: area.floor_id, 131 | }; 132 | }); 133 | } 134 | 135 | private updateLights( 136 | devices: HassDevice[], 137 | entities: HassEntity[], 138 | entityStates: Record<string, HassEntityState>, 139 | ) { 140 | for (const entityId of Object.keys(entityStates)) { 141 | if (!entityId.startsWith(`${EntityTypes.light}.`)) { 142 | continue; 143 | } 144 | const { state, device } = getStateEntityDeviceForEntityId( 145 | entityId, 146 | devices, 147 | entities, 148 | entityStates, 149 | ); 150 | const area = this.data.areas.find((a) => a.id === device.area_id); 151 | if (!area) { 152 | throw Error(`Area not found: ${device.area_id} for light ${entityId}`); 153 | } 154 | const light: Light = { 155 | areaId: area.id, 156 | areaName: area.name, 157 | deviceId: device.id, 158 | deviceName: device.name, 159 | entityId: entityId, 160 | state: getLightState(state.s), 161 | brightnessPercentage: getBrightnessPercentage(state.a.brightness), 162 | rgbColor: getRBGColor(state.a.rgb_color), 163 | }; 164 | const existingLightIndex = area.lights.findIndex( 165 | (l) => l.entityId === entityId, 166 | ); 167 | if (existingLightIndex !== -1) { 168 | area.lights[existingLightIndex] = light; 169 | } else { 170 | area.lights.push(light); 171 | } 172 | } 173 | } 174 | 175 | /** 176 | * @returns {string | null} areaId of the area where the light is located or null if not found 177 | */ 178 | private updateLightState( 179 | entityId: string, 180 | entityState: HassEntityState, 181 | ): string | null { 182 | for (const area of this.data.areas) { 183 | const light = area.lights.find((l) => l.entityId === entityId); 184 | if (light) { 185 | light.state = getLightState(entityState.s); 186 | if (entityState.a) { 187 | light.brightnessPercentage = getBrightnessPercentage( 188 | entityState.a.brightness, 189 | ); 190 | light.rgbColor = getRBGColor(entityState.a.rgb_color); 191 | } 192 | return area.id; 193 | } 194 | } 195 | return null; 196 | } 197 | 198 | getLights(areaId: string) { 199 | const area = this.data.areas.find((area) => area.id === areaId); 200 | if (!area) { 201 | throw new Error(`Area not found: ${areaId}`); 202 | } 203 | return area.lights; 204 | } 205 | 206 | getAverageBrightness(areaId: string) { 207 | const lights = this.getLights(areaId); 208 | if (lights.length === 0) { 209 | return 0; 210 | } 211 | const totalBrightness = lights.reduce( 212 | (acc, light) => acc + (light.brightnessPercentage || 0), 213 | 0, 214 | ); 215 | return totalBrightness / lights.length; 216 | } 217 | 218 | turnOffLight(entityId: string) { 219 | this.wsClient.sendTurnOffLight(entityId); 220 | } 221 | 222 | turnOnLight(entityId: string) { 223 | this.wsClient.sendTurnOnLight(entityId); 224 | } 225 | 226 | dimLight(entityId: string, brightnessPercentage: number) { 227 | const brightness = getBrightnessValue(brightnessPercentage); 228 | if (brightness === null || brightness === 0) { 229 | this.turnOffLight(entityId); 230 | } else { 231 | this.wsClient.sendTurnOnLight(entityId, { brightness }); 232 | } 233 | } 234 | 235 | async turnOffAllLights(areaId: string) { 236 | // console.log("Turning off all lights in area", areaId); 237 | const lights = this.getLights(areaId); 238 | for (const light of lights) { 239 | if (light.state === "on") { 240 | this.turnOffLight(light.entityId); 241 | } 242 | } 243 | } 244 | 245 | async turnOnAllLights(areaId: string) { 246 | const lights = this.getLights(areaId); 247 | for (const light of lights) { 248 | if (light.state === "off") { 249 | this.turnOnLight(light.entityId); 250 | } 251 | } 252 | } 253 | 254 | async dimAllLights(areaId: string, brightnessPercentage: number) { 255 | const lights = this.getLights(areaId); 256 | for (const light of lights) { 257 | this.dimLight(light.entityId, brightnessPercentage); 258 | } 259 | } 260 | } 261 | ``` -------------------------------------------------------------------------------- /func-calling/src/hass-ws-client/client.ts: -------------------------------------------------------------------------------- ```typescript 1 | /* WebSocket client for the Home Assistant server */ 2 | import { WebSocket } from "ws"; 3 | import EventEmitter from "node:events"; 4 | import { clearInterval, setInterval } from "timers"; 5 | 6 | export type HassArea = { 7 | area_id: string; // unique name 8 | floor_id: string | null; 9 | name: string; 10 | }; 11 | 12 | export type HassDevice = { 13 | area_id: string | null; 14 | id: string; // uuid 15 | manufacturer: string | null; 16 | model: string | null; // string not always usable 17 | name: string; 18 | name_by_user: string | null; 19 | }; 20 | 21 | export type HassEntity = { 22 | device_id: string | null; 23 | entity_id: string; // unique name 24 | }; 25 | 26 | export type HassEntityState = { 27 | s: string | "on" | "off" | "unavailable" | "not_home" | "home" | "unknown"; // state, number is string 28 | a: { 29 | [key: string]: unknown; // attributes 30 | }; 31 | }; 32 | 33 | export type HassHueLightEntityState = HassEntityState & { 34 | s: "on" | "off" | "unavailable"; 35 | a: { 36 | color_mode: "color_temp" | string | null; 37 | brightness: number | null; 38 | color_temp_kelvin: number | null; 39 | color_temp: number | null; 40 | hs_color: number[] | null; 41 | rgb_color: number[] | null; 42 | xy_color: number[] | null; 43 | }; 44 | }; 45 | 46 | /** 47 | * Message types that Home Assistant server sends to the client 48 | */ 49 | const SERVER_MESSAGE_TYPES = { 50 | AUTH_REQUIRED: "auth_required", 51 | AUTH_OK: "auth_ok", 52 | AUTH_INVALID: "auth_invalid", 53 | RESULT: "result", 54 | EVENT: "event", 55 | } as const; 56 | 57 | /** 58 | * Message types that the client can send to the Home Assistant server 59 | */ 60 | const CLIENT_MESSAGE_TYPES = { 61 | AUTH: "auth", 62 | SUBSCRIBE_ENTITIES: "subscribe_entities", 63 | CALL_SERVICE: "call_service", 64 | GET_AREA_REGISTRY: "config/area_registry/list", 65 | GET_DEVICE_REGISTRY: "config/device_registry/list", 66 | GET_ENTITY_REGISTRY: "config/entity_registry/list", 67 | } as const; 68 | 69 | export type ClientMessageType = 70 | (typeof CLIENT_MESSAGE_TYPES)[keyof typeof CLIENT_MESSAGE_TYPES]; 71 | export type ServerMessageType = 72 | (typeof SERVER_MESSAGE_TYPES)[keyof typeof SERVER_MESSAGE_TYPES]; 73 | 74 | export class HomeAssistantWebSocketClient { 75 | private connectionUrl: string; 76 | private token: string; 77 | private socket: WebSocket | null = null; 78 | private shouldLog: boolean; 79 | private runningId = 1; 80 | private refetchInterval: ReturnType<typeof setInterval> | null = null; 81 | private ids = { 82 | areas: 0, 83 | devices: 0, 84 | entities: 0, 85 | entityStates: 0, 86 | }; 87 | eventEmitter = new EventEmitter<{ 88 | areas: [HassArea[]]; 89 | devices: [HassDevice[]]; 90 | entities: [HassEntity[]]; 91 | entity_states: [Record<string, HassEntityState>]; 92 | entity_state_change: [Record<string, { "+": HassEntityState }>]; 93 | }>(); 94 | 95 | constructor( 96 | host: string, 97 | token: string, 98 | { isSecure = false, shouldLog = false } = {}, 99 | ) { 100 | const protocol = isSecure ? "wss" : "ws"; 101 | this.connectionUrl = `${protocol}://${host}/api/websocket`; 102 | this.token = token; 103 | this.shouldLog = shouldLog; 104 | } 105 | 106 | private log(message: string, ...args: unknown[]) { 107 | if (this.shouldLog) { 108 | console.log(`HomeAssistantWebSocketClient: ${message}`, ...args); 109 | } 110 | } 111 | 112 | connect() { 113 | this.log("Connecting to Home Assistant WS server..."); 114 | if (this.socket) { 115 | throw new Error("Socket unexpectedly already connected"); 116 | } 117 | 118 | this.runningId = 1; 119 | const socket = new WebSocket(this.connectionUrl); 120 | 121 | socket.onopen = () => { 122 | this.log("Connected to server"); 123 | }; 124 | 125 | socket.onmessage = (event) => { 126 | const data = JSON.parse(event.data.toString()); 127 | const serverMessageType = data.type; 128 | this.log("Received message from Home Assistant: ", serverMessageType); 129 | 130 | if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_REQUIRED) { 131 | socket.send( 132 | JSON.stringify({ 133 | type: CLIENT_MESSAGE_TYPES.AUTH, 134 | access_token: this.token, 135 | }), 136 | ); 137 | return; 138 | } 139 | 140 | if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_INVALID) { 141 | console.error("Authentication failed. Closing connection."); 142 | socket.close(); 143 | return; 144 | } 145 | 146 | if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_OK) { 147 | this.log("Authentication successful."); 148 | this.sendDataRequests(); 149 | return; 150 | } 151 | 152 | if (serverMessageType === SERVER_MESSAGE_TYPES.RESULT) { 153 | if (data.error) { 154 | console.error("Error result: ", data.error); 155 | return; 156 | } 157 | if (data.id === this.ids.areas) { 158 | this.log("Received areas result", data.result.length); 159 | this.eventEmitter.emit("areas", data.result); 160 | return; 161 | } 162 | if (data.id === this.ids.devices) { 163 | this.log("Received devices result", data.result.length); 164 | this.eventEmitter.emit("devices", data.result); 165 | return; 166 | } 167 | if (data.id === this.ids.entities) { 168 | this.log("Received entities result", data.result.length); 169 | this.eventEmitter.emit("entities", data.result); 170 | return; 171 | } 172 | if (data.id === this.ids.entityStates) { 173 | this.log("Successfully subscribed to entities"); 174 | return; 175 | } 176 | return; 177 | } 178 | 179 | if (serverMessageType === SERVER_MESSAGE_TYPES.EVENT) { 180 | if ("a" in data.event) { 181 | this.log("Received entities event", Object.keys(data.event.a)); 182 | this.eventEmitter.emit("entity_states", data.event.a); 183 | return; 184 | } 185 | if ("c" in data.event) { 186 | this.log("Received entities change event", Object.keys(data.event.c)); 187 | this.eventEmitter.emit("entity_state_change", data.event.c); 188 | return; 189 | } 190 | return; 191 | } 192 | }; 193 | 194 | socket.onclose = () => { 195 | this.log("Disconnected from server"); 196 | }; 197 | this.socket = socket; 198 | 199 | const threeMinutesInMs = 3 * 60 * 1000; 200 | this.refetchInterval = setInterval(() => { 201 | if (this.socket) { 202 | this.sendDataRequests(); 203 | } else if (this.refetchInterval) { 204 | clearInterval(this.refetchInterval); 205 | } 206 | }, threeMinutesInMs); 207 | } 208 | 209 | close() { 210 | if (this.socket) { 211 | if (this.refetchInterval) { 212 | clearInterval(this.refetchInterval); 213 | } 214 | this.socket.close(); 215 | } 216 | } 217 | 218 | private send(type: ClientMessageType, payload?: object) { 219 | if (!this.socket) { 220 | throw new Error("Socket is not connected"); 221 | } 222 | const id = this.runningId; 223 | const message = { ...payload, type, id }; 224 | this.log("Sending ws message to Home Assistant: ", message); 225 | this.socket.send(JSON.stringify(message)); 226 | this.runningId = 227 | this.runningId + 1 >= Number.MAX_SAFE_INTEGER ? 1 : this.runningId + 1; 228 | return id; 229 | } 230 | 231 | private sendDataRequests() { 232 | if (!this.socket) { 233 | throw new Error( 234 | "Attempting to sendDataRequests but socket is not connected", 235 | ); 236 | } 237 | this.ids.areas = this.send(CLIENT_MESSAGE_TYPES.GET_AREA_REGISTRY); 238 | this.ids.devices = this.send(CLIENT_MESSAGE_TYPES.GET_DEVICE_REGISTRY); 239 | this.ids.entities = this.send(CLIENT_MESSAGE_TYPES.GET_ENTITY_REGISTRY); 240 | this.ids.entityStates = this.send(CLIENT_MESSAGE_TYPES.SUBSCRIBE_ENTITIES); 241 | } 242 | 243 | sendToggleLight(entityId: string) { 244 | this.log("Sending toggle light"); 245 | this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { 246 | domain: "light", 247 | service: "toggle", 248 | service_data: { entity_id: entityId }, 249 | }); 250 | } 251 | 252 | sendTurnOnLight(entityId: string, data?: { brightness?: number }) { 253 | this.log("Sending turn on light"); 254 | this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { 255 | domain: "light", 256 | service: "turn_on", 257 | service_data: { entity_id: entityId, ...data }, 258 | }); 259 | } 260 | 261 | sendTurnOffLight(entityId: string) { 262 | this.log("Sending turn off light"); 263 | this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { 264 | domain: "light", 265 | service: "turn_off", 266 | service_data: { entity_id: entityId }, 267 | }); 268 | } 269 | } 270 | ``` -------------------------------------------------------------------------------- /mcp-server/src/hass-ws-client/client.ts: -------------------------------------------------------------------------------- ```typescript 1 | /* WebSocket client for the Home Assistant server */ 2 | import { WebSocket } from "ws"; 3 | import EventEmitter from "node:events"; 4 | import { clearInterval, setInterval } from "timers"; 5 | 6 | export type HassArea = { 7 | area_id: string; // unique name 8 | floor_id: string | null; 9 | name: string; 10 | }; 11 | 12 | export type HassDevice = { 13 | area_id: string | null; 14 | id: string; // uuid 15 | manufacturer: string | null; 16 | model: string | null; // string not always usable 17 | name: string; 18 | name_by_user: string | null; 19 | }; 20 | 21 | export type HassEntity = { 22 | device_id: string | null; 23 | entity_id: string; // unique name 24 | }; 25 | 26 | export type HassEntityState = { 27 | s: string | "on" | "off" | "unavailable" | "not_home" | "home" | "unknown"; // state, number is string 28 | a: { 29 | [key: string]: unknown; // attributes 30 | }; 31 | }; 32 | 33 | export type HassHueLightEntityState = HassEntityState & { 34 | s: "on" | "off" | "unavailable"; 35 | a: { 36 | color_mode: "color_temp" | string | null; 37 | brightness: number | null; 38 | color_temp_kelvin: number | null; 39 | color_temp: number | null; 40 | hs_color: number[] | null; 41 | rgb_color: number[] | null; 42 | xy_color: number[] | null; 43 | }; 44 | }; 45 | 46 | /** 47 | * Message types that Home Assistant server sends to the client 48 | */ 49 | const SERVER_MESSAGE_TYPES = { 50 | AUTH_REQUIRED: "auth_required", 51 | AUTH_OK: "auth_ok", 52 | AUTH_INVALID: "auth_invalid", 53 | RESULT: "result", 54 | EVENT: "event", 55 | } as const; 56 | 57 | /** 58 | * Message types that the client can send to the Home Assistant server 59 | */ 60 | const CLIENT_MESSAGE_TYPES = { 61 | AUTH: "auth", 62 | SUBSCRIBE_ENTITIES: "subscribe_entities", 63 | CALL_SERVICE: "call_service", 64 | GET_AREA_REGISTRY: "config/area_registry/list", 65 | GET_DEVICE_REGISTRY: "config/device_registry/list", 66 | GET_ENTITY_REGISTRY: "config/entity_registry/list", 67 | } as const; 68 | 69 | export type ClientMessageType = 70 | (typeof CLIENT_MESSAGE_TYPES)[keyof typeof CLIENT_MESSAGE_TYPES]; 71 | export type ServerMessageType = 72 | (typeof SERVER_MESSAGE_TYPES)[keyof typeof SERVER_MESSAGE_TYPES]; 73 | 74 | export class HomeAssistantWebSocketClient { 75 | private connectionUrl: string; 76 | private token: string; 77 | private socket: WebSocket | null = null; 78 | private shouldLog: boolean; 79 | private runningId = 1; 80 | private refetchInterval: ReturnType<typeof setInterval> | null = null; 81 | private ids = { 82 | areas: 0, 83 | devices: 0, 84 | entities: 0, 85 | entityStates: 0, 86 | }; 87 | eventEmitter = new EventEmitter<{ 88 | areas: [HassArea[]]; 89 | devices: [HassDevice[]]; 90 | entities: [HassEntity[]]; 91 | entity_states: [Record<string, HassEntityState>]; 92 | entity_state_change: [Record<string, { "+": HassEntityState }>]; 93 | }>(); 94 | 95 | constructor( 96 | host: string, 97 | token: string, 98 | { isSecure = false, shouldLog = false } = {}, 99 | ) { 100 | const protocol = isSecure ? "wss" : "ws"; 101 | this.connectionUrl = `${protocol}://${host}/api/websocket`; 102 | this.token = token; 103 | this.shouldLog = shouldLog; 104 | } 105 | 106 | private log(message: string, ...args: unknown[]) { 107 | if (this.shouldLog) { 108 | console.log(`HomeAssistantWebSocketClient: ${message}`, ...args); 109 | } 110 | } 111 | 112 | connect() { 113 | this.log("Connecting to Home Assistant WS server..."); 114 | if (this.socket) { 115 | throw new Error("Socket unexpectedly already connected"); 116 | } 117 | 118 | this.runningId = 1; 119 | const socket = new WebSocket(this.connectionUrl); 120 | 121 | socket.onopen = () => { 122 | this.log("Connected to server"); 123 | }; 124 | 125 | socket.onmessage = (event) => { 126 | const data = JSON.parse(event.data.toString()); 127 | const serverMessageType = data.type; 128 | this.log("Received message from Home Assistant: ", serverMessageType); 129 | 130 | if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_REQUIRED) { 131 | socket.send( 132 | JSON.stringify({ 133 | type: CLIENT_MESSAGE_TYPES.AUTH, 134 | access_token: this.token, 135 | }), 136 | ); 137 | return; 138 | } 139 | 140 | if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_INVALID) { 141 | console.error("Authentication failed. Closing connection."); 142 | socket.close(); 143 | return; 144 | } 145 | 146 | if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_OK) { 147 | this.log("Authentication successful."); 148 | this.sendDataRequests(); 149 | return; 150 | } 151 | 152 | if (serverMessageType === SERVER_MESSAGE_TYPES.RESULT) { 153 | if (data.error) { 154 | console.error("Error result: ", data.error); 155 | return; 156 | } 157 | if (data.id === this.ids.areas) { 158 | this.log("Received areas result", data.result.length); 159 | this.eventEmitter.emit("areas", data.result); 160 | return; 161 | } 162 | if (data.id === this.ids.devices) { 163 | this.log("Received devices result", data.result.length); 164 | this.eventEmitter.emit("devices", data.result); 165 | return; 166 | } 167 | if (data.id === this.ids.entities) { 168 | this.log("Received entities result", data.result.length); 169 | this.eventEmitter.emit("entities", data.result); 170 | return; 171 | } 172 | if (data.id === this.ids.entityStates) { 173 | this.log("Successfully subscribed to entities"); 174 | return; 175 | } 176 | return; 177 | } 178 | 179 | if (serverMessageType === SERVER_MESSAGE_TYPES.EVENT) { 180 | if ("a" in data.event) { 181 | this.log("Received entities event", Object.keys(data.event.a)); 182 | this.eventEmitter.emit("entity_states", data.event.a); 183 | return; 184 | } 185 | if ("c" in data.event) { 186 | this.log("Received entities change event", Object.keys(data.event.c)); 187 | this.eventEmitter.emit("entity_state_change", data.event.c); 188 | return; 189 | } 190 | return; 191 | } 192 | }; 193 | 194 | socket.onclose = () => { 195 | this.log("Disconnected from server"); 196 | }; 197 | this.socket = socket; 198 | 199 | const threeMinutesInMs = 3 * 60 * 1000; 200 | this.refetchInterval = setInterval(() => { 201 | if (this.socket) { 202 | this.sendDataRequests(); 203 | } else if (this.refetchInterval) { 204 | clearInterval(this.refetchInterval); 205 | } 206 | }, threeMinutesInMs); 207 | } 208 | 209 | close() { 210 | if (this.socket) { 211 | if (this.refetchInterval) { 212 | clearInterval(this.refetchInterval); 213 | } 214 | this.socket.close(); 215 | } 216 | } 217 | 218 | private send(type: ClientMessageType, payload?: object) { 219 | if (!this.socket) { 220 | throw new Error("Socket is not connected"); 221 | } 222 | const id = this.runningId; 223 | const message = { ...payload, type, id }; 224 | this.log("Sending ws message to Home Assistant: ", message); 225 | this.socket.send(JSON.stringify(message)); 226 | this.runningId = 227 | this.runningId + 1 >= Number.MAX_SAFE_INTEGER ? 1 : this.runningId + 1; 228 | return id; 229 | } 230 | 231 | private sendDataRequests() { 232 | if (!this.socket) { 233 | throw new Error( 234 | "Attempting to sendDataRequests but socket is not connected", 235 | ); 236 | } 237 | this.ids.areas = this.send(CLIENT_MESSAGE_TYPES.GET_AREA_REGISTRY); 238 | this.ids.devices = this.send(CLIENT_MESSAGE_TYPES.GET_DEVICE_REGISTRY); 239 | this.ids.entities = this.send(CLIENT_MESSAGE_TYPES.GET_ENTITY_REGISTRY); 240 | this.ids.entityStates = this.send(CLIENT_MESSAGE_TYPES.SUBSCRIBE_ENTITIES); 241 | } 242 | 243 | sendToggleLight(entityId: string) { 244 | this.log("Sending toggle light"); 245 | this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { 246 | domain: "light", 247 | service: "toggle", 248 | service_data: { entity_id: entityId }, 249 | }); 250 | } 251 | 252 | sendTurnOnLight(entityId: string, data?: { brightness?: number }) { 253 | this.log("Sending turn on light"); 254 | this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { 255 | domain: "light", 256 | service: "turn_on", 257 | service_data: { entity_id: entityId, ...data }, 258 | }); 259 | } 260 | 261 | sendTurnOffLight(entityId: string) { 262 | this.log("Sending turn off light"); 263 | this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { 264 | domain: "light", 265 | service: "turn_off", 266 | service_data: { entity_id: entityId }, 267 | }); 268 | } 269 | } 270 | ```