diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..637db26 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,31 @@ +name: Integration Tests + +on: + push: + pull_request: + +jobs: + integration-tests: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install -r mcp-local/tests/requirements.txt + + - name: Build MCP Docker image + run: docker buildx build -f mcp-local/Dockerfile -t arm-mcp . + + - name: Run integration tests + env: + MCP_IMAGE: arm-mcp:latest + run: pytest -v mcp-local/tests/test_mcp.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01a8744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Virtual environments +venv/ +.venv/ +env/ +.env/ +embedding-generation/venv/ +embedding-generation/yaml_data +embedding-generation/info + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md index 59887d6..7453837 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ If you would prefer to use a pre-built, multi-arch image, the official image can From the root of this repository: ```bash -docker buildx build --platform linux/arm64,linux/amd64 -f mcp-local/Dockerfile -t arm-mcp mcp-local +docker buildx build --platform linux/arm64,linux/amd64 -f mcp-local/Dockerfile -t arm-mcp . ``` For a single-platform build (faster): ```bash -docker buildx build -f mcp-local/Dockerfile -t arm-mcp mcp-local +docker buildx build -f mcp-local/Dockerfile -t arm-mcp . ``` ### 2. Configure Your MCP Client @@ -119,6 +119,19 @@ After updating the configuration, restart your MCP client to load the Arm MCP se - `Dockerfile`: Multi-stage Docker build - **`embedding-generation/`**: Scripts for regenerating the knowledge base from source documents +## Integration Testing + +### Pre-requisites + +- Build the mcp server docker image +- Install the required test packages using - `pip install -r tests/requirements.txt` within the `mcp_local` directory. + +### Testing Steps + +- Run the test script - `python -m pytest -s tests/test_mcp.py` +- Check if following 2 docker containers have started - **mcp server** & **testcontainer** +- All tests should pass without any errors. Warnings can be ignored. + ## Troubleshooting ### Accessing the Container Shell diff --git a/embedding-generation/generate-chunks.py b/embedding-generation/generate-chunks.py index 7f777fe..703e213 100644 --- a/embedding-generation/generate-chunks.py +++ b/embedding-generation/generate-chunks.py @@ -424,17 +424,29 @@ def createLearningPathChunks(): learn_url = "https://learn.arm.com/" response = http_session.get(learn_url, timeout=60) soup = BeautifulSoup(response.text, 'html.parser') - for card in soup.find_all(class_='main-topic-card'): - if 'tool-install' == card.get('id'): - ig_rel_path = card.get('link') - processLearningPath(ig_rel_path,"Install Guide") + + # Process Install Guides separately (directly from /install-guides page) + processLearningPath("/install-guides", "Install Guide") + + # Find category links - main-topic-card elements are now wrapped in tags + # Look for tags that contain main-topic-card divs + for a_tag in soup.find_all('a', href=True): + card = a_tag.find(class_='main-topic-card') + if card: + cat_rel_path = a_tag.get('href') + if cat_rel_path is None or cat_rel_path.startswith('http'): + continue + # Skip non-learning-path links (like /tag/ml/ or install guides button) + if not cat_rel_path.startswith('/learning-paths/'): + continue - else: - cat_rel_path = card.get('link') - cat_response = http_session.get(learn_url+cat_rel_path, timeout=60) + cat_response = http_session.get(learn_url.rstrip('/') + cat_rel_path, timeout=60) cat_soup = BeautifulSoup(cat_response.text, 'html.parser') for lp_card in cat_soup.find_all(class_="path-card"): - lp_url = learn_url + lp_card.get('link') + lp_link = lp_card.get('link') + if lp_link is None: + continue + lp_url = learn_url.rstrip('/') + lp_link # Chunking step processLearningPath(lp_url, "Learning Path") diff --git a/mcp-local/Dockerfile b/mcp-local/Dockerfile index b48e6df..21a65ba 100644 --- a/mcp-local/Dockerfile +++ b/mcp-local/Dockerfile @@ -43,11 +43,16 @@ RUN rm -f /usr/local/bin/aperf \ /opt/arm-migration-tools/processwatch \ /opt/arm-migration-tools/papi +COPY embedding-generation/generate-chunks.py /tmp/embedding-generation/generate-chunks.py +COPY embedding-generation/local_vectorstore_creation.py /tmp/embedding-generation/local_vectorstore_creation.py +COPY embedding-generation/requirements.txt /tmp/embedding-generation/requirements.txt +COPY embedding-generation/urls-to-chunk.csv /tmp/embedding-generation/urls-to-chunk.csv + # Copy embedding data and scripts from the embedding base image -COPY --from=joestech324/mcp-embedding-base:latest /embedding-data /tmp/embedding-data +COPY --from=joestech324/mcp-embedding-base:latest /embedding-data/intrinsic_chunks /tmp/embedding-generation/intrinsic_chunks # Install dependencies for vector database generation -WORKDIR /tmp/embedding-data +WORKDIR /tmp/embedding-generation RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt # Generate vector database @@ -61,16 +66,16 @@ ENV VIRTUAL_ENV=/app/.venv \ RUN python3 -m venv "$VIRTUAL_ENV" && pip install --upgrade pip -COPY requirements.txt . +COPY mcp-local/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy generated vector database files RUN mkdir -p ./data -RUN cp /tmp/embedding-data/metadata.json ./data/ && \ - cp /tmp/embedding-data/usearch_index.bin ./data/ +RUN cp /tmp/embedding-generation/metadata.json ./data/ && \ + cp /tmp/embedding-generation/usearch_index.bin ./data/ -COPY utils/ ./utils/ -COPY server.py . +COPY mcp-local/utils/ ./utils/ +COPY mcp-local/server.py . FROM ubuntu:24.04 AS runtime diff --git a/mcp-local/tests/constants.py b/mcp-local/tests/constants.py new file mode 100644 index 0000000..0e3b6bc --- /dev/null +++ b/mcp-local/tests/constants.py @@ -0,0 +1,247 @@ +# Copyright © 2026, Arm Limited and Contributors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +MCP_DOCKER_IMAGE = "arm-mcp:latest" + +INIT_REQUEST = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "pytest", "version": "0.1"}, + }, + } + +CHECK_IMAGE_REQUEST = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "check_image", + "arguments": { + "image": "ubuntu:24.04", + "invocation_reason": ( + "Checking ARM architecture compatibility for ubuntu:24.04 " + "container image as requested by the user" + ), + }, + }, + } + +EXPECTED_CHECK_IMAGE_RESPONSE = { + "status": "success", + "message": "Image ubuntu:24.04 supports all required architectures", + "architectures": [ + "amd64", + "unknown", + "arm", + "unknown", + "arm64", + "unknown", + "ppc64le", + "unknown", + "riscv64", + "unknown", + "s390x", + "unknown", + ], + } + +CHECK_SKOPEO_REQUEST = { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "skopeo", + "arguments": { + "image": "armswdev/arm-mcp", + "invocation_reason": ( + "Checking the architecture support of the armswdev/arm-mcp container image to verify ARM compatibility as requested by the user." + ), + }, + }, + } +# Fields Architecture, Os and Status are asserted in test to avoid mismatches due to dynamic fields +EXPECTED_CHECK_SKOPEO_RESPONSE = { + "status": "ok", + "code": 0, + "stdout": "{\n \"Name\": \"docker.io/armswdev/arm-mcp\",\n \"Digest\": \"\",\n \"RepoTags\": [\n \"latest\"\n ],\n \"Created\": \"\",\n \"DockerVersion\": \"\",\n \"Labels\": {\n \"org.opencontainers.image.ref.name\": \"ubuntu\",\n \"org.opencontainers.image.version\": \"24.04\"\n },\n \"Architecture\": \"arm64\",\n \"Os\": \"linux\",\n \"Layers\": [\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\",\n \"\"\n ],\n \"LayersData\": [\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 28861712,\n \"Annotations\": null\n },\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 142025708,\n \"Annotations\": null\n },\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 107240731,\n \"Annotations\": null\n },\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 1180,\n \"Annotations\": null\n },\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 7105736,\n \"Annotations\": null\n },\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 392970938,\n \"Annotations\": null\n },\n {\n \"MIMEType\": \"application/vnd.oci.image.layer.v1.tar+gzip\",\n \"Digest\": \"\",\n \"Size\": 32,\n \"Annotations\": null\n }\n ],\n \"Env\": [\n \"PATH=/app/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\",\n \"DEBIAN_FRONTEND=noninteractive\",\n \"PYTHONUNBUFFERED=1\",\n \"PIP_NO_CACHE_DIR=1\",\n \"WORKSPACE_DIR=/workspace\",\n \"VIRTUAL_ENV=/app/.venv\"\n ]\n}\n", + "stderr": "", + "cmd": [ + "skopeo", + "inspect", + "docker://armswdev/arm-mcp" + ] +} + +CHECK_NGINX_REQUEST = { + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "knowledge_base_search", + "arguments": { + "query": "nginx performance tweaks", + }, + }, + } + +EXPECTED_CHECK_NGINX_RESPONSE = [ +"https://learn.arm.com/learning-paths/servers-and-cloud-computing/nginx_tune/tune_static_file_server", + "https://learn.arm.com/learning-paths/servers-and-cloud-computing/nginx_tune/test_optimizations", + "https://learn.arm.com/learning-paths/servers-and-cloud-computing/nginx_tune/kernel_comp_lib", + "https://learn.arm.com/learning-paths/servers-and-cloud-computing/nginx_tune/before_and_after", +"https://learn.arm.com/learning-paths/servers-and-cloud-computing/nginx_tune/tune_revprox_and_apigw" + ] + +CHECK_MIGRATE_EASE_TOOL_REQUEST = { + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "migrate_ease_scan", + "arguments": { + "scanner": "java", + }, + }, + } +'''TODO: Need to use a user-controlled repo with static example for testing to check more detailed response params. +For now, only status field is asserted in test to avoid mismatches due to dynamic fields. +Sample response below for reference - +EXPECTED_CHECK_MIGRATE_EASE_TOOL_RESPONSE = { + "status": "success", + "returncode": 0, + "command": "migrate-ease-java --march armv8-a --output /tmp/migrate_ease_java_20260126-215207.json /tmp/migrate_ease_filtered_s45ojwm1", + "ran_from": "/app", + "target": "/workspace (filtered)", + "stdout": "[Java] Loading of check_points.yaml took 0.002821 seconds.\n[Java] Initialization of checkpoints took 0.000328 seconds.\nNo issue found.\n", + "stderr": "", + "output_file": "/tmp/migrate_ease_java_20260126-215207.json", + "output_format": "json", + "workspace_listing": [ + "invocation_reasons.yaml" + ], + "excluded_items": [], + "excluded_count": 0, + "parsed_results": { + "branch": None, + "commit": None, + "errors": [], + "file_summary": { + "jar": { + "count": 0, + "fileName": "Jar", + "loc": 0 + }, + "java": { + "count": 0, + "fileName": "java", + "loc": 0 + }, + "pom": { + "count": 0, + "fileName": "POM", + "loc": 0 + } + }, + "git_repo": None, + "issue_summary": { + "Error": { + "count": 0, + "des": "Exception encountered by the code scanning tool during the scanning process, not an issue with the code logic itself. User can ignore it." + }, + "JarIssue": { + "count": 0, + "des": "JAR package does not support target arch. Need to rebuild or upgrade." + }, + "JavaSourceIssue": { + "count": 0, + "des": "Java source file contains native call that may need modify/rebuild for target arch." + }, + "OtherIssue": { + "count": 0, + "des": "Issues exceeding the limit will be categorized as OtherIssue. when the issue count limit option is enabled" + }, + "PomIssue": { + "count": 0, + "des": "Pom imports java artifact that does not support target arch." + } + }, + "issue_type_config": None, + "issues": [], + "language_type": "java", + "march": "armv8-a", + "output": None, + "progress": True, + "quiet": False, + "remarks": [], + "root_directory": "/tmp/migrate_ease_filtered_s45ojwm1", + "source_dirs": [], + "source_files": [], + "target_os": "OpenAnolis", + "total_issue_count": 0 + }, + "output_file_deleted": True +}''' + +EXPECTED_CHECK_MIGRATE_EASE_TOOL_RESPONSE_STATUS = "success" + +CHECK_SYSREPORT_TOOL_REQUEST = { + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "sysreport_instructions", + "arguments": { + "invocation_reason": "Providing instructions for using the sysreport tool as requested by the user.", + }, + }, + } +EXPECTED_CHECK_SYSREPORT_TOOL_RESPONSE = { + "instructions": "\n# SysReport Installation and Usage\n\n## Installation\n```bash\ngit clone https://github.com/ArmDeveloperEcosystem/sysreport.git\ncd sysreport\n```\n\n## Usage\n```bash\npython3 sysreport.py\n```\n\n## What SysReport Does\n- Gathers comprehensive system information including architecture, CPU, memory, and hardware details\n- Useful for diagnosing system issues or understanding system capabilities\n- Provides detailed hardware and software configuration data\n\n## Note\nRun these commands directly on your host system (not in a container) to get accurate system information.\n", + "repository": "https://github.com/ArmDeveloperEcosystem/sysreport.git", + "usage_command": "python3 sysreport.py", + "note": "This tool must be run on the host system to provide accurate system information." +} + +CHECK_MCA_TOOL_REQUEST = { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "mca", + "arguments": { + "input_path": "/workspace/tests/sum_test.s", + "invocation_reason": "User requested to run the MCA tool on the ARM assembly file sum_test.s to analyze its performance characteristics, using the correct workspace path" + }, + }, + } + +'''TODO: Need to use a user-controlled repo with static example for testing to check more detailed response params. +For now, only status field is asserted in test to avoid mismatches due to dynamic fields. +Sample response below for reference - +EXPECTED_CHECK_MCA_TOOL_RESPONSE = { + "status": "ok", + "code": 0, + "stdout": "Iterations: 100\nInstructions: 500\nTotal Cycles: 501\nTotal uOps: 500\n\nDispatch Width: 3\nuOps Per Cycle: 1.00\nIPC: 1.00\nBlock RThroughput: 1.7\n\n\nInstruction Info:\n[1]: #uOps\n[2]: Latency\n[3]: RThroughput\n[4]: MayLoad\n[5]: MayStore\n[6]: HasSideEffects (U)\n\n[1] [2] [3] [4] [5] [6] Instructions:\n 1 1 0.33 add\tx1, x1, x2\n 1 1 0.33 add\tx1, x1, x3\n 1 1 0.33 add\tx1, x1, x4\n 1 1 0.33 add\tx1, x1, x5\n 1 1 0.33 add\tx1, x1, x6\n\n\nResources:\n[0] - CortexA510UnitALU0\n[1.0] - CortexA510UnitALU12\n[1.1] - CortexA510UnitALU12\n[2] - CortexA510UnitB\n[3] - CortexA510UnitDiv\n[4] - CortexA510UnitLd1\n[5] - CortexA510UnitLdSt\n[6] - CortexA510UnitMAC\n[7] - CortexA510UnitPAC\n[8] - CortexA510UnitVALU0\n[9] - CortexA510UnitVALU1\n[10.0] - CortexA510UnitVMAC\n[10.1] - CortexA510UnitVMAC\n[11] - CortexA510UnitVMC\n\n\nResource pressure per iteration:\n[0] [1.0] [1.1] [2] [3] [4] [5] [6] [7] [8] [9] [10.0] [10.1] [11] \n - 2.50 2.50 - - - - - - - - - - - \n\nResource pressure by instruction:\n[0] [1.0] [1.1] [2] [3] [4] [5] [6] [7] [8] [9] [10.0] [10.1] [11] Instructions:\n - 0.50 0.50 - - - - - - - - - - - add\tx1, x1, x2\n - 0.50 0.50 - - - - - - - - - - - add\tx1, x1, x3\n - 0.50 0.50 - - - - - - - - - - - add\tx1, x1, x4\n - 0.50 0.50 - - - - - - - - - - - add\tx1, x1, x5\n - 0.50 0.50 - - - - - - - - - - - add\tx1, x1, x6\n", + "stderr": "", + "cmd": [ + "llvm-mca", + "/workspace/tests/sum_test.s" + ] + }''' + +EXPECTED_CHECK_MCA_TOOL_RESPONSE_STATUS = "ok" diff --git a/mcp-local/tests/requirements.txt b/mcp-local/tests/requirements.txt new file mode 100644 index 0000000..2e2dc05 --- /dev/null +++ b/mcp-local/tests/requirements.txt @@ -0,0 +1,2 @@ +testcontainers +pytest \ No newline at end of file diff --git a/mcp-local/tests/sum_test.s b/mcp-local/tests/sum_test.s new file mode 100644 index 0000000..abf14c9 --- /dev/null +++ b/mcp-local/tests/sum_test.s @@ -0,0 +1,19 @@ +# Copyright © 2026, Arm Limited and Contributors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +add x1, x1, x2 +add x1, x1, x3 +add x1, x1, x4 +add x1, x1, x5 +add x1, x1, x6 \ No newline at end of file diff --git a/mcp-local/tests/test_mcp.py b/mcp-local/tests/test_mcp.py new file mode 100644 index 0000000..0aa3cae --- /dev/null +++ b/mcp-local/tests/test_mcp.py @@ -0,0 +1,170 @@ +# Copyright © 2026, Arm Limited and Contributors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import constants +import os +import time +from pathlib import Path + +import pytest +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + +def _encode_mcp_message(payload: dict) -> bytes: + # FastMCP stdio expects raw JSON per message (newline-delimited). + return (json.dumps(payload) + "\n").encode("utf-8") + + +def _read_docker_frame(sock, timeout: float) -> bytes: + deadline = time.time() + timeout + header = b"" + while len(header) < 8: + if time.time() > deadline: + raise TimeoutError("Timed out waiting for docker frame header.") + chunk = sock.recv(8 - len(header)) + if not chunk: + time.sleep(0.01) + continue + header += chunk + + # Docker frame format can be either in multiplexed (each frame prefixed with an 8-byte header) or raw mode. + # byte 0: stream type (0x01 = stdout, 0x02 = stderr) + # bytes 1-3: Reserved, always \x00\x00\x00 + # bytes 4-7: Payload size (big-endian uint32) + # This checks on header if frame is multiplexed or in raw mode. If bytes 1-3 are not zeros, the data is likely raw/unframed output, + # so the function returns it directly instead of trying to parse frame headers and extract payloads + if header[1:4] != b"\x00\x00\x00": + return header + + size = int.from_bytes(header[4:8], "big") + payload = b"" + while len(payload) < size: + if time.time() > deadline: + raise TimeoutError("Timed out waiting for docker frame payload.") + chunk = sock.recv(size - len(payload)) + if not chunk: + time.sleep(0.01) + continue + payload += chunk + return payload + + +def _read_mcp_message(sock, timeout: float = 10.0) -> dict: + deadline = time.time() + timeout + buffer = b"" + while True: + if time.time() > deadline: + raise TimeoutError("Timed out waiting for MCP response line.") + try: + frame = _read_docker_frame(sock, timeout) + except TimeoutError: + raise + buffer += frame + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + if not line: + continue + try: + return json.loads(line.decode("utf-8")) + except json.JSONDecodeError: + idx = line.find(b"{") + if idx != -1: + try: + return json.loads(line[idx:].decode("utf-8")) + except json.JSONDecodeError: + continue + +def test_mcp_stdio_transport_responds(): + image = os.getenv("MCP_IMAGE", constants.MCP_DOCKER_IMAGE) + repo_root = Path(__file__).resolve().parents[1] + print("\n***repo root: ", repo_root) + with ( + DockerContainer(image) + .with_volume_mapping(str(repo_root), "/workspace") + .with_kwargs(stdin_open=True, tty=False) + ) as container: + wait_for_logs(container, "Starting MCP server", timeout=60) + socket_wrapper = container.get_wrapped_container().attach_socket( + params={"stdin": 1, "stdout": 1, "stderr": 1, "stream": 1} + ) + raw_socket = socket_wrapper._sock + raw_socket.settimeout(10) + + raw_socket.sendall(_encode_mcp_message(constants.INIT_REQUEST)) + response = _read_mcp_message(raw_socket, timeout=20) + + #Check Container Init Test + assert response.get("id") == 1, "Test Failed: MCP initialize response id mismatch." + assert "result" in response, "Test Failed: MCP initialize response missing result field." + assert "serverInfo" in response["result"], "Test Failed: MCP initialize response missing serverInfo field." + raw_socket.sendall( + _encode_mcp_message({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + ) + + def _read_response(expected_id: int, timeout: float = 10.0) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + message = _read_mcp_message(raw_socket, timeout=timeout) + if message.get("id") == expected_id: + return message + raise TimeoutError(f"Timed out waiting for MCP response id={expected_id}.") + + print("\n***Test Passed: arm-mcp container initilized and ran successfully") + + #Check Image Tool Test + raw_socket.sendall(_encode_mcp_message(constants.CHECK_IMAGE_REQUEST)) + check_image_response = _read_response(2, timeout=60) + assert check_image_response.get("result")["structuredContent"] == constants.EXPECTED_CHECK_IMAGE_RESPONSE, "Test Failed: MCP check_image tool failed: content mismatch. Expected: {}, Received: {}".format(json.dumps(constants.EXPECTED_CHECK_IMAGE_RESPONSE,indent=2), json.dumps(check_image_response.get("result")["structuredContent"],indent=2)) + print("\n***Test Passed: MCP check_image tool succeeded") + + #Check Skopeo Tool Test + raw_socket.sendall(_encode_mcp_message(constants.CHECK_SKOPEO_REQUEST)) + check_skopeo_response = _read_response(3, timeout=60) + actual_architecture = json.loads(check_skopeo_response.get("result")["structuredContent"]["stdout"]).get("Architecture") + actual_os = json.loads(check_skopeo_response.get("result")["structuredContent"]["stdout"]).get("Os") + actual_status = check_skopeo_response.get("result")["structuredContent"].get("status") + assert actual_architecture == json.loads(constants.EXPECTED_CHECK_SKOPEO_RESPONSE["stdout"]).get("Architecture"), "Test Failed: MCP check_skopeo tool failed: Architecture mismatch. Expected: {}, Received: {}".format(constants.EXPECTED_CHECK_SKOPEO_RESPONSE["Architecture"], actual_architecture) + assert actual_os == json.loads(constants.EXPECTED_CHECK_SKOPEO_RESPONSE["stdout"]).get("Os"), "Test Failed: MCP check_skopeo tool failed: Os mismatch. Expected: {}, Received: {}".format(constants.EXPECTED_CHECK_SKOPEO_RESPONSE["Os"], actual_os) + assert actual_status == constants.EXPECTED_CHECK_SKOPEO_RESPONSE["status"], "Test Failed: MCP check_skopeo tool failed: Status mismatch. Expected: {}, Received: {}".format(constants.EXPECTED_CHECK_SKOPEO_RESPONSE["status"], actual_status) + print("\n***Test Passed: MCP check_skopeo tool succeeded") + + #Check NGINX Query Test + raw_socket.sendall(_encode_mcp_message(constants.CHECK_NGINX_REQUEST)) + check_nginx_response = _read_response(4, timeout=60) + urls = json.dumps(check_nginx_response["result"]["structuredContent"]) + assert any(expected in urls for expected in constants.EXPECTED_CHECK_NGINX_RESPONSE), "Test Failed: MCP check_nginx tool failed: content mismatch., Expected one of: {}, Received: {}".format(json.dumps(constants.EXPECTED_CHECK_NGINX_RESPONSE,indent=2), json.dumps(check_nginx_response.get("result")["structuredContent"],indent=2)) + print("\n***Test Passed: MCP check_nginx tool succeeded") + + #Check Migrate Ease Tool Test + raw_socket.sendall(_encode_mcp_message(constants.CHECK_MIGRATE_EASE_TOOL_REQUEST)) + check_migrate_ease_tool_response = _read_response(5, timeout=60) + #assert only the status field to avoid mismatches due to dynamic fields + assert check_migrate_ease_tool_response.get("result")["structuredContent"]["status"] == constants.EXPECTED_CHECK_MIGRATE_EASE_TOOL_RESPONSE_STATUS, "Test Failed: MCP check_migrate_ease_tool tool failed: status mismatch. Expected: {}, Received: {}".format(constants.EXPECTED_CHECK_MIGRATE_EASE_TOOL_RESPONSE_STATUS, check_migrate_ease_tool_response.get("result")["structuredContent"]["status"]) + print("\n***Test Passed: MCP check_migrate_ease_tool tool succeeded") + + #Check Sysreport Tool Test + raw_socket.sendall(_encode_mcp_message(constants.CHECK_SYSREPORT_TOOL_REQUEST)) + check_sysreport_response = _read_response(6, timeout=60) + assert check_sysreport_response.get("result")["structuredContent"] == constants.EXPECTED_CHECK_SYSREPORT_TOOL_RESPONSE, "Test Failed: MCP sysreport_instructions tool failed: content mismatch. Expected: {}, Received: {}".format(json.dumps(constants.EXPECTED_CHECK_SYSREPORT_TOOL_RESPONSE,indent=2), json.dumps(check_sysreport_response.get("result")["structuredContent"],indent=2)) + print("\n***Test Passed: MCP sysreport_instructions tool succeeded") + + #Check MCA Tool Test + raw_socket.sendall(_encode_mcp_message(constants.CHECK_MCA_TOOL_REQUEST)) + check_mca_response = _read_response(7, timeout=60) + assert check_mca_response.get("result")["structuredContent"]["status"] == constants.EXPECTED_CHECK_MCA_TOOL_RESPONSE_STATUS, "Test Failed: MCP mca tool failed: status mismatch.Expected: {}, Received: {}".format(json.dumps(constants.EXPECTED_CHECK_MCA_TOOL_RESPONSE_STATUS,indent=2), json.dumps(check_mca_response.get("result")["structuredContent"]["status"],indent=2)) + print("\n***Test Passed: MCP mca tool succeeded") + +if __name__ == "__main__": + pytest.main([__file__])