From 44ffb1ca5aa008f1d9316605ae675ba62ca940bd Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 9 Oct 2026 14:52:45 +0100 Subject: [PATCH] fix(macos): forward launcher CLI arguments and preserve MCP stdio --- .github/workflows/release.yml | 6 +++ docs/automation/mcp_handshake.py | 90 ++++++++++++++++++++++++++++++++ src/bin/ocs_launcher.rs | 22 ++++++++ 3 files changed, 118 insertions(+) create mode 100644 docs/automation/mcp_handshake.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79e07fb89..55a752b0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -511,6 +511,12 @@ jobs: codesign -d --entitlements - --xml OpenCADStudio.app/Contents/PlugIns/DWGThumbnail.appex \ | python3 -c 'import io, plistlib, sys; ent = plistlib.load(io.BytesIO(sys.stdin.buffer.read())); print("entitlements:", ent); assert ent.get("com.apple.security.app-sandbox") is True' + # Exercise CFBundleExecutable, not just the inner application: the + # Finder launcher must preserve --mcp and the client's stdio pipes. + - name: Verify packaged MCP handshake + timeout-minutes: 2 + run: python3 docs/automation/mcp_handshake.py OpenCADStudio.app/Contents/MacOS/OpenCADStudio + - name: Create .dmg run: | set +e diff --git a/docs/automation/mcp_handshake.py b/docs/automation/mcp_handshake.py new file mode 100644 index 000000000..7c91472cd --- /dev/null +++ b/docs/automation/mcp_handshake.py @@ -0,0 +1,90 @@ +"""Bounded stdio regression probe; point this at the packaged launcher on macOS.""" +import json +import os +from pathlib import Path +import queue +import signal +import subprocess +import sys +import tempfile +import threading + + +def probe(command: list[str], timeout: float = 10) -> None: + # A separate process group lets a failed probe clean up a broken launcher + # and any GUI child it incorrectly spawned. Never touch existing editors. + with tempfile.TemporaryFile() as stderr: + process = subprocess.Popen( + [*command, "--mcp"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr, + text=True, encoding="utf-8", start_new_session=os.name == "posix", + env={**os.environ, "OCS_SKIP_SCHEMA_SYNC": "1"}, + ) + lines = queue.Queue() + + def read_stdout() -> None: + try: + for line in process.stdout: + lines.put(line) + finally: + lines.put(None) + + threading.Thread(target=read_stdout, daemon=True).start() + + def request(request_id: int, method: str, params: dict) -> dict: + process.stdin.write(json.dumps({ + "jsonrpc": "2.0", "id": request_id, + "method": method, "params": params, + }) + "\n") + process.stdin.flush() + # stdin stays open while waiting: a server buffering until EOF + # must fail this test just like the launcher that ignores --mcp. + try: + line = lines.get(timeout=timeout) + except queue.Empty as error: + raise AssertionError(f"Timed out waiting for {method}") from error + assert line is not None, f"stdout closed before {method} responded" + response = json.loads(line) + assert response["jsonrpc"] == "2.0", response + assert response["id"] == request_id, response + assert "error" not in response, response + return response["result"] + + try: + initialized = request(1, "initialize", { + "protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "packaged-handshake", "version": "1"}, + }) + assert initialized["protocolVersion"] == "2025-11-25", initialized + assert initialized["serverInfo"]["name"] == "OpenCADStudio", initialized + process.stdin.write( + '{"jsonrpc":"2.0","method":"notifications/initialized"}\n' + ) + process.stdin.flush() + tools = request(2, "tools/list", {}) + assert {tool["name"] for tool in tools["tools"]} == { + "ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture", + }, tools + request(3, "ping", {}) + process.stdin.close() + assert process.wait(timeout=timeout) == 0, process.returncode + finally: + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.poll() is None: + process.kill() + process.wait(timeout=5) + process.stdin.close() + process.stdout.close() + stderr.seek(0) + diagnostics = stderr.read().decode("utf-8", errors="replace") + if diagnostics: + print(diagnostics, file=sys.stderr, end="") + + +if __name__ == "__main__": + probe([str(Path(sys.argv[1]).resolve())]) + print("MCP initialize, tools/list, ping and EOF shutdown passed") diff --git a/src/bin/ocs_launcher.rs b/src/bin/ocs_launcher.rs index 7022f2602..0fa891eba 100644 --- a/src/bin/ocs_launcher.rs +++ b/src/bin/ocs_launcher.rs @@ -151,6 +151,28 @@ impl Delegate { #[cfg(target_os = "macos")] fn main() { + // Explicit CLI invocations must reach the real argument parser before + // AppKit starts. In particular, --mcp needs this process's stdin/stdout, + // and --serve, --export, --help and --version must not launch a bare GUI. + // Keep arguments as OsString so file names need not be valid UTF-8. + // Finder may supply only its legacy process serial number; that still + // belongs to the persistent document-open relay below. + let args: Vec<_> = std::env::args_os().skip(1).collect(); + let finder_launch = + args.is_empty() || (args.len() == 1 && args[0].as_encoded_bytes().starts_with(b"-psn_")); + if !finder_launch { + use std::os::unix::process::CommandExt; + + // Replacing the process preserves stdio, PID, signals and exit status. + // Do not exec on a normal Finder launch: its AppKit delegate must stay + // alive to receive subsequent document-open events. + let error = std::process::Command::new(real_binary_path()) + .args(args) + .exec(); + eprintln!("OpenCADStudio launcher: failed to execute the application: {error}"); + std::process::exit(1); + } + let mtm = MainThreadMarker::new().expect("the launcher must run on the main thread"); let app = NSApplication::sharedApplication(mtm); // No window, no Dock icon, no menu bar — this process only relays.