The MCP project_create tool has passed auto_close=False since 8f8abe410
(2026-06-13), but create_project_handler only forwarded {"name": name}
to the REST API — auto_close was silently dropped and the controller's
Project.__init__ default (True, unchanged since 2016) won. Every project
created via MCP since June has auto_close=true on disk and closes when
the last client disconnects.
- projects.py: forward auto_close when present in params
- test_handlers.py: assert the forwarded json_data (with and without
auto_close)
- test_tool_params.py: the tool/handler param consistency test never
actually checked anything — three blind spots now fixed:
1. dispatch is asyncio.to_thread(_run_handler_sync, ...) whose
node.func is an Attribute, not a Name — no call ever matched
2. tools that build 'params' as a variable before passing it were
skipped; now the initial dict literal is resolved (extra-passed
direction only)
3. tool functions are async defs (ast.AsyncFunctionDef) but the
enclosing-function lookup only matched ast.FunctionDef, so tool_name
was always None
Also: map the two marker handlers missing from HANDLER_FILES, skip
handlers that forward params.items() generically (wildcard), union
passed keys across multi-branch dispatches (node_create single/batch),
and drop two dead helpers.
Verified: full suite 1568 passed; reverting the handler fix turns both
test_tool_handler_param_consistency and test_create red.
MCP is an optional AI feature that already depends on
agent.gns3_copilot (Gns3Connector, nornir/netmiko tools) and whose
MCP_AVAILABLE feature flag lives in gns3server/agent. Moving it there
collocates all AI features under one tree and removes AI code from the
core REST routes.
- git mv gns3server/api/routes/mcp -> gns3server/agent/mcp (no content changes)
- api/server.py, core/tasks.py: update import paths
- tests: tests/api/routes/mcp -> tests/agent/mcp, rewrite patch BASE and
handler imports; fix MCP_DIR depth in test_tool_params.py
- agent/__init__.py: probe the SDK via importlib.import_module so the
top-level name "mcp" is not bound in the agent namespace (it would
shadow the new gns3server.agent.mcp subpackage and break
'from gns3server.agent import mcp')
- docs: update source file paths
Verified: full suite 1567 passed; 82 MCP tools registered, SSE mounted
at /v3/mcp/transport.
NotificationQueue.get only generated a synthetic ping when the queue
was idle for the full timeout. Under sustained event load (e.g. a
project with markers matching at 15-260 events/s) the queue never
idled, so compute notification streams never carried a ping and the
controller stopped emitting compute.updated: clients lost compute
statistics until the event flow paused or the server restarted.
A ping is now guaranteed at least every timeout seconds regardless of
event flow: when the ping deadline is reached the next get() returns a
ping ahead of queued events (pings only carry statistics, so skipping
ahead of real events is harmless). Both the compute stream (compute
CPU/memory/disk stats -> compute.updated) and the controller stream
(idle keepalive) benefit.
Two exception paths could permanently kill the compute notification
chain (no more compute.updated events, no reconnection until a server
restart):
- connect() only caught ComputeError, but _run_http_query translates
HTTP status errors (401/403/404/...) into sibling ControllerError
subclasses (and a raw fastapi HTTPException for unexpected statuses).
Those escaped the fire-and-forget connect() task started at controller
startup and died silently. Now they notify clients, schedule an
exponential-backoff retry, and still re-raise for explicit callers.
The dead web.HTTP* except branches (never reached since
_run_http_query converts HTTP errors itself) are removed.
- _connect_notification() only caught aiohttp.ClientError. A malformed
frame (e.g. missing 'action') or any error raised while dispatching a
compute event (e.g. a pydantic ValidationError in
node.parse_node_response) escaped the task, skipped the reconnect
scheduling placed after the try block, and killed the stream forever.
Now any exception is logged with its traceback (the gather() future
holding it was never retrieved, so nothing was ever printed) and the
reconnect scheduling + final compute.updated emit live in the finally
block so every exit path recovers.
Also moves the usage-stats reset before the disconnect log line so the
emitted compute.updated snapshot is consistent.
The SKIP_INIT volume bridge replicated init.sh's seed + mount --bind script
via docker exec *after* the container started. That copied the mechanism but
not the invariant that makes init.sh safe — the entrypoint position, which
guarantees the volume is in place before the application runs. The exec runs
concurrently with the NOS boot, so whether the NOS loaded its persisted
config or the overlay's factory copy was a timing race:
- single node stop/start on an idle system won it (exec ~1s, SR Linux reads
its startup config at ~2-4s) — the save/stop/start round-trip passed;
- a server restart + project reload lost it (concurrent node starts queue on
the Docker API, delaying the exec by seconds) — SR Linux booted factory
while the persisted config.json sat intact on the host;
- XRd was immune (systemd boots tens of seconds before XR touches
/xr-storage), which is why the race was never observed on it.
Replace the bridge entirely:
- new DockerVM._prepare_volumes hook (no-op in the base class) runs in
create() after the image is present, before the container is created;
VendorDockerVM overrides it to seed each volume's host directory from the
image (throwaway docker create container + docker cp -a, nothing
executes). The .gns3_perms marker gates the seeding: a volume that ever
started is never re-seeded, so saved configuration is never overwritten
with factory content (also the upgrade path for existing nodes).
- VendorDockerVM._mount_binds now binds the volumes directly at their real
in-container paths (/etc/opt/srlinux) instead of /gns3volumes aliases, so
the persisted config is visible to the NOS from the very first process.
- _setup_skip_init_volumes and its start() call are gone; the container-side
_fix_permissions targets the volume paths directly (the direct binds
exist for the whole container lifetime, unlike the old bridge).
The volume-list computation (validation + overlap de-duplication) moves
into DockerVM._persistent_volume_list so create-time seeding and _mount_binds
cannot drift apart.
SkillsLoader.load_device_skills() now supports two device layouts: the
existing single file and a split directory (device/<device>/_base.yaml +
one YAML per protocol topic). Topic files are merged into the device
skill under 'topics', keyed by their 'topic' field; mismatched
device_type, missing _base.yaml and duplicate topics are handled with
explicit log-and-skip.
get_skill() and DeviceSkillsTool gain a 'topic' parameter following the
injection list -> index -> issue pattern. Topic bodies are never
returned without an explicit topic request - index/summary/full all
serve a topic index instead - so growing a device with new protocol
topics no longer grows the token cost of device-level lookups.
Also fix reload_skills() to actually drop injection skills that fail
validate_skill_format() instead of logging 'skipping' and merging them
anyway.
docker-exec-console.md gains a "Terminal geometry and size forwarding"
section: the WS binary control-frame protocol ({"cols","rows"} -> NAWS /
asyncssh resize), the tall 511x10000 default and when it is applied or
restored, the exec-creation race handling, the GNS3_CONSOLE_RESIZE=0
knob for paging CLIs, and the SR Linux flicker root cause with the
measured numbers (rows-driven ~2.4x output inflation on CPR-answering
clients; width-independent; sr_cli's scroll-append re-rendering is
inherent and identical outside GNS3).
vendor-nos-xrd.md: the appliance recipe now includes
GNS3_CONSOLE_RESIZE=0 with the rationale (shared exec + XR pager vs
browser resizes). New troubleshooting entry for the flicker symptom.
A browser's terminal-size control frame (NAWS through the console telnet
server) can arrive while client_connected_hook is still creating the
exec; the resize is a no-op then, and the tall default applied after
creation would overwrite it, leaving the session at 511x10000 until the
user resizes.
Record sizes received before the exec exists and prefer them over the
tall default once creation finishes. The recorded size is cleared when
the last client disconnects, together with the restore-to-default.
The exec behind a docker_exec console is shared by every console client,
so a browser's terminal-size resize (WS control frames -> NAWS) also
changes the geometry concurrent netmiko sessions see. SR Linux doesn't
care (no pager, no hard wrapping), but CLIs that page on the PTY window
size (IOS-XR) would park at --More-- again the moment a browser is
connected.
Split the client-driven NAWS path (_on_naws) from the internal resize
(_resize_exec): GNS3_CONSOLE_RESIZE=0 makes the console ignore client
resizes entirely and keep the tall 511x10000 no-paging default, while
the creation-time default and the restore-on-last-disconnect still go
through the internal path. XRd appliance templates should set it.
The docker_exec console defaults its exec PTY to 511x10000 (the no-NAWS
default that keeps the IOS-XR pager quiet for netmiko). A CPR-answering
client (xterm.js) on top of that tall canvas makes prompt_toolkit-based
CLIs (SR Linux sr_cli) re-emit their accumulated output on every
incremental render: ~145 KB instead of ~60 KB per command, visible in
the WebUI as full-screen clear/redraw flicker.
Let WebSocket console clients propagate their real terminal geometry:
binary frames {"cols": N, "rows": M} alongside text frames carrying
terminal data. The controller forwards binary frames (previously only
text was forwarded), and the compute side turns them into a NAWS
subnegotiation for telnet-based consoles (docker_exec included) or an
asyncssh pty size change for SSH consoles.
The docker_exec console restores the tall 511x10000 default when its
last client disconnects, so a later non-NAWS client (netmiko, bare
telnet) connecting to the still-live exec doesn't inherit a browser
geometry and hit PTY-window paging again.
A dev machine running a real gns3server (or qemu) can already listen on
5011; reserve_tcp_port then silently replaces it with the next free port
and test_console fails on the exact-echo assertion. Pick a port that is
actually free on the host first, like test_change_console_port does.
The brctl Ethernet switch runs a per-port uBridge relay, so it can host
the mark filter and packet filters like any uBridge-backed node. Add
ethernet_switch to _MARKER_CAPABLE_TYPES and _get_filter_node, narrow the
UDPLink.update() NIO-PUT skip down to the Dynamips-hosted ethernet_hub,
and expose the matching compute endpoints: PUT nio (filter/marker
reapply) plus the per-marker toggle/pause/resume/delete/rebuild routes.
The ethernet_hub keeps its exclusion: its routes still wire into the
Dynamips hub, which has no uBridge of its own.
Web clients serialize empty form fields as "" while unset values are
stored as None on the node. The bare != diff in the update handler then
sees a phantom change on every full PUT and recreates the container for
nothing -- even when the user only changed a controller-only field such
as netmiko_device_type.
Normalize at the schema boundary ("" -> None for start_command,
environment and extra_hosts; "" -> "/" for console_http_path), make
the setters apply the same canonicalization, and create nodes through
the setters instead of bypassing them in __init__ so both paths store
identical values.
_resolve_token tried the JWT path before checking for the gns3_ prefix,
so every API-key connection logged a spurious "JWT rejected" ERROR from
get_token_data. Check the prefix first, and downgrade the JWT-rejected
log to WARNING — a rejected token is a client problem, not a server one.
node_console_info now returns token_sha256_prefix (sha256, first 8 hex
chars) and token_ttl_seconds alongside the console WebSocket URL, and
controller WebSocket auth rejections include the sha256 prefix of the
token as received. Comparing the two immediately distinguishes a token
corrupted in transfer from server-side rejection causes (expired,
revoked, bad signature).
get_token_data used to raise the same "Could not validate credentials"
for every JWT-level failure (bad signature, expired, malformed), which
made console WebSocket auth failures impossible to tell apart. Return a
distinct detail per cause and log the underlying exception plus the
unverified header alg value on rejection.
The docker_exec console resized its exec PTY to 80x24 until a client
sent NAWS. CLIs that page on the PTY window size instead of the
terminal length (the IOS-XR pager) therefore parked long output at
--More-- for clients that never negotiate NAWS — netmiko, bare telnet —
making copilot device commands time out on XRd.
Default the exec to 511x10000 instead (511 matches netmiko's own
'terminal width 511' convention): no paging and no hard wrapping for
non-NAWS clients, while real NAWS clients keep resizing to their actual
geometry as before.
Also updates the project memory record with the confirmed root cause
and the fix.
Root cause (confirmed live): the XRd console reaches the container CLI
via docker exec, and the XR pager reads the exec PTY's 24-row window
(TIOCGWINSZ) instead of the CLI-level terminal length — so
'terminal length 0' shows in 'show terminal' yet long-output commands
page after exactly 24 lines and park at --More--, making netmiko time
out. '| no-more' does not exist on IOS-XR.
Also documents the agreed copilot-side fix design (tail-anchored
--More-- detection with quiet double-confirmation, reconnect before
retry, session_log), for the upcoming feat/copilot-xrd-more-handling
branch.
The vendored gns3fy Node model dropped default_username/default_password
from the API response, and the nornir groups hardcoded empty
credentials, so drivers that require authentication (gns3_ruijie_telnet,
stock netmiko SSH/telnet) could not log in.
Carry the per-node credentials through nodes_inventory() into the
nornir hosts data at host level, where they override the group's empty
fallback. Missing or cleared ("") values keep inheriting from the
group, so no-auth drivers are unaffected.
Since aef337e86 the config loader generates a random JWT secret even
without a main config file, so the class-scoped client/authorized_client
fixtures signed their tokens with a key that the autouse
run_around_tests fixture would immediately replace with the default
one for every test function. Every controller API test using those
fixtures was failing with 401 (BadSignature).
Sign both fixtures explicitly with DEFAULT_JWT_SECRET_KEY to match
the secret enforced at request time.
New GET /v3/netmiko/device_types endpoint returns the device types
supported by the netmiko library installed on the server, including
the gns3-copilot custom drivers, so the web UI can populate the
netmiko_device_type dropdown on templates and nodes.
The list is read at runtime from netmiko's ssh_dispatcher.CLASS_MAPPER
registry (the same table ConnectHandler dispatches on), filtered to
drop the '_ssh' aliases and the 'autodetect' pseudo type, and cached
for the process lifetime. Returns 501 when netmiko is not installed
(ai-features extra).
Add default_username/default_password as controller-only node properties
(the netmiko_device_type pattern): they are not sent to the compute,
persist with the project topology and can be updated or cleared per
node. Creating a node from a template seeds them from the template
appliance metadata, and the metadata itself is dropped there so it
never leaks into the node properties.
Appliance fields that describe the appliance (vendor information, default
credentials, installation instructions...) were dropped when installing a
template. Keep them in a new appliance_metadata JSON column on the
templates table, filled by the appliance-to-template conversion for both
registry v1-6 and v8 (version level values override the appliance level
ones). The nested schema allows extra fields so future registry fields
persist without a migration.
- install: resolve the image directory from the version's settings type and
skip image handling for docker appliances; guard appliance.images
- appliance schema: validate template_properties against template_type,
align cpu_throttling with the qemu template, add kvm and version idlepc
- conversion: map IOU image to path, kvm disable to accel=tcg, inherit only
same-type default settings, symbol fallback from the effective category,
template_properties cannot override structural fields
- allow clearing netmiko_device_type with an empty string
- download the template symbol regardless of the level it is defined at and
give qemu guests a default symbol
The vendored gns3fy Node model and its nodes_inventory() now carry the
node's netmiko_device_type field, and get_device_ports_from_topology()
resolves the Netmiko device type from it first, falling back to the
device_type:<type> tag. Nodes created from a template inherit the value
from the template automatically, so automation tooling gets the correct
Netmiko driver without tags.
Both the v1-6 and v8 appliance models accept an optional top-level
netmiko_device_type, and ApplianceToTemplate copies it into the created
template so installed appliances carry the automation hint end to end.
netmiko_device_type follows the CONTROLLER_ONLY_PROPERTIES pattern
(like console_auto_start): a node created from a template inherits the
template value, PUT /nodes can override it inside a topology, updates
never round-trip to the compute, and the value persists in the project
topology file.
Common template field (schema + templates table column + Alembic
migration) holding the Netmiko device type (e.g. 'cisco_xr', 'nokia_srl')
so Netmiko/Nornir based tooling can look up how to reach a node's CLI
without hard-coded vendor mappings. Free-form lowercase string on
purpose: Netmiko's platform list evolves independently of GNS3.
DockerPropertiesV8 now accepts custom_adapters (already available to
v1-6 top-level appliances and to Qemu v8 properties), so port-named
Docker appliances (XRd, SR Linux) can move to the v8 format without
losing their interface naming.
Appliance.type resolves the node type from the v8 settings template_type
(default set first) instead of misclassifying every v8 appliance as
qemu, and _get_default_symbol applies the docker guest symbol to v8
Docker guest appliances.
new_template() now converts the v8 settings[] format per the spec in
gns3-registry#734: settings selection (version name reference, then the
default set, then a single set), inherit_default_properties merging, and
template_properties expansion with category/usage/symbol resolved from
template_properties > version > appliance levels. Undefined properties
are left out so controller template defaults apply.
Registry versions 1-6 keep the existing top-level emulator block path.
The vendored gns3fy copy keeps its type lists as literals (the module is
shared with the standalone MCP service and cannot import server enums),
and CONSOLE_TYPES had drifted: 'ssh' and 'docker_exec' were missing while
both are valid server-side. Impact: the copilot topology reader validates
the whole node list in one pydantic pass, so a single vendor NOS node
(console_type 'docker_exec') made it drop the entire project and return
zero devices to every copilot device tool.
Add the missing values plus drift tests asserting the vendored lists
cover the server enums (skipped when ai-features extras are absent).
The 600 s clamp was unreachable in practice: the controller's stop
request times out at 240 s (controller/node.py) and the Docker stop
query gets the value +30 s as its HTTP timeout, so anything above 210
would abort upstream first and surface an error while the stop keeps
running server-side. Cap at the derived ceiling and document the chain
in the clamp and the docstring.
Nine fixes from a review of the docker-shm-devices diff:
* GNS3_STOP_TIMEOUT >300 s aborted at the manager's default HTTP timeout
before Docker finished the stop — the stop query now gets a timeout
with a margin over the grace period.
* Overlapping bind targets (GNS3_MASK_UDEV + GNS3_MASK_SYSTEMD on the
same unit, a unit named twice, an extra_configs target equal to a
masked unit) made Docker reject the create with 'Duplicate mount
point' — Mounts are deduplicated by target.
* ExtraConfig.target now carries a pydantic validator (absolute file
path, no '..'), so bad targets 422 at template-save time instead of
failing at node-create time after a multi-GB image pull; directory
forms ('/', '/etc/') are also rejected by the runtime guard instead
of raising IsADirectoryError (raw 500).
* _check_host_readiness skipped every remaining check when one
/proc/sys key was unreadable (mid-loop return) — now continues.
* The base-class GNS3_* env parser strips trailing commas like the
vendor parser, so 'GNS3_MASK_UDEV=1,' composed from a list still
activates.
* Vendor env knobs are re-parsed on every create(), so a PUT to the
node's environment takes effect on the next (re)create.
* The graceful SIGTERM stop is now limited to the explicit user stop
route; delete/update/close/crash-cleanup keep the immediate kill
(those paths force-delete or recreate the container right after).
* An extra_configs target beneath a persisted volume is shadowed by the
volume bind — warn at create time.
The 60 s SIGTERM grace was hardcoded, unlike every other vendor knob
(GNS3_SHM_SIZE, GNS3_DEVICES, GNS3_MASK_UDEV, ...) which rides the
environment line. Parse GNS3_STOP_TIMEOUT=<seconds> (default 60,
clamped to 1-600, invalid values keep the default) and use it in
VendorDockerVM._terminate_container().
DockerVM.stop() terminated containers with an immediate SIGKILL — fine
for init.sh-based containers whose state is persisted beforehand, but a
systemd NOS (Cisco XRd, SR Linux) needs a graceful shutdown and treats
the abrupt kill as an unclean shutdown (exit 137 on every stop).
Extract the final termination into _terminate_container() and override
it in VendorDockerVM: POST /containers/{id}/stop?t=60 sends SIGTERM and
waits for systemd to stop services; Docker itself SIGKILLs the
container once the grace period expires, so no fallback is needed.
Docker's 304 (already stopped) is swallowed.
PortManager.get_free_udp_port had an unguarded find-then-add sequence.
A link allocates both ends concurrently (asyncio.gather in
UDPLink._prepare -> two POST /ports/udp) and FastAPI runs the sync
route handler in a threadpool, so both threads could probe the same
'free' port before either recorded it — handing lport == rport to both
ends. uBridge sets SO_REUSEADDR on UDP NIO sockets, so the double bind
succeeds silently and the kernel delivers everything to the last-bound
socket: one node starves, the other echoes to itself.
Make every TCP/UDP allocate/reserve/release path atomic with an RLock,
and rebuild _link_data in UDPLink._prepare so reset() commits the fresh
port pair instead of re-sending the stale, already-released one.
Regression tests: threaded barrier allocation never returns duplicates
(red on the old code, UDP and TCP); reset() leaves exactly one mirrored
NIO pair per side with lport != rport (red on the old code).