MCP Servers for DevOps: What They Are and Two You Can Build This Week
MCP servers for DevOps: give Claude live access to Kubernetes, AWS and runbooks. The three server types, a read-only Kubernetes MCP server and common mistakes.
This article is published in English.
Sources: What Is an MCP Server and Why Every DevOps Team Should Have One in 2026
MCP, the Model Context Protocol, is an open standard from Anthropic that lets AI models connect to outside systems in a structured way: tools, APIs, databases and services. Without it, Claude lives in a box and every fact has to be carried in by hand. With it, Claude can reach out and ask the systems directly.
The "server" in MCP is just a small program. It exposes tools and data to Claude, handles the back-and-forth and runs wherever the data lives, whether that is a laptop, a cluster or a server. It isn't heavy infrastructure, and a working MCP server can be a 50-line Python script.
An analogy that helps
Slack integrations make a good comparison. Before them, somebody posts "deployment to production started" by hand. After them, the CI/CD pipeline posts it automatically, and Slack becomes a hub that knows what is going on. MCP does that for Claude. Before, a person feeds it facts about the infrastructure. Afterward, it holds a direct connection and can check for itself.
Why DevOps teams gain the most
Most MCP writing targets application developers. DevOps, SRE and platform engineers have more to gain, because their whole job is managing systems that already have APIs: Kubernetes, AWS, Datadog or Grafana, PagerDuty, Terraform Cloud, GitHub, Confluence and Notion. Each can be connected to Claude through MCP. Once that's done, there is no more hopping between tools to collect information. The work becomes asking questions.
What teams do with MCP servers
- Cluster questions. Asking which production deployments have run for over 30 days without an update sends Claude to the Kubernetes MCP server, which queries the cluster and returns the answer. There is no kubectl and no copy-paste.
- Cost questions. Asking which AWS services spiked this week compared with last sends Claude to an AWS server and Cost Explorer, and the reply is a plain-English breakdown.
- Incident context. A question about the PagerDuty incidents of the last 24 hours, and whether any are still open, pulls current state from a PagerDuty server and summarizes it.
- Runbook lookup. Asking for the runbook on database connection pool exhaustion searches the knowledge base through a Confluence server and returns the exact document.
- Security audits. Asking whether any security group allows 0.0.0.0/0 on ports other than 443 makes an AWS server scan the groups and flag violations.
Three kinds of server
Teams that start usually end up building three types.
Read-only observability servers
These connect Claude to monitoring and infrastructure APIs so it can describe the current state of things. Typical examples are a Kubernetes cluster state server, an AWS inventory server, a Datadog metrics server and a CloudWatch logs server. Risk is very low because Claude can only read. This is the place to start, and it builds confidence before any write access is granted.
Knowledge base servers
These link Claude to internal documentation for search and retrieval: a Confluence or Notion runbook server, a GitHub wiki server, a folder of local markdown or a store of architecture decision records. The risk is essentially zero, since it is search over documents. They matter because they make Claude feel like it really knows the company.
Action servers
These let Claude do things such as create tickets, trigger pipelines or restart services. Examples include Jira ticket creation, PagerDuty incident management, GitHub pull request creation and Slack notifications. Risk runs from medium to high because Claude is now changing things. Approval gates should always sit in front of write operations, so a pod restart never fires without a confirmation step.
Building a Kubernetes MCP server
What follows is a working read-only Kubernetes server in Python. Install the SDK first:
pip install mcp kubernetes --break-system-packages
Then save the server as k8s_mcp_server.py:
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
from kubernetes import client, config as k8s_config
# Load kubeconfig
try:
k8s_config.load_incluster_config()
except:
k8s_config.load_kube_config()
v1 = client.CoreV1Api()
apps_v1 = client.AppsV1Api()
# Create MCP server
app = Server("k8s-devops-server")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
"""Tell Claude what tools are available."""
return [
types.Tool(
name="get_pods",
description="Get all pods in a namespace with their status, restarts, and age.",
inputSchema={
"type": "object",
"properties": {
"namespace": {
"type": "string",
"description": "Kubernetes namespace (e.g. production, staging)"
}
},
"required": ["namespace"]
}
),
types.Tool(
name="get_pod_logs",
description="Get recent logs from a pod or deployment.",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Pod or deployment name"
},
"namespace": {
"type": "string",
"description": "Kubernetes namespace"
},
"lines": {
"type": "integer",
"description": "Number of log lines to return (default 50)"
}
},
"required": ["name", "namespace"]
}
),
types.Tool(
name="get_deployments",
description="List all deployments with replica counts and status.",
inputSchema={
"type": "object",
"properties": {
"namespace": {
"type": "string",
"description": "Kubernetes namespace"
}
},
"required": ["namespace"]
}
),
types.Tool(
name="get_events",
description="Get recent Kubernetes events. Useful for diagnosing issues.",
inputSchema={
"type": "object",
"properties": {
"namespace": {
"type": "string",
"description": "Kubernetes namespace"
}
},
"required": ["namespace"]
}
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
"""Handle tool calls from Claude."""
if name == "get_pods":
namespace = arguments["namespace"]
pods = v1.list_namespaced_pod(namespace=namespace)
lines = [f"Pods in {namespace}:\n"]
for pod in pods.items:
phase = pod.status.phase
restarts = sum(
cs.restart_count
for cs in (pod.status.container_statuses or [])
)
name_str = pod.metadata.name
lines.append(f" {name_str}: {phase} | restarts: {restarts}")
return [types.TextContent(type="text", text="\n".join(lines))]
elif name == "get_pod_logs":
pod_name = arguments["name"]
namespace = arguments["namespace"]
lines = arguments.get("lines", 50)
import subprocess
result = subprocess.run(
f"kubectl logs {pod_name} -n {namespace} --tail={lines}",
shell=True, capture_output=True, text=True
)
output = result.stdout or result.stderr or "No logs found"
return [types.TextContent(type="text", text=output)]
elif name == "get_deployments":
namespace = arguments["namespace"]
deployments = apps_v1.list_namespaced_deployment(namespace=namespace)
lines = [f"Deployments in {namespace}:\n"]
for d in deployments.items:
desired = d.spec.replicas or 0
available = d.status.available_replicas or 0
status = "✅" if desired == available else "⚠️"
lines.append(
f" {status} {d.metadata.name}: {available}/{desired} replicas"
)
return [types.TextContent(type="text", text="\n".join(lines))]
elif name == "get_events":
namespace = arguments["namespace"]
events = v1.list_namespaced_event(namespace=namespace)
# Sort by last timestamp, show recent ones
sorted_events = sorted(
events.items,
key=lambda e: e.last_timestamp or e.event_time,
reverse=True
)[:20]
lines = [f"Recent events in {namespace}:\n"]
for e in sorted_events:
lines.append(
f" [{e.type}] {e.reason}: {e.message[:100]}"
)
return [types.TextContent(type="text", text="\n".join(lines))]
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
To connect it to Claude Desktop, edit the config file, which lives at one of these paths:
Mac: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
and add the server entry:
{
"mcpServers": {
"kubernetes": {
"command": "python",
"args": ["/path/to/k8s_mcp_server.py"]
}
}
}
Restart Claude Desktop and it is ready. Asking how many pods are running in production now makes Claude actually check.
Building a runbook server
This one may be even more valuable. It points Claude at the team's runbooks, so in the middle of an incident Claude finds the right one instead of someone searching Confluence by hand.
import asyncio
import os
import glob
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
app = Server("runbook-server")
RUNBOOKS_PATH = os.path.expanduser("~/runbooks") # point to your runbooks folder
def load_all_runbooks() -> dict[str, str]:
"""Load all markdown runbooks from the folder."""
runbooks = {}
for filepath in glob.glob(f"{RUNBOOKS_PATH}/**/*.md", recursive=True):
with open(filepath, 'r', errors='replace') as f:
content = f.read()
name = os.path.relpath(filepath, RUNBOOKS_PATH)
runbooks[name] = content
return runbooks
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="search_runbooks",
description=(
"Search your team's runbooks for a specific topic, service, or error. "
"Use this when someone asks about a procedure, a known issue, "
"or how to fix something."
),
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to search for (e.g. 'database connection pool', 'nginx 502', 'SSL cert renewal')"
}
},
"required": ["query"]
}
),
types.Tool(
name="list_runbooks",
description="List all available runbooks by category.",
inputSchema={
"type": "object",
"properties": {}
}
),
types.Tool(
name="get_runbook",
description="Get the full content of a specific runbook by name.",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Runbook filename (from list_runbooks)"
}
},
"required": ["name"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
runbooks = load_all_runbooks()
if name == "list_runbooks":
names = sorted(runbooks.keys())
output = "Available runbooks:\n" + "\n".join(f" - {n}" for n in names)
return [types.TextContent(type="text", text=output)]
elif name == "search_runbooks":
query = arguments["query"].lower()
matches = []
for rb_name, content in runbooks.items():
if query in content.lower() or query in rb_name.lower():
# Return first 500 chars as preview
preview = content[:500].strip()
matches.append(f"📄 {rb_name}\n{preview}\n...")
if not matches:
return [types.TextContent(
type="text",
text=f"No runbooks found for '{query}'"
)]
return [types.TextContent(
type="text",
text=f"Found {len(matches)} runbook(s):\n\n" + "\n\n---\n\n".join(matches)
)]
elif name == "get_runbook":
rb_name = arguments["name"]
content = runbooks.get(rb_name)
if not content:
return [types.TextContent(type="text", text=f"Runbook not found: {rb_name}")]
return [types.TextContent(type="text", text=content)]
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await app.run(read_stream, write_stream, app.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
Add it to the Claude Desktop config alongside the other server:
{
"mcpServers": {
"kubernetes": {
"command": "python",
"args": ["/path/to/k8s_mcp_server.py"]
},
"runbooks": {
"command": "python",
"args": ["/path/to/runbook_mcp_server.py"]
}
}
}
After that, a question like "what's the runbook for high memory usage on the API pods?" returns the team's own runbook and not a generic search result.
Servers that already exist
Not everything has to be built from scratch. The official collection of reference servers on GitHub, under the modelcontextprotocol organization, covers GitHub (repositories, pull requests, issues and commits), Postgres, Slack, an AWS knowledge base search, local filesystem access and Brave web search. Community-built servers exist for Datadog (metrics, dashboards and monitors), PagerDuty (incidents and alerts) and Jira (tickets and sprints). Browsing that collection first is worthwhile, because someone may already have built what is needed.
Mistakes to avoid
- Granting write access too early. Begin read-only. After trust is earned, add write tools one at a time.
- Building one giant server. Small, focused servers are easier to maintain, easier to debug and easier to hand to specific people. A
k8s-servershould hold only Kubernetes tools, anaws-serveronly AWS tools and arunbook-serveronly documents. - Skipping logging. Record every tool call, including what Claude queried, when and what came back, especially before write tools arrive.
- Skipping approval on writes. Anything that creates a ticket, restarts a service or merges a pull request deserves a confirmation step, with Claude showing what it is about to do first.
A morning with a proper setup
A typical session starts by asking for the state of production. Claude checks the Kubernetes server and reports on deployments and pods, flags a pod with repeated restarts, and then follows up on the problem.
You: What's the state of production right now?
Claude: [checks kubernetes MCP server]
3 deployments all healthy, 47/47 pods running.
One pod in payments namespace has 8 restarts - api-worker-6d4f9b.
Everything else looks stable.
You: Why is that pod restarting?
Claude: [checks pod logs via MCP]
Logs show OOMKilled. Memory limit is 256Mi but it's hitting
248Mi regularly. Looks like it needs a bump to at least 512Mi.
Want me to check if we have a runbook for this?
You: Yes
Claude: [searches runbook MCP server]
Found it - "memory-limit-tuning.md"
The runbook says: increase limit in Helm values,
run helm upgrade, monitor for 10 minutes.
Last updated by Rahul on March 12th.
You: What did our AWS bill look like this week?
Claude: [checks AWS MCP server]
$4,823 this week vs $4,210 last week - up 14.5%.
Spike is from EC2 in us-east-1. Looks like
3 r6g.2xlarge instances were added on Tuesday.
That whole exchange takes about 90 seconds, with no tab switching, no copy-paste and no searching.
Starting this week
Building five servers at once is a mistake. Pick one. For most DevOps teams the best first choice is the runbook server, because it is read-only, delivers value immediately and works with whatever documents already exist. The start takes three steps:
- Collect the team's runbooks into a single folder, such as
~/runbooks/. - Take the runbook server code above and point it at that folder.
- Add it to the Claude Desktop config and restart.
That gives an assistant that knows the team's procedures. When it proves reliable, add the Kubernetes server, then AWS, then whatever comes next.
The bigger picture
MCP is still young and the ecosystem is growing fast, but a few things are already clear. Teams with MCP servers are having a noticeably different AI experience from teams without. Moving from a chat window to something that works like an ops teammate takes one thing, a server that exposes the right systems. And teams building these now are setting the pattern for how AI gets used in infrastructure work. This is no research project. Teams run these in production today, and the Kubernetes server above can be working in about 20 minutes.