Skip to content
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"restrictedpython>=8.0,<9.0",
"prance>=25.4.8.0",
"openapi-spec-validator>=0.8.4",
"fastmcp>=3.0.0",
]

[project.optional-dependencies]
Expand Down
5 changes: 1 addition & 4 deletions scanapi/evaluators/code_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,7 @@ def _get_allowed_modules(cls) -> dict[str, Any]:
dict: Dictionary of module names to imported modules
"""
# nosec B301: imports are constrained by cls.ALLOWED_MODULES.
return {
name: __import__(name)
for name in cls.ALLOWED_MODULES
}
return {name: __import__(name) for name in cls.ALLOWED_MODULES}

@classmethod
def _get_safe_globals(cls, response: Any = None) -> dict[str, Any]:
Expand Down
1 change: 1 addition & 0 deletions scanapi/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""MCP server implementation for ScanAPI.""" # pragma: no cover
112 changes: 112 additions & 0 deletions scanapi/mcp/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""MCP Server for ScanAPI."""

from fastmcp import FastMCP

from scanapi.scan import run_scan # pragma: no cover
from scanapi.settings import settings # pragma: no cover
from scanapi.cli import configure_logging # pragma: no cover
from scanapi.scan import write_output # pragma: no cover
from scanapi.session import session # pragma: no cover


mcp = FastMCP("scanapi")


@mcp.tool()
# pylint: disable=too-many-arguments,too-many-positional-arguments
# skipcq: PTC-W0049
def run( # pragma: no cover
spec_path: str,
config_path: str | None = None,
output_path: str | None = None,
no_report: bool = False,
browser: bool = False,
template: str | None = None,
log_level: str = "INFO",
) -> dict:
"""Run ScanAPI against an API specification.

Args:
spec_path (str): Path to the API specification file.
config_path (str | None, optional): Configuration file path. Default is scanapi.conf.
output_path (str | None, optional): Report output path. Default is scanapi-report.html.
no_report (bool, optional): Run ScanAPI without generating a report.
browser (bool, optional): Open the results file using a browser.
template (str | None, optional): Custom report template path.
log_level (str, optional): Set the logging level (e.g. DEBUG, INFO).

Returns:
dict: A dictionary containing the summary and results of the scan.
"""
configure_logging(log_level)

# Save preferences to the global settings
settings.save_preferences(
spec_path=spec_path,
output_path=output_path,
no_report=no_report,
config_path=config_path,
template=template,
open_browser=browser,
)

results = run_scan()

# Generate report if needed
if not no_report:
write_output(results)

total_tests = session.successes + session.failures + session.errors

# Serialize results to ensure they are JSON serializable for MCP transport
serialized_results = []
for r in results:
response_obj = r.get("response")
serialized_resp = None
if response_obj:
elapsed_obj = getattr(response_obj, "elapsed", None)
serialized_resp = {
"status_code": getattr(response_obj, "status_code", None),
"url": str(getattr(response_obj, "url", "")),
"method": getattr(getattr(response_obj, "request", None), "method", ""),
"elapsed": elapsed_obj.total_seconds() if elapsed_obj else 0,
"text": getattr(response_obj, "text", ""),
}

tests_results = []
for t in r.get("tests_results", []):
status = t.get("status")
status_str = status.name if hasattr(status, "name") else str(status)
tests_results.append({
"name": t.get("name"),
"status": status_str,
"failure": t.get("failure"),
})

serialized_results.append({
"request_node_name": r.get("request_node_name"),
"endpoint_name": r.get("endpoint_name"),
"no_failure": r.get("no_failure"),
"response": serialized_resp,
"tests_results": tests_results,
})

return {
"summary": {
"requests": len(results),
"tests": total_tests,
"passed": session.successes,
"failed": session.failures,
"success": session.succeed,
},
"results": serialized_results,
}


def main(): # pragma: no cover
"""Start the MCP server using the stdio transport."""
mcp.run(transport="stdio")


if __name__ == "__main__":
main()
29 changes: 24 additions & 5 deletions scanapi/scan.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from datetime import datetime

import yaml

Expand All @@ -19,8 +20,20 @@
logger = logging.getLogger(__name__)


def scan():
"""Caller function that tries to scans the file and write the report."""
def run_scan() -> list:
"""Core logic to run the scan and return the results.

Returns:
list: A list containing the results of the scan.
"""
# Reset the session for fresh runs, crucial for long-running MCP server
session.successes = 0
session.failures = 0
session.errors = 0
session.exit_code = ExitCode.OK

session.started_at = datetime.now()

spec_path = settings["spec_path"]

try:
Expand All @@ -42,7 +55,6 @@ def scan():
try:
root_node = EndpointNode(api_spec)
results = root_node.run()

except (
InvalidKeyError,
KeyError,
Expand All @@ -53,12 +65,19 @@ def scan():
logger.error(error_message)
raise SystemExit(ExitCode.USAGE_ERROR)

_write(results)
return list(results)


def scan():
"""Caller function that tries to scans the file and write the report."""
results = run_scan()

write_output(results)
write_summary()
session.exit()


def _write(results):
def write_output(results):
"""When the user passed the `--no-report` flag: prints the test results to
the console output.
When the user did not pass the `--no_report flag`: writes the results on a
Expand Down
Empty file added tests/unit/mcp/__init__.py
Empty file.
75 changes: 75 additions & 0 deletions tests/unit/mcp/test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import json
from unittest.mock import MagicMock

from scanapi.mcp.server import run

def test_run_serializes_response(mocker):
# Mock settings and run_scan
mocker.patch("scanapi.mcp.server.settings.save_preferences")
mocker.patch("scanapi.mcp.server.write_output")
mock_run_scan = mocker.patch("scanapi.mcp.server.run_scan")

# Create a mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.url = "https://httpbin.org/health/"
mock_response.request.method = "GET"
mock_response.elapsed.total_seconds.return_value = 0.5
mock_response.text = '{"status": "ok"}'

# Create a mock test status
class MockStatus:
name = "PASSED"

mock_results = [
{
"request_node_name": "health_check",
"endpoint_name": "health",
"no_failure": True,
"response": mock_response,
"tests_results": [
{
"name": "status is 200",
"status": MockStatus(),
"failure": None,
}
],
}
]
mock_run_scan.return_value = mock_results

# Mock session
mock_session = mocker.patch("scanapi.mcp.server.session")
mock_session.successes = 1
mock_session.failures = 0
mock_session.errors = 0
mock_session.succeed = True

# Call run
result = run(spec_path="dummy.yaml", no_report=True)

# Validate output
assert "summary" in result
assert "results" in result

# Assert serialization worked
serialized_results = result["results"]
assert len(serialized_results) == 1

req_result = serialized_results[0]
assert req_result["request_node_name"] == "health_check"

resp = req_result["response"]
assert resp["status_code"] == 200
assert resp["url"] == "https://httpbin.org/health/"
assert resp["method"] == "GET"
assert resp["elapsed"] == 0.5
assert resp["text"] == '{"status": "ok"}'

test_results = req_result["tests_results"]
assert len(test_results) == 1
assert test_results[0]["name"] == "status is 200"
assert test_results[0]["status"] == "PASSED"

# Ensure it's JSON serializable
json.dumps(result)
Loading
Loading