# 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: -------------------------------------------------------------------------------- ``` .env dist/ node_modules/ ``` -------------------------------------------------------------------------------- /mcp-server/.env.example: -------------------------------------------------------------------------------- ``` HOME_ASSISTANT_TOKEN="<your-home-assistant-token>" HOME_ASSISTANT_HOST="homeassistant.local:8123" HOME_ASSISTANT_SECURE="false" ``` -------------------------------------------------------------------------------- /func-calling/.env.example: -------------------------------------------------------------------------------- ``` OPEN_AI_API_KEY="your-openai-api-key" HOME_ASSISTANT_TOKEN="<your-home-assistant-token>" HOME_ASSISTANT_HOST="homeassistant.local:8123" HOME_ASSISTANT_SECURE="false" ``` -------------------------------------------------------------------------------- /func-calling/.gitignore: -------------------------------------------------------------------------------- ``` # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Logs logs _.log npm-debug.log_ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Caches .cache # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Runtime data pids _.pid _.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # IntelliJ based IDEs .idea # Finder (MacOS) folder config .DS_Store ``` -------------------------------------------------------------------------------- /mcp-server/.gitignore: -------------------------------------------------------------------------------- ``` # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Logs logs _.log npm-debug.log_ yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Caches .cache # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Runtime data pids _.pid _.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # IntelliJ based IDEs .idea # Finder (MacOS) folder config .DS_Store ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Function Calling vs MCP Server This repository is meant to illustrate the difference between LLM function calling and the Model Context Protocol (MCP). Function calling has been around for a while, while MCP is a newer standardization attempt. Comparing the two approaches showcases the value of MCP and how it builds on top of function calling. This repository contains two examples: - `/func-calling`: CLI app using OpenAI's function calling to control Home Assistant lights - `/mcp-server`: Node.js MCP server exposing a `control_lights` function to LLMs that use the MCP protocol 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) ## Home Assistant [Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform. I run it on a Raspberry Pi in my home. Home Assistant controls my lights, and you can control it via the Home Assistant WebSocket API. 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. ## Function Calling [OpenAI function calling docs](https://platform.openai.com/docs/guides/function-calling) 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. -> Functions live in your LLM application code. ## MCP Server [MCP docs](https://modelcontextprotocol.io/introduction) 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. -> MCP servers are standalone apps any MCP-compatible LLM can use. ### Setting up the MCP server 1. Create a `.env` file in the `mcp-server` directory: ```bash cp mcp-server/.env.example mcp-server/.env ``` 2. Add your Home Assistant API token to the `.env` file: ```bash HOME_ASSISTANT_API_TOKEN=<your-home-assistant-api-token> ``` 3. Build the MCP server: ```bash bun i bun run build ``` 4. Add the MCP server to your LLM app config (e.g., Cursor): ```json { "name": "home-assistant", "command": "node /Users/andrelandgraf/workspaces/mcps/mcp-server/dist/index.js" } ``` That's it! Your LLM app can now control Home Assistant lights through the MCP server. ``` -------------------------------------------------------------------------------- /func-calling/src/data-manager/config.ts: -------------------------------------------------------------------------------- ```typescript export type AreaConfig = { areaId: string; }; export const dashboardConfigs: AreaConfig[] = [ { areaId: "living_room", }, { areaId: "kitchen", }, { areaId: "bedroom", }, { areaId: "office", }, ]; ``` -------------------------------------------------------------------------------- /mcp-server/src/data-manager/config.ts: -------------------------------------------------------------------------------- ```typescript export type AreaConfig = { areaId: string; }; export const dashboardConfigs: AreaConfig[] = [ { areaId: "living_room", }, { areaId: "kitchen", }, { areaId: "bedroom", }, { areaId: "office", }, ]; ``` -------------------------------------------------------------------------------- /func-calling/package.json: -------------------------------------------------------------------------------- ```json { "name": "func-calling", "module": "index.ts", "type": "module", "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5.0.0" }, "dependencies": { "commander": "^13.1.0", "inquirer": "^12.4.2", "openai": "^4.86.1", "tiny-invariant": "^1.3.3" } } ``` -------------------------------------------------------------------------------- /mcp-server/package.json: -------------------------------------------------------------------------------- ```json { "name": "mcp-server", "module": "index.ts", "type": "module", "scripts": { "build": "bun build --target node --outfile dist/index.js --env inline src/index.ts" }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5.0.0" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", "tiny-invariant": "^1.3.3", "ws": "^8.18.1", "zod": "^3.24.2" } } ``` -------------------------------------------------------------------------------- /func-calling/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { // Enable latest features "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false } } ``` -------------------------------------------------------------------------------- /mcp-server/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { // Enable latest features "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false } } ``` -------------------------------------------------------------------------------- /func-calling/src/data-manager/data.ts: -------------------------------------------------------------------------------- ```typescript export type Area = { id: string; name: string; floorId: string | null; lights: Light[]; }; export type Light = { areaId: string; areaName: string; deviceId: string | null; deviceName: string; entityId: string; state: "on" | "off" | "unavailable"; brightnessPercentage: number | null; rgbColor: [number, number, number] | null; }; export const EntityTypes = { light: "light", } as const; export type HomeAssistantData = { areas: Area[]; }; export function getLightState(state?: string): Light["state"] { return state === "on" ? "on" : state === "off" ? "off" : "unavailable"; } export function getBrightnessPercentage(brightness: unknown): number | null { const maxBrightness = 255; let brightnessValue: number | null = null; if (typeof brightness === "string") { brightnessValue = Number.parseInt(brightness, 10); if (Number.isNaN(brightnessValue)) { return null; } } else if (typeof brightness === "number") { brightnessValue = brightness; } else { return null; } return Math.round((brightnessValue / maxBrightness) * 100); } export function getBrightnessValue( brightnessPercentage: number | null, ): number { if (brightnessPercentage === null) { return 0; } const maxBrightness = 255; return Math.round((brightnessPercentage / 100) * maxBrightness); } export function getRBGColor( rgbColor: unknown, ): [number, number, number] | null { if (!rgbColor || !Array.isArray(rgbColor)) return null; return rgbColor as [number, number, number]; } ``` -------------------------------------------------------------------------------- /mcp-server/src/data-manager/data.ts: -------------------------------------------------------------------------------- ```typescript export type Area = { id: string; name: string; floorId: string | null; lights: Light[]; }; export type Light = { areaId: string; areaName: string; deviceId: string | null; deviceName: string; entityId: string; state: "on" | "off" | "unavailable"; brightnessPercentage: number | null; rgbColor: [number, number, number] | null; }; export const EntityTypes = { light: "light", } as const; export type HomeAssistantData = { areas: Area[]; }; export function getLightState(state?: string): Light["state"] { return state === "on" ? "on" : state === "off" ? "off" : "unavailable"; } export function getBrightnessPercentage(brightness: unknown): number | null { const maxBrightness = 255; let brightnessValue: number | null = null; if (typeof brightness === "string") { brightnessValue = Number.parseInt(brightness, 10); if (Number.isNaN(brightnessValue)) { return null; } } else if (typeof brightness === "number") { brightnessValue = brightness; } else { return null; } return Math.round((brightnessValue / maxBrightness) * 100); } export function getBrightnessValue( brightnessPercentage: number | null, ): number { if (brightnessPercentage === null) { return 0; } const maxBrightness = 255; return Math.round((brightnessPercentage / 100) * maxBrightness); } export function getRBGColor( rgbColor: unknown, ): [number, number, number] | null { if (!rgbColor || !Array.isArray(rgbColor)) return null; return rgbColor as [number, number, number]; } ``` -------------------------------------------------------------------------------- /mcp-server/src/index.ts: -------------------------------------------------------------------------------- ```typescript import { HomeAssistantWebSocketClient } from "./hass-ws-client/client"; import { DataManager } from "./data-manager/data-manager"; import { dashboardConfigs } from "./data-manager/config"; import invariant from "tiny-invariant"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; // Validate environment variables invariant(process.env.HOME_ASSISTANT_HOST, "HOME_ASSISTANT_HOST must be set"); invariant(process.env.HOME_ASSISTANT_TOKEN, "HOME_ASSISTANT_TOKEN must be set"); invariant( process.env.HOME_ASSISTANT_SECURE, "HOME_ASSISTANT_SECURE must be set", ); // Initialize Home Assistant client const hassClient = new HomeAssistantWebSocketClient( process.env.HOME_ASSISTANT_HOST, process.env.HOME_ASSISTANT_TOKEN, { isSecure: process.env.HOME_ASSISTANT_SECURE === "true", shouldLog: false, }, ); const dataManager = new DataManager(hassClient); dataManager.start(); await new Promise((resolve) => setTimeout(resolve, 2000)); async function controlLight(params: { areaId: string; state: "on" | "off"; }) { if (params.state === "on") { await dataManager.turnOnAllLights(params.areaId); } else { await dataManager.turnOffAllLights(params.areaId); } } // Define the light control schema const lightControlSchema = { areaId: z .string() .describe( "The area ID of the light in Home Assistant (e.g., office, kitchen)", ), state: z.enum(["on", "off"]).describe("Whether to turn the light on or off"), } as const; // Create server instance const server = new McpServer({ name: "home-assistant", version: "1.0.0", }); // Register the light control function server.tool( "control_light", "Control a light in Home Assistant (turn on/off)", lightControlSchema, async (params) => { await controlLight(params); return { content: [ { type: "text", text: "Light control command executed successfully", }, ], }; }, ); // Create transport and start server const transport = new StdioServerTransport(); await server.connect(transport); console.log("🏠 Home Assistant MCP Server Started!"); console.log( "Available areas:", dashboardConfigs.map((config) => config.areaId), ); ``` -------------------------------------------------------------------------------- /func-calling/src/index.ts: -------------------------------------------------------------------------------- ```typescript import OpenAI from "openai"; import { HomeAssistantWebSocketClient } from "./hass-ws-client/client"; import invariant from "tiny-invariant"; import inquirer from "inquirer"; import { DataManager } from "./data-manager/data-manager"; import { dashboardConfigs } from "./data-manager/config"; // Validate environment variables invariant(process.env.OPEN_AI_API_KEY, "OPEN_AI_API_KEY must be set"); invariant(process.env.HOME_ASSISTANT_HOST, "HOME_ASSISTANT_HOST must be set"); invariant(process.env.HOME_ASSISTANT_TOKEN, "HOME_ASSISTANT_TOKEN must be set"); invariant( process.env.HOME_ASSISTANT_SECURE, "HOME_ASSISTANT_SECURE must be set", ); const openAiClient = new OpenAI({ apiKey: process.env.OPEN_AI_API_KEY, }); // Initialize Home Assistant client const hassClient = new HomeAssistantWebSocketClient( process.env.HOME_ASSISTANT_HOST, process.env.HOME_ASSISTANT_TOKEN, { isSecure: process.env.HOME_ASSISTANT_SECURE === "true", shouldLog: false, }, ); const dataManager = new DataManager(hassClient); dataManager.start(); await new Promise((resolve) => setTimeout(resolve, 2000)); const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ { type: "function", function: { name: "control_light", description: "Control a light in Home Assistant (turn on/off)", parameters: { type: "object", properties: { areaId: { type: "string", description: "The area ID of the light in Home Assistant (e.g., office, kitchen)", }, state: { type: "string", enum: ["on", "off"], description: "Whether to turn the light on or off", }, }, required: ["areaId", "state"], additionalProperties: false, }, strict: true, }, }, ]; // Get list of available area IDs const availableAreaIds = dashboardConfigs .map((config) => config.areaId) .join(", "); // Initialize chat history for OpenAI const chatHistory: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", 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.`, }, ]; async function controlLight(params: { areaId: string; state: "on" | "off"; }) { if (params.state === "on") { await dataManager.turnOnAllLights(params.areaId); } else { await dataManager.turnOffAllLights(params.areaId); } } async function processCommand(command: string) { try { // Add user's command to history chatHistory.push({ role: "user", content: command, }); const completion = await openAiClient.chat.completions.create({ model: "gpt-4", messages: chatHistory, tools: tools, }); const replyText = completion.choices[0].message.content; if (replyText) { console.log("\n🤖 Assistant:", replyText); } const toolCalls = completion.choices[0].message.tool_calls; if (toolCalls) { console.log("toolCalls", toolCalls); } if (toolCalls && toolCalls.length > 0) { const call = toolCalls[0]; if (call.function.name === "control_light") { const params = JSON.parse(call.function.arguments); await controlLight(params); // Add the assistant's message with tool calls to chat history chatHistory.push({ role: "assistant", content: replyText, tool_calls: toolCalls, }); // Add the tool response to chat history const toolResponse: OpenAI.Chat.ChatCompletionMessageParam = { role: "tool", content: "Command executed successfully", tool_call_id: call.id, }; chatHistory.push(toolResponse); } } } catch (error) { const errorMessage = `Error: ${error instanceof Error ? error.message : "Unknown error occurred"}`; console.error("\nError processing command:", error); // Add the error message to chat history chatHistory.push({ role: "developer", content: errorMessage, }); } } async function main() { console.log("🏠 Welcome to Home Assistant Light Control!"); console.log("Available areas:", availableAreaIds, "\n"); while (true) { const { command } = await inquirer.prompt([ { type: "input", name: "command", message: "Enter your command:", }, ]); await processCommand(command); console.log(); // Empty line for better readability } } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /func-calling/src/data-manager/data-manager.ts: -------------------------------------------------------------------------------- ```typescript import { EntityTypes, getBrightnessPercentage, getBrightnessValue, getLightState, getRBGColor, type Light, type HomeAssistantData, } from "./data"; import { type HassArea, type HassDevice, type HassEntity, type HassEntityState, HomeAssistantWebSocketClient, } from "../hass-ws-client/client"; function getStateEntityDeviceForEntityId( entityId: string, devices: HassDevice[], entities: HassEntity[], entityStates: Record<string, HassEntityState>, ) { const state = entityStates[entityId]; const entity = entities.find((e) => e.entity_id === entityId); if (!entity) { throw Error(`Entity not found: ${entityId}`); } const device = devices.find((d) => d.id === entity.device_id); if (!device) { throw Error(`Device not found: ${entity.device_id} for entity ${entityId}`); } return { state, entity, device }; } export class DataManager { private wsClient: HomeAssistantWebSocketClient; data: HomeAssistantData; incomingData: { areas: HassArea[] | null; devices: HassDevice[] | null; entities: HassEntity[] | null; entityStates: Record<string, HassEntityState> | null; } = { areas: null, devices: null, entities: null, entityStates: null, }; constructor(wsClient: HomeAssistantWebSocketClient) { this.wsClient = wsClient; this.data = { areas: [], }; } start() { this.wsClient.connect(); this.wsClient.eventEmitter.on("areas", (areas) => { this.incomingData.areas = areas; this.syncData(); }); this.wsClient.eventEmitter.on("devices", (devices) => { this.incomingData.devices = devices; this.syncData(); }); this.wsClient.eventEmitter.on("entities", (entities) => { this.incomingData.entities = entities; this.syncData(); }); this.wsClient.eventEmitter.on("entity_states", (entitiesMap) => { this.incomingData.entityStates = entitiesMap; this.syncData(); }); this.wsClient.eventEmitter.on("entity_state_change", (changes) => { if (this.incomingData.entityStates) { for (const entityId of Object.keys(changes)) { const change = changes[entityId]; const currentState = this.incomingData.entityStates[entityId]; this.incomingData.entityStates[entityId] = { ...currentState, ...change["+"], }; } } else { for (const entityId of Object.keys(changes)) { if (entityId.startsWith(`${EntityTypes.light}.`)) { const change = changes[entityId]; this.updateLightState(entityId, change["+"]); } } } }); } async cleanup() { this.wsClient.close(); } private syncData() { if ( this.incomingData.areas && this.incomingData.devices && this.incomingData.entities && this.incomingData.entityStates ) { this.updateAreas(this.incomingData.areas); this.updateLights( this.incomingData.devices, this.incomingData.entities, this.incomingData.entityStates, ); this.incomingData.areas = null; this.incomingData.devices = null; this.incomingData.entities = null; this.incomingData.entityStates = null; } } private updateAreas(areas: HassArea[]) { const staleAreas = this.data.areas; this.data.areas = areas.map((area) => { const staleArea = staleAreas.find((a) => a.id === area.area_id); return { lights: [], ...staleArea, id: area.area_id, name: area.name, floorId: area.floor_id, }; }); } private updateLights( devices: HassDevice[], entities: HassEntity[], entityStates: Record<string, HassEntityState>, ) { for (const entityId of Object.keys(entityStates)) { if (!entityId.startsWith(`${EntityTypes.light}.`)) { continue; } const { state, device } = getStateEntityDeviceForEntityId( entityId, devices, entities, entityStates, ); const area = this.data.areas.find((a) => a.id === device.area_id); if (!area) { throw Error(`Area not found: ${device.area_id} for light ${entityId}`); } const light: Light = { areaId: area.id, areaName: area.name, deviceId: device.id, deviceName: device.name, entityId: entityId, state: getLightState(state.s), brightnessPercentage: getBrightnessPercentage(state.a.brightness), rgbColor: getRBGColor(state.a.rgb_color), }; const existingLightIndex = area.lights.findIndex( (l) => l.entityId === entityId, ); if (existingLightIndex !== -1) { area.lights[existingLightIndex] = light; } else { area.lights.push(light); } } } /** * @returns {string | null} areaId of the area where the light is located or null if not found */ private updateLightState( entityId: string, entityState: HassEntityState, ): string | null { for (const area of this.data.areas) { const light = area.lights.find((l) => l.entityId === entityId); if (light) { light.state = getLightState(entityState.s); if (entityState.a) { light.brightnessPercentage = getBrightnessPercentage( entityState.a.brightness, ); light.rgbColor = getRBGColor(entityState.a.rgb_color); } return area.id; } } return null; } getLights(areaId: string) { const area = this.data.areas.find((area) => area.id === areaId); if (!area) { throw new Error(`Area not found: ${areaId}`); } return area.lights; } getAverageBrightness(areaId: string) { const lights = this.getLights(areaId); if (lights.length === 0) { return 0; } const totalBrightness = lights.reduce( (acc, light) => acc + (light.brightnessPercentage || 0), 0, ); return totalBrightness / lights.length; } turnOffLight(entityId: string) { this.wsClient.sendTurnOffLight(entityId); } turnOnLight(entityId: string) { this.wsClient.sendTurnOnLight(entityId); } dimLight(entityId: string, brightnessPercentage: number) { const brightness = getBrightnessValue(brightnessPercentage); if (brightness === null || brightness === 0) { this.turnOffLight(entityId); } else { this.wsClient.sendTurnOnLight(entityId, { brightness }); } } async turnOffAllLights(areaId: string) { // console.log("Turning off all lights in area", areaId); const lights = this.getLights(areaId); for (const light of lights) { if (light.state === "on") { this.turnOffLight(light.entityId); } } } async turnOnAllLights(areaId: string) { const lights = this.getLights(areaId); for (const light of lights) { if (light.state === "off") { this.turnOnLight(light.entityId); } } } async dimAllLights(areaId: string, brightnessPercentage: number) { const lights = this.getLights(areaId); for (const light of lights) { this.dimLight(light.entityId, brightnessPercentage); } } } ``` -------------------------------------------------------------------------------- /mcp-server/src/data-manager/data-manager.ts: -------------------------------------------------------------------------------- ```typescript import { EntityTypes, getBrightnessPercentage, getBrightnessValue, getLightState, getRBGColor, type Light, type HomeAssistantData, } from "./data"; import { type HassArea, type HassDevice, type HassEntity, type HassEntityState, HomeAssistantWebSocketClient, } from "../hass-ws-client/client"; function getStateEntityDeviceForEntityId( entityId: string, devices: HassDevice[], entities: HassEntity[], entityStates: Record<string, HassEntityState>, ) { const state = entityStates[entityId]; const entity = entities.find((e) => e.entity_id === entityId); if (!entity) { throw Error(`Entity not found: ${entityId}`); } const device = devices.find((d) => d.id === entity.device_id); if (!device) { throw Error(`Device not found: ${entity.device_id} for entity ${entityId}`); } return { state, entity, device }; } export class DataManager { private wsClient: HomeAssistantWebSocketClient; data: HomeAssistantData; incomingData: { areas: HassArea[] | null; devices: HassDevice[] | null; entities: HassEntity[] | null; entityStates: Record<string, HassEntityState> | null; } = { areas: null, devices: null, entities: null, entityStates: null, }; constructor(wsClient: HomeAssistantWebSocketClient) { this.wsClient = wsClient; this.data = { areas: [], }; } start() { this.wsClient.connect(); this.wsClient.eventEmitter.on("areas", (areas) => { this.incomingData.areas = areas; this.syncData(); }); this.wsClient.eventEmitter.on("devices", (devices) => { this.incomingData.devices = devices; this.syncData(); }); this.wsClient.eventEmitter.on("entities", (entities) => { this.incomingData.entities = entities; this.syncData(); }); this.wsClient.eventEmitter.on("entity_states", (entitiesMap) => { this.incomingData.entityStates = entitiesMap; this.syncData(); }); this.wsClient.eventEmitter.on("entity_state_change", (changes) => { if (this.incomingData.entityStates) { for (const entityId of Object.keys(changes)) { const change = changes[entityId]; const currentState = this.incomingData.entityStates[entityId]; this.incomingData.entityStates[entityId] = { ...currentState, ...change["+"], }; } } else { for (const entityId of Object.keys(changes)) { if (entityId.startsWith(`${EntityTypes.light}.`)) { const change = changes[entityId]; this.updateLightState(entityId, change["+"]); } } } }); } async cleanup() { this.wsClient.close(); } private syncData() { if ( this.incomingData.areas && this.incomingData.devices && this.incomingData.entities && this.incomingData.entityStates ) { this.updateAreas(this.incomingData.areas); this.updateLights( this.incomingData.devices, this.incomingData.entities, this.incomingData.entityStates, ); this.incomingData.areas = null; this.incomingData.devices = null; this.incomingData.entities = null; this.incomingData.entityStates = null; } } private updateAreas(areas: HassArea[]) { const staleAreas = this.data.areas; this.data.areas = areas.map((area) => { const staleArea = staleAreas.find((a) => a.id === area.area_id); return { lights: [], ...staleArea, id: area.area_id, name: area.name, floorId: area.floor_id, }; }); } private updateLights( devices: HassDevice[], entities: HassEntity[], entityStates: Record<string, HassEntityState>, ) { for (const entityId of Object.keys(entityStates)) { if (!entityId.startsWith(`${EntityTypes.light}.`)) { continue; } const { state, device } = getStateEntityDeviceForEntityId( entityId, devices, entities, entityStates, ); const area = this.data.areas.find((a) => a.id === device.area_id); if (!area) { throw Error(`Area not found: ${device.area_id} for light ${entityId}`); } const light: Light = { areaId: area.id, areaName: area.name, deviceId: device.id, deviceName: device.name, entityId: entityId, state: getLightState(state.s), brightnessPercentage: getBrightnessPercentage(state.a.brightness), rgbColor: getRBGColor(state.a.rgb_color), }; const existingLightIndex = area.lights.findIndex( (l) => l.entityId === entityId, ); if (existingLightIndex !== -1) { area.lights[existingLightIndex] = light; } else { area.lights.push(light); } } } /** * @returns {string | null} areaId of the area where the light is located or null if not found */ private updateLightState( entityId: string, entityState: HassEntityState, ): string | null { for (const area of this.data.areas) { const light = area.lights.find((l) => l.entityId === entityId); if (light) { light.state = getLightState(entityState.s); if (entityState.a) { light.brightnessPercentage = getBrightnessPercentage( entityState.a.brightness, ); light.rgbColor = getRBGColor(entityState.a.rgb_color); } return area.id; } } return null; } getLights(areaId: string) { const area = this.data.areas.find((area) => area.id === areaId); if (!area) { throw new Error(`Area not found: ${areaId}`); } return area.lights; } getAverageBrightness(areaId: string) { const lights = this.getLights(areaId); if (lights.length === 0) { return 0; } const totalBrightness = lights.reduce( (acc, light) => acc + (light.brightnessPercentage || 0), 0, ); return totalBrightness / lights.length; } turnOffLight(entityId: string) { this.wsClient.sendTurnOffLight(entityId); } turnOnLight(entityId: string) { this.wsClient.sendTurnOnLight(entityId); } dimLight(entityId: string, brightnessPercentage: number) { const brightness = getBrightnessValue(brightnessPercentage); if (brightness === null || brightness === 0) { this.turnOffLight(entityId); } else { this.wsClient.sendTurnOnLight(entityId, { brightness }); } } async turnOffAllLights(areaId: string) { // console.log("Turning off all lights in area", areaId); const lights = this.getLights(areaId); for (const light of lights) { if (light.state === "on") { this.turnOffLight(light.entityId); } } } async turnOnAllLights(areaId: string) { const lights = this.getLights(areaId); for (const light of lights) { if (light.state === "off") { this.turnOnLight(light.entityId); } } } async dimAllLights(areaId: string, brightnessPercentage: number) { const lights = this.getLights(areaId); for (const light of lights) { this.dimLight(light.entityId, brightnessPercentage); } } } ``` -------------------------------------------------------------------------------- /func-calling/src/hass-ws-client/client.ts: -------------------------------------------------------------------------------- ```typescript /* WebSocket client for the Home Assistant server */ import { WebSocket } from "ws"; import EventEmitter from "node:events"; import { clearInterval, setInterval } from "timers"; export type HassArea = { area_id: string; // unique name floor_id: string | null; name: string; }; export type HassDevice = { area_id: string | null; id: string; // uuid manufacturer: string | null; model: string | null; // string not always usable name: string; name_by_user: string | null; }; export type HassEntity = { device_id: string | null; entity_id: string; // unique name }; export type HassEntityState = { s: string | "on" | "off" | "unavailable" | "not_home" | "home" | "unknown"; // state, number is string a: { [key: string]: unknown; // attributes }; }; export type HassHueLightEntityState = HassEntityState & { s: "on" | "off" | "unavailable"; a: { color_mode: "color_temp" | string | null; brightness: number | null; color_temp_kelvin: number | null; color_temp: number | null; hs_color: number[] | null; rgb_color: number[] | null; xy_color: number[] | null; }; }; /** * Message types that Home Assistant server sends to the client */ const SERVER_MESSAGE_TYPES = { AUTH_REQUIRED: "auth_required", AUTH_OK: "auth_ok", AUTH_INVALID: "auth_invalid", RESULT: "result", EVENT: "event", } as const; /** * Message types that the client can send to the Home Assistant server */ const CLIENT_MESSAGE_TYPES = { AUTH: "auth", SUBSCRIBE_ENTITIES: "subscribe_entities", CALL_SERVICE: "call_service", GET_AREA_REGISTRY: "config/area_registry/list", GET_DEVICE_REGISTRY: "config/device_registry/list", GET_ENTITY_REGISTRY: "config/entity_registry/list", } as const; export type ClientMessageType = (typeof CLIENT_MESSAGE_TYPES)[keyof typeof CLIENT_MESSAGE_TYPES]; export type ServerMessageType = (typeof SERVER_MESSAGE_TYPES)[keyof typeof SERVER_MESSAGE_TYPES]; export class HomeAssistantWebSocketClient { private connectionUrl: string; private token: string; private socket: WebSocket | null = null; private shouldLog: boolean; private runningId = 1; private refetchInterval: ReturnType<typeof setInterval> | null = null; private ids = { areas: 0, devices: 0, entities: 0, entityStates: 0, }; eventEmitter = new EventEmitter<{ areas: [HassArea[]]; devices: [HassDevice[]]; entities: [HassEntity[]]; entity_states: [Record<string, HassEntityState>]; entity_state_change: [Record<string, { "+": HassEntityState }>]; }>(); constructor( host: string, token: string, { isSecure = false, shouldLog = false } = {}, ) { const protocol = isSecure ? "wss" : "ws"; this.connectionUrl = `${protocol}://${host}/api/websocket`; this.token = token; this.shouldLog = shouldLog; } private log(message: string, ...args: unknown[]) { if (this.shouldLog) { console.log(`HomeAssistantWebSocketClient: ${message}`, ...args); } } connect() { this.log("Connecting to Home Assistant WS server..."); if (this.socket) { throw new Error("Socket unexpectedly already connected"); } this.runningId = 1; const socket = new WebSocket(this.connectionUrl); socket.onopen = () => { this.log("Connected to server"); }; socket.onmessage = (event) => { const data = JSON.parse(event.data.toString()); const serverMessageType = data.type; this.log("Received message from Home Assistant: ", serverMessageType); if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_REQUIRED) { socket.send( JSON.stringify({ type: CLIENT_MESSAGE_TYPES.AUTH, access_token: this.token, }), ); return; } if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_INVALID) { console.error("Authentication failed. Closing connection."); socket.close(); return; } if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_OK) { this.log("Authentication successful."); this.sendDataRequests(); return; } if (serverMessageType === SERVER_MESSAGE_TYPES.RESULT) { if (data.error) { console.error("Error result: ", data.error); return; } if (data.id === this.ids.areas) { this.log("Received areas result", data.result.length); this.eventEmitter.emit("areas", data.result); return; } if (data.id === this.ids.devices) { this.log("Received devices result", data.result.length); this.eventEmitter.emit("devices", data.result); return; } if (data.id === this.ids.entities) { this.log("Received entities result", data.result.length); this.eventEmitter.emit("entities", data.result); return; } if (data.id === this.ids.entityStates) { this.log("Successfully subscribed to entities"); return; } return; } if (serverMessageType === SERVER_MESSAGE_TYPES.EVENT) { if ("a" in data.event) { this.log("Received entities event", Object.keys(data.event.a)); this.eventEmitter.emit("entity_states", data.event.a); return; } if ("c" in data.event) { this.log("Received entities change event", Object.keys(data.event.c)); this.eventEmitter.emit("entity_state_change", data.event.c); return; } return; } }; socket.onclose = () => { this.log("Disconnected from server"); }; this.socket = socket; const threeMinutesInMs = 3 * 60 * 1000; this.refetchInterval = setInterval(() => { if (this.socket) { this.sendDataRequests(); } else if (this.refetchInterval) { clearInterval(this.refetchInterval); } }, threeMinutesInMs); } close() { if (this.socket) { if (this.refetchInterval) { clearInterval(this.refetchInterval); } this.socket.close(); } } private send(type: ClientMessageType, payload?: object) { if (!this.socket) { throw new Error("Socket is not connected"); } const id = this.runningId; const message = { ...payload, type, id }; this.log("Sending ws message to Home Assistant: ", message); this.socket.send(JSON.stringify(message)); this.runningId = this.runningId + 1 >= Number.MAX_SAFE_INTEGER ? 1 : this.runningId + 1; return id; } private sendDataRequests() { if (!this.socket) { throw new Error( "Attempting to sendDataRequests but socket is not connected", ); } this.ids.areas = this.send(CLIENT_MESSAGE_TYPES.GET_AREA_REGISTRY); this.ids.devices = this.send(CLIENT_MESSAGE_TYPES.GET_DEVICE_REGISTRY); this.ids.entities = this.send(CLIENT_MESSAGE_TYPES.GET_ENTITY_REGISTRY); this.ids.entityStates = this.send(CLIENT_MESSAGE_TYPES.SUBSCRIBE_ENTITIES); } sendToggleLight(entityId: string) { this.log("Sending toggle light"); this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { domain: "light", service: "toggle", service_data: { entity_id: entityId }, }); } sendTurnOnLight(entityId: string, data?: { brightness?: number }) { this.log("Sending turn on light"); this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { domain: "light", service: "turn_on", service_data: { entity_id: entityId, ...data }, }); } sendTurnOffLight(entityId: string) { this.log("Sending turn off light"); this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { domain: "light", service: "turn_off", service_data: { entity_id: entityId }, }); } } ``` -------------------------------------------------------------------------------- /mcp-server/src/hass-ws-client/client.ts: -------------------------------------------------------------------------------- ```typescript /* WebSocket client for the Home Assistant server */ import { WebSocket } from "ws"; import EventEmitter from "node:events"; import { clearInterval, setInterval } from "timers"; export type HassArea = { area_id: string; // unique name floor_id: string | null; name: string; }; export type HassDevice = { area_id: string | null; id: string; // uuid manufacturer: string | null; model: string | null; // string not always usable name: string; name_by_user: string | null; }; export type HassEntity = { device_id: string | null; entity_id: string; // unique name }; export type HassEntityState = { s: string | "on" | "off" | "unavailable" | "not_home" | "home" | "unknown"; // state, number is string a: { [key: string]: unknown; // attributes }; }; export type HassHueLightEntityState = HassEntityState & { s: "on" | "off" | "unavailable"; a: { color_mode: "color_temp" | string | null; brightness: number | null; color_temp_kelvin: number | null; color_temp: number | null; hs_color: number[] | null; rgb_color: number[] | null; xy_color: number[] | null; }; }; /** * Message types that Home Assistant server sends to the client */ const SERVER_MESSAGE_TYPES = { AUTH_REQUIRED: "auth_required", AUTH_OK: "auth_ok", AUTH_INVALID: "auth_invalid", RESULT: "result", EVENT: "event", } as const; /** * Message types that the client can send to the Home Assistant server */ const CLIENT_MESSAGE_TYPES = { AUTH: "auth", SUBSCRIBE_ENTITIES: "subscribe_entities", CALL_SERVICE: "call_service", GET_AREA_REGISTRY: "config/area_registry/list", GET_DEVICE_REGISTRY: "config/device_registry/list", GET_ENTITY_REGISTRY: "config/entity_registry/list", } as const; export type ClientMessageType = (typeof CLIENT_MESSAGE_TYPES)[keyof typeof CLIENT_MESSAGE_TYPES]; export type ServerMessageType = (typeof SERVER_MESSAGE_TYPES)[keyof typeof SERVER_MESSAGE_TYPES]; export class HomeAssistantWebSocketClient { private connectionUrl: string; private token: string; private socket: WebSocket | null = null; private shouldLog: boolean; private runningId = 1; private refetchInterval: ReturnType<typeof setInterval> | null = null; private ids = { areas: 0, devices: 0, entities: 0, entityStates: 0, }; eventEmitter = new EventEmitter<{ areas: [HassArea[]]; devices: [HassDevice[]]; entities: [HassEntity[]]; entity_states: [Record<string, HassEntityState>]; entity_state_change: [Record<string, { "+": HassEntityState }>]; }>(); constructor( host: string, token: string, { isSecure = false, shouldLog = false } = {}, ) { const protocol = isSecure ? "wss" : "ws"; this.connectionUrl = `${protocol}://${host}/api/websocket`; this.token = token; this.shouldLog = shouldLog; } private log(message: string, ...args: unknown[]) { if (this.shouldLog) { console.log(`HomeAssistantWebSocketClient: ${message}`, ...args); } } connect() { this.log("Connecting to Home Assistant WS server..."); if (this.socket) { throw new Error("Socket unexpectedly already connected"); } this.runningId = 1; const socket = new WebSocket(this.connectionUrl); socket.onopen = () => { this.log("Connected to server"); }; socket.onmessage = (event) => { const data = JSON.parse(event.data.toString()); const serverMessageType = data.type; this.log("Received message from Home Assistant: ", serverMessageType); if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_REQUIRED) { socket.send( JSON.stringify({ type: CLIENT_MESSAGE_TYPES.AUTH, access_token: this.token, }), ); return; } if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_INVALID) { console.error("Authentication failed. Closing connection."); socket.close(); return; } if (serverMessageType === SERVER_MESSAGE_TYPES.AUTH_OK) { this.log("Authentication successful."); this.sendDataRequests(); return; } if (serverMessageType === SERVER_MESSAGE_TYPES.RESULT) { if (data.error) { console.error("Error result: ", data.error); return; } if (data.id === this.ids.areas) { this.log("Received areas result", data.result.length); this.eventEmitter.emit("areas", data.result); return; } if (data.id === this.ids.devices) { this.log("Received devices result", data.result.length); this.eventEmitter.emit("devices", data.result); return; } if (data.id === this.ids.entities) { this.log("Received entities result", data.result.length); this.eventEmitter.emit("entities", data.result); return; } if (data.id === this.ids.entityStates) { this.log("Successfully subscribed to entities"); return; } return; } if (serverMessageType === SERVER_MESSAGE_TYPES.EVENT) { if ("a" in data.event) { this.log("Received entities event", Object.keys(data.event.a)); this.eventEmitter.emit("entity_states", data.event.a); return; } if ("c" in data.event) { this.log("Received entities change event", Object.keys(data.event.c)); this.eventEmitter.emit("entity_state_change", data.event.c); return; } return; } }; socket.onclose = () => { this.log("Disconnected from server"); }; this.socket = socket; const threeMinutesInMs = 3 * 60 * 1000; this.refetchInterval = setInterval(() => { if (this.socket) { this.sendDataRequests(); } else if (this.refetchInterval) { clearInterval(this.refetchInterval); } }, threeMinutesInMs); } close() { if (this.socket) { if (this.refetchInterval) { clearInterval(this.refetchInterval); } this.socket.close(); } } private send(type: ClientMessageType, payload?: object) { if (!this.socket) { throw new Error("Socket is not connected"); } const id = this.runningId; const message = { ...payload, type, id }; this.log("Sending ws message to Home Assistant: ", message); this.socket.send(JSON.stringify(message)); this.runningId = this.runningId + 1 >= Number.MAX_SAFE_INTEGER ? 1 : this.runningId + 1; return id; } private sendDataRequests() { if (!this.socket) { throw new Error( "Attempting to sendDataRequests but socket is not connected", ); } this.ids.areas = this.send(CLIENT_MESSAGE_TYPES.GET_AREA_REGISTRY); this.ids.devices = this.send(CLIENT_MESSAGE_TYPES.GET_DEVICE_REGISTRY); this.ids.entities = this.send(CLIENT_MESSAGE_TYPES.GET_ENTITY_REGISTRY); this.ids.entityStates = this.send(CLIENT_MESSAGE_TYPES.SUBSCRIBE_ENTITIES); } sendToggleLight(entityId: string) { this.log("Sending toggle light"); this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { domain: "light", service: "toggle", service_data: { entity_id: entityId }, }); } sendTurnOnLight(entityId: string, data?: { brightness?: number }) { this.log("Sending turn on light"); this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { domain: "light", service: "turn_on", service_data: { entity_id: entityId, ...data }, }); } sendTurnOffLight(entityId: string) { this.log("Sending turn off light"); this.send(CLIENT_MESSAGE_TYPES.CALL_SERVICE, { domain: "light", service: "turn_off", service_data: { entity_id: entityId }, }); } } ```