iflow-mcp_xrds76354_sumo-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
utils/connection.py ADDED
@@ -0,0 +1,145 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import threading
5
+ from typing import Callable, Optional, TypeVar
6
+
7
+ import traci
8
+
9
+ from utils.sumo import find_sumo_binary
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ DEFAULT_TRACI_TIMEOUT_S = float(os.environ.get("SUMO_MCP_TRACI_TIMEOUT_S", "10"))
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def _run_with_timeout(func: Callable[[], T], timeout_s: float, description: str) -> T:
19
+ result: dict[str, T] = {}
20
+ error: dict[str, Exception] = {}
21
+ done = threading.Event()
22
+
23
+ def _worker() -> None:
24
+ try:
25
+ result["value"] = func()
26
+ except Exception as exc:
27
+ error["error"] = exc
28
+ finally:
29
+ done.set()
30
+
31
+ thread = threading.Thread(target=_worker, daemon=True, name=f"sumo-mcp:{description}")
32
+ thread.start()
33
+
34
+ if not done.wait(timeout_s):
35
+ raise TimeoutError(f"TimeoutError: {description} timed out after {timeout_s:.1f}s")
36
+
37
+ if "error" in error:
38
+ raise error["error"]
39
+
40
+ return result["value"]
41
+
42
+
43
+ class SUMOConnection:
44
+ """
45
+ Singleton class to manage the connection to the SUMO server via TraCI.
46
+ """
47
+ _instance: Optional['SUMOConnection'] = None
48
+ _connected: bool
49
+
50
+ def __new__(cls) -> "SUMOConnection":
51
+ if cls._instance is None:
52
+ cls._instance = super(SUMOConnection, cls).__new__(cls)
53
+ cls._instance._connected = False
54
+ return cls._instance
55
+
56
+ def connect(
57
+ self,
58
+ config_file: Optional[str] = None,
59
+ gui: bool = False,
60
+ port: int = 8813,
61
+ host: str = "localhost",
62
+ timeout_s: float = DEFAULT_TRACI_TIMEOUT_S,
63
+ ) -> None:
64
+ """
65
+ Start SUMO and connect, or connect to an existing instance.
66
+ If config_file is provided, starts a new instance.
67
+ If config_file is None, attempts to connect to existing server at host:port.
68
+ """
69
+ if self._connected:
70
+ logger.info("Already connected to SUMO.")
71
+ return
72
+
73
+ try:
74
+ if config_file:
75
+ binary_name = "sumo-gui" if gui else "sumo"
76
+ binary = find_sumo_binary(binary_name)
77
+ if not binary:
78
+ raise RuntimeError(
79
+ "Could not locate SUMO executable. "
80
+ "Please ensure SUMO is installed and either the binary is in PATH or SUMO_HOME is set."
81
+ )
82
+ # Add --no-step-log to prevent stdout pollution which breaks JSON-RPC
83
+ cmd = [binary, "-c", config_file, "--no-step-log", "true"]
84
+ logger.info(f"Starting SUMO with command: {cmd}")
85
+ _run_with_timeout(
86
+ lambda: traci.start(cmd, port=port, stdout=subprocess.DEVNULL),
87
+ timeout_s=timeout_s,
88
+ description="traci.start",
89
+ )
90
+ else:
91
+ logger.info(f"Connecting to existing SUMO at {host}:{port}")
92
+ _run_with_timeout(
93
+ lambda: traci.init(host=host, port=port),
94
+ timeout_s=timeout_s,
95
+ description="traci.init",
96
+ )
97
+
98
+ self._connected = True
99
+ logger.info("Successfully connected to SUMO.")
100
+ except Exception as e:
101
+ logger.error(f"Failed to connect to SUMO: {e}")
102
+ self._connected = False
103
+ try:
104
+ _run_with_timeout(traci.close, timeout_s=timeout_s, description="traci.close")
105
+ except Exception:
106
+ pass
107
+ raise
108
+
109
+ def disconnect(self, timeout_s: float = DEFAULT_TRACI_TIMEOUT_S) -> None:
110
+ """Disconnect from SUMO server."""
111
+ if not self._connected:
112
+ return
113
+
114
+ try:
115
+ _run_with_timeout(traci.close, timeout_s=timeout_s, description="traci.close")
116
+ logger.info("Disconnected from SUMO.")
117
+ except Exception as e:
118
+ logger.error(f"Error during disconnect: {e}")
119
+ finally:
120
+ self._connected = False
121
+
122
+ def is_connected(self) -> bool:
123
+ return self._connected
124
+
125
+ def traci_call(self, func: Callable[[], T], description: str, timeout_s: float = DEFAULT_TRACI_TIMEOUT_S) -> T:
126
+ """Run a TraCI call with a soft timeout and disconnect on timeout."""
127
+ if not self.is_connected():
128
+ raise RuntimeError("Not connected to SUMO.")
129
+
130
+ try:
131
+ return _run_with_timeout(func, timeout_s=timeout_s, description=description)
132
+ except TimeoutError:
133
+ self._connected = False
134
+ try:
135
+ _run_with_timeout(traci.close, timeout_s=timeout_s, description="traci.close")
136
+ except Exception:
137
+ pass
138
+ raise
139
+
140
+ def simulation_step(self, step: float = 0, timeout_s: float = DEFAULT_TRACI_TIMEOUT_S) -> None:
141
+ """Advance the simulation."""
142
+ self.traci_call(lambda: traci.simulationStep(step), description="traci.simulationStep", timeout_s=timeout_s)
143
+
144
+ # Global instance
145
+ connection_manager = SUMOConnection()
utils/output.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ DEFAULT_MAX_OUTPUT_CHARS = int(os.environ.get("SUMO_MCP_MAX_OUTPUT_CHARS", "8000"))
6
+
7
+
8
+ def truncate_text(text: str | None, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
9
+ """Truncate large stdout/stderr strings to keep MCP responses bounded."""
10
+ if not text:
11
+ return ""
12
+
13
+ if max_chars <= 0:
14
+ return ""
15
+
16
+ if len(text) <= max_chars:
17
+ return text
18
+
19
+ original_len = len(text)
20
+ tail = text[-max_chars:]
21
+ truncated = original_len - max_chars
22
+ return (
23
+ f"... <truncated {truncated} chars; showing last {max_chars} of {original_len}> ...\n"
24
+ f"{tail}"
25
+ )
26
+
utils/sumo.py ADDED
@@ -0,0 +1,185 @@
1
+ import glob
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import sumolib
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def find_sumo_binary(name: str) -> Optional[str]:
15
+ """
16
+ Find a SUMO binary by name.
17
+
18
+ Resolution order:
19
+ 1) `sumolib.checkBinary()` (respects SUMO_HOME when set)
20
+ 2) `shutil.which()` (respects PATH)
21
+
22
+ Returns:
23
+ The resolved absolute executable path, or None if it cannot be located.
24
+ """
25
+ resolved: Optional[str] = None
26
+ try:
27
+ candidate = sumolib.checkBinary(name)
28
+ except (SystemExit, OSError, FileNotFoundError, ImportError) as exc:
29
+ logger.debug("sumolib.checkBinary failed for %s: %s", name, exc)
30
+ candidate = None
31
+
32
+ if candidate and candidate != name:
33
+ resolved = candidate
34
+
35
+ if resolved:
36
+ # Trust sumolib's result if it looks like an absolute path
37
+ # Use os.path.isabs for cross-platform compatibility (handles /usr/bin on Windows)
38
+ if os.path.isabs(resolved):
39
+ return resolved
40
+ return shutil.which(resolved)
41
+
42
+ return shutil.which(name)
43
+
44
+
45
+ def _candidate_sumo_home_from_binary(sumo_binary: Optional[str]) -> Optional[Path]:
46
+ if not sumo_binary:
47
+ return None
48
+
49
+ path = Path(sumo_binary)
50
+ if not path.is_absolute():
51
+ return None
52
+
53
+ # Typical layout: <SUMO_HOME>/bin/sumo(.exe)
54
+ if path.parent.name.lower() == "bin":
55
+ return path.parent.parent
56
+ return None
57
+
58
+
59
+ def find_sumo_home() -> Optional[str]:
60
+ """
61
+ Resolve SUMO_HOME.
62
+
63
+ Priority:
64
+ 1) SUMO_HOME environment variable
65
+ 2) Derive from `sumo` executable location when it matches <SUMO_HOME>/bin/sumo
66
+ 3) Platform-specific common locations
67
+ """
68
+ env_home = os.environ.get("SUMO_HOME")
69
+ if env_home:
70
+ home = Path(env_home).expanduser()
71
+ if home.exists():
72
+ logger.debug("Resolved SUMO_HOME from env: %s", home)
73
+ return str(home)
74
+ logger.debug("SUMO_HOME env set but path does not exist: %s", home)
75
+
76
+ sumo_binary = find_sumo_binary("sumo")
77
+ candidate = _candidate_sumo_home_from_binary(sumo_binary)
78
+ if candidate and candidate.exists():
79
+ logger.debug("Resolved SUMO_HOME from sumo binary: %s", candidate)
80
+ return str(candidate)
81
+
82
+ if sys.platform == "win32":
83
+ win_paths = [
84
+ Path("C:/Program Files/Eclipse/sumo"),
85
+ Path("C:/Program Files (x86)/Eclipse/sumo"),
86
+ Path("D:/sumo"),
87
+ Path("C:/sumo"),
88
+ ]
89
+ for path in win_paths:
90
+ if path.exists() and (path / "tools").exists():
91
+ logger.debug("Resolved SUMO_HOME from Windows common paths: %s", path)
92
+ return str(path)
93
+
94
+ # Windows Registry (optional)
95
+ try:
96
+ import winreg
97
+
98
+ key_path = r"SOFTWARE\Eclipse\SUMO"
99
+ for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
100
+ try:
101
+ key = winreg.OpenKey(hive, key_path)
102
+ install_path, _ = winreg.QueryValueEx(key, "InstallPath")
103
+ winreg.CloseKey(key)
104
+ if not install_path:
105
+ continue
106
+ reg_home = Path(install_path)
107
+ if reg_home.exists() and (reg_home / "tools").exists():
108
+ logger.debug("Resolved SUMO_HOME from Windows Registry: %s", reg_home)
109
+ return str(reg_home)
110
+ except FileNotFoundError:
111
+ continue
112
+ except OSError:
113
+ continue
114
+ except ImportError:
115
+ pass
116
+
117
+ if sys.platform == "darwin":
118
+ patterns = [
119
+ "/usr/local/Cellar/sumo/*/share/sumo",
120
+ "/opt/homebrew/Cellar/sumo/*/share/sumo",
121
+ ]
122
+ matches: list[str] = []
123
+ for pattern in patterns:
124
+ matches.extend(glob.glob(pattern))
125
+
126
+ for raw in sorted(matches, reverse=True):
127
+ home = Path(raw)
128
+ if home.exists() and (home / "tools").exists():
129
+ logger.debug("Resolved SUMO_HOME from Homebrew cellar: %s", home)
130
+ return str(home)
131
+
132
+ linux_home = Path("/usr/share/sumo")
133
+ if linux_home.exists() and (linux_home / "tools").exists():
134
+ logger.debug("Resolved SUMO_HOME from Linux common path: %s", linux_home)
135
+ return str(linux_home)
136
+
137
+ return None
138
+
139
+
140
+ def find_sumo_tools_dir() -> Optional[str]:
141
+ """Return the SUMO tools directory if it can be located."""
142
+ sumo_home = find_sumo_home()
143
+ if not sumo_home:
144
+ return None
145
+
146
+ tools_dir = Path(sumo_home) / "tools"
147
+ if tools_dir.exists():
148
+ return str(tools_dir)
149
+
150
+ return None
151
+
152
+
153
+ def find_sumo_tool_script(script_name: str) -> Optional[str]:
154
+ """Find a SUMO python tool script (e.g. randomTrips.py) under SUMO tools dir."""
155
+ tools_dir = find_sumo_tools_dir()
156
+ if not tools_dir:
157
+ return None
158
+
159
+ script = Path(tools_dir) / script_name
160
+ if script.exists():
161
+ return str(script)
162
+
163
+ return None
164
+
165
+
166
+ def build_sumo_diagnostics(binary_name: str = "sumo") -> str:
167
+ """
168
+ Build a short, multi-line diagnostic string about SUMO discovery.
169
+
170
+ This is intended for inclusion in user-facing error messages.
171
+ """
172
+ env_home = os.environ.get("SUMO_HOME") or "Not Set"
173
+ which_bin = shutil.which(binary_name) or "Not Found"
174
+ detected_home = find_sumo_home() or "Not Found"
175
+ tools_dir = find_sumo_tools_dir() or "Not Found"
176
+
177
+ return "\n".join(
178
+ [
179
+ "Diagnostics:",
180
+ f" - SUMO_HOME env: {env_home}",
181
+ f" - which({binary_name}): {which_bin}",
182
+ f" - find_sumo_home(): {detected_home}",
183
+ f" - find_sumo_tools_dir(): {tools_dir}",
184
+ ]
185
+ )