feat: switch dynamic MCP container targets
This commit is contained in:
@@ -11,6 +11,7 @@ import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
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:
|
||||
"""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)
|
||||
wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh"
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
profile = normalize_env_profile(args.profile)
|
||||
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.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)
|
||||
for kind in ("tmux", "nvim"):
|
||||
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).")
|
||||
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_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True)
|
||||
|
||||
Reference in New Issue
Block a user