legacy-switch-mcp 0.1.0__tar.gz

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.
Files changed (60) hide show
  1. legacy_switch_mcp-0.1.0/.github/dependabot.yml +18 -0
  2. legacy_switch_mcp-0.1.0/.github/workflows/ci.yml +16 -0
  3. legacy_switch_mcp-0.1.0/.github/workflows/dependabot-automerge.yml +22 -0
  4. legacy_switch_mcp-0.1.0/.github/workflows/publish.yml +18 -0
  5. legacy_switch_mcp-0.1.0/.gitignore +4 -0
  6. legacy_switch_mcp-0.1.0/AGENTS.md +28 -0
  7. legacy_switch_mcp-0.1.0/CLAUDE.md +1 -0
  8. legacy_switch_mcp-0.1.0/LICENSE +21 -0
  9. legacy_switch_mcp-0.1.0/PKG-INFO +169 -0
  10. legacy_switch_mcp-0.1.0/README.md +156 -0
  11. legacy_switch_mcp-0.1.0/docs/ADDING-SWITCHES.md +74 -0
  12. legacy_switch_mcp-0.1.0/docs/IDEAS.md +29 -0
  13. legacy_switch_mcp-0.1.0/docs/PROTOCOL.md +67 -0
  14. legacy_switch_mcp-0.1.0/docs/assets/social-preview.png +0 -0
  15. legacy_switch_mcp-0.1.0/pyproject.toml +26 -0
  16. legacy_switch_mcp-0.1.0/scripts/e2e_check.py +77 -0
  17. legacy_switch_mcp-0.1.0/scripts/isolate_patch_ports.py +68 -0
  18. legacy_switch_mcp-0.1.0/scripts/maint.py +44 -0
  19. legacy_switch_mcp-0.1.0/scripts/recover_after_reset.py +43 -0
  20. legacy_switch_mcp-0.1.0/scripts/rotate_password.py +32 -0
  21. legacy_switch_mcp-0.1.0/server.json +20 -0
  22. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/__init__.py +1 -0
  23. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/changes.py +316 -0
  24. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/discovery.py +146 -0
  25. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/dlinkchanges.py +337 -0
  26. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/dlinkweb.py +277 -0
  27. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/maintenance.py +354 -0
  28. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/reports.py +72 -0
  29. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/safety.py +30 -0
  30. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/server.py +285 -0
  31. legacy_switch_mcp-0.1.0/src/legacy_switch_mcp/webui.py +398 -0
  32. legacy_switch_mcp-0.1.0/switches.example.json +4 -0
  33. legacy_switch_mcp-0.1.0/tests/conftest.py +17 -0
  34. legacy_switch_mcp-0.1.0/tests/fakedlink.py +137 -0
  35. legacy_switch_mcp-0.1.0/tests/fakeswitch.py +121 -0
  36. legacy_switch_mcp-0.1.0/tests/fixtures/cgi_setport_0.html +87 -0
  37. legacy_switch_mcp-0.1.0/tests/fixtures/cgi_setvlan_10.html +117 -0
  38. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_MgtVLAN.js +5 -0
  39. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_Mirror.js +7 -0
  40. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_PVLAN.js +4 -0
  41. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_Port.js +10 -0
  42. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_QVLAN.js +20 -0
  43. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_Switch.js +6 -0
  44. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_reply.bin +0 -0
  45. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_request_broadcast.bin +0 -0
  46. legacy_switch_mcp-0.1.0/tests/fixtures/dlink_trunking.js +39 -0
  47. legacy_switch_mcp-0.1.0/tests/fixtures/mirror.html +111 -0
  48. legacy_switch_mcp-0.1.0/tests/fixtures/port.html +201 -0
  49. legacy_switch_mcp-0.1.0/tests/fixtures/status.html +369 -0
  50. legacy_switch_mcp-0.1.0/tests/fixtures/trunk.html +39 -0
  51. legacy_switch_mcp-0.1.0/tests/fixtures/vlan.html +125 -0
  52. legacy_switch_mcp-0.1.0/tests/test_changes.py +177 -0
  53. legacy_switch_mcp-0.1.0/tests/test_discovery.py +49 -0
  54. legacy_switch_mcp-0.1.0/tests/test_dlink.py +80 -0
  55. legacy_switch_mcp-0.1.0/tests/test_dlink_changes.py +203 -0
  56. legacy_switch_mcp-0.1.0/tests/test_maintenance.py +242 -0
  57. legacy_switch_mcp-0.1.0/tests/test_safety.py +124 -0
  58. legacy_switch_mcp-0.1.0/tests/test_server.py +78 -0
  59. legacy_switch_mcp-0.1.0/tests/test_webui.py +85 -0
  60. legacy_switch_mcp-0.1.0/uv.lock +853 -0
