Skip to content

Commit 5aea268

Browse files
author
Julien Larocque-Dupont
authored
Merge pull request #200 from homeassistant-ai/feat/pyinstaller-binary
feat: add PyInstaller standalone binary builds
2 parents 7931b3f + e09ac61 commit 5aea268

37 files changed

Lines changed: 517 additions & 211 deletions

.github/workflows/build-binary.yml

Lines changed: 70 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,14 @@ jobs:
3333
artifact_name: ha-mcp-linux
3434
binary_ext: ''
3535
platform: linux
36-
create_mcpb: false
3736
- os: windows-latest
3837
artifact_name: ha-mcp-windows
3938
binary_ext: '.exe'
4039
platform: win32
41-
create_mcpb: true
4240
- os: macos-latest
4341
artifact_name: ha-mcp-macos-arm64
4442
binary_ext: ''
4543
platform: darwin
46-
create_mcpb: true
4744

4845
runs-on: ${{ matrix.os }}
4946
name: Build on ${{ matrix.os }}
@@ -98,70 +95,101 @@ jobs:
9895
exit $LASTEXITCODE
9996
}
10097
98+
- name: Rename binary for artifact
99+
shell: bash
100+
run: |
101+
mv dist/ha-mcp${{ matrix.binary_ext }} dist/${{ matrix.artifact_name }}${{ matrix.binary_ext }}
102+
103+
- name: Upload binary artifact
104+
uses: actions/upload-artifact@v4
105+
with:
106+
name: ${{ matrix.artifact_name }}
107+
path: dist/${{ matrix.artifact_name }}${{ matrix.binary_ext }}
108+
if-no-files-found: error
109+
110+
create-mcpb:
111+
needs: build
112+
runs-on: ubuntu-latest
113+
permissions:
114+
contents: read
115+
116+
steps:
117+
- name: Checkout code
118+
uses: actions/checkout@v4
119+
120+
- name: Set up Python
121+
uses: actions/setup-python@v5
122+
with:
123+
python-version: '3.13'
124+
125+
- name: Download Windows binary
126+
uses: actions/download-artifact@v4
127+
with:
128+
name: ha-mcp-windows
129+
path: mcpb-bundle
130+
131+
- name: Download macOS binary
132+
uses: actions/download-artifact@v4
133+
with:
134+
name: ha-mcp-macos-arm64
135+
path: mcpb-bundle
136+
101137
- name: Set version
102138
id: version
103-
shell: bash
104139
run: |
105140
if [ "${{ github.event_name }}" = "release" ]; then
106141
VERSION="${{ github.event.release.tag_name }}"
107-
# Remove 'v' prefix if present
108142
VERSION="${VERSION#v}"
109143
else
110144
VERSION="${{ github.event.inputs.version }}"
145+
VERSION="${VERSION:-0.0.0-dev}"
111146
fi
112147
echo "version=$VERSION" >> $GITHUB_OUTPUT
113148
114-
- name: Rename binary for artifact
115-
shell: bash
116-
run: |
117-
mv dist/ha-mcp${{ matrix.binary_ext }} dist/${{ matrix.artifact_name }}${{ matrix.binary_ext }}
118-
119-
- name: Create mcpb bundle (Windows/macOS only)
120-
if: matrix.create_mcpb
121-
shell: bash
149+
- name: Create combined mcpb bundle
122150
run: |
123151
VERSION="${{ steps.version.outputs.version }}"
124-
PLATFORM="${{ matrix.platform }}"
125-
BINARY_EXT="${{ matrix.binary_ext }}"
126152
127-
# Create mcpb directory structure
128-
mkdir -p mcpb-bundle
129-
130-
# Copy binary to bundle
131-
cp dist/${{ matrix.artifact_name }}${{ matrix.binary_ext }} mcpb-bundle/ha-mcp${{ matrix.binary_ext }}
132-
133-
# Copy icon to bundle
134-
cp packaging/mcpb/icon.png mcpb-bundle/icon.png
135-
136-
# Generate manifest.json with auto-discovered tools
137-
python packaging/mcpb/generate_manifest.py "$VERSION" "$PLATFORM" "$BINARY_EXT"
153+
# Rename binaries to standard names
154+
mv mcpb-bundle/ha-mcp-windows.exe mcpb-bundle/ha-mcp.exe
155+
mv mcpb-bundle/ha-mcp-macos-arm64 mcpb-bundle/ha-mcp
156+
157+
# Copy icons to bundle (light + dark variants + main icon)
158+
cp packaging/mcpb/icon-16.png mcpb-bundle/
159+
cp packaging/mcpb/icon-32.png mcpb-bundle/
160+
cp packaging/mcpb/icon-64.png mcpb-bundle/
161+
cp packaging/mcpb/icon-128.png mcpb-bundle/
162+
cp packaging/mcpb/icon-256.png mcpb-bundle/
163+
cp packaging/mcpb/icon-512.png mcpb-bundle/
164+
cp packaging/mcpb/icon-dark-16.png mcpb-bundle/
165+
cp packaging/mcpb/icon-dark-32.png mcpb-bundle/
166+
cp packaging/mcpb/icon-dark-64.png mcpb-bundle/
167+
cp packaging/mcpb/icon-dark-128.png mcpb-bundle/
168+
cp packaging/mcpb/icon-dark-256.png mcpb-bundle/
169+
cp packaging/mcpb/icon-dark-512.png mcpb-bundle/
170+
cp packaging/mcpb/icon-512.png mcpb-bundle/icon.png
171+
172+
# Generate manifest.json (multi-platform)
173+
python packaging/mcpb/generate_manifest.py "$VERSION"
138174
139175
# Create .mcpb file (zip archive)
140176
cd mcpb-bundle
141-
if [ "${{ runner.os }}" = "Windows" ]; then
142-
7z a -tzip "../${{ matrix.artifact_name }}.mcpb" *
143-
else
144-
zip -r "../${{ matrix.artifact_name }}.mcpb" *
145-
fi
177+
zip -r ../ha-mcp.mcpb *
146178
cd ..
147179
148-
- name: Upload binary artifact
149-
uses: actions/upload-artifact@v4
150-
with:
151-
name: ${{ matrix.artifact_name }}
152-
path: dist/${{ matrix.artifact_name }}${{ matrix.binary_ext }}
153-
if-no-files-found: error
180+
# List contents for verification
181+
echo "=== MCPB Contents ==="
182+
unzip -l ha-mcp.mcpb
154183
155184
- name: Upload mcpb artifact
156-
if: matrix.create_mcpb
157185
uses: actions/upload-artifact@v4
158186
with:
159-
name: ${{ matrix.artifact_name }}-mcpb
160-
path: ${{ matrix.artifact_name }}.mcpb
187+
name: ha-mcp-mcpb
188+
path: ha-mcp.mcpb
161189
if-no-files-found: error
162190

