Control your Network with AI - Building your first MCP Server with a local LLM
- Oct 1
- 8 min read

Table of Contents
Introduction:
I have previously written a Blog post explaining what MCP is. If you're unfamiliar with the MCP concept, I strongly suggest reading that first. In this post, I'll demonstrate how to set up your first MCP Server and provide important side notes along the way! Additionally, I made a Blog Post about configuring Meraki networks using ChatGPT natively. I received a lot of feedback expressing discomfort with exposing the API key to a public service, particularly in Germany. Therefore, this time, I focused on having a local LLM and operating a local MCP Server.
For this post my aim is to set up my own MCP Server locally to collect data about my infrastructure via a Catalyst Center.
Since this alone would be a pretty boring use-case, I plan to utilize the Catalyst Center MCP to gather information on all the IOS-XE Versions in use. Subsequently, I'll use a pre-built NIS MCP Server to query about known CVEs associated with these IOS-XE Versions. Finally, I'll enhance my code to gather the running configuration and ask the LLM to check if I'm impacted by the CVE and, if necessary, how to mitigate any risks.
Chapter 1: Setting up a local LLM
There are numerous options for running a pre-trained LLM. Hugging Face is undoubtedly the most well-known. Regarding Client/Agent software, VS-Code, Claude Desktop, and LM Studio are three of the most popular, at least from what I an see. I've chosen for LM Studio. One reason is that I can select a model from Hugging Face directly within the app. Additionally, it has MCP fully integrated, and I've chosen the MLX version of OpenAI's GPT-OSS 20B model. Thanks to Apple Silicon’s unified memory, integrated GPU, and Neural Engine, Macs can run large language models surprisingly well without needing a dedicated gaming GPU. Apple’s MLX (Machine Learning eXchange) framework enables this by efficiently distributing LLM workloads across the CPU, GPU, and Neural Engine. This combination provides strong performance and energy efficiency right on a laptop. However, you need an Apple Silicon (M1-M4) to take advantage of this.
Chapter 2: Write first lines of Python code
Let's begin by initializing a new project with uv init, as usual.
Since I plan to use the SDK, I'll add the package immediately with: uv add catalystcentersdk
It's crucial not to include your credentials directly in the code. For this project, I've created a file named config.py to store all credentials and included this file in the .gitignore.
Below is a simple code snippet that retrieves all devices and their respective details:
from catalystcentersdk import CatalystCenterAPI
import config
CatCen = CatalystCenterAPI(username = config.USERNAME,
password = config.PASSWORD,
base_url=config.URL,
verify=False)
def getDevices():
devices = CatCen.devices.devices()
return devices
print(getDevices())
Lets make this usable for our AI Agent that it can work with this informations.
The fastest and most convenient way currently is, as the name may already reveal, FastMCP.
FastMCP is very easy to use:
uv add fastmcp
import fastmcp within the Python code
instantiate
decorate your function (@mcp.tool())
make a good docstring explaining to the Agent what he can do with this function
add annotations
optional: make the function async
Doing all this will result in a code like this:
from catalystcentersdk import CatalystCenterAPI
from typing import Any
from fastmcp import FastMCP
import config
import asyncio
mcp = FastMCP("CatCen MCP")
CatCen = CatalystCenterAPI(username = config.USERNAME,
password = config.PASSWORD,
base_url=config.URL,
verify=False)
@mcp.tool()
async def getDevices() -> list[dict[str, Any]]:
"""
tool for accessing Cisco devices over the Catalyst Center (former
DNA Center). You also get a lot of attributes per device
using this you will get a list of all devices. within the list
you can read the attribues like the device name, IP Adress or
Name
example output:
[{'airQualityHealth': {}, 'band': {}, 'clientCount': {},
'cpuHealth': -1, 'deviceFamily': 'SWITCHES_AND_HUBS',
'deviceType': 'Cisco Catalyst 9000 UADP 8 Port Virtual <…>
"""
devices = await asyncio.to_thread(CatCen.devices.devices)
return devices.response
if __name__ == "__main__":
mcp.run(transport="http", port=8002, log_level="DEBUG")
Let's break down some special parts.
Every function decorated with @mcp.tool() becomes a tool provided by an MCP Server, ready to be utilized by the AI Agent.
The first thing that was somewhat unusual for me was the annotation, which declares the expected data type. If you're wondering why only 'Any' needs to be imported while others don't, it's because, since Python 3.9 and later, most of these constructs are included by default.
Examples are: Included in Python 3.12:
int, str, float, bool, bytes
list[T], dict[K, V], tuple[T, ...], set[T]
e.g. str | None
Comes from typing:
Any
But why is it necessary to specify this? Let's recall the JSON body when a client requests tools/list:
{
"jsonrpc": "2.0",
"id": 52,
"result": {
"tools": [
{
"name": "inventory_list",
"description": "List all managed devices; optional filter
by device family {SWITCH, AP, WLC}.",
"inputSchema": {
"type": "object",
"properties": {
"family": {
"type": "string",
"enum": ["SWITCH", "ROUTER", "AP", "WLC"]
}
}
}
}
Given that all data must be in JSON format, FastMCP needs to determine the type for this request and, conversely, how the result should be converted back into JSON.
Finally, you specify whether the MCP is accessible via STDIO or streamable HTTP. If you choose HTTP, there are some parameters associated with this setting.
Making the function asynchronous allows the MCP Server to execute multiple tools simultaneously, such as collecting data from several devices at once. Otherwise, calling a tool would essentially freeze the entire MCP server until the request is complete. However, this is not mandatory. FastMCP will manage and handle it as needed.
This may look as follows (highly simplified):
if inspect.iscoroutinefunction(tool_func):
result = await tool_func(*args)
else:
result = tool_func(*args)
To complete this action, starting the MCP Server by executing uv run CatCen.py.

Chapter 3: Testing with the MCP Inspector
The best current method for testing the running MCP Server is using the MCP Inspector.
I will not go over the installation process here. Please refer to the official guide.

You should be able to connect to your server and view a list of all tools. At this point in the guide, you should see your tool: getDevices. Along with all related information, such as the docstring
When you test it, you will get a response similar to this:
{
"content": [
{
"type": "text",
"text": "Output validation error: None is not of type 'array'"
}
],
"isError": true
}
By printing the type of the response we will get following:
<class 'catalystcentersdk.models.mydict.MyDict'>
Since FastMCP cannot manage SDK-specific objects, it's necessary to convert this object into a basic JSON object.
Here's how I've accomplished this:
If the object is a Pydantic model and have the dict attribute, use the dict() function to obtain the plain JSON object.
Enhancing the return statement:
return json.loads(json.dumps(
devices.response,
default=_json_deobjectations))
After creating the function accordingly:
def _json_deobjectations(o: Any) -> Any:
"""
take the mydict object and convert it to plain dict if it is a
pydantic object with dict
"""
if hasattr(o, "dict"):
return o.dict()
This can be applied to every new MCP tool, as it is able of handling both JSON objects and Pydantic objects.
Once the code is adapted, you will be able to view all the devices managed by the Catalyst Center.
Chapter 4: Action!
Since we have confirmed that the MCP Server functions as expected, we will proceed to use it with our Agent.
Configuring LM Studio
To enable access to the MCP Server, we need to modify the configuration file. Simply click on the power plug symbol, select Install from the dropdown menu, and edit mcp.json.

Since we have already started the MCP server, the configuration is quite straightforward:
{
"mcpServers": {
"CatCenMCP": {
"url": "http://127.0.0.1:8002/mcp"
}
}
}
Once you've chosen your MCP Server from the list, you'll see that the prompt field now features the button:

So lets test it:

Hell yeah! We've successfully created our first MCP Server! Congratulations!
Chapter 5: Taking it further - Real World Use Case
The final step is to leverage what has been learned!
In this example, I want to take the initial output and take it to another MCP Server in order see if there is a known CVE for the listed IOS Versions.
If there is, then obtain the IP Address of the device, gather its configuration, and verify if we are affected by this CVE. For instance, a BGP CVE is not a big problem for us if we don't use BGP.
MCP for searching CVE in the NVD
For this task i used this MCP Server. You need to request an API key and augment the mcp.json file in LM Studio.
{
"mcpServers": {
"CatCenMCP": {
"url": "http://127.0.0.1:8002/mcp"
},
"mcp-nvd": {
"command": "/opt/homebrew/bin/uvx",
"args": [
"mcp-nvd"
],
"env": {
"NVD_API_KEY": "<never share you API key>"
}
}
}
}
Adding Tool for gathering running configuration
I've developed a straightforward tool using Netmike to SSH into a device using its IP address in order to obtain the running configuration. Additionally, I've updated the config.py file to include the username and password needed for remote access.
from netmiko import ConnectHandler
mcp.tool()
async def getDeviceConfig(host: str, ) -> str:
"""
use the IP Adress of a device to ssh into it. Username and
Password are already present in this tool.
It will return the full running-configuration of the Cisco
Device
"""
device = {
"device_type": "cisco_ios",
"host": host,
"username": config.SSH_USERNAME,
"password": config.SSH_PASSWORD
}
try:
with ConnectHandler(**device) as conn:
# If the device needs enable mode:
# if device.get("secret"): conn.enable()
running_cfg = conn.send_command("show running-config",
use_textfsm=False)
#return running_cfg
return json.loads(json.dumps(running_cfg,
default=_json_deobjectations))
except Exception as e:
return f"Connection failed. Reason: {e}"
Let's do a test:
This time, I compiled a list of all switches along with their IP addresses, names, and IOS versions, and asked about any known CVEs. In blue, we can see that the agent is utilizing the search_cve tool from the CVE MCP.

CVE-2018 appears to be interesting. I want to know if we are impacted by this CVE. Therefore, I requested a configuration check. It should review the tool description, which states that the configuration can be obtained through our MCP server, and it will require the device's IP address to do so. Understand that in the Cisco configuration, if there are no CDP lines present, it means CDP is enabled. This is because it is a default setting, and defaults are not visible in the running configuration.

and the Agent also immediately recommended mitigation actions:

Security considerations
When integrating MCP into enterprise environments, it is crucial to consider security implementations beyond this simple lab. Not all MCP agents softwares are capable of securely storing credentials or API keys within the mcp.json file, which creates a huge risk. Furthermore, the MCP server itself runs with read/write permissions on you system, making it an attractive to abuse. To reduce risk, enterprises should ensure the LLM in use is not compromised. Cisco AI Defense, for example, provides threat detection and guardrails against malicious prompts, data exfiltration, or model abuse. Strong authentication to the MCP server must be mandatory, with OAuth 2.0 enforced to protect access tokens and session integrity. Additional hardening measures include restricting file system privileges, centralizing secret storage through enterprise-grade secret managers, enforcing TLS with mutual certificate validation between clients and the MCP server, and enabling granular audit logging to identify suspicious access or privilege escalation attempts. Security teams should also monitor MCP processes in the same way they would critical middleware or identity services, as its compromise could serve as a pivot into sensitive data and systems.
Comments