@@ -0,0 +1,18 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "uv"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ groups:
8
+ minor-and-patch:
9
+ update-types: ["minor", "patch"]
10
+ labels: ["dependencies"]
11
+ - package-ecosystem: "github-actions"
12
+ directory: "/"
13
+ schedule:
14
+ interval: "weekly"
15
+ groups:
16
+ actions:
17
+ patterns: ["*"]
18
+ labels: ["dependencies"]
@@ -0,0 +1,16 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ pull_request:
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python: ["3.11", "3.12", "3.13"]
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ - uses: astral-sh/setup-uv@v7
14
+ - run: uv python install ${{ matrix.python }}
15
+ - run: uv sync --extra dev --python ${{ matrix.python }}
16
+ - run: uv run --python ${{ matrix.python }} pytest
@@ -0,0 +1,22 @@
1
+ # Auto-merge patch/minor Dependabot PRs once CI is green.
2
+ # OFF until the repository variable AUTO_MERGE_DEPENDABOT is set to "true". Set it only after the repo is public
3
+ # and a ruleset requires the CI checks: without a required check, --auto would merge immediately.
4
+ name: Dependabot auto-merge
5
+ on: pull_request
6
+ permissions:
7
+ contents: write
8
+ pull-requests: write
9
+ jobs:
10
+ automerge:
11
+ if: github.actor == 'dependabot[bot]' && vars.AUTO_MERGE_DEPENDABOT == 'true'
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - id: meta
15
+ uses: dependabot/fetch-metadata@v3
16
+ with:
17
+ github-token: ${{ secrets.GITHUB_TOKEN }}
18
+ - if: steps.meta.outputs.update-type != 'version-update:semver-major'
19
+ run: gh pr merge --auto --squash "$PR_URL"
20
+ env:
21
+ PR_URL: ${{ github.event.pull_request.html_url }}
22
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,18 @@
1
+ # Publish to PyPI when a GitHub release is published. Uses PyPI trusted publishing (OIDC): no token is stored anywhere.
2
+ # One-time setup on pypi.org: add a "pending publisher" for this repo, workflow publish.yml, environment "pypi".
3
+ name: Publish
4
+ on:
5
+ release:
6
+ types: [published]
7
+ jobs:
8
+ pypi:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v7
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
@@ -0,0 +1,28 @@
1
+ # Working on legacy-switch-mcp (people and agents)
2
+
3
+ MCP server for old smart switches (D-Link DGS-1100, USRobotics 7724). Changes are **plan, then apply**; nothing is written to a switch otherwise.
4
+ Layout: `src/legacy_switch_mcp/` (server, per-vendor web sessions, change planners, safety, maintenance), `tests/` (fake switches, no hardware), `scripts/` (hardware-only), `docs/`.
5
+
6
+ ## Commands
7
+ ```bash
8
+ uv sync --extra dev
9
+ uv run pytest # 112 tests, ~1 s, fake switches only
10
+ ```
11
+ CI runs the same on Python 3.11, 3.12 and 3.13: do not use newer-only features.
12
+
13
+ ## Rules that matter most
14
+ - **Never post to a switch outside the allow-lists** in `dlinkweb.py` / `webui.py`, and never add a write path without a fake-switch test first. A write is "proven" only after a round trip on an unused, unprotected port; label it so in the README status table with the date, and say "fake-tested only" otherwise.
15
+ - **Do not run anything in `scripts/` or point tools at a real switch** unless the maintainer asked for it in this session. Maintenance stays off (`SWITCH_MCP_ENABLE_MAINTENANCE` unset) by default.
16
+ - **Nothing real in the repo**: fixtures, docs, tests, commit messages, issues and PRs use RFC 5737 addresses (192.0.2.x, 198.51.100.x, 203.0.113.x) and `00-00-5E-00-53-xx` MACs. No real hostnames, serials, passwords, tokens or personal names. Config and state live in `~/.config/switch-mcp` and `~/.local/state/switch-mcp`, never here.
17
+ - **Redact by value, not by key name.** When showing config or command output, mask anything shaped like a secret (long random strings, `user:key@host` URLs, `Authorization` headers) instead of trusting that a field is named "password". If a secret is exposed, say so and treat it as leaked.
18
+ - **Credentials are the maintainer's to create.** Do not mint, rotate or store tokens or keys yourself; give the exact command instead. Never put credentials in git config, remotes or files in this repo.
19
+
20
+ ## Working method
21
+ - **Verify, don't trust.** Run the tests, read the actual diff, and check claimed results yourself before saying something is done. A confident summary is not evidence; report failures and skipped steps plainly, with the output.
22
+ - **Branch, don't commit to `main`.** Work on a branch, run `uv run pytest` before pushing, and open a PR. Commits keep the existing author identity; agent-assisted commits end with a `Co-Authored-By:` trailer naming the agent.
23
+ - **Keep the MCP footprint small.** Every registered tool adds its description to every request of a session. Keep descriptions short, register only usable tools, and re-measure the token table in the README if the tool set changes.
24
+ - **Reproduce before fixing.** Add a failing fake-switch test for a bug first. The fakes only know what has been learned; when hardware disagrees with a fake, fix the fake too so the regression fails a test (see "Found on hardware" in the README).
25
+ - **Docs follow code.** `docs/ADDING-SWITCHES.md` is the process for new models and vendors; `docs/PROTOCOL.md` holds wire formats. Update them in the same change.
26
+
27
+ ## Dependencies
28
+ Dependabot opens weekly PRs (`uv`, `github-actions`). A bump is acceptable when CI is green; read the changelog for `mcp` (the SDK defines the tool schema) and re-run `tests/test_server.py` expectations for the registered tool set.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ion9
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: legacy-switch-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for old smart switches (D-Link DGS-1100, USRobotics 7724): plan-then-apply VLAN, port and trunk changes with read-back verification and rollback
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: mcp<2,>=1.2
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # legacy-switch-mcp
15
+
16
+ [![CI](https://github.com/Nebulous-Ideas/legacy-switch-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Nebulous-Ideas/legacy-switch-mcp/actions/workflows/ci.yml)
17
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
18
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)
19
+ ![MCP server](https://img.shields.io/badge/MCP-server-8A2BE2.svg)
20
+
21
+ <!-- mcp-name: io.github.Nebulous-Ideas/legacy-switch-mcp -->
22
+
23
+ > **Use at your own risk.** This tool can change VLANs, reboot switches and factory-reset them, and a bad change can cut you off from the switch.
24
+ > It has been proven on hardware on exactly two models (USRobotics 7724, D-Link DGS-1100-08) and is fake-switch tested only elsewhere. Read the plan before applying it,
25
+ > list your uplinks in `protected_ports`, and keep the maintenance switch off unless you need it. Provided as is, without warranty (see `LICENSE`).
26
+
27
+ An MCP server for two old "smart" switch families that no other MCP covers: the **USRobotics 7724** (24-port gigabit, port-based VLANs) and the
28
+ **D-Link DGS-1100** EasySmart series (802.1Q). Both are Cameo-built and share a UDP discovery protocol (see `docs/PROTOCOL.md`).
29
+
30
+ It reads state and makes changes the careful way: **plan, then apply**. A plan writes nothing; it shows the exact differences and the risks (for VLANs:
31
+ which connected ports would stop reaching each other, which ports leave the management VLAN). Applying needs the plan id and `confirm=true`, re-reads the switch
32
+ and refuses if it changed, saves a snapshot, writes, reads back to verify, and returns a rollback plan.
33
+
34
+ ## Install
35
+ No clone needed (needs [uv](https://docs.astral.sh/uv/)). First create `~/.config/switch-mcp/switches.json` and a password for each switch (see Setup below), then add the server to your MCP client:
36
+ ```bash
37
+ claude mcp add switch -- uvx legacy-switch-mcp # Claude Code
38
+ claude mcp add switch --env SWITCH_MCP_TOOLSETS=read -- uvx legacy-switch-mcp # look-only (fewest tokens)
39
+ ```
40
+ Claude Desktop, Cursor and other clients take the same command in their JSON config:
41
+ ```json
42
+ {"mcpServers": {"switch": {"command": "uvx", "args": ["legacy-switch-mcp"], "env": {"SWITCH_MCP_TOOLSETS": "read"}}}}
43
+ ```
44
+ Until the package is on PyPI, use `uvx --from git+https://github.com/Nebulous-Ideas/legacy-switch-mcp legacy-switch-mcp`.
45
+
46
+ ## Tools
47
+ | Tool | What it does |
48
+ |---|---|
49
+ | `list_switches`, `list_interfaces` | configured switches; IPv4 interfaces on this host |
50
+ | `discover(interface)`, `probe(host)` | UDP discovery (broadcast+multicast), or ask one switch by IP |
51
+ | `get_state(switch)` | parsed state: VLANs, ports, mirror (and D-Link system/trunk) |
52
+ | `read_page(switch, page)`, `overview(switch)` | raw page data |
53
+ | `backup_state`, `list_snapshots`, `describe_switch` | save the parsed state; list saved states; markdown description for a wiki |
54
+ | `backup_config`, `backup_firmware`, `list_files` | download the configuration (both) / firmware (D-Link) file into the state directory, mode 0600; the content is never returned |
55
+ | `plan_password`, `plan_ip`, `plan_reboot`, `plan_factory_reset`, `plan_restore_config` | maintenance plans (both vendors; reboot D-Link only). **Applying needs the server started with `SWITCH_MCP_ENABLE_MAINTENANCE=1`** |
56
+ | `plan_vlan_group`, `plan_port`, `plan_mirror`, `plan_trunk`, `plan_system` | USRobotics changes (plan only) |
57
+ | `dlink_plan_vlan`, `dlink_plan_vlan_delete`, `dlink_plan_pvid`, `dlink_plan_port`, `dlink_plan_mirror`, `dlink_plan_trunk`, `dlink_plan_trunk_delete`, `dlink_plan_system`, `dlink_plan_save` | D-Link changes (plan only) |
58
+ | `apply_plan(plan_id, confirm=true)` | apply a plan from this server session |
59
+
60
+ ## Setup
61
+ ```bash
62
+ uv sync --extra dev && uv run pytest
63
+ mkdir -p ~/.config/switch-mcp && cp switches.example.json ~/.config/switch-mcp/switches.json # then edit the addresses
64
+ ```
65
+ Passwords are never tool arguments and are never returned. Provide each as `SWITCH_MCP_PASSWORD_<NAME>` (name upper-cased, `-` becomes `_`) or in
66
+ `~/.config/switch-mcp/passwords.json` (`{"usr-switch": "..."}`, mode 0600; a looser mode is refused).
67
+
68
+ **Factory-default passwords** (so a reset switch is usable): both vendors document `admin` as the default (USRobotics quick-install guide; D-Link
69
+ EasySmart manuals, which also give 10.90.90.90 as the default D-Link address and 192.168.0.1 for the USRobotics). Change it after a reset.
70
+ This repository contains no other credentials; tests use a throwaway `pw`.
71
+
72
+ Register with Claude Code only for switch work (every MCP server adds its tool descriptions to every request of the session):
73
+ ```bash
74
+ claude mcp add switch -- uv run --project /path/to/legacy-switch-mcp legacy-switch-mcp
75
+ ```
76
+
77
+ ### Keeping the session cost small
78
+ Only the tools that can be used are registered. Measured with all 33 tools registered the schema text was about 4,350 tokens; now:
79
+ | Setup | Tools | Tokens per request (approx.) |
80
+ |---|---|---|
81
+ | default: read + files + a set for each vendor in `switches.json`, maintenance off | 28 (both vendors) | 2,450 |
82
+ | default + `SWITCH_MCP_ENABLE_MAINTENANCE=1` (adds the 5 maintenance plan tools) | 33 | 2,850 |
83
+ | `SWITCH_MCP_TOOLSETS=read,files,dlink` (D-Link work only) | 23 | 1,800 |
84
+ | `SWITCH_MCP_TOOLSETS=read` (look only, no plan or apply tools) | 10 | 600 |
85
+ `SWITCH_MCP_TOOLSETS` takes a comma list of `read`, `files`, `usr`, `dlink`, `maintenance`, or `all`; `apply_plan` is registered whenever any plan set is. A typo is an error, not a silent fallback.
86
+ Maintenance plan tools are not registered at all unless the maintenance switch is on (or `maintenance` is named in `SWITCH_MCP_TOOLSETS`); a registered one still refuses to apply without the switch.
87
+ For day-to-day looking: `claude mcp add switch --env SWITCH_MCP_TOOLSETS=read -- uv run ...`; for a change session leave it unset; for maintenance add `--env SWITCH_MCP_ENABLE_MAINTENANCE=1`.
88
+
89
+ ## Testing
90
+ ```bash
91
+ uv sync --extra dev && uv run pytest # 112 tests against the fake switches; no hardware needed
92
+ uv run python scripts/e2e_check.py <dlink-switch> <trunk-group> <port> <port> # real MCP stdio check against a D-Link, on IDLE ports only
93
+ ```
94
+ `e2e_check.py` starts the server as a client would, round-trips a system-location change and a trunk on the two idle ports you name (each rolled back), downloads a config, and
95
+ checks that a reboot plan is refused while maintenance is off. Run it only against a switch where those ports have no link; it leaves the switch as it found it.
96
+ Hardware-only checks (outages, factory reset, password) are in `scripts/maint.py`, `scripts/rotate_password.py` and `scripts/recover_after_reset.py`; see `docs/ADDING-SWITCHES.md` for the proof sequence.
97
+
98
+ ## Safety
99
+ - **Protected ports**: list uplinks in `switches.json` (`"protected_ports": [24]`); plans that touch them are refused unless `allow_protected=true`.
100
+ - **Rollback survives failure**: the rollback plan is written to disk before any write, so if the switch stops answering mid-change it can still be applied from a new session.
101
+ - **Audit log**: every plan, refusal and apply is appended to `~/.local/state/switch-mcp/audit.jsonl` (no credentials).
102
+ - **Maintenance is off by default.** Password, IP address, reboot, factory reset and config restore plan normally but refuse to apply unless the server was started with
103
+ `SWITCH_MCP_ENABLE_MAINTENANCE=1` (`claude mcp add switch --env SWITCH_MCP_ENABLE_MAINTENANCE=1 -- uv run ...`). The low-level writers refuse too. Passwords are generated by the server,
104
+ verified by logging in, stored in `passwords.json` (old one kept in `passwords.prev.json`; a `passwords.pending.json` exists while a change is in flight) and never shown or logged. An IP change refuses
105
+ unless `allow_ip_change=true`, if the address answers a ping, or if it is outside the gateway's subnet, and updates `switches.json` only after logging in at the new address. Reboot, reset and restore drop every port,
106
+ so they are refused while `protected_ports` exist unless `allow_protected=true`; a factory reset first saves the parsed state and the configuration file; a restore only accepts a file this server saved for the same switch (checksum checked).
107
+ - Only ten D-Link CGIs (VLAN add/delete, PVID, port, mirror, system name/location/timeout, save, three trunk CGIs) and five USRobotics forms (VLAN group, port, mirror, trunk, system name/location/timeout)
108
+ can ever be posted in normal operation. Behind the maintenance switch: five more D-Link CGIs (password, IP, reboot, reset, restore) and four USRobotics forms (password, system with IP, reset, restore);
109
+ two D-Link CGIs only download a file. Firmware upgrade, 802.1Q-mode changes and anything else cannot be posted.
110
+ - Plans live in server memory for 10 minutes; snapshots go to `~/.local/state/switch-mcp/`.
111
+ - One web session at a time (these switches evict a second login), always logged out. If a human has the web page open, close it first.
112
+ - The switches apply changes live; they may not keep them across a power loss unless saved from their own web page.
113
+
114
+ ## Status
115
+ | Feature | State |
116
+ |---|---|
117
+ | Discovery (both vendors) | proven on hardware |
118
+ | Reads (both) | proven on hardware |
119
+ | USRobotics writes: VLAN group, port, mirror | proven on hardware (round trips, switch left as found) |
120
+ | USRobotics write: trunk | proven on hardware 2026-11-08 (ports 17+18, both idle; answers in ~11 s, so the write has a 45 s timeout) |
121
+ | USRobotics write: system name/location/timeout | proven on hardware 2026-11-08 (location set and restored; the page also holds the IP settings, which are read back and re-sent unchanged, and did not change) |
122
+ | D-Link writes: VLAN add/delete, PVID, port, mirror | proven on hardware 2026-11-08 (unused port 8, VLAN 999; every write verified by read-back and rolled back, switch left identical to its backup) |
123
+ | D-Link writes: system name/location/timeout, save configuration | proven on hardware 2026-11-08 (location set and restored; save has no read-back) |
124
+ | D-Link writes: trunks | proven on hardware 2026-11-08 (idle ports 6+7: trunking switched on, group created, then removed and trunking switched off; no ping loss on the other ports) |
125
+ | The MCP tool layer (33 tools over stdio) | proven against a real D-Link with `scripts/e2e_check.py` (plan -> apply -> rollback through the registered tools, gate refusal, downloads) |
126
+
127
+ ### Found on hardware (the fake switches could not have told us)
128
+ - **USRobotics login check was wrong**: the reply to a wrong password has no login form (it only appears on the next page), so every password looked valid. Logins now fetch a real page and require it not to be the login form. This also made "verify the new password by logging in" meaningful for the USRobotics.
129
+ - **D-Link mirror disable**: posting only `cEn=0` to `PortMirroring.cgi` gets no answer at all and applies nothing (ReadTimeout, repeatable). The switch answers in 0.1 s when the form also carries the current sniffer port, mode and source ports. Fixed; the fake now hangs the same way so a regression fails a test.
130
+ - **USRobotics trunk**: the write applies but takes about 11.4 s to answer, longer than the 10 s default timeout; it was reported as "lost contact" although it had worked. Trunk writes now wait up to 45 s.
131
+
132
+ ### Maintenance operations (behind `SWITCH_MCP_ENABLE_MAINTENANCE=1`)
133
+ | Item | State |
134
+ |---|---|
135
+ | Config download (USRobotics `GET config.bin`, D-Link `backup.cgi` with `pswType`) | proven on hardware 2026-11-08, read-only. The D-Link file contains the admin password in clear text: it is stored 0600 and never returned |
136
+ | Firmware download (D-Link `FWBackup.cgi`, ~740 KB) | proven on hardware 2026-11-08, read-only. The USRobotics web interface has no firmware page |
137
+ | USRobotics system name/location/timeout | proven on hardware 2026-11-08 (location changed and restored; the IP settings were re-sent unchanged and did not change) |
138
+ | Password change (both) | proven on hardware 2026-11-08 (run by the operator with `scripts/rotate_password.py`, verified: the old password is refused, the stored new one works) |
139
+ | IP change (both) | proven on hardware 2026-11-08: D-Link 198.51.100.3 -> .4 -> .3 and USRobotics 192.0.2.3 -> .9 -> .3 (shown here as documentation addresses), each verified by logging in at the new address; `switches.json` updated, old address stopped answering |
140
+ | Reboot (D-Link; the USRobotics has no reboot page) | proven on hardware: back in about 7 s, ~2 s of ping loss behind it, uptime reset, saved password survived (save the configuration first: `dlink_plan_save`) |
141
+ | Factory reset + config restore, **D-Link** | proven on hardware: reset -> `10.90.90.90`/`admin` -> upload the saved config file -> back at its own address with its rotated password and a state identical to the pre-test backup (~4 s of cluster-link ping loss) |
142
+ | Factory reset, **USRobotics** | proven on hardware, but the recovery is different: after the reset, uploading the switch's own `config.bin` (it reboots and answers a timeout) restored **none** of the VLAN groups (only group 1 remained), flow control (back to Enable), name/location, address (192.168.0.1) or password (`admin`). Recovery means re-applying a saved state (`backup_state`) over the web pages: `maintenance.reapply_usr_state`, used by `scripts/recover_after_reset.py usr-3com <state.json> --state`. The same steps were done by hand on 2026-11-08 and the switch ended identical to its backup; the packaged function itself is fake-switch tested only |
143
+ | Config restore, USRobotics | the upload is accepted and the switch reboots (~30 s), but see above: do not rely on it to restore settings after a reset |
144
+
145
+ ### Operational notes from the outage tests
146
+ - In the test setup the D-Link carried only a redundant second link and the USRobotics carried the main LAN plus the first link: a D-Link reboot/restore/reset cost a few seconds of ping loss on that link, a USRobotics reset/restore takes the whole LAN down for about a minute. Do one switch at a time.
147
+ - After a USRobotics factory reset its password is `admin` again and the stored entry must say so before `plan_password` can rotate it (the recovery sets it; rotate again afterwards).
148
+ - Reaching a reset switch at its factory address needs a temporary host route on the machine running the tools (`ip addr add <x>/32` + `ip route add <default>/32 dev <bridge>`), not a /8 or /24 alias that would capture other traffic.
149
+
150
+ ### Not implemented
151
+ | Item | Why |
152
+ |---|---|
153
+ | Firmware upgrade (D-Link `FWUpgrade.cgi`; the vendor utility uses TFTP) | the upload protocol behind the button is unknown (the page only has buttons), and a bad image or a power cut during the write can brick the switch. `backup_firmware` keeps a copy of the running image. |
154
+ | The vendor utility's SOAP/UDP channel for writes (`SetDeviceCfg`, TFTP) | not captured; the web channel covers everything above. |
155
+ | DHCP (instead of a static address), 802.1Q mode switch | no need so far; the D-Link `set_ip.cgi` supports DHCP, which would make the switch's address unpredictable. |
156
+
157
+ See `docs/ADDING-SWITCHES.md` for how to add another switch, another model or firmware, or a new vendor.
158
+
159
+ ## Related projects (other gear with no vendor API)
160
+ - [tplink-easysmart-mcp](https://glama.ai/mcp/servers/ebenezer-isaac/tplink-easysmart-mcp): TP-Link Easy Smart switches over their web CGI, with dry-run, protected ports and lockout-aware failures.
161
+ - A second TP-Link Easy Smart server (`tplink-easy-smart-switch-mcp`, by qwe7002-ai) and a Netgear XS512EM server (`netgear-xs512em`, by supere989); find them by name on GitHub.
162
+ - [NetContext-MCP](https://github.com/hgursel/NetContext-MCP): SSH to older CLI switches (Cisco, Aruba) with legacy algorithms.
163
+
164
+ The gated-write pattern here (dry run, risk checks, session-eviction caveat) follows the same approach; ideas flow both ways.
165
+
166
+ See `docs/PROTOCOL.md` for the wire formats and the page/CGI map and `docs/IDEAS.md` for what was borrowed from the projects above and a sketch of a multi-vendor umbrella (an idea only).
167
+
168
+ ---
169
+ A Nebulous Ideas project, built by the Nebulous Collective: the people and agents who work on it.
@@ -0,0 +1,156 @@
1
+ # legacy-switch-mcp
2
+
3
+ [![CI](https://github.com/Nebulous-Ideas/legacy-switch-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Nebulous-Ideas/legacy-switch-mcp/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)
6
+ ![MCP server](https://img.shields.io/badge/MCP-server-8A2BE2.svg)
7
+
8
+ <!-- mcp-name: io.github.Nebulous-Ideas/legacy-switch-mcp -->
9
+
10
+ > **Use at your own risk.** This tool can change VLANs, reboot switches and factory-reset them, and a bad change can cut you off from the switch.
11
+ > It has been proven on hardware on exactly two models (USRobotics 7724, D-Link DGS-1100-08) and is fake-switch tested only elsewhere. Read the plan before applying it,
12
+ > list your uplinks in `protected_ports`, and keep the maintenance switch off unless you need it. Provided as is, without warranty (see `LICENSE`).
13
+
14
+ An MCP server for two old "smart" switch families that no other MCP covers: the **USRobotics 7724** (24-port gigabit, port-based VLANs) and the
15
+ **D-Link DGS-1100** EasySmart series (802.1Q). Both are Cameo-built and share a UDP discovery protocol (see `docs/PROTOCOL.md`).
16
+
17
+ It reads state and makes changes the careful way: **plan, then apply**. A plan writes nothing; it shows the exact differences and the risks (for VLANs:
18
+ which connected ports would stop reaching each other, which ports leave the management VLAN). Applying needs the plan id and `confirm=true`, re-reads the switch
19
+ and refuses if it changed, saves a snapshot, writes, reads back to verify, and returns a rollback plan.
20
+
21
+ ## Install
22
+ No clone needed (needs [uv](https://docs.astral.sh/uv/)). First create `~/.config/switch-mcp/switches.json` and a password for each switch (see Setup below), then add the server to your MCP client:
23
+ ```bash
24
+ claude mcp add switch -- uvx legacy-switch-mcp # Claude Code
25
+ claude mcp add switch --env SWITCH_MCP_TOOLSETS=read -- uvx legacy-switch-mcp # look-only (fewest tokens)
26
+ ```
27
+ Claude Desktop, Cursor and other clients take the same command in their JSON config:
28
+ ```json
29
+ {"mcpServers": {"switch": {"command": "uvx", "args": ["legacy-switch-mcp"], "env": {"SWITCH_MCP_TOOLSETS": "read"}}}}
30
+ ```
31
+ Until the package is on PyPI, use `uvx --from git+https://github.com/Nebulous-Ideas/legacy-switch-mcp legacy-switch-mcp`.
32
+
33
+ ## Tools
34
+ | Tool | What it does |
35
+ |---|---|
36
+ | `list_switches`, `list_interfaces` | configured switches; IPv4 interfaces on this host |
37
+ | `discover(interface)`, `probe(host)` | UDP discovery (broadcast+multicast), or ask one switch by IP |
38
+ | `get_state(switch)` | parsed state: VLANs, ports, mirror (and D-Link system/trunk) |
39
+ | `read_page(switch, page)`, `overview(switch)` | raw page data |
40
+ | `backup_state`, `list_snapshots`, `describe_switch` | save the parsed state; list saved states; markdown description for a wiki |
41
+ | `backup_config`, `backup_firmware`, `list_files` | download the configuration (both) / firmware (D-Link) file into the state directory, mode 0600; the content is never returned |
42
+ | `plan_password`, `plan_ip`, `plan_reboot`, `plan_factory_reset`, `plan_restore_config` | maintenance plans (both vendors; reboot D-Link only). **Applying needs the server started with `SWITCH_MCP_ENABLE_MAINTENANCE=1`** |
43
+ | `plan_vlan_group`, `plan_port`, `plan_mirror`, `plan_trunk`, `plan_system` | USRobotics changes (plan only) |
44
+ | `dlink_plan_vlan`, `dlink_plan_vlan_delete`, `dlink_plan_pvid`, `dlink_plan_port`, `dlink_plan_mirror`, `dlink_plan_trunk`, `dlink_plan_trunk_delete`, `dlink_plan_system`, `dlink_plan_save` | D-Link changes (plan only) |
45
+ | `apply_plan(plan_id, confirm=true)` | apply a plan from this server session |
46
+
47
+ ## Setup
48
+ ```bash
49
+ uv sync --extra dev && uv run pytest
50
+ mkdir -p ~/.config/switch-mcp && cp switches.example.json ~/.config/switch-mcp/switches.json # then edit the addresses
51
+ ```
52
+ Passwords are never tool arguments and are never returned. Provide each as `SWITCH_MCP_PASSWORD_<NAME>` (name upper-cased, `-` becomes `_`) or in
53
+ `~/.config/switch-mcp/passwords.json` (`{"usr-switch": "..."}`, mode 0600; a looser mode is refused).
54
+
55
+ **Factory-default passwords** (so a reset switch is usable): both vendors document `admin` as the default (USRobotics quick-install guide; D-Link
56
+ EasySmart manuals, which also give 10.90.90.90 as the default D-Link address and 192.168.0.1 for the USRobotics). Change it after a reset.
57
+ This repository contains no other credentials; tests use a throwaway `pw`.
58
+
59
+ Register with Claude Code only for switch work (every MCP server adds its tool descriptions to every request of the session):
60
+ ```bash
61
+ claude mcp add switch -- uv run --project /path/to/legacy-switch-mcp legacy-switch-mcp
62
+ ```
63
+
64
+ ### Keeping the session cost small
65
+ Only the tools that can be used are registered. Measured with all 33 tools registered the schema text was about 4,350 tokens; now:
66
+ | Setup | Tools | Tokens per request (approx.) |
67
+ |---|---|---|
68
+ | default: read + files + a set for each vendor in `switches.json`, maintenance off | 28 (both vendors) | 2,450 |
69
+ | default + `SWITCH_MCP_ENABLE_MAINTENANCE=1` (adds the 5 maintenance plan tools) | 33 | 2,850 |
70
+ | `SWITCH_MCP_TOOLSETS=read,files,dlink` (D-Link work only) | 23 | 1,800 |
71
+ | `SWITCH_MCP_TOOLSETS=read` (look only, no plan or apply tools) | 10 | 600 |
72
+ `SWITCH_MCP_TOOLSETS` takes a comma list of `read`, `files`, `usr`, `dlink`, `maintenance`, or `all`; `apply_plan` is registered whenever any plan set is. A typo is an error, not a silent fallback.
73
+ Maintenance plan tools are not registered at all unless the maintenance switch is on (or `maintenance` is named in `SWITCH_MCP_TOOLSETS`); a registered one still refuses to apply without the switch.
74
+ For day-to-day looking: `claude mcp add switch --env SWITCH_MCP_TOOLSETS=read -- uv run ...`; for a change session leave it unset; for maintenance add `--env SWITCH_MCP_ENABLE_MAINTENANCE=1`.
75
+
76
+ ## Testing
77
+ ```bash
78
+ uv sync --extra dev && uv run pytest # 112 tests against the fake switches; no hardware needed
79
+ uv run python scripts/e2e_check.py <dlink-switch> <trunk-group> <port> <port> # real MCP stdio check against a D-Link, on IDLE ports only
80
+ ```
81
+ `e2e_check.py` starts the server as a client would, round-trips a system-location change and a trunk on the two idle ports you name (each rolled back), downloads a config, and
82
+ checks that a reboot plan is refused while maintenance is off. Run it only against a switch where those ports have no link; it leaves the switch as it found it.
83
+ Hardware-only checks (outages, factory reset, password) are in `scripts/maint.py`, `scripts/rotate_password.py` and `scripts/recover_after_reset.py`; see `docs/ADDING-SWITCHES.md` for the proof sequence.
84
+
85
+ ## Safety
86
+ - **Protected ports**: list uplinks in `switches.json` (`"protected_ports": [24]`); plans that touch them are refused unless `allow_protected=true`.
87
+ - **Rollback survives failure**: the rollback plan is written to disk before any write, so if the switch stops answering mid-change it can still be applied from a new session.
88
+ - **Audit log**: every plan, refusal and apply is appended to `~/.local/state/switch-mcp/audit.jsonl` (no credentials).
89
+ - **Maintenance is off by default.** Password, IP address, reboot, factory reset and config restore plan normally but refuse to apply unless the server was started with
90
+ `SWITCH_MCP_ENABLE_MAINTENANCE=1` (`claude mcp add switch --env SWITCH_MCP_ENABLE_MAINTENANCE=1 -- uv run ...`). The low-level writers refuse too. Passwords are generated by the server,
91
+ verified by logging in, stored in `passwords.json` (old one kept in `passwords.prev.json`; a `passwords.pending.json` exists while a change is in flight) and never shown or logged. An IP change refuses
92
+ unless `allow_ip_change=true`, if the address answers a ping, or if it is outside the gateway's subnet, and updates `switches.json` only after logging in at the new address. Reboot, reset and restore drop every port,
93
+ so they are refused while `protected_ports` exist unless `allow_protected=true`; a factory reset first saves the parsed state and the configuration file; a restore only accepts a file this server saved for the same switch (checksum checked).
94
+ - Only ten D-Link CGIs (VLAN add/delete, PVID, port, mirror, system name/location/timeout, save, three trunk CGIs) and five USRobotics forms (VLAN group, port, mirror, trunk, system name/location/timeout)
95
+ can ever be posted in normal operation. Behind the maintenance switch: five more D-Link CGIs (password, IP, reboot, reset, restore) and four USRobotics forms (password, system with IP, reset, restore);
96
+ two D-Link CGIs only download a file. Firmware upgrade, 802.1Q-mode changes and anything else cannot be posted.
97
+ - Plans live in server memory for 10 minutes; snapshots go to `~/.local/state/switch-mcp/`.
98
+ - One web session at a time (these switches evict a second login), always logged out. If a human has the web page open, close it first.
99
+ - The switches apply changes live; they may not keep them across a power loss unless saved from their own web page.
100
+
101
+ ## Status
102
+ | Feature | State |
103
+ |---|---|
104
+ | Discovery (both vendors) | proven on hardware |
105
+ | Reads (both) | proven on hardware |
106
+ | USRobotics writes: VLAN group, port, mirror | proven on hardware (round trips, switch left as found) |
107
+ | USRobotics write: trunk | proven on hardware 2026-11-08 (ports 17+18, both idle; answers in ~11 s, so the write has a 45 s timeout) |
108
+ | USRobotics write: system name/location/timeout | proven on hardware 2026-11-08 (location set and restored; the page also holds the IP settings, which are read back and re-sent unchanged, and did not change) |
109
+ | D-Link writes: VLAN add/delete, PVID, port, mirror | proven on hardware 2026-11-08 (unused port 8, VLAN 999; every write verified by read-back and rolled back, switch left identical to its backup) |
110
+ | D-Link writes: system name/location/timeout, save configuration | proven on hardware 2026-11-08 (location set and restored; save has no read-back) |
111
+ | D-Link writes: trunks | proven on hardware 2026-11-08 (idle ports 6+7: trunking switched on, group created, then removed and trunking switched off; no ping loss on the other ports) |
112
+ | The MCP tool layer (33 tools over stdio) | proven against a real D-Link with `scripts/e2e_check.py` (plan -> apply -> rollback through the registered tools, gate refusal, downloads) |
113
+
114
+ ### Found on hardware (the fake switches could not have told us)
115
+ - **USRobotics login check was wrong**: the reply to a wrong password has no login form (it only appears on the next page), so every password looked valid. Logins now fetch a real page and require it not to be the login form. This also made "verify the new password by logging in" meaningful for the USRobotics.
116
+ - **D-Link mirror disable**: posting only `cEn=0` to `PortMirroring.cgi` gets no answer at all and applies nothing (ReadTimeout, repeatable). The switch answers in 0.1 s when the form also carries the current sniffer port, mode and source ports. Fixed; the fake now hangs the same way so a regression fails a test.
117
+ - **USRobotics trunk**: the write applies but takes about 11.4 s to answer, longer than the 10 s default timeout; it was reported as "lost contact" although it had worked. Trunk writes now wait up to 45 s.
118
+
119
+ ### Maintenance operations (behind `SWITCH_MCP_ENABLE_MAINTENANCE=1`)
120
+ | Item | State |
121
+ |---|---|
122
+ | Config download (USRobotics `GET config.bin`, D-Link `backup.cgi` with `pswType`) | proven on hardware 2026-11-08, read-only. The D-Link file contains the admin password in clear text: it is stored 0600 and never returned |
123
+ | Firmware download (D-Link `FWBackup.cgi`, ~740 KB) | proven on hardware 2026-11-08, read-only. The USRobotics web interface has no firmware page |
124
+ | USRobotics system name/location/timeout | proven on hardware 2026-11-08 (location changed and restored; the IP settings were re-sent unchanged and did not change) |
125
+ | Password change (both) | proven on hardware 2026-11-08 (run by the operator with `scripts/rotate_password.py`, verified: the old password is refused, the stored new one works) |
126
+ | IP change (both) | proven on hardware 2026-11-08: D-Link 198.51.100.3 -> .4 -> .3 and USRobotics 192.0.2.3 -> .9 -> .3 (shown here as documentation addresses), each verified by logging in at the new address; `switches.json` updated, old address stopped answering |
127
+ | Reboot (D-Link; the USRobotics has no reboot page) | proven on hardware: back in about 7 s, ~2 s of ping loss behind it, uptime reset, saved password survived (save the configuration first: `dlink_plan_save`) |
128
+ | Factory reset + config restore, **D-Link** | proven on hardware: reset -> `10.90.90.90`/`admin` -> upload the saved config file -> back at its own address with its rotated password and a state identical to the pre-test backup (~4 s of cluster-link ping loss) |
129
+ | Factory reset, **USRobotics** | proven on hardware, but the recovery is different: after the reset, uploading the switch's own `config.bin` (it reboots and answers a timeout) restored **none** of the VLAN groups (only group 1 remained), flow control (back to Enable), name/location, address (192.168.0.1) or password (`admin`). Recovery means re-applying a saved state (`backup_state`) over the web pages: `maintenance.reapply_usr_state`, used by `scripts/recover_after_reset.py usr-3com <state.json> --state`. The same steps were done by hand on 2026-11-08 and the switch ended identical to its backup; the packaged function itself is fake-switch tested only |
130
+ | Config restore, USRobotics | the upload is accepted and the switch reboots (~30 s), but see above: do not rely on it to restore settings after a reset |
131
+
132
+ ### Operational notes from the outage tests
133
+ - In the test setup the D-Link carried only a redundant second link and the USRobotics carried the main LAN plus the first link: a D-Link reboot/restore/reset cost a few seconds of ping loss on that link, a USRobotics reset/restore takes the whole LAN down for about a minute. Do one switch at a time.
134
+ - After a USRobotics factory reset its password is `admin` again and the stored entry must say so before `plan_password` can rotate it (the recovery sets it; rotate again afterwards).
135
+ - Reaching a reset switch at its factory address needs a temporary host route on the machine running the tools (`ip addr add <x>/32` + `ip route add <default>/32 dev <bridge>`), not a /8 or /24 alias that would capture other traffic.
136
+
137
+ ### Not implemented
138
+ | Item | Why |
139
+ |---|---|
140
+ | Firmware upgrade (D-Link `FWUpgrade.cgi`; the vendor utility uses TFTP) | the upload protocol behind the button is unknown (the page only has buttons), and a bad image or a power cut during the write can brick the switch. `backup_firmware` keeps a copy of the running image. |
141
+ | The vendor utility's SOAP/UDP channel for writes (`SetDeviceCfg`, TFTP) | not captured; the web channel covers everything above. |
142
+ | DHCP (instead of a static address), 802.1Q mode switch | no need so far; the D-Link `set_ip.cgi` supports DHCP, which would make the switch's address unpredictable. |
143
+
144
+ See `docs/ADDING-SWITCHES.md` for how to add another switch, another model or firmware, or a new vendor.
145
+
146
+ ## Related projects (other gear with no vendor API)
147
+ - [tplink-easysmart-mcp](https://glama.ai/mcp/servers/ebenezer-isaac/tplink-easysmart-mcp): TP-Link Easy Smart switches over their web CGI, with dry-run, protected ports and lockout-aware failures.
148
+ - A second TP-Link Easy Smart server (`tplink-easy-smart-switch-mcp`, by qwe7002-ai) and a Netgear XS512EM server (`netgear-xs512em`, by supere989); find them by name on GitHub.
149
+ - [NetContext-MCP](https://github.com/hgursel/NetContext-MCP): SSH to older CLI switches (Cisco, Aruba) with legacy algorithms.
150
+
151
+ The gated-write pattern here (dry run, risk checks, session-eviction caveat) follows the same approach; ideas flow both ways.
152
+
153
+ See `docs/PROTOCOL.md` for the wire formats and the page/CGI map and `docs/IDEAS.md` for what was borrowed from the projects above and a sketch of a multi-vendor umbrella (an idea only).
154
+
155
+ ---
156
+ A Nebulous Ideas project, built by the Nebulous Collective: the people and agents who work on it.
@@ -0,0 +1,74 @@
1
+ # Adding or expanding supported switches
2
+
3
+ Three kinds of work, from least to most:
4
+
5
+ 1. **Another switch of a family that is already supported** (another USRobotics 7724 or D-Link DGS-1100-08): a config entry, nothing else.
6
+ 2. **Another model or firmware of a supported family** (DGS-1100-16/24, a newer D-Link firmware, another Cameo-built switch): a few constants, some detection, new fixtures.
7
+ 3. **A new vendor** (TP-Link Easy Smart, Netgear, ...): a new session class, parser, planner and fake, following the same safety rails.
8
+
9
+ Rule that applies to all three: **read first, write last, and never trust a fake switch alone**. Both vendors' write paths were found by reading the switch's own pages,
10
+ and the fake switches (tests/fakeswitch.py, tests/fakedlink.py) only know what we already learned; on 2026-11-08 real hardware showed two behaviours the fakes could not
11
+ have predicted (a mirror disable that hangs without the full form, a trunk write that takes 11 s). A write is "proven" only after a round trip on an unused port.
12
+
13
+ ## 1. Another switch of a supported family
14
+ 1. Make sure the host can reach it (the management VLAN or a routed path) and note its address. `discover(interface)` or `probe(host)` confirms the Cameo discovery reply.
15
+ 2. Add it to `~/.config/switch-mcp/switches.json`:
16
+ ```json
17
+ "lab-switch": {"host": "192.0.2.10", "vendor": "dlink", "protected_ports": []}
18
+ ```
19
+ `vendor` is `usrobotics` or `dlink`. Add every uplink and every port that carries your management path to `protected_ports` before any write.
20
+ 3. Give it a password: `SWITCH_MCP_PASSWORD_LAB_SWITCH` (name upper-cased, `-` becomes `_`) or an entry in `~/.config/switch-mcp/passwords.json` (mode 0600). Passwords are never tool arguments.
21
+ 4. Run `get_state lab-switch` and `backup_state lab-switch` before anything else. Read the port map (which port goes where) by matching the MAC table or the per-port counters, set `protected_ports`, then plan.
22
+
23
+ ## 2. Another model or firmware of a supported family
24
+ What is model-specific today (search the code for these):
25
+ - **D-Link**: `ROOT = "/DGS-1100-08_1.10.039/"` in `dlinkweb.py` (the firmware folder), `PORTS = 8`, `TRUNK_GROUPS`/`TRUNK_MAX_PORTS`, `TIMEOUT_MIN/MAX`, the speed and state tables, and the quad rule for trunk groups.
26
+ Find the real values on the switch: fetch `index.htm`; it contains `RT='/<model>_<firmware>/';`. `DS/const.js` holds `g_PortNum`, `g_TrunkNum`, `g_MaxTkPtNum`, `g_MinTmOut`, `g_MaxTmOut`.
27
+ A sensible refactor is to read `RT` from `index.htm` at login and the limits from `DS/const.js`, instead of hard-coding them; do that before adding a second D-Link model.
28
+ - **USRobotics**: 24 ports and 15 VLAN groups appear in `webui.py` validation (`1 <= port <= 24`, `group` 1-15) and the trunk option sets are read from the page, not hard-coded.
29
+
30
+ Steps:
31
+ 1. **Get the pages.** Log in with the existing session class (it handles the login hash/cookies) and save the pages to a scratch directory, read-only. For a D-Link: `index.htm` -> `Prefix.js` -> `en.js` -> `tree/treeitems.js` ->
32
+ the `iss/H_*.htm` pages and the `DS/*.js` data files. `en.js` maps menu names to page files (`HTML0125=ISD+'H_25_Mirroring.htm'`); each page ends in `submitForm(form, "<name>.cgi")`.
33
+ Do not run any page's buttons.
34
+ 2. **Diff against the supported model.** Compare data variable names (`ds_*`), form control names and CGI names with `docs/PROTOCOL.md`. Differences become constants or parsers; identical pages need no code.
35
+ 3. **Fixtures.** Save the data files as `tests/fixtures/dlink_<name>.js` with real MACs, serials and IPs replaced by documentation values (RFC 5737 addresses, `00-00-5E-00-53-xx` MACs). The repository is shared; the user's real switches live only in `~/.config`.
36
+ 4. **Tests before hardware.** Extend the fake to the new port count/limits and make the existing plan tests pass for the new model; add tests for anything the model does differently.
37
+ 5. **Hardware.** Read-only first (`get_state`, compare with the web page on screen). Then the proof sequence below on a port with no link and not in `protected_ports`.
38
+
39
+ ## 3. A new vendor
40
+ Create one module per concern, mirroring what exists:
41
+ | File | Job |
42
+ |---|---|
43
+ | `<vendor>web.py` | the session class: login (always logged out in `__exit__`, one session at a time through the shared lock), an allow-list of read pages, parsers, and **an allow-list of write endpoints that is the only thing `_post` accepts** |
44
+ | `<vendor>changes.py` | `plan_*` functions (return `op`, `params`, `before`, `summary`, `risks`; they write nothing), plus `current(op, state, params)`, `target(op, params)`, `write(session, op, params)` and `inverse(op, params, before)` for apply and rollback |
45
+ | `server.py` | `@mcp.tool()` wrappers named `<vendor>_plan_*`; the shared `apply_plan` and `backup_state` need no change |
46
+ | `webui.open_session` | return the new session class for the new `vendor` value |
47
+ | `tests/fake<vendor>.py` | an in-memory switch that mimics the real pages, including quirks you have measured |
48
+ Reuse, do not reimplement: `safety.guard_protected` (protected ports), `safety.audit` (the audit log, no secrets), `changes.apply_plan` (re-read and refuse if the switch changed, snapshot, rollback plan written to disk **before** the write,
49
+ read-back verification, lost-contact handling).
50
+
51
+ How to find the write protocol of a vendor that has no API:
52
+ 1. Fetch the web UI's pages and read the JavaScript: form control names and the URL each form posts to are plain text. Where the UI is a single-page app, read its scripts and watch one request in the browser's network tab.
53
+ 2. Check what the vendor's own utility sends (the Cameo SmartConsole's SOAP/UDP channel was decoded this way; see `PROTOCOL.md`).
54
+ 3. Search for existing open-source drivers or MCP servers for the family (`docs/IDEAS.md` lists the TP-Link, Netgear and CLI-based ones) and read their request code before writing your own.
55
+ 4. Record the form names, the allowed values, and every client-side rule the page enforces in JavaScript before posting (the switch may not re-check them): repeat those rules in the `plan_*` function so a bad request fails at plan time.
56
+
57
+ ## The proof sequence for any new write (on an unused, unprotected port)
58
+ 1. `backup_state`, and write down the exact state you expect to return to.
59
+ 2. `plan_*` and read the plan; `apply_plan` with `confirm=true` only after reading it.
60
+ 3. Check the **read-back** (`verified: true`) and that nothing else changed (compare the full state against the backup: ports, VLANs, mirror, system).
61
+ 4. Apply the **rollback plan** and compare again. The switch must match the backup (ignoring uptime).
62
+ 5. If an apply returns "lost contact" or a timeout, do not repeat it blindly: read the state first, then probe the request by hand with a long timeout to see whether the switch is slow (trunks) or never answers (a field it expects is missing).
63
+ 6. Only then change the README status table from "fake switch only" to "proven on hardware", with the date.
64
+
65
+ ## Things that bit us
66
+ - These switches evict a second web session: close any browser tab on the switch first, and never run two tools at once.
67
+ - A form field that the page disables in JavaScript is not sent by a browser, but some CGIs still need it (D-Link mirror disable).
68
+ - Slow writes are normal on old firmware (USRobotics trunk, ~11 s). Give such writes a long timeout, or a success will be reported as lost contact.
69
+ - An empty value may be ignored rather than cleared (USRobotics VLAN description: a single space clears it).
70
+ - Changes are live but may not survive a power cut until saved (D-Link: `dlink_plan_save`; USRobotics: from its own page).
71
+ - Anything that can lock you out or drop the network (password, IP, reboot, reset, restore) belongs behind `webui.require_maintenance(...)`, with a plan that says what breaks, protected-port refusal, a saved backup first and a verification that does not depend on the session being changed. Firmware upgrade is still not implemented: capture the upload protocol from the vendor tool first.
72
+
73
+ ## Beyond switches
74
+ `docs/IDEAS.md` sketches a multi-vendor umbrella (one server, one plan/apply core, vendor drivers behind it). The pieces above are exactly the seam it would use: a driver is a session class plus planners; the core owns safety, audit and rollback.
@@ -0,0 +1,29 @@
1
+ # Ideas borrowed, and an umbrella that is only an idea
2
+
3
+ ## What came from other projects in this space
4
+ Other MCP servers for gear with no vendor API (TP-Link Easy Smart, Netgear Smart Managed Plus, SSH to older CLI switches, UniFi controllers)
5
+ have settled on a few habits. This project adopted the ones that fit two port-based/EasySmart switches:
6
+
7
+ | Habit seen elsewhere | Here |
8
+ |---|---|
9
+ | Dry run before every write | `plan_*` tools: nothing is written until `apply_plan(confirm=true)` |
10
+ | Protected ports | `protected_ports` per switch in `switches.json`; plans touching them are refused unless `allow_protected=true` |
11
+ | Lockout-aware failure handling | the rollback plan is saved to disk BEFORE the write; if contact is lost afterwards, `apply_plan` reports it and the plan can be applied from a new session |
12
+ | Session-eviction caveat | one web session at a time, always logged out; documented in the README |
13
+ | JSONL audit log | `audit.jsonl` in the state directory: plans, refusals, applies, backups (never credentials) |
14
+ | Documentation output for network gear | `describe_switch` returns markdown for a wiki page |
15
+ | Backups before changes | a snapshot before every apply, plus `backup_state` and `list_snapshots` |
16
+
17
+ ## The umbrella idea (not built: no other hardware here to test against)
18
+ The pieces that do not care about the vendor are already separate modules: the plan store and rollback (`changes.py`), the audit log and protected-port
19
+ guard (`safety.py`), the reports (`reports.py`). A multi-vendor "legacy switch" server would add one driver per family behind a small interface:
20
+
21
+ ```
22
+ discover(interface) -> list[SwitchInfo]
23
+ state() -> dict # parsed, vendor-neutral where possible
24
+ plan_* / write_* # the few forms a driver may post
25
+ current(op, state, params), target(op, params), inverse(op, params, before)
26
+ ```
27
+
28
+ `dlinkchanges.py` already has `current`/`target`/`inverse`/`write` in exactly that shape, so a TP-Link Easy Smart or Netgear driver (both have public
29
+ protocol write-ups) could be added without touching the core. Without the hardware to prove writes, that would be guesswork, so it stays a note.