Before MCP existed I had written the same integration four times. Once for a customer support agent that needed to read our Postgres database. Again, differently, for a script that used a different provider's SDK. A third time as a set of function definitions inside an agent framework. A fourth when we switched models and the tool-calling format changed underneath us.
Same database. Same three queries. Four incompatible wrappers, each with its own auth handling and its own bugs.
That is the problem the Model Context Protocol solves, and it is worth being precise about it, because MCP gets described as something much grander than it is. It is a standard way for a model-facing application to discover and call tools that live outside it. That is the whole thing. It is not intelligence, it is not an agent framework, it is a plug shape.
The Actual Shape of It
There are two sides. A server exposes capabilities — tools it can run, resources it can read, prompts it can offer. A client is the AI application that connects to servers and makes those capabilities available to a model.
The important consequence is the one that took me a while to appreciate: the server has no idea which model is on the other end. You write a server once and every compliant client can use it. The four wrappers collapse into one.
Communication happens over JSON-RPC, either on stdio for a local process or over HTTP for something remote. Local stdio servers are what most people meet first — the editor spawns your server as a subprocess, which sounds crude and is actually the right call for anything touching a developer's own machine.
A Server, Minus the Ceremony
The thing that surprised me most is how little code it takes. Here is a server exposing one useful tool:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "orders", version: "1.0.0" });
server.tool(
"find_orders",
"Find a customer's recent orders by their email address.",
{
email: z.string().email().describe("Customer email, exactly as registered"),
limit: z.number().int().min(1).max(20).default(5),
},
async ({ email, limit }) => {
const rows = await db.orders.findByEmail(email, limit);
if (rows.length === 0) {
return { content: [{ type: "text", text: `No orders found for ${email}.` }] };
}
return {
content: [{
type: "text",
text: rows
.map((o) => `${o.id} · ${o.date} · £${o.total} · ${o.status}`)
.join("\n"),
}],
};
}
);
await server.connect(new StdioServerTransport());
Three details in there matter more than the plumbing.
The description is the interface. The model chooses tools by reading descriptions. "Find a customer's recent orders by their email address" gets called correctly; "orders lookup" gets called at random times with wrong arguments. Write descriptions for a competent colleague who has never seen your system.
The schema does real work. Zod here is not just validation — it becomes the JSON Schema the model sees, and the .describe() calls become the parameter hints. Constraints you express in the schema are constraints the model tries to respect.
Return prose, not raw JSON. The empty case returns a sentence explaining what happened rather than []. A tool that returns an empty array teaches the model "there is no data"; a tool that says "no orders found for this email" lets it consider that the email might be wrong. Tool output is a prompt. Write it like one.
Tools, Resources, Prompts
MCP has three primitives and the distinction is genuinely useful once it lands.
Tools do things. They are model-controlled — the model decides when to call them, based on the description. Anything with a side effect is a tool.
Resources are readable content addressed by URI — a file, a config, a document. They are application-controlled: the client decides what to attach, not the model. Use these for context you want available rather than fetched.
Prompts are reusable templates the user explicitly invokes — the slash-command shape. User-controlled, so they are for workflows a person triggers deliberately.
Most servers I have written are mostly tools with a couple of resources. But knowing the split stops you exposing "read the config file" as a tool the model calls on a whim.
The Security Part, Which Is Not Optional
Installing an MCP server is installing software that runs on your machine with your permissions and is driven by a model reading untrusted text. Both halves of that sentence should make you careful.
What I actually do:
Scope credentials to the server, tightly. The orders server gets a database role that can read the orders table. Not an admin connection string because it was easier.
Separate read from write, and gate the writes. Read tools run freely. Anything that mutates, sends, or spends goes through a confirmation step. The model proposes; a human or a deterministic rule disposes.
Treat every tool argument as hostile. The model chose that argument, possibly influenced by a document someone else wrote. Validate and parameterise exactly as you would with a web form. A tool that string-concatenates a query is a SQL injection with extra steps.
Read third-party servers before installing them. A popular server from a stranger's repo is an unaudited dependency with filesystem access. The supply chain risk here is real and the ecosystem is young.
The prompt-injection angle deserves naming explicitly: if one of your tools returns content from an external source — a web page, a ticket, an email — that content can contain instructions. The defence is not a warning in the system prompt. It is that the dangerous tools require approval regardless of how convincingly the model asks.
Where It Is Worth Your Time
MCP earns its keep when the same capability is needed by more than one AI surface, or when you want a capability available in tools you did not write — an editor, a desktop client, a colleague's agent.
For a single agent in a single application with three tools, you do not need it. Function calling directly against the provider SDK is less machinery and works fine. I would not retrofit MCP onto a working single-purpose agent just to be current.
Where I have found it genuinely worth the setup: internal company capabilities. One server for the deployment system, one for the ticketing system, one for the data warehouse — each written once, each with its own scoped credentials, all usable from whichever AI tool a team member prefers. That is a real reduction in duplicated integration work, and it is the same argument that made REST worth standardising.
The protocol is young and it will change. But the underlying idea — that the connection between models and tools should be a shape rather than a per-vendor rewrite — is one of those things that looks obvious in hindsight. My four wrappers certainly think so.