163191
release:
164-
needs: build
192+
needs: [build, create-mcpb]
165193
runs-on: ubuntu-latest
166194
if: github.event_name == 'release'
167195
permissions:
@@ -183,5 +211,4 @@ jobs:
183211
artifacts/ha-mcp-linux/ha-mcp-linux
184212
artifacts/ha-mcp-windows/ha-mcp-windows.exe
185213
artifacts/ha-mcp-macos-arm64/ha-mcp-macos-arm64
186-
artifacts/ha-mcp-windows-mcpb/ha-mcp-windows.mcpb
187-
artifacts/ha-mcp-macos-arm64-mcpb/ha-mcp-macos-arm64.mcpb
214+
artifacts/ha-mcp-mcpb/ha-mcp.mcpb

PRIVACY.md

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@
22

33
**Last updated:** November 2024
44

5+
## Scope
6+
7+
This privacy policy covers only the Home Assistant MCP Server ("ha-mcp") software. It does not cover the MCP host or client application you use to run ha-mcp. Please refer to your MCP client's privacy policy for information about how it handles your data.
8+
59
## Overview
610

7-
Home Assistant MCP Server ("ha-mcp") runs entirely on your local machine and communicates only with your own Home Assistant instance. We are committed to transparency about any data collection.
11+
Ha-mcp runs on your local machine and communicates with your own Home Assistant instance. We are committed to transparency about any data collection.
12+
13+
## Anonymous Usage Statistics
814

9-
## Data Collection
15+
Ha-mcp may collect anonymous usage statistics to help improve the server. If enabled, this includes:
1016

11-
**Ha-mcp does not collect any data.** The server runs entirely locally on your machine and does not send telemetry, analytics, or any information to external servers.
17+
- **Tool usage counts** — which tools are used and how often
18+
- **Server version** — to understand adoption of updates
19+
- **Request/response sizes** — to optimize performance (not content)
1220

