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__])