{
  "markdown": "# Cisco SD-WAN MCP Server\n\n<!-- mcp-name: io.github.pcDamasceno/cisco-sdwan-mcp -->\n\n[![PyPI](https://img.shields.io/pypi/v/cisco-sdwan-mcp)](https://pypi.org/project/cisco-sdwan-mcp/)\n[![Python](https://img.shields.io/pypi/pyversions/cisco-sdwan-mcp)](https://pypi.org/project/cisco-sdwan-mcp/)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n[![MCP Registry](https://img.shields.io/badge/MCP%20Registry-cisco--sdwan--mcp-6E56CF)](https://registry.modelcontextprotocol.io/v0/servers?search=cisco-sdwan-mcp)\n\n`cisco-sdwan-mcp` is an [MCP](https://modelcontextprotocol.io) server for\n**Cisco Catalyst SD-WAN Manager (vManage)**, built with\n[FastMCP](https://github.com/jlowin/fastmcp).\n\nIt gives an LLM client a working view of your SD-WAN fabric — inventory,\ndevice health, control and data plane state, alarms, path quality, templates\nand policies — so you can ask \"why is the Frankfurt branch down?\" and get an\nanswer backed by real controller data instead of a guess.\n\n**Read-only by default.** The configuration-changing tools are not registered\nunless you explicitly enable them, and even then every call requires a human to\napprove it.\n\n---\n\n## Contents\n\n- [What you get](#what-you-get)\n- [Quickstart](#quickstart)\n- [Configuration](#configuration)\n- [Write protection](#write-protection)\n- [Tool reference](#tool-reference)\n- [Prompts](#prompts)\n- [Resources](#resources)\n- [Connecting an MCP client](#connecting-an-mcp-client)\n- [Transports](#transports-http-vs-stdio)\n- [Authenticating MCP clients](#authenticating-mcp-clients)\n- [Docker](#docker)\n- [Deploying](#deploying)\n- [Adding your own tools](#adding-your-own-tools)\n- [Testing](#testing)\n- [Troubleshooting](#troubleshooting)\n- [Project structure](#project-structure)\n\n---\n\n## What you get\n\n| Area | Tools |\n|---|---|\n| **Inventory** | `list_devices`, `get_device`, `get_fabric_summary`, `list_inventory` |\n| **Device health** | `check_device_health`, `get_system_status`, `get_control_connections`, `get_bfd_sessions`, `get_omp_peers`, `get_interfaces` |\n| **Alarms & events** | `get_alarm_summary`, `list_alarms`, `list_events` |\n| **Path quality** | `find_degraded_tunnels`, `get_tunnel_statistics`, `get_interface_statistics` |\n| **Templates & policy** | `list_device_templates`, `get_device_template`, `list_feature_templates`, `list_policies`, `get_template_input_variables` |\n| **Config groups & profiles** | `list_config_groups`, `get_config_group`, `get_device_config_group`, `get_config_group_device_variables`, `list_feature_profiles`, `get_feature_profile`, `get_parcel`, `get_parcel_schema`, `get_device_running_config` |\n| **Configuration** *(opt-in)* | `attach_device_template`, `activate_vsmart_policy`, `update_parcel`, `update_cli_addon_config`, `deploy_config_group`, `get_task_status` |\n\nPlus four workflow [prompts](#prompts) and three [resources](#resources).\n\nThree design decisions are worth knowing up front, because they shape every\ntool:\n\n- **Responses are projected, not dumped.** A vManage device record carries 60+\n  fields; a 200-device fabric would bury a model's context. Each tool returns\n  the fields that answer the question and accepts `detailed=true` when you want\n  everything.\n- **Counts always accompany results.** Every list reports `count` (what\n  matched) alongside `returned` (what you got), so \"3 devices are down\" is\n  never confused with \"3 devices are down in the first 100 I looked at\".\n- **Failures come back as answers.** A wrong password, an unreachable\n  controller or an unknown hostname returns a readable message — often with the\n  valid options — rather than raising. The model can then correct itself or\n  tell you exactly what to fix.\n\n---\n\n## Quickstart\n\n### Prerequisites\n\n- Python 3.11+\n- [uv](https://docs.astral.sh/uv/) (recommended) **or** pip\n- A reachable Cisco Catalyst SD-WAN Manager (vManage) and an account on it\n\n> **Make a dedicated vManage account.** Give it a read-only role to start.\n> The account's privileges are the real security boundary — see\n> [Write protection](#write-protection).\n\n### Install\n\nFrom PyPI, if you only want to run it:\n\n```bash\nuvx cisco-sdwan-mcp        # no install, run it straight\npip install cisco-sdwan-mcp\n```\n\nFrom a checkout, if you want to change it:\n\n```bash\ngit clone https://github.com/pcDamasceno/cisco-sdwan-mcp.git\ncd cisco-sdwan-mcp\n\n# with uv (recommended)\nuv sync --extra dev\n\n# with pip\npip install -e \".[dev]\"\n```\n\n### Configure\n\n```bash\ncp .env.example .env\n```\n\nThe three settings you must fill in:\n\n```bash\nSDWAN_VMANAGE_URL=https://vmanage.example.com:8443\nSDWAN_USERNAME=automation-readonly\nSDWAN_PASSWORD=...\n```\n\nThe server reads `.env` from the repository root at startup — set\n`SDWAN_ENV_FILE` to load a different file. Variables already present in the\nenvironment (compose `env_file`, Kubernetes secrets) are never overwritten by\nit, and the startup log names the file it used.\n\n### Run\n\n```bash\nuv run python -m cisco_sdwan_mcp.server        # or: python -m cisco_sdwan_mcp.server\n```\n\nThe server starts over **HTTP** on `0.0.0.0:8000`. The MCP endpoint is at\n`http://localhost:8000/mcp`, a health probe at `http://localhost:8000/healthz`,\nand this README at `http://localhost:8000/`.\n\nStartup logs confirm what it will talk to before any client connects:\n\n```\nINFO  cisco_sdwan_mcp.server: vManage controller: vmanage.example.com:8443 (user automation-readonly, TLS verify: True)\nINFO  cisco_sdwan_mcp.server: Write tools: disabled (read-only)\n```\n\n### First call\n\nPoint an MCP client at it (see [Connecting an MCP client](#connecting-an-mcp-client))\nand ask for `get_fabric_summary`. It is one round trip and exercises\nauthentication, TLS and reachability at once:\n\n```json\n{\n  \"total_devices\": 42,\n  \"by_type\": {\"vedge\": 38, \"vsmart\": 2, \"vbond\": 1, \"vmanage\": 1},\n  \"by_reachability\": {\"reachable\": 40, \"unreachable\": 2},\n  \"unreachable_count\": 2,\n  \"unreachable_devices\": [{\"host-name\": \"BR2-EDGE1\", \"system-ip\": \"10.0.0.12\", \"site-id\": \"1002\"}]\n}\n```\n\n---\n\n## Configuration\n\nEverything is environment-driven; `.env.example` is the annotated reference.\n\n### vManage connection\n\n| Variable | Default | Description |\n|---|---|---|\n| `SDWAN_VMANAGE_URL` | — | Controller URL, e.g. `https://vmanage.example.com:8443`. **Required** (or use `SDWAN_VMANAGE_HOST`) |\n| `SDWAN_VMANAGE_HOST` | — | Hostname instead of a full URL |\n| `SDWAN_VMANAGE_PORT` | `443` | Port, when using `SDWAN_VMANAGE_HOST` |\n| `SDWAN_USERNAME` | — | vManage username. **Required** |\n| `SDWAN_PASSWORD` | — | vManage password. **Required** |\n| `SDWAN_VERIFY_SSL` | `true` | TLS certificate verification |\n| `SDWAN_CA_BUNDLE` | — | Path to a CA bundle — the right answer for a private CA |\n| `SDWAN_TIMEOUT` | `60` | Seconds to wait for vManage |\n| `SDWAN_PAGE_SIZE` | `100` | Default cap on records per tool call |\n| `SDWAN_ENABLE_WRITES` | `false` | Register the configuration tools — see below |\n\n### Server\n\n| Variable | Default | Description |\n|---|---|---|\n| `MCP_SERVER_NAME` | `cisco-sdwan-mcp` | Name advertised to MCP clients |\n| `MCP_TRANSPORT` | `http` | `http` or `stdio` |\n| `MCP_HOST` | `0.0.0.0` | Bind address (HTTP only) |\n| `MCP_PORT` | `8000` | Bind port (HTTP only) |\n| `MCP_AUTH` | `none` | How MCP *clients* authenticate to this server |\n| `LOG_LEVEL` | `INFO` | Python log level |\n\n> `SDWAN_USERNAME`/`SDWAN_PASSWORD` authenticate **this server to vManage**.\n> `MCP_AUTH` governs how **clients authenticate to this server**. They are\n> unrelated, and you generally want both.\n\n### TLS\n\nvManage very often presents a self-signed or private-CA certificate. In\ndescending order of preference:\n\n1. Point `SDWAN_CA_BUNDLE` at the controller's CA — verification stays on.\n2. Add the CA to `certificates/`, which the Docker build installs into the\n   container trust store automatically.\n3. Only as a last resort, on a lab you control, set `SDWAN_VERIFY_SSL=false`.\n   The server logs a warning naming the host each time it does this, because\n   it means anything on the path can read the credentials.\n\n---\n\n## Write protection\n\nThe tools that change configuration are gated twice.\n\n**Gate 1 — registration.** With `SDWAN_ENABLE_WRITES` unset or `false`, the\nmodule holding them is never imported. They do not appear in the tool list, so\na model cannot call them by mistake, misinterpretation or prompt injection.\nThe server is read-only by construction, not by policy.\n\n**Gate 2 — confirmation.** With writes enabled, each call still asks the user\nthrough MCP elicitation, naming the template or policy and the devices\naffected, before anything reaches vManage. Clients that do not implement\nelicitation cannot silently proceed — the call is refused unless the caller\npasses `confirm=true`, which puts the decision in a human's hands either way.\n\n```bash\nSDWAN_ENABLE_WRITES=true uv run python -m cisco_sdwan_mcp.server\n```\n\n```\nWARNING cisco_sdwan_mcp.tools: SDWAN_ENABLE_WRITES=true — configuration-changing tools are registered.\n                   Each one still requires explicit user confirmation before it runs.\n```\n\n> **The vManage account is the real boundary.** `SDWAN_ENABLE_WRITES` controls\n> which tools exist in *this* server; it does nothing about what the account\n> can do through any other path. If a change must be impossible, use a\n> read-only vManage role — do not rely on this flag alone.\n\nvManage applies configuration asynchronously: a write returns a `task_id`,\nmeaning *accepted*, not *applied*. Poll `get_task_status(task_id)` until it\nreports done.\n\nThe intended flow for a template push, with a review step in the middle:\n\n```\nlist_device_templates          → find the template\nget_device_template            → see what it configures and who has it\nget_template_input_variables   → the exact per-device values (read-only preview)\ncheck_device_health            → never push to an already-broken device\nattach_device_template         → asks for approval, returns a task_id\nget_task_status                → confirm it actually landed\n```\n\nAnd for a config-group fabric, where edits and deploys are separate steps:\n\n```\nget_device_config_group          → which group owns the device\nget_feature_profile / get_parcel → find the parcel and its current values\nget_parcel_schema                → which parcel a setting lives in, and its shape\nupdate_parcel                    → asks for approval; changes intent only\nget_device_running_config        → snapshot before the push\ndeploy_config_group              → asks for approval, returns a task_id\nget_task_status                  → confirm it landed, then re-diff the running config\n```\n\n---\n\n## Tool reference\n\nEvery tool takes `limit` (cap on records) and most take `detailed` (return all\nvManage fields instead of the summary set).\n\n### Inventory\n\n| Tool | What it answers |\n|---|---|\n| `get_fabric_summary()` | Device counts by type, reachability and version, plus every unreachable device. **Start here** for open questions. |\n| `list_devices(device_type, reachability, site_id)` | Devices vManage is currently talking to. |\n| `get_device(device)` | One device's full record. Accepts hostname, system IP or chassis number. |\n| `list_inventory(category, unattached_only)` | Everything *provisioned*, including devices that never onboarded, have invalid certificates or carry no template. |\n\n### Device health\n\n| Tool | What it answers |\n|---|---|\n| `check_device_health(device)` | **Triage in one call** — system status, control connections and BFD, with a `problems` list naming what is wrong. |\n| `get_system_status(device)` | Uptime, CPU, memory, disk, last reboot reason. |\n| `get_control_connections(device)` | Connections to vSmart/vBond/vManage. Check first when a device will not come up. |\n| `get_bfd_sessions(device, state)` | Data-plane tunnels to other edges. Check when sites reach controllers but not each other. |\n| `get_omp_peers(device)` | OMP peering — control up but OMP down means no overlay routes. |\n| `get_interfaces(device, vpn_id, interface_name)` | Interface status, addressing and error counters. |\n\nThese poll the device through vManage, so they reflect live state but cost a\nround trip to the edge. Prefer `check_device_health` over three separate calls.\n\n### Alarms and events\n\n| Tool | What it answers |\n|---|---|\n| `get_alarm_summary(hours)` | Counts by severity and component, top rules, most affected devices. Cheap — call before listing. |\n| `list_alarms(hours, severity, active_only)` | The alarms themselves. |\n| `list_events(hours, severity, component)` | Raw event stream — noisier, but shows flaps and transitions that never became alarms. |\n\n### Path quality\n\n| Tool | What it answers |\n|---|---|\n| `find_degraded_tunnels(hours, max_loss_percent, max_latency_ms, max_jitter_ms)` | Tunnels breaching thresholds, worst first, each saying which threshold it broke. |\n| `get_tunnel_statistics(device, hours)` | Raw per-tunnel loss/latency/jitter/vQoE. |\n| `get_interface_statistics(device, hours, interface_name)` | Historical throughput and error counters. |\n\nThese read vManage's statistics database — fast, but only as fresh as the last\ncollection cycle (30 minutes on most deployments). For live state, use the\ndevice health tools.\n\n### Templates and policy\n\n| Tool | What it answers |\n|---|---|\n| `list_device_templates(device_type, attached_only)` | Templates and their attachment counts. |\n| `get_device_template(template_id)` | One template's definition plus attached devices. |\n| `list_feature_templates(template_type)` | The building blocks. |\n| `list_policies(policy_scope)` | Centralized (vSmart) or localized policies, and which is active. |\n| `get_template_input_variables(template_id, device_ids)` | Read-only preview of the values an attachment would push. |\n\n### Config groups and feature profiles\n\nThe UX-2.0 configuration model: a *config group* bundles *feature profiles*\n(system, transport, service, cli, policy-object), each profile holds *parcels*\n— the actual configuration payloads — and a device belongs to at most one\ngroup. On a fabric managed this way the template endpoints report nothing\nuseful; these tools are the equivalent surface. vManage reports membership on\nthe WAN-edge inventory as `\"managed-by\": \"Config-Group <name>\"`, which is also\nwhat `get_device` and `list_inventory` surface.\n\n| Tool | What it answers |\n|---|---|\n| `list_config_groups()` | Groups, their profiles, device counts and up-to-date state. |\n| `get_config_group(group)` | One group (by name or ID) with its member devices and which are awaiting a deploy. |\n| `get_device_config_group(device)` | Which group manages a device — and therefore whether templates must keep away. |\n| `get_config_group_device_variables(group, device_ids)` | The per-device values a deploy would resolve; the config-group twin of `get_template_input_variables`. |\n| `list_feature_profiles(profile_type)` | Profiles of one type, or all five. |\n| `get_feature_profile(profile_type, profile_id)` | The parcel tree, each node with a ready-to-use `parcelPath`. |\n| `get_parcel(profile_type, profile_id, parcel_path, parcel_id)` | One parcel's payload — the actual knobs. |\n| `get_parcel_schema(profile_type, parcel_path)` | Every field a parcel type can hold; consult before editing. |\n| `get_device_running_config(device)` | The device's current configuration, for pre/post-deploy diffs. |\n\nWith writes enabled, the config-group counterparts to a template push are\n`update_parcel` (edit structured knobs read-modify-write), `update_cli_addon_config`\n(append/replace raw IOS-XE lines in a CLI add-on profile, returns a diff) and\n`deploy_config_group` (push the group to named member devices — the only step\nthat touches production). Edits mark members `configGroupUpToDate: false`\nuntil deployed. `attach_device_template` refuses devices a config group\nmanages, so the two configuration models cannot fight over a device.\n\n---\n\n## Prompts\n\nReusable workflows that encode the order an engineer actually works in —\ncontrol plane before data plane, evidence before conclusions.\n\n| Prompt | Use it for |\n|---|---|\n| `troubleshoot_device(device, symptom)` | Structured device triage, stopping at the first real cause |\n| `fabric_health_report(hours)` | A whole-fabric report: devices, alarms, path quality, recommendations |\n| `analyse_path_quality(hours, site)` | Tunnel performance, clustered by color / site / device to point at the cause |\n| `review_template_change(template_id)` | Pre-change review with an explicit go/no-go — recommends only, never attaches |\n\n---\n\n## Resources\n\n| URI | Contents |\n|---|---|\n| `sdwan://config` | Connection settings in effect — controller, user, TLS mode, whether writes are on. Never includes the password. |\n| `sdwan://devices` | Current fabric inventory with per-device status |\n| `sdwan://device/{identifier}` | One device's full record, by hostname or system IP |\n\n---\n\n## Connecting an MCP client\n\n### Claude Desktop (`claude_desktop_config.json`)\n\n```json\n{\n  \"mcpServers\": {\n    \"cisco-sdwan\": {\n      \"url\": \"http://localhost:8000/mcp\"\n    }\n  }\n}\n```\n\n### VS Code (GitHub Copilot) — `.vscode/mcp.json`\n\n```json\n{\n  \"servers\": {\n    \"cisco-sdwan\": {\n      \"type\": \"http\",\n      \"url\": \"http://localhost:8000/mcp\"\n    }\n  }\n}\n```\n\n### With token auth enabled\n\n```json\n{\n  \"mcpServers\": {\n    \"cisco-sdwan\": {\n      \"url\": \"http://localhost:8000/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer dev-token\" }\n    }\n  }\n}\n```\n\nWith an OAuth mode (`github`, `google`, `oauth-proxy`, …) no header is needed —\nMCP clients discover the flow and open the login screen themselves.\n\n### stdio (local subprocess)\n\nFrom PyPI — nothing to clone, `uvx` fetches the package on first run:\n\n```json\n{\n  \"mcpServers\": {\n    \"cisco-sdwan\": {\n      \"command\": \"uvx\",\n      \"args\": [\"cisco-sdwan-mcp\"],\n      \"env\": {\n        \"MCP_TRANSPORT\": \"stdio\",\n        \"SDWAN_VMANAGE_URL\": \"https://vmanage.example.com:8443\",\n        \"SDWAN_USERNAME\": \"automation-readonly\",\n        \"SDWAN_PASSWORD\": \"...\"\n      }\n    }\n  }\n}\n```\n\nFrom a checkout:\n\n```json\n{\n  \"mcpServers\": {\n    \"cisco-sdwan\": {\n      \"command\": \"uv\",\n      \"args\": [\"run\", \"python\", \"-m\", \"cisco_sdwan_mcp.server\"],\n      \"cwd\": \"/absolute/path/to/cisco-sdwan-mcp\",\n      \"env\": {\n        \"MCP_TRANSPORT\": \"stdio\",\n        \"SDWAN_VMANAGE_URL\": \"https://vmanage.example.com:8443\",\n        \"SDWAN_USERNAME\": \"automation-readonly\",\n        \"SDWAN_PASSWORD\": \"...\"\n      }\n    }\n  }\n}\n```\n\n---\n\n## Transports: HTTP vs stdio\n\n- **HTTP (default)** — the deployment transport. Serves many clients\n  concurrently, works behind load balancers, and is the only transport where\n  `MCP_AUTH` applies. Everything in `deploy/` assumes it.\n- **stdio** — for local single-user use where a desktop client spawns the\n  server as a subprocess. No network listener, so `MCP_HOST`/`MCP_PORT` and\n  `MCP_AUTH` do not apply; the process is secured by your OS user.\n\n```bash\nMCP_TRANSPORT=stdio uv run python -m cisco_sdwan_mcp.server\n```\n\nDeploying anywhere or serving multiple users → HTTP. One client on your own\nmachine → either works.\n\n---\n\n## Authenticating MCP clients\n\nAuthentication of clients **to this server** is off by default and selected at\nstartup with `MCP_AUTH`. The factory lives in `cisco_sdwan_mcp/auth.py`; all modes are\nbacked by FastMCP's built-in providers.\n\n| `MCP_AUTH` | Use case |\n|---|---|\n| `none` (default) | Local development, or network-level protection (IAM, VPN, mTLS) |\n| `static` | Fixed bearer tokens — quick tests only, never production |\n| `jwt` | You already have an IdP issuing JWTs (Keycloak, Okta, Entra ID, Cognito…) |\n| `introspection` | Your IdP issues opaque tokens (RFC 7662) |\n| `oauth-proxy` | Full OAuth 2.1 login flow via any OAuth provider |\n| `github`, `google`, `azure`, `auth0`, `workos` | Full login flow via a hosted identity provider, preconfigured |\n\n```bash\n# Development tokens (never in production — tokens sit in plain env vars)\nMCP_AUTH=static MCP_AUTH_STATIC_TOKENS=dev-token uv run python -m cisco_sdwan_mcp.server\n\n# JWT via your IdP's JWKS endpoint\nMCP_AUTH=jwt\nMCP_AUTH_JWKS_URI=https://idp.example.com/realms/main/protocol/openid-connect/certs\nMCP_AUTH_ISSUER=https://idp.example.com/realms/main\nMCP_AUTH_AUDIENCE=cisco-sdwan-mcp\n```\n\nThe full variable reference for every mode is in [`.env.example`](.env.example).\n\nNotes:\n\n- Auth applies to the **HTTP transport only**.\n- `/healthz` and `/` stay public — probes and humans don't carry tokens; the\n  MCP endpoint returns `401` without a valid token.\n- OAuth flows require HTTPS on the public URL in production.\n- To add your own scheme, write a builder in `cisco_sdwan_mcp/auth.py` and register it in\n  `_BUILDERS` ([provider docs](https://gofastmcp.com/servers/auth/authentication)).\n\n> A server exposing your WAN topology should not run `MCP_AUTH=none` on a\n> reachable network. See [`deploy/README.md`](deploy/README.md).\n\n---\n\n## Docker\n\n```bash\ncp .env.example .env      # fill in vManage URL and credentials\ndocker compose up -d --build\n```\n\nOr manually:\n\n```bash\ndocker build -t cisco-sdwan-mcp .\ndocker run -p 8000:8000 \\\n  -e SDWAN_VMANAGE_URL=https://vmanage.example.com:8443 \\\n  -e SDWAN_USERNAME=automation-readonly \\\n  -e SDWAN_PASSWORD=... \\\n  cisco-sdwan-mcp\n```\n\nHelper scripts build the image, replace any container of the same name, and\nstart the server at `http://localhost:8000/mcp`, passing your `.env` through:\n\n```bash\nbash scripts/run_docker.sh          # Linux/macOS\n.\\scripts\\run_docker.ps1            # Windows PowerShell\n```\n\nThe image includes a `HEALTHCHECK` against `/healthz`, so `docker ps` shows\ncontainer health out of the box.\n\n### Corporate CA certificates\n\nDrop any `.crt`/`.pem` root CA files into `certificates/`. The build adds them\nto the container trust store and runs `update-ca-certificates` automatically —\nwhich covers **both** a TLS-intercepting proxy and a vManage certificate signed\nby your internal CA. Leave the directory empty if you don't need it.\n\nFor a proxy, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` are predefined Docker build\nargs and need no Dockerfile edits:\n\n```bash\ndocker build --build-arg HTTPS_PROXY=http://proxy.example.com:8080 -t cisco-sdwan-mcp .\ndocker run -p 8000:8000 -e HTTPS_PROXY=http://proxy.example.com:8080 cisco-sdwan-mcp\n```\n\n---\n\n## Deploying\n\nThe container is a plain HTTP server on port 8000 with a `/healthz` probe, so\nit runs anywhere. `deploy/` ships raw Kubernetes manifests, a Helm chart, a\nStyrmin driver and a Cloud Run service definition — see\n[`deploy/README.md`](deploy/README.md) for full walkthroughs, including the\nSD-WAN-specific parts: reaching a management-network controller from the cloud,\nprivate-CA handling, and vManage's per-account session limits.\n\n```bash\nkubectl apply -k deploy/kubernetes\n```\n\n```bash\nhelm install sdwan-mcp oci://ghcr.io/pcdamasceno/charts/cisco-sdwan-mcp \\\n  --set sdwan.vmanageUrl=https://vmanage.example.com:8443\n```\n\n```bash\ngcloud run services replace deploy/cloud-run-service.yaml --region europe-west3\n```\n\nThis repository is also a [Styrmin](https://github.com/opsmill/styrmin)\nApplication Driver — `driver.styrmin.yml` and `values.j2.yml` at the root are\nwhat Styrmin reads when it clones it. See\n[`deploy/styrmin.md`](deploy/styrmin.md).\n\n`/healthz` deliberately does **not** check vManage. A brief controller outage\nshould not restart pods — tools report the problem per call, and the server\nrecovers on its own.\n\n---\n\n## Adding your own tools\n\nCapabilities live in three packages, one module per concern. Each package's\n`__init__.py` imports its modules so the decorators run — add a module, add one\nimport line.\n\n```python\n# cisco_sdwan_mcp/tools/my_tools.py\nfrom cisco_sdwan_mcp.sdwan.client import get_client\nfrom cisco_sdwan_mcp.sdwan.formatting import envelope, project\nfrom cisco_sdwan_mcp.tools._helpers import resolve_device_id, sdwan_tool\n\n\n@sdwan_tool\nasync def get_dhcp_leases(device: str, limit: int = 100) -> dict:\n    \"\"\"Show DHCP leases the device is serving.\n\n    Args:\n        device: Hostname, system IP or chassis number.\n        limit: Maximum leases to return.\n    \"\"\"\n    system_ip = await resolve_device_id(device)\n    client = await get_client()\n    records = await client.get_data(\n        \"/dataservice/device/dhcp/server\", {\"deviceId\": system_ip}\n    )\n    fields = (\"ifname\", \"address\", \"client-id\", \"state\", \"expires\")\n    return envelope(project(records, fields), limit=limit, device=device)\n```\n\nThen add `my_tools` to the import list in `cisco_sdwan_mcp/tools/__init__.py`.\n\nUse `@sdwan_tool` rather than `@mcp.tool` — it registers the tool *and*\nconverts SD-WAN failures into a readable `{\"error\", \"message\"}` result. Reach\nfor the shared helpers rather than reimplementing them:\n\n| Helper | Purpose |\n|---|---|\n| `resolve_device_id(device)` | Hostname / system IP / chassis → the system IP vManage's real-time endpoints need |\n| `client.get_data(path, params)` | GET and unwrap vManage's `{\"data\": [...]}` envelope |\n| `project(records, fields)` | Trim wide records to what matters |\n| `envelope(records, limit=...)` | Add `count`/`returned`/truncation notes |\n| `build_query(hours=..., rules=...)` | Build the JSON `query` param alarms/events/statistics need |\n| `count_by(records, field)` | Tally a field into a summary |\n\nThe docstring is what the model reads to decide whether to call your tool —\nsay what question it answers, not just which endpoint it hits. Keep write\noperations in `config_tools.py` so the registration gate keeps covering them.\n\n---\n\n## Testing\n\n```bash\nuv run pytest      # or: pytest\n```\n\nThe suite runs against a fake vManage (`httpx.MockTransport`) rather than a\nlive controller, so it covers the things that actually break in the field:\n\n- the login handshake, including vManage answering a **failed** login with\n  HTTP 200 and an HTML body\n- CSRF token handling, and controllers older than 19.2 that have no token\n  endpoint\n- session expiry mid-session → one transparent re-login and retry\n- error translation: unreachable host, timeout, 403, non-JSON response\n- every read tool's filtering, projection and truncation\n- the write gate, verified in a fresh interpreter: the configuration tools are\n  absent without `SDWAN_ENABLE_WRITES=true` and present with it\n- write confirmation: declining, or a client that cannot prompt, must not\n  produce an HTTP call to vManage\n\n`tests/conftest.py` holds the fake controller; use it as the pattern for your\nown tools.\n\n---\n\n## Troubleshooting\n\n| Symptom | Cause and fix |\n|---|---|\n| `ConfigurationError: No controller configured` | `SDWAN_VMANAGE_URL` (or `SDWAN_VMANAGE_HOST`) is unset. |\n| `AuthenticationError: rejected the credentials` | Wrong username/password, or the account is locked. vManage returns HTTP 200 with the login page for a bad password — the client detects that and reports it as an auth failure. |\n| `AuthenticationError: may lack the required role` | Authentication worked but the account lacks privileges for that endpoint. Template and policy endpoints need more than a bare read-only role. |\n| `AuthenticationError: issued no JSESSIONID cookie` | The URL points at a proxy that strips cookies, not at vManage itself. |\n| `APIError: cannot reach <host>` | DNS, routing, firewall or the wrong port. vManage commonly listens on 8443, not 443. |\n| `APIError: timed out after 60s` | Real-time endpoints poll the device itself. Raise `SDWAN_TIMEOUT`, or scope the query to one device. |\n| TLS / certificate verify failed | Private CA. Set `SDWAN_CA_BUNDLE` or add the CA to `certificates/`. `SDWAN_VERIFY_SSL=false` is a lab-only last resort. |\n| `APIError: response was not valid JSON` | The endpoint doesn't exist on this vManage version — API paths vary across releases. |\n| A write tool \"doesn't exist\" | Expected: `SDWAN_ENABLE_WRITES` is not `true`. |\n| A write returns `applied: false` with `confirm=True` guidance | The client doesn't support MCP elicitation, so it cannot ask you to approve. |\n| Empty results everywhere, no error | The account may be scoped to a tenant or device group with no devices. Check `sdwan://config` and try `list_inventory`. |\n\nSet `LOG_LEVEL=DEBUG` for more detail. Note that vManage error bodies can be\nverbose — check what yours returns before enabling debug logs in a shared\nenvironment.\n\n---\n\n## Project structure\n\n```\n.\n├── cisco_sdwan_mcp/\n│   ├── mcp.py                    ← Shared FastMCP instance, auth wiring, /healthz\n│   ├── auth.py                   ← MCP client authentication factory (MCP_AUTH)\n│   ├── server.py                 ← Entry point: transport selection, startup logging\n│   ├── sdwan/                    ← vManage integration layer (no MCP knowledge)\n│   │   ├── config.py             ← SDWAN_* settings\n│   │   ├── client.py             ← Async client: login, CSRF, session recovery\n│   │   ├── formatting.py         ← Projection, envelopes, vManage query builder\n│   │   └── errors.py             ← Exception hierarchy\n│   ├── tools/\n│   │   ├── _helpers.py           ← @sdwan_tool, device resolution\n│   │   ├── inventory_tools.py    ← Devices and fabric summary\n│   │   ├── monitoring_tools.py   ← Control plane, BFD, OMP, interfaces, health\n│   │   ├── alarm_tools.py        ← Alarms and events\n│   │   ├── statistics_tools.py   ← Tunnel and interface statistics\n│   │   ├── template_tools.py     ← Templates and policies (read-only)\n│   │   ├── config_group_tools.py ← Config groups, feature profiles, parcels (read-only)\n│   │   ├── config_tools.py       ← Template/policy writes — imported only when enabled\n│   │   └── config_group_write_tools.py ← Parcel/CLI/deploy writes — same gate\n│   ├── resources/sdwan_resources.py\n│   └── prompts/sdwan_prompts.py\n├── tests/\n│   ├── conftest.py               ← Fake vManage (httpx.MockTransport)\n│   ├── sample_data.py            ← Representative vManage payloads\n│   ├── test_client.py            ← Login, session recovery, error translation\n│   ├── test_formatting.py        ← Projection, envelopes, query building\n│   ├── test_tools.py             ← Read tools via the in-memory MCP client\n│   ├── test_config_tools.py      ← The write confirmation gate\n│   ├── test_config_group_tools.py← Config-group reads, writes and the template guardrail\n│   ├── test_server.py            ← Registration, the write gate, public routes\n│   └── test_auth.py              ← MCP client auth factory\n├── deploy/                       ← Kubernetes + Cloud Run\n├── scripts/                      ← Docker helper scripts (bash / PowerShell)\n├── certificates/                 ← Drop-in CA certificates for Docker builds\n├── .env.example                  ← Annotated reference of every variable\n├── docker-compose.yml\n├── Dockerfile\n└── pyproject.toml\n```\n\n---\n\n## Resources\n\n- [Cisco Catalyst SD-WAN Manager API docs](https://developer.cisco.com/docs/sdwan/)\n- [FastMCP documentation](https://gofastmcp.com)\n- [Model Context Protocol specification](https://modelcontextprotocol.io)\n\n---\n\n## License\n\n[MIT](LICENSE)\n",
  "bytes": 30870,
  "sha": "8a05d7a7cafecd7a177bb5c3fe85328fbb9e019bdd835d28832b628c3232f85b",
  "repo_slug": "pcdamasceno/cisco-sdwan-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_pcdamasceno_cisco_sdwan_mcp_96df8fa7/readme"
}