Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/itential_mcp/runtime/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class CommandConfig:
"metavar": "<object>",
"help": "Parameters to pass to the tool",
},
"--config": {"help": CONFIG_HELP_MESSAGE},
},
add_platform_group=True,
),
Expand Down
15 changes: 15 additions & 0 deletions src/itential_mcp/runtime/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,23 @@ def parse_args(args: Sequence) -> Tuple[Callable, Tuple[Any, ...], dict]:
parser = _create_main_parser()
_create_subparsers(parser)

# Pre-capture --config from raw args before argparse parsing.
# When --config appears before the subcommand (e.g., itential-mcp --config
# file.conf call ...), the main parser captures it but the subparser
# overwrites it with its default (None). This preserves the value.
pre_config = None
args_list = list(args)
if "--config" in args_list:
idx = args_list.index("--config")
if idx + 1 < len(args_list):
pre_config = args_list[idx + 1]

parsed_args = parser.parse_args(args=args)

# Restore --config if the subparser overwrote it with None
if parsed_args.config is None and pre_config is not None:
parsed_args.config = pre_config

_process_logging_config(parsed_args)

if parsed_args.help or parsed_args.command is None:
Expand Down
9 changes: 7 additions & 2 deletions src/itential_mcp/runtime/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,10 @@ async def run(tool: str, params: Mapping[str, Any] | None = None) -> None:
# Execute operations
result = await client.call_tool(tool, **kwargs)

data = json.loads(result.content[0].text)
print(f"\n{json.dumps(data, indent=4)}")
text = result.content[0].text

try:
data = json.loads(text)
print(f"\n{json.dumps(data, indent=4)}")
except json.JSONDecodeError:
print(f"\n{text}")
75 changes: 75 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,81 @@ def __init__(self, tool, params):

assert result[1] == ("my_tool", '{"test": true}')

def test_call_with_config_attribute(self):
"""Test that call function works when args has config attribute"""

class MockArgs:
def __init__(self, tool, params, config):
self.tool = tool
self.params = params
self.config = config

args = MockArgs("my_tool", '{"test": true}', "/path/to/config.conf")
result = commands.call(args)

# call command should still return the same tuple structure
assert result[0] == runner.run
assert result[1] == ("my_tool", '{"test": true}')
assert result[2] is None


class TestCallCommandConfigIntegration:
"""Test cases for call command --config integration"""

def test_call_subparser_accepts_config_flag(self):
"""Test that the call subparser accepts --config flag"""
from unittest.mock import patch
from itential_mcp.runtime.parser import _create_main_parser, _create_subparsers

parser = _create_main_parser()
with patch("itential_mcp.runtime.parser.add_platform_group"), \
patch("itential_mcp.runtime.parser.add_server_group"):
_create_subparsers(parser)

# Should parse successfully with --config on call subcommand
args = parser.parse_args(["call", "get_health", "--config", "/path/to/config.conf"])

assert args.command == "call"
assert args.tool == "get_health"
assert args.config == "/path/to/config.conf"

def test_call_subparser_config_default_is_none(self):
"""Test that --config defaults to None when not provided"""
from unittest.mock import patch
from itential_mcp.runtime.parser import _create_main_parser, _create_subparsers

parser = _create_main_parser()
with patch("itential_mcp.runtime.parser.add_platform_group"), \
patch("itential_mcp.runtime.parser.add_server_group"):
_create_subparsers(parser)

args = parser.parse_args(["call", "get_health"])

assert args.command == "call"
assert args.tool == "get_health"
assert args.config is None

def test_call_subparser_config_with_params(self):
"""Test that --config works alongside --params on call subcommand"""
from unittest.mock import patch
from itential_mcp.runtime.parser import _create_main_parser, _create_subparsers

parser = _create_main_parser()
with patch("itential_mcp.runtime.parser.add_platform_group"), \
patch("itential_mcp.runtime.parser.add_server_group"):
_create_subparsers(parser)

args = parser.parse_args([
"call", "get_devices",
"--config", "/path/to/config.conf",
"--params", '{"filter": "active"}',
])

assert args.command == "call"
assert args.tool == "get_devices"
assert args.config == "/path/to/config.conf"
assert args.params == '{"filter": "active"}'


class TestModuleStructure:
"""Test cases for overall module structure and imports"""
Expand Down
51 changes: 51 additions & 0 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,54 @@ async def test_run_full_workflow(
assert '"name": "John Doe"' in output
assert '"status": "success"' in output
assert '"role": "admin"' in output

@pytest.mark.asyncio
@patch("sys.stdout", new_callable=StringIO)
@patch("itential_mcp.runtime.runner.Client")
@patch("itential_mcp.runtime.runner.Server")
@patch("itential_mcp.runtime.runner.config.get")
async def test_run_non_json_result_output(
self, mock_config_get, mock_server_class, mock_client_class, mock_stdout
):
"""Test that non-JSON results (e.g. TOON format) are printed directly"""
# Setup mocks
mock_config = MagicMock()
mock_config_get.return_value = mock_config

# Setup server instance mock
mock_server_instance = MagicMock()
mock_server_instance.mcp = MagicMock()
mock_server_instance.__aenter__ = AsyncMock(return_value=mock_server_instance)
mock_server_instance.__aexit__ = AsyncMock(return_value=None)
mock_server_class.return_value = mock_server_instance

mock_client = AsyncMock()
mock_client.ping.return_value = True

# Mock tool list response
mock_tool = MagicMock()
mock_tool.name = "test_tool"
mock_tool.inputSchema = {"properties": {}, "required": []}

mock_list_response = MagicMock()
mock_list_response.tools = [mock_tool]
mock_client.list_tools_mcp.return_value = mock_list_response

# Mock TOON-formatted result (not valid JSON)
toon_result = "status: running\nhost: selab-ip6-dev\nservices[2]:\n redis: running\n mongo: running"
mock_result_content = MagicMock()
mock_result_content.text = toon_result
mock_result = MockCallToolResult(content=[mock_result_content])
mock_client.call_tool.return_value = mock_result

mock_client_class.return_value.__aenter__.return_value = mock_client
mock_client_class.return_value.__aexit__.return_value = None

# Execute
await runner.run("test_tool")

# Verify TOON output is printed directly
output = mock_stdout.getvalue()
assert "status: running" in output
assert "host: selab-ip6-dev" in output
assert "redis: running" in output