Skip to content

Commit 612204f

Browse files
committed
Add 'get_network_routes' tool
1 parent 0acfb06 commit 612204f

7 files changed

Lines changed: 212 additions & 0 deletions

File tree

src/linux_mcp_server/commands.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ class CommandGroup(BaseModel):
135135
"stats": CommandSpec(args=("cat", "/proc/net/dev")),
136136
}
137137
),
138+
"network_routes": CommandGroup(
139+
commands={
140+
"default": CommandSpec(args=("ip", "route")),
141+
}
142+
),
138143
# === Logs ===
139144
"journal_logs": CommandGroup(
140145
commands={

src/linux_mcp_server/formatters.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from linux_mcp_server.models import NetworkConnection
99
from linux_mcp_server.models import NetworkInterface
1010
from linux_mcp_server.models import ProcessInfo
11+
from linux_mcp_server.models import Route
1112
from linux_mcp_server.utils import format_bytes
1213

1314

@@ -140,6 +141,37 @@ def format_network_interfaces(
140141
return "\n".join(lines)
141142

142143

144+
def format_routes(
145+
routes: list[Route],
146+
header: str = "=== Routing Table ===\n",
147+
) -> str:
148+
"""Format routing table entries into a readable string.
149+
150+
Args:
151+
routes: List of Route objects.
152+
header: Header text for the output.
153+
154+
Returns:
155+
Formatted string representation.
156+
"""
157+
lines = [header]
158+
lines.append(
159+
f"{'Destination':<20} {'Gateway':<18} {'Device':<10} {'Protocol':<10} {'Scope':<10} {'Source':<18} {'Metric'}"
160+
)
161+
lines.append("-" * 110)
162+
163+
for route in routes:
164+
gateway = route.gateway or "-"
165+
metric = str(route.metric) if route.metric is not None else "-"
166+
lines.append(
167+
f"{route.destination:<20} {gateway:<18} {route.device:<10} {route.protocol:<10} "
168+
f"{route.scope:<10} {route.source:<18} {metric}"
169+
)
170+
171+
lines.append(f"\n\nTotal routes: {len(routes)}")
172+
return "\n".join(lines)
173+
174+
143175
def format_process_detail(
144176
ps_output: str,
145177
proc_status: dict[str, str] | None = None,

src/linux_mcp_server/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ class ListeningPort(BaseModel):
4343
process: str = ""
4444

4545

46+
class Route(BaseModel):
47+
"""Parsed route entry from ip route output."""
48+
49+
destination: str
50+
gateway: str = ""
51+
device: str = ""
52+
protocol: str = ""
53+
scope: str = ""
54+
source: str = ""
55+
metric: int | None = None
56+
57+
4658
class NetworkInterface(BaseModel):
4759
"""Parsed network interface information."""
4860

src/linux_mcp_server/parsers.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from linux_mcp_server.models import NetworkInterface
1414
from linux_mcp_server.models import NodeEntry
1515
from linux_mcp_server.models import ProcessInfo
16+
from linux_mcp_server.models import Route
1617
from linux_mcp_server.models import SwapInfo
1718
from linux_mcp_server.models import SystemInfo
1819
from linux_mcp_server.models import SystemMemory
@@ -280,6 +281,81 @@ def parse_ip_brief(stdout: str) -> dict[str, NetworkInterface]:
280281
return interfaces
281282

282283

284+
def parse_ip_route(stdout: str) -> list[Route]:
285+
"""Parse ip route output into Route objects.
286+
287+
Handles standard ``ip route`` output where each line describes a route.
288+
Key-value tokens (e.g. ``via``, ``dev``, ``proto``, ``scope``, ``src``,
289+
``metric``) are extracted into the corresponding model fields.
290+
291+
Args:
292+
stdout: Raw output from ip route command.
293+
294+
Returns:
295+
List of Route objects.
296+
"""
297+
routes: list[Route] = []
298+
lines = stdout.strip().split("\n")
299+
300+
for line in lines:
301+
line = line.strip()
302+
if not line:
303+
continue
304+
305+
parts = line.split()
306+
if not parts:
307+
continue
308+
309+
destination = parts[0]
310+
gateway = ""
311+
device = ""
312+
protocol = ""
313+
scope = ""
314+
source = ""
315+
metric: int | None = None
316+
317+
i = 1
318+
while i < len(parts):
319+
token = parts[i]
320+
if token == "via" and i + 1 < len(parts):
321+
gateway = parts[i + 1]
322+
i += 2
323+
elif token == "dev" and i + 1 < len(parts):
324+
device = parts[i + 1]
325+
i += 2
326+
elif token == "proto" and i + 1 < len(parts):
327+
protocol = parts[i + 1]
328+
i += 2
329+
elif token == "scope" and i + 1 < len(parts):
330+
scope = parts[i + 1]
331+
i += 2
332+
elif token == "src" and i + 1 < len(parts):
333+
source = parts[i + 1]
334+
i += 2
335+
elif token == "metric" and i + 1 < len(parts):
336+
try:
337+
metric = int(parts[i + 1])
338+
except ValueError:
339+
pass
340+
i += 2
341+
else:
342+
i += 1
343+
344+
routes.append(
345+
Route(
346+
destination=destination,
347+
gateway=gateway,
348+
device=device,
349+
protocol=protocol,
350+
scope=scope,
351+
source=source,
352+
metric=metric,
353+
)
354+
)
355+
356+
return routes
357+
358+
283359
def parse_system_info(results: dict[str, str]) -> SystemInfo:
284360
"""Parse system info command results into SystemInfo object.
285361

src/linux_mcp_server/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from linux_mcp_server.tools.network import get_listening_ports
88
from linux_mcp_server.tools.network import get_network_connections
99
from linux_mcp_server.tools.network import get_network_interfaces
10+
from linux_mcp_server.tools.network import get_network_routes
1011

1112
# processes
1213
from linux_mcp_server.tools.processes import get_process_info
@@ -49,6 +50,7 @@
4950
"get_memory_information",
5051
"get_network_connections",
5152
"get_network_interfaces",
53+
"get_network_routes",
5254
"get_process_info",
5355
"get_service_logs",
5456
"get_service_status",

src/linux_mcp_server/tools/network.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from linux_mcp_server.formatters import format_listening_ports
88
from linux_mcp_server.formatters import format_network_connections
99
from linux_mcp_server.formatters import format_network_interfaces
10+
from linux_mcp_server.formatters import format_routes
1011
from linux_mcp_server.parsers import parse_ip_brief
12+
from linux_mcp_server.parsers import parse_ip_route
1113
from linux_mcp_server.parsers import parse_proc_net_dev
1214
from linux_mcp_server.parsers import parse_ss_connections
1315
from linux_mcp_server.parsers import parse_ss_listening
@@ -103,3 +105,29 @@ async def get_listening_ports(
103105
ports = parse_ss_listening(stdout)
104106
return format_listening_ports(ports)
105107
return f"Error getting listening ports: return code {returncode}, stderr: {stderr}"
108+
109+
110+
@mcp.tool(
111+
title="Get network routes",
112+
description="Get the system routing table showing network routes, gateways, and interfaces.",
113+
tags={"connectivity", "network", "routing"},
114+
annotations=ToolAnnotations(readOnlyHint=True),
115+
)
116+
@log_tool_call
117+
@disallow_local_execution_in_containers
118+
async def get_network_routes(
119+
host: Host = None,
120+
) -> str:
121+
"""Get network routing table.
122+
123+
Retrieves the system routing table including destination networks,
124+
gateways, devices, protocols, scopes, source addresses, and metrics.
125+
"""
126+
cmd = get_command("network_routes")
127+
128+
returncode, stdout, stderr = await cmd.run(host=host)
129+
130+
if is_successful_output(returncode, stdout):
131+
routes = parse_ip_route(stdout)
132+
return format_routes(routes)
133+
return f"Error getting network routes: return code {returncode}, stderr: {stderr}"

tests/tools/test_network.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,60 @@ async def test_get_listening_ports_error(self, mcp_client, mock_execute):
211211
match = re.compile(r"error calling tool.*raised intentionally", flags=re.I)
212212
with pytest.raises(ToolError, match=match):
213213
await mcp_client.call_tool("get_listening_ports")
214+
215+
216+
class TestGetNetworkRoutes:
217+
"""Test get_network_routes function."""
218+
219+
@pytest.mark.parametrize(
220+
("host", "mock_output", "expected_content"),
221+
[
222+
pytest.param(
223+
None,
224+
"default via 192.168.1.1 dev eth0 proto dhcp src 192.168.1.100 metric 100\n"
225+
"192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.100\n"
226+
"172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1",
227+
["192.168.1.1", "eth0", "Total routes: 3"],
228+
id="local",
229+
),
230+
pytest.param(
231+
"remote.host",
232+
"default via 10.0.0.1 dev ens5 proto dhcp metric 100\n"
233+
"10.0.0.0/24 dev ens5 proto kernel scope link src 10.0.0.5",
234+
["10.0.0.1", "ens5"],
235+
id="remote",
236+
),
237+
],
238+
)
239+
async def test_get_network_routes_success(self, mcp_client, mock_execute, host, mock_output, expected_content):
240+
"""Test getting network routes with success."""
241+
mock_execute.return_value = (0, mock_output, "")
242+
result = await mcp_client.call_tool("get_network_routes", arguments={"host": host})
243+
result_text = result.content[0].text.casefold()
244+
245+
assert "routing table" in result_text
246+
assert all(content.casefold() in result_text for content in expected_content), (
247+
"Did not find all expected values"
248+
)
249+
250+
@pytest.mark.parametrize(
251+
("return_value",),
252+
[
253+
pytest.param((1, "", "Command not found"), id="command_fails"),
254+
pytest.param((0, "", ""), id="empty_output"),
255+
],
256+
)
257+
async def test_get_network_routes_failure(self, mcp_client, mock_execute, return_value):
258+
"""Test getting network routes when command fails or returns empty."""
259+
mock_execute.return_value = return_value
260+
result = await mcp_client.call_tool("get_network_routes")
261+
result_text = result.content[0].text.casefold()
262+
263+
assert "error" in result_text
264+
265+
async def test_get_network_routes_error(self, mcp_client, mock_execute):
266+
"""Test getting network routes with general error."""
267+
mock_execute.side_effect = ValueError("Raised intentionally")
268+
match = re.compile(r"error calling tool.*raised intentionally", flags=re.I)
269+
with pytest.raises(ToolError, match=match):
270+
await mcp_client.call_tool("get_network_routes")

0 commit comments

Comments
 (0)