mcp-server-harmony 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """MCP server for the Logitech Harmony Hub."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,157 @@
1
+ """Every protocol detail this server relies on that hasn't been checked on a real hub.
2
+
3
+ Why a registry instead of comments: a comment saying "unverified" is easy to
4
+ miss and never goes away. Here each assumption has an id that:
5
+ - code cites next to where it depends on it (``# ASSUMPTION H-PROVISION``),
6
+ - a test cites to document it (tests/test_assumptions.py, test_contract.py),
7
+ - HARDWARE_VALIDATION.md cites in the step that confirms it,
8
+ - `mcp-server-harmony doctor` prints, with its status.
9
+
10
+ tests/test_assumptions.py fails if an id is missing from the README or
11
+ HARDWARE_VALIDATION.md, so the three can't drift apart.
12
+
13
+ To record a hardware result, change ``status`` to "hardware-verified" (or
14
+ "hardware-contradicted", with what you saw in ``note``) and commit it.
15
+
16
+ Confidence is about the *claim*, judged from its source:
17
+ high - the library we run (aioharmony, used by Home Assistant) depends on it
18
+ medium - inferred from another project's code or docs, not from aioharmony itself
19
+ low - our own guess; the simulator implements it but nothing confirms it
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Literal
26
+
27
+ Confidence = Literal["high", "medium", "low"]
28
+ Status = Literal["simulator-only", "hardware-verified", "hardware-contradicted"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Assumption:
33
+ id: str
34
+ claim: str
35
+ source: str
36
+ confidence: Confidence
37
+ status: Status = "simulator-only"
38
+ note: str = ""
39
+
40
+
41
+ # Hardware runs, cited by the notes below.
42
+ _HW_2026_10_14 = "2026-10-14, two real hubs (Harmony Hub, Office Hub, firmware 4.15.600) via the service on LXC210"
43
+
44
+ ASSUMPTIONS: tuple[Assumption, ...] = (
45
+ Assumption(
46
+ "H-PORT",
47
+ "The hub's local API is HTTP and websocket on TCP 8088.",
48
+ "aioharmony const.DEFAULT_WS_HUB_PORT, used for every request",
49
+ "high",
50
+ status="hardware-verified",
51
+ note=_HW_2026_10_14 + ": both hubs connected on 8088.",
52
+ ),
53
+ Assumption(
54
+ "H-PROVISION",
55
+ "POST http://<hub>:8088/ with cmd 'setup.account?getProvisionInfo' returns data.activeRemoteId, "
56
+ "which the websocket URL needs as hubId.",
57
+ "aioharmony hubconnector_websocket._retrieve_hub_info; Home Assistant validates hubs the same way",
58
+ "high",
59
+ status="hardware-verified",
60
+ note=_HW_2026_10_14 + ": both websockets opened, which needs the provisioned hubId.",
61
+ ),
62
+ Assumption(
63
+ "H-FRAMES",
64
+ "Requests are {hubId, timeout, hbus:{cmd, id, params}}; replies are {cmd, code, id, msg, data} and are "
65
+ "matched to requests by id.",
66
+ "aioharmony hub_send and responsehandler",
67
+ "high",
68
+ status="hardware-verified",
69
+ note=_HW_2026_10_14 + ": get_status and power_off got their replies.",
70
+ ),
71
+ Assumption(
72
+ "H-CONFIG-SHAPE",
73
+ "The config has activity[] and device[] with string ids, controlGroup[].function[], and each "
74
+ "function's action is a JSON *string* holding command and deviceId.",
75
+ "aioharmony json_config and _get_config",
76
+ "high",
77
+ note="Partial, " + _HW_2026_10_14 + ": the running activity's id (32845636) resolved to its name "
78
+ "through the parsed config. Devices and commands not exercised yet.",
79
+ ),
80
+ Assumption(
81
+ "H-POWEROFF",
82
+ "Activity -1 is the built-in PowerOff; starting it turns everything off.",
83
+ "aioharmony power_off() is start_activity(-1)",
84
+ "high",
85
+ status="hardware-verified",
86
+ note=_HW_2026_10_14 + ": power_off on Office Hub sent runactivity -1; the hub went off, and the LG "
87
+ "Office TV dropped to standby (confirmed independently by mcp-server-webos).",
88
+ ),
89
+ Assumption(
90
+ "H-NAME",
91
+ "'connect.discoveryinfo?get' returns the hub's friendlyName (the name set in the Harmony app).",
92
+ "aioharmony HarmonyClient.name",
93
+ "high",
94
+ status="hardware-verified",
95
+ note=_HW_2026_10_14 + ": the hubs reported 'Harmony Hub' and 'Office Hub'.",
96
+ ),
97
+ Assumption(
98
+ "H-START-SEQUENCE",
99
+ "Starting an activity: the hub acks runactivity, sends stateDigest notify (activityStatus 1, or 0 for "
100
+ "power off), startActivity code 100 progress frames, startActivity code 200 when done, then "
101
+ "startActivityFinished.",
102
+ "aioharmony's start_activity handlers; the fake hub's exact order and timing are hand-built",
103
+ "medium",
104
+ ),
105
+ Assumption(
106
+ "H-PRESS-SILENT",
107
+ "holdAction press/release gets no reply on success, only on failure.",
108
+ "aioharmony send_commands comment ('the HUB sends a message back if there is an issue')",
109
+ "medium",
110
+ ),
111
+ Assumption(
112
+ "H-PRESS-ERROR",
113
+ "A rejected press is answered with a non-200 code and a msg. The fake hub uses code 417 with "
114
+ "'Unknown command'; the real code and text are unknown.",
115
+ "none: the fake hub's choice",
116
+ "low",
117
+ ),
118
+ Assumption(
119
+ "H-HOLD",
120
+ "hold_ms keeps the button 'pressed' between the press and release frames; whether the hub repeats the "
121
+ "IR code while held (like a held physical button) is unknown.",
122
+ "aioharmony SendCommandDevice.delay (press, sleep, release)",
123
+ "low",
124
+ ),
125
+ Assumption(
126
+ "H-CONFIG-PUSH",
127
+ "Editing the hub in the Harmony app makes it push stateDigest notify with a new configVersion "
128
+ "(syncStatus not 1); aioharmony then refetches the config.",
129
+ "aioharmony _notification_callback",
130
+ "medium",
131
+ ),
132
+ Assumption(
133
+ "H-RECONNECT",
134
+ "After the websocket drops, aioharmony reconnects by itself (first retry after 1 s, backing off to 30 s) "
135
+ "and calls the disconnect and connect callbacks.",
136
+ "aioharmony hubconnector_websocket._reconnect",
137
+ "high",
138
+ ),
139
+ Assumption(
140
+ "H-SSDP",
141
+ "Hubs answer an SSDP M-SEARCH for ST urn:myharmony-com:device:harmony:1, with LOCATION on the hub's address.",
142
+ "Home Assistant's harmony manifest matches that deviceType; HA may learn it from NOTIFY, not M-SEARCH",
143
+ "medium",
144
+ ),
145
+ Assumption(
146
+ "H-TIMING",
147
+ "An activity start or power-off finishes within 60 s.",
148
+ "none: our timeout, picked to cover slow TVs",
149
+ "low",
150
+ ),
151
+ )
152
+
153
+ BY_ID = {a.id: a for a in ASSUMPTIONS}
154
+
155
+
156
+ def unverified() -> list[Assumption]:
157
+ return [a for a in ASSUMPTIONS if a.status != "hardware-verified"]
harmony_mcp/catalog.py ADDED
@@ -0,0 +1,181 @@
1
+ """The hub's own configuration, turned into the names the model may use.
2
+
3
+ A Harmony hub already holds a closed world: the activities you set up, the
4
+ devices in them, and every command each device knows (the IR codes live on the
5
+ hub; we only ever send a command *name*). That makes the hub's config the
6
+ allow-list. The model can only start an activity, or send a command, that
7
+ appears here; there is no way to send a raw IR code or an unknown device id.
8
+
9
+ This module is pure data shaping (no I/O), so it is tested directly against a
10
+ config captured in the same shape the hub returns.
11
+
12
+ The raw config (``HarmonyAPI.config``) looks like this, trimmed::
13
+
14
+ {
15
+ "activity": [
16
+ {"id": "-1", "label": "PowerOff", "controlGroup": []},
17
+ {"id": "38123456", "label": "Watch Shield",
18
+ "controlGroup": [
19
+ {"name": "Volume", "function": [
20
+ {"name": "VolumeUp", "label": "Volume Up",
21
+ "action": "{\\"command\\":\\"VolumeUp\\",\\"type\\":\\"IRCommand\\",\\"deviceId\\":\\"71234567\\"}"}
22
+ ]}
23
+ ]}
24
+ ],
25
+ "device": [
26
+ {"id": "71234567", "label": "Onkyo AV Receiver", "manufacturer": "Onkyo",
27
+ "model": "TX-NR7100", "controlGroup": [...same shape as above...]}
28
+ ]
29
+ }
30
+
31
+ Two quirks worth knowing: ids are *strings* in the config but *ints* everywhere
32
+ in aioharmony's API, and each function's ``action`` is itself a JSON string.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import logging
39
+ from dataclasses import dataclass, field
40
+ from typing import Any
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+ # Harmony's built-in "everything off" pseudo-activity. It is in the config's
45
+ # activity list, but it is not something the user set up, so it is reported as
46
+ # power "off" rather than listed as an activity.
47
+ POWER_OFF_ID = -1
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Command:
52
+ """One button the hub knows how to send.
53
+
54
+ name - the hub's canonical name, e.g. "VolumeUp". This is what is sent.
55
+ label - the human label, e.g. "Volume Up". Accepted as input too.
56
+ device_id - the device that receives it. For an activity's commands this is
57
+ whichever device the activity routes that button to (volume to
58
+ the receiver, play/pause to the Shield, ...).
59
+ group - the hub's control group, e.g. "Volume", "TransportBasic".
60
+ """
61
+
62
+ name: str
63
+ label: str
64
+ device_id: int
65
+ group: str
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Device:
70
+ device_id: int
71
+ name: str
72
+ manufacturer: str
73
+ model: str
74
+ commands: tuple[Command, ...] = field(default=(), repr=False)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Activity:
79
+ activity_id: int
80
+ name: str
81
+ commands: tuple[Command, ...] = field(default=(), repr=False)
82
+
83
+
84
+ def norm(name: str) -> str:
85
+ """Matching key: case-, space-, dash- and underscore-insensitive.
86
+
87
+ The model may say "volume up", "Volume Up", "VolumeUp" or "volume_up";
88
+ all of them should find the hub's "VolumeUp".
89
+ """
90
+ return "".join(ch for ch in name.lower() if ch.isalnum())
91
+
92
+
93
+ def _parse_commands(control_groups: Any) -> tuple[Command, ...]:
94
+ out: list[Command] = []
95
+ for group in control_groups or []:
96
+ if not isinstance(group, dict):
97
+ continue
98
+ group_name = str(group.get("name", ""))
99
+ for fn in group.get("function", []) or []:
100
+ if not isinstance(fn, dict):
101
+ continue
102
+ try:
103
+ action = json.loads(fn.get("action") or "")
104
+ cmd = Command(
105
+ name=str(action["command"]),
106
+ label=str(fn.get("label") or fn.get("name") or action["command"]),
107
+ device_id=int(action["deviceId"]),
108
+ group=group_name,
109
+ )
110
+ except (ValueError, KeyError, TypeError):
111
+ # A function we can't parse is simply not offered to the model.
112
+ log.debug("Skipping unparseable function %r", fn)
113
+ continue
114
+ out.append(cmd)
115
+ return tuple(out)
116
+
117
+
118
+ def _find(commands: tuple[Command, ...], wanted: str) -> Command | None:
119
+ key = norm(wanted)
120
+ for c in commands:
121
+ if norm(c.name) == key or norm(c.label) == key:
122
+ return c
123
+ return None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Catalog:
128
+ """Everything the hub's config says the model is allowed to touch."""
129
+
130
+ activities: tuple[Activity, ...] = ()
131
+ devices: tuple[Device, ...] = ()
132
+
133
+ @classmethod
134
+ def from_config(cls, config: dict[str, Any] | None) -> Catalog:
135
+ if not config:
136
+ return cls()
137
+ activities = []
138
+ for a in config.get("activity", []) or []:
139
+ try:
140
+ aid = int(a["id"])
141
+ except (KeyError, TypeError, ValueError):
142
+ continue
143
+ if aid == POWER_OFF_ID:
144
+ continue
145
+ activities.append(Activity(aid, str(a.get("label", aid)), _parse_commands(a.get("controlGroup"))))
146
+ devices = []
147
+ for d in config.get("device", []) or []:
148
+ try:
149
+ did = int(d["id"])
150
+ except (KeyError, TypeError, ValueError):
151
+ continue
152
+ devices.append(
153
+ Device(
154
+ did,
155
+ str(d.get("label", did)),
156
+ str(d.get("manufacturer", "")),
157
+ str(d.get("model", "")),
158
+ _parse_commands(d.get("controlGroup")),
159
+ )
160
+ )
161
+ return cls(tuple(activities), tuple(devices))
162
+
163
+ # --- lookups by what the model typed ----------------------------------
164
+ def activity(self, name: str) -> Activity | None:
165
+ key = norm(name)
166
+ return next((a for a in self.activities if norm(a.name) == key), None)
167
+
168
+ def activity_by_id(self, activity_id: int | None) -> Activity | None:
169
+ return next((a for a in self.activities if a.activity_id == activity_id), None)
170
+
171
+ def device(self, name: str) -> Device | None:
172
+ key = norm(name)
173
+ return next((d for d in self.devices if norm(d.name) == key), None)
174
+
175
+ def device_name(self, device_id: int) -> str:
176
+ d = next((d for d in self.devices if d.device_id == device_id), None)
177
+ return d.name if d else str(device_id)
178
+
179
+ @staticmethod
180
+ def find_command(commands: tuple[Command, ...], name: str) -> Command | None:
181
+ return _find(commands, name)
harmony_mcp/cli.py ADDED
@@ -0,0 +1,302 @@
1
+ """Entry point: `mcp-server-harmony [serve|check|call|discover|doctor|simulate]`.
2
+
3
+ serve speak MCP: stdio by default (the client launches it), or --http for
4
+ a long-lived service behind Cloudflare Access (remote.py). --dry-run
5
+ reads the hubs but sends nothing that changes anything.
6
+ check first contact with one hub (--host) or every configured one: list
7
+ what it has and save it, with its own name, to config.json.
8
+ call call one tool exactly as the model would (through an in-process MCP
9
+ client, so arguments are validated the same way) and print the
10
+ result: `call start_activity activity="Den/Watch TV"`; `call tools`.
11
+ discover look for hubs with SSDP (discovery.py); `check --discover` adds them.
12
+ doctor check each hub layer by layer and say what failed (doctor.py);
13
+ --dump writes redacted fixtures from your real hubs.
14
+ simulate run wire-level fake hubs (sim/fake_hub.py) on 127.0.0.1 and
15
+ 127.0.0.2, so everything above works without hardware.
16
+
17
+ There is no pairing step: a Harmony hub answers anyone on the LAN.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import logging
27
+ import os
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ from aioharmony.exceptions import HarmonyException
32
+ from aioharmony.harmonyapi import HarmonyAPI
33
+
34
+ from . import __version__, remote
35
+ from .catalog import Catalog
36
+ from .config import Protocol, config_dir, load_settings, save_hub
37
+ from .logsafe import setup_logging
38
+
39
+
40
+ def _setup_logging(args: argparse.Namespace, default: int = logging.INFO) -> None:
41
+ verbose = getattr(args, "verbose", 0)
42
+ level = logging.DEBUG if verbose else default
43
+ unredacted = getattr(args, "no_redact", False) or os.environ.get("HARMONY_LOG_UNREDACTED") in ("1", "true")
44
+ setup_logging(level, redacted=not unredacted)
45
+
46
+
47
+ # --- check ------------------------------------------------------------------------------
48
+ async def _check_one(host: str, protocol: Protocol | None) -> bool:
49
+ api = HarmonyAPI(ip_address=host, protocol=protocol)
50
+ try:
51
+ if not await api.connect():
52
+ print(f"Couldn't connect to a Harmony hub at {host} (TCP 8088). Try `doctor`.", file=sys.stderr)
53
+ return False
54
+ except (HarmonyException, OSError) as exc:
55
+ print(f"Couldn't connect to {host}: {exc}. Try `doctor`.", file=sys.stderr)
56
+ return False
57
+ try:
58
+ # aioharmony reports the IP as the name when the hub didn't send one.
59
+ name = api.name if api.name and api.name != host else None
60
+ catalog = Catalog.from_config(api.config)
61
+ current = catalog.activity_by_id(api.current_activity[0])
62
+ print(f"{name or host} (firmware {api.fw_version}, {api.protocol}) at {host}")
63
+ print(f" Running: {current.name if current else 'nothing (off)'}")
64
+ print(" Activities:")
65
+ for a in catalog.activities:
66
+ print(f" {a.name} ({len(a.commands)} commands)")
67
+ print(" Devices:")
68
+ for d in catalog.devices:
69
+ print(f" {d.name} [{d.manufacturer} {d.model}] ({len(d.commands)} commands)")
70
+ finally:
71
+ await api.close()
72
+ path = save_hub(host, name)
73
+ print(f" Saved to {path}")
74
+ return True
75
+
76
+
77
+ async def _cmd_check(host: str | None, discover_first: bool = False) -> int:
78
+ settings = load_settings()
79
+ for p in settings.problems:
80
+ print(f"Config: {p}", file=sys.stderr)
81
+ if discover_first:
82
+ from .discovery import discover
83
+
84
+ hosts = [h.host for h in await discover()]
85
+ if not hosts:
86
+ print("SSDP found no hubs; use --host <ip> (see `discover --help`).", file=sys.stderr)
87
+ return 1
88
+ else:
89
+ hosts = [host] if host else [h.host for h in settings.hubs]
90
+ if not hosts:
91
+ print("No hubs configured. Run: mcp-server-harmony check --host <ip> (once per hub)", file=sys.stderr)
92
+ return 2
93
+ if settings.port is not None:
94
+ import aioharmony.hubconnector_websocket as ws_connector
95
+
96
+ ws_connector.DEFAULT_HUB_PORT = settings.port
97
+ results = [await _check_one(h, settings.protocol) for h in hosts]
98
+ return 0 if all(results) else 1
99
+
100
+
101
+ # --- discover -----------------------------------------------------------------------------
102
+ async def _cmd_discover(timeout: float, st: str, raw: bool) -> int:
103
+ from .discovery import discover
104
+
105
+ print(f"Searching for {st} ({timeout:.0f}s)...")
106
+ hits = await discover(timeout=timeout, st=st)
107
+ for hit in hits:
108
+ print(f" {hit.host:<16} {hit.server or ''} {hit.location or ''}")
109
+ if raw:
110
+ print(" " + hit.raw.strip().replace("\n", "\n "))
111
+ if not hits:
112
+ print(
113
+ "Nothing answered. SSDP is multicast: it doesn't cross VLANs or guest networks, and on WSL2 needs "
114
+ "mirrored networking. `check --host <ip>` always works. (Unverified on a real hub: ASSUMPTION H-SSDP.)"
115
+ )
116
+ return 1
117
+ print("Add them with: mcp-server-harmony check --discover")
118
+ return 0
119
+
120
+
121
+ # --- doctor -------------------------------------------------------------------------------
122
+ async def _cmd_doctor(args: argparse.Namespace) -> int:
123
+ from dataclasses import replace
124
+
125
+ from .config import HubSettings
126
+ from .doctor import render, run_doctor, to_json
127
+
128
+ settings = load_settings()
129
+ if args.host:
130
+ settings = replace(settings, hubs=(HubSettings(args.host),))
131
+ report = await run_doctor(settings, timeout=args.timeout, dump_dir=Path(args.dump) if args.dump else None)
132
+ print(to_json(report) if args.json else render(report))
133
+ return 0 if report.ok else 1
134
+
135
+
136
+ # --- simulate -----------------------------------------------------------------------------
137
+ SIM_HUBS = (("living_room", "Living Room", "127.0.0.1", 12345678), ("den", "Den", "127.0.0.2", 87654321))
138
+
139
+
140
+ async def _cmd_simulate(args: argparse.Namespace, ready: asyncio.Event | None = None) -> int:
141
+ from .sim.fake_hub import FakeHub, load_fixture
142
+
143
+ fakes = [
144
+ FakeHub(load_fixture(fixture), name=name, host=host, port=args.port, remote_id=rid, step_delay=args.step_delay)
145
+ for fixture, name, host, rid in SIM_HUBS[: args.hubs]
146
+ ]
147
+ try:
148
+ for f in fakes:
149
+ await f.start()
150
+ except OSError as exc:
151
+ print(
152
+ f"Couldn't start a fake hub: {exc}. Is port {args.port} free? (On macOS add the second loopback "
153
+ "address first: sudo ifconfig lo0 alias 127.0.0.2.)",
154
+ file=sys.stderr,
155
+ )
156
+ for f in fakes:
157
+ await f.stop()
158
+ return 1
159
+ cfg_dir = Path(args.write_config) if args.write_config else None
160
+ if cfg_dir is not None:
161
+ cfg_dir.mkdir(parents=True, exist_ok=True)
162
+ doc: dict[str, object] = {"protocol": "WEBSOCKETS", "hubs": [{"host": f.host} for f in fakes]}
163
+ if args.port != 8088:
164
+ doc["port"] = args.port
165
+ (cfg_dir / "config.json").write_text(json.dumps(doc, indent=2) + "\n")
166
+ print("Fake Harmony hubs (verified-against-nothing: they implement the protocol as aioharmony expects it):")
167
+ for f in fakes:
168
+ print(
169
+ f" {f.name:<12} {f.host}:{f.port} activities: {', '.join(a['label'] for a in f.config['activity'][1:])}"
170
+ )
171
+ target = cfg_dir or config_dir()
172
+ print(f"\nIn another terminal:\n HARMONY_CONFIG_DIR={target} mcp-server-harmony doctor")
173
+ print(f" HARMONY_CONFIG_DIR={target} mcp-server-harmony serve # or add it to Claude Code/Desktop")
174
+ if cfg_dir is None:
175
+ print("(Pass --write-config DIR to have a config.json for these written for you.)")
176
+ print("Ctrl+C to stop.")
177
+ sys.stdout.flush()
178
+ if ready is not None:
179
+ ready.set()
180
+ try:
181
+ while True:
182
+ await asyncio.sleep(args.flaky or 3600)
183
+ if args.flaky:
184
+ print("Dropping all connections (--flaky)")
185
+ for f in fakes:
186
+ await f.drop_connections()
187
+ finally:
188
+ for f in fakes:
189
+ await f.stop()
190
+
191
+
192
+ async def _cmd_call(args: argparse.Namespace) -> int:
193
+ from mcp import Client
194
+
195
+ from .server import mcp
196
+
197
+ tool_args: dict[str, object] = {}
198
+ for pair in args.args:
199
+ key, sep, raw = pair.partition("=")
200
+ if not sep:
201
+ print(f"arguments are key=value, got {pair!r}", file=sys.stderr)
202
+ return 2
203
+ try:
204
+ tool_args[key] = json.loads(raw) # repeat=3 -> 3, hub=null -> None
205
+ except json.JSONDecodeError:
206
+ tool_args[key] = raw # activity=Watch TV -> a string
207
+ async with Client(mcp) as c:
208
+ if args.tool == "tools":
209
+ for t in (await c.list_tools()).tools:
210
+ print(f"{t.name:<16} {(t.description or '').splitlines()[0]}")
211
+ return 0
212
+ result = await c.call_tool(args.tool, tool_args)
213
+ if result.is_error:
214
+ print(" ".join(getattr(part, "text", "") for part in result.content) or "error", file=sys.stderr)
215
+ return 1
216
+ body = result.structured_content
217
+ if isinstance(body, dict) and set(body) == {"result"}:
218
+ body = body["result"]
219
+ print(json.dumps(body, indent=2))
220
+ return 0
221
+
222
+
223
+ # --- main ---------------------------------------------------------------------------------
224
+ def build_parser() -> argparse.ArgumentParser:
225
+ parser = argparse.ArgumentParser(prog="mcp-server-harmony", description="MCP server for Logitech Harmony Hubs")
226
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
227
+ common = argparse.ArgumentParser(add_help=False)
228
+ common.add_argument("-v", "--verbose", action="count", default=0, help="debug logging (aioharmony frames too)")
229
+ common.add_argument(
230
+ "--no-redact",
231
+ action="store_true",
232
+ help="don't mask IPs and MACs in logs; secrets stay masked (HARMONY_LOG_UNREDACTED=1)",
233
+ )
234
+ sub = parser.add_subparsers(dest="cmd")
235
+ serve = sub.add_parser("serve", parents=[common], help="Run the MCP server (stdio by default, or --http)")
236
+ serve.add_argument(
237
+ "--dry-run",
238
+ action="store_true",
239
+ help="read the hubs but send nothing that changes anything (HARMONY_DRY_RUN=1)",
240
+ )
241
+ remote.add_http_arguments(serve, default_port=8713, default_path="/harmony/mcp")
242
+ check = sub.add_parser(
243
+ "check", parents=[common], help="Connect to a hub (or every configured hub), list it, and save it"
244
+ )
245
+ where = check.add_mutually_exclusive_group()
246
+ where.add_argument("--host", help="A hub's IP address or hostname, to add it")
247
+ where.add_argument("--discover", action="store_true", help="find hubs with SSDP first, then check and save each")
248
+ call = sub.add_parser("call", parents=[common], help="Call one tool as the model would and print the result")
249
+ call.add_argument("tool", help="tool name, or 'tools' to list them")
250
+ call.add_argument("args", nargs="*", metavar="key=value", help="tool arguments (values are JSON if they parse)")
251
+ call.add_argument("--dry-run", action="store_true", help="send nothing that changes anything")
252
+ disc = sub.add_parser("discover", parents=[common], help="Look for hubs on the LAN with SSDP")
253
+ disc.add_argument("--timeout", type=float, default=3.0)
254
+ disc.add_argument("--st", default="urn:myharmony-com:device:harmony:1", help="search target (try ssdp:all)")
255
+ disc.add_argument("--raw", action="store_true", help="print each reply in full")
256
+ doc = sub.add_parser("doctor", parents=[common], help="Check config and every hub, layer by layer")
257
+ doc.add_argument("--json", action="store_true", help="machine-readable output")
258
+ doc.add_argument("--dump", metavar="DIR", help="also write each hub's redacted raw data to DIR")
259
+ doc.add_argument("--timeout", type=float, default=10.0, help="seconds per step (default 10)")
260
+ doc.add_argument("--host", help="check this address instead of the configured hubs")
261
+ sim = sub.add_parser("simulate", parents=[common], help="Run fake hubs locally, for testing without hardware")
262
+ sim.add_argument("--hubs", type=int, choices=[1, 2], default=2)
263
+ sim.add_argument("--port", type=int, default=8088, help="port for every fake hub (aioharmony uses 8088)")
264
+ sim.add_argument("--step-delay", type=float, default=0.4, help="seconds per device while an activity starts")
265
+ sim.add_argument("--write-config", metavar="DIR", help="write a config.json pointing at the fake hubs to DIR")
266
+ sim.add_argument("--flaky", type=float, metavar="SECONDS", help="drop every connection every SECONDS")
267
+ return parser
268
+
269
+
270
+ def main(argv: list[str] | None = None) -> None:
271
+ parser = build_parser()
272
+ args = parser.parse_args(argv)
273
+
274
+ if args.cmd in ("check", "call", "discover", "doctor", "simulate"):
275
+ _setup_logging(args, default=logging.WARNING)
276
+ if getattr(args, "dry_run", False):
277
+ os.environ["HARMONY_DRY_RUN"] = "1"
278
+ with contextlib.suppress(KeyboardInterrupt):
279
+ if args.cmd == "check":
280
+ sys.exit(asyncio.run(_cmd_check(args.host, args.discover)))
281
+ if args.cmd == "call":
282
+ sys.exit(asyncio.run(_cmd_call(args)))
283
+ if args.cmd == "discover":
284
+ sys.exit(asyncio.run(_cmd_discover(args.timeout, args.st, args.raw)))
285
+ if args.cmd == "doctor":
286
+ sys.exit(asyncio.run(_cmd_doctor(args)))
287
+ sys.exit(asyncio.run(_cmd_simulate(args)))
288
+ sys.exit(130)
289
+
290
+ _setup_logging(args)
291
+ if getattr(args, "dry_run", False):
292
+ os.environ["HARMONY_DRY_RUN"] = "1" # read by load_settings in the server's lifespan
293
+ # Imported here so the other commands don't pay for the MCP server's imports.
294
+ from .server import mcp
295
+
296
+ if getattr(args, "http", False):
297
+ try:
298
+ remote.serve_http(mcp, remote.http_config(args))
299
+ except remote.ConfigError as exc:
300
+ parser.exit(2, f"mcp-server-harmony: {exc}\n")
301
+ else:
302
+ mcp.run() # stdio: JSON-RPC over stdin/stdout