Before the Model Context Protocol, connecting a model to your company's systems meant writing an adapter per pair. Your chat product needed a Postgres integration, a Jira integration and a filesystem integration. So did your IDE assistant. So did the internal agent your platform team built. Each one re-implemented the same tool definitions against the same backends in a slightly different shape, and each one diverged the moment somebody fixed a bug in only one of them.
That is the classic N×M integration problem, and it has a classic solution: define a protocol, write each integration once as a server, and let every client speak to it. MCP is that protocol for model context. An MCP server exposes capabilities; an MCP client consumes them; the host application decides which servers a given session may reach.
The problem the protocol is actually shaped around
It would be easy to describe MCP as "a standard for LLM tools", but that undersells the part that makes it interesting. Tool calling was already standardised in practice — every major model provider accepts a list of named functions with JSON Schema parameters, and the shapes are close enough to translate mechanically.
What was not standardised was discovery and lifecycle. How does a client find out which tools exist right now? How does a server say "the set of available tools just changed because the user opened a different project"? How does a server hand back a document that the application should attach to context, versus something the model should decide to call? How does an integration ship as an artefact a user can install, rather than code the application vendor has to write?
Those are protocol questions, not prompt questions, and they are what MCP answers.
Three primitives, distinguished by who is in control
MCP defines several primitives, and the useful way to remember them is by which party decides when they are used.
Tools are model-controlled. The client advertises them to the model, and the model chooses to invoke one. Each tool carries a name, a human-readable description and a JSON Schema for its inputs. This is the primitive that maps directly onto the tool-calling loop at the heart of every agent.
Resources are application-controlled. A resource is addressable content identified by a URI — a file, a database row, a build log. The server exposes what exists; the host application decides what to read and when to place it in context. The model does not reach for a resource on its own. This separation is deliberate: reading a file into context is a decision with cost and privacy implications, and it belongs to the application.
Prompts are user-controlled. A prompt is a named, parameterised template a server publishes for the user to invoke deliberately — the "/review this diff" entries in a command palette. They are not instructions injected behind the user's back.
The three-way split is the design idea worth stealing even if you never write an MCP server. Most home-grown agent integrations collapse all three into "tools", which means the model ends up deciding things the application should have decided.
Transport, sessions and capability negotiation
MCP messages are JSON-RPC 2.0. That choice buys request/response correlation, notifications and a well-understood error shape for free, and it means a server is readable in a terminal.
Two transports cover the realistic deployment shapes. stdio runs the server as a local subprocess of the host, communicating over standard input and output — the right answer for anything touching local files, a local database or developer credentials, because nothing is exposed on a network interface. HTTP covers remote servers, where a hosted integration serves many users and needs its own authorisation.
When a session opens, client and server exchange an initialisation handshake and declare capabilities: which primitives each side supports, and which optional behaviours — such as notifying the client when a list of tools changes. Nothing is assumed. A client written against a server that only offers tools does not break when it meets a server that also offers resources.
One capability worth knowing about is sampling, in which a server asks the client to run a model completion on its behalf. It inverts the usual direction and lets a server implement model-assisted behaviour without holding an API key of its own — with the host retaining approval over whether that request is honoured.
What a server looks like
Here is a small server exposing one tool over stdio. It is deliberately unglamorous: argument validation, a real query, a structured result.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { findDeploys } from './db.js';
const server = new McpServer({
name: 'deploys',
version: '1.0.0',
});
server.registerTool(
'list_recent_deploys',
{
title: 'List recent deploys',
description:
'Return deploys for one service, newest first. Includes failed deploys. ' +
'Does not include rollbacks — use list_rollbacks for those.',
inputSchema: {
service: z.string().describe('Service name exactly as it appears in the registry'),
limit: z.number().int().min(1).max(25).default(10),
status: z.enum(['succeeded', 'failed', 'any']).default('any'),
},
},
async ({ service, limit, status }) => {
const rows = await findDeploys({ service, limit, status });
if (rows.length === 0) {
return {
content: [
{ type: 'text', text: `No deploys found for service "${service}".` },
],
};
}
return {
content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
};
},
);
const transport = new StdioServerTransport();
await server.connect(transport);Line 12 is the tool's identity — stable, snake_case, and namespaced by the server, so
two servers can both offer something called search without colliding. Line 19 is the
input schema, which is where correctness is enforced; a bad limit never reaches the
database. Line 31 handles the empty case explicitly rather than returning [], because
a model reading [] will frequently conclude the tool failed and retry it.
That last point generalises. Tool responses are read by a model, not by a parser you control. "Worked and found nothing" and "did not work" must be distinguishable in plain language, or the loop will thrash.
Why this is not just OpenAPI with extra steps
The comparison comes up constantly, and the differences are real.
| OpenAPI / REST | MCP | |
|---|---|---|
| Primary consumer | Application code | A model, mediated by a host |
| Description text | Documentation for humans | Load-bearing input to tool selection |
| Discovery | Fetch a spec, generate a client | Live, per-session, can change mid-session |
| Granularity | Resource-oriented endpoints | Task-oriented capabilities |
| Errors | Status codes for a caller to branch on | Prose observations a model must act on |
| Auth | Scopes for a service identity | Per-session, per-user, host-mediated |
The granularity row is the one that trips up teams wrapping an existing API. A REST API
exposes GET /invoices, GET /invoices/{id}, GET /customers/{id} and expects the
caller to compose them. Handing all three to a model produces multi-step retrieval
where one purpose-built find_overdue_invoices_for_customer would have done. Good MCP
servers are written at the granularity of the task, not the granularity of the table.
The errors row matters just as much. A 404 is a perfectly good signal for code. For a
model it is ambiguous — wrong ID, deleted record, or no permission? Say which, in
words.
The trust boundary MCP does not close
MCP standardises how capabilities are described and invoked. It does not, and cannot, decide whether a given capability should be reachable from a given piece of content.
The moment a server returns text that came from outside your organisation — a web page, an inbound email, a public issue comment — that text enters the model's context alongside your instructions. If the model can then call a tool with side effects, the untrusted text has an execution path. This is the core of why prompt injection is an architecture problem, and no amount of protocol design removes it.
What the protocol does give you is a clean place to put controls. Because servers are discrete, installable units, the host can:
- Scope tool availability per session. A run summarising public web pages does not need the server that can write to production.
- Require approval per invocation for anything with side effects, with the arguments shown to the user in full.
- Separate read servers from write servers, so a single misjudged tool selection cannot escalate from reading to acting.
- Log every call — server, tool, arguments, result size — as the audit trail the transcript alone does not provide.
The trap is treating "it's an MCP server" as a trust statement. A server is a program you are running with your credentials. Install them with the same care you apply to a dependency, and prefer stdio servers you can read over remote ones you cannot.
Designing a server an agent can actually use
A handful of habits separate servers that work from servers that produce plausible nonsense.
Write descriptions for selection, not for documentation. The model is choosing between your tool and a dozen others. State what the tool returns, what it does not cover, and the neighbouring tool it is confused with.
Return the smallest useful payload. Every field you return is resent to the model on every subsequent turn of the loop. Returning a whole row when the agent needs an ID and a status is a context budget bug that only shows up on long runs.
Make destructive operations narrow and explicit. execute_sql is a tool that can
do anything, which means the model's tool selection is now your authorisation layer.
archive_invoice(invoiceId) cannot be talked into dropping a table.
Version the contract. Tool names and argument shapes are an API. Renaming a field breaks every saved workflow and every evaluation case pinned to the old shape.
Keep tool count low per session. Selection accuracy degrades as the candidate set grows, and the descriptions themselves occupy context. Two focused servers beat one that exposes forty tools.
What to build on it now
The immediately useful pattern is a read-only server over the system your team asks the most questions about — deploys, incidents, the analytics warehouse. It has a small blast radius, it produces an obvious improvement in day-to-day work, and it forces you to confront tool granularity and error prose before anything can write.
From there, the natural next step is wiring a server into an application you control, which is the subject of building an AI agent with Next.js. The protocol is the easy part. Deciding what a model should be able to reach, and proving it stays inside that boundary, is the work.