MCP Servers: Giving Language Models Hands and Eyes
A new protocol that lets a model actually do things, not just talk about them

Contents
A language model on its own is a brain in a jar. It can reason, summarise, and write you a sonnet about your firewall rules, but it cannot read a file, query a database, or check whether your website is actually up. It only knows what was in its training data and whatever you paste into the chat window. The first time I asked a local model to “check my backups ran last night” and it confidently invented a plausible-sounding answer with fabricated timestamps, the problem crystallised: the gap between knowing things and doing things is the most interesting problem in applied AI right now, and the Model Context Protocol — MCP — is the most sensible attempt I’ve seen at closing it.
MCP arrived in late November 2024, open-sourced by Anthropic, and I’ve spent weeks since wiring it into my own homelab. It’s young, it’s rough at a few edges, and it’s already changed how I think about connecting models to the messy real world. This is what it is, how to build one, and — because I can’t help myself — how not to hand a model the keys to your entire filesystem in the process.
The problem MCP solves
Before MCP, every “give the model a tool” integration was bespoke. You’d hand-roll a function-calling shim for one model, glue it to your database, and then do the whole thing again — differently, in a different format — for the next model and the next data source. It was an N-times-M mess: every model multiplied by every tool, each pairing reinvented by hand. Change your model provider and half your integrations broke. Add a data source and you re-wrote the glue for every model you supported.
MCP turns that into N-plus-M. You write a server that exposes some capability — your filesystem, a Postgres database, a web search, the GitHub API — and any MCP-aware client can use it. The client (the thing hosting the model) and the server (the thing offering the capability) speak one common protocol. Write the server once; every compliant client benefits. Swap your model; the servers don’t care. This decoupling is the whole ballgame, and it’s why MCP feels less like a fad than the function-calling free-for-all it replaces.
If you’ve built anything with local models — pointing tools and retrieval at a self-hosted stack the way Open WebUI pipelines chain models with tools and RAG — the appeal is immediate. MCP standardises the layer that everyone was previously improvising.
Hands and eyes
The protocol gives a model two broad kinds of power, and the metaphor in the title isn’t just decoration.
Tools are the hands. These are actions the model can invoke: run a query, write a file, create an issue, restart a container. The server advertises what each tool does and what arguments it takes, and the model decides when to call it based on what you’ve asked. A tool call has side effects — it changes the world.
Resources are the eyes. These are things the model can read for context: the contents of a file, a row from a table, the current weather, the output of a health check. The model pulls them in when it needs them, rather than you stuffing everything into the prompt up front and blowing your context window on data it may not use.
There’s a third piece, prompts — reusable templates the server can offer to the client — but tools and resources are where the day-to-day action is, and where the value shows up first.
What a server looks like
The thing that genuinely surprised me is how little code a useful server takes. Under the hood it’s JSON-RPC over a transport — stdio for local servers, or streamable HTTP for remote ones. The SDKs hide almost all of that plumbing. Here’s the shape of a tiny Python server exposing one tool:
| |
That is a complete, working server. Point a compatible client at it and the model can now ask your machine how much disk it has left — and, far more usefully, reason about the answer, warn you before a volume fills, or refuse to start a job that won’t fit. The docstring isn’t decoration either; it’s how the model knows what the tool does and when to reach for it. Write vague docstrings and the model fumbles, calling the wrong tool or passing nonsense arguments. Write clear, specific ones and it picks the right tool first time. Treat your docstrings as prompt engineering, because that’s exactly what they are.
Wiring it into a client
A client is configured by telling it how to launch each server. For a stdio server that’s a command and its arguments:
| |
The client spawns each server as a subprocess, asks it what tools and resources it offers, and presents those to the model. There’s already a healthy crop of reference servers — filesystem, Git, fetch, several database connectors — so you can have a model poking around your notes folder or querying a read-only database within minutes. The ecosystem of third-party servers has grown fast, which is a mixed blessing I’ll get to.
The bit to be careful about
Here is where my self-hosting paranoia earns its keep, and where I want you to slow down. An MCP server is a thing that runs code on your behalf, often with the same permissions as the process that launched it. A filesystem server pointed at the wrong directory, or a database server handed write access it didn’t need, is a loaded foot-gun. And a model can be talked into misusing a tool through cunning input — prompt injection stops being a theoretical curiosity the moment the model has hands. A malicious document the model reads can contain instructions that steer a later tool call. This is not hypothetical; it’s the central security problem of the whole agentic-tooling movement.
Three rules I hold to:
- Least privilege, always. Give each server the narrowest scope that does the job. Read-only where you can. A notes server needs one directory, not your home folder.
- Vet the servers you install. A random third-party MCP server from a repo you’ve never heard of is arbitrary code you’re granting tool access. The convenience of
npx -y some-serveris also its danger. Read what it does first. - Never expose a remote MCP server to the open internet without authentication in front of it. If you’re running the streamable-HTTP transport, put an authenticating reverse proxy ahead of it, the same discipline you’d apply to any service. The protocol gives the model power; you are responsible for fencing it in.
Troubleshooting the common failures
The client shows no tools. Almost always the server crashed on startup and the client swallowed the error. Run the server by hand from a terminal first — python /opt/mcp/homelab_server.py — and watch for a stack trace. A missing dependency or a bad path is the usual cause.
The model has the tool but never calls it. Your tool description or docstring is too vague, or the tool name is unintuitive. Rewrite the docstring to state plainly what it does and when to use it. Models pick tools from their descriptions, not their function names.
stdio server hangs. stdio transport uses standard in and out for the protocol, so if your tool code prints to stdout with a stray print(), you corrupt the JSON-RPC stream. Log to stderr or a file, never stdout, inside a stdio server. This one costs everybody an afternoon exactly once.
Remote server refuses connections. Check the transport matches on both ends and that your proxy isn’t buffering the server-sent event stream — streaming responses need the proxy configured to pass them through unbuffered.
For agentic work specifically, it helps to keep an audit trail of what the model actually did on your machines. Pairing MCP tool use with something like the Linux audit framework tracking who did what on your servers means that when a model does touch a system, you have a record independent of the model’s own (fallible, promptable) account of events.
A slightly bigger server: resources and errors
Once the one-tool toy makes sense, the next useful pattern is exposing a resource the model can read and returning proper errors when a tool can’t do the job. Here’s a server that does both — a tool that runs a read-only health check and a resource that hands over a log file:
| |
Two things to notice. The tool returns a string describing the failure rather than raising, so the model gets to reason about the timeout instead of the whole call blowing up — models handle “error: timed out” far more gracefully than an opaque crash. And the resource caps what it returns; handing a model an unbounded log file is how you torch your context window and your token budget in one call. Bound your outputs. A resource that can return a megabyte will, eventually, return a megabyte at the worst possible moment.
Is it worth it?
If you’re building anything where a model needs to touch real systems — your data, your files, your infrastructure — then yes, emphatically. MCP is the first integration approach that doesn’t feel like it’ll be obsolete the moment you swap models, precisely because it decouples the two. It’s new enough that documentation still has gaps and some community servers are flaky, but the core idea is right and the adoption curve has been steep for good reason.
For a casual chatbot user, you’ll never notice it exists, and that’s fine. For anyone building tools, wiring models into real workflows, or running a homelab they’d like a model to help manage, it’s the standard worth betting on. I’ve replaced three hand-rolled integrations with MCP servers already, my model can now answer “did the backups run” by actually looking instead of guessing, and I’m not going back to the N-times-M mess.




