Skip to content
Merged
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
67 changes: 52 additions & 15 deletions HOPE/src/hope_mcp_server/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,12 @@ def looks_like_applocker_block(stdout: str, stderr: str) -> bool:


def julia_string_literal(value: str | Path) -> str:
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
escaped = (
str(value)
.replace("\\", "\\\\")
.replace("$", "\\$")
.replace('"', '\\"')
)
return f'"{escaped}"'


Expand All @@ -396,8 +401,13 @@ def build_project_julia_command(repo_root: Path, julia_bin: str) -> list[str]:


def build_run_command(repo_root: Path, julia_bin: str, case_path: Path) -> list[str]:
julia_script = f"using HOPE; HOPE.run_hope({julia_string_literal(case_path)})"
return [*build_project_julia_command(repo_root, julia_bin), "-e", julia_script]
julia_script = "using HOPE; HOPE.run_hope(ARGS[1])"
return [
*build_project_julia_command(repo_root, julia_bin),
"-e",
julia_script,
str(case_path),
]


def build_debug_solver_environment_command(
Expand All @@ -421,8 +431,8 @@ def build_debug_solver_environment_command(
"emit(\"STEP=using_HOPE_done\"); "
"using JuMP; "
"emit(\"STEP=using_JuMP_done\"); "
f"case = {julia_string_literal(case_path)}; "
f"solver = {julia_string_literal(solver)}; "
"case = ARGS[1]; "
"solver = ARGS[2]; "
"emit(\"HOPE_PATH=\" * string(pathof(HOPE))); "
"emit(\"STEP=initiate_solver_start\"); "
"opt = HOPE.initiate_solver(case, solver); "
Expand All @@ -436,6 +446,8 @@ def build_debug_solver_environment_command(
"emit(\"MODEL_TYPE=\" * string(typeof(model))); "
"emit(\"PROBE_STATUS=ok\")"
),
str(case_path),
solver,
]


Expand Down Expand Up @@ -815,7 +827,16 @@ def hope_debug_solver_environment(
return julia_error

settings = read_yaml(settings_path_for_case(case_path))
selected_solver = solver or str(settings.get("solver", "highs"))
selected_solver = str(
solver if solver is not None else settings.get("solver", "highs")
).strip().lower()
if selected_solver not in _SOLVER_OPTIONS:
return error_result(
"invalid_solver",
f"Unsupported solver '{selected_solver}'.",
solver=selected_solver,
allowed_solvers=list(_SOLVER_OPTIONS),
)
command = build_debug_solver_environment_command(
repo_root,
julia_bin,
Expand Down Expand Up @@ -902,7 +923,16 @@ def hope_debug_solver_environment_async(
return julia_error

settings = read_yaml(settings_path_for_case(case_path))
selected_solver = solver or str(settings.get("solver", "highs"))
selected_solver = str(
solver if solver is not None else settings.get("solver", "highs")
).strip().lower()
if selected_solver not in _SOLVER_OPTIONS:
return error_result(
"invalid_solver",
f"Unsupported solver '{selected_solver}'.",
solver=selected_solver,
allowed_solvers=list(_SOLVER_OPTIONS),
)
command = build_debug_solver_environment_command(
repo_root,
julia_bin,
Expand Down Expand Up @@ -1501,12 +1531,14 @@ def hope_run_holistic(
if julia_error is not None:
return julia_error

julia_script = (
"using HOPE; "
f"HOPE.run_hope_holistic({julia_string_literal(gtep_path)}, "
f"{julia_string_literal(pcm_path)})"
)
command = [*build_project_julia_command(repo_root, julia_bin), "-e", julia_script]
julia_script = "using HOPE; HOPE.run_hope_holistic(ARGS[1], ARGS[2])"
command = [
*build_project_julia_command(repo_root, julia_bin),
"-e",
julia_script,
str(gtep_path),
str(pcm_path),
]

# Tag job with both case IDs
job_id = _launch_job(
Expand Down Expand Up @@ -1579,9 +1611,14 @@ def hope_run_erec(

julia_script = (
"using HOPE; "
f"HOPE.calculate_erec({julia_string_literal(case_path)}, overrides={overrides_str})"
f"HOPE.calculate_erec(ARGS[1], overrides={overrides_str})"
)
command = [*build_project_julia_command(repo_root, julia_bin), "-e", julia_script]
command = [
*build_project_julia_command(repo_root, julia_bin),
"-e",
julia_script,
str(case_path),
]

job_id = _launch_job(
command,
Expand Down
67 changes: 66 additions & 1 deletion HOPE/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from hope_mcp_server.core import (
_Job,
_jobs,
build_debug_solver_environment_command,
build_run_command,
hope_cancel_job,
hope_case_info,
Expand Down Expand Up @@ -193,10 +194,30 @@ def test_build_run_command_uses_env_config(self) -> None:
"--startup-file=no",
f"--project={REPO_ROOT}",
"-e",
f"using HOPE; HOPE.run_hope({julia_string_literal(case_path)})",
"using HOPE; HOPE.run_hope(ARGS[1])",
str(case_path),
],
)

def test_julia_string_literal_escapes_interpolation(self) -> None:
self.assertEqual(julia_string_literal("$solver"), '"\\$solver"')

def test_debug_command_passes_dynamic_values_as_arguments(self) -> None:
case_path = REPO_ROOT / "ModelCases" / "$case"
command = build_debug_solver_environment_command(
REPO_ROOT,
JULIA_BIN,
case_path,
"$solver",
)
script_index = command.index("-e") + 1
script = command[script_index]
self.assertIn("case = ARGS[1]", script)
self.assertIn("solver = ARGS[2]", script)
self.assertNotIn(str(case_path), script)
self.assertNotIn("$solver", script)
self.assertEqual(command[script_index + 1 :], [str(case_path), "$solver"])

def test_missing_julia_path_returns_structured_error(self) -> None:
with mock.patch.dict(
"os.environ",
Expand Down Expand Up @@ -311,6 +332,37 @@ def test_debug_solver_environment_reports_probe_details(self) -> None:
self.assertEqual(result["probe"]["PROBE_STATUS"], "ok")
run_mock.assert_called_once()

def test_debug_solver_environment_rejects_invalid_solver(self) -> None:
with (
mock.patch("hope_mcp_server.core.validate_julia_command", return_value=(JULIA_BIN, None)),
mock.patch("hope_mcp_server.core.subprocess.run") as run_mock,
):
result = hope_debug_solver_environment(
case_id="MD_GTEP_clean_case",
solver="$unsupported",
)
self.assertFalse(result["ok"])
self.assertEqual(result["error_type"], "invalid_solver")
self.assertEqual(
result["allowed_solvers"],
["cbc", "clp", "highs", "scip", "gurobi", "cplex"],
)
run_mock.assert_not_called()

def test_debug_solver_environment_rejects_invalid_configured_solver(self) -> None:
with (
mock.patch("hope_mcp_server.core.validate_julia_command", return_value=(JULIA_BIN, None)),
mock.patch(
"hope_mcp_server.core.read_yaml",
return_value={"solver": "$unsupported"},
),
mock.patch("hope_mcp_server.core.subprocess.run") as run_mock,
):
result = hope_debug_solver_environment(case_id="MD_GTEP_clean_case")
self.assertFalse(result["ok"])
self.assertEqual(result["error_type"], "invalid_solver")
run_mock.assert_not_called()

def test_debug_solver_environment_reports_timeout_progress(self) -> None:
timeout_error = subprocess.TimeoutExpired(
cmd=["julia"],
Expand Down Expand Up @@ -342,6 +394,19 @@ def test_debug_solver_environment_async_launches_background_job(self) -> None:
self.assertIn("--startup-file=no", result["command"])
launch_mock.assert_called_once()

def test_debug_solver_environment_async_rejects_invalid_solver(self) -> None:
with (
mock.patch("hope_mcp_server.core.validate_julia_command", return_value=(JULIA_BIN, None)),
mock.patch("hope_mcp_server.core._launch_job") as launch_mock,
):
result = hope_debug_solver_environment_async(
case_id="MD_GTEP_clean_case",
solver="$unsupported",
)
self.assertFalse(result["ok"])
self.assertEqual(result["error_type"], "invalid_solver")
launch_mock.assert_not_called()

def test_setup_command_disables_startup_file(self) -> None:
command = setup_command(REPO_ROOT, JULIA_BIN)
self.assertIn("--startup-file=no", command)
Expand Down
2 changes: 1 addition & 1 deletion powermcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
Configure and connect MCP clients with the `powermcp` CLI (`powermcp install`).
"""

__version__ = "0.4.0"
__version__ = "0.4.1"

__all__ = ["__version__"]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ build-backend = "hatchling.build"

[project]
name = "powermcp"
version = "0.4.0"
version = "0.4.1"
description = "MCP servers for power-system software (PowerWorld, OpenDSS, PSS/E, pandapower, PyPSA, and more)"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
2 changes: 1 addition & 1 deletion tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

def test_package_versions_match():
project = tomllib.loads((registry.REPO_ROOT / "pyproject.toml").read_text())["project"]
assert project["version"] == __version__ == "0.4.0"
assert project["version"] == __version__ == "0.4.1"


def test_core_tools_present_and_have_no_extra():
Expand Down
Loading