diff --git a/README.md b/README.md index 03a923a..f139909 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ docker buildx build -f mcp-local/Dockerfile -t armlimited/arm-mcp . --load Choose the configuration that matches your MCP client: -The examples below include the optional Docker arguments required for **Arm Performix**. These SSH-related settings are only needed when you want the MCP server to run remote commands on a target device through Arm Performix. If you are not using Arm Performix, you can omit the `-e` and SSH `-v` lines. +The examples below include the optional Docker arguments required for **Arm Performix**. These SSH-related settings are only needed when you want the MCP server to run remote commands on a target device through Arm Performix. If you are not using Arm Performix, you can omit the SSH `-v` lines. #### Claude Code @@ -66,8 +66,6 @@ Add to `.mcp.json` in your project: "-v", "/path/to/your/workspace:/workspace", "-v", "/path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro", "-v", "/path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro", - "-e", "SSH_KEY_PATH=/run/keys/ssh-key.pem", - "-e", "KNOWN_HOSTS_PATH=/run/keys/known_hosts", "armlimited/arm-mcp" ] } @@ -92,8 +90,6 @@ Add to `.vscode/mcp.json` in your project, or globally at `~/Library/Application "-v", "/path/to/your/workspace:/workspace", "-v", "/path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro", "-v", "/path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro", - "-e", "SSH_KEY_PATH=/run/keys/ssh-key.pem", - "-e", "KNOWN_HOSTS_PATH=/run/keys/known_hosts", "armlimited/arm-mcp" ] } @@ -121,8 +117,6 @@ Add to `~/.kiro/settings/mcp.json`: "-v", "/path/to/your/workspace:/workspace", "-v", "/path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro", "-v", "/path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro", - "-e", "SSH_KEY_PATH=/run/keys/ssh-key.pem", - "-e", "KNOWN_HOSTS_PATH=/run/keys/known_hosts", "armlimited/arm-mcp" ], "timeout": 60000 @@ -149,8 +143,6 @@ Add to `.gemini/settings.json` in your project root: "-v", "/path/to/your/workspace:/workspace", "-v", "/path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro", "-v", "/path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro", - "-e", "SSH_KEY_PATH=/run/keys/ssh-key.pem", - "-e", "KNOWN_HOSTS_PATH=/run/keys/known_hosts", "armlimited/arm-mcp" ] } @@ -170,13 +162,11 @@ args = [ "-v", "/path/to/your/workspace:/workspace", "-v", "/path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro", "-v", "/path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro", - "-e", "SSH_KEY_PATH=/run/keys/ssh-key.pem", - "-e", "KNOWN_HOSTS_PATH=/run/keys/known_hosts", "armlimited/arm-mcp" ] ``` -**Note**: Replace `/path/to/your/workspace` with the actual path to your project directory that you want the MCP server to access. If you are enabling Arm Performix, also replace the `/path/to/your/ssh/private_key` and `/path/to/your/ssh/known_hosts` paths with your local files. +**Note**: Replace `/path/to/your/workspace` with the actual path to your project directory that you want the MCP server to access. If you are enabling Arm Performix, also replace the `/path/to/your/ssh/private_key` and `/path/to/your/ssh/known_hosts` paths with your local files. The MCP container auto-discovers files mounted under `/run/keys`, as shown in the configs above. ### 3. Restart Your MCP Client diff --git a/mcp-local/performix-deployment-scenarios.md b/mcp-local/performix-deployment-scenarios.md index 29f9ce0..fa1f0f4 100644 --- a/mcp-local/performix-deployment-scenarios.md +++ b/mcp-local/performix-deployment-scenarios.md @@ -34,12 +34,14 @@ Validate SSH access: ssh -i /path/to/key @ ``` -### 2. MCP server runtime must have SSH config +### 2. MCP server runtime must have the SSH files mounted -Your MCP server/container configuration must include: +Your MCP server/container configuration must mount: -- `SSH_KEY_PATH` -- `KNOWN_HOSTS_PATH` +- the private key file under `/run/keys` +- the `known_hosts` file under `/run/keys` + +The MCP container will discover these per-file mounts from `/proc/self/mounts` automatically. ### 3. Target workload must be runnable @@ -138,6 +140,6 @@ flowchart LR If `apx_recipe_run` fails: - Verify SSH key permissions (`chmod 600 /path/to/key`). -- Verify `SSH_KEY_PATH` and `KNOWN_HOSTS_PATH` in MCP config. +- Verify the SSH key and `known_hosts` files are mounted into `/run/keys`. - Verify target IP, username, and command path. -- Verify selected recipe is supported in your target environment. \ No newline at end of file +- Verify selected recipe is supported in your target environment. diff --git a/mcp-local/server.py b/mcp-local/server.py index b7fb2e0..fa079de 100644 --- a/mcp-local/server.py +++ b/mcp-local/server.py @@ -19,7 +19,13 @@ from utils.config import METADATA_PATH, USEARCH_INDEX_PATH, MODEL_NAME, SUPPORTED_SCANNERS, DEFAULT_ARCH from utils.search_utils import build_bm25_index, deduplicate_urls, hybrid_search, load_metadata, load_usearch_index from utils.docker_utils import check_docker_image_architectures -from utils.apx import prepare_target, run_workload, get_results +from utils.apx import ( + prepare_target, + run_workload, + get_results, + resolve_apx_ssh_mount_env, + build_apx_ssh_mount_help, +) from utils.migrate_ease_utils import run_migrate_ease_scan from utils.skopeo_tool import skopeo_help, skopeo_inspect from utils.llvm_mca_tool import mca_help, llvm_mca_analyze @@ -292,20 +298,23 @@ def apx_recipe_run(cmd:str, remote_ip_addr:str, remote_usr:str, recipe:str="code }, ) apx_dir = os.environ.get("APX_HOME", "/opt/apx") - key_path = os.getenv("SSH_KEY_PATH") - known_hosts_path = os.getenv("KNOWN_HOSTS_PATH") + ssh_mount_env = resolve_apx_ssh_mount_env() + key_path = ssh_mount_env["key_path"] + known_hosts_path = ssh_mount_env["known_hosts_path"] if not key_path or not known_hosts_path: + mount_help = build_apx_ssh_mount_help( + ssh_mount_env["mount_targets"], + known_hosts_reason=ssh_mount_env.get("known_hosts_reason"), + key_reason=ssh_mount_env.get("key_reason"), + ) return { "status": "error", "recipe": recipe, "stage": "config_validation", "message": "Missing SSH configuration for APX target access.", - "suggestion": "Set SSH_KEY_PATH and KNOWN_HOSTS_PATH in the MCP docker run configuration, then retry.", - "details": ( - "SSH_KEY_PATH and KNOWN_HOSTS_PATH environment variables must be set in the docker run " - "command in the MCP config file to mount in the container to use APX." - ), + "suggestion": mount_help["suggestion"], + "details": mount_help["details"], } target_add_res = prepare_target(remote_ip_addr, remote_usr, key_path, apx_dir) diff --git a/mcp-local/utils/apx.py b/mcp-local/utils/apx.py index b25e788..721c51b 100644 --- a/mcp-local/utils/apx.py +++ b/mcp-local/utils/apx.py @@ -9,6 +9,8 @@ QUERY_REGISTRY_PATH = Path(__file__).resolve().parent.parent / "sql" / "queries.sql" ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") +RUN_KEYS_DIR = Path("/run/keys") +PROC_MOUNTS_PATH = Path("/proc/self/mounts") def load_recipe_query_map(sql_file_path: Path) -> Dict[str, Dict[str, str]]: @@ -248,6 +250,179 @@ def _build_atp_error_response( response["raw_output"] = _trim_output(raw_output) return response + +def _decode_mount_field(field: str) -> str: + return re.sub( + r"\\([0-7]{3})", + lambda match: chr(int(match.group(1), 8)), + field, + ) + + +def discover_run_keys_mounts( + mounts_path: Optional[Path] = None, + run_keys_dir: Optional[Path] = None, +) -> List[str]: + mounts_path = mounts_path or PROC_MOUNTS_PATH + run_keys_dir = run_keys_dir or RUN_KEYS_DIR + + if not mounts_path.exists(): + return [] + + mount_targets: List[str] = [] + try: + for line in mounts_path.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) < 2: + continue + target = _decode_mount_field(parts[1]) + if target == str(run_keys_dir) or target.startswith(f"{run_keys_dir}/"): + mount_targets.append(target) + except (OSError, UnicodeDecodeError): + return [] + + # Preserve input order while removing duplicates. + return list(dict.fromkeys(mount_targets)) + + +def _select_known_hosts_path(mount_targets: List[str]) -> Tuple[Optional[str], Optional[str]]: + matches = [ + path for path in mount_targets + if "known_hosts" in Path(path).name.lower().replace("-", "_") + ] + if len(matches) == 1: + return matches[0], None + if len(matches) > 1: + return None, f"Multiple known_hosts mount targets were found: {matches}." + return None, "No known_hosts mount target was found." + + +def _select_ssh_key_path( + mount_targets: List[str], + known_hosts_path: Optional[str], +) -> Tuple[Optional[str], Optional[str]]: + candidates = [path for path in mount_targets if path != known_hosts_path] + if not candidates: + return None, "No SSH key mount target was found." + if len(candidates) == 1: + return candidates[0], None + + key_like = [ + path for path in candidates + if any( + token in Path(path).name.lower() + for token in ("ssh", "key", ".pem", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa") + ) + ] + if len(key_like) == 1: + return key_like[0], None + if len(key_like) > 1: + return None, ( + f"Multiple SSH key-like mount targets were found: {key_like}. " + "Mount only one SSH private key file under /run/keys." + ) + return None, ( + f"Unable to identify the SSH private key from mount target names. " + f"Candidate non-known_hosts mounts: {candidates}." + ) + + +def _list_run_keys_files(run_keys_dir: Optional[Path] = None) -> List[str]: + run_keys_dir = run_keys_dir or RUN_KEYS_DIR + if not run_keys_dir.exists() or not run_keys_dir.is_dir(): + return [] + + return sorted( + str(path) + for path in run_keys_dir.iterdir() + if path.is_file() + ) + + +def build_apx_ssh_mount_help( + mount_targets: List[str], + run_keys_dir: Optional[Path] = None, + known_hosts_reason: Optional[str] = None, + key_reason: Optional[str] = None, +) -> Dict[str, str]: + run_keys_dir = run_keys_dir or RUN_KEYS_DIR + mount_summary = mount_targets if mount_targets else "none" + run_keys_files = _list_run_keys_files(run_keys_dir) + mount_hint = ( + "Mount the files individually as described in the README, for example " + "'-v /path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro' and " + "'-v /path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro'." + ) + + suggestion = ( + "Mount the SSH private key and known_hosts as individual files under /run/keys, then retry." + ) + resolution_reasons = [reason for reason in (known_hosts_reason, key_reason) if reason] + reason_text = "" + if resolution_reasons: + reason_text = " Resolution detail: " + " ".join(resolution_reasons) + + if mount_targets == [str(run_keys_dir)] and run_keys_files: + file_summary = run_keys_files + details = ( + "APX auto-discovers individual file mounts under /run/keys from /proc/self/mounts. " + f"No individual file mounts were discovered. " + f"Discovered mounts: {mount_summary}. Files currently present under {run_keys_dir}: {file_summary}. " + "This usually means a directory was mounted, for example '-v ~/.ssh:/run/keys:ro', so " + "auto-discovery cannot infer which file is the key and which file is known_hosts from mount data alone. " + f"{mount_hint}{reason_text}" + ) + else: + details = ( + "APX auto-discovers individual file mounts under /run/keys from /proc/self/mounts. " + f"Discovered mounts: {mount_summary}. " + "Make sure both files are mounted individually under /run/keys as described in the README, " + "for example '-v /path/to/your/ssh/private_key:/run/keys/ssh-key.pem:ro' and " + f"'-v /path/to/your/ssh/known_hosts:/run/keys/known_hosts:ro'.{reason_text}" + ) + + return { + "suggestion": suggestion, + "details": details, + } + + +def resolve_apx_ssh_mount_env() -> Dict[str, Any]: + key_path = os.getenv("SSH_KEY_PATH") + known_hosts_path = os.getenv("KNOWN_HOSTS_PATH") + mount_targets: List[str] = [] + key_reason: Optional[str] = None + known_hosts_reason: Optional[str] = None + + if key_path and known_hosts_path: + return { + "key_path": key_path, + "known_hosts_path": known_hosts_path, + "mount_targets": mount_targets, + "key_reason": key_reason, + "known_hosts_reason": known_hosts_reason, + } + + mount_targets = discover_run_keys_mounts() + + if not known_hosts_path: + known_hosts_path, known_hosts_reason = _select_known_hosts_path(mount_targets) + if known_hosts_path: + os.environ["KNOWN_HOSTS_PATH"] = known_hosts_path + + if not key_path: + key_path, key_reason = _select_ssh_key_path(mount_targets, known_hosts_path) + if key_path: + os.environ["SSH_KEY_PATH"] = key_path + + return { + "key_path": key_path, + "known_hosts_path": known_hosts_path, + "mount_targets": mount_targets, + "key_reason": key_reason, + "known_hosts_reason": known_hosts_reason, + } + def extract_run_id(output: str) -> str: if not output: return ""