tracerlab 0.1.5__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.
- tracerlab-0.1.5/.gitignore +7 -0
- tracerlab-0.1.5/LICENSE +21 -0
- tracerlab-0.1.5/PKG-INFO +82 -0
- tracerlab-0.1.5/README.md +57 -0
- tracerlab-0.1.5/npm/README.md +57 -0
- tracerlab-0.1.5/pyproject.toml +52 -0
- tracerlab-0.1.5/src/tracerlab/__init__.py +3 -0
- tracerlab-0.1.5/src/tracerlab/bridge.py +359 -0
- tracerlab-0.1.5/src/tracerlab/catalog.py +230 -0
- tracerlab-0.1.5/src/tracerlab/cli.py +151 -0
- tracerlab-0.1.5/src/tracerlab/configgen.py +566 -0
- tracerlab-0.1.5/src/tracerlab/data/pt_api.json.gz +0 -0
- tracerlab-0.1.5/src/tracerlab/js/bridge.html +3 -0
- tracerlab-0.1.5/src/tracerlab/js/bridge.js +94 -0
- tracerlab-0.1.5/src/tracerlab/js/runtime.js +592 -0
- tracerlab-0.1.5/src/tracerlab/labs.py +371 -0
- tracerlab-0.1.5/src/tracerlab/netcalc.py +165 -0
- tracerlab-0.1.5/src/tracerlab/pkt.py +199 -0
- tracerlab-0.1.5/src/tracerlab/planner.py +629 -0
- tracerlab-0.1.5/src/tracerlab/pt_extension/bridge.html +25 -0
- tracerlab-0.1.5/src/tracerlab/pt_extension/ptmcp.js +58 -0
- tracerlab-0.1.5/src/tracerlab/pt_extension/ptmcp.pts +0 -0
- tracerlab-0.1.5/src/tracerlab/server.py +1056 -0
- tracerlab-0.1.5/src/tracerlab/twofish.py +203 -0
- tracerlab-0.1.5/tests/data/enterprise_live.json +1 -0
- tracerlab-0.1.5/tests/fake_pt.js +200 -0
- tracerlab-0.1.5/tests/fixtures.py +39 -0
- tracerlab-0.1.5/tests/test_live_fake.py +88 -0
- tracerlab-0.1.5/tests/test_offline.py +167 -0
tracerlab-0.1.5/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atharv Santosh
|
|
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.
|
tracerlab-0.1.5/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tracerlab
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: MCP server for Cisco Packet Tracer 9. Build, configure and verify networks from an AI agent.
|
|
5
|
+
Project-URL: Homepage, https://github.com/atharv-santosh/tracerlab
|
|
6
|
+
Project-URL: Repository, https://github.com/atharv-santosh/tracerlab
|
|
7
|
+
Project-URL: Issues, https://github.com/atharv-santosh/tracerlab/issues
|
|
8
|
+
Author-email: Atharv Santosh <atharvsantosh1@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,ccna,cisco,ios,mcp,networking,packet-tracer
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Education
|
|
21
|
+
Classifier: Topic :: System :: Networking
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: mcp<3,>=2.3
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Tracerlab
|
|
27
|
+
|
|
28
|
+
MCP server for Cisco Packet Tracer 9. Lets Claude Code (or any MCP client) build networks in a running Packet Tracer: add devices, cable them, push IOS configs, address everything, set up routing, ping, and find what's broken. It can also read and write .pkt files without Packet Tracer open.
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
Twelve devices, twelve cables, OSPF across the ring and 6/6 pings, from one prompt.
|
|
33
|
+
|
|
34
|
+
There are also some ready-made labs (router on a stick, OSPF/EIGRP/RIP topologies, NAT, HSRP, EtherChannel, IPv6) that build in one call.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Needs [uv](https://docs.astral.sh/uv/) and Packet Tracer 9.
|
|
39
|
+
|
|
40
|
+
Register the server with your MCP client:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
claude mcp add packet-tracer -- uvx --from tracerlab packet-tracer-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`) and Codex take the same command:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"packet-tracer": { "command": "uvx", "args": ["--from", "tracerlab", "packet-tracer-mcp"] }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then install the Packet Tracer side once:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
uvx tracerlab install-extension
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Restart Packet Tracer and answer Yes when it asks about the new script module. A small "PT-MCP Bridge"
|
|
63
|
+
window shows up and goes green once the MCP server is running.
|
|
64
|
+
|
|
65
|
+
If something isn't connecting, run `uvx tracerlab doctor`.
|
|
66
|
+
|
|
67
|
+
## Use
|
|
68
|
+
|
|
69
|
+
Open Packet Tracer, start Claude Code and ask for things like:
|
|
70
|
+
|
|
71
|
+
- build a 3 router OSPF ring with a PC on each LAN and ping everything
|
|
72
|
+
- set up router on a stick with VLANs 10, 20, 30 and DHCP
|
|
73
|
+
- what's wrong with this network
|
|
74
|
+
- show me the running config on R1
|
|
75
|
+
|
|
76
|
+
`examples/enterprise-two-site.pkt` is a bigger network built entirely through the tools.
|
|
77
|
+
|
|
78
|
+
## Tests
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
uv run pytest
|
|
82
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Tracerlab
|
|
2
|
+
|
|
3
|
+
MCP server for Cisco Packet Tracer 9. Lets Claude Code (or any MCP client) build networks in a running Packet Tracer: add devices, cable them, push IOS configs, address everything, set up routing, ping, and find what's broken. It can also read and write .pkt files without Packet Tracer open.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Twelve devices, twelve cables, OSPF across the ring and 6/6 pings, from one prompt.
|
|
8
|
+
|
|
9
|
+
There are also some ready-made labs (router on a stick, OSPF/EIGRP/RIP topologies, NAT, HSRP, EtherChannel, IPv6) that build in one call.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Needs [uv](https://docs.astral.sh/uv/) and Packet Tracer 9.
|
|
14
|
+
|
|
15
|
+
Register the server with your MCP client:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
claude mcp add packet-tracer -- uvx --from tracerlab packet-tracer-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`) and Codex take the same command:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"packet-tracer": { "command": "uvx", "args": ["--from", "tracerlab", "packet-tracer-mcp"] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then install the Packet Tracer side once:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
uvx tracerlab install-extension
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Restart Packet Tracer and answer Yes when it asks about the new script module. A small "PT-MCP Bridge"
|
|
38
|
+
window shows up and goes green once the MCP server is running.
|
|
39
|
+
|
|
40
|
+
If something isn't connecting, run `uvx tracerlab doctor`.
|
|
41
|
+
|
|
42
|
+
## Use
|
|
43
|
+
|
|
44
|
+
Open Packet Tracer, start Claude Code and ask for things like:
|
|
45
|
+
|
|
46
|
+
- build a 3 router OSPF ring with a PC on each LAN and ping everything
|
|
47
|
+
- set up router on a stick with VLANs 10, 20, 30 and DHCP
|
|
48
|
+
- what's wrong with this network
|
|
49
|
+
- show me the running config on R1
|
|
50
|
+
|
|
51
|
+
`examples/enterprise-two-site.pkt` is a bigger network built entirely through the tools.
|
|
52
|
+
|
|
53
|
+
## Tests
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
uv run pytest
|
|
57
|
+
```
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Tracerlab
|
|
2
|
+
|
|
3
|
+
MCP server for Cisco Packet Tracer 9. Lets Claude Code (or any MCP client) build networks in a running Packet Tracer: add devices, cable them, push IOS configs, address everything, set up routing, ping, and find what's broken. It can also read and write .pkt files without Packet Tracer open.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Twelve devices, twelve cables, OSPF across the ring and 6/6 pings, from one prompt.
|
|
8
|
+
|
|
9
|
+
There are also some ready-made labs (router on a stick, OSPF/EIGRP/RIP topologies, NAT, HSRP, EtherChannel, IPv6) that build in one call.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Needs [uv](https://docs.astral.sh/uv/) and Packet Tracer 9.
|
|
14
|
+
|
|
15
|
+
Register the server with your MCP client:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
claude mcp add packet-tracer -- uvx --from tracerlab packet-tracer-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`) and Codex take the same command:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"packet-tracer": { "command": "uvx", "args": ["--from", "tracerlab", "packet-tracer-mcp"] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then install the Packet Tracer side once:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
uvx tracerlab install-extension
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Restart Packet Tracer and answer Yes when it asks about the new script module. A small "PT-MCP Bridge"
|
|
38
|
+
window shows up and goes green once the MCP server is running.
|
|
39
|
+
|
|
40
|
+
If something isn't connecting, run `uvx tracerlab doctor`.
|
|
41
|
+
|
|
42
|
+
## Use
|
|
43
|
+
|
|
44
|
+
Open Packet Tracer, start Claude Code and ask for things like:
|
|
45
|
+
|
|
46
|
+
- build a 3 router OSPF ring with a PC on each LAN and ping everything
|
|
47
|
+
- set up router on a stick with VLANs 10, 20, 30 and DHCP
|
|
48
|
+
- what's wrong with this network
|
|
49
|
+
- show me the running config on R1
|
|
50
|
+
|
|
51
|
+
`examples/enterprise-two-site.pkt` is a bigger network built entirely through the tools.
|
|
52
|
+
|
|
53
|
+
## Tests
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
uv run pytest
|
|
57
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tracerlab"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "MCP server for Cisco Packet Tracer 9. Build, configure and verify networks from an AI agent."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Atharv Santosh", email = "atharvsantosh1@gmail.com" }]
|
|
9
|
+
keywords = ["mcp", "packet-tracer", "cisco", "networking", "ccna", "ios", "automation"]
|
|
10
|
+
dependencies = ["mcp>=2.3,<3"]
|
|
11
|
+
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Education",
|
|
15
|
+
"Intended Audience :: System Administrators",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Education",
|
|
22
|
+
"Topic :: System :: Networking",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/atharv-santosh/tracerlab"
|
|
27
|
+
Repository = "https://github.com/atharv-santosh/tracerlab"
|
|
28
|
+
Issues = "https://github.com/atharv-santosh/tracerlab/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
tracerlab = "tracerlab.cli:main"
|
|
32
|
+
packet-tracer-mcp = "tracerlab.server:main"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["hatchling"]
|
|
42
|
+
build-backend = "hatchling.build"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/tracerlab"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = ["src/tracerlab", "tests", "README.md", "LICENSE"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""HTTP long-poll bridge to the PT script module.
|
|
2
|
+
|
|
3
|
+
If another tracerlab already owns the port, jobs are relayed through it (POST /exec).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import itertools
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("tracerlab.bridge")
|
|
21
|
+
|
|
22
|
+
HOST = "127.0.0.1"
|
|
23
|
+
PORT = int(os.environ.get("TRACERLAB_PORT") or os.environ.get("PTMCP_PORT") or "39150")
|
|
24
|
+
POLL_HOLD_S = 20.0 # how long a /poll request is held open when idle
|
|
25
|
+
STALE_AFTER_S = POLL_HOLD_S + 10.0
|
|
26
|
+
PICKUP_S = 8.0 # a queued job must be picked up by a poll within this
|
|
27
|
+
MAX_BODY = 64 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PTError(RuntimeError):
|
|
31
|
+
"""Raised for errors reported by Packet Tracer or the bridge."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotConnected(PTError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Job:
|
|
40
|
+
id: int
|
|
41
|
+
code: str
|
|
42
|
+
future: asyncio.Future
|
|
43
|
+
picked: asyncio.Event = field(default_factory=asyncio.Event)
|
|
44
|
+
created: float = field(default_factory=time.monotonic)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bridge_js() -> str:
|
|
48
|
+
return resources.files("tracerlab").joinpath("js/bridge.js").read_text()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bridge_build() -> str:
|
|
52
|
+
import hashlib
|
|
53
|
+
return hashlib.sha1(_bridge_js().encode()).hexdigest()[:10]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _runtime_js() -> str:
|
|
57
|
+
return resources.files("tracerlab").joinpath("js/runtime.js").read_text()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _runtime_version(js: str) -> str:
|
|
61
|
+
# include the hash so edits to runtime.js reload without a version bump
|
|
62
|
+
import hashlib
|
|
63
|
+
from . import __version__
|
|
64
|
+
return f"{__version__}+{hashlib.sha1(js.encode()).hexdigest()[:8]}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Bridge:
|
|
68
|
+
def __init__(self, host: str = HOST, port: int = PORT) -> None:
|
|
69
|
+
self.host, self.port = host, port
|
|
70
|
+
self._queue: asyncio.Queue[_Job] = asyncio.Queue()
|
|
71
|
+
self._inflight: dict[int, _Job] = {}
|
|
72
|
+
self._ids = itertools.count(1)
|
|
73
|
+
self._chunks: dict[int, dict[int, str]] = {}
|
|
74
|
+
self._server: asyncio.base_events.Server | None = None
|
|
75
|
+
self._last_seen = 0.0
|
|
76
|
+
self._pt_info: dict[str, Any] = {}
|
|
77
|
+
self._runtime_loaded_for: str | None = None # session token of the PT side
|
|
78
|
+
self._runtime_lock = asyncio.Lock()
|
|
79
|
+
self.mode = "stopped" # "owner" | "relay" | "stopped"
|
|
80
|
+
|
|
81
|
+
async def start(self) -> None:
|
|
82
|
+
# created here so they bind to the running loop
|
|
83
|
+
self._queue = asyncio.Queue()
|
|
84
|
+
self._inflight = {}
|
|
85
|
+
self._chunks: dict[int, dict[int, str]] = {}
|
|
86
|
+
self._runtime_lock = asyncio.Lock()
|
|
87
|
+
try:
|
|
88
|
+
self._server = await asyncio.start_server(self._handle, self.host, self.port)
|
|
89
|
+
self.mode = "owner"
|
|
90
|
+
log.info("bridge listening on http://%s:%d", self.host, self.port)
|
|
91
|
+
except OSError:
|
|
92
|
+
if await asyncio.to_thread(self._probe_owner):
|
|
93
|
+
self.mode = "relay"
|
|
94
|
+
log.info("port %d owned by another ptmcp; relaying", self.port)
|
|
95
|
+
else:
|
|
96
|
+
raise PTError(f"port {self.port} is in use by something that is not ptmcp "
|
|
97
|
+
"(set PTMCP_PORT to another port and update the PT module)")
|
|
98
|
+
|
|
99
|
+
async def stop(self) -> None:
|
|
100
|
+
self._last_seen = 0.0
|
|
101
|
+
if self._server:
|
|
102
|
+
self._server.close()
|
|
103
|
+
await self._server.wait_closed()
|
|
104
|
+
for job in list(self._inflight.values()):
|
|
105
|
+
if not job.future.done():
|
|
106
|
+
job.future.set_exception(PTError("bridge stopped"))
|
|
107
|
+
|
|
108
|
+
def _probe_owner(self) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
with urllib.request.urlopen(f"http://{self.host}:{self.port}/ping", timeout=2) as r:
|
|
111
|
+
return json.loads(r.read()).get("service") == "ptmcp"
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def connected(self) -> bool:
|
|
117
|
+
return time.monotonic() - self._last_seen < STALE_AFTER_S
|
|
118
|
+
|
|
119
|
+
def status(self) -> dict[str, Any]:
|
|
120
|
+
if self.mode == "relay":
|
|
121
|
+
try:
|
|
122
|
+
with urllib.request.urlopen(f"http://{self.host}:{self.port}/ping", timeout=2) as r:
|
|
123
|
+
d = json.loads(r.read())
|
|
124
|
+
d["mode"] = "relay"
|
|
125
|
+
return d
|
|
126
|
+
except Exception as e: # pragma: no cover - network edge
|
|
127
|
+
return {"mode": "relay", "connected": False, "error": str(e)}
|
|
128
|
+
return {
|
|
129
|
+
"service": "ptmcp",
|
|
130
|
+
"mode": self.mode,
|
|
131
|
+
"connected": self.connected,
|
|
132
|
+
"seconds_since_poll": round(time.monotonic() - self._last_seen, 1) if self._last_seen else None,
|
|
133
|
+
"pt": self._pt_info,
|
|
134
|
+
"queued": self._queue.qsize(),
|
|
135
|
+
"inflight": len(self._inflight),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async def run(self, code: str, timeout: float = 60.0, *, raw: bool = False) -> Any:
|
|
139
|
+
"""Run a JS function body in PT (PTM in scope) and return its JSON value."""
|
|
140
|
+
if not raw:
|
|
141
|
+
await self._ensure_runtime(timeout)
|
|
142
|
+
return await self._submit(code, timeout)
|
|
143
|
+
|
|
144
|
+
async def wait_connected(self, timeout: float) -> bool:
|
|
145
|
+
end = time.monotonic() + timeout
|
|
146
|
+
while time.monotonic() < end:
|
|
147
|
+
if self.mode == "relay" or self.connected:
|
|
148
|
+
return True
|
|
149
|
+
await asyncio.sleep(0.25)
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
async def _ensure_runtime(self, timeout: float) -> None:
|
|
153
|
+
async with self._runtime_lock:
|
|
154
|
+
probe = "return (typeof PTM !== 'undefined' && PTM.VERSION) ? PTM.VERSION : null;"
|
|
155
|
+
v = await self._submit(probe, timeout)
|
|
156
|
+
js = _runtime_js()
|
|
157
|
+
want = _runtime_version(js)
|
|
158
|
+
if v != want:
|
|
159
|
+
await self._submit(js + f"\nPTM.VERSION = {json.dumps(want)};\nreturn PTM.VERSION;", timeout)
|
|
160
|
+
|
|
161
|
+
async def _submit(self, code: str, timeout: float) -> Any:
|
|
162
|
+
if self.mode == "relay":
|
|
163
|
+
return await asyncio.to_thread(self._relay, code, timeout)
|
|
164
|
+
if not self.connected:
|
|
165
|
+
raise NotConnected(
|
|
166
|
+
"Packet Tracer is not connected. Open Packet Tracer 9 with the PT-MCP script "
|
|
167
|
+
"module installed (Extensions > PT-MCP Bridge shows a green 'connected' pill). "
|
|
168
|
+
"See pt_setup_instructions.")
|
|
169
|
+
loop = asyncio.get_running_loop()
|
|
170
|
+
job = _Job(next(self._ids), code, loop.create_future())
|
|
171
|
+
self._inflight[job.id] = job
|
|
172
|
+
await self._queue.put(job)
|
|
173
|
+
try:
|
|
174
|
+
await asyncio.wait_for(job.picked.wait(), PICKUP_S)
|
|
175
|
+
except asyncio.TimeoutError:
|
|
176
|
+
self._inflight.pop(job.id, None)
|
|
177
|
+
job.future.cancel()
|
|
178
|
+
self._last_seen = 0.0
|
|
179
|
+
raise NotConnected("Packet Tracer stopped polling (PT closed, bridge window closed, or a modal "
|
|
180
|
+
"dialog is blocking PT). Check the 'PT-MCP Bridge' window.") from None
|
|
181
|
+
try:
|
|
182
|
+
env = await asyncio.wait_for(job.future, timeout)
|
|
183
|
+
except asyncio.TimeoutError:
|
|
184
|
+
raise PTError(f"Packet Tracer did not answer within {timeout:.0f}s "
|
|
185
|
+
"(a modal dialog open in PT blocks scripting)") from None
|
|
186
|
+
finally:
|
|
187
|
+
self._inflight.pop(job.id, None)
|
|
188
|
+
return _unwrap(env)
|
|
189
|
+
|
|
190
|
+
def _relay(self, code: str, timeout: float) -> Any:
|
|
191
|
+
req = urllib.request.Request(
|
|
192
|
+
f"http://{self.host}:{self.port}/exec",
|
|
193
|
+
data=json.dumps({"code": code, "timeout": timeout}).encode(),
|
|
194
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
195
|
+
try:
|
|
196
|
+
with urllib.request.urlopen(req, timeout=timeout + 5) as r:
|
|
197
|
+
env = json.loads(r.read())
|
|
198
|
+
except urllib.error.HTTPError as e:
|
|
199
|
+
env = json.loads(e.read() or b"{}")
|
|
200
|
+
if env.get("bridge_error"):
|
|
201
|
+
raise (NotConnected if env.get("not_connected") else PTError)(env["bridge_error"])
|
|
202
|
+
return _unwrap(env)
|
|
203
|
+
|
|
204
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
205
|
+
try:
|
|
206
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
207
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
208
|
+
method, target, _ = lines[0].split(" ", 2)
|
|
209
|
+
headers = {k.strip().lower(): v.strip() for k, v in
|
|
210
|
+
(l.split(":", 1) for l in lines[1:] if ":" in l)}
|
|
211
|
+
n = int(headers.get("content-length", "0") or 0)
|
|
212
|
+
if n > MAX_BODY:
|
|
213
|
+
return await self._send(writer, 413, {"error": "too large"})
|
|
214
|
+
if "chunked" in headers.get("transfer-encoding", "").lower():
|
|
215
|
+
body = await _read_chunked(reader)
|
|
216
|
+
else:
|
|
217
|
+
body = await reader.readexactly(n) if n else b""
|
|
218
|
+
path = target.split("?", 1)[0]
|
|
219
|
+
if method == "OPTIONS":
|
|
220
|
+
return await self._send(writer, 204, None)
|
|
221
|
+
if path == "/ping":
|
|
222
|
+
return await self._send(writer, 200, self.status())
|
|
223
|
+
if path == "/hello":
|
|
224
|
+
from urllib.parse import parse_qs
|
|
225
|
+
q = parse_qs(target.split("?", 1)[1]) if "?" in target else {}
|
|
226
|
+
self._pt_info["webview"] = {k: v[0] for k, v in q.items()}
|
|
227
|
+
log.info("PT web view hello: %s", self._pt_info["webview"])
|
|
228
|
+
return await self._send(writer, 200, {"ok": True})
|
|
229
|
+
if path == "/poll":
|
|
230
|
+
return await self._poll(writer, target)
|
|
231
|
+
if path == "/result" and method == "POST":
|
|
232
|
+
self._last_seen = time.monotonic()
|
|
233
|
+
self._on_result(json.loads(body))
|
|
234
|
+
return await self._send(writer, 200, {"ok": True})
|
|
235
|
+
if path == "/exec" and method == "POST":
|
|
236
|
+
return await self._exec_for_relay(writer, json.loads(body))
|
|
237
|
+
if path == "/r": # chunked GET result from the PT web view
|
|
238
|
+
self._last_seen = time.monotonic()
|
|
239
|
+
self._on_chunk(target)
|
|
240
|
+
return await self._send_raw(writer, 200, "application/javascript", b"0;")
|
|
241
|
+
if path == "/bridge.js":
|
|
242
|
+
js = _bridge_js().replace("__PORT__", str(self.port)).replace("__BUILD__", _bridge_build())
|
|
243
|
+
return await self._send_raw(writer, 200, "application/javascript", js.encode())
|
|
244
|
+
if path == "/log":
|
|
245
|
+
log.info("PT web view: %s", target)
|
|
246
|
+
return await self._send(writer, 200, {"ok": True})
|
|
247
|
+
if path in ("/", "/bridge"):
|
|
248
|
+
return await self._send_raw(writer, 200, "text/html; charset=utf-8",
|
|
249
|
+
_bridge_page(self.port).encode())
|
|
250
|
+
await self._send(writer, 404, {"error": "not found"})
|
|
251
|
+
except (asyncio.IncompleteReadError, ConnectionError):
|
|
252
|
+
pass
|
|
253
|
+
except Exception as e: # pragma: no cover - defensive
|
|
254
|
+
log.exception("http handler failed")
|
|
255
|
+
try:
|
|
256
|
+
await self._send(writer, 500, {"error": str(e)})
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
finally:
|
|
260
|
+
try:
|
|
261
|
+
writer.close()
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
async def _poll(self, writer: asyncio.StreamWriter, target: str) -> None:
|
|
266
|
+
self._last_seen = time.monotonic()
|
|
267
|
+
q: dict[str, str] = {}
|
|
268
|
+
if "?" in target:
|
|
269
|
+
from urllib.parse import parse_qs
|
|
270
|
+
q = {k: v[0] for k, v in parse_qs(target.split("?", 1)[1]).items()}
|
|
271
|
+
if q.get("pt"):
|
|
272
|
+
self._pt_info.update({"version": q.get("pt"), "session": q.get("s")})
|
|
273
|
+
try:
|
|
274
|
+
job = await asyncio.wait_for(self._queue.get(), POLL_HOLD_S)
|
|
275
|
+
except asyncio.TimeoutError:
|
|
276
|
+
self._last_seen = time.monotonic()
|
|
277
|
+
return await self._reply_poll(writer, q, {"id": 0})
|
|
278
|
+
if job.future.done(): # caller gave up while queued
|
|
279
|
+
return await self._reply_poll(writer, q, {"id": 0})
|
|
280
|
+
job.picked.set()
|
|
281
|
+
self._last_seen = time.monotonic()
|
|
282
|
+
try:
|
|
283
|
+
await self._reply_poll(writer, q, {"id": job.id, "code": job.code})
|
|
284
|
+
except ConnectionError:
|
|
285
|
+
await self._queue.put(job) # web view vanished mid-poll; retry on next poll
|
|
286
|
+
|
|
287
|
+
async def _reply_poll(self, writer, q: dict, obj: dict) -> None:
|
|
288
|
+
cb = q.get("cb", "")
|
|
289
|
+
if cb and cb.replace("_", "").isalnum(): # JSONP for the PT web view
|
|
290
|
+
body = f"{cb}({json.dumps({**obj, 'build': _bridge_build()})});".encode()
|
|
291
|
+
return await self._send_raw(writer, 200, "application/javascript", body)
|
|
292
|
+
await self._send(writer, 200, obj)
|
|
293
|
+
|
|
294
|
+
def _on_chunk(self, target: str) -> None:
|
|
295
|
+
from urllib.parse import parse_qs
|
|
296
|
+
q = {k: v[0] for k, v in parse_qs(target.split("?", 1)[1], keep_blank_values=True).items()}
|
|
297
|
+
jid, i, n = int(q["id"]), int(q["i"]), int(q["n"])
|
|
298
|
+
parts = self._chunks.setdefault(jid, {})
|
|
299
|
+
parts[i] = q.get("d", "")
|
|
300
|
+
if len(parts) == n:
|
|
301
|
+
del self._chunks[jid]
|
|
302
|
+
self._on_result(json.loads("".join(parts[k] for k in range(n))))
|
|
303
|
+
|
|
304
|
+
def _on_result(self, msg: dict) -> None:
|
|
305
|
+
job = self._inflight.get(int(msg.get("id", 0)))
|
|
306
|
+
if job and not job.future.done():
|
|
307
|
+
job.future.set_result(msg)
|
|
308
|
+
|
|
309
|
+
async def _exec_for_relay(self, writer, req: dict) -> None:
|
|
310
|
+
try:
|
|
311
|
+
value = await self._submit(req["code"], float(req.get("timeout", 60)))
|
|
312
|
+
await self._send(writer, 200, {"ok": True, "value": value})
|
|
313
|
+
except NotConnected as e:
|
|
314
|
+
await self._send(writer, 200, {"bridge_error": str(e), "not_connected": True})
|
|
315
|
+
except PTError as e:
|
|
316
|
+
await self._send(writer, 200, {"bridge_error": str(e)})
|
|
317
|
+
|
|
318
|
+
async def _send(self, writer, status: int, obj: Any) -> None:
|
|
319
|
+
body = b"" if obj is None else json.dumps(obj).encode()
|
|
320
|
+
await self._send_raw(writer, status, "application/json", body)
|
|
321
|
+
|
|
322
|
+
async def _send_raw(self, writer, status: int, ctype: str, body: bytes) -> None:
|
|
323
|
+
reason = {200: "OK", 204: "No Content", 404: "Not Found", 413: "Too Large",
|
|
324
|
+
500: "Error"}.get(status, "OK")
|
|
325
|
+
hdr = (f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\n"
|
|
326
|
+
f"Content-Length: {len(body)}\r\nAccess-Control-Allow-Origin: *\r\n"
|
|
327
|
+
"Access-Control-Allow-Headers: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
|
|
328
|
+
"Cache-Control: no-store\r\nConnection: close\r\n\r\n")
|
|
329
|
+
writer.write(hdr.encode() + body)
|
|
330
|
+
await writer.drain()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _read_chunked(reader: asyncio.StreamReader) -> bytes:
|
|
334
|
+
body = bytearray()
|
|
335
|
+
while True:
|
|
336
|
+
size = int((await reader.readline()).split(b";")[0].strip() or b"0", 16)
|
|
337
|
+
if size == 0:
|
|
338
|
+
await reader.readline()
|
|
339
|
+
return bytes(body)
|
|
340
|
+
if len(body) + size > MAX_BODY:
|
|
341
|
+
raise ConnectionError("body too large")
|
|
342
|
+
body += await reader.readexactly(size)
|
|
343
|
+
await reader.readline()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _unwrap(env: dict) -> Any:
|
|
347
|
+
"""PT side replies {ok, value} or {ok:false, error, line}."""
|
|
348
|
+
if isinstance(env, str):
|
|
349
|
+
env = json.loads(env)
|
|
350
|
+
if env.get("ok"):
|
|
351
|
+
return env.get("value")
|
|
352
|
+
msg = env.get("error") or "unknown Packet Tracer error"
|
|
353
|
+
if env.get("line"):
|
|
354
|
+
msg += f" (line {env['line']})"
|
|
355
|
+
raise PTError(msg)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _bridge_page(port: int) -> str:
|
|
359
|
+
return resources.files("tracerlab").joinpath("js/bridge.html").read_text().replace("__PORT__", str(port))
|