YueGuobin 55b3a7d622
feat: implement standard MCP protocol with SSE transport
- Use FastMCP (Anthropic MCP SDK) for tool registration and SSE transport
- Mount SSE app under /v3/mcp/transport with JWT token authentication
- Token passed via ?token=<jwt> query parameter on SSE connection
- Token validated against GNS3 auth_service and stored in contextvars
- Tool handlers create Gns3Connector with JWT token to call GNS3 REST API
- 7 project tools: list_projects, get_project, create_project, delete_project,
  open_project, close_project, get_project_stats
- Unauthenticated SSE connections return 401
2026-06-04 22:19:43 +08:00

223 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#
# Copyright (C) 2020 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
MCP (Model Context Protocol) service for GNS3 server.
Implements the standard MCP protocol over SSE transport using FastMCP:
/v3/mcp/sse — SSE stream
/v3/mcp/messages/ — JSON-RPC messages
Tools are registered via @mcp.tool() decorators.
"""
import contextvars
import json
import asyncio
import logging
from typing import Any
from urllib.parse import parse_qs
from fastapi import APIRouter
from fastapi.responses import Response
from mcp.server.fastmcp import FastMCP
from gns3server.config import Config
log = logging.getLogger(__name__)
# ── Perconnection JWT token ─────────────────────────────────────────
# Set during SSE authentication, read by tool handlers running in the
# same asyncio task (contextvars propagate through asyncio.to_thread).
_jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"mcp_jwt_token", default=None
)
# ── Token validation ──────────────────────────────────────────────────
async def _validate_token(token: str) -> bool:
"""Return True if token is a valid GNS3 JWT."""
from gns3server.services import auth_service
try:
auth_service.get_username_from_token(token)
return True
except Exception:
return False
# ── Server URL helper ─────────────────────────────────────────────────
def _server_url() -> str:
cfg = Config.instance().settings
host = cfg.Server.host
if host == "0.0.0.0":
host = "127.0.0.1"
scheme = "https" if cfg.Server.enable_ssl else "http"
return f"{scheme}://{host}:{cfg.Server.port}"
# ── FastMCP Server ────────────────────────────────────────────────────
mcp = FastMCP("GNS3 MCP Server")
# ── Tool handlers ─────────────────────────────────────────────────────
def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
"""Run a synchronous Gns3Connector handler in a thread."""
ctx = {
"server_url": _server_url(),
"jwt_token": _jwt_token_var.get(),
}
result = handler(params, ctx)
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
@mcp.tool()
async def list_projects() -> list[dict[str, Any]]:
"""List all GNS3 projects accessible to the current user."""
from .projects import list_projects_handler
return await asyncio.to_thread(_run_handler_sync, list_projects_handler, {})
@mcp.tool()
async def get_project(project_id: str) -> list[dict[str, Any]]:
"""Get detailed information about a specific project.
Args:
project_id: Project UUID
"""
from .projects import get_project_handler
return await asyncio.to_thread(_run_handler_sync, get_project_handler, {"project_id": project_id})
@mcp.tool()
async def create_project(name: str, description: str = "") -> list[dict[str, Any]]:
"""Create a new GNS3 project.
Args:
name: Project name
description: Optional project description
"""
from .projects import create_project_handler
params = {"name": name}
if description:
params["description"] = description
return await asyncio.to_thread(_run_handler_sync, create_project_handler, params)
@mcp.tool()
async def delete_project(project_id: str) -> list[dict[str, Any]]:
"""Delete a GNS3 project permanently.
Args:
project_id: UUID of the project to delete
"""
from .projects import delete_project_handler
return await asyncio.to_thread(_run_handler_sync, delete_project_handler, {"project_id": project_id})
@mcp.tool()
async def open_project(project_id: str) -> list[dict[str, Any]]:
"""Open a closed GNS3 project.
Args:
project_id: Project UUID
"""
from .projects import open_project_handler
return await asyncio.to_thread(_run_handler_sync, open_project_handler, {"project_id": project_id})
@mcp.tool()
async def close_project(project_id: str) -> list[dict[str, Any]]:
"""Close an open GNS3 project.
Args:
project_id: Project UUID
"""
from .projects import close_project_handler
return await asyncio.to_thread(_run_handler_sync, close_project_handler, {"project_id": project_id})
@mcp.tool()
async def get_project_stats(project_id: str) -> list[dict[str, Any]]:
"""Get statistics (nodes, links, snapshots, drawings) for a project.
Args:
project_id: Project UUID
"""
from .projects import get_project_stats_handler
return await asyncio.to_thread(_run_handler_sync, get_project_stats_handler, {"project_id": project_id})
# ── Authwrapped SSE app ──────────────────────────────────────────────
def _make_auth_wrapper(sse_app):
"""Wrap the SSE app with JWT validation from ?token= query parameter.
The wrapper intercepts GET requests (SSE connections), validates the
JWT token, and stores it in a context variable so tool handlers can
use it to call the GNS3 REST API. POST messages are passed through
unchanged (they are authenticated by their session association).
"""
async def auth_wrapper(scope, receive, send):
if scope["type"] == "http" and scope["method"] == "GET":
params = parse_qs(scope.get("query_string", b"").decode())
tokens = params.get("token", [])
if not tokens or not await _validate_token(tokens[0]):
response = Response("Missing or invalid token", status_code=401)
await response(scope, receive, send)
return
_jwt_token_var.set(tokens[0])
await sse_app(scope, receive, send)
return auth_wrapper
# ── FastAPI router ────────────────────────────────────────────────────
router = APIRouter(prefix="/mcp", tags=["MCP"])
@router.get("/")
async def mcp_root():
"""MCP service metadata."""
return {
"name": "GNS3 MCP Server",
"version": "1.0.0",
"protocol": "Model Context Protocol",
"transport": "SSE",
"authentication": "?token=<jwt>",
"endpoints": {
"sse": "/v3/mcp/transport/sse?token=<jwt>",
"messages": "/v3/mcp/transport/messages/",
},
}
def register_starlette_routes(app):
"""Mount the authenticated SSE app under /v3/mcp/transport."""
raw_sse_app = mcp.sse_app(mount_path="")
wrapped = _make_auth_wrapper(raw_sse_app)
app.mount("/v3/mcp/transport", wrapped, name="mcp-sse")
log.info("MCP SSE server mounted at /v3/mcp/transport")