feat: switch dynamic MCP container targets

This commit is contained in:
user
2026-07-17 08:24:37 +02:00
parent 1a98c14654
commit 1e1c124c9f
2 changed files with 259 additions and 2 deletions
+214 -2
View File
@@ -11,6 +11,7 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import stat
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
@@ -3833,7 +3834,17 @@ def run_probe(config: WorkspaceConfig, args: argparse.Namespace) -> None:
def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None: def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None:
"""Replace stemctl with the MCP server bound to one verified instance.""" """Manage the dynamic MCP target or serve one explicitly bound instance."""
if args.mcp_kind == "list":
print_mcp_instances(config)
return
if args.mcp_kind == "select":
select_mcp_instance(config, args.selector)
return
if args.mcp_kind == "status":
print_mcp_selection(config)
return
env_script = ensure_env_tool(config) env_script = ensure_env_tool(config)
wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh" wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh"
if not wrapper.is_file(): if not wrapper.is_file():
@@ -3848,6 +3859,201 @@ def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None:
os.execvpe(str(wrapper), [str(wrapper), args.instance], env) os.execvpe(str(wrapper), [str(wrapper), args.instance], env)
def mcp_selection_root(config: WorkspaceConfig) -> Path:
"""Return the short, stable host path used by generic MCP providers."""
return config.socket_root / "mcp-selected"
def mcp_registry_records(config: WorkspaceConfig) -> list[dict]:
registry_root = config.socket_root / "registry"
records: list[dict] = []
for path in sorted(registry_root.glob("*.json")):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(record, dict):
record["_registry_path"] = str(path)
records.append(record)
return records
def is_unix_socket(path: Path) -> bool:
try:
return stat.S_ISSOCK(path.stat().st_mode)
except OSError:
return False
def probe_mcp_socket_servers(record: dict, nvim_socket: Path, tmux_socket: Path) -> None:
runtime = str(record["runtime"])
container_name = str(record["container_name"])
probes = (
(
"Neovim",
[runtime, "exec", container_name, "nvim", "--server", str(nvim_socket), "--remote-expr", "1"],
),
(
"tmux",
[runtime, "exec", container_name, "tmux", "-S", str(tmux_socket), "list-sessions"],
),
)
failures: list[str] = []
for name, command in probes:
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=3,
)
except subprocess.TimeoutExpired:
failures.append(f"{name}: connection timed out")
continue
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip().splitlines()
failures.append(f"{name}: {detail[-1] if detail else 'connection failed'}")
if failures:
raise SystemExit("MCP socket files exist, but their servers are not live:\n" + "\n".join(failures))
def validated_mcp_record(record: dict) -> tuple[dict, Path, Path]:
required = ("runtime", "container_name", "container_id", "instance_key", "socket_dir")
missing = [key for key in required if not str(record.get(key, "")).strip()]
if missing:
raise SystemExit(f"Incomplete MCP registry ({', '.join(missing)}): {record.get('_registry_path', '?')}")
runtime = str(record["runtime"])
container_name = str(record["container_name"])
expected_id = str(record["container_id"])
expected_instance_key = str(record["instance_key"])
if shutil.which(runtime) is None:
raise SystemExit(f"Recorded container runtime is unavailable: {runtime}")
inspected = subprocess.run(
[runtime, "inspect", container_name],
check=False,
capture_output=True,
text=True,
)
if inspected.returncode != 0:
raise SystemExit(f"Container is not available: {container_name}")
try:
inspection = json.loads(inspected.stdout)[0]
except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error:
raise SystemExit(f"Cannot inspect container identity: {container_name}") from error
actual_id = str(inspection.get("Id", ""))
labels = inspection.get("Config", {}).get("Labels") or {}
actual_instance_key = str(labels.get("edu.stem.instance-key", ""))
if actual_id != expected_id or actual_instance_key != expected_instance_key:
raise SystemExit(f"Stale MCP registry rejected for {container_name} (container identity changed).")
socket_dir = Path(str(record["socket_dir"]))
nvim_socket = socket_dir / "n.sock"
tmux_socket = socket_dir / "t.sock"
missing_sockets = [str(path) for path in (nvim_socket, tmux_socket) if not is_unix_socket(path)]
if missing_sockets:
raise SystemExit("MCP sockets are not live:\n" + "\n".join(missing_sockets))
probe_mcp_socket_servers(record, nvim_socket, tmux_socket)
return record, nvim_socket, tmux_socket
def resolve_mcp_record(config: WorkspaceConfig, selector: str) -> dict:
selector = selector.strip()
if not selector:
raise SystemExit("MCP selector cannot be empty.")
matches: list[dict] = []
for record in mcp_registry_records(config):
container_id = str(record.get("container_id", ""))
if selector in {str(record.get("instance", "")), str(record.get("container_name", ""))}:
matches.append(record)
elif len(selector) >= 12 and container_id.startswith(selector):
matches.append(record)
unique = {str(record.get("container_id", "")): record for record in matches}
if not unique:
raise SystemExit(
f"No registered MCP container matches {selector!r}. "
"Use 'stemctl mcp list' to see available instances."
)
if len(unique) > 1:
names = ", ".join(sorted(str(record.get("instance", "?")) for record in unique.values()))
raise SystemExit(f"Ambiguous MCP selector {selector!r}: {names}")
return next(iter(unique.values()))
def select_mcp_instance(config: WorkspaceConfig, selector: str) -> None:
record, nvim_socket, tmux_socket = validated_mcp_record(resolve_mcp_record(config, selector))
container_id = str(record["container_id"])
cid12 = container_id[:12]
selection_root = mcp_selection_root(config)
target_root = selection_root / "targets" / cid12
target_root.mkdir(parents=True, exist_ok=True)
os.chmod(selection_root, 0o700)
os.chmod(selection_root / "targets", 0o700)
os.chmod(target_root, 0o700)
for link, target in ((target_root / "n.sock", nvim_socket), (target_root / "t.sock", tmux_socket)):
temporary = link.with_name(f".{link.name}.{os.getpid()}")
temporary.unlink(missing_ok=True)
temporary.symlink_to(target)
os.replace(temporary, link)
metadata = {
key: record.get(key, "")
for key in ("runtime", "container_name", "container_id", "profile", "target", "instance", "instance_key", "thread_key", "socket_dir")
}
metadata_path = target_root / "selection.json"
temporary_metadata = metadata_path.with_name(f".{metadata_path.name}.{os.getpid()}")
temporary_metadata.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
os.replace(temporary_metadata, metadata_path)
current = selection_root / "current"
temporary_current = selection_root / f".current.{os.getpid()}"
temporary_current.unlink(missing_ok=True)
temporary_current.symlink_to(Path("targets") / cid12)
os.replace(temporary_current, current)
print(f"instance\t{record.get('instance', '')}")
print(f"container\t{record.get('container_name', '')}")
print(f"container_id\t{container_id}")
print(f"nvim_socket\t{current / 'n.sock'}")
print(f"tmux_socket\t{current / 't.sock'}")
def print_mcp_selection(config: WorkspaceConfig) -> None:
current = mcp_selection_root(config) / "current"
metadata_path = current / "selection.json"
try:
record = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise SystemExit("No dynamic MCP container is selected. Use 'stemctl mcp select INSTANCE'.") from error
record, _, _ = validated_mcp_record(record)
print(f"instance\t{record.get('instance', '')}")
print(f"container\t{record.get('container_name', '')}")
print(f"container_id\t{record.get('container_id', '')}")
print(f"nvim_socket\t{current / 'n.sock'}")
print(f"tmux_socket\t{current / 't.sock'}")
def print_mcp_instances(config: WorkspaceConfig) -> None:
print("container_id\tinstance\tprofile\tcontainer\tsockets")
for record in sorted(mcp_registry_records(config), key=lambda item: str(item.get("instance", ""))):
socket_dir = Path(str(record.get("socket_dir", "")))
live = False
if is_unix_socket(socket_dir / "n.sock") and is_unix_socket(socket_dir / "t.sock"):
try:
validated_mcp_record(record)
live = True
except SystemExit:
pass
print(
f"{str(record.get('container_id', ''))[:12]}\t{record.get('instance', '')}\t"
f"{record.get('profile', '')}\t{record.get('container_name', '')}\t"
f"{'ready' if live else 'not-ready'}"
)
def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None: def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None:
profile = normalize_env_profile(args.profile) profile = normalize_env_profile(args.profile)
selector_values, pane_target = split_pane_selector(args.selector, getattr(args, "pane", None)) selector_values, pane_target = split_pane_selector(args.selector, getattr(args, "pane", None))
@@ -4301,11 +4507,17 @@ def build_parser() -> argparse.ArgumentParser:
probe_list_parser = probe_subparsers.add_parser("list", help="List usable probe/target devices without sudo.") probe_list_parser = probe_subparsers.add_parser("list", help="List usable probe/target devices without sudo.")
probe_list_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") probe_list_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
mcp_parser = subparsers.add_parser("mcp", help="Run the container-bundled MCP server for one logical instance.") mcp_parser = subparsers.add_parser("mcp", help="Select an MCP container or run its bundled MCP server.")
mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_kind", required=True) mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_kind", required=True)
for kind in ("tmux", "nvim"): for kind in ("tmux", "nvim"):
kind_parser = mcp_subparsers.add_parser(kind, help=f"Serve {kind} tools over MCP stdio.") kind_parser = mcp_subparsers.add_parser(kind, help=f"Serve {kind} tools over MCP stdio.")
kind_parser.add_argument("instance", help="Stable logical instance name (required to avoid cross-container selection).") kind_parser.add_argument("instance", help="Stable logical instance name (required to avoid cross-container selection).")
mcp_select_parser = mcp_subparsers.add_parser(
"select", help="Atomically point dynamic tmux and Neovim MCP sockets at one container."
)
mcp_select_parser.add_argument("selector", help="Logical instance, container name, or at least 12 ID characters.")
mcp_subparsers.add_parser("status", help="Show and validate the dynamically selected MCP container.")
mcp_subparsers.add_parser("list", help="List registered container instances and socket readiness.")
tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.") tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.")
tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True) tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True)
+45
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import io import io
import os import os
import socket
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
@@ -289,6 +290,50 @@ class StemctlContractTests(unittest.TestCase):
self.assertEqual(args.mcp_kind, "nvim") self.assertEqual(args.mcp_kind, "nvim")
self.assertEqual(args.instance, "hazard3-inf-bss-task1") self.assertEqual(args.instance, "hazard3-inf-bss-task1")
def test_mcp_select_accepts_instance_or_container_id(self) -> None:
parser = rvctl.build_parser()
by_instance = parser.parse_args(["mcp", "select", "rp2350-pointers-final"])
by_container = parser.parse_args(["mcp", "select", "3aca2c1c4c7a"])
self.assertEqual(by_instance.mcp_kind, "select")
self.assertEqual(by_instance.selector, "rp2350-pointers-final")
self.assertEqual(by_container.selector, "3aca2c1c4c7a")
def test_mcp_select_switches_both_sockets_with_one_current_symlink(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
socket_dir = root / "source"
socket_dir.mkdir()
nvim_server = socket.socket(socket.AF_UNIX)
tmux_server = socket.socket(socket.AF_UNIX)
try:
nvim_server.bind(str(socket_dir / "n.sock"))
tmux_server.bind(str(socket_dir / "t.sock"))
config = SimpleNamespace(socket_root=root)
record = {
"runtime": "podman",
"container_name": "stem-rp2350-example",
"container_id": "3aca2c1c4c7a" + "0" * 52,
"profile": "rp2350",
"target": "rp2350-rv",
"instance": "rp2350-example",
"instance_key": "example000001",
"thread_key": "thread000001",
"socket_dir": str(socket_dir),
}
with mock.patch.object(rvctl, "resolve_mcp_record", return_value=record), mock.patch.object(
rvctl,
"validated_mcp_record",
return_value=(record, socket_dir / "n.sock", socket_dir / "t.sock"),
), redirect_stdout(io.StringIO()):
rvctl.select_mcp_instance(config, "rp2350-example")
current = root / "mcp-selected" / "current"
self.assertEqual(os.readlink(current), "targets/3aca2c1c4c7a")
self.assertEqual((current / "n.sock").resolve(), socket_dir / "n.sock")
self.assertEqual((current / "t.sock").resolve(), socket_dir / "t.sock")
finally:
nvim_server.close()
tmux_server.close()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()