Skip to content

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.tool plus mcp.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.