Compare commits
10 Commits
ae56716a86
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbf33d3c7 | ||
|
|
a3927ba66c | ||
|
|
2120685cdf | ||
|
|
e17787fa0f | ||
|
|
76bd1582e7 | ||
|
|
8a3025081a | ||
|
|
15f5918e20 | ||
|
|
09a500904c | ||
|
|
a323b1e9d0 | ||
|
|
7b742858db |
@@ -1,171 +1,173 @@
|
||||
"""MCP Bridge integration for Home Assistant.
|
||||
"""MCP Bridge integration for Home Assistant."""
|
||||
from __future__ import annotations
|
||||
|
||||
This integration provides services to expose filtered entities and scripts
|
||||
to MCP (Model Context Protocol) servers for AI agent control.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.const import CONF_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.components.script import async_get_script_fields
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "mcp_bridge"
|
||||
|
||||
# Label to mark scripts as MCP-accessible
|
||||
MCP_ACCESSIBLE_LABEL = "mcp_accessible"
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the MCP Bridge integration."""
|
||||
_LOGGER.info("Setting up MCP Bridge integration")
|
||||
|
||||
async def get_exposed_entities(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Get all entities exposed to voice assistants."""
|
||||
entity_reg = er.async_get(hass)
|
||||
area_reg = ar.async_get(hass)
|
||||
|
||||
exposed_entities = []
|
||||
entities: list[dict[str, Any]] = []
|
||||
|
||||
# Iterate through all entities
|
||||
for entity_id, state in hass.states.async_all():
|
||||
entity_entry = entity_reg.async_get(entity_id)
|
||||
for state in hass.states.async_all():
|
||||
entity_entry = entity_reg.async_get(state.entity_id)
|
||||
if not entity_entry:
|
||||
continue
|
||||
|
||||
if not entity_entry.options.get("conversation", {}).get(
|
||||
"should_expose", False
|
||||
):
|
||||
continue
|
||||
|
||||
# Check if entity is exposed to conversation (voice assistant)
|
||||
if entity_entry and entity_entry.options.get("conversation", {}).get("should_expose", False):
|
||||
# Get area name if available
|
||||
area_name = None
|
||||
if entity_entry.area_id:
|
||||
area_reg = hass.helpers.area_registry.async_get(hass)
|
||||
area = area_reg.async_get_area(entity_entry.area_id)
|
||||
if area:
|
||||
area_name = area.name
|
||||
|
||||
# Build entity data
|
||||
entity_data = {
|
||||
"entity_id": entity_id,
|
||||
entities.append(
|
||||
{
|
||||
"entity_id": state.entity_id,
|
||||
"state": state.state,
|
||||
"friendly_name": state.attributes.get("friendly_name", entity_id),
|
||||
"friendly_name": state.attributes.get(
|
||||
"friendly_name", state.entity_id
|
||||
),
|
||||
"area": area_name,
|
||||
"domain": entity_id.split(".")[0],
|
||||
"domain": state.entity_id.split(".", 1)[0],
|
||||
"device_class": state.attributes.get("device_class"),
|
||||
"supported_features": state.attributes.get("supported_features", 0),
|
||||
"supported_features": state.attributes.get(
|
||||
"supported_features", 0
|
||||
),
|
||||
"last_changed": state.last_changed.isoformat(),
|
||||
"last_updated": state.last_updated.isoformat(),
|
||||
"attributes": dict(state.attributes)
|
||||
"attributes": dict(state.attributes),
|
||||
}
|
||||
|
||||
exposed_entities.append(entity_data)
|
||||
|
||||
_LOGGER.debug(f"Found {len(exposed_entities)} exposed entities")
|
||||
)
|
||||
|
||||
return {
|
||||
"entities": exposed_entities,
|
||||
"count": len(exposed_entities)
|
||||
"entities": entities,
|
||||
"count": len(entities),
|
||||
}
|
||||
|
||||
async def get_exposed_scripts(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Get all scripts marked as MCP-accessible."""
|
||||
entity_reg = er.async_get(hass)
|
||||
scripts: list[dict[str, Any]] = []
|
||||
|
||||
exposed_scripts = []
|
||||
|
||||
# Get all script entities
|
||||
for entity_id in hass.states.async_entity_ids("script"):
|
||||
entity_entry = entity_reg.async_get(entity_id)
|
||||
|
||||
# Check if script has the mcp_accessible label
|
||||
if entity_entry and MCP_ACCESSIBLE_LABEL in entity_entry.labels:
|
||||
state = hass.states.get(entity_id)
|
||||
if not state:
|
||||
if not entity_entry:
|
||||
continue
|
||||
|
||||
# Get script attributes
|
||||
script_data = {
|
||||
"entity_id": entity_id,
|
||||
"friendly_name": state.attributes.get("friendly_name", entity_id),
|
||||
"description": state.attributes.get("description", ""),
|
||||
"fields": state.attributes.get("fields", {}),
|
||||
}
|
||||
if MCP_ACCESSIBLE_LABEL not in entity_entry.labels:
|
||||
continue
|
||||
|
||||
exposed_scripts.append(script_data)
|
||||
fields = await async_get_script_fields(hass, entity_id)
|
||||
|
||||
_LOGGER.debug(f"Found {len(exposed_scripts)} exposed scripts")
|
||||
|
||||
return {
|
||||
"scripts": exposed_scripts,
|
||||
"count": len(exposed_scripts)
|
||||
}
|
||||
|
||||
async def get_entity_metadata(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Get detailed metadata for a specific entity."""
|
||||
entity_id = call.data.get(CONF_ENTITY_ID)
|
||||
|
||||
if not entity_id:
|
||||
_LOGGER.error("entity_id not provided")
|
||||
return {"error": "entity_id required"}
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
entity_entry = entity_reg.async_get(entity_id)
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
scripts.append(
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"friendly_name": state.attributes.get(
|
||||
"friendly_name", entity_id
|
||||
)
|
||||
if state
|
||||
else entity_id,
|
||||
"description": state.attributes.get("description", "")
|
||||
if state
|
||||
else "",
|
||||
"fields": fields or {},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"scripts": scripts,
|
||||
"count": len(scripts),
|
||||
}
|
||||
|
||||
|
||||
async def get_entity_metadata(call: ServiceCall) -> dict[str, Any]:
|
||||
entity_id = call.data.get(CONF_ENTITY_ID)
|
||||
if not entity_id:
|
||||
return {"error": "entity_id is required"}
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
area_reg = ar.async_get(hass)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
if not state:
|
||||
_LOGGER.error(f"Entity {entity_id} not found")
|
||||
return {"error": f"Entity {entity_id} not found"}
|
||||
|
||||
# Get area name if available
|
||||
entity_entry = entity_reg.async_get(entity_id)
|
||||
|
||||
area_name = None
|
||||
if entity_entry and entity_entry.area_id:
|
||||
area_reg = hass.helpers.area_registry.async_get(hass)
|
||||
area = area_reg.async_get_area(entity_entry.area_id)
|
||||
if area:
|
||||
area_name = area.name
|
||||
|
||||
metadata = {
|
||||
data: dict[str, Any] = {
|
||||
"entity_id": entity_id,
|
||||
"state": state.state,
|
||||
"friendly_name": state.attributes.get("friendly_name", entity_id),
|
||||
"friendly_name": state.attributes.get(
|
||||
"friendly_name", entity_id
|
||||
),
|
||||
"area": area_name,
|
||||
"domain": entity_id.split(".")[0],
|
||||
"domain": entity_id.split(".", 1)[0],
|
||||
"device_class": state.attributes.get("device_class"),
|
||||
"supported_features": state.attributes.get("supported_features", 0),
|
||||
"supported_features": state.attributes.get(
|
||||
"supported_features", 0
|
||||
),
|
||||
"last_changed": state.last_changed.isoformat(),
|
||||
"last_updated": state.last_updated.isoformat(),
|
||||
"attributes": dict(state.attributes)
|
||||
"attributes": dict(state.attributes),
|
||||
}
|
||||
|
||||
if entity_entry:
|
||||
metadata["labels"] = list(entity_entry.labels)
|
||||
metadata["is_exposed_to_conversation"] = entity_entry.options.get(
|
||||
data["labels"] = list(entity_entry.labels)
|
||||
data["is_exposed_to_conversation"] = entity_entry.options.get(
|
||||
"conversation", {}
|
||||
).get("should_expose", False)
|
||||
|
||||
return metadata
|
||||
return data
|
||||
|
||||
# Register services
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_exposed_entities",
|
||||
get_exposed_entities,
|
||||
schema=None
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_exposed_scripts",
|
||||
get_exposed_scripts,
|
||||
schema=None
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_entity_metadata",
|
||||
get_entity_metadata,
|
||||
schema=None
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
_LOGGER.info("MCP Bridge services registered successfully")
|
||||
|
||||
return True
|
||||
130
diagnose_install.sh
Normal file
130
diagnose_install.sh
Normal file
@@ -0,0 +1,130 @@
|
||||
#!/bin/bash
|
||||
# MCP Bridge Installation Diagnostic Script
|
||||
# Run this manually in HA terminal to see what's happening
|
||||
|
||||
echo "=== MCP Bridge Installation Diagnostic ==="
|
||||
echo ""
|
||||
|
||||
# 1. Check network connectivity
|
||||
echo "1. Testing Gitea connectivity..."
|
||||
GITEA_URL="${GITEA_URL:-https://www.git.quarantinedstudio.com}"
|
||||
REPO="${REPO:-mvezina/homeassistant-mcp-bridge}"
|
||||
|
||||
echo " Attempting to reach: $GITEA_URL/$REPO"
|
||||
if curl -I "$GITEA_URL/$REPO" 2>&1 | head -n 1; then
|
||||
echo " ✓ Can reach Gitea server"
|
||||
else
|
||||
echo " ✗ Cannot reach Gitea server"
|
||||
echo " Please check:"
|
||||
echo " - Is Gitea running?"
|
||||
echo " - Is the URL correct?"
|
||||
echo " - Can HA reach your Gitea server?"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 2. Check if curl exists
|
||||
echo "2. Checking for curl..."
|
||||
if command -v curl &> /dev/null; then
|
||||
echo " ✓ curl is available: $(which curl)"
|
||||
curl --version | head -n 1
|
||||
else
|
||||
echo " ✗ curl is NOT available"
|
||||
echo " Checking for wget..."
|
||||
if command -v wget &> /dev/null; then
|
||||
echo " ✓ wget is available: $(which wget)"
|
||||
else
|
||||
echo " ✗ Neither curl nor wget available!"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 3. Check config directory
|
||||
echo "3. Checking config directory..."
|
||||
CONFIG_DIR="/config"
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
echo " ✓ /config exists"
|
||||
else
|
||||
echo " ✗ /config does not exist, trying alternate..."
|
||||
CONFIG_DIR="${HOME}/.homeassistant"
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
echo " ✓ Using $CONFIG_DIR"
|
||||
else
|
||||
echo " ✗ Cannot find config directory!"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 4. Check/create custom_components directory
|
||||
echo "4. Checking custom_components directory..."
|
||||
CUSTOM_DIR="$CONFIG_DIR/custom_components"
|
||||
if [ -d "$CUSTOM_DIR" ]; then
|
||||
echo " ✓ $CUSTOM_DIR exists"
|
||||
else
|
||||
echo " ! $CUSTOM_DIR does not exist, creating..."
|
||||
mkdir -p "$CUSTOM_DIR" && echo " ✓ Created successfully" || echo " ✗ Failed to create"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. Check write permissions
|
||||
echo "5. Checking write permissions..."
|
||||
TEST_FILE="$CUSTOM_DIR/test_write_$$"
|
||||
if touch "$TEST_FILE" 2>/dev/null; then
|
||||
echo " ✓ Can write to $CUSTOM_DIR"
|
||||
rm "$TEST_FILE"
|
||||
else
|
||||
echo " ✗ Cannot write to $CUSTOM_DIR"
|
||||
echo " Permission issue!"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 6. Try downloading a test file
|
||||
echo "6. Testing file download..."
|
||||
BRANCH="${BRANCH:-main}"
|
||||
TEST_URL="$GITEA_URL/$REPO/raw/branch/$BRANCH/custom_components/mcp_bridge/manifest.json"
|
||||
echo " URL: $TEST_URL"
|
||||
|
||||
TEMP_FILE="/tmp/mcp_test_$$"
|
||||
if curl -fsSL "$TEST_URL" -o "$TEMP_FILE" 2>/dev/null; then
|
||||
echo " ✓ Successfully downloaded test file"
|
||||
echo " File size: $(wc -c < "$TEMP_FILE") bytes"
|
||||
echo " First line: $(head -n 1 "$TEMP_FILE")"
|
||||
rm "$TEMP_FILE"
|
||||
else
|
||||
echo " ✗ Failed to download test file"
|
||||
echo " Possible issues:"
|
||||
echo " - Wrong repository name or path"
|
||||
echo " - Wrong branch name"
|
||||
echo " - File doesn't exist at that path"
|
||||
echo " - Network/firewall blocking"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7. Check existing installation
|
||||
echo "7. Checking for existing installation..."
|
||||
INSTALL_DIR="$CONFIG_DIR/custom_components/mcp_bridge"
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo " ✓ Installation directory exists: $INSTALL_DIR"
|
||||
echo " Files:"
|
||||
ls -lh "$INSTALL_DIR" 2>/dev/null || echo " (empty or cannot list)"
|
||||
|
||||
if [ -f "$INSTALL_DIR/manifest.json" ]; then
|
||||
VERSION=$(grep -oP '"version":\s*"\K[^"]+' "$INSTALL_DIR/manifest.json" 2>/dev/null || echo "unknown")
|
||||
echo " Current version: $VERSION"
|
||||
fi
|
||||
else
|
||||
echo " ! Installation directory does not exist yet"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 8. Provide installation command
|
||||
echo "8. Suggested installation command:"
|
||||
echo ""
|
||||
echo " export GITEA_URL=\"$GITEA_URL\""
|
||||
echo " export REPO=\"$REPO\""
|
||||
echo " export BRANCH=\"$BRANCH\""
|
||||
echo " curl -fsSL \"\$GITEA_URL/\$REPO/raw/branch/\$BRANCH/quick_install.sh\" | bash"
|
||||
echo ""
|
||||
echo "=== Diagnostic Complete ==="
|
||||
echo ""
|
||||
echo "If all checks passed, run the installation command above."
|
||||
echo "If checks failed, fix the issues indicated and try again."
|
||||
@@ -0,0 +1,36 @@
|
||||
# Add this to your configuration.yaml
|
||||
|
||||
shell_command:
|
||||
# Update MCP Bridge integration from Gitea
|
||||
update_mcp_bridge: >
|
||||
curl -fsSL https://www.git.quarantinedstudio.com/mvezina/homeassistant-mcp-bridge/raw/branch/main/quick_install.sh |
|
||||
GITEA_URL=https://www.git.quarantinedstudio.com
|
||||
REPO=mvezina/homeassistant-mcp-bridge
|
||||
bash
|
||||
|
||||
# Then create an automation or script to call it:
|
||||
|
||||
script:
|
||||
update_mcp_bridge:
|
||||
alias: "Update MCP Bridge Integration"
|
||||
sequence:
|
||||
- service: shell_command.update_mcp_bridge
|
||||
- delay:
|
||||
seconds: 5
|
||||
- service: system_log.write
|
||||
data:
|
||||
message: "MCP Bridge updated. Please restart Home Assistant."
|
||||
level: warning
|
||||
- service: persistent_notification.create
|
||||
data:
|
||||
title: "MCP Bridge Updated"
|
||||
message: "The MCP Bridge integration has been updated. Please restart Home Assistant for changes to take effect."
|
||||
|
||||
# Optional: Add a button to your dashboard
|
||||
button:
|
||||
- type: button
|
||||
name: Update MCP Bridge
|
||||
icon: mdi:download
|
||||
tap_action:
|
||||
action: call-service
|
||||
service: script.update_mcp_bridge
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# MCP Bridge Quick Installer with Logging
|
||||
# Logs to /config/mcp_bridge_install.log
|
||||
|
||||
# Configuration
|
||||
GITEA_URL="${GITEA_URL:-http://your-gitea.local:3000}"
|
||||
REPO="${REPO:-username/homeassistant-mcp-bridge}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
BASE_URL="$GITEA_URL/$REPO/raw/branch/$BRANCH/custom_components/mcp_bridge"
|
||||
|
||||
# Logging
|
||||
LOG_FILE="/config/mcp_bridge_install.log"
|
||||
exec 1> >(tee -a "$LOG_FILE")
|
||||
exec 2>&1
|
||||
|
||||
echo "=== MCP Bridge Installation Started at $(date) ==="
|
||||
echo "Configuration:"
|
||||
echo " GITEA_URL: $GITEA_URL"
|
||||
echo " REPO: $REPO"
|
||||
echo " BRANCH: $BRANCH"
|
||||
echo " BASE_URL: $BASE_URL"
|
||||
echo ""
|
||||
|
||||
# Determine config directory
|
||||
CONFIG_DIR="${CONFIG_DIR:-/config}"
|
||||
if [ ! -d "$CONFIG_DIR" ]; then
|
||||
CONFIG_DIR="${HOME}/.homeassistant"
|
||||
echo "Using alternate config dir: $CONFIG_DIR"
|
||||
fi
|
||||
|
||||
DEST="$CONFIG_DIR/custom_components/mcp_bridge"
|
||||
echo "Installation directory: $DEST"
|
||||
echo ""
|
||||
|
||||
# Create directory
|
||||
echo "Creating directory..."
|
||||
mkdir -p "$DEST" || {
|
||||
echo "ERROR: Failed to create directory $DEST"
|
||||
exit 1
|
||||
}
|
||||
echo "Directory created: $DEST"
|
||||
echo ""
|
||||
|
||||
# Download files
|
||||
echo "Downloading files..."
|
||||
cd "$DEST" || exit 1
|
||||
|
||||
for file in __init__.py manifest.json services.yaml; do
|
||||
echo " Downloading $file from $BASE_URL/$file"
|
||||
|
||||
if curl -fsSL "$BASE_URL/$file" -o "$file"; then
|
||||
echo " ✓ $file downloaded successfully ($(wc -c < "$file") bytes)"
|
||||
else
|
||||
echo " ✗ FAILED to download $file"
|
||||
echo " URL attempted: $BASE_URL/$file"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Installation Summary ==="
|
||||
echo "Files installed in: $DEST"
|
||||
ls -lh "$DEST"
|
||||
echo ""
|
||||
echo "✅ MCP Bridge installed successfully!"
|
||||
echo "⚠️ IMPORTANT: Restart Home Assistant to activate"
|
||||
echo ""
|
||||
echo "Installation completed at $(date)"
|
||||
echo "=== End of Installation Log ==="
|
||||
Reference in New Issue
Block a user