"""Tests for CLIMCPServer functionality."""
from unittest.mock import Mock, patch
from mcp_chain.cli_mcp import CLIMCPServer
def test_cli_mcp_server_creation():
"""Test creating a CLIMCPServer."""
server = CLIMCPServer("test-cli", commands=["ls"])
assert server.name == "test-cli"
assert server.commands == ["ls"]
def test_cli_mcp_server_creation_with_description():
"""Test creating a CLIMCPServer with custom description."""
custom_desc = "Custom description for ls command"
server = CLIMCPServer("test-cli", commands=["ls"], descriptions={"ls": custom_desc})
assert server.name == "test-cli"
assert server.commands == ["ls"]
assert server.descriptions == {"ls": custom_desc}
def test_cli_mcp_server_implements_dict_mcp_server_protocol():
"""Test that CLIMCPServer implements DictMCPServer protocol."""
server = CLIMCPServer("test-cli", commands=["echo"])
# Should have get_metadata method
assert hasattr(server, "get_metadata")
assert callable(server.get_metadata)
# Should have handle_request method
assert hasattr(server, "handle_request")
assert callable(server.handle_request)
def test_get_metadata_returns_tools():
"""Test that get_metadata returns tools for configured command."""
server = CLIMCPServer("test-cli", commands=["echo"])
metadata = server.get_metadata()
assert isinstance(metadata, dict)
assert "tools" in metadata
assert "server_name" in metadata
assert metadata["server_name"] == "test-cli"
# Should have one tool for echo command
tools = metadata["tools"]
assert len(tools) == 1
assert tools[0]["name"] == "echo"
def test_handle_tools_list_request():
"""Test handling tools/list request."""
server = CLIMCPServer("test-cli", commands=["echo"])
request = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 1
assert "result" in response
assert "tools" in response["result"]
assert len(response["result"]["tools"]) == 1
@patch("subprocess.run")
def test_handle_tool_call_request(mock_run):
"""Test handling tools/call request."""
# Mock subprocess.run
mock_result = Mock()
mock_result.stdout = "Hello World"
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
server = CLIMCPServer("test-cli", commands=["echo"])
request = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "echo", "arguments": {"_args": ["Hello World"]}},
}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 2
assert "result" in response
assert not response["result"]["isError"]
assert len(response["result"]["content"]) == 1
assert "Hello World" in response["result"]["content"][0]["text"]
# Verify subprocess.run was called correctly
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0] # First positional argument
assert call_args[0] == "echo"
assert "Hello World" in call_args
@patch("subprocess.run")
def test_handle_tool_call_with_flags(mock_run):
"""Test handling tools/call request with boolean flags."""
mock_result = Mock()
mock_result.stdout = "output"
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
server = CLIMCPServer("test-cli", commands=["ls"])
request = {
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "ls",
"arguments": {
"l": True, # Should become -l
"a": True, # Should become -a
"verbose": True, # Should become --verbose
},
},
}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 3
assert "result" in response
# Verify subprocess.run was called with correct flags
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0] # First positional argument
assert call_args[0] == "ls"
assert "-l" in call_args
assert "-a" in call_args
assert "--verbose" in call_args
def test_handle_unknown_method():
"""Test handling unknown method request."""
server = CLIMCPServer("test-cli", commands=["echo"])
request = {"jsonrpc": "2.0", "id": 4, "method": "unknown/method", "params": {}}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 4
assert "error" in response
assert response["error"]["code"] == -32601
assert "Method not found" in response["error"]["message"]
def test_handle_unknown_tool():
"""Test handling call to unknown tool."""
server = CLIMCPServer("test-cli", commands=["echo"])
request = {
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {"name": "unknown_command", "arguments": {}},
}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 5
assert "error" in response
assert response["error"]["code"] == -32602
assert "Tool not found" in response["error"]["message"]
@patch("subprocess.run")
def test_command_error_handling(mock_run):
"""Test handling command execution errors."""
# Mock subprocess.run to raise an exception
mock_run.side_effect = Exception("Command failed")
server = CLIMCPServer("test-cli", commands=["echo"])
request = {
"jsonrpc": "2.0",
"id": 6,
"method": "tools/call",
"params": {"name": "echo", "arguments": {"_args": ["test"]}},
}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 6
assert "result" in response
assert response["result"]["isError"]
assert "Error executing echo" in response["result"]["content"][0]["text"]
@patch("subprocess.run")
def test_command_with_stderr_and_exit_code(mock_run):
"""Test handling command with stderr output and non-zero exit code."""
mock_result = Mock()
mock_result.stdout = "some output"
mock_result.stderr = "error message"
mock_result.returncode = 1
mock_run.return_value = mock_result
server = CLIMCPServer("test-cli", commands=["ls"])
request = {
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {"name": "ls", "arguments": {"_args": ["/nonexistent"]}},
}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 7
assert "result" in response
assert not response["result"][
"isError"
] # We still return success but include error info
content = response["result"]["content"][0]["text"]
assert "STDOUT:" in content
assert "some output" in content
assert "STDERR:" in content
assert "error message" in content
assert "Exit code: 1" in content
def test_single_command_server():
"""Test CLIMCPServer with a single command in the list."""
server = CLIMCPServer("test-cli", commands=["echo"])
metadata = server.get_metadata()
assert len(metadata["tools"]) == 1
assert metadata["tools"][0]["name"] == "echo"
assert metadata["server_name"] == "test-cli"
@patch("subprocess.run")
def test_get_help_text_integration(mock_run):
"""Test that help text extraction works with real-like subprocess calls."""
# Mock the help command call
mock_result = Mock()
mock_result.stdout = """ls - list directory contents
Usage: ls [OPTION]... [FILE]...
List information about the FILEs (the current directory by default).
Options:
-a, --all do not ignore entries starting with .
-l use a long listing format
--help display this help and exit
"""
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
server = CLIMCPServer("test-cli", commands=["ls"])
# This should trigger help text extraction
metadata = server.get_metadata()
assert len(metadata["tools"]) == 1
tool = metadata["tools"][0]
assert tool["name"] == "ls"
assert "list directory contents" in tool["description"]
# Should have extracted some options
schema = tool["inputSchema"]
assert "properties" in schema
assert "_args" in schema["properties"] # Positional args always included
@patch("subprocess.run")
def test_description_override_functionality(mock_run):
"""Test that custom description overrides help text extraction."""
# Mock help text extraction
mock_result = Mock()
mock_result.stdout = "ls - list directory contents\nUsage: ls [OPTION]... [FILE]..."
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
custom_desc = "My custom description for ls tool"
server = CLIMCPServer("test-cli", commands=["ls"], descriptions={"ls": custom_desc})
metadata = server.get_metadata()
# Should use custom description instead of extracted one
assert len(metadata["tools"]) == 1
tool = metadata["tools"][0]
assert tool["name"] == "ls"
assert tool["description"] == custom_desc
# Verify help text was still fetched (for input schema)
mock_run.assert_called()
@patch("subprocess.run")
def test_fallback_to_help_text_when_no_override(mock_run):
"""Test that help text is used when no description override is provided."""
# Mock help text extraction
mock_result = Mock()
mock_result.stdout = "ls - list directory contents\nUsage: ls [OPTION]... [FILE]..."
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
server = CLIMCPServer("test-cli", commands=["ls"]) # No descriptions parameter
metadata = server.get_metadata()
# Should extract description from help text
assert len(metadata["tools"]) == 1
tool = metadata["tools"][0]
assert tool["name"] == "ls"
assert tool["description"] == "ls - list directory contents"
# Verify help text was fetched
mock_run.assert_called()
def test_cli_mcp_server_creation_with_multiple_commands():
"""Test creating a CLIMCPServer with multiple commands."""
commands = ["git", "docker", "ls"]
server = CLIMCPServer("multi-cli", commands=commands)
assert server.name == "multi-cli"
assert server.commands == commands
assert len(server.commands) == 3
assert "git" in server.commands
assert "docker" in server.commands
assert "ls" in server.commands
def test_cli_mcp_server_creation_with_descriptions_dict():
"""Test creating a CLIMCPServer with multiple commands and custom descriptions."""
commands = ["git", "docker"]
descriptions = {
"git": "Custom Git description",
"docker": "Custom Docker description",
}
server = CLIMCPServer("multi-cli", commands=commands, descriptions=descriptions)
assert server.name == "multi-cli"
assert server.commands == commands
assert server.descriptions == descriptions
assert server.descriptions["git"] == "Custom Git description"
assert server.descriptions["docker"] == "Custom Docker description"
def test_get_metadata_with_multiple_commands():
"""Test that get_metadata returns multiple tools for multiple commands."""
commands = ["echo", "ls", "date"]
server = CLIMCPServer("multi-cli", commands=commands)
metadata = server.get_metadata()
assert len(metadata["tools"]) == 3
tool_names = [tool["name"] for tool in metadata["tools"]]
assert "echo" in tool_names
assert "ls" in tool_names
assert "date" in tool_names
assert metadata["server_name"] == "multi-cli"
@patch("subprocess.run")
def test_descriptions_dict_overrides_in_metadata(mock_run):
"""Test that descriptions dict overrides tool descriptions in metadata."""
# Mock help text for commands
mock_result = Mock()
mock_result.stdout = (
"echo - display a line of text\nUsage: echo [OPTION]... [STRING]..."
)
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
commands = ["echo", "ls"]
descriptions = {
"echo": "Custom echo tool description",
"ls": "Custom ls tool description",
}
server = CLIMCPServer("multi-cli", commands=commands, descriptions=descriptions)
metadata = server.get_metadata()
assert len(metadata["tools"]) == 2
# Find tools by name and check descriptions
tools_by_name = {tool["name"]: tool for tool in metadata["tools"]}
assert "echo" in tools_by_name
assert "ls" in tools_by_name
assert tools_by_name["echo"]["description"] == "Custom echo tool description"
assert tools_by_name["ls"]["description"] == "Custom ls tool description"
@patch("subprocess.run")
def test_handle_tool_call_with_multiple_commands(mock_run):
"""Test handling tool calls for specific commands when multiple commands are available."""
# Mock subprocess.run to capture the executed command
mock_result = Mock()
mock_result.stdout = "test output"
mock_result.stderr = ""
mock_result.returncode = 0
mock_run.return_value = mock_result
commands = ["echo", "ls", "date"]
server = CLIMCPServer("multi-cli", commands=commands)
# Test calling specific command (ls)
request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "ls", "arguments": {"_args": ["-l"]}},
}
response = server.handle_request(request)
assert response["jsonrpc"] == "2.0"
assert response["id"] == 1
assert "result" in response
assert not response["result"]["isError"]
assert "test output" in response["result"]["content"][0]["text"]
# Verify the correct command was executed
mock_run.assert_called_with(
["ls", "-l"], capture_output=True, text=True, timeout=30, check=False
)