13-
**We do NOT collect:**
21+
**What we do NOT collect:**
1422
- Entity names or IDs
1523
- Home Assistant configuration
1624
- Personal information
1725
- Automation or script content
1826
- Any data from your smart home devices
19-
- Usage statistics or analytics
20-
- Error reports (unless you explicitly send them)
27+
28+
Telemetry is configurable in the settings.
2129

2230
## Bug Reports
2331

@@ -31,26 +39,23 @@ You are always in control of whether to send a bug report.
3139

3240
## Your Home Assistant Data
3341

34-
When you use ha-mcp, Claude Desktop accesses data from your Home Assistant instance, including entity states, automations, and device information. This data:
42+
When you use ha-mcp, your MCP client accesses data from your Home Assistant instance, including entity states, automations, and device information. This data:
3543

36-
- Is processed locally by Claude Desktop
37-
- Is subject to [Anthropic's Privacy Policy](https://www.anthropic.com/privacy)
44+
- Is processed by your MCP client application
45+
- Is subject to your MCP client's privacy policy
3846
- Is NOT collected, stored, or transmitted by ha-mcp
3947

40-
## Third-Party Services
41-
42-
Ha-mcp communicates with:
48+
## Services Ha-mcp Communicates With
4349

4450
- **Your Home Assistant instance** — via the URL and token you provide
45-
- **Claude Desktop** — the MCP client that runs ha-mcp
46-
47-
Ha-mcp does not communicate with any other external services.
51+
- **Your MCP client** — the application that runs ha-mcp
52+
- **Our telemetry server** — for anonymous usage statistics (if enabled)
4853

4954
## Data Security
5055

51-
- Your Home Assistant credentials are stored locally by Claude Desktop
56+
- Your Home Assistant credentials are stored locally by your MCP client
57+
- Anonymous telemetry contains no identifying information
5258
- Bug reports are only sent when you explicitly approve
53-
- No data leaves your local network except to your Home Assistant instance
5459

5560
## Changes to This Policy
5661

@@ -67,8 +72,8 @@ For privacy-related questions or concerns:
6772

6873
| Aspect | Status |
6974
|--------|--------|
70-
| Telemetry | None |
75+
| Anonymous telemetry | Configurable |
7176
| Personal data collected | None |
7277
| Bug reports | User-approved only |
7378
| Local processing | Yes |
74-
| Third-party sharing | None |
79+
| Third-party data sharing | None |

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -606,11 +606,11 @@ For future enhancements and planned features, see the [Development Roadmap](http
606606

607607
## 🔒 Privacy
608608

609-
Ha-mcp runs **entirely locally** on your machine. We collect no data, send no telemetry, and have no external dependencies beyond your own Home Assistant instance.
609+
Ha-mcp runs **locally** on your machine. Your smart home data stays on your network.
610610

611-
- **No analytics or tracking**
612-
- **No cloud services**
613-
- **Your data stays on your network**
611+
- **Configurable telemetry** — optional anonymous usage stats
612+
- **No personal data collection** — we never collect entity names, configs, or device data
613+
- **User-controlled bug reports** — only sent with your explicit approval
614614

615615
For full details, see our [Privacy Policy](PRIVACY.md).
616616

packaging/mcpb/generate_manifest.py

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,52 @@
44

55
import ast
66
import json
7-
import re
87
import sys
98
from pathlib import Path
109

1110

1211
def extract_tools_from_file(file_path: Path) -> list[dict]:
13-
"""Extract tool definitions from a Python file."""
12+
"""Extract tool definitions from a Python file.
13+
14+
MCPB manifest only supports 'name' and 'description' for tools.
15+
We use the 'title' from annotations as the display name.
16+
"""
1417
tools = []
1518
content = file_path.read_text(encoding="utf-8")
1619
tree = ast.parse(content)
1720

1821
for node in ast.walk(tree):
1922
if isinstance(node, ast.AsyncFunctionDef) and node.name.startswith("ha_"):
20-
# Get the docstring
23+
# Get the docstring for description
2124
docstring = ast.get_docstring(node) or ""
22-
# Take first line as description
2325
description = docstring.split("\n")[0].strip() if docstring else ""
2426

25-
# If no docstring, try to get from decorator
26-
if not description:
27-
for decorator in node.decorator_list:
28-
if isinstance(decorator, ast.Call):
29-
for keyword in decorator.keywords:
30-
if keyword.arg == "description" and isinstance(keyword.value, ast.Constant):
31-
description = keyword.value.value
32-
break
33-
if keyword.arg == "annotations" and isinstance(keyword.value, ast.Dict):
34-
for k, v in zip(keyword.value.keys, keyword.value.values):
35-
if isinstance(k, ast.Constant) and k.value == "title":
36-
if isinstance(v, ast.Constant):
37-
description = v.value
38-
break
39-
40-
# Fallback to function name conversion
27+
# Try to get title from decorator annotations
28+
title = None
29+
for decorator in node.decorator_list:
30+
if isinstance(decorator, ast.Call):
31+
for keyword in decorator.keywords:
32+
if keyword.arg == "annotations" and isinstance(keyword.value, ast.Dict):
33+
for k, v in zip(keyword.value.keys, keyword.value.values):
34+
if isinstance(k, ast.Constant) and k.value == "title":
35+
if isinstance(v, ast.Constant):
36+
title = v.value
37+
break
38+
# Also check for description in decorator if no docstring
39+
if not description and keyword.arg == "description" and isinstance(keyword.value, ast.Constant):
40+
description = keyword.value.value
41+
42+
# Use title as the display name, fallback to formatted function name
43+
display_name = title if title else node.name.replace("ha_", "").replace("_", " ").title()
44+
45+
# Use docstring first line as description, fallback to title or formatted name
4146
if not description:
42-
description = node.name.replace("ha_", "").replace("_", " ").title()
47+
description = display_name
4348

49+
# MCPB only supports name and description
50+
# Use title/display_name as the "name" shown in UI
4451
tools.append({
45-
"name": node.name,
52+
"name": display_name,
4653
"description": description[:100] # Truncate long descriptions
4754
})
4855

@@ -68,22 +75,21 @@ def generate_manifest(
6875
template_path: Path,
6976
output_path: Path,
7077
version: str,
71-
platform: str,
72-
binary_ext: str,
7378
tools: list[dict]
7479
):
75-
"""Generate manifest.json from template with discovered tools."""
80+
"""Generate manifest.json from template with discovered tools.
81+
82+
Creates a multi-platform bundle supporting both macOS and Windows.
83+
"""
7684
template = json.loads(template_path.read_text(encoding="utf-8"))
7785

7886
# Update tools list
7987
template["tools"] = tools
8088
template["tools_generated"] = True
8189

82-
# Replace placeholders
90+
# Replace version placeholder
8391
manifest_str = json.dumps(template, indent=2)
8492
manifest_str = manifest_str.replace("${VERSION}", version)
85-
manifest_str = manifest_str.replace("${PLATFORM}", platform)
86-
manifest_str = manifest_str.replace("${BINARY_EXT}", binary_ext)
8793

8894
# Update description with actual tool count
8995
manifest = json.loads(manifest_str)
@@ -97,16 +103,14 @@ def generate_manifest(
97103

98104

99105
def main():
100-
if len(sys.argv) < 4:
101-
print("Usage: generate_manifest.py <version> <platform> <binary_ext>")
102-
print("Example: generate_manifest.py 4.7.4 win32 .exe")
106+
if len(sys.argv) < 2:
107+
print("Usage: generate_manifest.py <version>")
108+
print("Example: generate_manifest.py 4.7.4")
103109
sys.exit(1)
104110

105111
version = sys.argv[1]
106-
platform = sys.argv[2]
107-
binary_ext = sys.argv[3] if len(sys.argv) > 3 else ""
108112

109-
# Paths - script is in dist/mcpb/, project root is 2 levels up
113+
# Paths - script is in packaging/mcpb/, project root is 2 levels up
110114
script_dir = Path(__file__).parent
111115
project_root = script_dir.parent.parent
112116
tools_dir = project_root / "src" / "ha_mcp" / "tools"
@@ -120,7 +124,7 @@ def main():
120124
tools = discover_all_tools(tools_dir)
121125

122126
# Generate manifest
123-
generate_manifest(template_path, output_path, version, platform, binary_ext, tools)
127+
generate_manifest(template_path, output_path, version, tools)
124128

125129

126130
if __name__ == "__main__":

packaging/mcpb/icon-32.png

132 Bytes
Loading

0 commit comments

Comments
 (0)