
In our announcement post we explained why adding an MCP connector to Simple Facts matters — AI agents need a structured, on-demand interface into your network automation toolkit, and the Model Context Protocol provides exactly that. This article is the follow-up for the engineers who want to know how it actually works: which tools we’re exposing, what real code they map to, and the design decisions that shaped the connector. This is how we wired Simple Facts to MCP: A Technical Deep Dive
Before the MCP connector, Simple Facts had two consumption surfaces. The CLI handled one-shot, operator-driven runs. Prefect handled scheduled pipelines, retries, artifact history, and the operational visibility that comes with a proper workflow UI. Both are the right tools for their contexts.
But an AI agent reasoning in real time has a different shape of need. It doesn’t want to submit a Prefect flow run and wait for a webhook. It wants to call a tool, get a structured response, incorporate that response into its reasoning, and decide what to call next. That’s a synchronous, on-demand interaction model — and neither the CLI nor Prefect is designed for it.
MCP fills that gap. It gives agents a discoverable, callable surface into Simple Facts without changing anything about how Prefect or the CLI work. The three surfaces coexist, each optimised for a different operator mode.
The connector follows a strict three-layer architecture. Nothing lives in the wrong layer.
┌─────────────────────────────────────────────────┐
│ AI Agent / Claude Desktop │
│ (any MCP-compatible client or agent) │
└────────────────────┬────────────────────────────┘
│ MCP protocol (stdio / SSE)
▼
┌─────────────────────────────────────────────────┐
│ sfacts MCP Server │
│ ┌─────────────────────────────────────────┐ │
│ │ Tool definitions (JSON schema) │ │
│ │ Thin parameter wrappers │ │
│ │ Credential injection (env / keyring) │ │
│ └─────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────┘
│ Direct Python calls
▼
┌─────────────────────────────────────────────────┐
│ sfacts Core Layer │
│ ┌──────────────────────────────────────────┐ │
│ │ NetworkDiscovery (scanner + discovery)│ │
│ │ BasicFactCollector (collector) │ │
│ │ NetBoxSyncManager (core/netbox/) │ │
│ │ NautobotSyncManager(core/nautobot/) │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
The MCP server owns the protocol surface and nothing else. All business logic — scanning, fact collection, IPAM synchronisation — lives in the sfacts/core/ layer, exactly where it lived before. The connector adds the protocol adapter; it doesn’t rewrite the automation.
One design decision worth calling out explicitly: the MCP tools wrap the core classes directly, not the Prefect task wrappers. Prefect tasks are designed for async, long-running pipeline execution. The core classes are synchronous Python — they return clean dicts and raise clean exceptions. That’s the right shape for an MCP tool call.
The connector exposes seven tools across three functional groups: discovery, fact collection, and IPAM management. Here’s how each one maps to the real sfacts codebase.
The entry point for any network inventory workflow. An agent calls this with a CIDR range and gets back a structured list of discovered devices — host, device type, and credentials embedded for the next step.
Agent
│
│ discover_subnet(subnet="192.168.1.0/24")
▼
MCP Tool
│
│ NetworkDiscovery.discover_subnet()
▼
SubnetScanner.scan_ports() ← parallel TCP/22 probe across all hosts
│
│ responsive IPs
▼
Netmiko SSHDetect ← device type fingerprinting per host
│
│ device_type identified
▼
List[DeviceDict] → returned to agent
Underneath, Netmiko’s SSHDetect does the heavy lifting on device type fingerprinting. The agent gets back a clean list it can pass directly to collect_facts.
The lab-mode counterpart to discover_subnet. Instead of scanning a CIDR range, it connects through SSH tunnels — the pattern used with containerlab and remote lab environments. An agent passes a host and port range; the discovery engine maps each port to a virtual device.
Agent
│
│ discover_tunnel(host="localhost", port_range="2101-2116")
▼
MCP Tool
│
│ NetworkDiscovery.discover_tunnel()
▼
Port range expansion ← 2101, 2102, ... 2116
│
▼
Netmiko SSHDetect (per port) ← each port = one virtual device
│
▼
List[DeviceDict] → returned to agent
The most data-rich tool in the set. It takes the device list from discovery and opens parallel NAPALM connections to each device, running a standard set of getters. The result is the central facts dict that every downstream tool operates on.
Agent
│
│ collect_facts(devices=[...])
▼
MCP Tool
│
│ BasicFactCollector.collect_facts_batch()
▼
ThreadPoolExecutor ← parallel connections, configurable workers
│
├── NAPALM get_facts → hostname, model, serial, OS version
├── NAPALM get_interfaces → interface list, speed, MTU, admin state
├── NAPALM get_interfaces_ip → IP/prefix assignments per interface
├── NAPALM get_vlans → VLAN table
├── NAPALM get_lldp_neighbors → neighbour topology
├── NAPALM get_network_instances → VRF table (optional, platform-dependent)
└── NAPALM get_environment → CPU, memory, temperature (optional)
│
▼
FactsDict { → returned to agent
"devices": { hostname: {...} },
"collection_summary": { successful, failed }
}
The optional getters — VRF and environment data — are attempted on every device but silently skipped on platforms that don’t support them. The agent always gets a clean response regardless of platform mix.
The write tools. Both take the facts dict from collect_facts and synchronise it to NetBox or Nautobot respectively. The surfaces are intentionally identical — an agent that knows how to call one knows how to call the other. For a full picture of what gets synchronised, see our NetBox IPAM integration guide.
Agent
│
│ sync_to_netbox(facts_data={...}, site_name="Production", dry_run=True)
▼
MCP Tool
│
├── dry_run=True → count objects, return diff preview, no writes
│
└── dry_run=False
│
│ NetBoxSyncManager / NautobotSyncManager
▼
├── bulk=True → batch API operations (10-50x faster)
└── bulk=False → standard sequential API calls
│
▼
Sites → Manufacturers → Device Types → Device Roles
→ Devices → Interfaces → VLANs → Prefixes
→ VRFs → IP Addresses → MAC Addresses
│
▼
SyncResultDict { devices_created, interfaces_created, ... }
→ returned to agent
The dry_run parameter is a first-class tool parameter, not an afterthought. An agent can always call with dry_run=True to get a preview of what would change before committing — exactly the kind of safety check you want in an autonomous workflow.
Read-only status tools. They test connectivity and return object counts across all IPAM and DCIM categories — sites, devices, interfaces, IP addresses, VLANs, prefixes, VRFs, and more. Safe to call at any point in an agent workflow, with no side effects.
Agent
│
│ get_netbox_status()
▼
MCP Tool
│
│ NetBoxSyncManager.test_connection()
▼
Connection check ← returns early with error if unreachable
│
▼
Object count queries ← dcim.sites, dcim.devices, ipam.* etc.
│
▼
StatusDict {
"status": "success",
"connection": { url, version },
"statistics": { sites, devices, interfaces, ip_addresses, ... }
} → returned to agent
These tools carry over the preflight/postflight pattern from the Prefect flows — where every mutating deployment checks IPAM state before and after. In an MCP context, an agent can replicate that pattern explicitly: call status, call sync, call status again, diff the counts.
The destructive tools. They delete IPAM and DCIM objects in the correct dependency order — IP addresses first, then interfaces, devices, VLANs, prefixes, and finally the structural objects like device types and manufacturers. Scoped to a specific site or full instance-wide, depending on parameters.
Agent
│
│ purge_netbox(site_name="Lab", dry_run=True) ← always True by default
▼
MCP Tool
│
├── dry_run=True → count objects only, return counts, no deletes
│
└── dry_run=False (explicit agent decision required)
│
▼
Deletion order (dependency-safe):
IP Addresses → MAC Addresses → Interfaces → Devices
→ VLANs → Prefixes → VRFs* → Device Types*
→ Manufacturers* → Device Roles* → Sites*
(* full purge only — skipped for scoped site purge)
│
▼
PurgeResultDict { status, counts, deleted }
→ returned to agent
dry_run=True is the default at the tool definition level. An agent has to explicitly pass dry_run=False to trigger actual deletion — making it impossible to accidentally purge data through a casual tool call.
MCP supports two transport modes. stdio runs the server as a local process — the agent spawns it, communicates over stdin/stdout, and the process exits when done. SSE (Server-Sent Events) runs as a persistent HTTP service accessible over the network.
We’re starting with stdio. The primary use case is a single network operator running Simple Facts locally — connecting Claude Desktop or a local agent directly to their network toolkit. stdio is zero-configuration for that scenario: no server to run, no ports to open, no auth layer to manage at the MCP transport level. SSE is a natural next step for multi-user or server-hosted deployments.
One of the most important design decisions: credentials never travel through the MCP tool call. An agent doesn’t pass a NetBox token or SSH password as a tool parameter. The MCP server reads credentials the same way the CLI and Prefect do — from environment variables or the OS keyring.
Agent tool call params:
{ "subnet": "192.168.1.0/24", "timeout": 10 }
│
▼
MCP Server
│
├── Tool params → Python kwargs (subnet, timeout)
│
└── Credentials → injected from env / keyring
NETOPS_USERNAME, NETOPS_PASSWORD ← SSH credentials
NETBOX_URL, NETBOX_TOKEN ← NetBox API
NAUTOBOT_URL, NAUTOBOT_TOKEN ← Nautobot API
│
▼
Core class constructor
NetworkDiscovery(credentials={...}, timeout=10)
This keeps sensitive values out of the MCP message stream entirely. The agent gets tool results — it never sees a token or password. This is the same credential boundary the CLI and Prefect already enforce, so the MCP connector doesn’t introduce any new attack surface.
A clear responsibility split prevents the two surfaces from conflicting or duplicating each other.
| Concern | Prefect | MCP |
|---|---|---|
| Scheduled / recurring runs | ✓ | |
| Retry logic and failure handling | ✓ | |
| Artifact history and audit trail | ✓ | |
| Preflight / postflight status diffs | ✓ | Agent-driven (explicit calls) |
| Real-time agent tool calls | ✓ | |
| On-demand dry-run previews | ✓ | |
| Long-running pipeline orchestration | ✓ | |
| Conversational inventory queries | ✓ |
The key insight is that both surfaces call the same sfacts/core/ classes. There is no logic duplication. Prefect tasks wrap the core layer for pipeline execution. MCP tools wrap the same core layer for agent execution. The core layer is unaware of either.
sfacts/core/
│
┌─────────────┴─────────────┐
│ │
sfacts/tasks/ sfacts/mcp/
(Prefect wrappers) (MCP tool wrappers)
│ │
▼ ▼
Prefect flows MCP Server
Scheduled pipelines Agent tool calls
Artifact history Real-time responses
Retry / observability Dry-run previews
With seven tools across the full inventory lifecycle — discovery, fact collection, IPAM sync, status checks, and cleanup — Simple Facts now has three distinct consumption surfaces: CLI for operator one-shots, Prefect for scheduled pipelines, and MCP for agent-driven workflows. Each is the right tool for its context. None of them required changes to the core library to add the next one.
The connector is in active development ahead of a public release. On the roadmap for potential MCP tools: a drift detection tool that diffs live facts against current IPAM state without triggering a sync, a topology tool that reshapes the LLDP neighbour data already collected by NAPALM into a structured peer map, and a device search tool for filtered queries against NetBox or Nautobot inventory. These are the tools that make agent-driven troubleshooting workflows genuinely powerful — not just automation triggers, but reasoning aids.
Follow the Simple Facts repository for the release, and open an issue if there’s a tool or use case you’d like to see covered.
