From 89e11d265ff3ec4b4516be5cfc22932dcedc5f4c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 15 Jul 2026 14:28:16 +0800 Subject: [PATCH 1/4] fix(mcp): pass user.token_version when generating temp JWT from API key _resolve_token generated a temp JWT with a hardcoded ver=0 after validating the API key. Users who had logged out at least once (token_version >= 1) would hit "Token has been revoked" 401 on every MCP tool call, because the REST auth chain rejects ver=0 when the user's token_version no longer matches. Fix: pass the user's actual token_version to create_access_token so the temp JWT carries the correct ver claim. --- gns3server/api/routes/mcp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 07c3e060f..dd70f0636 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -233,7 +233,7 @@ async def _resolve_token(token: str) -> str | None: user = await user_repo.get_user(db_key.user_id) if user: _jwt_username_var.set(user.username) - fresh_token = auth_service.create_access_token(user.username) + fresh_token = auth_service.create_access_token(user.username, token_version=user.token_version) return fresh_token except Exception: pass From 3d6d9a33966de10acb846307c4364412fb8b5f8d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 15 Jul 2026 22:42:24 +0800 Subject: [PATCH 2/4] fix(mcp): thread token_version into console/download token minting Token revocation is a strict version check (token_data.token_version != user.token_version). create_access_token defaults token_version to 0, so the short-lived JWTs minted for the console WebSocket URL (nodes) and the download URLs (symbols, links) carried ver=0. Any user who had logged out at least once (token_version >= 1, e.g. the default admin) got tokens rejected as "revoked" on first use. e433991cf fixed this in _resolve_token's API-key branch but missed these three independent minting sites. Now resolve token_version during _resolve_token (the JWT branch decodes it, the API-key branch reads user.token_version), carry it through gns3_ctx, and pass it at every minting call. --- gns3server/api/routes/mcp/__init__.py | 13 +++++++++++-- gns3server/api/routes/mcp/links.py | 2 +- gns3server/api/routes/mcp/nodes.py | 2 +- gns3server/api/routes/mcp/symbols.py | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index dd70f0636..e0765ffe7 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -194,6 +194,12 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( _jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( "mcp_jwt_username", default=None ) +# token_version extracted during token validation — short-lived JWTs minted for +# download/console URLs must carry the same version, or the revocation check +# (token_data.token_version != user.token_version) rejects them as "revoked". +_jwt_token_version_var: contextvars.ContextVar[int] = contextvars.ContextVar( + "mcp_jwt_token_version", default=0 +) # ── Token validation ────────────────────────────────────────────────── @@ -208,8 +214,9 @@ async def _resolve_token(token: str) -> str | None: """ # Try JWT first try: - username = auth_service.get_username_from_token(token) - _jwt_username_var.set(username) + token_data = auth_service.get_token_data(token) + _jwt_username_var.set(token_data.username) + _jwt_token_version_var.set(token_data.token_version) return token except Exception: pass @@ -233,6 +240,7 @@ async def _resolve_token(token: str) -> str | None: user = await user_repo.get_user(db_key.user_id) if user: _jwt_username_var.set(user.username) + _jwt_token_version_var.set(user.token_version) fresh_token = auth_service.create_access_token(user.username, token_version=user.token_version) return fresh_token except Exception: @@ -292,6 +300,7 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]: "server_url": _server_url(), "jwt_token": _jwt_token_var.get(), "jwt_username": _jwt_username_var.get(), + "jwt_token_version": _jwt_token_version_var.get(), } result = handler(params, ctx) return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}] diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 543cb39e1..31daa1f26 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -336,7 +336,7 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An if not project_id: return {"error": "project_id is required"} username = gns3_ctx.get("jwt_username") - download_token = auth_service.create_access_token(username, expires_in=10) if username else None + download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None link_ids = params.get("link_ids") if link_ids: diff --git a/gns3server/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index 135872d3b..7c003d80a 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -316,7 +316,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An console_type = node.get("console_type", "unknown") # Short-lived JWT for the WebSocket URL (10 min) username = gns3_ctx.get("jwt_username") - ws_token = auth_service.create_access_token(username, expires_in=10) if username else None + ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws" if ws_token: raw_url += f"?token={ws_token}" diff --git a/gns3server/api/routes/mcp/symbols.py b/gns3server/api/routes/mcp/symbols.py index d404e651b..6026ee51f 100644 --- a/gns3server/api/routes/mcp/symbols.py +++ b/gns3server/api/routes/mcp/symbols.py @@ -54,7 +54,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict return {"error": "symbol_id is required"} download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw" username = gns3_ctx.get("jwt_username") - download_token = auth_service.create_access_token(username, expires_in=10) if username else None + download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None result = { "symbol_id": symbol_id, "download_url": download_url, From d4389b70686a60c707038bc1818f8a4b3fa324e9 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 15 Jul 2026 22:42:24 +0800 Subject: [PATCH 3/4] fix(rbac): guard None current_user on websocket auth failure get_current_active_user_from_websocket returns None after closing the socket on an auth failure (revoked token, bad credentials, inactive user). has_privilege_on_websocket dereferenced current_user.is_superadmin without a None check, so any websocket auth failure surfaced as an AttributeError traceback instead of a clean close. Bail out early when current_user is None, mirroring the guard already present in ws_console. --- gns3server/api/routes/controller/dependencies/rbac.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gns3server/api/routes/controller/dependencies/rbac.py b/gns3server/api/routes/controller/dependencies/rbac.py index e67953486..41f4cbd0d 100644 --- a/gns3server/api/routes/controller/dependencies/rbac.py +++ b/gns3server/api/routes/controller/dependencies/rbac.py @@ -52,6 +52,10 @@ def has_privilege_on_websocket( current_user: schemas.User = Depends(get_current_active_user_from_websocket), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) ): + # Authentication may have failed and closed the socket inside the auth + # dependency, returning None — bail out before touching the user object. + if current_user is None: + return None if not current_user.is_superadmin: path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'") From 884c40038cb8a8b56cc30cc4b35a515ea1f281f7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 15 Jul 2026 23:16:40 +0800 Subject: [PATCH 4/4] docs(mcp): note --no-close for node_console websocat usage A heredoc (<<<) closes stdin at once, so websocat dropped the WebSocket before the device's reply arrived. Add --no-close to the node_console usage examples and the returned command field so the connection stays open while output is read. --- gns3server/api/routes/mcp/__init__.py | 8 +++++--- gns3server/api/routes/mcp/nodes.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index e0765ffe7..032c8269a 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -571,16 +571,18 @@ async def node_console( Complete workflow: 1. Call this tool with project_id and node_id to get the WebSocket URL 2. Connect to the returned URL using websocat in text mode (-t): - > websocat -t "ws://:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}" + > websocat -t --no-close "ws://:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}" 3. Send device commands with \\r\\n line endings via heredoc: - > websocat -t "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n' + > websocat -t --no-close "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n' 4. Receive response: websocat receives and displays device output Use 'timeout' to avoid connection hanging: - > timeout 10 websocat -t "ws://..." <<< $'commands\\r\\n' + > timeout 10 websocat -t --no-close "ws://..." <<< $'commands\\r\\n' Key points: - Use \\r\\n (not \\n) to match console protocol line endings - Use $'...' format for escape sequences in bash + - --no-close keeps the WebSocket open after stdin (heredoc) hits EOF, so + device output is not cut off before it arrives - Set a timeout to prevent hanging connections """ return await asyncio.to_thread(_run_handler_sync, get_node_console_info_handler, { diff --git a/gns3server/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index 7c003d80a..dcdfc96c6 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -328,7 +328,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An "node_name": node.get("name"), "console_type": console_type, "ws_url": ws_url, - "command": f"websocat {ws_url}", + "command": f"websocat -t --no-close {ws_url}", } if console_type in ("vnc",): result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"