How to Connect Claude AI to Cisco ACI Using MCP: A Step-by-Step Guide

How to Connect Claude AI to Cisco ACI Using MCP: A Step-by-Step Guide

Most AI solutions can explain Cisco ACI. You can ask any of them how EPGs, Bridge Domains, and Contracts relate to each other, and you will get a solid answer. Ask one to actually create a tenant on your fabric, verify it landed correctly, and tell you if anything’s structurally broken, but in this case it can’t, because it has no way to reach your APIC controller at all. The gap is what MCP(Model Context Protocol) closes. This is the full, reproducible walkthrough of how I built a strong working MCP server for Cisco ACI, and from the first API call to real bug I found and fixed it along the way, using nothing but Python, the APIC Rest API, and Claude.What you will needPython 3.10 or later (MCP's Python SDK requires it — 3.9 will fail with a cryptic "no matching distribution" error if you try)Access to an APIC controller — I used Cisco's free DevNet "ACI Simulator Always-On" sandbox (sandboxapicdc.cisco.com), no real hardware requiredClaude Code, Claude Desktop, or any other MCP-compatible clientpip install requests mcpMCP in two minutesMCP defines three pieces: a server that exposes tools(plain functions), a client(Claude, in this case) that discovers and calls those tolls, and a transport, typically stdio, where the client spawns your scripts as a subprocess and exchanges JSON-RPC messages over its stdin/stdout.The entire trick of the Python SDK’s FastMCP class is that it builds the tool schema Claude needs, such as name, parameters, description which derives from a normal Python function’s signature and docstring. You never write JSON-RPC by hand. Step 1 — The session and authentication layerYou need to create a file named aci_mcp_server.py. Then need to start your code as below:APIC uses token based authentication: one login POST returns a session cookie, which gets reused for every subsequent call until it expires. import os import requests import urllib3 from mcp.server.fastmcp import FastMCP urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) APIC_URL = os.environ.get("APIC_URL", "https://sandboxapicdc.cisco.com") APIC_USER = os.environ.get("APIC_USER", "admin") APIC_PASSWORD = os.environ.get("APIC_PASSWORD") mcp = FastMCP("aci-lab") _session = requests.Session() _session.verify = False def _login(): resp = _session.post( f"{APIC_URL}/api/aaaLogin.json", json={"aaaUser": {"attributes": {"name": APIC_USER, "pwd": APIC_PASSWORD}}}, timeout=10, ) resp.raise_for_status() return resp.json() def _get(path: str, params: dict = None): if "APIC-cookie" not in _session.cookies: _login() resp = _session.get(f"{APIC_URL}{path}", params=params, timeout=15) if resp.status_code == 403: _login() # session expired — retry once resp = _session.get(f"{APIC_URL}{path}", params=params, timeout=15) resp.raise_for_status() return resp.json() Notice _login() never runs at import time - it fires lazily, on whichever tool call happens first. That matters later. Step 2 — Your first read-only tool @mcp.tool() def list_tenants() -> str: """List every tenant currently configured on the ACI fabric.""" data = _get("/api/class/fvTenant.json") names = [obj["fvTenant"]["attributes"]["name"] for obj in data.get("imdata", [])] return ", ".join(sorted(names)) if names else "No tenants found." if __name__ == "__main__": mcp.run() That’s a complete, working MCP tool. Register it with your client (for Claude Code: claude mcp add aci-lab -- python3 aci_mcp_server.py ), start a fresh session, and ask: “list all tenants.” On my first real run against the sandbox, this returned all 13 tenants on the fabric, exactly matching the APIC dashboard.List out all current tenantStep 3 — Writes need a different contract with the modelA read tool, returning wrong data is annoying. A write tool executing the wrong thing is a different category of problem. Every state-changing tool in this server follows the same two-call pattern: Default to a dry run, require an explicit confirm=True to actually apply.@mcp.tool() def create_tenant(name: str, description: str = "", confirm: bool = False) -> str: """Create a new tenant on the fabric. State-changing — requires confirm=True to actually apply; otherwise it's a dry run. """ if not confirm: return f"Dry run only — would create tenant '{name}'. Call again with confirm=True to apply." if "APIC-cookie" not in _session.cookies: _login() payload = {"fvTenant": {"attributes": {"name": name, "descr": description}, "children": []}} resp = _session.post(f"{APIC_URL}/api/mo/uni/tn-{name}.json", json=payload, timeout=15) resp.raise_for_status() return f"Created tenant '{name}'." The mechanism is simple, but the effect is real, ask Claude to create a tenant, and it reads that first sentence of the docstring, describes what it’s about to do, and waits for you to actually say yes, before the first byte reaches APIC.Tenant CreationSome guardrails need to be stronger than “ask first ” delete_tenant includes a check that can’t be overridden by by confirm=True at all:@mcp.tool() def delete_tenant(name: str, confirm: bool = False) -> str: """Refuses to touch 'common', 'infra', 'mgmt' regardless of confirm.""" if name in ("common", "infra", "mgmt"): return f"Refused: '{name}' is a built-in system tenant and cannot be deleted via this tool." if not confirm: return f"Dry run only — would delete tenant '{name}'. Call again with confirm=True to apply." # ... proceeds only if neither check stopped it I tested it directly: asking Claude to delete Common Tenant, produced an immediate, unconditional refusal, not even a dry run was offered, because the check runs before the confirm logic is ever consulted. That distinction which is optional confirmation vs structural refusal, is a real design decision, not a detail. Not every guardrail deserves the same strength. Trying to delete Common tenantStep 4 — Building the object hierarchyACI's policy model is a nested tree: Tenant → Application Profile → EPG → Bridge Domain → VRF. Each layer got its own tool(create_application_profile, create_epg, create_bridge_domain, create_vrf ) , all following the same dry-run/confirm pattern as above. I won’t repeat all four here, but they are mechanically identical to create_tenant, which construct the right APIC payload, gate it behind confirm, POST to the right /api/mo/... path.The bug I didn't expect to findHere’s where this stopped being a straightforward build.I asked Claude to wire a Bridge Domain to a CRF in a brand-new tenant. The APIIC call returned success. Everything looked fine, until I asked for independent verification instead of trusting that response, and checked the fabric’s own fault list. A fault was sitting there, naming the exact object. The VRF and the Bridge Domain pointed at didn’t actually exist. APIC had accepted a POST referencing something non-existing, and rendered success anyway, a classic silent-failure API pattern, which is syntactically valid request, but semantically meaningless result, no error anywhere in the response. This fix wasn’t a try/except around the symptom. It was a pre-flight check before the object gets created at all:def _vrf_exists(tenant: str, vrf_name: str): """Check whether a VRF exists in the given tenant, or in 'common'.""" for scope in (tenant, "common"): try: data = _get(f"/api/mo/uni/tn-{scope}/ctx-{vrf_name}.json") except Exception: continue if data.get("imdata"): return True, scope return False, None @mcp.tool() def create_bridge_domain(tenant: str, bd_name: str, vrf: str = "default", confirm: bool = False) -> str: if not confirm: return f"Dry run only — would create Bridge Domain '{bd_name}' using VRF '{vrf}'." if "APIC-cookie" not in _session.cookies: _login() exists, scope = _vrf_exists(tenant, vrf) if not exists: return (f"Refused: VRF '{vrf}' does not exist in tenant '{tenant}' or in 'common'. " f"APIC would silently accept a BD pointing at a nonexistent VRF and report " f"success, but the relation never actually forms. Create the VRF first.") # ... proceeds only if the VRF genuinely exists The lesson generalizes past this one bug, an AI agent that trusts every “success” response is only as reliable as the API underneath it. One that is built to verify , cross checking independent signals like a fault list, refusing dangling references before they are created , catches exactly the kind of failure that normally survives until someone’s paging you about it three weeks later.App profile and BD CreationBD association to EPGFinishing the Bridge Domain: Subnet AssignmentA Bridge Domain with a valid VRF is not completed unless it has a valid gateway. So we need to assign a subnet@mcp.tool() def add_subnet(tenant: str, bd_name: str, gateway_cidr: str, scope: str = "private", confirm: bool = False) -> str: """Add a subnet (gateway IP) to an existing Bridge Domain. Without this, a Bridge Domain has no Layer 3 presence — endpoints in it can't route anywhere, even if VRF/EPG/contract are all otherwise correct.""" ... Assigned SubnetStep 5 — Policy enforcement with contracts Structure alone does not restrict traffic. Two EPGs can exist, they can be perfectly wired to their Bridge Domains, and still they have no policy governing whether they can talk to each other. @mcp.tool() def create_contract(tenant: str, contract_name: str, port: str = "", protocol: str = "tcp", confirm: bool = False) -> str: """Creates a contract with one filter. If port is given, permits only that port/protocol; otherwise permits any IP traffic.""" # constructs a vzFilter + vzEntry, then a vzBrCP contract referencing it ... @mcp.tool() def apply_contract(tenant: str, provider_ap: str, provider_epg: str, consumer_ap: str, consumer_epg: str, contract_name: str, confirm: bool = False) -> str: """Applies an existing contract between a provider and consumer EPG. Refuses if the contract doesn't already exist — same pre-flight pattern as the VRF fix above.""" ... Direction matters here in a way that’s easy to get backwards, the provider is whichever EPG is offering the service such as the database, listening on its port, and the consumer is whichever EPG initiates the connection like an Application tier, reaching out to that database.Step 6 — Fabric access policy: VLAN pools, domains, and AAEPs Everything through step 5 builds a complete and correct policy tree. Two layers are still missing, and without those nothing can control which VLANs are even valid on a given port, and nothing ties a physical port to any of it. Here are three more tools@mcp.tool() def create_vlan_pool(pool_name: str, vlan_from: int, vlan_to: int, confirm: bool = False) -> str: """Create a static-allocation VLAN pool — the fabric-wide range of VLAN IDs available to assign to domains.""" ... @mcp.tool() def create_physical_domain(domain_name: str, vlan_pool_name: str, confirm: bool = False) -> str: """Create a physical domain and tie it to an existing VLAN pool.""" ... @mcp.tool() def create_aaep(aaep_name: str, domain_name: str, confirm: bool = False) -> str: """Create an AAEP and tie it to an existing physical domain.""" ... create_vlan_pool defines a range of valid VLAN IDs. create_physical_domain claims that range. create_aaep creates the profile that references the domain. associate_epg_domain (not shown — same shape again) ties an EPG to the domain, so the fabric knows it's allowed to use VLANs from that pool. Step 7 — Binding a real port, and the gap that turned up Nothing ties it to a physical port and that requires a completely separate chain, and those are Interface policy group, a Leaf Interface Profile with a port selector, and a Leaf/Switch Profle with a node selector.@mcp.tool() def bind_port_to_aaep(pod: int, node: int, interface: str, aaep_name: str, policy_group_name: str = "", confirm: bool = False) -> str: """Ties a physical port down to an AAEP — creates an Interface Policy Group, a Leaf Interface Profile, and a Leaf/Switch Profile in one call.""" ... # Interface Policy Group -> AAEP — reuse if it already exists, only # create it if this is genuinely the first time this name has been used. pg_existing = _get(f"/api/mo/uni/infra/funcprof/accportgrp-{pg_name}.json").get("imdata") if pg_existing: pg_status = f"reused existing policy group '{pg_name}'" else: # ...create it fresh pg_status = f"created new policy group '{pg_name}'" That reuse check if you are using the existing Interface Policy Group or it is required to create a new oneStep 8 — A tool that audits, not just lists The last tool was showing the entire tenants subtree in one call and flags anything incomplete, and the same class of problem was the VRF bug, which was made into a permanent check instead of a one fix.@mcp.tool() def get_tenant_detail(tenant: str) -> str: """Full-subtree audit: every AP, EPG (and its BD), BD (and its VRF), VRF, and contract. Flags an EPG with no BD, or a BD with no VRF, instead of just listing objects.""" data = _get(f"/api/mo/uni/tn-{tenant}.json", params={"rsp-subtree": "full"}) # recursively walks the nested response, building a structured summary # with "NO VRF (incomplete)" / "NO BD (incomplete)" flags where relevant ... Putting it together: a real test case With all of the above, here is the full sequence I tired to ran against a completely fresh tenant and the flow was like below:Tenant → VRF → two Bridge Domains → Application Profile → an App-tier EPG and a DB-tier EPG → a contract permitting only TCP/5432 between them, DB as provider, App as consumer, and added with VLAN pool, domain, AAEP, and port bindings from the section above, all in one build.Two Checkpoints with get_tenant_detail before and after the contract, and it returned me a clean before/after, and a zero incomplete flags at either point, and the contract was correctly listed after step two. There was a final cross-check placed against the fault list that confirmed nothing new had broken. That is the real proof which is not confirmed by saying ‘success’ only, but also there was a separate check that confirmed it actually worked. A final cross-check confirmed the AAEP resolved correctly to the Interface policy group and tied to a correct port.What this adds up toTwenty-one tools now cover fabric visibility (tenants, health, faults, a port level EPG/AAEP loopup), the full object hierarchy, and policy enforcement through contracts. The interesting fact is that all tools are being used through plain conversation.List out Faults for the TenantsEPG and AAEP validationThe bigger takeaway is not the tool count. We can say that MCP is just a wire protocol, and it does not know or even care what is behind a tool, and it does not enforce safety on its own. The intelligence is entirely in what you choose to expose, what is needed for a dry run first, and what should be never overridable, and no matter how confidently it is asked for. It can build that thoughtfully, and “Ai can explain your network” truns into “AI can safely operate it”The full source for this server with all 21 tools(or it can be updated gradually), the READMEf file, the test case walked through above, and the changelog documenting this exact bug fix is available at github.com/realpaul3907/aci-mcp-server. You can clone it, and point it at your own APIC (a free DevNet sandbox works fine), and it is ready to run.Follow me on Linkedin

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.