On this page
Part 2 of the seven-part series Building an Agentic NOC. Each part builds on the ones before it; the full list is at the end.
In the first post we drew the two axes: MCP runs vertically from an agent down to its tools, A2A runs horizontally across agents. This post builds the vertical one. We are going to write the smallest MCP server that is still real, run it, and then watch the actual JSON-RPC cross the wire, because the protocol is not complicated and you should see it rather than take my word for it. Everything here runs on your machine with one dependency, and every byte of output below is captured from a real run, not typed by hand.
What we are building, and the one dependency
An MCP server exposes capabilities to an agent. We will expose exactly one tool, a function called subnet_info that takes an IPv4 CIDR and returns the network address, broadcast, netmask, and usable host count. It is a deliberately boring tool, and that is the point: it is useful to a network engineer, it is pure arithmetic with no side effects, and its read-only nature makes it a safe first thing to hand an autonomous agent. The only dependency is the official MCP Python SDK.
pip install mcpThe server
The whole server is this file. Save it as server.py.
import ipaddress
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("netlab")
@mcp.tool()
def subnet_info(cidr: str) -> dict:
"""Return network address, broadcast, netmask, and usable host count for an IPv4 CIDR."""
net = ipaddress.ip_network(cidr, strict=False)
return {
"cidr": str(net),
"network": str(net.network_address),
"broadcast": str(net.broadcast_address),
"netmask": str(net.netmask),
"num_addresses": net.num_addresses,
"usable_hosts": max(net.num_addresses - 2, 0),
}
if __name__ == "__main__":
mcp.run() # defaults to the stdio transportThree things are doing real work here, and none of them are boilerplate you can ignore. The FastMCP("netlab") object is the server, and the string is the server name a client will see. The @mcp.tool() decorator registers the function as an MCP tool, and this is the part worth slowing down on: the SDK reads the function's type hints and docstring and generates a JSON Schema for the input automatically, so the cidr: str annotation becomes a typed parameter the agent knows how to fill. You do not write the schema by hand. Finally mcp.run() with no argument starts the server on the stdio transport, which is the one you want for a local server.
Running it with a client
An MCP server does nothing on its own, it waits for a client. Here is the smallest client that spawns the server, completes the handshake, lists the tools, and calls one. Save it as client.py in the same directory.
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command="python3", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
init = await session.initialize()
print("server:", init.serverInfo.name, "| protocol:", init.protocolVersion)
tools = await session.list_tools()
for t in tools.tools:
print("tool:", t.name, "| inputSchema keys:", list(t.inputSchema.get("properties", {})))
res = await session.call_tool("subnet_info", {"cidr": "10.110.0.0/24"})
print("result:", res.content[0].text)
asyncio.run(main())Notice what the client never does: it never imports server.py. It launches it as a separate process (command="python3", args=["server.py"]) and talks to it over a protocol. That separation is the entire value of MCP, and it is worth internalising now because it is exactly how a real agent will reach a server it did not write. Run it:
$ python3 client.py
server: netlab | protocol: 2025-11-25
tool: subnet_info | inputSchema keys: ['cidr']
result: {
"cidr": "10.110.0.0/24",
"network": "10.110.0.0",
"broadcast": "10.110.0.255",
"netmask": "255.255.255.0",
"num_addresses": 256,
"usable_hosts": 254
}The negotiated protocol version is 2025-11-25, the current MCP specification revision at the time of writing, chosen by the SDK on both ends. The tool's input schema was discovered, not configured. And the call returned structured data. That is a working MCP integration in about forty lines.
What is actually on the wire
The SDK hides the protocol, which is convenient and also the reason so many people use MCP without knowing what it is. Let us remove the SDK from the client side and speak the protocol by hand, so you can see that there is no magic. MCP over stdio is newline-delimited JSON-RPC 2.0: the client writes one JSON object per line to the server's standard input, and the server writes one JSON object per line to its standard output. Here are the four messages a client sends, in order.
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"wire-demo","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"subnet_info","arguments":{"cidr":"10.110.0.0/24"}}}The sequence is not arbitrary. First initialize, where the client and server agree on a protocol version and each declares its capabilities. Then the notifications/initialized message, which is a notification and therefore has no id and gets no reply, telling the server the handshake is done. Only then may the client call methods, here tools/list to discover what exists and tools/call to run one. Pipe those four lines into the server and it responds with one JSON-RPC result per request that carried an id. One subtlety trips people up here: a plain pipe closes stdin the moment the four lines are sent, and the stdio server shuts down before the asynchronous tools/call result is written, so a naive pipe shows only the first two responses. Keep stdin open a moment longer, for example (cat requests.txt; sleep 1) | python3 server.py, to get all three. This is the real captured output.
id=1: {"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"netlab","version":"1.28.1"}}}
id=2: {"result":{"tools":[{"name":"subnet_info","description":"Return network address, broadcast, netmask, and usable host count for an IPv4 CIDR.","inputSchema":{"type":"object","title":"subnet_infoArguments","properties":{"cidr":{"title":"Cidr","type":"string"}},"required":["cidr"]}}]}}
id=3: {"result":{"content":[{"type":"text","text":"{\n \"cidr\": \"10.110.0.0/24\",\n \"network\": \"10.110.0.0\",\n \"broadcast\": \"10.110.0.255\",\n \"netmask\": \"255.255.255.0\",\n \"num_addresses\": 256,\n \"usable_hosts\": 254\n}"}],"isError":false}}Read the id=1 response and you can see the server declaring which capabilities it supports: tools, resources, and prompts, the three MCP primitives. Our server only uses tools, but the handshake advertises all three. Read the id=2 response and there is the schema the SDK generated from our type hint, an object with a required string property named cidr. This is precisely what lets an agent construct a valid call without a human wiring it up. Read the id=3 response and the tool's return value comes back as a text content block, with an isError flag that is false because nothing went wrong.
The pieces you now understand
Four ideas carry over to everything else in this series. The transport is stdio: the server is a subprocess and the two sides exchange newline-delimited JSON-RPC over its standard input and output. The lifecycle is fixed: initialize, then initialized, then calls. The schema is generated from your Python types, which is why the agent can call your function correctly. And the result is structured content, not free text, which is why the agent can reason about it.
One property of this tool matters more than it looks. subnet_info cannot change anything. It reads its input and returns arithmetic. When we build the real fabric server later in the series, that read-only quality stops being a happy accident of a toy example and becomes a hard rule enforced in the transport itself, because the whole point of letting an agent touch production infrastructure is that it must not be able to break it. A tool that can only read is a tool you can hand to an autonomous system and sleep at night.
Reproduce it
Two files, one dependency, three commands.
pip install mcp
# save server.py and client.py from above, in the same directory
python3 client.pyTwo fields depend on your environment: serverInfo.version is the installed mcp SDK version (1.28.1 here) and the negotiated protocolVersion (2025-11-25) is the newest revision that SDK supports, so a different mcp release changes both. If the rest of your output matches what is printed above, you have a working MCP server and you have seen the protocol it speaks. Next post we build the other axis: a minimal A2A agent with a real Agent Card, so one agent can discover and delegate to another. After that, the evolution of these protocols, and then the real system.
Building an Agentic NOC, the full series: