Building an MCP server with FastMCP¶
An MCP server is how you give an AI agent a safe, typed interface to your network — a set of functions it can discover and call instead of a free-form shell. This article shows how to build one with Python's FastMCP: expose read-only network tools, add a data resource, run it locally or over HTTP, and keep the design from becoming a liability.
What MCP is¶
The Model Context Protocol (MCP) is an open standard that lets AI applications connect to external tools and data through a consistent interface. Instead of every agent, IDE, or chat app inventing its own integrations, an MCP server exposes capabilities that any MCP-aware client can discover and use. The server advertises what it offers; the client reads those definitions and calls them by name.
A server exposes three kinds of primitive:
| Primitive | What it is | Example |
|---|---|---|
| Tools | Functions the model can call to fetch live data or take an action | "get the status of an interface" |
| Resources | Read-only data the client loads into context | a device inventory, a config file |
| Prompts | Reusable prompt templates the server offers | a "triage this interface" template |
Tools are the primitive that matters most for network automation — they are how an agent gets live information. Client and server talk over a transport: either stdio (the client launches the server as a local subprocess and exchanges JSON-RPC over stdin/stdout) or Streamable HTTP (a single networked HTTP endpoint, the current remote transport in the spec).
FastMCP is the Python framework that makes building a server trivial: you decorate ordinary functions, and it handles the protocol, schema generation, and transport. Install it with pip install fastmcp (Python 3.10+).
A read-only network-information server¶
The server below exposes tools an agent can call to learn about the network. Each tool is a plain function with type hints and a docstring — FastMCP turns those into the schema the model sees, so no separate schema file is needed. The docstring is not a comment; it is what the model reads to decide when to call the tool, so clear, accurate descriptions directly improve tool selection.
import os
from fastmcp import FastMCP
from netmiko import ConnectHandler
mcp = FastMCP("network-info")
def _connect(host: str):
return ConnectHandler(
device_type="cisco_ios",
host=host,
username="automation",
password=os.environ["DEVICE_PASSWORD"],
)
@mcp.tool
def get_interface_status(host: str, interface: str) -> dict:
"""Return the operational status of an interface on a device."""
conn = _connect(host)
try:
out = conn.send_command(f"show interface {interface}")
finally:
conn.disconnect()
return {"host": host, "interface": interface, "data": out}
@mcp.tool
def list_interfaces(host: str) -> dict:
"""List all interfaces and their status on a device."""
conn = _connect(host)
try:
# use_textfsm parses the CLI table into structured rows
out = conn.send_command("show ip interface brief", use_textfsm=True)
finally:
conn.disconnect()
return {"host": host, "interfaces": out}
if __name__ == "__main__":
mcp.run() # serve over stdio by default
A few details carry weight. The credential comes from the environment, never the code or the prompt — the same discipline as any secure automation code. Arguments are typed, so FastMCP validates them before your function runs. And use_textfsm=True returns a list of dictionaries rather than a wall of text, which the model can reason over far more reliably than raw CLI output (see Network automation with Python). The @mcp.tool decorator is the current idiomatic form; the older @mcp.tool() with parentheses still works.
A third read-only tool returns the running configuration, which an agent can summarize or search on request:
@mcp.tool
def get_running_config(host: str) -> dict:
"""Return the full running configuration of a device (read-only)."""
conn = _connect(host)
try:
out = conn.send_command("show running-config")
finally:
conn.disconnect()
return {"host": host, "config": out}
Exposing data as a resource¶
Beyond tools, a server can expose resources — read-only data a client pulls into context. A device inventory is a natural fit: the agent loads it to know which devices exist before it ever calls a tool. A resource is identified by a URI and declared with @mcp.resource:
@mcp.resource("inventory://devices")
def device_inventory() -> dict:
"""The list of managed devices and their roles."""
return {
"r1": {"role": "core", "mgmt_ip": "10.0.0.1"},
"r2": {"role": "edge", "mgmt_ip": "10.0.0.2"},
}
Running the server: stdio or HTTP¶
By default mcp.run() serves over stdio, which a local client (an IDE, or an agent on the same host) launches as a subprocess. To reach a remote agent, run it with the HTTP transport instead:
# serve over the network instead of stdio
mcp.run(transport="http", host="127.0.0.1", port=8000)
The client then connects — to http://127.0.0.1:8000/mcp for the HTTP transport — discovers the available tools and resources automatically from their schemas, and calls them by name. Bind to 127.0.0.1 for local testing; exposing the server beyond the loopback interface means putting real authentication and TLS in front of it first.
Designing for safety¶
Notice what the example server deliberately does not do: it never configures anything. Every tool queries; none of them writes. That is the whole point. An MCP server is exactly where the excessive-agency risk from AI security is either contained or unleashed. A read-only server, if an attacker subverts the agent through prompt injection, can leak information — bad, but bounded. A server with a configure_interface tool can change the network, and a single injected instruction becomes a config change.
So the rule is read-by-default, write-by-approval. If you must add a write tool:
- Gate it behind explicit human approval — the tool returns a proposed change and a diff; a person confirms before anything is pushed.
- Validate arguments against an allowlist, not just a type — an interface name or VLAN ID should match a known-good pattern before it reaches a device.
- Scope the credential to exactly what the tool needs, following least privilege.
- Validate the generated change before it lands: lint it, dry-run it, and prove it in a lab such as Cisco Modeling Labs or with pyATS.
Keeping the default surface read-only is not a limitation of FastMCP — it allows writes freely. It is a design choice that decides your blast radius.
Key takeaways¶
- MCP is an open standard that lets AI clients discover and use external tools, resources, and prompts through a consistent interface, over stdio or Streamable HTTP.
- Tools — callable functions — are the key primitive for giving an agent live network information.
- FastMCP builds a server from decorated functions:
@mcp.toolplusmcp.run(). The schema comes from the function's type hints and docstring, so a well-typed, well-documented function is the tool definition. - Build for safety: prefer read-only tools, source secrets from the environment, and use typed, validated arguments.
- Add any write tool only behind human approval and strict validation — this is where the excessive-agency risk lives.