eos-mcp 0.2.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.
- eos_mcp/__init__.py +8 -0
- eos_mcp/__main__.py +92 -0
- eos_mcp/config.py +70 -0
- eos_mcp/eapi.py +307 -0
- eos_mcp/server.py +430 -0
- eos_mcp-0.2.0.dist-info/METADATA +109 -0
- eos_mcp-0.2.0.dist-info/RECORD +10 -0
- eos_mcp-0.2.0.dist-info/WHEEL +5 -0
- eos_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- eos_mcp-0.2.0.dist-info/top_level.txt +1 -0
eos_mcp/__init__.py
ADDED
eos_mcp/__main__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Allow running as: python -m eos_mcp"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from eos_mcp import __version__
|
|
10
|
+
from eos_mcp import config as cfg_mod
|
|
11
|
+
from eos_mcp import eapi
|
|
12
|
+
from eos_mcp.server import _ensure_config, mcp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _check_config(check_host: str | None = None) -> int:
|
|
16
|
+
"""Verify config.ini is loadable and optionally test eAPI connectivity."""
|
|
17
|
+
err = _ensure_config("")
|
|
18
|
+
if err:
|
|
19
|
+
print(f"Configuration error: {err}", file=sys.stderr)
|
|
20
|
+
return 1
|
|
21
|
+
|
|
22
|
+
cfg, path = cfg_mod.load("")
|
|
23
|
+
hosts = cfg_mod.get_hosts(cfg)
|
|
24
|
+
print(f"OK: config loaded from {path}")
|
|
25
|
+
print(f"Devices ({len(hosts)}): {', '.join(hosts) if hosts else '(none)'}")
|
|
26
|
+
|
|
27
|
+
if check_host is None:
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
if not cfg.has_section(check_host):
|
|
31
|
+
print(f"Error: host '{check_host}' not found in config", file=sys.stderr)
|
|
32
|
+
return 2
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
creds = cfg_mod.get_creds(cfg, check_host)
|
|
36
|
+
node = eapi.get_node(host=check_host, **creds)
|
|
37
|
+
result = node.execute(["show version"])
|
|
38
|
+
model = result["result"][0].get("modelName", "?")
|
|
39
|
+
version = result["result"][0].get("version", "?")
|
|
40
|
+
print(f"OK: connected to {check_host} (model={model}, EOS={version})")
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print(f"Connection error ({check_host}): {e}", file=sys.stderr)
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> None:
|
|
49
|
+
"""Entry point for console_scripts."""
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="eos-mcp",
|
|
52
|
+
description=(
|
|
53
|
+
"MCP server for Arista EOS. Exposes EOS device operations "
|
|
54
|
+
"(show commands, config management, tech-support collection) "
|
|
55
|
+
"to AI assistants via eAPI."
|
|
56
|
+
),
|
|
57
|
+
epilog=(
|
|
58
|
+
"Config file discovery order: --config_path argument > "
|
|
59
|
+
f"{cfg_mod.CONFIG_ENV_VAR} env var > ./config.ini > "
|
|
60
|
+
"~/.config/eos-mcp/config.ini."
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--check",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Verify config.ini is loadable and list devices, then exit.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--check-host",
|
|
71
|
+
metavar="HOSTNAME",
|
|
72
|
+
help="With --check, also open an eAPI connection to HOSTNAME.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--transport",
|
|
76
|
+
choices=["stdio", "streamable-http"],
|
|
77
|
+
default="stdio",
|
|
78
|
+
help="Transport protocol (default: stdio)",
|
|
79
|
+
)
|
|
80
|
+
args = parser.parse_args()
|
|
81
|
+
|
|
82
|
+
if args.check or args.check_host:
|
|
83
|
+
sys.exit(_check_config(args.check_host))
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
mcp.run(transport=args.transport)
|
|
87
|
+
except KeyboardInterrupt:
|
|
88
|
+
os._exit(0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
eos_mcp/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Configuration file loader (XDG-compliant).
|
|
2
|
+
|
|
3
|
+
Config file discovery order:
|
|
4
|
+
1. explicit override argument
|
|
5
|
+
2. EOS_MCP_CONFIG environment variable
|
|
6
|
+
3. ./config.ini (current directory)
|
|
7
|
+
4. ~/.config/eos-mcp/config.ini
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import configparser
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CONFIG_ENV_VAR = "EOS_MCP_CONFIG"
|
|
17
|
+
_XDG_PATH = Path.home() / ".config" / "eos-mcp" / "config.ini"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_config_path(override: str = "") -> Path:
|
|
21
|
+
"""Resolve config file path using discovery order."""
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override)
|
|
24
|
+
env = os.environ.get(CONFIG_ENV_VAR, "")
|
|
25
|
+
if env:
|
|
26
|
+
return Path(env)
|
|
27
|
+
local = Path("config.ini")
|
|
28
|
+
if local.exists():
|
|
29
|
+
return local
|
|
30
|
+
return _XDG_PATH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load(config_path: str = "") -> tuple[configparser.ConfigParser, Path]:
|
|
34
|
+
"""Load config and return (ConfigParser, resolved_path).
|
|
35
|
+
|
|
36
|
+
Raises FileNotFoundError if the resolved path does not exist.
|
|
37
|
+
"""
|
|
38
|
+
path = find_config_path(config_path)
|
|
39
|
+
cfg = configparser.ConfigParser()
|
|
40
|
+
read = cfg.read(path)
|
|
41
|
+
if not read:
|
|
42
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
43
|
+
return cfg, path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_hosts(cfg: configparser.ConfigParser, tags: list[str] | None = None) -> list[str]:
|
|
47
|
+
"""Return section names (hostnames), optionally filtered by tags.
|
|
48
|
+
|
|
49
|
+
A host matches if it has *any* of the requested tags.
|
|
50
|
+
"""
|
|
51
|
+
result = []
|
|
52
|
+
for section in cfg.sections():
|
|
53
|
+
if tags:
|
|
54
|
+
raw = cfg.get(section, "tags", fallback="")
|
|
55
|
+
host_tags = {t.strip() for t in raw.split(",") if t.strip()}
|
|
56
|
+
if not host_tags.intersection(tags):
|
|
57
|
+
continue
|
|
58
|
+
result.append(section)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_creds(cfg: configparser.ConfigParser, host: str) -> dict[str, str | bool]:
|
|
63
|
+
"""Return connection credentials for a host (falls back to DEFAULT)."""
|
|
64
|
+
defaults = cfg.defaults()
|
|
65
|
+
return {
|
|
66
|
+
"username": cfg.get(host, "username", fallback=defaults.get("username", "")),
|
|
67
|
+
"password": cfg.get(host, "password", fallback=defaults.get("password", "")),
|
|
68
|
+
"transport": cfg.get(host, "transport", fallback=defaults.get("transport", "https")),
|
|
69
|
+
"verify": cfg.getboolean(host, "verify", fallback=False),
|
|
70
|
+
}
|
eos_mcp/eapi.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""pyeapi client wrapper with TLS compatibility and connection cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ssl
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pyeapi
|
|
10
|
+
|
|
11
|
+
# EOS 4.28.x supports TLS 1.0–1.2 with legacy cipher suites.
|
|
12
|
+
# Python 3.14 raised the default security level, causing
|
|
13
|
+
# SSLV3_ALERT_HANDSHAKE_FAILURE. Patch _create_unverified_context globally
|
|
14
|
+
# so pyeapi's HTTPS transport works with older EOS versions.
|
|
15
|
+
_orig_create_unverified_context = ssl._create_unverified_context
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tls_compat_context(protocol=ssl.PROTOCOL_TLS_CLIENT, **kwargs):
|
|
19
|
+
ctx = _orig_create_unverified_context(protocol, **kwargs)
|
|
20
|
+
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
|
|
21
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
|
22
|
+
return ctx
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ssl._create_unverified_context = _tls_compat_context
|
|
26
|
+
|
|
27
|
+
# hostname -> pyeapi.client.Node
|
|
28
|
+
_cache: dict[str, pyeapi.client.Node] = {}
|
|
29
|
+
_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_node(
|
|
33
|
+
host: str,
|
|
34
|
+
username: str,
|
|
35
|
+
password: str,
|
|
36
|
+
transport: str = "https",
|
|
37
|
+
verify: bool = False,
|
|
38
|
+
) -> pyeapi.client.Node:
|
|
39
|
+
"""Return a cached Node, creating a new connection if needed."""
|
|
40
|
+
with _lock:
|
|
41
|
+
if host not in _cache:
|
|
42
|
+
_cache[host] = pyeapi.connect(
|
|
43
|
+
host=host,
|
|
44
|
+
username=username,
|
|
45
|
+
password=password,
|
|
46
|
+
transport=transport,
|
|
47
|
+
verify=verify,
|
|
48
|
+
)
|
|
49
|
+
return _cache[host]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clear_cache(host: str | None = None) -> None:
|
|
53
|
+
"""Remove cached node(s) so the next call creates a fresh connection."""
|
|
54
|
+
with _lock:
|
|
55
|
+
if host is not None:
|
|
56
|
+
_cache.pop(host, None)
|
|
57
|
+
else:
|
|
58
|
+
_cache.clear()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_show(node: pyeapi.client.Node, command: str) -> str:
|
|
62
|
+
"""Run a single show command and return text output."""
|
|
63
|
+
result = node.execute([command], encoding="text")
|
|
64
|
+
return result["result"][0].get("output", "")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_shows(node: pyeapi.client.Node, commands: list[str]) -> str:
|
|
68
|
+
"""Run multiple show commands and return labelled concatenated text."""
|
|
69
|
+
result = node.execute(commands, encoding="text")
|
|
70
|
+
parts = [
|
|
71
|
+
f"--- {cmd} ---\n{result['result'][i].get('output', '')}"
|
|
72
|
+
for i, cmd in enumerate(commands)
|
|
73
|
+
]
|
|
74
|
+
return "\n".join(parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_running_config(node: pyeapi.client.Node) -> str:
|
|
78
|
+
"""Return running-config as text."""
|
|
79
|
+
return run_show(node, "show running-config")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _timer_str(seconds: int) -> str:
|
|
83
|
+
h, rem = divmod(seconds, 3600)
|
|
84
|
+
m, s = divmod(rem, 60)
|
|
85
|
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def push_config(
|
|
89
|
+
node: pyeapi.client.Node,
|
|
90
|
+
session_name: str,
|
|
91
|
+
config_lines: list[str],
|
|
92
|
+
dry_run: bool = True,
|
|
93
|
+
commit_timer: int = 300,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Apply config lines via a named configure session.
|
|
96
|
+
|
|
97
|
+
All commands are sent in a single eAPI call to maintain session context.
|
|
98
|
+
When dry_run=True the session is aborted after showing diffs.
|
|
99
|
+
When dry_run=False the session is committed with a rollback timer.
|
|
100
|
+
|
|
101
|
+
Returns dict: {session_name, diffs, committed, timer_seconds}.
|
|
102
|
+
"""
|
|
103
|
+
terminate = "abort" if dry_run else f"commit timer {_timer_str(commit_timer)}"
|
|
104
|
+
cmds = (
|
|
105
|
+
[f"configure session {session_name}"]
|
|
106
|
+
+ list(config_lines)
|
|
107
|
+
+ ["show session-config diffs", terminate]
|
|
108
|
+
)
|
|
109
|
+
result = node.execute(cmds, encoding="text")
|
|
110
|
+
diffs_idx = 1 + len(config_lines)
|
|
111
|
+
diffs = result["result"][diffs_idx].get("output", "(no diffs)")
|
|
112
|
+
return {
|
|
113
|
+
"session_name": session_name,
|
|
114
|
+
"diffs": diffs,
|
|
115
|
+
"committed": not dry_run,
|
|
116
|
+
"timer_seconds": 0 if dry_run else commit_timer,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def confirm_session(node: pyeapi.client.Node, session_name: str) -> str:
|
|
121
|
+
"""Confirm (finalize) a pending configure session commit timer."""
|
|
122
|
+
node.execute([f"configure session {session_name} commit"])
|
|
123
|
+
return f"Session '{session_name}' confirmed and committed."
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def abort_session(node: pyeapi.client.Node, session_name: str) -> str:
|
|
127
|
+
"""Abort a pending configure session."""
|
|
128
|
+
node.execute([f"configure session {session_name} abort"])
|
|
129
|
+
return f"Session '{session_name}' aborted."
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def collect_tech_support(node: pyeapi.client.Node) -> str:
|
|
133
|
+
"""Return show tech-support output (large — may take 30+ seconds)."""
|
|
134
|
+
return run_show(node, "show tech-support")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_device_facts(node: pyeapi.client.Node) -> dict[str, Any]:
|
|
138
|
+
"""Return structured device facts from show version + show hostname.
|
|
139
|
+
|
|
140
|
+
Keys: hostname, fqdn, model, serial, version, hardware_revision,
|
|
141
|
+
uptime_seconds, memory_total_kb, memory_free_kb, mac, architecture.
|
|
142
|
+
"""
|
|
143
|
+
result = node.execute(["show version", "show hostname"])
|
|
144
|
+
v = result["result"][0]
|
|
145
|
+
h = result["result"][1]
|
|
146
|
+
return {
|
|
147
|
+
"hostname": h.get("hostname", ""),
|
|
148
|
+
"fqdn": h.get("fqdn", ""),
|
|
149
|
+
"model": v.get("modelName", ""),
|
|
150
|
+
"serial": v.get("serialNumber", ""),
|
|
151
|
+
"version": v.get("version", ""),
|
|
152
|
+
"hardware_revision": v.get("hardwareRevision", ""),
|
|
153
|
+
"uptime_seconds": v.get("uptime", 0),
|
|
154
|
+
"memory_total_kb": v.get("memTotal", 0),
|
|
155
|
+
"memory_free_kb": v.get("memFree", 0),
|
|
156
|
+
"mac": v.get("systemMacAddress", ""),
|
|
157
|
+
"architecture": v.get("architecture", ""),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_config_diff(node: pyeapi.client.Node, rollback_id: int = 1) -> str:
|
|
162
|
+
"""Return config diff between running-config and a rollback checkpoint.
|
|
163
|
+
|
|
164
|
+
rollback_id=1 → diff vs most recent rollback checkpoint (default)
|
|
165
|
+
rollback_id=N → diff vs Nth rollback checkpoint
|
|
166
|
+
|
|
167
|
+
Note: 'show running-config diffs' (vs startup) requires EOS 4.30+.
|
|
168
|
+
This function uses 'show rollback config <N>' which is available on
|
|
169
|
+
EOS 4.28+, falling back gracefully when no rollbacks exist.
|
|
170
|
+
"""
|
|
171
|
+
candidates = [
|
|
172
|
+
f"show rollback config {rollback_id}",
|
|
173
|
+
f"show running-config diffs",
|
|
174
|
+
]
|
|
175
|
+
last_err = ""
|
|
176
|
+
for cmd in candidates:
|
|
177
|
+
try:
|
|
178
|
+
return run_show(node, cmd)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
last_err = str(e)
|
|
181
|
+
return f"Config diff not available on this EOS version: {last_err}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def list_config_sessions(node: pyeapi.client.Node) -> str:
|
|
185
|
+
"""Return configure session list with state (show configuration sessions detail)."""
|
|
186
|
+
return run_show(node, "show configuration sessions detail")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _get_environment_text(node: pyeapi.client.Node) -> str:
|
|
190
|
+
"""Return environment status text, trying EOS 4.28+ and older syntax."""
|
|
191
|
+
for cmd in ("show system environment all", "show environment all"):
|
|
192
|
+
try:
|
|
193
|
+
return run_show(node, cmd)
|
|
194
|
+
except Exception:
|
|
195
|
+
continue
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_health(node: pyeapi.client.Node) -> dict[str, Any]:
|
|
200
|
+
"""Run health checks for daily_brief.
|
|
201
|
+
|
|
202
|
+
Checks: device facts (uptime, memory), environment (temperature/cooling/fans/PSUs),
|
|
203
|
+
errdisabled interfaces, and MLAG status (when active).
|
|
204
|
+
|
|
205
|
+
Returns dict: {anomalies: list[str], info: dict}.
|
|
206
|
+
anomalies entries are prefixed with 'CRITICAL:' or 'WARNING:'.
|
|
207
|
+
"""
|
|
208
|
+
anomalies: list[str] = []
|
|
209
|
+
info: dict[str, Any] = {}
|
|
210
|
+
|
|
211
|
+
# Device facts (uptime + memory — no extra API call)
|
|
212
|
+
try:
|
|
213
|
+
facts = get_device_facts(node)
|
|
214
|
+
info["hostname"] = facts["hostname"]
|
|
215
|
+
info["model"] = facts["model"]
|
|
216
|
+
info["version"] = facts["version"]
|
|
217
|
+
uptime_s = int(facts["uptime_seconds"])
|
|
218
|
+
uptime_d, rem = divmod(uptime_s, 86400)
|
|
219
|
+
uptime_h, rem2 = divmod(rem, 3600)
|
|
220
|
+
uptime_m = rem2 // 60
|
|
221
|
+
if uptime_d == 0 and uptime_h == 0:
|
|
222
|
+
info["uptime"] = f"{uptime_m}m"
|
|
223
|
+
elif uptime_d == 0:
|
|
224
|
+
info["uptime"] = f"{uptime_h}h"
|
|
225
|
+
else:
|
|
226
|
+
info["uptime"] = f"{uptime_d}d {uptime_h}h"
|
|
227
|
+
if uptime_d == 0:
|
|
228
|
+
display = f"{uptime_m}m" if uptime_h == 0 else f"{uptime_h}h"
|
|
229
|
+
anomalies.append(f"WARNING: uptime {display} — recent reboot?")
|
|
230
|
+
total_kb = facts.get("memory_total_kb", 0)
|
|
231
|
+
free_kb = facts.get("memory_free_kb", 0)
|
|
232
|
+
if total_kb > 0:
|
|
233
|
+
used_pct = (total_kb - free_kb) / total_kb * 100
|
|
234
|
+
info["memory_pct"] = round(used_pct, 1)
|
|
235
|
+
if used_pct >= 90:
|
|
236
|
+
anomalies.append(
|
|
237
|
+
f"CRITICAL: memory {used_pct:.0f}% used ({free_kb // 1024} MB free)"
|
|
238
|
+
)
|
|
239
|
+
elif used_pct >= 80:
|
|
240
|
+
anomalies.append(
|
|
241
|
+
f"WARNING: memory {used_pct:.0f}% used ({free_kb // 1024} MB free)"
|
|
242
|
+
)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
anomalies.append(f"CRITICAL: cannot fetch facts: {exc}")
|
|
245
|
+
|
|
246
|
+
# Environment (temperature / cooling / fans / PSUs)
|
|
247
|
+
try:
|
|
248
|
+
env = _get_environment_text(node)
|
|
249
|
+
if env:
|
|
250
|
+
for _prefix in ("System temperature status", "System cooling status"):
|
|
251
|
+
_sline = next((l.strip() for l in env.splitlines() if l.startswith(_prefix)), None)
|
|
252
|
+
if _sline is not None and "Ok" not in _sline:
|
|
253
|
+
anomalies.append(f"CRITICAL: {_sline}")
|
|
254
|
+
for line in env.splitlines():
|
|
255
|
+
stripped = line.strip()
|
|
256
|
+
# Fan/PSU failure: lines starting with a digit, PowerSupply, or Fan (FanTray, Fan1/1…)
|
|
257
|
+
if stripped and " Fail" in stripped:
|
|
258
|
+
if stripped[0].isdigit() or stripped.startswith(("PowerSupply", "Fan")):
|
|
259
|
+
anomalies.append(f"CRITICAL: hardware Fail: {stripped}")
|
|
260
|
+
else:
|
|
261
|
+
anomalies.append("WARNING: environment status unavailable")
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
anomalies.append(f"WARNING: environment check failed: {exc}")
|
|
264
|
+
|
|
265
|
+
# Errdisabled interfaces
|
|
266
|
+
try:
|
|
267
|
+
intf_text = run_show(node, "show interfaces status")
|
|
268
|
+
errdisabled = [
|
|
269
|
+
line.split()[0]
|
|
270
|
+
for line in intf_text.splitlines()
|
|
271
|
+
if "errdisabled" in line and line.strip()
|
|
272
|
+
]
|
|
273
|
+
info["errdisabled"] = errdisabled
|
|
274
|
+
if errdisabled:
|
|
275
|
+
anomalies.append(f"WARNING: errdisabled: {', '.join(errdisabled)}")
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
anomalies.append(f"WARNING: interface check failed: {exc}")
|
|
278
|
+
|
|
279
|
+
# MLAG status (ignore when disabled or not configured)
|
|
280
|
+
try:
|
|
281
|
+
mlag_text = run_show(node, "show mlag")
|
|
282
|
+
mlag_state = ""
|
|
283
|
+
mlag_neg = ""
|
|
284
|
+
mlag_peer = ""
|
|
285
|
+
for line in mlag_text.splitlines():
|
|
286
|
+
stripped = line.strip()
|
|
287
|
+
if stripped.startswith("state") and ":" in stripped:
|
|
288
|
+
mlag_state = stripped.split(":", 1)[1].strip().lower()
|
|
289
|
+
elif stripped.startswith("negotiation status") and ":" in stripped:
|
|
290
|
+
mlag_neg = stripped.split(":", 1)[1].strip().lower()
|
|
291
|
+
elif stripped.startswith("peer-link status") and ":" in stripped:
|
|
292
|
+
mlag_peer = stripped.split(":", 1)[1].strip().lower()
|
|
293
|
+
if mlag_state and mlag_state != "disabled":
|
|
294
|
+
info["mlag_state"] = mlag_state
|
|
295
|
+
info["mlag_neg"] = mlag_neg
|
|
296
|
+
info["mlag_peer"] = mlag_peer
|
|
297
|
+
if mlag_state != "active":
|
|
298
|
+
anomalies.append(f"CRITICAL: MLAG state={mlag_state}")
|
|
299
|
+
else:
|
|
300
|
+
if mlag_neg and mlag_neg != "connected":
|
|
301
|
+
anomalies.append(f"CRITICAL: MLAG negotiation={mlag_neg}")
|
|
302
|
+
if mlag_peer and mlag_peer != "up":
|
|
303
|
+
anomalies.append(f"CRITICAL: MLAG peer-link={mlag_peer}")
|
|
304
|
+
except Exception:
|
|
305
|
+
pass # MLAG not configured on this device
|
|
306
|
+
|
|
307
|
+
return {"anomalies": anomalies, "info": info}
|
eos_mcp/server.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""MCP server exposing Arista EOS device operations via eAPI.
|
|
2
|
+
|
|
3
|
+
Provides tools for show commands, device facts, config retrieval,
|
|
4
|
+
config push via configure session (with commit timer + confirm/abort),
|
|
5
|
+
config diff, session management, and tech-support collection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import datetime
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
from eos_mcp import config as cfg_mod
|
|
17
|
+
from eos_mcp import eapi
|
|
18
|
+
|
|
19
|
+
mcp = FastMCP("eos-mcp")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _config_path(override: str) -> str:
|
|
23
|
+
return override or os.environ.get(cfg_mod.CONFIG_ENV_VAR, "")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _connect(host: str, config_path: str) -> eapi.pyeapi.client.Node:
|
|
27
|
+
"""Load config and return a connected Node for host."""
|
|
28
|
+
cfg, _ = cfg_mod.load(config_path)
|
|
29
|
+
creds = cfg_mod.get_creds(cfg, host)
|
|
30
|
+
return eapi.get_node(host=host, **creds)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_hosts(
|
|
34
|
+
cfg,
|
|
35
|
+
hostnames: list[str] | None,
|
|
36
|
+
tags: list[str] | None,
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
result = set(hostnames or [])
|
|
39
|
+
if tags:
|
|
40
|
+
result.update(cfg_mod.get_hosts(cfg, tags))
|
|
41
|
+
return sorted(result)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_config(config_path: str) -> str | None:
|
|
45
|
+
"""Return error string if config is unloadable, else None."""
|
|
46
|
+
try:
|
|
47
|
+
cfg_mod.load(config_path)
|
|
48
|
+
return None
|
|
49
|
+
except FileNotFoundError as e:
|
|
50
|
+
return str(e)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def get_router_list(tags: list[str] | None = None, config_path: str = "") -> str:
|
|
55
|
+
"""List EOS devices registered in config, optionally filtered by tags."""
|
|
56
|
+
try:
|
|
57
|
+
cfg, path = cfg_mod.load(_config_path(config_path))
|
|
58
|
+
except FileNotFoundError as e:
|
|
59
|
+
return f"Error: {e}"
|
|
60
|
+
hosts = cfg_mod.get_hosts(cfg, tags)
|
|
61
|
+
if not hosts:
|
|
62
|
+
return f"No hosts found (config: {path}, tags: {tags})"
|
|
63
|
+
lines = [f"Hosts ({len(hosts)}) from {path}:"]
|
|
64
|
+
for h in hosts:
|
|
65
|
+
host_tags = cfg.get(h, "tags", fallback="")
|
|
66
|
+
lines.append(f" {h} tags={host_tags or '(none)'}")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@mcp.tool()
|
|
71
|
+
def get_device_facts(hostname: str, config_path: str = "") -> str:
|
|
72
|
+
"""Return structured device facts: hostname, model, serial, EOS version, uptime, memory."""
|
|
73
|
+
try:
|
|
74
|
+
node = _connect(hostname, _config_path(config_path))
|
|
75
|
+
f = eapi.get_device_facts(node)
|
|
76
|
+
uptime_h = int(f["uptime_seconds"]) // 3600
|
|
77
|
+
uptime_d, uptime_h = divmod(uptime_h, 24)
|
|
78
|
+
fqdn_line = f"\nfqdn: {f['fqdn']}" if f["fqdn"] != f["hostname"] else ""
|
|
79
|
+
return (
|
|
80
|
+
f"hostname: {f['hostname']}{fqdn_line}\n"
|
|
81
|
+
f"model: {f['model']}\n"
|
|
82
|
+
f"serial: {f['serial']}\n"
|
|
83
|
+
f"EOS version: {f['version']}\n"
|
|
84
|
+
f"hardware revision: {f['hardware_revision']}\n"
|
|
85
|
+
f"uptime: {uptime_d}d {uptime_h}h\n"
|
|
86
|
+
f"memory total: {f['memory_total_kb'] // 1024} MB\n"
|
|
87
|
+
f"memory free: {f['memory_free_kb'] // 1024} MB\n"
|
|
88
|
+
f"MAC: {f['mac']}\n"
|
|
89
|
+
f"architecture: {f['architecture']}"
|
|
90
|
+
)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
eapi.clear_cache(hostname)
|
|
93
|
+
return f"Error ({hostname}): {e}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@mcp.tool()
|
|
97
|
+
def get_device_facts_batch(
|
|
98
|
+
hostnames: list[str] | None = None,
|
|
99
|
+
tags: list[str] | None = None,
|
|
100
|
+
max_workers: int = 5,
|
|
101
|
+
config_path: str = "",
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Return device facts (model, serial, EOS version, uptime) for multiple devices in parallel."""
|
|
104
|
+
try:
|
|
105
|
+
cfg, _ = cfg_mod.load(_config_path(config_path))
|
|
106
|
+
except FileNotFoundError as e:
|
|
107
|
+
return f"Error: {e}"
|
|
108
|
+
|
|
109
|
+
targets = _resolve_hosts(cfg, hostnames, tags)
|
|
110
|
+
if not targets:
|
|
111
|
+
return "No hosts resolved."
|
|
112
|
+
|
|
113
|
+
cp = _config_path(config_path)
|
|
114
|
+
|
|
115
|
+
def _run_one(host: str) -> tuple[str, str]:
|
|
116
|
+
try:
|
|
117
|
+
node = _connect(host, cp)
|
|
118
|
+
f = eapi.get_device_facts(node)
|
|
119
|
+
uptime_h = int(f["uptime_seconds"]) // 3600
|
|
120
|
+
uptime_d, uptime_h = divmod(uptime_h, 24)
|
|
121
|
+
return host, f"{f['model']} EOS {f['version']} serial={f['serial']} up={uptime_d}d{uptime_h}h"
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
eapi.clear_cache(host)
|
|
124
|
+
return host, f"Error: {exc}"
|
|
125
|
+
|
|
126
|
+
results: dict[str, str] = {}
|
|
127
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
128
|
+
for host, output in pool.map(_run_one, targets):
|
|
129
|
+
results[host] = output
|
|
130
|
+
|
|
131
|
+
lines = [f"Device facts ({len(targets)} hosts):"]
|
|
132
|
+
for host in targets:
|
|
133
|
+
lines.append(f" {host:<35} {results.get(host, '')}")
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
def get_version(hostname: str, config_path: str = "") -> str:
|
|
139
|
+
"""Return EOS version string for a device (quick connectivity check)."""
|
|
140
|
+
try:
|
|
141
|
+
node = _connect(hostname, _config_path(config_path))
|
|
142
|
+
result = node.execute(["show version"])
|
|
143
|
+
v = result["result"][0]
|
|
144
|
+
return f"{v.get('modelName', '?')} EOS {v.get('version', '?')}"
|
|
145
|
+
except Exception as e:
|
|
146
|
+
eapi.clear_cache(hostname)
|
|
147
|
+
return f"Error ({hostname}): {e}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@mcp.tool()
|
|
151
|
+
def get_config_diff(hostname: str, rollback_id: int = 1, config_path: str = "") -> str:
|
|
152
|
+
"""Show config diff on an EOS device.
|
|
153
|
+
|
|
154
|
+
rollback_id=1 (default) → diff vs most recent rollback checkpoint.
|
|
155
|
+
rollback_id=N → diff vs Nth rollback checkpoint.
|
|
156
|
+
Note: diff vs startup-config requires EOS 4.30+; older versions fall back gracefully.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
node = _connect(hostname, _config_path(config_path))
|
|
160
|
+
diff = eapi.get_config_diff(node, rollback_id)
|
|
161
|
+
return diff if diff.strip() else "(no diff — running-config matches checkpoint)"
|
|
162
|
+
except Exception as e:
|
|
163
|
+
eapi.clear_cache(hostname)
|
|
164
|
+
return f"Error ({hostname}): {e}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@mcp.tool()
|
|
168
|
+
def list_config_sessions(hostname: str, config_path: str = "") -> str:
|
|
169
|
+
"""List configure sessions and their state on an EOS device.
|
|
170
|
+
|
|
171
|
+
Shows pending, pendingCommitTimer, and completed sessions.
|
|
172
|
+
Useful for tracking in-flight push_config operations.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
node = _connect(hostname, _config_path(config_path))
|
|
176
|
+
return eapi.list_config_sessions(node)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
eapi.clear_cache(hostname)
|
|
179
|
+
return f"Error ({hostname}): {e}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@mcp.tool()
|
|
183
|
+
def run_command(hostname: str, command: str, config_path: str = "") -> str:
|
|
184
|
+
"""Run a single enable-mode command on an EOS device and return text output."""
|
|
185
|
+
try:
|
|
186
|
+
node = _connect(hostname, _config_path(config_path))
|
|
187
|
+
return eapi.run_show(node, command)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
eapi.clear_cache(hostname)
|
|
190
|
+
return f"Error ({hostname}): {e}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@mcp.tool()
|
|
194
|
+
def run_commands(hostname: str, commands: list[str], config_path: str = "") -> str:
|
|
195
|
+
"""Run multiple enable-mode commands on one EOS device and return labelled output."""
|
|
196
|
+
try:
|
|
197
|
+
node = _connect(hostname, _config_path(config_path))
|
|
198
|
+
return eapi.run_shows(node, commands)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
eapi.clear_cache(hostname)
|
|
201
|
+
return f"Error ({hostname}): {e}"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@mcp.tool()
|
|
205
|
+
def run_command_batch(
|
|
206
|
+
command: str,
|
|
207
|
+
hostnames: list[str] | None = None,
|
|
208
|
+
tags: list[str] | None = None,
|
|
209
|
+
max_workers: int = 5,
|
|
210
|
+
config_path: str = "",
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Run an enable-mode command on multiple EOS devices in parallel.
|
|
213
|
+
|
|
214
|
+
Specify targets via 'hostnames', 'tags', or both.
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
cfg, _ = cfg_mod.load(_config_path(config_path))
|
|
218
|
+
except FileNotFoundError as e:
|
|
219
|
+
return f"Error: {e}"
|
|
220
|
+
|
|
221
|
+
targets = _resolve_hosts(cfg, hostnames, tags)
|
|
222
|
+
if not targets:
|
|
223
|
+
return "No hosts resolved (check hostnames/tags)."
|
|
224
|
+
|
|
225
|
+
cp = _config_path(config_path)
|
|
226
|
+
|
|
227
|
+
def _run_one(host: str) -> tuple[str, str]:
|
|
228
|
+
try:
|
|
229
|
+
node = _connect(host, cp)
|
|
230
|
+
return host, eapi.run_show(node, command)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
eapi.clear_cache(host)
|
|
233
|
+
return host, f"Error: {exc}"
|
|
234
|
+
|
|
235
|
+
results: dict[str, str] = {}
|
|
236
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
237
|
+
for host, output in pool.map(_run_one, targets):
|
|
238
|
+
results[host] = output
|
|
239
|
+
|
|
240
|
+
lines = []
|
|
241
|
+
for host in targets:
|
|
242
|
+
lines.append(f"# {host}")
|
|
243
|
+
lines.append(results.get(host, ""))
|
|
244
|
+
lines.append("")
|
|
245
|
+
return "\n".join(lines)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mcp.tool()
|
|
249
|
+
def get_config(hostname: str, config_path: str = "") -> str:
|
|
250
|
+
"""Retrieve the running-config from an EOS device."""
|
|
251
|
+
try:
|
|
252
|
+
node = _connect(hostname, _config_path(config_path))
|
|
253
|
+
return eapi.get_running_config(node)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
eapi.clear_cache(hostname)
|
|
256
|
+
return f"Error ({hostname}): {e}"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@mcp.tool()
|
|
260
|
+
def push_config(
|
|
261
|
+
hostname: str,
|
|
262
|
+
config_lines: list[str],
|
|
263
|
+
session_name: str = "mcp-push",
|
|
264
|
+
dry_run: bool = True,
|
|
265
|
+
commit_timer: int = 300,
|
|
266
|
+
config_path: str = "",
|
|
267
|
+
) -> str:
|
|
268
|
+
"""Push configuration to an EOS device via a named configure session.
|
|
269
|
+
|
|
270
|
+
When dry_run=True (default) shows diffs and aborts — no changes applied.
|
|
271
|
+
When dry_run=False commits with a rollback timer (default 300 seconds).
|
|
272
|
+
Call confirm_config_session before the timer expires to finalize.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
node = _connect(hostname, _config_path(config_path))
|
|
276
|
+
result = eapi.push_config(
|
|
277
|
+
node=node,
|
|
278
|
+
session_name=session_name,
|
|
279
|
+
config_lines=config_lines,
|
|
280
|
+
dry_run=dry_run,
|
|
281
|
+
commit_timer=commit_timer,
|
|
282
|
+
)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
eapi.clear_cache(hostname)
|
|
285
|
+
return f"Error ({hostname}): {e}"
|
|
286
|
+
|
|
287
|
+
if result["committed"]:
|
|
288
|
+
status = f"COMMITTED — timer={result['timer_seconds']}s, confirm with confirm_config_session"
|
|
289
|
+
else:
|
|
290
|
+
status = "DRY RUN — session aborted, no changes applied"
|
|
291
|
+
return f"Session '{result['session_name']}' [{status}]\n\nDiffs:\n{result['diffs']}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@mcp.tool()
|
|
295
|
+
def confirm_config_session(
|
|
296
|
+
hostname: str,
|
|
297
|
+
session_name: str = "mcp-push",
|
|
298
|
+
config_path: str = "",
|
|
299
|
+
) -> str:
|
|
300
|
+
"""Confirm (finalize) a pending configure session commit timer on an EOS device."""
|
|
301
|
+
try:
|
|
302
|
+
node = _connect(hostname, _config_path(config_path))
|
|
303
|
+
return eapi.confirm_session(node, session_name)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
eapi.clear_cache(hostname)
|
|
306
|
+
return f"Error ({hostname}): {e}"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@mcp.tool()
|
|
310
|
+
def abort_config_session(
|
|
311
|
+
hostname: str,
|
|
312
|
+
session_name: str = "mcp-push",
|
|
313
|
+
config_path: str = "",
|
|
314
|
+
) -> str:
|
|
315
|
+
"""Abort a pending configure session on an EOS device."""
|
|
316
|
+
try:
|
|
317
|
+
node = _connect(hostname, _config_path(config_path))
|
|
318
|
+
return eapi.abort_session(node, session_name)
|
|
319
|
+
except Exception as e:
|
|
320
|
+
eapi.clear_cache(hostname)
|
|
321
|
+
return f"Error ({hostname}): {e}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@mcp.tool()
|
|
325
|
+
def collect_tech_support(hostname: str, config_path: str = "") -> str:
|
|
326
|
+
"""Collect show tech-support from an EOS device (large output, 30+ seconds)."""
|
|
327
|
+
try:
|
|
328
|
+
node = _connect(hostname, _config_path(config_path))
|
|
329
|
+
return eapi.collect_tech_support(node)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
eapi.clear_cache(hostname)
|
|
332
|
+
return f"Error ({hostname}): {e}"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@mcp.tool()
|
|
336
|
+
def daily_brief(
|
|
337
|
+
hostnames: list[str] | None = None,
|
|
338
|
+
tags: list[str] | None = None,
|
|
339
|
+
max_workers: int = 5,
|
|
340
|
+
config_path: str = "",
|
|
341
|
+
) -> str:
|
|
342
|
+
"""Run health checks on EOS devices and return a Markdown daily brief.
|
|
343
|
+
|
|
344
|
+
Checks environment (temperature, cooling, fans, PSUs), errdisabled interfaces,
|
|
345
|
+
and device uptime. Returns CRITICAL/WARNING/OK per device with a summary.
|
|
346
|
+
Specify targets via 'hostnames', 'tags', or both (default: all configured devices).
|
|
347
|
+
"""
|
|
348
|
+
try:
|
|
349
|
+
cfg, _ = cfg_mod.load(_config_path(config_path))
|
|
350
|
+
except FileNotFoundError as e:
|
|
351
|
+
return f"Error: {e}"
|
|
352
|
+
|
|
353
|
+
targets = _resolve_hosts(cfg, hostnames, tags)
|
|
354
|
+
if not targets and hostnames is None and tags is None:
|
|
355
|
+
targets = cfg_mod.get_hosts(cfg, None)
|
|
356
|
+
if not targets:
|
|
357
|
+
return "No hosts resolved."
|
|
358
|
+
|
|
359
|
+
cp = _config_path(config_path)
|
|
360
|
+
|
|
361
|
+
def _run_one(host: str) -> tuple[str, dict]:
|
|
362
|
+
try:
|
|
363
|
+
node = _connect(host, cp)
|
|
364
|
+
return host, eapi.check_health(node)
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
eapi.clear_cache(host)
|
|
367
|
+
return host, {
|
|
368
|
+
"anomalies": [f"CRITICAL: connection failed: {exc}"],
|
|
369
|
+
"info": {},
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
results: dict[str, dict] = {}
|
|
373
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
374
|
+
for host, result in pool.map(_run_one, targets):
|
|
375
|
+
results[host] = result
|
|
376
|
+
|
|
377
|
+
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
378
|
+
lines = [f"## EOS デイリーブリーフ({now})", ""]
|
|
379
|
+
|
|
380
|
+
critical_count = warning_count = ok_count = 0
|
|
381
|
+
|
|
382
|
+
for host in targets:
|
|
383
|
+
r = results.get(host, {})
|
|
384
|
+
anomalies = r.get("anomalies", [])
|
|
385
|
+
info = r.get("info", {})
|
|
386
|
+
|
|
387
|
+
criticals = [a for a in anomalies if a.startswith("CRITICAL")]
|
|
388
|
+
warnings = [a for a in anomalies if a.startswith("WARNING")]
|
|
389
|
+
|
|
390
|
+
if criticals:
|
|
391
|
+
status = "CRITICAL"
|
|
392
|
+
critical_count += 1
|
|
393
|
+
elif warnings:
|
|
394
|
+
status = "WARNING"
|
|
395
|
+
warning_count += 1
|
|
396
|
+
else:
|
|
397
|
+
status = "OK"
|
|
398
|
+
ok_count += 1
|
|
399
|
+
|
|
400
|
+
model = info.get("model", "?")
|
|
401
|
+
version = info.get("version", "?")
|
|
402
|
+
uptime = info.get("uptime", "?")
|
|
403
|
+
|
|
404
|
+
lines.append(f"### {host} [{status}]")
|
|
405
|
+
lines.append(f"- model: {model} EOS {version} uptime: {uptime}")
|
|
406
|
+
|
|
407
|
+
for a in criticals + warnings:
|
|
408
|
+
lines.append(f"- {a}")
|
|
409
|
+
|
|
410
|
+
if not anomalies:
|
|
411
|
+
mem_pct = info.get("memory_pct")
|
|
412
|
+
mem_str = f" メモリ: {mem_pct}%" if mem_pct is not None else ""
|
|
413
|
+
mlag_state = info.get("mlag_state", "")
|
|
414
|
+
if mlag_state == "active":
|
|
415
|
+
mlag_str = f" MLAG: Active/{info.get('mlag_neg', '?').capitalize()}"
|
|
416
|
+
elif mlag_state and mlag_state != "disabled":
|
|
417
|
+
mlag_str = f" MLAG: {mlag_state}"
|
|
418
|
+
else:
|
|
419
|
+
mlag_str = ""
|
|
420
|
+
lines.append(f"- 環境: OK{mem_str}{mlag_str} errdisabled: なし")
|
|
421
|
+
|
|
422
|
+
lines.append("")
|
|
423
|
+
|
|
424
|
+
lines += [
|
|
425
|
+
"### サマリー",
|
|
426
|
+
f"- CRITICAL: {critical_count} 台",
|
|
427
|
+
f"- WARNING: {warning_count} 台",
|
|
428
|
+
f"- OK: {ok_count} 台",
|
|
429
|
+
]
|
|
430
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eos-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for Arista EOS device operations via eAPI
|
|
5
|
+
Author: AIKAWA Shigechika
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/shigechika/eos-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/shigechika/eos-mcp
|
|
9
|
+
Project-URL: Issues, https://github.com/shigechika/eos-mcp/issues
|
|
10
|
+
Keywords: arista,eos,eapi,mcp,model-context-protocol,network,automation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: mcp>=1.0
|
|
24
|
+
Requires-Dist: pyeapi
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# eos-mcp
|
|
31
|
+
|
|
32
|
+
MCP server for Arista EOS device operations via eAPI.
|
|
33
|
+
|
|
34
|
+
Exposes EOS show commands, running-config retrieval, configuration push
|
|
35
|
+
(via configure session with commit timer), and tech-support collection
|
|
36
|
+
to MCP-compatible AI assistants.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install eos-mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
Copy `config.ini.example` to `~/.config/eos-mcp/config.ini` and fill in credentials:
|
|
47
|
+
|
|
48
|
+
```ini
|
|
49
|
+
[DEFAULT]
|
|
50
|
+
username = admin
|
|
51
|
+
password = yourpassword
|
|
52
|
+
transport = https
|
|
53
|
+
verify = false
|
|
54
|
+
|
|
55
|
+
[switch1.example.com]
|
|
56
|
+
tags = main,dc1
|
|
57
|
+
|
|
58
|
+
[switch2.example.com]
|
|
59
|
+
tags = main,dc1
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Config file discovery order:
|
|
63
|
+
1. `--config_path` argument
|
|
64
|
+
2. `EOS_MCP_CONFIG` environment variable
|
|
65
|
+
3. `./config.ini` (current directory)
|
|
66
|
+
4. `~/.config/eos-mcp/config.ini`
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Verify config and list devices
|
|
72
|
+
eos-mcp --check
|
|
73
|
+
|
|
74
|
+
# Test connectivity to a specific host
|
|
75
|
+
eos-mcp --check --check-host switch1.example.com
|
|
76
|
+
|
|
77
|
+
# Start MCP server (stdio transport, default)
|
|
78
|
+
eos-mcp
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Tools
|
|
82
|
+
|
|
83
|
+
| Tool | Description |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `get_router_list` | List registered devices (optional tag filter) |
|
|
86
|
+
| `get_device_facts` | Return structured facts for one device (model, serial, EOS version, uptime, memory) |
|
|
87
|
+
| `get_device_facts_batch` | Return device facts for multiple devices in parallel |
|
|
88
|
+
| `get_version` | Return EOS version string (quick connectivity check) |
|
|
89
|
+
| `run_command` | Run a single enable-mode command on one device |
|
|
90
|
+
| `run_commands` | Run multiple enable-mode commands on one device |
|
|
91
|
+
| `run_command_batch` | Run an enable-mode command on multiple devices in parallel |
|
|
92
|
+
| `get_config` | Retrieve running-config |
|
|
93
|
+
| `get_config_diff` | Show config diff vs rollback checkpoint |
|
|
94
|
+
| `list_config_sessions` | List configure sessions and their state |
|
|
95
|
+
| `push_config` | Push config via configure session (dry_run=True by default) |
|
|
96
|
+
| `confirm_config_session` | Confirm a pending commit timer session |
|
|
97
|
+
| `abort_config_session` | Abort a pending session |
|
|
98
|
+
| `collect_tech_support` | Collect show tech-support output |
|
|
99
|
+
| `daily_brief` | Health check (environment, errdisabled, uptime) across multiple devices |
|
|
100
|
+
|
|
101
|
+
## Requirements
|
|
102
|
+
|
|
103
|
+
- Python >= 3.10
|
|
104
|
+
- Arista EOS with eAPI enabled (`management api http-commands`)
|
|
105
|
+
- Network access to port 443 (HTTPS) on target devices
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
Apache-2.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
eos_mcp/__init__.py,sha256=LXOBJslQMjVwvpxgy2XT0QRd_3iBLEvHZLfaTXAgROs,229
|
|
2
|
+
eos_mcp/__main__.py,sha256=lrxPS_dnrIN08cWVZL8gKO-BONMPuyJ6bEjo2acOzJY,2788
|
|
3
|
+
eos_mcp/config.py,sha256=x9yOcohwAf511vt6QoEHaIvpT-h3tm5mnJqn2jIO8r0,2264
|
|
4
|
+
eos_mcp/eapi.py,sha256=F7ih9QTepnZijZILeokY6vOxCKzEEibvb0vHRuF_Kao,11465
|
|
5
|
+
eos_mcp/server.py,sha256=IoraW6_0y6xGcp6CfmVwOY2yPUNUi6oG_paYxb0SUjk,14385
|
|
6
|
+
eos_mcp-0.2.0.dist-info/METADATA,sha256=wVicM_RDsBGnxt0oExRJPU3qb24GUkLJsc6ATvqqguU,3442
|
|
7
|
+
eos_mcp-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
eos_mcp-0.2.0.dist-info/entry_points.txt,sha256=NAO3rz3e9unTrl6zK__ldhGckF6d3cM0jsbrCv6DElQ,50
|
|
9
|
+
eos_mcp-0.2.0.dist-info/top_level.txt,sha256=HVys6NPD4runPUc492KHiP3Yehg8shc2QhYb2yQeRmo,8
|
|
10
|
+
eos_mcp-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eos_mcp
|