diff --git a/src/macos_mcp/__main__.py b/src/macos_mcp/__main__.py
index dd4a891..93e3c43 100644
--- a/src/macos_mcp/__main__.py
+++ b/src/macos_mcp/__main__.py
@@ -39,6 +39,7 @@
import logging
import os
from pathlib import Path
+import plistlib
import secrets
import shutil
import socket
@@ -936,28 +937,15 @@ def _resolve_program() -> list[str]:
def _build_plist(program_args: list[str]) -> str:
log_out = CONFIG_DIR / "server.log"
log_err = CONFIG_DIR / "server.error.log"
- args_xml = "\n".join(f" {a}" for a in program_args)
- return f"""\
-
-
-
-
- Label
- {_AGENT_LABEL}
- ProgramArguments
-
-{args_xml}
-
- RunAtLoad
-
- KeepAlive
-
- StandardOutPath
- {log_out}
- StandardErrorPath
- {log_err}
-
-"""
+ payload = {
+ "Label": _AGENT_LABEL,
+ "ProgramArguments": program_args,
+ "RunAtLoad": True,
+ "KeepAlive": True,
+ "StandardOutPath": str(log_out),
+ "StandardErrorPath": str(log_err),
+ }
+ return plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=False).decode("utf-8")
def _launchctl(*args: str) -> subprocess.CompletedProcess:
diff --git a/tests/test_launchd_plist.py b/tests/test_launchd_plist.py
new file mode 100644
index 0000000..e7cf515
--- /dev/null
+++ b/tests/test_launchd_plist.py
@@ -0,0 +1,21 @@
+import plistlib
+
+import macos_mcp.__main__ as server
+
+
+def test_build_plist_escapes_special_characters(mocker, tmp_path):
+ config_dir = tmp_path / "config & logs "
+ mocker.patch.object(server, "CONFIG_DIR", config_dir)
+ args = ["/tmp/tool & helper", "serve", "--label=", "a>b"]
+
+ rendered = server._build_plist(args)
+ parsed = plistlib.loads(rendered.encode("utf-8"))
+
+ assert parsed == {
+ "Label": "com.macos-mcp.server",
+ "ProgramArguments": args,
+ "RunAtLoad": True,
+ "KeepAlive": True,
+ "StandardOutPath": str(config_dir / "server.log"),
+ "StandardErrorPath": str(config_dir / "server.error.log"),
+ }